Praxis · M3 · tickets #358, #364 · PR #394

Shadow Edge Rays Explained

Praxis traces its shadow rays in a compute pass and keeps, for each pixel, a bit per light in the shadow mask. Volume 24 gave that mask a second word for the far side of a silhouette. This volume follows the next fix: the edge of a shadow itself, where light turns to shadow across one surface. It covers why one ray per pixel cannot antialias it, the survey that priced the options, the rule that picks which pixels get more rays, the two extra dispatches that trace them, the bits that carry the answer to the lit pass, and what it costs and still misses. Two live demos show a patch of pixels under each scheme, and one pixel's bits as the lit pass reads them.

Provenance. The code is main at d32b3d37, PR #394, which closes ticket #358. The survey is docs/research/shadow-edge-antialiasing.md, from ticket #364 and PR #391. Measurements are those documents' own, taken on an Apple M4 Pro with wgpu on Metal at 2560×1440. Where the guide brings in published work, it names the source.

Chapter 1 · ticket #358

Stairs beside smooth edges

Key idea. Before PR #394, geometry edges resolved from four samples per pixel but shadow edges from one, so a shadow stepped by whole pixels right beside the smooth outline of the object casting it.

Ticket #353's visual audit looked at every Reference scene at 2560×1440 on the M4 Pro. Ticket #358 wrote down what it found in the shadows. On town, a tree's outline is smooth beside the stair-stepped edge of the shadow it casts. Thin shadows, such as a curb's sliver or a wire's, break into saw-teeth and dashes. In the overhead frames, 500 and 700, the shadows of roof props and railings become scattered single black pixels that would shimmer as the camera moves. crowd's shadow outlines on the ground show the same one-pixel stairs.

The shadows are meant to be hard: ADR 0020 traces hard shadows, with no penumbra. They were not meant to be aliased, which is not the image MSAA gives everything else. The ticket ruled two easy answers out from the start: accumulating over frames, since ADR 0008 keeps screen-space history out of the frame, and four rays at every pixel, about four times the pass's cost, without first measuring it.

beforecrowd, figures' shadows on the ground, 6xafter

Two dark blue figures beside black bars of shadow on grey ground, magnified 6 times. Left, before: the shadow bars' edges are hard stairs of whole pixels. Right, after: the stairs take intermediate grey steps.

beforeshadows, ground and contact edge, 8xafter

Grey ground where a light region, a mid-grey shadow and a black contact shadow meet, magnified 8 times. Left, before: each boundary steps by whole pixels. Right, after: the boundaries carry intermediate shades.

beforetown, frame 300, a wire's shadow, 6xafter

A thin dark diagonal line across a pale wall beside a dark block, magnified 6 times. Left, before: the line is a chain of hard pixel steps. Right, after: the steps soften into intermediate shades.
Before and after, crops from PR #394's gallery at 2560×1440: main at ec7e6e9b on the left of each crop, the PR's branch on the right. In the PR's difference images, pixels changed: shadows 766, crowd 8,895 with shadow proxies and 9,168 refitted, town 12,139 at frame 300 and 134,843 at frame 700, helmet 9,349, pbr-sweep 641, normal-tangent 1,731, and none in the five scenes that cast no sun shadow edge. The PR found no new artifacts: no dark rims, no lit seams, and nothing new where only a point light's shadow falls.
Chapter 2 · docs/research/shadow-edge-antialiasing.md

A ray is a point sample

Key idea. Only more visibility samples recover an edge inside one surface. Filtering a one-sample answer can blur or straighten it, but cannot restore what no sample saw.

The shadow mask pass traces one ray toward each light from the surface a pixel's nearest sample shows. That ray answers yes or no for one point. Every other point of the pixel takes the same answer.

Shadow of the Tomb Raider's authors put it in one line at GTC 2019: "We typically shoot only one ray per pixel", "Which is equivalent to 'point sampling' of the visibility signal". A shadow edge that crosses a pixel therefore lands on whichever side of the edge the ray's start point lies. Along the edge, the answer flips from one pixel to the next in whole steps: a staircase.

