Four-Pass Demo

A Complete Four-Pass Shadow Volume Demo

The finished page puts a wall behind the shelf, a lamp above and in front of it, and gives each book a shadow volume. The left pane draws the volumes as translucent red geometry over the unshadowed scene; the right pane runs the four passes:

Shadow volumes made visible (left) and the four-pass stencil shadows (right)HTML
<canvas id="c" width="1240" height="340" style="width:620px;height:170px"></canvas>
<script src="https://cdn.jsdelivr.net/npm/gl-matrix@3.4.4/gl-matrix-min.js"></script>
<script src="webgl-helper.js"></script>
<script>
const gl = document.getElementById('c').getContext('webgl2', { stencil: true });
const { mat4, mat3, glMatrix: { toRadian } } = glMatrix;
const lit = GLH.lit(gl, GLH.shelfFS), u = lit.uniforms, shelf = GLH.shelf(gl, lit.attributes);
const lamp = [-0.2, 1.3, 1.1], eye = [0.9, 0.9, 2.9], black = [0, 0, 0];
const lampColor = [3, 2.8, 2.55], ambient = [0.28, 0.29, 0.33];
const wall = { color: GLH.rgb('#cdbfa8'), model: mat4.fromTranslation([], [0, 0.13, -0.45]) };
mat4.scale(wall.model, wall.model, [3.5, 1.3, 0.04]);
wall.normal = mat3.normalFromMat4([], wall.model);
const scene = [...shelf.items, wall], books = shelf.items.slice(0, 6);
const volumes = new Float32Array(books.flatMap((book) =>      // six volumes, one buffer
  [...GLH.shadowVolume(GLH.box(), book.model, lamp).vertices]));
const shadow = GLH.load(gl, `#version 300 es
  in vec4 aPosition; uniform mat4 uViewProjection;
  void main() { gl_Position = uViewProjection * aPosition; }`, `#version 300 es
  precision mediump float; out vec4 c; void main() { c = vec4(0.9, 0.1, 0.1, 0.25); }`);
const vao = GLH.vao(gl, shadow.attributes, volumes, [['aPosition', 4]]);
const drawScene = (vp, lampColor, ambient) => {
  gl.useProgram(lit.program); u.uViewProjection(vp); u.uEye(eye); u.uShininess(40); u.uFoil(1);
  u['uLights[0].position'](lamp); u['uLights[0].color'](lampColor);
  u['uLights[1].color'](black); u.uAmbient(ambient);
  shelf.draw(u, scene);
};
const drawVolumes = (vp) => {
  gl.useProgram(shadow.program); shadow.uniforms.uViewProjection(vp);
  gl.bindVertexArray(vao); gl.drawArrays(gl.TRIANGLES, 0, volumes.length / 4);
};
gl.clearColor(0.93, 0.91, 0.87, 1); gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);
GLH.panes(gl, 2, (pane, aspect) => {
  gl.clear(gl.STENCIL_BUFFER_BIT);                   // panes() clears color and depth
  const vp = mat4.multiply([], mat4.perspective([], toRadian(40), aspect, 0.1, Infinity),
    mat4.lookAt([], eye, [0.1, -0.2, 0], [0, 1, 0]));
  if (pane === 0) {                                  // the volumes, for debugging
    drawScene(vp, lampColor, ambient);
    gl.enable(gl.BLEND); gl.depthMask(false);
    gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
    drawVolumes(vp);
    gl.disable(gl.BLEND); gl.depthMask(true);
    return;
  }
  drawScene(vp, black, black);                                  // 1: depth (and black)
  gl.colorMask(false, false, false, false); gl.depthMask(false); gl.disable(gl.CULL_FACE);
  gl.enable(gl.STENCIL_TEST); gl.stencilFunc(gl.ALWAYS, 0, 0xff);
  gl.stencilOpSeparate(gl.BACK, gl.KEEP, gl.INCR_WRAP, gl.KEEP);
  gl.stencilOpSeparate(gl.FRONT, gl.KEEP, gl.DECR_WRAP, gl.KEEP);
  drawVolumes(vp);                                              // 2: stencil counts
  gl.colorMask(true, true, true, true); gl.enable(gl.CULL_FACE); gl.depthFunc(gl.LEQUAL);
  gl.stencilFunc(gl.EQUAL, 0, 0xff); gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
  drawScene(vp, lampColor, black);                              // 3: lamp where count is 0
  gl.disable(gl.STENCIL_TEST); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);
  drawScene(vp, black, ambient);                                // 4: add ambient
  gl.disable(gl.BLEND); gl.depthMask(true); gl.depthFunc(gl.LESS);
});
window.__done = true;
</script>
Browser output of Listing 3.94
Browser output of 94

