Link Search Menu Expand Document

9: Reflections


Download code
09-reflections.zip
Live example
examples/09-reflections/index.html

We’re closing in on the classic ray-tracer demo image - a mirrored sphere resting on a chessboard. There’s only two things missing… we can’t do mirrors, and we can’t do chessboards.

Let’s start by adding reflection support. Here’s the scene we’re going to use:

image-20220320132142854

First, we need to add another property to our Finish class, which controls how reflective a shape is. A finish with reflection==1 is a perfect mirror; a finish with reflection == 0 doesn’t reflect at all.

// modules/appearance.js

import { Color } from "./color.js";
import { Finish } from './finish.js';
import { Ray } from './ray.js';

/** A shape's appearance is a combination of the material it's made from and the finish applied to it. */
export class Appearance {

    constructor(material, finish) {
        this.material = material ?? Color.Grey;
        this.finish = finish ?? Finish.Default;
    }

    #getColorAt = point => this.material.getColorAt(point);
    getAmbientColorAt = point => this.#getColorAt(point).scale(this.finish.ambient);
    getDiffuseColorAt = point => this.#getColorAt(point).scale(this.finish.diffuse);

    reflect = (point, reflex, scene, depth) => {
        if (! this.finish.reflection) return Color.Black;
        let reflectedRay = new Ray(point, reflex);
        let reflectedColor = reflectedRay.trace(scene, depth);
        return reflectedColor.scale(this.finish.reflection);        
    }
}

Reflection, recursion, and MAX_DEPTH

We’re going to add reflection support by using recursion – whenever a ray reflects from a shape, that effectively runs a whole fresh trace to figure out what the “reflected” scene looks like, and then adds that the the result of the original trace.

If you’ve ever stood between two parallel mirrors, you’ve seen this kind of recursion happening in real life… and if they were optically perfect mirrors in a vacuum, you’d get infinite reflections. But computers don’t really like doing infinite things. So to stop our renderer disappearing into an infinite loop, we need to put a limit on how many times a ray will reflect before we give up and assume what we’ve got by now is good enough.

We’re going to add a MAX_DEPTH to our settings.js module, and then pass a depth parameter to the Ray.trace method; each time we recursively trace a ray, we’ll increment the depth, and if depth > MAX_DEPTH, we bail out.

If you find scenes are taking too long to render, decreaseMAX_DEPTH. If you find you’re getting weird optical artefacts and blank areas where there should be reflections, increase MAX_DEPTH (and be prepared to wait a long time for your images to render!)

Ray.trace should now pass the depth parameter to getColorAt; here’s the updated code:

// modules/ray.js

import { MAX_DEPTH } from './settings.js';

export class Ray {
    /** Construct a new ray starting from the specified start 
     * point, and pointing in the specified direction */
    constructor(start, direction) {
        this.start = start;
        this.direction = direction.unit();
    }

    /** Trace this ray through the specified scene, and return the resulting color. */
    trace = (scene, depth = 0) => {
        if (depth > MAX_DEPTH) return scene.background;
        let distances = scene.shapes.map(s => s.closestDistanceAlongRay(this));
        let shortestDistance = Math.min.apply(Math, distances);
        if (shortestDistance == Infinity) return scene.background;
        let nearestShape = scene.shapes[distances.indexOf(shortestDistance)];
        let point = this.start.add(this.direction.scale(shortestDistance));
        return nearestShape.getColorAt(point, this, scene, depth + 1);
    }

    reflect = normal => {
        let inverse = this.direction.invert();
        return inverse.add(normal.scale(normal.dot(inverse)).add(this.direction).scale(2));
    }
    
    toString = () => `${this.start.toString()} => ${this.direction.toString()}`;
}

Finally, we need to add a snippet of code to shape.js so that if a shape has a non-zero reflection, it’ll create a new ray, bounce that ray off into the scene to see what else it hits, calculate the reflected color, and add that to the returned color:

let reflectionAmount = this.appearance.finish.reflection;
if (reflectionAmount) {
    let reflectionRay = new Ray(point, reflex);
    let reflectedColor = reflectionRay.trace(scene, depth);
    colorToReturn = colorToReturn.add(reflectedColor.scale(reflectionAmount));
}

That’s it. Here’s the same scene rendered with every shape set to reflection = 0.5:

image-20220320135145309


Download code
09-reflections.zip
Live example
examples/09-reflections/index.html