4x MSAA smooths geometry edges because the rasteriser tests coverage at four sample points per pixel, and the resolve averages them (MSAA Fragment Merging Explained). Volume 24 kept that for silhouettes by giving each surface of a pixel its own shadow word (Edge Shadows Explained). Inside one surface, though, there is one word, from one ray. Aila and Laine wrote the rule for shadow maps in 2004, and it holds for rays: "When supersampling or multisampling is used for antialiasing, the shadow term needs to be determined separately for each sample."

The survey's options

Ticket #364 surveyed the published ways to antialias hard ray-traced shadows and prototyped the ray-based ones on town. It found coverage-aware storage, like volume 24's, fixes only silhouettes; edge-stopping filters blur or keep the stairs; morphological filters such as MLAA and SMAA rebuild a line along long edges but, as the SMAA paper says of any one-sample input, cannot recover subpixel features. More samples were the only way to get the edge's true coverage. The question was where to spend them.

OptionAt a shadow edge on one surfaceGPU frame on town
A. One ray per pixel, as beforeSteps by whole pixels; thin shadows break into dashesno cost
B. Edge-adaptive rays, directional lightsFour levels where a sun verdict differs from a neighbour's; point-light edges keep their steps+6.1 ms
C. Edge-adaptive rays, every lightFour levels at every edge some pixel's ray saw+11.2 ms
D. A ray per sample at every pixelFour levels everywhere; a thin shadow seen wherever any sample meets it+26.3 ms
E. Morphological filter of the maskA rebuilt line along long, straight edges; thin shadows staynot measured

From the survey's Options, its prototype's medians on town's 1,800-frame path at 2560×1440, against interleaved runs without it. The prototype, on branch prototype/364-shadow-edge-aa, was never merged.

The owner chose B on 2026-10-30, with a ceiling: the build may add at most 4 ms to town's GPU frame median against its Baseline, or fall back to A. B refines the sun's edges, the survey's examples being the crowd's figures, trees and the street, for about half of C's cost. The prototype's +6.1 ms was over the ceiling, but 2.6 to 3.0 ms of it went to detecting edges, which a leaner layout was expected to cut. ADR 0020's sentence "one hard shadow ray per pixel per light" gained a clause, word for word from the decision: "and, where a directional light's verdict differs from a neighbour's, one from each other sample that shows the same surface."

Chapter 3 · list_shadow_edges · differing_lights

Rays where verdicts differ

Key idea. A shadow edge runs between pixels whose rays disagree, so comparing each pixel's verdict with its four neighbours' finds almost every edge some ray saw, at the cost of a few reads per pixel.

The idea is old. Turner Whitted's 1980 ray tracer looked for the places aliasing shows, "regions of abrupt change in intensity" and "locations where small objects fall between sampling points and disappear", and filtered only there: "If the intensity values are not nearly equal […], the algorithm subdivides the sample square and starts over again." Shadow of the Tomb Raider shipped the same split for ray-traced shadows in its ultra DXR mode: "Phase 1 – Cast 1 ray per pixel Phase 2 – Cast up to 2 more rays 'where necessary'". Its slides give neither the exact rule nor a cost.

Praxis's rule

A pixel is a shadow edge for a directional light when its verdict for that light differs from the verdict of its left, right, upper or lower neighbour. Three details shape it:

  • Only lights both pixels traced. A surface whose interpolated normal turns from a light is hidden from it with no ray at all. Its verdict flips at the terminator with nothing to antialias, since, as the shader says, "its shading already fades the light out at its terminator". So the comparison masks out any light either pixel did not trace a ray toward.
  • No Froxel check. The decision said to compare only within one Froxel, because the prototype compared every light and a point light's bit position follows its Froxel's list. A directional light's bit sits at the same index at every pixel, so the build compares directional lights across Froxel borders, where a check would only have left the edges stepped.
  • The first seven directional lights. Their extra verdicts must fit the bits chapter 5 describes. A directional light past the seventh keeps one ray per pixel. Every Reference scene has one sun.

A listed pixel then traces each light that differs again, from each of its other samples that shows the nearest surface: at most three more rays per light. The nearest sample's own verdict stands.

// shadow_mask.wgsl at d32b3d37.
// The lights of `own`'s verdicts that differ from `neighbour`'s, among those
// both traced a ray toward […]
fn differing_lights(own: u32, neighbour: u32) -> u32 {
    return ((own ^ neighbour) >> VERDICT_HIDDEN_SHIFT)
        & (own >> VERDICT_TRACED_SHIFT)
        & (neighbour >> VERDICT_TRACED_SHIFT)
        & EDGE_LIGHTS;
}