The volumes fan out away from the lamp and run off the canvas toward infinity. On the right, the books throw hard-edged shadows onto the plank and the wall behind them; gl.getError() stays 0, and SwiftShader 2,546 draws the same image to within one pixel.

Shadow volumes made visible (left) and the four-pass stencil shadows they produce (right)HTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f4ee; }
  canvas { display: block; width: 100%; max-width: 600px; }
</style>
<canvas id="c" width="1240" height="340"></canvas>
<script>
const m4 = {
  perspective(fovy, aspect, near, far) {       // far = Infinity gives the infinite projection
    const f = 1 / Math.tan(fovy * Math.PI / 360);
    if (far === Infinity) return [f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, -1, -1, 0, 0, -2 * near, 0];
    const d = near - far;
    return [f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) / d, -1, 0, 0, 2 * far * near / d, 0];
  },
  lookAt(eye, target, up = [0, 1, 0]) {
    const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
    const unit = (v) => v.map((c) => c / Math.hypot(...v));
    const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
    const z = unit(eye.map((c, i) => c - target[i])), x = unit(cross(up, z)), y = cross(z, x);
    return [x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, eye), -dot(y, eye), -dot(z, eye), 1];
  },
  multiply(a, b) {
    const out = new Array(16).fill(0);
    for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) for (let k = 0; k < 4; k++) out[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k];
    return out;
  },
  model(x, y, z, sx, sy, sz, turn = 0) {        // T * Ry(turn) * S
    const c = Math.cos(turn), s = Math.sin(turn);
    return [c * sx, 0, -s * sx, 0, 0, sy, 0, 0, s * sz, 0, c * sz, 0, x, y, z, 1];
  },
  normal(m) {
    const [a, b, c, , d, e, f, , g, h, i] = m;
    const n = [e * i - f * h, f * g - d * i, d * h - e * g, c * h - b * i, a * i - c * g, b * g - a * h, b * f - c * e, c * d - a * f, a * e - b * d];
    const det = a * n[0] + b * n[1] + c * n[2];
    return n.map((v) => v / det);
  },
};
const CUBE = (() => {                             // 36 vertices: position, normal (counterclockwise outside)
  const faces = [[[0,0,1],[1,0,0],[0,1,0]], [[0,0,-1],[-1,0,0],[0,1,0]], [[1,0,0],[0,0,-1],[0,1,0]],
                 [[-1,0,0],[0,0,1],[0,1,0]], [[0,1,0],[1,0,0],[0,0,-1]], [[0,-1,0],[1,0,0],[0,0,1]]];
  const out = [];
  for (const [n, u, v] of faces) for (const [a, b] of [[-1,-1],[1,-1],[1,1],[-1,-1],[1,1],[-1,1]])
    out.push(...[0, 1, 2].map(i => (n[i] + a * u[i] + b * v[i]) / 2), ...n);
  return new Float32Array(out);
})();
// The shadow volume of a box under a point light, as world-space x, y, z, w triangles:
// front cap (lit faces), back cap (unlit faces pushed to infinity, w = 0) and silhouette sides.
function shadowVolume(model, light) {
  const sub = (a, b) => a.map((v, i) => v - b[i]);
  const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
  const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
  const at = (i) => [0, 1, 2].map(r => model[r] * CUBE[i * 6] + model[4 + r] * CUBE[i * 6 + 1] + model[8 + r] * CUBE[i * 6 + 2] + model[12 + r]);
  const key = (a, b) => [...a, ...b].map((k) => k.toFixed(4)).join();
  const far = (p) => [...sub(p, light), 0];      // a direction away from the light: a point at infinity
  const out = [], edges = new Map();
  for (let t = 0; t < 36; t += 3) {
    const [a, b, c] = [t, t + 1, t + 2].map(at);
    const lit = dot(cross(sub(b, a), sub(c, a)), sub(light, a)) > 0;
    out.push(...[a, b, c].flatMap((p) => (lit ? [...p, 1] : far(p))));
    for (const [p, q] of [[a, b], [b, c], [c, a]]) edges.set(key(p, q), { p, q, lit });
  }
  for (const { p, q, lit } of edges.values()) {    // a lit face's edge next to an unlit face
    if (!lit || edges.get(key(q, p))?.lit !== false) continue;
    out.push(...q, 1, ...p, 1, ...far(p), ...q, 1, ...far(p), ...far(q));
  }
  return out;
}
function program(vs, fs) {
  const p = gl.createProgram();
  for (const [type, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]]) {
    const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
    gl.attachShader(p, s);
  }
  gl.linkProgram(p);
  return p;
}

