DEV Community

Cover image for Raytracing 3D Engine from Scratch Part 2: Lights
ndesmic
ndesmic

Posted on

Raytracing 3D Engine from Scratch Part 2: Lights

Last time we created a very simple raycaster that could draw a sphere. Not really exciting, but the next step should give us more interesting results. We'll extend this concept of raycasting to include bouncing, and with lights and materials specified, we can get results better than the WebGL engine.

Color

Let's start with something simple. We can add a new property to the sphere, it's color.

createMeshes(){
    this.meshes = {
        sphere: {
            position: [0,0,0],
            radius: 1,
            color: [0,1,0,1]
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

We'll switch to using float coordinates to represent colors internally. We'll also change the output to be the color of the sphere.

raytrace(ray){
    const intersection = this.intersectObjects(ray);
    if (intersection.distance === Infinity) {
        return [255, 255, 255];
    }
    return intersection.mesh.color;
}
Enter fullscreen mode Exit fullscreen mode

Finally in the render loop lets make sure we convert the float colors back into integer RGBA.

//render
pixelData.data[index + 0] = Math.floor(color[0] * 255);
pixelData.data[index + 1] = Math.floor(color[1] * 255);
pixelData.data[index + 2] = Math.floor(color[2] * 255);
pixelData.data[index + 3] = Math.floor(color[3] * 255);
Enter fullscreen mode Exit fullscreen mode

Image description

I added an extra sphere just to create some asymmetry but this seems to work.

Diffuse Lights

Next we need a concept of lights. These will be similar to the lights in the WebGL engine.

export class Light {
    #position;
    #color;

    constructor(light) {
        this.#position = light.position ?? [0, 0, 0];
        this.#color = light.color ?? [1, 1, 1, 1];
    }

    get position(){
        return this.#position;
    }

    get color(){
        return this.#color;
    }
}
Enter fullscreen mode Exit fullscreen mode

We're starting out with simple point-lights that have position and color. This is a data container which might be useful later if we need to calculate more attributes.

We'll also create a new method to add the lights:

createLights(){
    this.lights = {
        light1: new Light({
            position: [0,0,-2],
            color: [1,1,1,1]
        })
    };
}
Enter fullscreen mode Exit fullscreen mode

Call it in connectedCallback with the others.

So we now have the concept of a light. Now we need to hook it up to the raytracer. This is where we extend the concept of the raycast to include light bounces. First we need more information about where the rays are hitting things. Our intersectObjects method gives us the distance, so we just need to travel that distance in the direction of the ray from the origin to get the intersection:

const intersectionPoint = addVector(ray.origin, scaleVector(ray.direction, intersection.distance));
Enter fullscreen mode Exit fullscreen mode

Once we have the intersection point we can see how each light contributes to its color. First we need to see if the light can even be seen from that point, if it can't then we're in a shadow and it doesn't contribute.

isVisible(origin, destination) {
    const toDestination = subtractVector(destination, origin);
    const intersection = this.intersectObjects({ origin, direction: normalizeVector(toDestination) });
    const expectedDistance = getVectorMagnitude(toDestination);
    const delta = 0.005;
    return intersection.distance > expectedDistance - delta || intersection.distance < delta;
}
Enter fullscreen mode Exit fullscreen mode

We cast another ray from the intersection toward the light and see what got hit. If there was a collision closer than the distance of the light then it was obstructed. We add a little bit of delta since floating points can have rounding errors so we need a little bit of a threshold. This can happen if the expected distance had a rounding error or if the collision is in a bad spot where the new direction will product a collision with the same surface due to rounding. If you get weird dithering you probably need to adjust this. Also keep in mind the order of subtraction, if you get it backwards, values will be negative.

Next, we need the normal of the surface. For spheres this is very easy because the normals point outward, it's the position of the collision minus the position of the sphere, normalized.

Now we can compute diffuse lighting just light we did in the WebGL shader. We get the amount of light going the direction of the surface normal (dot product). Then we take that amount of contribution, multiply with the light color and then multiple again with the surface color and add it to the final color. We need to do this once per light which is why we add. If any light was not visible from the point then the point is in darkness and we'll return black (default case). Also our vector operations currently operate on 3 dimensional values, so we need to add alpha back in or nothing will show up!

getSurfaceInfo(collisionPosition, mesh){
    let color = [0,0,0];
    for(const light of Object.values(this.lights)){
        if(this.isVisible(collisionPosition, light.position)){
            const normal = normalizeVector(subtractVector(collisionPosition, mesh.position));
            const toLight = subtractVector(light.position, collisionPosition);
            const lightAmount = multiplyVector(light.color, dotVector(toLight, normal));
            color = addVector(color, componentwiseMultiplyVector(mesh.color, lightAmount));
        }
    }
    return [...color, 1];
}
Enter fullscreen mode Exit fullscreen mode

If we move the light slightly to the right we should get this scene:

Image description

Even more interesting, if we move the light to just the right spot we have shadows!

Image description

If we add more lights then we can even get diffuse light in the shadow:

Image description

And one last one with colored lighting. The middle sphere is white, with red light coming from the camera and green light coming from the right side. The side sphere is a magenta color.

Image description

We can see that we get all the light mixing, with shadows and everything. Not bad!

Specular Lights

We can do specular reflections too. As with diffuse lighting it's mostly the same thing as with WebGL. First we need a few things, the specular amount (I call specularity) and the specular exponent (I call gloss). We can (optionally) define these for each sphere.

createMeshes(){
    this.meshes = {
        sphere: {
            position: [0,0,0],
            radius: 1,
            color: [1, 1, 1, 1],
            specularity: 1,
            gloss: 100
        },
        sphere1: {
            position: [0.75,0,-1.5],
            radius: 0.1,
            color: [1,0,1,1]
        }
    };
}
Enter fullscreen mode Exit fullscreen mode

The other bit we need is the camera's location which we can get by passing the ray which contains the origin. This should give us all the pieces to compute specular highlights. In getSurfaceInfo we do another calculation per light. If the mesh has specularity, then we will add it.

getSurfaceInfo(collisionPosition, mesh, ray){
    let color = [0,0,0];
    for(const light of Object.values(this.lights)){
        if(this.isVisible(collisionPosition, light.position)){
            const normal = normalizeVector(subtractVector(collisionPosition, mesh.position));
            const toLight = subtractVector(light.position, collisionPosition);
            const lightAmount = multiplyVector(light.color, dotVector(toLight, normal));
            color = addVector(color, componentwiseMultiplyVector(mesh.color, lightAmount));
            if(mesh.specularity){
                const toCamera = normalizeVector(subtractVector(ray.origin, collisionPosition));
                const halfVector = normalizeVector(addVector(toLight, toCamera));
                const baseSpecular = clamp(dotVector(halfVector, normal), 0.0, 1.0);
                const specularMagnitude = baseSpecular ** mesh.gloss;
                const specularLight = componentwiseMultiplyVector(light.color, [specularMagnitude, specularMagnitude, specularMagnitude, 1.0]);
                color = addVector(color, specularLight);
            }
        }
    }
    return [...color, 1];
}
Enter fullscreen mode Exit fullscreen mode

We compute something called the half-vector, which is the light vector plus the camera vector. If you checked the WebGL version this is the "Blinn-Phong" specular model which creates a better shape than vanilla Phong. We calculate the amount of light in the direction of the half-vector (dot product) and clamp it so that it does not go negative. Finally we take the specular base amount and raise it to the gloss power. In this case specular its "how matte/shiny is the surface" and gloss is "how sharp will the reflection be". We also multiply by the light color to make sure colored lights work. In this case I'm actually assuming that the specular highlight can be full white even reflecting off a colored surface. If we didn't want that we could set a property on the object that holds it's specular color and then multiply by that just prior to adding it (in fact if we did that we can use that instead of the specularity value, just with multiple components).

In the end we can show specular highlights.

Image description

This is pretty cool.

You can find the code here: https://github.com/ndesmic/geort/tree/v2

Discussion (0)