What the rule cannot see

The rule compares the rays it has. A shadow that none of them hit leaves no disagreement to find. Whitted named this miss in his own paper: small objects fall between samples, and his fix, a minimum radius for bounding spheres, "will not always work for rays being reflected from curved surfaces". The demo below shows the two ways it happens on one surface. A thin shadow, narrower than the spacing of the rays, is refined where it crosses some pixel's ray and stays a gap between. A shadow's corner can clip one pixel's samples while that pixel's ray and its four neighbours' rays all stay lit; only the diagonal neighbour is shadowed, and the rule does not look diagonally. PR #394's review names a case of it, an edge through a pixel's corner with all four neighbours on one side, and leaves it as the miss the survey cites from Whitted.

lit in shadow a sample a ray was traced from a sample that copies the nearest sample's verdict a listed pixel differs from the reference drag handle
Rays
Shadow
Rays traced
Pixels listed
Pixels that differ from the reference
Samples' verdicts off, summed

Live demo. A patch of 16 × 10 pixels of flat ground under one sun, each pixel's four samples at the standard 4x offsets that SAMPLE_OFFSETS holds. Drag on the patch, or use the sliders, to move the shadow. Each pixel shows the share of its samples in shadow. One per pixel: a ray from each pixel's nearest sample, whose answer every sample copies. Edge-adaptive: the same, then each pixel whose ray disagrees with a 4-neighbour's is listed and traced again from its other three samples. One per sample: option D, the reference. Simplified: the ground comes nearer toward the bottom of the view, so each pixel's nearest sample is its lowest, sample 3, and every ray starts there; the ground faces the sun, so every pixel traces it; one light, one surface. Run in Node over the sliders' range at 5° and 0.25 px steps, 59,829 placements of each shape: a straight edge never left a partly shadowed pixel unlisted, since with the same nearest sample in every pixel a line that cuts a pixel's samples also separates its ray from a 4-neighbour's; a corner did in 3,648 placements, 6.1%.
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.

// shadow_mask.wgsl at d32b3d37, build_shadow_mask.
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.

// shadow_mask.wgsl at d32b3d37.
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.

// shadow_mask.wgsl at d32b3d37, list_shadow_edges.
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.

