Shadow Volumes

Shadow Volumes and Silhouette Edges

Seen from the light, an object's outline is its silhouette: the edges shared by a face turned toward the light and a face turned away from it, as seen from the light, not the viewer. Stretching each silhouette edge away from the light into a quad gives the sides of the volume; the light-facing triangles close it at the front, and the other triangles, pushed to infinity, close it at the back (Capping Volumes). The fifteenth part of webgl-helper.js builds this on the CPU for any closed mesh in GLH.box()'s layout:

webgl-helper.js, fifteenth part: the shadow volume of a closed meshJavaScript
// Section 3.11: the shadow volume of a closed mesh (GLH.box layout) under a point light, as
// world-space x, y, z, w triangles: front cap, silhouette sides, and back cap at infinity
GLH.shadowVolume = function (mesh, model, light) {
  const { vec3 } = glMatrix, out = [], edges = new Map();
  const at = (i) => vec3.transformMat4([], mesh.vertices.subarray(i * 8, i * 8 + 3), model);
  const key = (a, b) => [...a, ...b].map((k) => k.toFixed(4)).join();
  const far = (p) => [...vec3.sub([], p, light), 0];       // w = 0: a direction, at infinity
  for (let t = 0; t < mesh.indices.length; t += 3) {
    const [a, b, c] = [0, 1, 2].map((k) => at(mesh.indices[t + k]));
    const n = vec3.cross([], vec3.sub([], b, a), vec3.sub([], c, a));
    const lit = vec3.dot(n, vec3.sub([], light, a)) > 0;      // faces the light?
    out.push(...[a, b, c].flatMap((p) => (lit ? [...p, 1] : far(p))));   // the two caps
    for (const [p, q] of [[a, b], [b, c], [c, a]]) edges.set(key(p, q), { p, q, lit });
  }
  let silhouette = 0;
  for (const { p, q, lit } of edges.values()) {            // lit face next to an unlit one
    if (!lit || edges.get(key(q, p))?.lit !== false) continue;
    out.push(...q, 1, ...p, 1, ...far(p), ...q, 1, ...far(p), ...far(q));   // side quad
    silhouette++;
  }
  return { vertices: new Float32Array(out), silhouette };
};

A shared edge runs in opposite directions in its two counterclockwise triangles, so looking up the reversed key finds the neighbor. The side quad takes the lit face's edge reversed, which keeps it counterclockwise seen from outside the volume; the stencil pass depends on that winding. A light straight above a cube sees 4 silhouette edges; a light off a corner sees 6:

Silhouette edges of a unit box for two light positionsJavaScript
const box = GLH.box(), identity = glMatrix.mat4.create();
for (const light of [[0, 3, 0], [3, 3, 3]]) {
  const { vertices, silhouette } = GLH.shadowVolume(box, identity, light);
  const triangles = vertices.length / 12;                 // 3 vertices of 4 floats each
  console.log(`light at ${light}: ${silhouette} silhouette edges, ${triangles} triangles`);
}
Output
light at 0,3,0: 4 silhouette edges, 20 triangles
light at 3,3,3: 6 silhouette edges, 24 triangles

Twelve cap triangles plus two per edge: cheap to rebuild on the CPU for a few boxes.

A moving lamp over a book box: silhouette edges found on the CPU and stretched away from the light into volume sidesHTMLLive
<!doctype html>
<style>
  body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
  canvas { display: block; width: 100%; max-width: 600px; }
  p { max-width: 600px; margin: 4px 0; font-family: monospace; }
</style>
<canvas id="c" width="600" height="320"></canvas>
<p id="out"></p>
<script>
const m4 = {
  perspective(fovy, aspect, near, far) {
    const f = 1 / Math.tan(fovy * Math.PI / 360), 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;
  },
};
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 box = [];                                   // triangles of a book-shaped box, world space
for (const [n, u, v] of faces) for (const [a, b] of [[-1,-1],[1,-1],[1,1],[-1,-1],[1,1],[-1,1]])
  box.push([0, 1, 2].map(i => (n[i] + a * u[i] + b * v[i]) / 2 * [0.9, 1.2, 0.5][i] + [0, 0.6, 0][i]));
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];

