Computer Graphics

Shadow Mapping: How 3D Scenes Cast Shadows in Real Time

Every frame of a modern game — 60 times a second, across millions of pixels — the GPU answers one deceptively simple question for every surface point it draws: is the sun visible from here, or is something in the way? The dominant answer, shipped in virtually every real-time engine from Unreal to Unity to id Tech, is shadow mapping: render the scene once from the light's point of view, store how far the nearest surface is in a depth texture, then during normal shading compare each visible fragment's light-space depth against that stored value. If the fragment is farther away, something occluded it — it's in shadow.

Invented by Lance Williams at the New York Institute of Technology in 1978, shadow mapping wins because it is purely image-space: it never asks what the geometry is, only how deep it is. That makes it O(1) per shaded pixel and utterly indifferent to triangle count, which is exactly why it beat the alternatives to become the industry default.

  • InventedLance Williams, 1978
  • Passes2 (depth pass + shade pass)
  • Per-pixel costO(1) lookup, O(k²) with PCF
  • SpaceO(w·h) depth texture
  • Best forReal-time GPU rasterization
  • RivalShadow volumes (stencil)

Interactive visualization

Press play, or step through manually. The visualization is yours to drive — try it before reading on.

Open visualization fullscreen ↗

Watch the 60-second explainer

A condensed visual walkthrough — narrated, captioned, under a minute.

The Core Idea: A Depth Photograph From the Light

Shadow mapping reframes visibility as a comparison between two depth measurements. The central insight is an invariant: for a given light, a surface point P is lit if and only if P is the closest surface to the light along the ray from the light through P. Anything closer means P is occluded.

The algorithm establishes this in two passes:

  • Pass 1 — the depth pass. Place a camera at the light. Rasterize the whole scene, but write only depth (no color, no shading). The result is the shadow map: a texture where each texel stores the distance from the light to the nearest surface in that direction. This is literally a depth photograph of the scene as the light sees it.
  • Pass 2 — the shading pass. Render from the real camera. For each fragment at world position P, transform P into the light's clip space to get coordinates (u, v, d), where (u,v) indexes the shadow map and d is P's depth as seen from the light. Read the stored depth z = shadowMap[u,v]. If d > z + bias, some other surface was closer to the light — P is in shadow. Otherwise it's lit.

Crucially, the test never inspects triangles, meshes, or object identity. It compares two floating-point depths. That image-space nature is the whole reason the technique scales.

The Algorithm Step by Step

Concretely, the two passes look like this. The light's view-projection matrix lightVP maps world space into the light's normalized device coordinates; a bias matrix remaps the resulting [-1,1] range to the [0,1] texture range.

// Pass 1: render depth from the light
bindFramebuffer(shadowFBO)      // depth-only target
setViewport(SM_W, SM_H)
for each triangle T in scene:
    gl_Position = lightVP * worldPos(T)   // vertex shader
    // fragment shader writes gl_FragDepth implicitly

// Pass 2: shade from the eye, sampling the shadow map
for each visible fragment at world P:
    vec4 lp = lightVP * vec4(P, 1.0)
    vec3 proj = lp.xyz / lp.w             // perspective divide
    proj = proj * 0.5 + 0.5              // [-1,1] -> [0,1]
    float d = proj.z                    // this fragment's light-depth
    float z = texture(shadowMap, proj.xy) // nearest stored depth
    float lit = (d > z + bias) ? 0.0 : 1.0
    color = ambient + lit * directLighting

Three subtleties make or break a correct implementation:

  • The perspective divide (lp.xyz / lp.w) is mandatory for spot/point lights, whose projection is perspective; a directional light uses an orthographic projection where w = 1.
  • Fragments that fall outside the shadow map's frustum (proj.xy ∉ [0,1], or proj.z > 1) must be treated as lit, not shadowed, or the world beyond the map goes black.
  • The bias term is not optional. Without it, the technique self-shadows catastrophically (see pitfalls).

Complexity Analysis