const gl = document.getElementById('c').getContext('webgl2', { stencil: true });   // ask for stencil bits
const lit = program(`#version 300 es
layout(location = 0) in vec3 aPosition; layout(location = 1) in vec3 aNormal;
uniform mat4 uViewProjection, uModel; uniform mat3 uNormalMatrix; out vec3 vPosition, vNormal;
void main() { vec4 w = uModel * vec4(aPosition, 1.0); vPosition = w.xyz; vNormal = uNormalMatrix * aNormal; gl_Position = uViewProjection * w; }`,
`#version 300 es
precision highp float;
in vec3 vPosition, vNormal; uniform vec3 uColor, uLamp, uLampColor, uAmbient, uEye; out vec4 c;
void main() {
  vec3 n = normalize(vNormal), toLamp = uLamp - vPosition; float d = length(toLamp); vec3 l = toLamp / d;
  float lambert = max(dot(n, l), 0.0), fade = 1.0 / (d * d);
  float spec = lambert > 0.0 ? pow(max(dot(n, normalize(l + normalize(uEye - vPosition))), 0.0), 40.0) : 0.0;
  c = vec4(uColor * (uAmbient + uLampColor * lambert * fade) + uLampColor * spec * fade * 0.2, 1.0);
}`);
const volumeProgram = program(`#version 300 es
layout(location = 0) in vec4 aPosition; uniform mat4 uViewProjection;
void main() { gl_Position = uViewProjection * aPosition; }`, `#version 300 es
precision mediump float; out vec4 c; void main() { c = vec4(0.9, 0.1, 0.1, 0.25); }`);

const cubeVAO = gl.createVertexArray();
gl.bindVertexArray(cubeVAO);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, CUBE, gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 24, 0); gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12); gl.enableVertexAttribArray(1);

const covers = ['#1f5f8b', '#5b3f99', '#e09a10', '#3f7d3a', '#b5452f', '#2a9d8f'];
const rgb = (hex) => [1, 3, 5].map((k) => parseInt(hex.slice(k, k + 2), 16) / 255);
const books = covers.map((hex, i) => ({ color: rgb(hex), model: m4.model(-0.81 + i * 0.32, 0, 0, 0.26, 0.9, 0.2, (20 - i * 8) * Math.PI / 180) }));
const scene = [...books,
  { color: rgb('#8a6240'), model: m4.model(0, -0.495, 0, 2.3, 0.08, 0.7) },    // plank
  { color: rgb('#cdbfa8'), model: m4.model(0, 0.13, -0.45, 3.5, 1.3, 0.04) }];  // wall