// Silhouette: edges between a face turned toward the light and one turned away (seen from the light).
function silhouette(light) {
  const key = (a, b) => [...a, ...b].map((k) => k.toFixed(4)).join();
  const edges = new Map(), result = [];
  for (let t = 0; t < box.length; t += 3) {
    const [a, b, c] = box.slice(t, t + 3);
    const lit = dot(cross(sub(b, a), sub(c, a)), sub(light, a)) > 0;
    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())
    if (lit && edges.get(key(q, p))?.lit === false) result.push([p, q]);   // the reversed key is the neighbour
  return result;
}

const gl = document.getElementById('c').getContext('webgl2');
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
layout(location = 0) in vec3 aPosition; uniform mat4 uViewProjection;
void main() { gl_Position = uViewProjection * vec4(aPosition, 1.0); gl_PointSize = 12.0; }`], [gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float; uniform vec4 uColor; out vec4 c; void main() { c = uColor; }`]]) {
  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(program, s);
}
gl.linkProgram(program); gl.useProgram(program);
const uColor = gl.getUniformLocation(program, 'uColor');
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0);
function draw(mode, points, color) {
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(points.flat()), gl.DYNAMIC_DRAW);
  gl.uniform4fv(uColor, color);
  gl.drawArrays(mode, 0, points.length);
}
gl.uniformMatrix4fv(gl.getUniformLocation(program, 'uViewProjection'), false,
  m4.multiply(m4.perspective(45, 600 / 320, 0.1, 30), m4.lookAt([2.6, 2.6, 4.2], [0, 0.4, 0])));
gl.enable(gl.DEPTH_TEST);

function frame(ms) {
  const a = ms * 0.0006;
  const light = [Math.cos(a) * 2.2, 2.6 + Math.sin(a * 0.7) * 0.4, Math.sin(a) * 1.4];
  const edges = silhouette(light);
  gl.clearColor(0.93, 0.91, 0.87, 1);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  draw(gl.TRIANGLES, [[-3, 0, -3], [3, 0, 3], [3, 0, -3], [-3, 0, -3], [-3, 0, 3], [3, 0, 3]], [0.82, 0.78, 0.7, 1]);
  // Faces toward the light in blue, away in dark blue.
  for (let t = 0; t < box.length; t += 3) {
    const [p, q, r] = box.slice(t, t + 3);
    const lit = dot(cross(sub(q, p), sub(r, p)), sub(light, p)) > 0;
    draw(gl.TRIANGLES, [p, q, r], lit ? [0.3, 0.55, 0.8, 1] : [0.1, 0.2, 0.32, 1]);
  }
  draw(gl.POINTS, [light], [0.95, 0.75, 0.1, 1]);
  // Each silhouette edge stretched away from the light (a finite length here, infinite in the real volume).
  gl.enable(gl.BLEND); gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
  gl.depthMask(false);
  for (const [p, q] of edges) {
    const far = (v) => v.map((c, i) => c + (c - light[i]) * 1.4);
    draw(gl.TRIANGLES, [q, p, far(p), q, far(p), far(q)], [0.85, 0.15, 0.1, 0.25]);
  }
  gl.depthMask(true); gl.disable(gl.BLEND);
  gl.disable(gl.DEPTH_TEST);
  draw(gl.LINES, edges.flat(), [0.9, 0.1, 0.05, 1]);    // the silhouette itself
  gl.enable(gl.DEPTH_TEST);
  document.getElementById('out').textContent = `light at (${light.map(v => v.toFixed(1)).join(', ')}): ` +
    `${edges.length} silhouette edges -> ${edges.length * 2} side triangles + 12 cap triangles`;
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>