DEV Community

Cover image for Raytracing 3D Engine from Scratch Part 3: Planes and Reflection
ndesmic
ndesmic

Posted on

Raytracing 3D Engine from Scratch Part 3: Planes and Reflection

We can create spheres all right but that's not how most objects we want will be defined. What we really want is flat surfaces to build up true meshes.

Cleanup

We previous had a method createMeshes which was a holdover from the WebGL engine. Since the raytracer can do a lot more than meshes, I've renamed this and the corresponding this.meshes property to createObjects and this.objects respectively.

Another thing I did was rename multiplyVector to scaleVector. This better describes the operation since multiplication can have several meanings.

Also, each object will now have a type property, which will define what exactly it is so we can perform the correct algorithm.

Intersecting Planes

The next step is doing intersections with a plane. Mathematically, this is the same thing we do for circles, in fact it's a bit easier. We plug the equation for a ray origin + t * direction into the equation for a plane dot(position, normal) - constant for the position value. Some re-arranging terms gets us:

intersectPlane(ray, plane){
    return (plane.offset - dotVector(ray.origin, plane.normal)) / dotVector(ray.direction, plane.normal);
}
Enter fullscreen mode Exit fullscreen mode

We also need to update intersectObjects to use the new method if the object is a plane:

intersectObjects(ray) {
    let closest = { distance: Infinity, object: null };
    for (let object of Object.values(this.objects)) {
        let distance;
        switch(object.type){
            case "sphere": {
                distance = this.intersectSphere(ray, object);
                break;
            }
            case "plane": {
                distance = this.intersectPlane(ray, object);
                break;
            }
        }
        if (distance != undefined && distance < closest.distance && distance > 0.001) {
            closest = { distance, object };
        }
    }
    return closest;
}
Enter fullscreen mode Exit fullscreen mode

Note that I added a new term on the if condition distance > 0.001. This prevents some problems when something intersects the origin. If we placed a plane such that it intersects the camera's position we'll basically get a black screen because the collision is at the camera location. By forcing collisions to not be too close, we can prevent that.

Next we need to get the normal of the plane. This is actually given by the plane equation but we should make sure it's actually normalized or it won't work correctly. I also combined this is with the sphere normal logic so we can get the normals of either.

getNormal(collisionPosition, object){
    switch(object.type){
        case "sphere": {
            return normalizeVector(subtractVector(collisionPosition, object.position));
        }
        case "plane": {
            return normalizeVector(object.normal);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally in getSurfaceInfo we use the new method instead of hard-coding the sphere normal.

getSurfaceInfo(collisionPosition, object, ray){
    let color = [0,0,0];
    for(const light of Object.values(this.lights)){
        if(this.isVisible(collisionPosition, light.position)){
            const normal = this.getNormal(collisionPosition, object);
            const toLight = subtractVector(light.position, collisionPosition);
            const lightAmount = multiplyVector(light.color, dotVector(toLight, normal));
            color = addVector(color, componentwiseMultiplyVector(object.color, lightAmount));
            if(object.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 ** object.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'll add a little plane to our scene:

plane: {
    type: "plane",
    normal: [0,1,-1],
    offset: 0,
    color: [0.3,0.3,0.3,1]
}
Enter fullscreen mode Exit fullscreen mode

And voila! It all just sorta works with shadowing and all.

Image description

Reflections

Once of the nice things about raytracing is the ability to do realistic reflections. In order to do this we'll need to calculate the bounces the rays make off of reflective objects.

This will replace our specular implementation because this is realistically how specular should work. It's the same as how phong originally worked except once we reflect the ray direction we follow to the next surface and calculate the color again (and keep following the bounces). The final color is scaled by the specular amount which controls the reflectivity of the surface. That is specularity 1 is a pure mirror which might not work the way you expect (especially if there is also diffuse which means more light is reflected than came in).

Reflection around a normal looks like this:

//vector.js
export function reflectVector(vec, normal) {
    return [
        vec[0] - 2 * dotVector(vec, normal) * normal[0],
        vec[1] - 2 * dotVector(vec, normal) * normal[1],
        vec[2] - 2 * dotVector(vec, normal) * normal[2],
    ];
}
Enter fullscreen mode Exit fullscreen mode
getSurfaceInfo(collisionPosition, object, ray, bounceCounter){
    let color = [0,0,0];
    for(const light of Object.values(this.lights)){
        if(this.isVisible(collisionPosition, light.position)){
            const normal = this.getNormal(collisionPosition, object);
            const toLight = normalizeVector(subtractVector(light.position, collisionPosition));
            const lightAmount = scaleVector(light.color, dotVector(toLight, normal));
            color = addVector(color, componentwiseMultiplyVector(object.color, lightAmount));
            if(object.specularity){
                const reflectionDirection = reflectVector(ray.direction, normal);
                const reflectionColor = this.raytrace({ origin: collisionPosition, direction: reflectionDirection }, bounceCounter - 1);
                const specularLight = clamp(scaleVector(reflectionColor, object.specularity), 0.0, 1.0);
                color = addVector(color, specularLight);
            }
        }
    }
    return [...color, 1];
}
Enter fullscreen mode Exit fullscreen mode

You will notice that I added an extra parameter bounceCounter. Since the recursive look up can be near infinite we need to cut it off at some point. We make a slight modification to raytrace to deal with it:

raytrace(ray, bounceCounter = MAX_BOUNCES){
    if(bounceCounter <= 0){
        return BACKGROUND_COLOR;
    }
    const intersection = this.intersectObjects(ray);
    if (intersection.distance === Infinity || intersection.distance === -Infinity) {
        return BACKGROUND_COLOR;
    }
    const collisionPoint = addVector(ray.origin, scaleVector(ray.direction, intersection.distance));
    return this.getSurfaceInfo(collisionPoint, intersection.object, ray, bounceCounter);
}
Enter fullscreen mode Exit fullscreen mode

We're counting down from MAX_BOUNCES and if we hit zero we simply return the default color. I've defined MAX_BOUNCES to 3. Also, I extracted the background color so it can be easily changed, white is a bad choice when testing reflections.

I made the background cornflower blue and adjusted the camera a bit. The sphere (0,0,0) is sitting on a plane pointing upward at y = -1. The plane is a grey color with 0.7 specularity.

Image description

We can see a reflection off the plane.

Ambient Lighting

At this point it's getting hard to control the color of things because the light keeps blowing out into something too bright because we aren't applying any conservation of energy. I don't really want to open that can of worms yet, so lets give us a little bit of extra room to work with. We can introduce a concept of "ambient" light. This is light coming from all directions so it's just added to everything. This helps prevent the shadows from being so harsh.

In getSurfaceInfo we'll just add the light to the color at the start: let color = componentwiseMultiplyVector(BACKGROUND_LIGHT, object.color);

Often times the object material itself will specify the ambient color along with diffuse and specular but I don't really see much need to do so.

Image description

This way we can brighten the whole scene without messing with the lights.

Taking it to the max

Adding an extra light source and sphere and changing the background to something that blows out less:

Image description

Reflections, Reflections of reflections, shadows, reflection of shadows, diffuse, and ambient lighting. Still very satisfying the results we get without too much code.

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

Discussion (0)