Volumes vs Mapping

Shadow Volumes Versus Shadow Mapping

Frank Crow described shadow volumes in 1977, Tim Heidmann showed the stencil implementation in 1991, and Doom 3 (2004) made them famous. Most engines now use shadow mapping instead (Framebuffers and Shadows), which renders depth from the light and compares against it:

Stencil 672,850 shadow volumes and shadow mapping compared
Stencil shadow volumes Shadow mapping
Precision exact to the pixel limited by map resolution
Edges hard only softened with filtering (PCF)
Geometry closed meshes, silhouettes per light anything, including alpha-tested leaves
Cost fill rate: large volumes cover the screen one extra depth render per light
Artifacts none from sampling acne, peter-panning, aliasing
Needs stencil buffer, infinite far plane depth texture (WebGL 2 core)

Volumes suit a few simple occluders and crisp results; shadow maps suit large or foliage-heavy scenes and soft shadows, and are what Three.js 30,815 and Babylon.js 89,603 (Three.js and Babylon.js) ship.

The same lamp and shelf: exact, hard stencil shadows (left) against a 256 x 256 shadow map with PCF (right)HTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f4ee; }
  canvas { display: block; width: 100%; max-width: 600px; }
  .names { display: flex; max-width: 600px; text-align: center; font: 11px monospace; color: #333; }
  .names div { flex: 1; padding: 4px 2px; }
</style>
<canvas id="c" width="1240" height="340"></canvas>
<div class="names"><div>stencil shadow volumes: pixel-exact, hard edges</div><div>shadow mapping: soft PCF edges, limited by map resolution</div></div>
<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; uniform vec4 uColor; out vec4 c; void main() { c = uColor; }`);

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.uniform4f(gl.getUniformLocation(volumeProgram, 'uColor'), 0.9, 0.1, 0.1, 0.25);
  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);
}

// --- Shadow mapping for comparison: render depth from the lamp, then compare against it ---
const SIZE = 256;
const depthTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, depthTexture);
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.DEPTH_COMPONENT24, SIZE, SIZE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE, gl.COMPARE_REF_TO_TEXTURE);   // sampler2DShadow
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const shadowFBO = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, shadowFBO);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, depthTexture, 0);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
const depthOnly = program(`#version 300 es
layout(location = 0) in vec3 aPosition; uniform mat4 uLightVP, uModel;
void main() { gl_Position = uLightVP * uModel * vec4(aPosition, 1.0); }`, `#version 300 es
precision mediump float; void main() {}`);
const mapped = program(`#version 300 es
layout(location = 0) in vec3 aPosition; layout(location = 1) in vec3 aNormal;
uniform mat4 uViewProjection, uModel, uLightVP; uniform mat3 uNormalMatrix;
out vec3 vPosition, vNormal; out vec4 vLightClip;
void main() { vec4 w = uModel * vec4(aPosition, 1.0); vPosition = w.xyz; vNormal = uNormalMatrix * aNormal;
  vLightClip = uLightVP * w; gl_Position = uViewProjection * w; }`, `#version 300 es
precision highp float; precision highp sampler2DShadow;
in vec3 vPosition, vNormal; in vec4 vLightClip;
uniform vec3 uColor, uLamp, uLampColor, uAmbient; uniform sampler2DShadow uShadowMap; out vec4 c;
void main() {
  vec3 n = normalize(vNormal), toLamp = uLamp - vPosition; float d = length(toLamp);
  float lambert = max(dot(n, toLamp / d), 0.0);
  vec3 p = vLightClip.xyz / vLightClip.w * 0.5 + 0.5;          // shadow-map coordinates and depth
  float lit = 0.0;
  for (int x = -1; x <= 1; x++) for (int y = -1; y <= 1; y++)  // 3 x 3 percentage-closer filtering
    lit += texture(uShadowMap, vec3(p.xy + vec2(x, y) / 256.0, p.z - 0.002));
  c = vec4(uColor * (uAmbient + uLampColor * lambert / (d * d) * lit / 9.0), 1.0);
}`);
const lightVP = m4.multiply(m4.perspective(90, 1, 0.3, 6), m4.lookAt(lamp, [0, -0.2, -0.2]));
function drawWith(p, vp) {
  gl.useProgram(p);
  const u = (n) => gl.getUniformLocation(p, n);
  gl.uniformMatrix4fv(u('uLightVP'), false, lightVP);
  if (vp) {
    gl.uniformMatrix4fv(u('uViewProjection'), false, vp);
    gl.uniform3fv(u('uLamp'), lamp); gl.uniform3fv(u('uLampColor'), lampColor); gl.uniform3fv(u('uAmbient'), ambient);
  }
  gl.bindVertexArray(cubeVAO);
  for (const { color, model } of scene) {
    gl.uniformMatrix4fv(u('uModel'), false, model);
    if (vp) { gl.uniformMatrix3fv(u('uNormalMatrix'), false, m4.normal(model)); gl.uniform3fv(u('uColor'), color); }
    gl.drawArrays(gl.TRIANGLES, 0, 36);
  }
}

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]));
// Left: the four stencil passes.
gl.viewport(0, 0, 620, 340); gl.scissor(3, 0, 614, 340);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT);
drawScene(vp, black, 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);
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);
gl.disable(gl.STENCIL_TEST); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);
drawScene(vp, black, ambient);
gl.disable(gl.BLEND); gl.depthMask(true); gl.depthFunc(gl.LESS);
// Right: one depth render from the lamp, then one lit render that samples it.
gl.bindFramebuffer(gl.FRAMEBUFFER, shadowFBO);
gl.viewport(0, 0, SIZE, SIZE); gl.scissor(0, 0, SIZE, SIZE);
gl.clear(gl.DEPTH_BUFFER_BIT);
gl.enable(gl.POLYGON_OFFSET_FILL); gl.polygonOffset(2, 4);   // slope-scaled bias against shadow acne
drawWith(depthOnly, null);
gl.disable(gl.POLYGON_OFFSET_FILL);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(620, 0, 620, 340); gl.scissor(623, 0, 614, 340);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.bindTexture(gl.TEXTURE_2D, depthTexture);
drawWith(mapped, vp);
</script>