Home Games Shader Sandbox

Game Dev Mechanics: Shadow Mapping — How It Works

Shadow Mapping
Quality:
Shadow Quality: Soft PCF
Drag to orbit • Scroll to zoom

The Illusion of Depth: Why Shadows Matter

Open any modern video game and shadows are everywhere: the dark patch behind a tree at golden hour, the pool of shade cast by a tower, a character's silhouette on sunlit pavement. Shadows ground objects in the world, communicate spatial relationships, and carry gameplay information: where a platform edge ends, where an enemy lurks, whether a thrown object will hit or miss. Computing correct, real-time shadows is one of the hardest problems in real-time rendering.

The dominant technique used in virtually every commercial game engine today is shadow mapping, first described by Lance Williams in his 1978 paper "Casting Curved Shadows on Curved Surfaces." Despite its age, the core algorithm remains the foundation of shadow rendering in Unity, Unreal Engine, Godot, and essentially every modern 3D game. Simple in concept and deceptively tricky in practice, it has been extended by successive generations of graphics engineers into a wide family of techniques.

The Central Insight: A Light With a Camera

The key insight is simple: a shadow is a region the light cannot see. To find which regions a light cannot see, we render the scene from the light's point of view and record how far it can see in every direction. This recording, the shadow map, is a depth texture storing the distance to the nearest occluding surface in each direction the light shines.

When rendering from the player's camera, we check each visible fragment: is this point closer to the light than the shadow map recorded, or is something blocking the path? If the fragment is farther from the light than the stored depth, something is occluding it — the fragment is in shadow.

The Two-Pass Algorithm

Shadow mapping proceeds in exactly two rendering passes:

  • Pass 1: Depth Map Generation. Position a virtual camera at the light source. Render the entire scene from this perspective, storing only depth values into a dedicated depth texture. No color output is needed, only geometry.
  • Pass 2: Shadow Testing. Render the scene normally from the player camera. For each fragment, transform its world-space position into the light's clip space. Sample the shadow map at the corresponding UV coordinates. Compare the sampled depth to the fragment's actual depth from the light. If the fragment is further away than stored, it is in shadow.

Pass 1: Building the Shadow Map

The light's virtual camera type depends on the light source. Directional lights (like sunlight) use an orthographic projection — all rays travel in parallel and the frustum is a rectangular box. Spotlights use a perspective projection radiating from a point. Point lights require a cube shadow map: six perspective depth renders into the faces of a cube.

For a directional light, we define a combined view-projection matrix for the light:

$$M_{light} = M_{proj}^{light} \cdot M_{view}^{light}$$

We render scene geometry through this transform into a depth buffer. The result is a 2D texture where each texel stores the normalized depth of the nearest visible surface in $[0, 1]$. Shadow map resolution is a key parameter — 1024×1024 is common for typical shadows, 2048×2048 or higher for quality-critical work.

Pass 2: The Shadow Test

During the main rendering pass, for each fragment at world-space position $P_{world}$, we transform it into the light's clip space:

$$P_{light} = M_{light} \cdot P_{world}$$

After the perspective divide, we remap normalized device coordinates from $[-1, 1]$ to UV space $[0, 1]$:

$$uv = \frac{P_{light}.xy}{P_{light}.w} \cdot 0.5 + 0.5, \quad d_{frag} = \frac{P_{light}.z}{P_{light}.w} \cdot 0.5 + 0.5$$

We sample the shadow map at $uv$ to get $d_{shadow}$, then test:

$$\text{inShadow} = d_{shadow} < d_{frag}$$

In GLSL, the classical shadow test looks like this:

float computeShadow(sampler2D shadowMap, vec4 fragPosLightSpace) {
    vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
    projCoords = projCoords * 0.5 + 0.5;

    float closestDepth = texture2D(shadowMap, projCoords.xy).r;
    float currentDepth = projCoords.z;

    // 1.0 = in shadow, 0.0 = lit
    return currentDepth > closestDepth ? 1.0 : 0.0;
}

The Shadow Acne Problem

Run the basic algorithm and you will immediately encounter an ugly artifact: a pattern of dark stripes or moire across lit surfaces, called shadow acne or self-shadowing. It arises because the shadow map has finite resolution. Each texel represents a finite area of the scene. Due to the angle between the light and the surface, a fragment can appear to lie below the depth stored for its own shadow map texel, shadowing itself, even when fully lit.

The standard fix is a shadow bias: a small constant offset applied before the depth comparison:

$$\text{inShadow} = (d_{shadow} + bias) < d_{frag}$$

float shadow = (currentDepth - bias) > closestDepth ? 1.0 : 0.0;

Typical values range from $0.001$ to $0.005$. Too large a bias introduces Peter Panning: objects appear to float above their own shadows. A more adaptive solution is slope-scale bias, which scales the bias by the angle between the light and the surface normal $\vec{N}$:

$$bias = \max(b_{min},\; b_{slope} \cdot (1 - \vec{N} \cdot \vec{L}))$$

Surfaces nearly perpendicular to the light need little correction; grazing-angle surfaces need more. Many GPU APIs expose this as a rasterizer state (glPolygonOffset in OpenGL, depthBiasConstantFactor in Vulkan).

Percentage Closer Filtering (PCF)

Basic shadow mapping produces hard, aliased shadow edges. In reality, most light sources have finite area, creating soft penumbra at shadow boundaries. Percentage Closer Filtering (PCF), introduced by Reeves, Salesin, and Cook in 1987, approximates soft shadows by sampling the shadow map multiple times in a neighborhood and averaging the binary shadow test results:

