Skip to content
Shadow Mapping

Shadow Mapping

Shadow mapping answers a deceptively simple question: is this fragment visible from the light’s point of view? The answer requires rendering the scene twice — once from the light to capture depth, once from the camera to produce the final image — and comparing depths across two different coordinate spaces. That comparison is the whole technique.

The Core Idea

Every fragment in the final image has a world position. To determine whether it is in shadow, project that position into the light’s clip space and compare its depth against the closest recorded depth at that location. If the fragment is farther from the light than the recorded depth, something else is closer — the fragment is in shadow.

Three ingredients make this work:

  1. A depth map — the scene rendered from the light’s POV into a floating-point framebuffer, recording only depth
  2. A light matrix — the composition of the light’s projection and view matrices, mapping world positions into the depth map’s UV space
  3. A shadow test — per fragment in the main pass, sample the depth map and compare

Depth Pass

The scene is rendered once from the light’s point of view. Only depth matters — no color, no lighting:

function renderDepthMap(lightPosition) {
  push()
  fbo.begin()
  clear()
  ortho(-110, 110, -110, 110, 110, 350)
  camera(lightPosition.x, lightPosition.y, lightPosition.z)
  pvMatrix(lightPV)   // capture P·V while inside the light's camera
  landscape()
  fbo.end()
  pop()
}

pvMatrix(lightPV) writes the light’s $P \cdot V$ matrix into a pre-allocated Float32Array(16). This is the only output of the depth pass that the main pass needs — alongside fbo.depth itself.

The Bias Matrix

The depth map covers UV coordinates [0,1]2[ 0, 1]^2, but the light’s clip space covers [1,1]3[-1, 1]^3. The bias matrix remaps NDC to texture space:

B=(0.5000.500.500.5000.50.50001) B = \begin{pmatrix} 0.5 & 0 & 0 & 0.5 \\ 0 & 0.5 & 0 & 0.5 \\ 0 & 0 & 0.5 & 0.5 \\ 0 & 0 & 0 & 1 \end{pmatrix}

In column-major storage — the layout setUniform and GLSL both expect:

const biasMatrix = new Float32Array([
  0.5, 0,   0,   0,   // col 0
  0,   0.5, 0,   0,   // col 1
  0,   0,   0.5, 0,   // col 2
  0.5, 0.5, 0.5, 1,   // col 3 — translation
])

The translation sits in column 3. Reading left-to-right gives the columns, not the rows — the standard column-major trap (see Foundations / Matrices).

The world-to-light-UV matrix is then:

W=B(PV)light W = B \cdot (P \cdot V)_{\text{light}}
mat4Mul(worldLightMatrix, biasMatrix, lightPV)

mat4Mul follows standard math order: out = A · B.

The Shadow Test

In the fragment shader, each world-space position is projected into the depth map:

float visibility(vec4 lPosition4) {
  vec3 lPosition3 = lPosition4.xyz / lPosition4.w;   // perspective divide
  float closestDepth = texture(depth, lPosition3.xy).r;
  float currentDepth = lPosition3.z;
  return currentDepth < closestDepth ? 1.0 : 0.5;
}

The vertex shader pre-computes lPosition4 = uWorldLightMatrix * worldPosition and passes it as a varying — the interpolation across the triangle is intentional and correct.

Light Position in Eye Space

The diffuse term requires the light direction in eye space. mapLocation converts directly:

mapLocation(eyeLightPos, lightPosition, {
  from: p5.Tree.WORLD,
  to:   p5.Tree.EYE,
})
shadowShader.setUniform('uLightPosition', eyeLightPos)

eyeLightPos is a Float32Array(3) allocated once in setup — no allocation per frame.

Debug Mode

The sketch exposes the internals deliberately. Press d to toggle:

  • Depth map preview (bottom-right HUD) — shows the raw depth buffer from the light’s POV. A well-formed depth map has gradual gradients; hard edges indicate depth discontinuities
  • Light position gizmo — the yellow sphere shows where the light is in world space
  • Axes and grid — spatial reference for reading the shadow geometry

Press a to animate the light. Keys 1 / 2 / 3 switch between three scenes with different shadow complexity. The acne is visible intentionally — it is the starting point for the first exercise.

Shadow Acne

The artifact visible at grazing angles is shadow acne: a fragment samples its own depth in the depth map, and floating-point precision causes it to test as self-shadowed. The fragment’s recorded depth and its test depth differ by a tiny amount — enough to flip the comparison.

The standard fix is a constant depth bias: push the depth map values slightly away from the light before comparison:

return (currentDepth - bias) < closestDepth ? 1.0 : 0.5;

Choosing bias is a tradeoff — too small leaves acne, too large detaches shadows from their casters (Peter Panning).

The Coordinate Chain

The full space chain for shadow mapping, written out explicitly:

localMworldVLlight eyePLlight clipBshadow UV \text{local} \xrightarrow{M} \text{world} \xrightarrow{V_L} \text{light eye} \xrightarrow{P_L} \text{light clip} \xrightarrow{B} \text{shadow UV}

And in parallel for the main camera:

worldVCcamera eyePCcamera clip÷wNDCscreen \text{world} \xrightarrow{V_C} \text{camera eye} \xrightarrow{P_C} \text{camera clip} \xrightarrow{\div w} \text{NDC} \xrightarrow{} \text{screen}

Both chains share the world-space vertex. The vertex shader computes both simultaneously.

p5.tree API

FunctionRole
pvMatrix(out)Capture light’s P·V into Float32Array(16)
mat4Mul(out, A, B)Compose bias · lightPV
mapLocation(out, p, opts)Convert light position WORLD→EYE
beginHUD() / endHUD()Depth map preview overlay
axes() / grid()Scene-space debug gizmos

Exercises

1 — Remove acne with a depth bias Add a bias uniform to the fragment shader. Connect it to a panel slider. Find the minimum bias that eliminates acne for each of the three landscapes without introducing Peter Panning.

2 — Perspective light projection The current depth pass uses ortho. Replace it with perspective. Compare the depth map previews. Notice how the resolution distribution changes — orthographic is uniform, perspective concentrates resolution near the light.

3 — Percentage Closer Filtering (PCF) Instead of a single depth sample, sample a 3×3 neighbourhood around lPosition3.xy and average the results. This softens shadow edges without blurring the depth map itself. Explain why blurring the depth map directly would not produce soft shadows.

References

  1. Williams, L. (1978). Casting curved shadows on curved surfaces. SIGGRAPH.
  2. Stamminger, M. & Drettakis, G. (2002). Perspective Shadow Maps. SIGGRAPH.
  3. Pettineo, M. (2012). Shadow mapping summary. The Danger Zone.