The Shadow Map Algorithm

Lance Williams published shadow mapping in 1978, and it remains the standard real-time technique. A point is lit if nothing lies between it and the light, and the depth buffer of a camera placed at the light records exactly the nearest surface along every ray. So the algorithm takes two passes:

  1. Depth pass: render the scene from the light into a depth texture, the shadow map.

  2. Shading pass: render from the camera. Transform each fragment into the light's clip space, look up the stored depth at its position, and compare. If the fragment is farther from the light than the stored surface, something blocks it: it is in shadow.

A shadow map stores the nearest depth per light ray; farther fragments are in shadow
A shadow map stores the nearest depth per light ray; farther fragments are in shadow

It costs one extra render per light and handles any geometry, even alpha-tested leaves (Volumes vs Mapping); its weakness is the map's finite resolution, the subject of the next four subsections.

The two passes with a moving lamp: depth rendered from the lamp (left), then each fragment compared against it (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="1200" height="300"></canvas>
<div class="names"><div>pass 1: depth from the lamp (the shadow map)</div><div>pass 2: farther than the stored depth = in shadow</div></div>
<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;
  },
  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);
  },
};
function cube() {
  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);
}
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');
const depthProgram = program(`#version 300 es
layout(location = 0) in vec3 aPosition; uniform mat4 uViewProjection, uModel;
void main() { gl_Position = uViewProjection * uModel * vec4(aPosition, 1.0); }`, `#version 300 es
precision mediump float; void main() {}`);      // depth only: no colour is written
const litProgram = 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; out vec4 outColor;
uniform highp sampler2DShadow uShadowMap;
uniform mat4 uLightViewProjection;
uniform vec3 uColor, uLamp;
uniform float uBias, uSpread;
void main() {
  vec4 p = uLightViewProjection * vec4(vPosition, 1.0);
  vec3 s = p.xyz / p.w * 0.5 + 0.5;                   // shadow-map u, v and depth, all 0..1
  vec2 texel = uSpread / vec2(textureSize(uShadowMap, 0));
  float lit = 0.0;
  for (int x = -1; x <= 1; x++) for (int y = -1; y <= 1; y++)   // 3 x 3 PCF (all nine alike when uSpread = 0)
    lit += texture(uShadowMap, vec3(s.xy + vec2(x, y) * texel, s.z - uBias));
  vec3 n = normalize(vNormal), l = normalize(uLamp - vPosition);
  outColor = vec4(uColor * (0.3 + 0.9 * max(dot(n, l), 0.0) * lit / 9.0), 1.0);
}`);
gl.bindVertexArray(gl.createVertexArray());
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 scene = [...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) })),
  { 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], size = 512;
function drawScene(p, viewProjection) {
  const u = (n) => gl.getUniformLocation(p, n);
  gl.uniformMatrix4fv(u('uViewProjection'), false, viewProjection);
  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);
  }
}
// The shadow map: a depth texture in comparison mode, on a framebuffer with no colour attachment.
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);
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.drawBuffers([gl.NONE]);
const light = m4.multiply(m4.perspective(90, 1, 0.5, 5), m4.lookAt(lamp, [0.1, -0.2, -0.3]));


const showDepth = program(`#version 300 es
out vec2 vUV;
void main() { vUV = vec2(gl_VertexID & 1, gl_VertexID >> 1) * 2.0; gl_Position = vec4(vUV * 2.0 - 1.0, 0.0, 1.0); }`,
`#version 300 es
precision highp float; uniform highp sampler2D uDepth; in vec2 vUV; out vec4 c;
void main() {
  float d = texture(uDepth, vUV).r * 2.0 - 1.0, near = 0.5, far = 5.0;
  c = vec4(vec3((2.0 * near * far / (far + near - d * (far - near)) - near) / (far - near)), 1.0);
}`);
const camera = m4.multiply(m4.perspective(40, 2, 0.1, 20), m4.lookAt(eye, [0.1, -0.2, 0]));
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); gl.enable(gl.SCISSOR_TEST);
gl.clearColor(0.93, 0.91, 0.87, 1);
function frame(ms) {
  const lampNow = [-0.5 + Math.sin(ms * 0.0006) * 0.45, 1.3, 1.1];   // swings over the left half
  const lightNow = m4.multiply(m4.perspective(90, 1, 0.5, 5), m4.lookAt(lampNow, [0.1, -0.2, -0.3]));
  // Pass 1: the depth buffer of a camera at the lamp records the nearest surface on every ray.
  gl.bindFramebuffer(gl.FRAMEBUFFER, shadowFBO);
  gl.viewport(0, 0, size, size); gl.scissor(0, 0, size, size);
  gl.clear(gl.DEPTH_BUFFER_BIT);
  gl.useProgram(depthProgram);
  drawScene(depthProgram, lightNow);
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  // Left: the map itself, as grey (comparison off to read the raw depths).
  gl.bindTexture(gl.TEXTURE_2D, depthTexture);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE, gl.NONE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
  gl.viewport(0, 0, 600, 300); gl.scissor(0, 0, 597, 300);
  gl.clear(gl.COLOR_BUFFER_BIT);
  gl.viewport(150, 0, 300, 300);
  gl.useProgram(showDepth);
  gl.drawArrays(gl.TRIANGLES, 0, 3);
  // Pass 2: from the camera, compare each fragment's depth from the lamp with the stored one.
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE, gl.COMPARE_REF_TO_TEXTURE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  gl.viewport(600, 0, 600, 300); gl.scissor(603, 0, 597, 300);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  gl.useProgram(litProgram);
  gl.uniformMatrix4fv(gl.getUniformLocation(litProgram, 'uLightViewProjection'), false, lightNow);
  gl.uniform3fv(gl.getUniformLocation(litProgram, 'uLamp'), lampNow);
  gl.uniform1f(gl.getUniformLocation(litProgram, 'uBias'), 0.004);
  gl.uniform1f(gl.getUniformLocation(litProgram, 'uSpread'), 1.0);
  drawScene(litProgram, camera);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>