// shadow_mask.wgsl at d32b3d37, trace_shadow_edges.
for (var sample = 0u; sample < SAMPLE_COUNT; sample++) {
    let depth = depths[sample];
    // The nearest sample's verdict stands; another surface's samples
    // and the background's keep theirs.
    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.
Chapter 5 · targets.rs · main.wgsl

Four bits a light

Key idea. The per-sample verdicts ride in the spare bits of a texel the lit pass already loads, and each fragment shades a light by the share of its own covered samples that the light reaches.

Since volume 24, shadow_mask_samples, an R32Uint texture with a texel per pixel, marked which of the pixel's four samples show the second surface. That used 4 of its 32 bits. PR #394 fills the other 28: four bits per directional light, one per sample, for seven lights.

bit 0-3 the samples that show the second surface bit 4 + 4·l + s light l's verdict at sample s, for l = 0..6 4 + 7 × 4 = 32 bits, asserted in Rust

build_shadow_mask writes each light's nearest verdict into all four of its bits, so a pixel that is never listed reads the same as before: all hidden or all lit. trace_shadow_edges overwrites the bits of the samples it traced. The texel holds, at every pixel, the verdicts the lit pass should use.

The share of covered samples

A fragment knows which samples its triangle covers: WGSL's sample_mask input. The lit pass decides from it which surface the fragment shades, as volume 24 did, and, for the nearest surface, counts how many of its covered samples the light is hidden from, with countOneBits, WGSL's population count:

// main.wgsl at d32b3d37.
fn directional_hidden_share(
    mask: vec2<u32>,
    sample_verdicts: u32,
    nearest_surface: bool,
    covered_samples: u32,
    index: u32
) -> f32 {
    let hidden = (sample_verdicts >> (SAMPLE_COUNT * index)) & covered_samples;
    let share = f32(countOneBits(hidden)) / f32(max(countOneBits(covered_samples), 1u));
    return select(f32(masked(mask, index)), share, nearest_surface && index < SHADOW_EDGE_LIGHTS);
}

// fs_main: the light's illuminance, scaled by the share that sees it.
let first_illuminance = first.intensity * f32(has_directional_light)
    * (1.0 - directional_hidden_share(mask, sample_verdicts, nearest_surface, covered, 0u));

Why over the covered samples, and not all four? When two triangles split a pixel, the lit pass runs a fragment for each, and each writes its colour only into the samples it covers. If each scaled its light by the whole pixel's share, a triangle lying wholly in light would darken its own samples by the shadow on the other triangle's. Counting its own samples gives each fragment the answer MSAA would give: the resolve then averages samples that are each right. The demo below lets you compare the two.

A fragment of the second surface still reads that surface's word from the mask's second layer, one bit per light, so its shadow edges stay one ray per pixel. A directional light past the seventh, and every point light, read their single bit as before.

The test at the seam

a_pixel_split_by_a_suns_shadow_edge_resolves_between_shadow_and_light, in src/render/renderer.rs, places a rectangle behind the camera so the right edge of the sun's shadow on the floor crosses one pixel's middle. Its two left samples, 0 and 2, lie in the shadow, and its two right ones, 1 and 3, in light. The pixel's red channel must lie between its shadowed left neighbour's and its lit right neighbour's, more than 16 steps from each. On main before the PR it failed: "the edge pixel 38 is not between the shadowed 38 and the lit 158". With the change it passes.

Pixel
Share over
SampleShowsSun

The pixel's shadow_mask_samples texel, bit 31 on the left:

Live demo. One pixel's four samples at the standard 4x offsets. Each sample shows triangle A or B of the nearest surface, or C, a farther surface behind a silhouette, which makes it a second-surface sample. A's shading lets through 0.9 of the sun, B's 0.45 and C's 0.6, as three different triangles would. Set each sample's true verdict with its Sun toggle; the reference shades every sample by its own. Listed: the texel holds each nearest-surface sample's own verdict, as after trace_shadow_edges. Not listed: every bit copies the nearest sample's, here sample 3, as after build_shadow_mask alone. Simplified: sample 3 is always the nearest, so it cannot show C; C's word in layer 1 is traced from its lowest-numbered sample, where the pass picks its farthest; only the first light's four bits are live, and the other six lights' stay clear.
Chapter 6 · PR #394's metrics

What it costs

Key idea. town's GPU frame rose 2.86 ms, inside the 4 ms ceiling and less than half the prototype's 6.1 ms, for 29.5 MB at 2560×1440 and a 7.5% fall in measured aliasing.

Prototype, sun-edges mode+6.1 ms
PR #394, three interleaved A/B pairs+2.65 to +3.07 ms
PR #394, against the Baseline+2.86 ms
0 ms268 ms4 ms ceiling
Added GPU frame on town, M4 Pro, 2560×1440, 1,800 frames. The A/B pairs alternated base and branch binaries three times: base 17.61, 17.67 and 17.88 ms, branch 20.53, 20.74 and 20.53 ms. ADR 0020's amendment records 2.7 to 3.1 ms.

town on the M4 Pro at 2560×1440 against main's Baselines, from praxis report, in µs:

TimeMedianBaselineΔRatio
GPU frame20,489.117,633.8+2,855.41.162
Gate, rays off8,686.58,678.6+7.91.001
Shadow-ray time11,271.88,556.4+2,715.31.317
shadow_mask_pass12,461.09,601.5+2,859.41.298
lit_pass16,342.513,487.8+2,854.71.212
tonemapping_pass6,533.64,059.3+2,474.21.610
CPU frame8,223.86,304.7+1,919.21.304

From PR #394's Metrics, rows trimmed, p95 and Budget columns left out. town's GPU frame Budget is 12,000 µs, which its Baseline already exceeded.

Reading the rows

  • The GPU frame is the cost. The lit pass gained no load or binding, only a few bit counts per directional light, and the tonemapping pass nothing, but Metal overlaps their starts with the shadow mask pass, so their timestamps take in its growth (Frame Timing Explained; volume 24 met the same overlap). The GPU frame, a span, counts it once.
  • The gate did not move. ADR 0019's gate is timed with shadow rays off, when the shadow mask pass does not run: +7.9 µs is noise.
  • The CPU frame rose though the CPU does little more. The change adds two dispatches and two buffer clears to the CPU's recording. The PR attributes the rise to ticket #381: every CPU stage slows by 20 to 40% once the main thread waits on the GPU. The interleaved runs agree: base 6.96, 6.97 and 7.69 ms, branch 8.15, 7.89 and 8.50 ms.

Memory and counters

  • 29,491,216 bytes at 2560×1440. The directional verdicts, 4 bytes per pixel, 14,745,600; the shadow edge list, a count plus 4 bytes per pixel, since every pixel could be listed, 14,745,604; and a 12-byte dispatch buffer. The bench's gpu_memory_bytes rose by exactly that sum. The texel's verdict bits cost nothing: they fill bits that were already allocated.
  • Heap allocations: +13, 10,501 against 10,488.
  • Aliasing RMSE −7.5%. The bench compares the Golden's frame, at the Goldens' 640×360, with the same frame rendered at three times the size and box-filtered in linear light. aliasing_rmse fell from 5,579 to 5,161, a ratio of 0.925, and aliasing_p99 from 18,210 to 15,999, 0.879.

How many pixels get listed depends on the view. The prototype counted the sun's alone in captures of town: 33,818 at frame 300, at street level, about 0.9% of the frame; 359,015 at frame 700, overhead among roof props, about 9.7%. The build's rule drops the prototype's Froxel check, and the PR does not report its counts.

Chapter 7 · PR #394 · the survey

What stays aliased

Key idea. The pass refines edges that some pixel's ray saw, on the nearest surface, for the first seven directional lights. Everything outside that keeps one ray per pixel.

  • Shadows no pixel's ray hit. A shadow narrower than the spacing of the rays is refined where it crosses one, and missing between. The roof-prop speckle at town's frame 700 stays as it was, as the survey predicted. Against a ray per sample at every pixel, the prototype's mode that traced every light again at each listed pixel still differed at 106,242 pixels of frame 700, 2.9%, the thin shadows of roof props and railings, and at 6,771 of frame 300.
  • A corner with all four neighbours on one side. The miss chapter 3's demo places: a shadow's corner clips a pixel's samples, but its ray and its four neighbours' rays agree. PR #394's review names the case, an edge through a pixel's corner with all four neighbours on one side, and leaves it as the miss the survey cites from Whitted.
  • Point lights. Their edges keep one ray per pixel. Option C would refine them, at +11.2 ms in the prototype.
  • Directional lights past the seventh. The texel has no bits left for them. Every Reference scene has one sun.
  • The second surface. A fragment of the farther surface at a silhouette reads its one bit per light from the mask's second layer, and is never traced again.
  • No look at surfaces. The comparison reads only verdict words, not whether the neighbour shows the same surface. Where a silhouette separates a lit surface from a shadowed one, the pixel is listed and its samples on the nearest surface are traced again, which costs rays but gives the right answer.

beforetown, frame 700, roofs, 4xafter

Rooftops crowded with props under a blue sky, magnified 4 times. Left, before, and right, after: the props' tiny shadows appear as scattered dark pixels in both, nearly unchanged.
The miss, from PR #394's gallery: main at ec7e6e9b on the left, the PR's branch on the right. The shadows of the roof props are narrower than a pixel. Where no pixel's ray hit one, no neighbour disagrees, nothing is listed, and the speckle stays.
Review

Check yourself

A pixel's ray toward the sun is lit, and so are the rays of its left, right, upper and lower neighbours, yet two of its samples lie in shadow. Is it listed, and what does it shade?

No. list_shadow_edges compares the pixel's verdict only with its 4-neighbours', and all five agree, so nothing differs. Its texel keeps the nearest sample's verdict at all four samples, and every fragment shades the sun as fully lit. This happens where a shadow's corner clips the pixel and only the diagonal neighbour lies in shadow, or where a thin shadow passes between the rays: what no ray saw stays unseen.

A listed pixel's texel is 0x00000050, and two triangles of one surface split it, A covering samples 0 and 2, B covering 1 and 3. What sun share does each fragment shade, and why would dividing by all four samples be wrong?

Bits 0 to 3 are clear, so no sample shows a second surface and both fragments shade the nearest. The sun's four bits, 4 to 7, are 0b0101: samples 0 and 2 hidden. A's share is popcount(0101 & 0101) / popcount(0101) = 2/2, fully hidden; B's is popcount(0101 & 1010) / 2 = 0, fully lit. Over all four, each would take 2/4, and A would light its shadowed samples by half while B darkened its lit ones; with A's and B's shading different, the resolve would land off the true value.

The shadow mask pass grew by 2.86 ms and so did the lit pass. Is the change's cost 5.7 ms?

No. The lit pass gains almost no work: it already loaded the texel that carries the verdicts, and adds only a few bit counts per directional light. Metal overlaps the lit pass's start with the shadow mask pass, so the lit pass's own timestamps take in the time it waits. The GPU frame is a span that counts that time once, and it rose 2.86 ms, which the interleaved A/B runs bracket at 2.65 to 3.07 ms.

Reference

Reading list

Grouped by source, each with what it is good for here. Code links are pinned to d32b3d37.

This ticket

In the repo

The code, at d32b3d37

  • shadow_mask.wgsl build_shadow_mask, differing_lights, shadow_edge_lights, list_shadow_edges, trace_shadow_edges, PixelList
  • main.wgsl directional_hidden_share and fs_main's resolve over covered samples
  • shadow_mask_pass.rs the four dispatches, the clears, the stand-in, SHADOW_EDGE_LIGHTS and SAMPLE_OFFSETS
  • targets.rs the directional verdicts, the shadow edge list, the R32Uint samples texel
  • renderer.rs the seam test, a_pixel_split_by_a_suns_shadow_edge_resolves_between_shadow_and_light

Earlier volumes

Papers and references

Books

  • Akenine-Möller, Haines, Hoffman et al., Real-Time Rendering, 4th ed., §5.4 Aliasing and Antialiasing, pp. 130–147 sampling, supersampling and MSAA's coverage and shading rates. Section and pages from the book's contents.
  • The same book, chapter 7, Shadows hard and soft shadows, and the shadow-map families ADR 0020 set aside
Reference

Glossary

Shadow mask, Surface normals and Froxel are CONTEXT.md's definitions, the first shortened; so are the tooltips for Golden, Baseline and Reference scene. The rest are this guide's own.

Shadow mask
A bit per light at each pixel, set where that light is hidden from the surface the pixel's nearest sample shows. Where a directional light's bit at the nearest surface differs from a 4-neighbour's, a shadow edge, or where the nearest sample turns from that light while another of the surface's samples faces it, that light is traced again from each other sample that shows the surface, and the fragment shades it by the share of its samples that see it.
Verdict
Whether a light is hidden from a surface, and whether a ray found it so; a surface turned from the light is hidden with no ray.
Shadow edge
A pixel whose verdict for one of the first seven directional lights, among the lights both traced, differs from a 4-neighbour's.
Point sample
One answer taken at one point and used for the whole pixel, as one shadow ray per pixel is.
Edge-adaptive sampling
Tracing more samples only where neighbouring samples disagree, as Whitted did in 1980.
Hidden share
The fraction of a fragment's covered samples from which a light is hidden; the lit pass scales the light by one minus it.
Sample mask
A fragment-stage input: a bit for each of the pixel's samples that the fragment's triangle covers.
SIMD group
Invocations a GPU core runs in lockstep, one instruction for all; 32 on Apple's GPUs, as reverse-engineered on the M1 and assumed for the M4 Pro.
Indirect dispatch
A compute dispatch whose workgroup counts come from a GPU buffer, so earlier GPU work can size it.
Surface normals
Two normals per sample of the depth target, its triangle's own and the one interpolated over the triangle, which the depth passes write with shadow rays on and the shadow mask pass reads. Where they disagree near a light's terminator, the triangle can turn from a light the interpolated normal faces.
Froxel
A cell of the view frustum, bounded in screen space and depth, that holds the list of lights reaching it.
Built from tickets #358 and #364, PR #394 at d32b3d37, the survey, ADR 0020, CONTEXT.md and the sources in the reading list. Every measurement is the PR's or the survey's own, on one Apple M4 Pro. Both demos are this page's illustrative models.