Link Search Menu Expand Document

8: Specular Highlights


Download code
08-highlights.zip
Live example
examples/08-highlights/index.html

Time to introduce some more terminology. The color of a shape in our renderer scene is actually controlled by a bunch of different parameters

  • ambient color is how much of the shape’s underlying color is visible, even when it isn’t directly illuminated. Ambient lighting is a bit of a hack; in the real world, especially in daylight, there’s so much reflected sunlight and artificial light bouncing around that we can see what color objects are even if they’re not being directly illuminated. Calculating those kinds of lighting effects in a renderer is prohibitively difficult, though, so if we need it, we fake it by adding a bit of ambient color to the object’s appearance.
  • diffuse color is how strongly the shape reacts to direct light sources.
  • specular highlights are the bright shiny spots we get when a light source reflects in the surface of a smooth curved object.

The spheres in this image are all made from the same material – they’re the same color – but you can see how ambient, diffuse, and specular lighting affect their appearance.

Image showing examples of ambient, diffuse, and specular lighting

To use these optical properties in our scenes, we need to add a new property to our shapes. Until now, everything in our world has been a solid color; now we’re going to introduce something called appearance.

A shape’s appearance has two parts:

  • the material – what’s the object actually made of?
  • the finish – how shiny is it? Does it have highlights?

We’re going to introduce two new classes here, Appearance and Finish:

// modules/finish.js

import { Color } from './color.js'; 

export class Finish {
    static Default = new Finish();

    constructor(options = {}) {
        this.ambient = options.ambient ?? 0;
        this.diffuse = options.diffuse ?? 0.7;
        this.shiny = options.shiny ?? 0;
    }

    addHighlight = (reflex, light, lightVector) => {
        if (! this.shiny) return Color.Black;
        let intensity = reflex.dot(lightVector.unit());
        if (intensity <= 0) return Color.Black;
        let exponent = 32 * this.shiny * this.shiny;
        intensity = Math.pow(intensity, exponent);
        return light.color.scale(this.shiny * intensity);
    }    
}


// modules/appearance.js

import { Color } from "./color.js";
import { Finish } from './finish.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);
}

When we create shapes in a scene, we now have the ability to specify a finish as well as a color:

let shinyRed = new Appearance(
   new Color(255,0,0),
   new Finish({ambient: 0.1, diffuse: 0.6, specular: 0.9})
};

let shinyRedBall = new Sphere(
	Vector.X 	// centre,
	1 			// radius,
	shinyRed 	// appearance
);

Now we need to update our Shape.getColorAt method to take these new properties into account.

First, we’ll add a method to calculate the reflection direction at a specific point – i.e. if a ray of light hits our shape at a certain point, which way is it going to “bounce”?

 // add this method to modules/shape.js
 
 reflect = (incident, normal) => {
     let inverse = incident.invert();
     return inverse.add(normal.scale(normal.dot(inverse)).add(incident).scale(2));
 }

Now, we’ll modify getColorAt to calculate ambient, diffuse, and specular lighting effects for each point on the surface of our object. We’re changing four things here

  1. The getColorAt method now needs the Ray as an argument, because we can’t calculate lighting effects unless we know which direction the light came from.
  2. The ambient color is now calculated based on this.material.finish.ambient - previously we just used a global AMBIENT constant for this.
  3. The amount of light contributed by a light source is multiplied by this.material.finish.diffuse, so that objects with a diffuse value close to zero aren’t strongly affected by light sources, and objects with a diffuse value close to one will react strongly to light sources.
  4. We take the dot-product of the reflex vector and the lightDirection vector; if it’s greater than zero, we add a specular highlight (a “shiny spot”) to the surface of the object.

The actual amount of specular highlighting is given by this formula:

let exponent = 16 * this.material.finish.specular * this.material.finish.specular;
specular = Math.pow(specular, exponent);

Yes, that’s raising it to the power of 16. That’s what produces the sharp drop-off effect around the specular highlight. Here’s the modified shape.js with the ambient, diffuse and specular calculations included:

// modules/shape.js

import { THRESHOLD } from './settings.js';
import { Vector } from './vector.js';
import { Color } from './color.js';
import { Ray } from './ray.js';
import { Finish } from './finish.js';

export class Shape {

    constructor(appearance) {
        this.appearance = appearance;
        if (! this.appearance.finish) this.appearance.finish = Finish.Default;
    }

    intersect = () => { throw ("Classes which extend Shape must implement intersect"); };

    getNormalAt = () => { throw ("Classes which extend Shape must implement getNormalAt"); }

    closestDistanceAlongRay = (ray) => {
        let distances = this.intersect(ray).filter(d => d > THRESHOLD);
        let shortestDistance = Math.min.apply(Math, distances);
        return shortestDistance;
    }

    /** return true if the specified light casts a shadow of this shape at the specified point  */
    castsShadowFor = (point, vector) => {
        let distanceToLight = vector.length;
        let ray = new Ray(point, vector);
        return (this.closestDistanceAlongRay(ray) <= distanceToLight);
    }

    getColorAt = (point, ray, scene) => {
        let normal = this.getNormalAt(point);
        let color = this.appearance.getAmbientColorAt(point);
        let reflex = ray.reflect(normal);
        scene.lights.forEach(light => {
            let v = Vector.from(point).to(light.position);

            // If this point is in shadow, do not add any illumination for this light source
            if (scene.shapes.some(shape => shape.castsShadowFor(point, v))) return;

            let brightness = normal.dot(v.unit());
            if (brightness <= 0) return;            

            let illumination = light.illuminate(this.appearance, point, brightness);
            color = color.add(illumination);

            let highlight = this.appearance.finish.addHighlight(reflex, light, v);
            color = color.add(highlight);            
        });
        return color;
    }
}

Check out the live demo for this section to see two examples of lighting effects in action; one is the code used to draw the image above; the other shows how white spheres react to colored lights:

image-20220320113806912


Download code
08-highlights.zip
Live example
examples/08-highlights/index.html