$$\text{shadow} = \frac{1}{N^2} \sum_{i=-k}^{k} \sum_{j=-k}^{k} \text{compare}\!\left(d_{shadow}\!\left(uv + \frac{(i,j)}{mapSize}\right),\; d_{frag}\right)$$

PCF compares depths before averaging, never the depth values themselves (blurring them would give wrong results). It averages binary lit/shadow decisions, producing a smooth gradient at shadow edges:

float PCF(sampler2D shadowMap, vec4 fragPosLightSpace, float bias) {
    vec3 p = fragPosLightSpace.xyz / fragPosLightSpace.w * 0.5 + 0.5;
    float currentDepth = p.z;
    float shadow = 0.0;
    vec2 texelSize = 1.0 / vec2(textureSize(shadowMap, 0));
    for (int x = -1; x <= 1; ++x)
        for (int y = -1; y <= 1; ++y) {
            float d = texture2D(shadowMap, p.xy + vec2(x,y) * texelSize).r;
            shadow += (currentDepth - bias) > d ? 1.0 : 0.0;
        }
    return shadow / 9.0;
}

Larger kernels produce softer results at higher GPU cost. Poisson-disk sampling distributes samples more efficiently than a regular grid, avoiding structured aliasing patterns. PCSS (Percentage Closer Soft Shadows) goes further, making the filter kernel size proportional to the blocker distance, simulating how penumbra widens with receiver-to-occluder separation.

Cascaded Shadow Maps

A directional light's shadow frustum must cover the entire visible scene. For large outdoor worlds, a single shadow map stretched across hundreds of meters gives terrible texel density: nearby fragments get blurry, pixelated shadow edges. This perspective aliasing is the main limitation of single shadow maps.

Cascaded Shadow Maps (CSM) solve this by using multiple shadow maps, one per depth range of the view frustum. Close to the camera, a tightly fitted map delivers crisp detail. Further away, progressively larger maps cover wider ranges. The degradation is imperceptible because distant shadows subtend fewer screen pixels anyway.

Cascade split distances are commonly distributed logarithmically (or blended with a linear term via $\lambda$) to match the camera's nonlinear depth perception:

$$z_i = z_{near} \cdot \left(\frac{z_{far}}{z_{near}}\right)^{i/N}$$

float[] CascadeSplits(float near, float far, int N, float lambda) {
    float[] splits = new float[N];
    float ratio = far / near;
    for (int i = 0; i < N; i++) {
        float p = (i + 1) / (float)N;
        float logSplit = near * Mathf.Pow(ratio, p);
        float linSplit = near + (far - near) * p;
        splits[i] = Mathf.Lerp(linSplit, logSplit, lambda);
    }
    return splits;
}

In the fragment shader, the current fragment's camera-space depth selects which cascade to sample. Blending between cascades at boundaries hides the transition seam.

Modern Variants

  • Variance Shadow Maps (VSM): Store depth mean $\mu$ and mean-squared $\mu_2$ in the shadow map. Chebyshev's inequality bounds the lit probability: $P(x \leq t) \leq \sigma^2 / (\sigma^2 + (t-\mu)^2)$ where $\sigma^2 = \mu_2 - \mu^2$. This enables Gaussian blurring for soft shadows but can exhibit light bleeding.
  • Exponential Shadow Maps (ESM): Store $e^{c \cdot depth}$, enabling log-space blurring with less bleeding than VSM.
  • Virtual Shadow Maps (Unreal Engine 5): A massive virtual texture atlas with on-demand page allocation, effectively a 16K×16K shadow map that only allocates pages for visible regions. This achieves high resolution across open worlds at manageable memory cost.
  • Ray-Traced Shadows: Hardware ray tracing (NVIDIA RTX, AMD RDNA2) casts shadow rays directly, producing pixel-accurate area-light soft shadows without the aliasing or filtering approximations of shadow maps. Still too expensive for all lights in most real-time scenarios, so shadow mapping remains the dominant approach.

Shadow Mapping in Real Games

Unity uses CSM for directional lights with configurable cascade counts, PCF filtering, and optional baked shadow masks for mixed real-time/static workflows. Unreal Engine supports traditional CSM alongside Virtual Shadow Maps in UE5 for its open-world titles. Godot 4 offers CSM directional shadows with PCSS for physically-based penumbra softening.

Games like Red Dead Redemption 2, The Witcher 3, and Horizon Zero Dawn all use custom CSM implementations with additional techniques (contact-hardening soft shadows, shadow LOD, cache-and-reproject for static geometry) to handle their enormous outdoor environments at 60fps.

Understanding shadow mapping, including its two-pass structure, bias trade-offs, PCF softening, and cascaded extensions, gives you what you need to make informed decisions about shadow quality and performance. The interactive demo above lets you see these quality modes side-by-side: toggle between hard shadows (basic depth comparison), PCF, and soft PCF to see how filtering transforms shadow edges. Enable the frustum visualization to see the orthographic light camera that generates the depth map each frame.

Comments

Like this article? Consider supporting us

Your support helps us keep creating free game dev content, tutorials, and tools.

Free

$0 /month

Newsletter and public posts

  • Newsletter access
  • Public posts & updates
  • Community access

Studio Backer

$25 /month

Direct impact on development with your name in the credits

  • Everything in Supporter
  • Your name in game credits
  • Priority feature requests
  • Direct developer access
  • Monthly asset downloads