Chapter 20 · Part IV · The GPU

Shadows

A ray tracer finds shadows by asking one more question per pixel. A rasterizer has no rays to ask with, so it takes a picture from the light's point of view first, and checks every pixel against it.

Live WebGPU · shadow mapping
A ring turning over some pillars, with the sun slowly circling. Every shadow comes from a depth picture taken from the sun, the shadow map. Lower its resolution and shadows turn blocky; filter it and their edges soften; set the bias to zero and lit surfaces break out in shadowy stripes ("acne"). The method buttons switch between the filtering techniques of Parts 3 and 4: plain PCF, contact-hardening PCSS, and the blurrable variance (VSM) and exponential variance (EVSM2) maps.

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.


Part 1 · The idea
What does the light see?

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:

  1. 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.
  2. 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.

Interactive · a shadow map, seen from the side
The sun shines from the upper left. Along the top, the shadow map: each texel records how far its ray (faint lines) traveled before hitting something. Surfaces are colored lit or shadowed by comparing their own distance from the light with the texel they fall in. With few texels, shadow edges become steps. With zero bias, lit surfaces break into stripes: shadow acne. With too much bias, shadows detach from the objects that cast them.

Part 2 · The two classic problems
Acne and blockiness

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).

Interactive · shadow acne, bias, and shadow-map resolution
A real (if tiny) shadow map, covering 10 m of ground, rendered on the CPU. With no bias, every lit surface tilted to the sun is striped with acne, one stripe per texel; lower the sun and the stripes get worse. A constant bias that clears them with a high sun isn't enough for a low one, and raising it far enough starts to lift the shadows away from the objects. A slope-scaled bias grows where the surface is tilted to the light, so it clears the low-sun ground too, but notice that the pole's shadow now starts some way from its base. A normal offset moves the lookup point off the surface instead, and clears the acne while keeping that shadow attached. At 16² or 32², every shadow's edge follows the texel grid, and the pole's thin shadow breaks apart.

Part 3 · Soft edges
Filtering

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.

Interactive · why penumbras widen
A wide light at the top, a blocker, and the ground. Each point on the ground is shaded by how much of the light it can see. Rays from the light's two ends past the blocker's corner mark where the penumbra starts and stops. Raise the blocker and the penumbra widens: its width is the blocker's height times the light's width, divided by the distance from the light down to the blocker.

Percentage-closer soft shadows (PCSS, published by Randima Fernando in 2005) imitate this in three steps:

  1. 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.
  2. 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.
  3. 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.


Part 4 · Filterable shadow maps
VSM, ESM, EVSM

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.

In plain words · Chebyshev's limit

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.

Interactive · four filters on one row of a shadow map
Top: a side view. Light comes straight down; a shadow map 240 texels wide records the nearest depth across the row. Bottom: how lit each point of the ground is judged to be, by each method with the same filter width. PCF is the reference: it really does count the texels. Add the second blocker and VSM bleeds light into the shadow under it; EVSM2 doesn't. Lower the exponent and ESM leaks near contact, where blocker and ground are close.

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.


Part 5 · Covering a world
Cascaded shadow maps

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.

Interactive · cascades seen from above
The camera (bottom) looks up the page, out to 100 meters, with the sun straight overhead. Its view is divided into three ranges, each covered by a square shadow map of 1,024 × 1,024 texels. Change the spacing to see how much detail the near cascade gets. Then turn the camera: with a tight fit, each square changes size as the view turns, so its texels change size and shadow edges swim. A square big enough for the slice at any angle (sphere fit) stays the same size.

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 view

The 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.

Animated · a shadow map grid following a moving camera
The same shadow (outlined) captured by a shadow-map grid that follows the camera. Left: the grid moves smoothly, by fractions of a texel, so the blocky edge crawls and flickers. Right: the grid moves only in whole-texel jumps, so every texel keeps sampling the same spot in the world, and the edge holds still.

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.

Interactive · fitting cascades to the visible depths
The camera (bottom) looks up the page; its far plane is 100 meters away. A wall blocks the view (drag the slider to move it), so nothing beyond it is ever on screen. Without fitting, three cascades split the full 100 meters, and the wall sits in the coarse middle or far cascade. Fitted to the nearest and farthest visible depths, the three cascades share just the visible range, and the texels near the camera get many times smaller.

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.

Interactive · the pages a frame actually needs
Seen from above: the camera (center) looks up the page, between buildings. Each nested square is one level of the virtual shadow map, twice the size of the one inside it, divided into 16 × 16 pages. Each visible spot on the ground asks for the level whose texels match its pixel size there, so near spots need fine levels and far ones coarse levels. Only the filled pages are allocated and drawn. Take away the buildings and more of the world is visible: more pages.
Live WebGPU · cascaded shadow maps
A walk around a field of posts and blocks, with a fence close by, out to 150 meters, under a low sun. Each shadow map is 1,024 × 1,024. With a single map stretched over the whole view, nearby shadows are coarse blocks. With three cascades, they're sharp up close. Switch off stabilized (tight fit, no texel snapping) and watch the fence's shadow crawl as the camera walks; switch it back on and it holds still. Show cascades tints each range.

Part 6 · Other lights, and beyond
Spots, bulbs, and rays

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.


Part 7 · The code
Two passes

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.


Recap

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.

Exercises

Try it yourself

  1. 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 N inside shadowAmount: pass it in. Surfaces facing the light need almost no bias; surfaces nearly edge-on need the most.

  2. Show the shadow map. Draw the shadow map in a corner of the screen: a small full-screen-style pass that reads it with textureLoad and outputs its depth as gray.
    Hint

    Depth textures are read with textureLoad(shadowMap, vec2i(x, y), 0), which returns an f32. Use a viewport (pass.setViewport) to draw into just the corner.

  3. 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.

  4. Wider filter. Set FILTER to 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.

  5. 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.

  6. 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 cascade c + 1 as well, and mix the two results. It costs a second lookup, but only in the band.