Let n be the scene's triangle (or vertex) count, let the shadow map be w×h texels, and let the eye framebuffer be W×H pixels.

  • Pass 1 time: Θ(n) vertex transforms plus rasterization of the depth image, i.e. Θ(n + w·h) — linear in geometry, independent of the eye view. This is the same cost as an ordinary render minus the shading work.
  • Pass 2 time: Standard rasterization Θ(n + W·H), with each shaded fragment adding exactly one texture fetch and one comparison for the shadow test — O(1) per pixel. Total shadow-specific overhead is Θ(W·H).
  • With PCF (percentage-closer filtering), each fragment samples a k×k neighborhood of the shadow map, making the per-pixel test O(k²); a 3×3 kernel is 9 fetches, still a small constant.
  • Space: Θ(w·h) for the depth texture, typically 1024²–4096² at 16–32 bits per texel (2–64 MB). No per-object storage — the map is the only extra memory.

The headline result is that shadow-map cost is decoupled from triangle count on the shading side: doubling scene complexity does not change the per-pixel shadow test at all. Contrast shadow volumes, whose cost scales with the number of silhouette edges and can trigger fill-rate blowups on complex meshes. This O(1)-per-pixel property is precisely why GPUs standardized on shadow mapping.

The Two Signature Artifacts: Acne and Peter-Panning

A naive implementation with bias = 0 produces shadow acne — a moiré of dark stripes crawling across lit surfaces. The cause is a sampling mismatch: the shadow map stores depth at texel centers, but a lit fragment rarely lands exactly on a texel center. Across a texel that spans a surface tilted relative to the light, the stored depth and the fragment's true depth differ by up to the surface's depth-slope across that texel. Where the fragment's depth exceeds the quantized stored depth by that slop, the surface incorrectly shadows itself.

  • The fix is bias: add a small ε so d > z + bias forgives the quantization gap. A constant bias is crude; slope-scaled bias increases ε with the surface's angle to the light (bias ∝ tan θ), because grazing surfaces have larger depth slop per texel.
  • Over-bias causes peter-panning: push the bias too far and the contact shadow detaches from the object's base, so a character appears to float above its own shadow like Peter Pan. Bias is a tightrope between acne and detachment.
  • Normal-offset bias and front-face culling in the depth pass (rendering only back faces into the map) are common robustness tricks that reduce acne without as much peter-panning.

The other core artifact is projective aliasing: the shadow map has finite resolution, so its texels stretch across surfaces near the camera or at grazing angles, giving blocky, staircased shadow edges. This is the resolution problem that cascaded and perspective-warped variants exist to solve.

Soft Shadows and Higher Resolution: The Key Variants

Raw shadow mapping gives hard, jagged, single-resolution edges. Production renderers layer several well-known variants on top.

  • PCF (Percentage-Closer Filtering, Reeves et al., 1987): instead of one binary test, sample a k×k block of shadow-map texels, run the depth comparison at each, and average the 0/1 results. A fragment straddling a shadow edge returns, say, 0.6 — a soft, anti-aliased penumbra. GPUs offer hardware 2×2 PCF for near-free bilinear shadow filtering. Cost is O(k²) fetches.
  • Cascaded Shadow Maps (CSM): the standard fix for directional-light aliasing over huge scenes. Split the view frustum into 2–4 depth slices and render a separate shadow map for each, giving near objects high texel density and far objects low. Each fragment selects its cascade by depth. This is the go-to technique for outdoor sunlight in every major engine.
  • Variance Shadow Maps (VSM) and Exponential/Moment SM: store depth and depth² (or exponential/moment moments) so the shadow test becomes a smooth statistical estimate that can be blurred with a separable filter — cheap large-kernel soft shadows, at the cost of light-bleeding artifacts.
  • PCSS (Percentage-Closer Soft Shadows): estimate a blocker distance from the map, then scale the PCF kernel by the penumbra geometry, producing physically plausible contact-hardening soft shadows.

For point lights, which cast in all directions, the map becomes a cube shadow map (six faces) or a single dual-paraboloid map; the depth test is otherwise identical.

Why It Won, and Where It's Used