const lamp = [-0.2, 1.3, 1.1], eye = [0.9, 0.9, 2.9], black = [0, 0, 0];
const lampColor = [3, 2.8, 2.55], ambient = [0.28, 0.29, 0.33];

const volumes = new Float32Array(books.flatMap((book) => shadowVolume(book.model, lamp)));   // one buffer
const volumeVAO = gl.createVertexArray();
gl.bindVertexArray(volumeVAO);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, volumes, gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 4, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0);

function drawScene(vp, lampLight, ambientLight) {
  gl.useProgram(lit);
  const u = (n) => gl.getUniformLocation(lit, n);
  gl.uniformMatrix4fv(u('uViewProjection'), false, vp);
  gl.uniform3fv(u('uLamp'), lamp); gl.uniform3fv(u('uEye'), eye);
  gl.uniform3fv(u('uLampColor'), lampLight); gl.uniform3fv(u('uAmbient'), ambientLight);
  gl.bindVertexArray(cubeVAO);
  for (const { color, model } of scene) {
    gl.uniformMatrix4fv(u('uModel'), false, model);
    gl.uniformMatrix3fv(u('uNormalMatrix'), false, m4.normal(model));
    gl.uniform3fv(u('uColor'), color);
    gl.drawArrays(gl.TRIANGLES, 0, 36);
  }
}
function drawVolumes(vp) {
  gl.useProgram(volumeProgram);
  gl.uniformMatrix4fv(gl.getUniformLocation(volumeProgram, 'uViewProjection'), false, vp);
  gl.bindVertexArray(volumeVAO);
  gl.enable(gl.POLYGON_OFFSET_FILL);             // push the volume a hair back so its front cap never
  gl.polygonOffset(1, 1);                         // z-fight with the lit faces they sit on
  gl.drawArrays(gl.TRIANGLES, 0, volumes.length / 4);
  gl.disable(gl.POLYGON_OFFSET_FILL);
}

gl.clearColor(0.93, 0.91, 0.87, 1);
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); gl.enable(gl.SCISSOR_TEST);
const vp = m4.multiply(m4.perspective(40, 620 / 340, 0.1, Infinity), m4.lookAt(eye, [0.1, -0.2, 0]));
for (let pane = 0; pane < 2; pane++) {
  gl.viewport(pane * 620, 0, 620, 340); gl.scissor(pane * 620 + 3, 0, 614, 340);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT);
  if (pane === 0) {                                             // the volumes, for debugging
    drawScene(vp, lampColor, ambient);
    gl.enable(gl.BLEND); gl.depthMask(false);
    gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
    drawVolumes(vp);
    gl.disable(gl.BLEND); gl.depthMask(true);
    continue;
  }
  drawScene(vp, black, black);                                  // 1: depth (and black)
  gl.colorMask(false, false, false, false); gl.depthMask(false); gl.disable(gl.CULL_FACE);
  gl.enable(gl.STENCIL_TEST); gl.stencilFunc(gl.ALWAYS, 0, 0xff);
  gl.stencilOpSeparate(gl.BACK, gl.KEEP, gl.INCR_WRAP, gl.KEEP);
  gl.stencilOpSeparate(gl.FRONT, gl.KEEP, gl.DECR_WRAP, gl.KEEP);
  drawVolumes(vp);                                              // 2: stencil counts (z-fail)
  gl.colorMask(true, true, true, true); gl.enable(gl.CULL_FACE); gl.depthFunc(gl.LEQUAL);
  gl.stencilFunc(gl.EQUAL, 0, 0xff); gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
  drawScene(vp, lampColor, black);                              // 3: lamp where the count is 0
  gl.disable(gl.STENCIL_TEST); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);
  drawScene(vp, black, ambient);                                // 4: add ambient everywhere
  gl.disable(gl.BLEND); gl.depthMask(true); gl.depthFunc(gl.LESS);
}
</script>