Chapter 4 · shadow_mask.wgsl · shadow_mask_pass.rs
Two more dispatches
Key idea. The pass gains two dispatches: one compares each pixel's verdicts with its neighbours' and lists the edges, one traces the list. Both read the 4-byte word per pixel that the first dispatch writes, the pixel's own and its four neighbours'.
The shadow mask pass was one compute pass of two dispatches: build_shadow_mask, an invocation per pixel, and trace_second_surfaces, an indirect dispatch over the pixels with a second surface (Edge Shadows Explained). PR #394 adds two more to the same pass:
shadow mask pass, one compute pass
build_shadow_mask per pixel: rays from the nearest surface, as before,
and a word of directional verdicts per pixel
trace_second_surfaces indirect, per listed second surface, as before
list_shadow_edges per pixel: does a light both traced differ from a
4-neighbour's verdict? append the pixel
trace_shadow_edges indirect, per listed pixel: trace those lights again
from each other sample of the nearest surface
A word per pixel to compare
build_shadow_mask already knew each directional light's verdict. It now also writes them to directional_verdicts, a storage buffer of one u32 per pixel: the second surface's samples in bits 0 to 3, a hidden bit per light from bit 8, and a traced bit per light from bit 16, for the first seven lights.
directional_verdicts[invocation.y * u32(size.x) + invocation.x] = second_samples
| (hidden << VERDICT_HIDDEN_SHIFT)
| (nearest.traced_directional_lights << VERDICT_TRACED_SHIFT);
The prototype's detection read 24 bytes for each of five pixels and cost 2.6 to 3.0 ms of GPU frame. The build reads five 4-byte words instead. The PR gives no separate figure for its detection, only the whole change's.
The comparison cannot run in build_shadow_mask itself. A pixel's neighbour across a workgroup's border belongs to another workgroup, and WGSL's synchronization functions, its only barriers, have "a Workgroup execution scope": nothing inside one dispatch waits for another workgroup's writes. A later dispatch does see an earlier one's writes, which the pass already relies on when trace_second_surfaces reads the list build_shadow_mask wrote, so list_shadow_edges, a dispatch of its own, sees every neighbour's word.
fn shadow_edge_lights(pixel: vec2<i32>, own: u32, size: vec2<i32>) -> u32 {
if (own >> VERDICT_TRACED_SHIFT) == 0u {
return 0u;
}
let last = size - 1;
let width = size.x;
let left = directional_verdicts[pixel.y * width + max(pixel.x - 1, 0)];
let right = directional_verdicts[pixel.y * width + min(pixel.x + 1, last.x)];
let up = directional_verdicts[max(pixel.y - 1, 0) * width + pixel.x];
let down = directional_verdicts[min(pixel.y + 1, last.y) * width + pixel.x];
return differing_lights(own, left) | differing_lights(own, right)
| differing_lights(own, up) | differing_lights(own, down);
}
At the target's sides the index clamps, so the pixel stands in for the neighbour past it and no edge is found there.
A list, not a branch
Edge pixels are a minority laid along thin lines. Tracing their extra rays where they lie, behind an if, would make each SIMD group that holds one of them wait for it. Volume 24 measured that trap: tracing second surfaces in place cost town 4.1 ms, against 1.08 ms from a list. Boksansky and colleagues found the same for sparse shadow rays: "shooting a single ray for one pixel out of four in a close neighborhood yields similar performance as shooting rays for each of these pixels". Lauritzen's 2010 course gives the cure within each tile: "Pack a list of pixels that require per-sample shading". Volume 24 found that packing within each workgroup did not help on town, so the pass, as there, packs one list for the whole frame. Marrs and colleagues, compacting the pixels that their adaptive antialiasing traces into a one-dimensional buffer, report that "Compaction yields a 13% to 29% performance improvement", a vendor's figure on GeForce RTX 2080 and 2070 GPUs at 1080p.
So list_shadow_edges appends each edge pixel to shadow_edges with one atomic add, and the entry that opens each run of 64 grows the indirect dispatch's workgroup counts, exactly as the second-surface list does. The two lists share one WGSL struct, renamed from SecondSurfaces to PixelList: a count, then an entry per listed pixel, its column in the low 16 bits and its row above.
let own = directional_verdicts[pixel.y * size.x + pixel.x];
if shadow_edge_lights(pixel, own, size) == 0u {
return;
}
let entry = atomicAdd(&shadow_edges.count, 1u);
shadow_edges.entries[entry] = invocation.x | (invocation.y << 16u);
Tracing the list
trace_shadow_edges runs an invocation per entry. It works out the lights that differ again from the five words, loads the pixel's four depths, and, for each sample other than the nearest that is neither a second-surface sample nor background, rebuilds the surface that sample shows, from its own offset, depth and surface normals, and takes each differing light's verdict there with the same function the first dispatch uses.
for (var sample = 0u; sample < SAMPLE_COUNT; sample++) {
let depth = depths[sample];
if sample == nearest || ((second_samples >> sample) & 1u) == 1u || depth <= 0.0 {
continue;
}
let surface = sample_surface(pixel, sample, depth, vec2<f32>(size));
for (var index = 0u; index < edge_light_count; index++) {
if ((edge_lights >> index) & 1u) == 0u {
continue;
}
let bit = 1u << (SAMPLE_COUNT * index + sample);
sample_verdicts = select(sample_verdicts & ~bit, sample_verdicts | bit, directional_verdict(surface, index).hidden);
}
}
- Cleared first. Before the pass, the encoder clears the new dispatch buffer and the new list's count, beside the second-surface ones.
- A stand-in per indirect dispatch. An indirect dispatch may not read its workgroup counts from a buffer a bound group can write, so each indirect dispatch binds the 12-byte stand-in buffer in its own dispatch buffer's slot. The pass now builds three bind groups from one function: the per-pixel dispatches' group, and one for each indirect dispatch.
- No new binding in the lit pass. The per-sample verdicts go where the lit pass already looks, which chapter 5 describes.