Shadow mapping is the default in essentially every real-time rasterizer: Unreal Engine (Virtual Shadow Maps are its modern cascade-free evolution), Unity (cascaded shadow maps for its directional lights), the Frostbite and id Tech engines, and WebGL/WebGPU frameworks like Three.js, whose DirectionalLight.shadow uses exactly the two-pass depth-texture scheme with PCF. It's baked into hardware: GPUs since the GeForce 3 era expose depth textures with a built-in comparison sampler (sampler2DShadow) that does the d > z test and 2×2 filtering in fixed function.

It won for three reasons:

  • Geometry independence. The shading-pass cost is O(1) per pixel regardless of scene complexity — the property shadow volumes lack.
  • Generality. It shadows anything that can be rasterized into a depth buffer: skinned characters, alpha-tested foliage (via alpha-clip in the depth pass), tessellated terrain. Shadow volumes need clean manifold silhouettes.
  • GPU alignment. It is nothing but rasterization plus a texture fetch — the two operations GPUs are fastest at.

Its ceiling is quality: perfect hard edges and unbiased contact require ray tracing, which is why modern hybrid renderers (RTX, Lumen) increasingly ray-trace shadows where the budget allows while keeping shadow maps for the bulk of lights.

Shadow mapping vs. the classic alternative and the ground-truth reference
PropertyShadow MappingShadow VolumesRay-Traced Shadows
DomainImage-space (depth texture)Object-space (silhouette geometry)Ray/scene intersection
Cost driverMap resolution & filter sizeSilhouette edge countRays × BVH depth
Per-pixelO(1) / O(k²) PCFO(1) stencil testO(log n) per shadow ray
Hard vs softSoft via PCF/VSM/PCSSHard edges onlySoft with area sampling
Main artifactAcne, peter-panning, aliasingFill-rate blowup, robustnessNoise (needs denoise)
Used inNearly all rasterizersDoom 3 era enginesRTX / path tracers

Frequently asked questions

Why not just ray-trace every shadow instead?

Ray tracing gives exact, bias-free, physically correct shadows, but each shadow ray costs an O(log n) traversal of an acceleration structure (a BVH) and typically needs many samples plus denoising for soft edges. Shadow mapping replaces that with one texture fetch — O(1) per pixel, independent of triangle count — which for decades fit the real-time budget far better. Modern engines increasingly do both: ray-trace key shadows, shadow-map the rest.

What exactly is the time and space complexity?

The depth pass is Θ(n + w·h) for n triangles into a w×h map. The shading pass adds Θ(W·H) shadow-specific work — exactly one comparison per pixel, O(1) each, or O(k²) with a k×k PCF kernel. Space is Θ(w·h) for the single depth texture (commonly 2–64 MB). Critically, the per-pixel shadow test does not grow with scene geometry.

What causes shadow acne and how do you fix it?

Acne is self-shadowing caused by depth quantization: the map stores depth at texel centers, so a tilted surface's true fragment depth can slightly exceed the stored value across a texel, tripping the d > z test on lit surfaces. The fix is a depth bias, ideally slope-scaled (larger for grazing angles) plus a normal offset. Too much bias, though, produces peter-panning where shadows detach from their casters.

How does shadow mapping produce soft shadows?

The raw test is binary, giving hard edges. Percentage-Closer Filtering (PCF) samples a k×k neighborhood of the map, runs the comparison at each texel, and averages the results into a fractional [0,1] visibility that anti-aliases the edge. Variance shadow maps and PCSS extend this to wide, physically plausible penumbras that harden near contact points.

How do you shadow a point light that shines in all directions?

A single flat shadow map only covers one frustum, so an omnidirectional point light uses a cube shadow map: render depth into six faces (±X, ±Y, ±Z), then sample the cube in the direction from the light to the fragment. A cheaper alternative is a dual-paraboloid map (two hemispheres). The depth-comparison logic is otherwise unchanged.

Why does the shadow look blocky far from the camera, and what's the fix?

That's projective aliasing: a fixed-resolution map has to cover the whole light frustum, so texels stretch across distant or grazing surfaces, giving staircased edges. Cascaded Shadow Maps (CSM) solve it by splitting the view frustum into depth slices, each with its own map, so near geometry gets far more texels per unit area than the distant background.