Chapter 21 sketched the problem: lighting cost grows with pixels times lights, and most of those pairs contribute nothing, because a small light reaches only a few meters. This chapter builds the standard solutions, from screen tiles to 3D clusters, and then the newer stochastic methods that let even shadowed, area-shaped lights number in the thousands.
Lists of lights per screen tile
The first widely used fix, from around 2011, splits the screen into tiles of, say, 16 × 16 pixels. Before lighting, a compute shader works out which lights touch each tile, and writes a short list per tile. Then each pixel loops over its tile's list instead of every light.
"Touch" needs a 3D test. A tile is a narrow pyramid stretching away from the camera: its sides come from the tile's edges on screen. The culling shader reads the depth buffer to find the nearest and furthest surface in the tile, which trims the pyramid to a short slab around what's actually there. A light's sphere of influence that misses the slab is skipped. Applied to deferred shading, this is tiled deferred; applied to forward shading after a depth pre-pass, it was named Forward+ (Takahiro Harada, Jay McKee and Jason Yang, 2012).
Tiles have one weakness, and it's in depth. A tile that covers the edge of a nearby pillar and the far distant floor behind it has a min/max depth range spanning the whole scene, so it collects every light along its line of sight, near and far. Such depth discontinuities are everywhere in real scenes: foliage, fences, character silhouettes.
Slicing the view in depth too
Clustered shading (Ola Olsson, Markus Billeter and Ulf Assarsson, 2012) divides the view volume in depth as well: each screen tile's pyramid is cut into slices, giving a 3D grid of little boxes called clusters. Lights are listed per cluster, and each pixel finds its cluster from its screen position and its depth. A pixel on the near pillar and a pixel on the far floor, in the same tile, use different clusters and different lists.
The slices get thicker with distance, usually spaced exponentially (each slice a fixed factor deeper than the last), so that clusters stay roughly cube-shaped on screen, the same idea as the logarithmic shadow cascades of Chapter 20. Because the cluster grid doesn't depend on the depth buffer, it can be built before anything is drawn, which suits forward rendering, transparent objects and volumetric fog alike: anything at any depth can look up the lights that reach it.
// Which cluster is this pixel in? 16 x 9 tiles on screen, 24 slices from NEAR to FAR.
fn clusterIndex(pixel: vec2f, viewDepth: f32) -> u32 {
let tile = vec2u(pixel / u.screenSize * vec2f(16.0, 9.0));
let slice = u32(clamp(log(viewDepth / NEAR) / log(FAR / NEAR) * 24.0, 0.0, 23.0));
return (slice * 9u + tile.y) * 16u + tile.x;
}
// In the fragment shader: only the lights listed for this cluster.
let c = clusterIndex(pixel.xy, viewDepth);
for (var k = 0u; k < lightCounts[c]; k++) {
let light = lights[lightIndices[c * MAX_PER_CLUSTER + k]];
color += shade(light, position, normal);
}
Building the lists is a compute pass with one thread per cluster: compute the cluster's box in view space from its tile and slice, then test it against every light's sphere. With 3,456 clusters and 4,096 lights, that's 14 million box-sphere tests, done in well under a millisecond. Big renderers make it cheaper still by first sorting lights into a coarse grid or a bounding volume hierarchy (Chapter 14), and by only building lists for clusters that actually contain visible pixels.
The same lists work for deferred shading (clustered deferred), and modern renderers mix and match: a deferred pass for most surfaces, a clustered forward pass for transparent ones and special materials, all reading the same light lists.
Which lights get shadows?
Culling makes unshadowed lights cheap. Shadows are another matter: each shadow-casting light needs a shadow map (six for a point light), redrawn whenever anything near it moves. Renderers ration them. Shadow maps go into one large atlas texture, with each light given a region sized by how big it appears on screen; only the nearest and most important few dozen lights get shadows at all; static scenery's shadows are cached and reused. Small lights far away are shadowless, and nobody notices.
With ray tracing (Chapter 36), the picture changes: a shadow is just a ray toward the light, so the question becomes which lights to send rays to. That leads to the stochastic methods of Part 4.
One shadow ray per pixel, chosen well
A path tracer (Chapter 15) doesn't loop over lights at all: at each shading point it picks one light at random, sends one shadow ray, and divides the result by the probability of having picked that light. That's correct on average, but if it picks uniformly among 4,000 lights, nearly every pick is a distant light that barely matters, and the image is pure noise. The fix is to pick lights in proportion to how much they're likely to contribute: importance sampling, again.
- Light trees group nearby lights into a bounding volume hierarchy, where each node stores its lights' total power and extent. To pick a light for a shading point, walk down the tree, at each node choosing the child that looks brighter from here with higher probability (Alejandro Conty Estevez and Christopher Kulla, 2018).
- Resampled importance sampling (RIS) draws a handful of candidate lights cheaply, scores each by its unshadowed contribution, and keeps one with probability in proportion to its score. Only that one gets a shadow ray.
- ReSTIR (Benedikt Bitterli and colleagues, 2020) makes RIS spectacularly effective by reusing candidates: each pixel stores its current pick in a tiny reservoir (the chosen light, plus the running total of scores seen), and combines its reservoir with last frame's (via motion vectors, Chapter 31) and with its neighbors'. Every pixel effectively considers thousands of candidates while evaluating one shadow ray. Recent games use it for direct lighting from thousands of shadowed lights, and its descendants for indirect light too.
How do you pick one item from a stream, in proportion to each item's weight, without storing the stream? Keep one chosen item and the running total of weights. When a new item with weight w arrives, add w to the total, and replace the chosen item with the new one with probability w ÷ total. At the end, each item has been kept with probability exactly its weight ÷ the total. That's weighted reservoir sampling, and since two reservoirs can be merged the same way (treat the other reservoir's choice as one item carrying its whole total), pixels can pool their candidates cheaply.
The remaining noise is removed by a denoiser (Chapter 36) and by accumulating over frames (Chapter 31). The result: thousands of shadowed lights for a fixed cost per pixel, independent of how many lights there are.
Write the test
The heart of every culling scheme is a test between a light's sphere (here a circle) and a cell's box. This
playground counts lights per tile with a deliberately crude test: a circle "touches" a tile if the circle's
center is inside it. Rewrite touches to be correct, and watch the counts grow to the true values.
Then try a cheaper, looser test (treat the circle as a square) and see how many extra lights it lists.
What we have so far
- Lighting cost is pixels × lights; culling lists, for each region of the screen, only the lights that can reach it.
- Tiled shading (tiled deferred, Forward+) lists lights per screen tile, trimmed by the tile's depth range; depth discontinuities inflate the lists.
- Clustered shading slices each tile exponentially in depth; pixels look up their cluster by screen position and depth. It works for forward, deferred, transparency and volumes.
- Shadows for many lights are rationed in an atlas, or, with ray tracing, made stochastic.
- Light trees, RIS and ReSTIR pick one light per pixel in proportion to its contribution, reusing candidates across neighbors and frames with reservoirs.
Chapter 33 gathers the screen-space effects that run after the lights: ambient occlusion done properly, reflections, indirect light and contact shadows.
Try it yourself
- The correct test. Finish
touchesin the playground.Hint
const px = Math.max(x0, Math.min(cx, x1)), py = Math.max(y0, Math.min(cy, y1)); return (px - cx) ** 2 + (py - cy) ** 2 < r * r;The same clamp-then-measure test, in three dimensions, is what the cluster culling shader runs. - Lights per cluster. In the hero, set 4,096 lights and look at "lights per pixel". Where are the lists longest, and why?
Hint
Far away, where each cluster is large and many lights' spheres overlap it. Exponential slicing keeps far clusters bigger in the world but similar on screen, so their lists grow; a cap on list length (here 256) or a limit on light range keeps the worst case bounded.
- Merge two reservoirs. Pixel A's reservoir chose light 12 from a total weight of 3.0; pixel B's chose light 40 from a total of 1.0. What is the chance the merged reservoir keeps light 40?
Hint
Treat B's choice as one item of weight 1.0 joining A's total of 3.0: it replaces A's choice with probability 1.0 ÷ (3.0 + 1.0) = 25%. (Full ReSTIR also re-scores the candidate from the receiving pixel's point of view, since a light that's bright for B may be dim for A.)