A point is in shadow if something sits between it and the light. Chapter 13's ray tracer answered that with a shadow ray. A rasterizer draws one triangle at a time and never knows what else is in the scene, so it needs a different trick. The one nearly every real-time renderer uses was published by Lance Williams in 1978: the shadow map.
A picture from the light
Stand where the light is and look at the scene. Everything you can see is lit; everything hidden from you is in shadow. So:
- Render the scene from the light's point of view, keeping only the depth buffer (Chapter 10). Each texel of this shadow map records how far the light's view reaches before hitting something.
- Render the scene normally from the camera. For every pixel, work out where its surface point appears in the light's picture, and how far it is from the light. If it's further than the depth the shadow map recorded there, something nearer the light was in the way: the point is in shadow.
For a sun, which is so far away that its light arrives in parallel, the light's "camera" uses an orthographic projection (Chapter 8), aimed along the sun's direction and sized to cover the area you need shadows in. For a spotlight, a perspective projection matching its cone.
Bias and resolution
Shadow acne
A shadow map texel covers a patch of surface, and records one depth for the whole patch: the depth at its center. A surface tilted relative to the light is nearer the light at one end of the patch and further at the other. So points on the far half of each patch measure "further than the recorded depth", and shadow themselves. The result is shadow acne: stripes of false shadow across lit surfaces.
The standard fix is a bias: pretend each point is slightly nearer the light before comparing, so small differences don't count. Too little and the acne returns; too much and the shadow starts noticeably away from the object, which then seems to float (called "peter-panning", after the boy who lost his shadow). Good renderers scale the bias with the surface's tilt to the light (slope-scaled bias, which GPUs support directly), or push the point a little along its normal (normal offset), which fixes acne with less floating.
Resolution
A shadow map is an image, so it has a resolution, and one texel can cover a big patch of ground. Near the camera, where each screen pixel covers a tiny patch, a single shadow-map texel may span many pixels, and shadow edges show its blocky outline. Higher resolution helps, at a cost in memory and drawing time; aiming the shadow map only where it's needed helps more (Part 5).
Percentage-closer filtering
Compare first, average after
You can't blur a shadow map like an ordinary image: averaging depths gives a depth that no surface has. The trick, published by William Reeves, David Salesin and Robert Cook in 1987, is to compare first and average after. Test the point against several neighboring shadow-map texels, each test giving lit (1) or shadowed (0), and average the results. The edge of a shadow becomes a smooth ramp from 0 to 1. This is percentage-closer filtering (PCF), and GPUs help directly: a comparison sampler does the depth comparison in hardware, on four neighboring texels at once, and blends the four results bilinearly.
@group(0) @binding(1) var shadowMap: texture_depth_2d;
@group(0) @binding(2) var shadowSampler: sampler_comparison;
// 1.0 = fully lit, 0.0 = fully in shadow, in between at soft edges.
fn shadowAmount(world: vec3f) -> f32 {
let p = u.lightViewProj * vec4f(world, 1.0); // where the light sees this point
let uv = p.xy * vec2f(0.5, -0.5) + 0.5; // NDC -> texture coordinates (y flips)
if (any(uv < vec2f(0.0)) || any(uv > vec2f(1.0))) {
return 1.0; // outside the map: call it lit
}
let depth = p.z - u.bias; // nearer the light by the bias
let texel = 1.0 / f32(textureDimensions(shadowMap).x);
var sum = 0.0;
for (var y = -1; y <= 1; y++) { // 3 x 3 neighboring texels
for (var x = -1; x <= 1; x++) {
sum += textureSampleCompareLevel(shadowMap, shadowSampler, uv + vec2f(f32(x), f32(y)) * texel, depth);
}
}
return sum / 9.0;
}
(The Level version of the call reads the top mip level only. Shadow maps don't have
mipmaps, and unlike the plain version it may be called after an early return, which WGSL
otherwise forbids for texture sampling: the GPU works out mip levels by comparing neighboring pixels, so
all of them must reach the call together.)
Shadows that sharpen where they touch
PCF gives every shadow edge the same softness, and real shadows aren't like that. Lights have a size: a lamp shade, a window, the sun's disc, which spans about half a degree of sky. From a point near a shadow's edge, part of the light is hidden and part is visible, and the band where that happens, the penumbra, gets wider the further the shadow falls from the object casting it. A pole's shadow is crisp at its foot and blurry at its tip.
Percentage-closer soft shadows (PCSS, published by Randima Fernando in 2005) imitate this in three steps:
- Blocker search. Read the shadow map in an area around the point, and average the depths of the texels that are nearer the light than the point is. That's roughly how far away the blocker is.
- Penumbra estimate. The gap between blocker and receiver, times the light's size as seen from the receiver, gives the width of the penumbra. For the sun, every meter between blocker and ground widens the penumbra by about a centimeter.
- Filter. Run PCF with a filter as wide as that estimate.
Wide filters need many samples, so PCSS is expensive; renderers spread a few samples per pixel in a pattern that's rotated differently at every pixel, then let temporal antialiasing (Chapter 19) average the noise away. Choose PCSS in the hero image at the top and watch the ring's shadow sharpen where it comes close to a pillar.
Storing numbers that blur
PCF's cost grows with the width of its filter: a 9 × 9 filter is 81 comparisons for every pixel on screen. Ordinary images don't have this problem. A blurred image can be computed once, cheaply, with a separable blur (Chapter 5), and then read with a single bilinear, mipmapped lookup. Depths can't be blurred that way, because the comparison has to happen before the averaging. But what if the shadow map stored something that can be averaged first and still answer the question afterwards?
Variance shadow maps
Variance shadow maps (William Donnelly and Andrew Lauritzen, 2006) store two numbers per texel: the depth, and the depth multiplied by itself. Both can be blurred like any image. After blurring, the first is the average depth of the area, and from the two together you can work out how spread out the depths are (the variance, the average of the squares minus the square of the average). And from an average and a spread, a classic rule of statistics gives a limit on how much of the area can be further away than a given depth.
Suppose the depths in some patch of the shadow map average 4 meters, with a spread (standard deviation)
of half a meter. What share of them could possibly be 6 meters or more? Not many: every depth that far
from the average pushes the spread up a lot, and the spread is only half a meter. Pafnuty Chebyshev's
inequality (from the 1860s) makes this exact: the share beyond a distance t past the average is at
most spread² / (spread² + t²), here 0.25 / (0.25 + 4) ≈ 6%.
A receiving point at 6 meters is lit only through texels whose depth is 6 or more, so it is at most 6% lit. For the common case of one flat blocker over one flat receiver, the limit is reached exactly, so variance shadow maps use it directly as the lit fraction:
fn chebyshev(m: vec2f, d: f32, minVariance: f32) -> f32 {
if (d <= m.x) {
return 1.0; // in front of the average: lit
}
let variance = max(m.y - m.x * m.x, minVariance); // spread^2, from the two blurred numbers
let t = d - m.x;
return variance / (variance + t * t);
}
Now every trick for images applies to shadows. Blur the shadow map once per frame with a separable blur, of
any width, at the same cost per pixel. Use bilinear filtering and mipmaps when reading it. Even render it
with MSAA. The minVariance plays the part of the bias: a tiny spread assumed everywhere, which
keeps flat lit surfaces from shadowing themselves.
The weakness is light bleeding. Chebyshev's limit is only exact for one blocker over one receiver. When the filter area contains two blockers at different depths, the spread is large, and a point far behind both of them is judged partly lit: a pale ghost of light appears inside shadows, outlining the nearer blocker. You can see it in the figure below by adding the second blocker.
Exponential shadow maps
Exponential shadow maps (Thomas Annen and colleagues, and Marco Salvi, both 2008) take a
different route. Store a rapidly growing function of the depth, exp(c × depth), with
c a large constant like 40. Then multiply the blurred value by exp(−c × receiver
depth). Texel by texel, that product is exp(−c × gap), where the gap is how far in front
of the receiver the texel's surface lies. A texel at the receiver's own depth gives 1; a texel well in front
gives nearly 0. So the blurred product is close to the share of texels not in front: the lit
fraction. It's cheap (one number per texel) and has no bleeding between blockers, but where a blocker nearly
touches its receiver, the gap is small, the "nearly 0" isn't small enough, and light leaks in at contact
points. A bigger c helps until the numbers grow too large to store: exp(88) is
already the largest a 32-bit float can hold.
Exponential variance shadow maps
EVSM (Andrew Lauritzen and Michael McCool, 2008) combines the two. First stretch each
depth with the exponential, e = exp(c × depth), then do variance shadow mapping on the stretched
values: store e and e × e, blur, and apply Chebyshev's limit with the receiver's
stretched depth. The stretch makes the gap between a far receiver and a nearer blocker enormous compared
to the spread between blockers, so the bleeding term all but vanishes. The full version, EVSM4, also stores
the moments of a second, negative stretch (−exp(−c × depth)) and takes the smaller of the two
answers; the cheaper EVSM2 keeps just the positive one, half the memory, and is usually
enough. Since e × e is exp(2c × depth), the exponent must stay below about 42 with
32-bit floats and depths from 0 to 1.
The EVSM2 pieces in WGSL: a blur pass writes the two stretched moments, and the lighting shader reads them back and applies the same Chebyshev function. Choose EVSM2 in the hero to see it running, and VSM to see the bleeding where the ring's shadow overlaps a pillar's.
const C = 30.0; // the exponent: bigger = less bleeding, until floats overflow
// In the blur pass, for each shadow-map depth (0 to 1) it reads:
fn evsmMoments(depth: f32) -> vec2f {
let e = exp(C * depth);
return vec2f(e, e * e); // blurred, then stored in an rg32float texture
}
// In the lighting pass:
fn evsmShadow(m: vec2f, receiverDepth: f32) -> f32 {
let e = exp(C * receiverDepth);
let minVariance = 0.00002 * (C * e) * (C * e); // the bias, stretched the same way
return chebyshev(m, e, minVariance);
}
Filterable shadow maps cost a blur pass and more memory (two 32-bit numbers per texel instead of one depth), but the lookup costs the same however soft the shadow. Research has gone further still: moment shadow maps (Christoph Peters and Reinhard Klein, 2015) store four powers of the depth and reconstruct the depths more faithfully, with less bleeding again.
Spending texels where they're needed
A sun lights everything you can see, out to the horizon. One shadow map covering all of that would spread its texels so thinly that shadows near the camera would be a blur of huge blocks. But you don't need the same detail everywhere: distant shadows cover few pixels, near ones cover many. Cascaded shadow maps split the camera's view into ranges by distance, near, middle and far, and give each its own shadow map. The near cascade covers a small area in fine detail; each further one covers more ground with coarser texels. Getting cascades to look good takes care at each step, so let's go through them.
1. Choosing the splits
Where should one range end and the next begin? Evenly spaced splits (0–33 m, 33–67 m, 67–100 m) give the nearest cascade a huge area, wasting detail where it matters most. Ideally each texel should cover about the same number of screen pixels, and since things shrink on screen in proportion to their distance, that calls for logarithmic spacing: each range a fixed multiple longer than the one before. Pure logarithmic spacing makes the first range tiny, though, so the usual practical split scheme (Fan Zhang and colleagues, 2006) computes both positions for each split and blends them, with a blend weight around 0.5 to 0.9.
const splits = [NEAR];
for (let i = 1; i <= COUNT; i++) {
const fraction = i / COUNT;
const linear = NEAR + (FAR - NEAR) * fraction;
const logarithmic = NEAR * Math.pow(FAR / NEAR, fraction);
splits.push(linear + (logarithmic - linear) * lambda);
}With the live sample's settings (NEAR 0.3 m, FAR 150 m, three cascades,
lambda 0.8), the ranges end at about 12, 35 and 150 meters: the first cascade covers less than a
tenth of the view's depth, and that's where most of the screen's pixels are.
2. Fitting each cascade
Each range is a slice of the camera's view: a chopped-off pyramid with eight corners. Transform those corners into the light's view (Chapter 8), and the smallest box around them gives the cascade's orthographic projection. That tight fit gives the smallest texels, but as the camera turns, the slice's outline seen from the light changes, so the box, and every texel in it, changes size from frame to frame. A steadier choice is a box around the slice's bounding sphere, the smallest ball containing all eight corners, whose size doesn't depend on which way the camera faces. It wastes some texels, but in exchange the texel size never changes.
The box must also reach further toward the light than the slice does, because objects outside the view can
still cast shadows into it, a mountain behind the camera for instance. Rather than stretching the box
(which wastes depth precision), renderers can let those casters be flattened onto the box's near
side: their depth is clamped instead of the triangles being cut away. This is called pancaking,
and in WebGPU it's the optional "depth-clip-control" feature, which adds
unclippedDepth: true to a pipeline's primitive settings.
3. Holding still
Even with a constant size, there's another source of shimmering. As the camera moves, the cascade moves with it, and its texel grid slides across the world by fractions of a texel. Each frame, a shadow's edge is sampled at slightly different places, so its stair-steps crawl along the edge, a flicker the eye picks up at once. The fix is to move the cascade only in whole texels: in the light's view, round the position of the box's center to a multiple of the texel size. The grid then stays locked to the world, and an edge falls on the same texels every frame.
Fitting and snapping together, for one cascade, with the chapter's vector and matrix helpers.
lightRotation is the light's view matrix without any translation, so the light's x and y axes
are the shadow map's rows and columns:
// the bounding sphere of the slice's eight corners
let center = [0, 0, 0];
for (const p of corners) {
center = add(center, scale(p, 1 / 8));
}
let radius = 0;
for (const p of corners) {
radius = Math.max(radius, length(sub(p, center)));
}
// round up, so rounding errors can't change the size
radius = Math.ceil(radius * 4) / 4;
// in the light's view, move the center only in whole texels
const texel = 2 * radius / SIZE;
const c = transformPoint(lightRotation, center);
const x = Math.round(c[0] / texel) * texel;
const y = Math.round(c[1] / texel) * texel;
// the cascade's box: x +/- radius, y +/- radius, in the light's viewThe Math.ceil matters more than it looks. The corners move as the camera turns, and their
bounding sphere's radius wobbles in the last few digits; rounding it up to a quarter meter keeps the texel size
exactly the same from frame to frame, which snapping depends on.
4. Picking and blending
When shading a pixel, pick the cascade from its distance to the camera, along the view direction: the first range it falls in. (A slightly better choice tests whether the point lands inside each cascade's map, nearest first, since the maps overlap more than the ranges do.) At the boundary between two cascades the shadow detail jumps visibly, so renderers blend the two maps' results across a narrow band near the split, or dither between them and let temporal antialiasing smooth it out.
Each cascade has a different texel size, so a bias that suits one is wrong for the next. A normal offset scaled by the cascade's texel size handles this neatly: move the point a texel or so along its surface normal before looking it up, so it can't be in its own way.
In the shader, with u.splits holding the far end of each range and u.texel each
cascade's texel size in meters:
// the first range this point falls in
var c = 0u;
for (var i = 0u; i < 2u; i++) {
if (viewDepth > u.splits[i]) {
c = i + 1u;
}
}
// normal offset, in this cascade's texels
var vis = sampleCascade(c, world + normal * u.texel[c] * 1.5);
// over the last 10% of the range, fade into the next cascade
let band = (u.splits[c] - viewDepth) / (u.splits[c] * 0.1);
if (c < 2u && band < 1.0) {
let next = sampleCascade(c + 1u, world + normal * u.texel[c + 1u] * 1.5);
vis = mix(next, vis, band);
}sampleCascade is the 3 × 3 filtered lookup from earlier in the chapter, aimed at one
layer of a texture array that holds all the cascades' maps:
@group(0) @binding(2) var shadowMaps: texture_depth_2d_array; // layer = cascade
@group(0) @binding(3) var shadowSampler: sampler_comparison;
// 1.0 = lit, 0.0 = in shadow, as seen by cascade c's shadow map
fn sampleCascade(c: u32, world: vec3f) -> f32 {
let p = u.light[c] * vec4f(world, 1.0); // u.light: one matrix per cascade
let uv = p.xy * vec2f(0.5, -0.5) + 0.5;
let depth = p.z - 0.0002; // a small bias
let texel = 1.0 / f32(textureDimensions(shadowMaps).x);
var sum = 0.0;
for (var y = -1; y <= 1; y++) {
for (var x = -1; x <= 1; x++) {
let at = uv + vec2f(f32(x), f32(y)) * texel;
sum += textureSampleCompareLevel(shadowMaps, shadowSampler, at, c, depth);
}
}
return sum / 9.0;
}5. Saving time
Three or four cascades means drawing the scene's shadow casters three or four more times, so renderers economize: cull the casters to each cascade's box (Chapter 9) so each object is drawn only into the maps it touches; update far cascades every second or fourth frame, since they change slowly; cache static scenery and re-render only what moves.
6. Fitting to what's on screen
Splits computed from the camera's near and far planes assume the view reaches all the way out. Often it doesn't: indoors, or facing a wall, every visible pixel might be within ten meters, and three cascades spread over a hundred waste nearly all their texels. Sample distribution shadow maps (SDSM, Andrew Lauritzen and colleagues, 2011) look at the depth buffer, which the renderer has already drawn, and fit the cascades to the depths actually on screen. A compute shader reduces the depth buffer to its nearest and farthest values (each workgroup takes the minimum and maximum of a tile, then the tiles' results are reduced again, as in the Hi-Z pyramid of Chapter 34), and the splits are spread over that range instead. The same pass can find each cascade's tightest box in the light's view, from the visible points that fall in it.
The catch is the readback: the CPU needs the result to set up the cascades' matrices, and waiting for the GPU stalls both. Renderers either use the previous frame's range, which is almost always close enough, or keep the whole setup on the GPU, writing the cascade matrices into a buffer the shadow passes read directly.
7. Virtual shadow maps
Cascades still pick a handful of fixed resolutions. Virtual shadow maps go further: imagine one gigantic shadow map, say 16,384 texels across for each of several nested levels around the camera (a clipmap, like the terrain rings of Chapter 37), far too big to store. Split it into pages of 128 × 128 texels. Each frame, a compute pass goes through the depth buffer and, for each visible pixel, works out which level gives a texel about the size of that pixel, and marks the page there as needed. Only marked pages get memory (from a pool, through a page table, like virtual memory in an operating system), and only those are rendered, and only when something in them changed: a static page, once drawn, is cached for as long as it's needed.
The result is shadow detail that matches the screen everywhere, near and far, at a memory cost proportional to what's visible. The price is complexity: a page table to look up on every shadow sample, a marking pass, drawing into many small pages (with culling per page), and bookkeeping to know which cached pages a moving object has invalidated.
Beyond the sun
Other kinds of light need other shapes of shadow map. A spotlight uses one perspective shadow map. A point light, such as a bulb, shines in every direction, so it renders six shadow maps, one for each face of a cube around the light, stored together as a cube map; that's six times the drawing, which is why shadow-casting point lights are used sparingly. Renderers with many lights pack their shadow maps into one big atlas texture, giving each light a region sized by how much of the screen it affects.
And shadow maps aren't the end of the story. Fine shadows in small cracks, which a shadow map's texels are too coarse to catch, can be added by a short ray march across the depth buffer near each pixel (contact shadows). On GPUs with ray-tracing hardware, renderers increasingly trace actual shadow rays, as in Chapter 13, through the acceleration structures of Chapter 14, and get exact, correctly softened shadows from lights of any shape.
Shadow mapping in WebGPU
Here's a complete shadow-mapped scene. The first render pass draws everything from the sun with a
depth-only pipeline (no fragment shader at all, just a depth texture). The second draws from the camera,
and the fragment shader calls shadowAmount from above. Change BIAS to 0 to see
acne, SHADOW_SIZE to 128 to see blocks, and FILTER to 0 to see hard edges.
Notice that the shadow pass draws with culling off: thin objects need both sides in the shadow map. Some renderers deliberately draw only back faces into the shadow map, which moves the recorded depth to the far side of each object and reduces acne on the lit side.
What we have so far
- A shadow map is a depth picture rendered from the light. A point is in shadow if it's further from the light than the depth recorded where it falls in that picture.
- Shadow acne comes from each texel recording one depth for a tilted patch; a bias (constant, slope-scaled or along the normal) fixes it, and too much makes shadows detach.
- Shadow-map resolution limits detail; PCF compares several texels and averages the results for soft edges. Comparison samplers do it in hardware.
- PCSS estimates the blocker's distance and widens the filter to match, so shadows sharpen where objects touch.
- Filterable shadow maps store numbers that can be blurred like an image. VSM stores depth and depth squared and uses Chebyshev's limit, but bleeds light between overlapping blockers; ESM stores an exponential and leaks at contact; EVSM2 applies VSM to exponentially stretched depths and avoids most of both.
- Cascaded shadow maps give near ranges of the view fine texels and far ranges coarse ones. Choose splits between even and logarithmic; fit each to a bounding sphere and snap it to whole texels so it doesn't shimmer; blend at the seams; scale the bias per cascade.
- Spotlights use one perspective map; point lights need six. Contact shadows and ray tracing fill in what shadow maps miss.
We now have most of the pieces of a modern renderer. Chapter 21 looks at how they're organized into a frame: which passes run in what order, where lighting happens, and the post-processing that finishes the picture.
Try it yourself
- Slope-scaled bias. Instead of a fixed bias, use
bias = 0.0005 + 0.003 * (1 - dot(N, toLight)), larger for surfaces at a glancing angle to the light. Can you set the base lower without acne?Hint
You'll need
NinsideshadowAmount: pass it in. Surfaces facing the light need almost no bias; surfaces nearly edge-on need the most. - Show the shadow map. Draw the shadow map in a corner of the screen: a small full-screen-style pass that reads it with
textureLoadand outputs its depth as gray.Hint
Depth textures are read with
textureLoad(shadowMap, vec2i(x, y), 0), which returns anf32. Use a viewport (pass.setViewport) to draw into just the corner. - Tighter fit. The sun's orthographic projection covers ±7 units. Shrink it to fit just the pillars and ring. What happens to the shadow sharpness?
Hint
The same number of texels covering a smaller area means smaller texels and sharper shadows. Fitting the shadow map tightly to what the camera can see is the core idea behind cascades, and behind methods that fit the map to the visible depth range each frame.
- Wider filter. Set
FILTERto 2 (5 × 5) and 3 (7 × 7). What does each cost, and what does it look like where the ring's shadow meets a pillar?Hint
Cost grows with the square: 9, 25, 49 comparisons per pixel. Wide filters also blur shadow detail and can let light leak through thin objects.
- Reduce the bleeding. A common VSM fix treats any lit fraction below some amount as fully shadowed, and stretches the rest:
clamp((p - 0.3) / 0.7, 0.0, 1.0). Try it in the filter figure's VSM curve (in your head, or in its code). What does it cost?Hint
Bleeding below 30% disappears, but every soft edge also gets narrower and harder, since the bottom 30% of each ramp is cut off. It trades bleeding for softness.
- Blend the cascades. Near the end of each cascade's range, say the last 10%, mix its shadow with the next cascade's, rising from 0% to 100% across the band. Where would you add this in the cascade demo's shader?
Hint
After choosing cascade
c, compute how far through its range the pixel is. If it's in the last 10%, look up cascadec + 1as well, andmixthe two results. It costs a second lookup, but only in the band.