Praxis · M3 · tickets #203, #205 and #260 · PRs #259, #258, #265 and #277

How a pixel asks whether the sun reaches it

Ticket #203 gave Praxis its first shadows. Wherever the sun lights a pixel, Praxis now traces one ray from the pixel's surface point toward the sun, against the static TLAS, and the lit pass leaves the sun's light out if the ray hits anything. The idea fits in a sentence. The work was in where the ray starts: in which frame, how far off the surface, and from which point of a pixel on an edge. This guide follows one ray through those choices, with live demos of shadow acne, the shadow terminator, an MSAA edge and the test that checks the shadows. Chapter 9 follows ticket #205, which gives every point light the same ray, one that has to end at the light, and chapter 10 ticket #260, which moves every ray out of the lit pass into a compute pass of its own.

Provenance. #203 lands in two parts. Part 1, PR #259, adds the shadows Reference scene; part 2, PR #258, traces the sun's shadow rays over it. #259 merged on 2026-10-22 as c4454af, and #258 on 2026-10-23 as 0db5e46. Every figure quoted from the work was verified during the work on #203, and the code is quoted from #258's merge, 0db5e46. Chapter 9 adds ticket #205, whose PR #265 merged on 2026-10-23 as 5a5f075; its figures were verified during the work on #205, and its code is quoted from that commit. Chapter 10 adds ticket #260, whose PR #277 merged on 2026-10-23 as bd7b988: its code is quoted from that commit, unchanged on main at 69e02dc, and its figures are PR #277's. #260 moves the rays out of the lit pass, so chapters 1 to 9 show them as #203 and #205 traced them, inline, and say where #260 changes that. The sun's rays' cost is in chapter 8, the point lights' in chapter 9, the shadow mask's in chapter 10.

Chapter 1 · ADR 0020

One hard ray per pixel

A point is in the sun's shadow when something stands between it and the sun. Praxis asks that question directly. From the surface point a pixel shows, Praxis traces one ray toward the sun; if the ray hits anything, the pixel gets none of the sun's light. Its ambient term and its point lights still reach it.

ADR 0020 settled this shape for all direct light: one hard shadow ray per pixel per light. Direct light is Temporal class (a), complete in every frame: a shadow uses no earlier frame, so it cannot lag behind a moving sun or a moving object. The ADR supersedes ADR 0008's lighting clause and drops cascaded shadow maps, and ray queries joined the Capability floor (ADR 0021), so every adapter Praxis starts on can trace.

Two pixels' shadow rays: pixel 1's leaves the scene toward the sun, pixel 2's hits a box. sunlight camera 1 2
The question each pixel asks. Pixels 1 and 2 see two points of the ground. From each, one ray is traced toward the sun, against the light's direction. Pixel 1's ray leaves the scene, so the sun lights its point. Pixel 2's ray hits the box, so its point keeps only the ambient term and the point lights. The sun's light arrives along one direction everywhere, so one ray per pixel settles the sun for that pixel.

Why hard, for now

A ray toward one direction treats the sun as a point, so each point is lit or not and the shadow's edge is sharp. The real sun covers 0.53° of sky, which gives a penumbra of about 0.9 cm per metre between an occluder and its shadow, so contact shadows stay sharp either way. Soft sun shadows were considered and left out. Jittered rays without a denoiser need about 4 rays per pixel: by this guide's arithmetic, 7 to 8 ms on the M4 Pro's open scene, where one ray measured 1.8 to 2.0 ms. Real-time denoisers commonly reuse earlier frames, which is class (c), and ADR 0020 uses class (c) nowhere. Sun penumbrae reopen when area lights arrive or a Golden shows hard sun edges at distance.

What a ray can hit

The rays run against the static TLAS that #202 built: every static Instance's compacted BLAS, tagged with its slot. Acceleration Structures Explained covers how it is built. Two things are not in it yet. Point lights cast no shadow until #205. Moving Instances, such as the orrery's planets, enter no TLAS until #204, so they cast no shadow either.

Only where the light adds something

The milestone's rule, item 4 of its decision on map #17, is that every light reaching a pixel traces one ray there, skipped only where the light adds nothing: where N·L ≤ 0, so the surface faces away from the light, or beyond the light's range. The sun has no range, so its ray is skipped only where N·L ≤ 0. Chapter 5 shows why that rule mattered.

Chapter 2 · PR #258

A ray query in a fragment shader

A ray query lets an ordinary shader trace a ray: it starts a query, lets the GPU walk the acceleration structure, and reads back what the ray met. The lit pass's fragment shader does that inline, in the pass that shades the pixel, so the shadow needs no pass of its own. The whole query is three calls.

Since #260. The lit pass traces no ray. shadowed now lives in src/render/shaders/shadow_mask.wgsl, where a compute pass calls it for each pixel and light and keeps the answers in a shadow mask that the lit pass reads (chapter 10). This chapter shows the query as #203 merged it, at 0db5e46; since #204 it also queries the dynamic TLAS (chapter 9).

wgpu 30.0.1 exposes no ray-tracing pipeline, the kind with hit shaders, through its API; only its Vulkan backend can build one (the GI survey, docs/research/global-illumination-and-lighting.md). Ray queries are what the Capability floor requires, as EXPERIMENTAL_RAY_QUERY. The shadow is the function shadowed in src/render/shaders/main.wgsl, and its last lines are the query:

var query: ray_query;
// Opaque, so one rayQueryProceed completes the traversal, and any hit
// shadows the point.
rayQueryInitialize(
    &query,
    static_tlas,
    RayDesc(
        RAY_FLAG_FORCE_OPAQUE | RAY_FLAG_TERMINATE_ON_FIRST_HIT,
        0xffu,
        0.0,
        distance,
        tlas_position + normal * offset,
        direction
    )
);
rayQueryProceed(&query);
return rayQueryGetCommittedIntersection(&query).kind != RAY_QUERY_INTERSECTION_NONE;
The ray query, from shadowed in src/render/shaders/main.wgsl at 0db5e46. RayDesc's fields are, in order, flags, cull_mask, tmin, tmax, origin and dir.
FieldValueWhy
flagsFORCE_OPAQUE | TERMINATE_ON_FIRST_HITevery hit counts, and the first one found ends the search: any hit shadows the point
cull_mask0xffevery TLAS entry's mask is 0xff, so the ray meets them all
tmin0.0the origin's offset keeps the ray off its own surface (chapter 4)
tmax3.40282347e38, f32's largestthe sun is at infinity: UNBOUNDED_RAY_DISTANCE
originthe point in the TLAS's frame, moved off the surfacechapters 3, 4 and 6
dirthe unit vector toward the sunthe same direction the shading uses
The ray, field by field.

One call to Proceed

A ray query finds hits as candidates. An opaque one is committed as soon as it is found; any other waits for the shader to confirm it, which is how alpha-tested geometry is traced, and why a general query calls rayQueryProceed in a loop. Here nothing needs confirming: every BLAS is built OPAQUE (src/assets/pools.rs), and the ray forces opacity besides. Terminate-on-first-hit stops the search at the first hit, which is all a shadow needs: any hit will do, not only the nearest. So one rayQueryProceed completes the traversal, and the committed intersection's kind gives the answer.

Declaring it in WGSL

naga accepts the types acceleration_structure and ray_query only after an enable directive, here enable wgpu_ray_query; (naga 30.0.1 also takes enable wgpu_ray_tracing_pipeline;, the extension for ray-tracing pipelines). Praxis's shader assembler writes that directive at the top of each module that lists the extension (src/render/shaders/mod.rs). The TLAS is one binding of the scene-geometry bind group, group 2, declared from its definition as static_tlas: acceleration_structure and visible to the fragment stage only (src/render/bind_groups.rs).

On Metal

naga's MSL backend turns the flags into Metal's intersection_params: terminate-on-first-hit becomes accept_any_intersection(true), and the forced opacity force_opacity(opaque) (naga's back/msl/ray.rs). Metal ray queries need MSL 2.4. wgpu-hal offers ray queries only on a Metal device that reports both supportsRaytracing and supportsRaytracingFromRender (metal/adapter.rs), so any adapter that meets the Capability floor can trace from a fragment shader. The TLAS refers to its BLASes without binding them, so each command buffer that uses a TLAS gets the BLASes it references made resident when it is submitted: wgpu-core's set_acceleration_structure_dependencies, which on Metal is a new residency set per command buffer (metal/command.rs).

Inside a branch

The ray is traced only where the sun adds light, so it sits inside an if whose condition differs from pixel to pixel:

// Each directional light's shadow ray is traced only where the light
// adds something.
if SHADOW_RAYS && has_directional_light && dot(normal, first_direction) > 0.0
    && shadowed(
        in.centroid_position,
        geometric_normal,
        first_direction,
        UNBOUNDED_RAY_DISTANCE
    ) {
    first_illuminance = vec3<f32>(0.0);
}
Where the ray is traced, in fs_main at 0db5e46, for the first directional light, the sun: only with the Render switch on (chapter 7), a directional light present, and the shading normal facing it. A shadowed point loses the sun's illuminance; the point lights and the ambient term are added after. Chapter 4 explains geometric_normal, and chapter 6 centroid_position. Since #260, fs_main reads the sun's bit from the shadow mask instead.

That is non-uniform control flow, which is fine for a ray query. It is not fine for derivatives, and so not for textureSample, which uses them to pick a mip: fs_main samples every Texture first, before any branch. WGSL's && evaluates its right side only when its left side is true, so shadowed runs only where the tests before it pass.

Chapter 3 · ADR 0014

Where the ray starts

The TLAS holds its Instances in f32, placed in a frame of its own, and a ray must start in that frame. The shader that traces it knows each point only relative to the camera. So the shader moves the point into the TLAS's frame with the same careful arithmetic the vertex shader uses, which keeps shadows as precise 100 km from the World's origin as next to it.

ADR 0014 keeps World positions in f64, because f32 positions miss its precision bar beyond 512 m from their origin. The GPU never holds a World position in f32: it gets an Anchor, the integer coordinates of a 64 m grid cell, plus an f32 offset from the cell's corner, and shaders compose positions relative to the camera.

The static TLAS has a frame of its own. Its origin is the corner of the camera's Anchor in the first Frame data, and it stays there for the scene: M3 never re-anchors it, and its scenes stay within about 2 km of it, where an f32 step is at most 0.24 mm. Each Instance enters the TLAS with its translation from that corner composed in f64 and rounded to f32 once (anchored_transform in src/render/acceleration_structures.rs).

The ray's origin must be in that frame too. With p the point's position relative to the camera:

origin = (camera Anchor − TLAS Anchor) × 64 m + camera offset + p

The shader already has a function of that shape. camera_relative_position (src/render/shaders/common.wgsl) composes an Instance's position relative to the camera for the vertex shader, and it is under the 100 km precision oracle of #119:

// A point's position relative to the camera, from its Instance's anchor and
// offset, the camera's, and its position relative to the Instance's pivot
// (ADR 0014). The anchor difference is exact in integers, so f32 only ever
// holds magnitudes near the camera.
fn camera_relative_position(
    anchor: vec3<i32>,
    offset: vec3<f32>,
    camera_anchor: vec3<i32>,
    camera_offset: vec3<f32>,
    local_position: vec3<f32>
) -> vec3<f32> {
    let anchor_position = vec3<f32>(anchor - camera_anchor) * ANCHOR_CELL_SIZE;
    return anchor_position + (offset - camera_offset) + local_position;
}

// In shadowed: the camera stands where an Instance does in this composition,
// and the TLAS's frame, with no offset from its Anchor, where the camera does.
let tlas_position = camera_relative_position(
    camera.anchor,
    camera.offset,
    camera.tlas_anchor,
    vec3<f32>(0.0),
    position
);
One function, two frames. The vertex shader passes an Instance's Anchor and offset first and the camera's second. shadowed passes the camera's first and the TLAS's second, with a zero offset: the camera takes the Instance's place, and the TLAS's frame takes the camera's. From common.wgsl and main.wgsl at 0db5e46, and since #260 in shadow_mask.wgsl, unchanged.

The shader needs the TLAS's Anchor for that, so the camera uniform now carries it as tlas_anchor, which the Renderer fills when it uploads the camera. The uniform grew from 96 to 112 bytes: 16 more bytes uploaded each frame.

The 100 km check

Every Golden scene is also captured shifted by 1563 whole cells, 100,032 m along X and Z, and must still match its Golden (tests/goldens.rs). The TLAS's Anchor is the camera's in the first Frame data, so it shifts with the camera's, and the composition sees the same numbers at both places. By this guide's arithmetic, for the shadows scene's camera:

At the originShifted 100,032 m
Camera World position, m(0, 2.4, 3.0)(100,032, 2.4, 100,035)
Camera Anchor(0, 0, 0)(1563, 0, 1563)
Camera offset, m(0, 2.4, 3.0)(0, 2.4, 3.0)
TLAS Anchor(0, 0, 0)(1563, 0, 1563)
(camera Anchor − TLAS Anchor) × 64 m(0, 0, 0)(0, 0, 0)
Same inputs, same f32. Every term of the composition is the same in both columns, and so are the points' positions relative to the camera, so ray origins come out bit-identical, and so do the images.

Starting the ray in the wrong frame fails at once. Taken as the origin as it is, the position relative to the camera puts every ray off by the camera's position in the TLAS's frame, (0, 2.4, 3.0) m in the shadows scene. Chapter 7's test, run that way, found 176,558 judged pixels wrong.

Chapter 4 · PR #258

Shadow acne and the normal offset

A ray that starts exactly on its own surface can hit that surface. The point is rounded on its way to becoming the ray's origin, so the origin can sit a hair below the triangle it came from, and the ray then meets that triangle from behind. The result is shadow acne, dark speckles over lit surfaces. The cure is to start the ray a little way off the surface, along its normal.

Three roundings stand between a surface point and its ray: the rasteriser interpolates the point's position in f32, the shader composes it into the TLAS's frame (chapter 3), and the GPU intersects the ray with the BLAS's triangles. Each rounds differently. f32 keeps 24 significant bits, so each rounding moves a coordinate by at most 2−24 of its magnitude. That is tiny, and it is enough: an origin below the surface by any amount sends the ray into it.

Praxis moves the origin off the surface by a normal offset that grows with the coordinates, along the normal interpolated from the triangle's vertices (geometric_normal, before the normal map bends it):

const SHADOW_RAY_OFFSET_SCALE: f32 = 1.52587890625e-5; // 2^-16

let extent = max(abs(tlas_position), abs(position));
let offset = SHADOW_RAY_OFFSET_SCALE * max(max(extent.x, extent.y), max(extent.z, 1.0));
// … and in the ray's description, its origin:
tlas_position + normal * offset
The normal offset, from main.wgsl at 0db5e46, and since #260 in shadow_mask.wgsl, unchanged: 2−16 times the largest coordinate magnitude of the point, relative to the camera or in the TLAS's frame, and of 1 m at least.

2−16 is 28 = 256 times 2−24, so the origin clears the surface by at least 256 roundings at any magnitude. The magnitude is the larger of the two positions the ray's path rounds, the one relative to the camera and the one in the TLAS's frame. From 1 m down, the offset keeps its floor of 2−16 m, about 15 µm.

Since #260. The shadow mask pass moves each ray's origin the same way, along the face normal it rebuilds from depth rather than geometric_normal, and rebuilding the point from depth takes the rasteriser's place. That rebuild is several operations rather than one rounding: its test holds the rebuilt point within 2−20 of its largest coordinate, sixteen times inside the offset (chapter 10).

Examples:
Offset, 2^−16 × max(1 m, coordinate)
f32 step at that magnitude
One rounding, at most half a step
Offset, in roundings

Live demo. The offset at a coordinate magnitude, against f32's rounding there. f32's step doubles at each power of two while the offset grows smoothly, so from 1 m up the offset stays between 256 and 512 roundings; below 1 m it keeps its floor while the rounding keeps shrinking. At 64 m the offset is about 1 mm, at 4 km about 6 cm, and from 1 m down 15 µm; PR #258 gives the first two. M3's scenes stay within about 2 km of the TLAS's origin.

Without the offset, chapter 7's test failed with 7,832 judged pixels wrong: acne over the sphere's lit side. The demo in chapter 5 has an offset switch, for seeing acne appear.

The offset is Praxis's own. Wächter and Binder's chapter in Ray Tracing Gems treats the same problem; that book, its code listings included, is under CC BY-NC-ND 4.0, so Praxis copies none of it. Physically Based Rendering bounds the rounding error of each intersection point and offsets the next ray's origin by that bound (4th edition §6.8.5, "Bounding Intersection Point Error", and §6.8.6, "Robust Spawned Ray Origins"). Its names differ from the shader's: there the geometric normal is the triangle's own, and a normal interpolated from the vertices, which geometric_normal holds, is a shading normal.

Chapter 5 · PR #258

The shadow terminator

A coarse mesh drawn with smooth shading is flat triangles shaded with curved normals. Near the line where a sphere turns away from the sun, a triangle can already face away from the sun while the normals interpolated over it still face it. The shading says lit, but the triangle itself stands between its pixels and the sun, so the ray says shadowed, and the lit edge follows the triangles instead of the curve.

The shadows sphere is a UV sphere of 48 × 24 segments, 7.5° each (uv_sphere in src/scenes/mod.rs), drawn with normals interpolated from its vertices: smooth shading on flat triangles. Its vertices lie on the true sphere and its triangles inside it, by up to 2.1 mm at its radius of 0.5 m. A triangle's circumradius is about 4.6 cm, √2 sin 3.75° of the radius, so its plane turns up to 5.3° from the normals interpolated over it. Within 5.3° of the terminator, then, a triangle can face away from the sun while its interpolated normal faces it.

The tempting rule

A fragment shader can find its triangle's plane from screen-space derivatives of the position, cross(dpdx(p), dpdy(p)). PR #258's first version used that plane: where the triangle faced away from the sun while the interpolated normal faced it, it skipped the ray and lit the pixel. The spheres' terminators came out smooth. The rule was removed, for two reasons:

  • It leaked light into cast shadows. On SciFiHelmet, smoothed bevels sit in that band. Where another part of the helmet shadows them, the rule lit them anyway: lit patches on the helmet's shadowed neck. PR #258 shows them in a 400% zoom of a helmet's face, with the rule and without it.
  • It went past the milestone's rule. A ray is skipped only where its light adds nothing, N·L ≤ 0 or beyond range. In the band the light does add something, so skipping the ray ignores whatever stands in its way.

The review found the contradiction, and the helmet's images showed the leak.

Facets
Normal offset
Skip the ray where the facet faces away
lit: its ray reached the sun dark, as on a smooth sphere wrong: dark where the shading says lit wrong: lit in the block's shadow

Live demo. A cross-section of a coarse sphere: its facets in solid line, the true circle dotted, and 72 to 75 samples on the facets, shaded with normals interpolated across each facet. Every sample whose normal faces the sun traces one ray toward it, against the facets and a block that turns with the sun, so the block's shadow always falls across one terminator; short ticks mark the true terminators. Drag on the drawing, or use the slider, to turn the sun. The numbers are the shader's: the circle has the sphere's radius, 0.5 m; each origin carries a rounding error of up to three f32 roundings of a coordinate near 1 m, pseudo-random per sample; and the offset is 2−16 m. Both are far too small to draw, so the drawing marks their outcome. Simplified: two dimensions instead of three, in f64.

What ships

The ray is traced wherever N·L > 0 with the shading normal. On a coarse smooth-shaded mesh the terminator then steps along the triangles: tens of pixels on the shadows sphere. The analytic test leaves out the band within 5.3° of the sphere's terminator (TERMINATOR_BAND in tests/shadows.rs), where a triangle can hide the sun from its own pixels.

Since #260. The shadow mask pass tests the sun, like every light, against the face normal it rebuilds from depth, not the shading normal: where the face turns from it, the sun is hidden there with no ray, and elsewhere its ray is traced. So on a coarse smooth-shaded mesh the terminator follows the faces, one of the three visual changes PR #277 lists (chapter 10). The analytic test still leaves out the 5.3° band.

The real fix is deferred as a fog line. It needs the triangle's vertices and normals in the fragment shader, through barycentrics or PRIMITIVE_INDEX, both outside the Capability floor. Hanika's "Hacking the Shadow Terminator" (Ray Tracing Gems II, chapter 4) is the general treatment, and it needs the same data.

Chapter 6 · PR #258

MSAA edges and centroid interpolation

With 4x MSAA, a pixel on a triangle edge is shaded once for each triangle that covers some of its samples. Each of those fragments interpolates its inputs at the pixel's centre by default, even where the centre lies off its triangle. There the position lies on the triangle's plane extended past its edge, and past a concave crease that plane runs inside the mesh: a ray started there hits the neighbouring triangle. SciFiHelmet showed it as dark specks along its triangle edges.

WGSL names where an input is interpolated (§13.3.1.4). With center, the default, "Interpolation is performed at the center of the pixel." With centroid, "Interpolation is performed at a point that lies within all the samples covered by the fragment within the current primitive." #203 adds a second copy of the position, interpolated at the centroid, and starts the ray from it:

// The position again, interpolated within the samples the triangle
// covers: at an edge pixel the centre can lie off the triangle, where
// its plane, extended past a crease, runs inside the mesh, so a shadow
// ray starting there would find the mesh in its way.
@location(5) @interpolate(perspective, centroid) centroid_position: vec3<f32>,
The centroid copy, a field of VertexOutput in main.wgsl at 0db5e46, which #260 removes. The shading keeps the position interpolated at the centre; only the ray starts from the copy, shadowed(in.centroid_position, …).
The pixel, on screen
The crease, in cross-section along the pixel's row
triangle A's side triangle B's side a sample its ray lit a sample its ray left dark the pixel's centre a fragment's centroid
Ray origin:
Crease:
The pixel, resolved

Live demo. One pixel on the edge between triangles A and B, its four samples at Vulkan's standard 4x locations. Each triangle's fragment covers the samples on its side of the edge and starts one ray, from its position interpolated at the pixel's centre or at the centroid of its covered samples; its samples take that ray's answer, and the resolve averages them. The cross-section runs along the pixel's horizontal axis, through the crease, with the sun up and to the left. Drawn to no scale. Simplified: a vertical edge, a fixed sun, the centroid taken as the mean of the covered samples, a point the GPU may choose differently within them, and the resolved pixel as the mean of its samples' colours as displayed, where Praxis's resolve averages exposed colour weighted by 1/(1 + luminance).

PR #258 shows the specks in a 500% zoom of a helmet's shoulder, with the ray started at the pixel's centre and at the centroid.

Since #260. The centroid copy is gone. The shadow mask pass starts a pixel's rays at its nearest sample, rebuilt from that sample's depth and so on the surface the sample shows, and every fragment of the pixel reads the answer. Chapter 10 shows what that gives up at an edge pixel.

Chapter 7 · PRs #259 and #258

Proving it

A shadow can look right and still be wrong. #203 checks its rays three ways: against an analytic answer on a scene built for it, against the unshadowed image with the rays switched off, and through the Goldens the rays changed.

The shadows scene

Part 1, PR #259, adds the shadows Reference scene (src/scenes/mod.rs). A sphere of radius 0.5 m hangs with its centre 1 m over a ground plane that reaches 8 m from the origin each way. Both are one Mesh whose two Primitives name their own Materials: the ground light grey, 0.8, and the sphere orange, (0.8, 0.25, 0.1), both of roughness 0.6. That makes it the milestone's mixed-Material Golden too. The sun shines along (0.5, −1, −0.3) at 100,000 lx. A white point light stands at (1.5, 2.5, 1.0), 900,000 cd with a range of 20 m; its shadow comes with #205. The camera stands at (0, 2.4, 3.0) and looks at (0, 0.45, −0.4) through a 45° vertical field of view, at 640 × 360.

The shadows scene with rays on: an orange sphere over a light grey ground plane under a blue background, its shadow on the ground behind it and to its right.
Golden:
The shadows scene, its Metal Goldens at 640 × 360, re-encoded as JPEG for this page. Part 1's has no rays, and part 2 reproduces it byte for byte with the rays switched off; part 2's adds the sun's shadow on the ground, behind the sphere and to its right. The point light, up and to the right, casts no shadow until #205.

The analytic oracle

The test, tests/shadows.rs, captures the scene twice, with shadow rays on and off. A pixel counts as darkened when some channel drops by more than one 8-bit step and none rises. The oracle intersects each pixel centre's camera ray with the true sphere and the plane, in f64, and sorts what it sees: sky, sunlit sphere, unlit sphere, sunlit ground, or shadowed ground, where the ray from the ground point toward the sun enters the sphere. Where it sees shadowed ground, a judged pixel must be darkened; anywhere else it must be unchanged, no channel more than one step off.

Two kinds of pixel are not judged. A pixel is judged only where its centre and its 8 neighbours' centres see alike. That leaves out the one-pixel band around every edge of what the pixels see, which absorbs the difference between the true sphere and its triangles: their shadow's outline lies within 2.5 mm of the true one's on the ground, where a pixel spans about 9.5 mm. And the sphere within 5.3° of its terminator is left out, for chapter 5's reason. 5,869 shadowed-ground pixels are judged.

sky sunlit sphere unlit sphere sunlit ground shadowed ground not judged: the one-pixel band not judged: within 5.3° of the terminator
Pixels:
Not judged:
Shadowed-ground pixels judged
Pixels judged in all
Not judged
The shadow on the ground

Live demo. The test's oracle, ported from tests/shadows.rs to this page: each pixel centre's camera ray meets the true sphere and the ground in f64, and the colour gives what it sees. At 640 × 360 under the scene's sun, the port judges 5,869 shadowed-ground pixels, the test's figure. The sliders move the sun by its elevation above the ground and its azimuth, clockwise from the camera's view direction seen from above; the scene's sun stands at 59.8° and 239.0°. Fewer pixels make the bands easy to see, and wider in metres. The shadow's size is this guide's arithmetic: on the ground it is an ellipse 2r across and 2r / sin(elevation) long, r = 0.5 m, centred h / tan(elevation) from the point under the sphere, h = 1 m.

A wrong origin fails it

A test earns trust by failing when the code is wrong. Run with the two wrong origins of chapters 3 and 4, it failed by wide margins:

  • No normal offset: 7,832 judged pixels wrong, acne over the sphere's lit side.
  • The position relative to the camera taken as the origin in the TLAS's frame: 176,558 wrong.

Rays off draws the unshadowed image

The rays have a Render switch: RenderSwitches { culling, froxel_lights, shadow_rays } in src/render/renderer.rs at 0db5e46. Each of those switches is a WGSL override that every module declares, override SHADOW_RAYS: bool;, and the lit pipeline sets it from the switches; with it false, the compiler folds the ray away. Since then, #272 and #289 have added count_point_lights and crowd_form, which are not overrides. The defaults are what ships; only capture and bench pass others.

A renderer test, shadow_rays_off_draw_the_image_no_shadow_darkens, puts a floor ahead of the camera and a square behind it, out of view, between the floor and the light. With rays on, the floor's middle darkens. With rays off, the frame is exactly the image of the frame without the square. And with the switch off, captures of all nine Reference scenes are byte-identical to main's binary, the helmet's at 1280 × 720, and to part 1's unshadowed shadows Golden.

Which Goldens changed

  • pbr-sweep: the lower row's spheres catch the upper row's shadows; the sun shines from up, left and in front.
  • normal-tangent: the real bumps cast shadows on their plate; the normal-mapped quads, flat geometry, cast none.
  • shadows: the sphere's shadow on the ground, in the scene new with #203.
  • helmet, fetched and without a Golden: the helmets shadow themselves and each other.
  • Unchanged: uv-grid, far-coplanar, minification and texture-wall, where nothing stands between their surfaces and the sun; and the orrery, whose Instances are all Moving Instances and so in no TLAS yet.
Chapter 8

What it costs

Traced inline, the rays are paid for on the GPU, inside the lit pass. Their cost is measured as a difference: each Reference scene with rays on minus rays off, in the GPU frame's time and the lit pass's, at 2560 × 1440 on the M4 Pro.

Each figure is a median over three interleaved runs of each binary, 2,048 frames each, on a quiet machine. With rays off, the GPU frame is main's within 4.3 µs in every scene but texture-wall, where it stays within its spread, so the difference is the rays alone.

SceneLit pass, rays offLit pass, rays onRays' cost
orrery282 µs867 µs+0.59 ms, with an empty TLAS: its planets are Moving Instances
uv-grid404 µs1,598 µs+1.19 ms
minification458 µs2,497 µs+2.04 ms
far-coplanar312 µs2,612 µs+2.30 ms
pbr-sweep403 µs2,973 µs+2.57 ms
normal-tangent438 µs3,321 µs+2.88 ms
texture-wall964 µs4,822 µs+3.86 ms
shadows439 µs5,751 µs+5.31 ms
helmet707 µs9,266 µs+8.56 ms
Rays on minus rays off, the lit pass's GPU time at 2560 × 1440 with 4x MSAA on the M4 Pro (#258, on #201's light buffer and Froxel lists). The GPU frame grows by the same amount, but in texture-wall, whose frame is bound by other work. Even an empty TLAS costs about half a millisecond; the rest grows with how much geometry each ray starts in and passes, the helmets' dense, self-shadowing triangles most of all.

For scale, ADR 0020 cites one sun ray per pixel at 1.8 to 2.0 ms on the M4 Pro's open scene, measured with ray queries in compute shaders before #203 (the GI survey's probe, audited in #97). Traced from the lit pass, rays cost more than that on dense geometry: the helmets' frame goes from 1.6 to 10.2 ms, still under ADR 0013's 12 ms. Skipping the rays of helper invocations, which a GPU adds where a triangle covers only part of a 2 × 2 quad of pixels so that the quad can take derivatives, changed nothing measurable.

On the CPU side, the camera uniform's 16 new bytes are uploaded every frame.

Since #260. The rays, the sun's and every point light's, are traced from a compute pass into the shadow mask, where they cost the GPU frame 2.4 to 13.4 times less than inline (chapter 10).

Chapter 9 · PR #265

Point lights: where the ray ends

The sun is at infinity, so its ray never has to stop. A point light stands somewhere, so its ray must end where the light is: a ceiling above a lamp does not stand between the lamp and the floor it lights. Ticket #205 gives every point light that reaches a pixel one hard shadow ray there, starting where the sun's does and ending at the light.

The ray goes through the sun's own function, shadowed(position, normal, direction, distance), so it starts as the sun's does: from the centroid-interpolated position (chapter 6), offset along the geometric normal (chapter 4), in the TLAS's frame (chapter 3). Since #204's first part, PR #264, merged as 79cdabb, shadowed traces against the static TLAS and, where that finds nothing, against the dynamic TLAS of Moving Instances, unless it is empty, so every light's ray meets both; Dynamic TLAS Explained, the next guide, covers that second TLAS. At 5a5f075, a point light's ray is traced in the lit pass's loop over the point lights of the pixel's Froxel:

// The point lights the pixel's Froxel lists, in light order, or, with
// FROXEL_LIGHTS off, every point light: brute force. Each one's shadow
// ray runs from the centroid position to the light, traced only where
// the light adds something there: where the surface faces it, within its
// range.
var froxel = 0u;
var point_light_count = light.point_light_count;
if FROXEL_LIGHTS {
    froxel = froxel_at(camera.view_proj * vec4<f32>(in.camera_relative_position, 1.0));
    point_light_count = froxel_lists[froxel].light_count;
}
for (var entry = 0u; entry < point_light_count; entry++) {
    var index = light.directional_light_count + entry;
    if FROXEL_LIGHTS {
        index = froxel_lists[froxel].lights[entry];
    }
    let point = lights[index];
    let position = point_light_position(point, camera.anchor, camera.offset);
    let centroid_to_light = position - in.centroid_position;
    var intensity = point.intensity;
    if SHADOW_RAYS && dot(normal, centroid_to_light) > 0.0
        && dot(centroid_to_light, centroid_to_light) < point.range * point.range
        && shadowed(
            in.centroid_position,
            geometric_normal,
            normalize(centroid_to_light),
            length(centroid_to_light)
        ) {
        intensity = vec3<f32>(0.0);
    }
    outgoing_luminance += point_light_luminance(
        surface,
        normal,
        view_direction,
        position - in.camera_relative_position,
        intensity,
        point.range
    );
}
Each point light's ray, from fs_main in src/render/shaders/main.wgsl at 5a5f075. Where the ray finds the light hidden, the light keeps no intensity for this pixel, and its shading adds nothing. The ray starts from the centroid position; the shading takes the light's offset from the pixel's centre, position - in.camera_relative_position. Since #260, the loop reads each light's bit from the shadow mask instead (chapter 10).

The ray ends at the light

The sun's ray runs to UNBOUNDED_RAY_DISTANCE, f32's largest value, past anything a TLAS holds. A point light's runs length(centroid_to_light), the distance from the surface point to the light, which becomes the ray's tmax (chapter 2): the query looks for hits up to the light and no farther. Whatever lies beyond the light shadows nothing it lights. With an unbounded ray, a lamp under a ceiling would light nothing below it: each ray toward the lamp would run past it and meet the ceiling.

The shadows scene cannot show the difference, since nothing there lies beyond its light: an unbounded ray would give the same answers. A Renderer test, a_point_lights_shadow_ray_ends_at_the_light in src/render/renderer.rs, can. A white floor 2 m ahead of the camera is lit by a point light 1 m behind the camera, and a square 0.2 m across stands behind the camera too, out of view. Placed 0.5 m from the light, between it and the floor, the square darkens the floor's middle, where its shadow is 1.2 m across. Placed 1 m beyond the light, it leaves the frame exactly as it is without the square, although an unbounded ray from anywhere in the floor's middle 0.6 m would meet it. With the point light's ray made unbounded, like the sun's, the test fails: "a square beyond the point light shadowed the floor".

lit: its ray reached the light dark: the sphere hides the light wrong: dark, its ray met the ceiling beyond the light beyond the range: no ray, no light
Ray:
Ceiling:

Live demo. A side view of the shadows scene's sphere and point light, which stands 1.5 m to the right of the sphere's centre and 2.5 m above the ground, and of 21 points of the ground, each tracing one ray toward the light where the light reaches it. Ending at the light, as shipped, the rays never meet the ceiling 1 m above it; unbounded, like the sun's, every ray the sphere does not stop runs past the light into the ceiling. The slider sets the light's range: past it a point traces no ray, and the light adds nothing there anyway. The scene's light reaches 20 m. Simplified: two dimensions instead of three, without the light's 1 m toward the camera, and no normal offset, which is far too small to draw.

Skipped only where the light adds nothing

Which rays a pixel traces is item 4 of the milestone's decision on map #17:

"Every light that reaches a pixel traces one hard shadow ray there, skipped only where the light adds nothing (N·L ≤ 0, or beyond its range). A per-light opt-out is a fog line. A cap on shadowed lights per Froxel (pre-mortem) is rejected: Froxel contents change as the camera moves, so shadows would pop."

In the loop, that is two tests before the call. dot(normal, centroid_to_light) > 0.0 is N·L > 0 with the shading normal, the sun's test; the offset to the light needs no normalising for its sign. dot(centroid_to_light, centroid_to_light) < point.range * point.range holds within the light's range. Beyond it the light adds nothing: Praxis attenuates point lights as KHR_lights_punctual recommends, max(min(1 − (d/r)4, 1), 0) / d2, an inverse-square falloff whose window reaches zero at the range r and stays there (point_light_attenuation in src/render/shaders/shading.wgsl). As in chapter 2, WGSL's && evaluates its right side only when its left side is true, so shadowed runs only where both tests pass, and normalize never sees a zero vector: N·L > 0 needs a non-zero offset.

Both tests are made at the ray's start, the centroid position, and the shading at the pixel's centre. At an MSAA edge pixel the two points lie a fraction of a pixel apart, so the tests and the shading can disagree only where the light adds almost nothing: at the very edge of its range, or where the surface turns from the light.

No cap

The project's pre-mortem, its review of what the ADRs cost world and game design (docs/research/world-design-pre-mortem.md), had raised a cap on the lights each Froxel shadows. A Froxel's list holds the point lights whose range reaches into its bounds: one of 32 × 18 screen rectangles, cut into 32 slices of depth, 18,432 lists of up to 127 lights each (src/render/froxel_pass.rs). Which lights those are depends on where the camera is. As it moves, a light's range sphere comes to reach a Froxel's rectangle and slice, or stops reaching them, and the list gains or loses that light. Capped at, say, four, a list would shadow the first four lights it holds, in light order; a light joining ahead of them would take one of their shadows away from one frame to the next, and give it back when it left. Shadows would pop, with nothing in the scene changing.

So a pixel traces one ray for every light that reaches it, and its Froxel's list, 127 lights at most, is the only limit. What that costs is set by how many lights reach each pixel, and so by the lights' ranges. The town Reference scene (#206), with the sun and 1,024 point lights, is where that bill comes due, and its light ranges will set it.

Since #260. The project's owner amended item 4 for the shadow mask on 2026-10-23: the mask holds bits for 59 lights per pixel, and a light past them casts no shadow there and is counted. Chapter 10 gives the reasons.

Brute force traces the same rays

With the froxel_lights Render switch off, the loop visits every point light: brute force. With it on, it visits the pixel's Froxel list. A light that adds anything to a pixel reaches the pixel's Froxel, so the list holds it, in light order, the order brute force visits lights in; a light the list leaves out adds nothing there, ray or no ray. So the two trace the same rays wherever a light adds anything, in the same order, until a list is full: it has 127 places, and Frame stats count the lights it had no room for. The shadow_rays switch turns the point lights' rays off with the sun's: the same SHADOW_RAYS override comes first in every ray's test.

Proving it, with two lights

tests/shadows.rs now judges both lights. The shadows scene's point light stands at (1.5, 2.5, 1.0) m: white, 900,000 cd, which gives the sun's 100,000 lx 3 m away, with a range of 20 m. Every point of the ground lies within 13.3 m of it and faces it, so no ray toward it is skipped there. The oracle sorts each pixel centre in f64 as before, per light now: sky; sphere, lit or not by each light; within 5.3° of either light's terminator, not judged; ground, hidden or not from each light. Outside the one-pixel band, a pixel whose ground is hidden from any light must be darker with rays on than off, and every other must be unchanged.

It judges 5,851 pixels in the sun's shadow and 18,719 in the point light's. Run before the shader change, it failed with 18,719 judged pixels wrong: the point light's shadow, every judged pixel of it, not darkened. The band still absorbs the triangles: their shadows' outlines lie within 2.5 mm of the true sphere's under the sun, at most a quarter of a pixel there, and within 11 mm under the point light, whose rays graze the ground farther out, at most three quarters of a pixel.

The sun's count is 18 below chapter 7's 5,869: those pixels lie along the edge of the point light's shadow where it crosses the sun's, under the sphere, and the band now follows that edge too. The two shadows overlap only in a sliver there, at most 3 pixels wide and all of it in the band, so no judged pixel lies in both. Both findings are this guide's arithmetic, from its own port of the test, which reproduces the test's counts. Chapter 7's demo ports the test as #203 left it, with the sun alone.

The shadows scene with every light's rays: the orange sphere over the light grey ground, the point light's large shadow on the ground to its left and behind it, and the sun's smaller shadow to its right; the two touch under the sphere.
The shadows scene after #205, its Metal Golden at 640 × 360 at 5a5f075, re-encoded as JPEG for this page; the scene has no Moving Instances, so #204 does not change it. The point light, up, to the right and in front of the sphere, casts the large shadow on its left; the sun casts the smaller one on its right, as in chapter 7. By this guide's arithmetic, the point light's shadow is larger because the light is near, 2.3 m from the sphere's centre, and lower, 39.8° above the horizon from there against the sun's 59.8°. On #204's first part, #205 changes four Goldens on each backend: pbr-sweep, normal-tangent, shadows and the orrery's, whose planets, Moving Instances, meet the point lights' rays in the dynamic TLAS.

What it costs

Every scene but texture-wall has point lights: the same four coloured ones in most, a white one in minification and in shadows. The table gives, per scene, the lit pass's GPU time with no rays, with the sun's (main) and with every light's (#205), at 2560 × 1440 on the M4 Pro.

SceneLit pass, no raysSun's rays (main)Every light's (#205)Point lights' rays
far-coplanar314 µs2,753 µs2,830 µs+0.08 ms: its lights reach no surface it shows, so this is the loop's code alone
minification462 µs2,617 µs3,811 µs+1.19 ms, one light
uv-grid410 µs1,661 µs3,498 µs+1.84 ms, four lights
orrery294 µs1,693 µs5,724 µs+4.03 ms, four lights, its planets in the dynamic TLAS
normal-tangent441 µs3,445 µs8,204 µs+4.76 ms, four lights
shadows439 µs6,045 µs11,242 µs+5.20 ms, one light
pbr-sweep410 µs3,064 µs9,695 µs+6.63 ms, four lights
helmet715 µs9,498 µs40,569 µs+31.07 ms, four lights: 7.8 ms each
The lit pass's GPU time at 2560 × 1440 with 4x MSAA on the M4 Pro: medians over three interleaved runs of four binaries, 2,048 frames each, main at 79cdabb (#204's first part) and #205's branch, each with the shadow_rays switch on and off, on a machine in interactive use (PR #265). texture-wall has no point lights and is left out.

Each point light costs up to what the sun's ray costs in the same scene: 7.8 ms per light on the helmets, whose sun ray costs 8.8 ms; 5.2 ms on shadows, against its sun's 5.6 ms; 0.5 to 1.7 ms per light elsewhere. With its four lights shadowed, the helmet scene's whole GPU frame takes 41.5 ms, against ADR 0013's 12 ms. Where no point light traces at all, the pass still grows a little, 0.08 ms on far-coplanar: the ray query's code in the loop costs it anyway. With 1,024 point lights and no cap, town is where these figures meet the budget; #260 lists the options to measure, and chapter 10 follows its first, the shadow mask.

Chapter 10 · PR #277

The shadow mask: rays out of the lit pass

Until #260, every ray in this guide was traced in the lit pass, by the fragment shader that shades the pixel. That was the simplest place for them, and it was slow: on the helmets, the rays cost the GPU frame 34.6 ms. Ticket #260 moves them into a compute pass of their own, just before the lit pass. It traces one ray per pixel and light, starting from the depth the earlier passes leave, and keeps each answer as one bit: the shadow mask. The lit pass reads the bits and traces nothing.

PR #277 merged it on 2026-10-23 as bd7b988. The code is quoted from that commit, and main at 69e02dc still has it unchanged; the figures are PR #277's, measured on the M4 Pro on 2026-10-23 and recorded in docs/performance.md, under "Shadow rays: from the lit pass to a shadow mask".

What the lit pass paid

#260 measured both forms on every Reference scene in the same interleaved rounds; the table at the end of this chapter has them all. Inline, on main at d12da41, the rays cost the GPU frame from 2.53 ms on far-coplanar to 34.61 ms on the helmets, texture-wall aside: its frame is its 10,000 Textures (#183).

Not all of that was the rays' own work. A first version of the mask kept a fallback in fs_main: a ray query for any light past the mask's bits, which no Reference scene reached, so it never ran. With it in the shader, the lit pass took 0.9 to 3.9 ms longer at 1440p, in two rounds each on uv-grid, pbr-sweep, shadows and the helmets, on main at 470221e: 1,362 to 4,611 µs, against 429 to 754 µs without it. On the helmets, the GPU frame was 8.0 ms with it and 4.1 ms without (the decision on map #17). On the M4 Pro, a ray query in the fragment shader cost time even where it never ran.

Why was not measured cause by cause. Two causes are plausible, and naga's lowering alone does not explain it:

  • Occupancy. A fragment shader that holds a ray query's state through its light loop keeps registers live across the whole BRDF, so each GPU core keeps fewer fragments in flight. The mask pass carries only the ray's state.
  • Edge pixels. At an MSAA edge, each triangle covering some of a pixel's samples ran its own fragment, and with it its own rays.
  • Not naga alone. naga lowers a ray query for Metal the same way in either pass, fetching every field of the committed hit and tracking its initialisation, as ticket #260 noted, so the lowering by itself does not explain the difference.

A pass of its own

To trace from a pixel, the new pass has to know which surface the pixel shows, and the depth target knows: once the depth passes have run, each of its samples holds the depth of the nearest surface there. The catch is phase two of ADR 0019's culling. The depth pre-pass draws phase one's Instances, those visible the frame before. Phase two's, those the culling pass finds visible only after the Hi-Z test, were drawn into depth by the lit pass itself, just before it shaded. A mask traced from the depth pre-pass's depth alone would have started those pixels' rays from whatever lay behind them.

So with shadow rays on, phase two's Instances draw into depth in a render pass of their own, phase_two_depth_pass, which loads the depth target and stores it, and the shadow mask pass runs next, before the lit pass. With rays off, neither runs, and the lit pass draws phase two's depth as before.

Inline: #203 and #205
  1. culling, phase one
  2. Froxel pass
  3. depth pre-pass
  4. depth pyramid pass
  5. culling, phase two
  6. lit pass: phase two's depth, then shading, a ray per fragment and light
  7. tonemapping pass
Shadow mask: #260
  1. culling, phase one
  2. Froxel pass
  3. depth pre-pass
  4. depth pyramid pass
  5. culling, phase two
  6. phase two's depth pass
  7. shadow mask pass: a ray per pixel and light
  8. lit pass: shading, reading the mask
  9. tonemapping pass
traces the shadow rays new with #260
The frame's passes with shadow rays on, in order, from the culling pass's first phase; earlier work, such as the dynamic TLAS's build, comes first in both forms. With rays off, the two forms run the same passes: neither new pass runs, and the lit pass draws phase two's depth itself.

The nearest sample

The shadow mask pass (src/render/shadow_mask_pass.rs and src/render/shaders/shadow_mask.wgsl) runs one invocation per pixel, in workgroups of 8 × 8. Each rebuilds, from the pixel's four depth samples, what the lit pass got from its vertices: a surface point to start the rays from, and a normal to move them off the surface along.

Of the four samples it takes the nearest. Depth is reverse-Z (ADR 0015), so that is the greatest depth, and where two tie, the first of them. The pass loads the four with four explicit loads rather than a loop, as the depth pyramid pass does, where a loop over the samples ran slower on Metal (PR #240). A pixel whose nearest depth is 0, the value the depth target is cleared to where no surface was drawn, shows the background, and its word stays empty.

Its position, from its depth

The pass rebuilds the sample's position relative to the camera from its depth and its place in the pixel, in position_at, which #260 shares in common.wgsl and holds to an f64 oracle (position_at_rebuilds_the_point_a_depth_shows_through_a_pixel in src/render/shader_function_tests.rs). Two ADRs keep that short. The view-projection matrix holds no translation, since positions reach the GPU relative to the camera (ADR 0014). Its reverse-Z projection with no far plane puts the near plane's distance in clip z and the depth along the view axis in clip w (ADR 0015), so a sample's depth is the near distance over its view depth. And the matrix's rows are the view's axes: right and up, scaled by the projection, and forward. With x and y the sample's normalised device coordinates:

view depth = near distance / depth position = view depth × (forward + x × right / |right|² + y × up / |up|²)

Where in the pixel the sample lies matters as much as its depth. The four sit at the standard 4x offsets from the pixel's top left corner, (0.375, 0.125), (0.875, 0.375), (0.125, 0.625) and (0.625, 0.875) of a pixel, which Rust declares to the shader as SAMPLE_OFFSETS. Vulkan promises those places only where an adapter sets standardSampleLocations, which wgpu does not report, so a test, each_depth_sample_lies_where_the_pass_rebuilds_it, draws a sloped triangle over a 37 × 23 depth target and checks every sample's depth against the triangle's plane at those offsets, so a backend that put its samples elsewhere would fail it.

A sample holds its own surface's depth at its own place, so the rebuilt point lies on the surface the sample shows. Chapter 6's pixel centre could lie on a triangle's plane extended past its edge; a sample's point cannot, and the centroid copy of the position is gone.

Its face normal

The depth target holds no normals, so the pass takes the surface's from its neighbours: the same sample at the pixels to the left and right, and above and below. On each axis it keeps the neighbour nearer in depth to its own sample, the likelier to lie on the same surface, and the cross product of the steps to the two it keeps gives the face normal, turned toward the camera. Where an axis has no neighbour showing a surface, the normal falls back to the direction toward the camera. It is not the normal the lit pass shades with: no normal map bends it, and no vertex normals smooth it.

One ray per light, one bit per answer

For each light, the pass first asks whether the face turns from it; if it does, the light is hidden there, with no ray. Otherwise it calls shadowed, which moved from main.wgsl to shadow_mask.wgsl with its body unchanged: one ray against the static TLAS, then against the dynamic TLAS unless it is empty, from the point composed into the TLAS's frame (chapter 3) and moved off the surface by chapter 4's 2−16 of its largest coordinate, now along the face normal. A point light's ray ends at the light (chapter 9), and a point light out of range of the point gets no ray.

Each answer is one bit of the pixel's word, an Rg32Uint texel: two 32-bit halves, 8 bytes per pixel, 29,491,200 bytes at 2560 × 1440. The bits follow the pixel's lights in the order the lit pass sums them, the directional lights first, then the entries of the list of the pixel's Froxel, so the lit pass finds a light's bit by counting. 59 bits hold lights. The second half's top 5 bits hold the slice of the Froxel whose list the bits follow, one of 32:

first half, bits 0–31: lights 0–31 second half, bits 0–26: lights 32–58 bits 27–31: Froxel slice

With the froxel_lights Render switch off, the bits follow every point light in light order instead, as brute force sums them.

59 lights, then no shadow

A Froxel's list holds up to 127 lights, more than the word has bits for. A light past the 59th at a pixel, and within its range there, casts no shadow at that pixel. The pass counts such lights, in shadow_mask_lights_overflowed; Frame stats read the count back, and the first frame with any warns, as the Froxel lists do with lights past their 127.

That is a cap of the kind chapter 9's item 4 rejected. The project's owner amended the decision for the mask on 2026-10-23 (map #17), and chose the fixed capacity over the alternative: an inline fallback for the lights past the bits, the ray query that cost the lit pass 0.9 to 3.9 ms without ever running. By chapter 9's reasoning, past 59 lights shadows can pop as the Froxel lists change with the camera; the count shows when that happens.

How the lit pass reads it

The lit pass loads its pixel's word once. For each light it sums, it reads the light's bit, and a set bit leaves the light out, as a ray that hit did before. A directional light's bit is its index. A point light's is the number of directional lights plus its entry in the Froxel's list, as long as the fragment's Froxel is the word's.

Usually it is. Both passes find the Froxel's screen rectangle from the pixel's position: the lit pass now takes its fragment's framebuffer coordinates where it used to project its position, so the two agree. The slice comes from depth, and that can differ: at an MSAA edge, where the fragment's surface is not the nearest sample's, or where a slice ends between the nearest sample and the pixel's centre. So the lit pass compares its own slice with the word's. Where they differ, it looks each light up in the word's Froxel list instead, by binary search, since the list is in light order, and takes the bit of the entry it finds (froxel_mask_bit). A light that list lacks casts no shadow at that fragment.

// Whether the shadow mask's word for a pixel, `mask`, hides the light whose
// bit is `bit` (src/render/shadow_mask_pass.rs): a light past its bits casts
// no shadow there.
fn masked(mask: vec2<u32>, bit: u32) -> bool {
    let half = select(mask.x, mask.y, bit >= 32u);
    return bit < SHADOW_MASK_LIGHTS && ((half >> (bit & 31u)) & 1u) == 1u;
}

// … in fs_main, before the loop over the point lights:
froxel = froxel_at(in.clip_position.xy, size, view_depth);
mask_froxel = froxel - froxel % FROXEL_SLICES + (mask.y >> (SHADOW_MASK_LIGHTS - 32u));
// … and in it, each light's bit:
var bit = light.directional_light_count + entry;
if SHADOW_RAYS && FROXEL_LIGHTS && mask_froxel != froxel {
    bit = froxel_mask_bit(mask_froxel, index);
}
let intensity = point.intensity * f32(!masked(mask, bit));
Reading the mask, from src/render/shaders/main.wgsl at bd7b988, unchanged at 69e02dc. mask_froxel is the Froxel at the fragment's screen rectangle and the word's slice, which the second half's top bits hold; where it is not the fragment's own, froxel_mask_bit finds the light's entry in its list. A bit past the 59th reads as not hidden.

The lit pass's shader has no ray query left. Reading the word costs it 22 to 49 µs over its time with rays off, texture-wall aside (the table below). It also lifts a limit ADR 0019 records: Metal forbids ray queries in mesh-shaded pipelines, which had kept the lit pass from being one.

What an edge pixel gives up

Inline, an edge pixel ran one fragment per triangle covering some of its samples, and each traced its own rays from the centroid of the samples it covered (chapter 6). The mask traces once per pixel, from the nearest sample, and every fragment of the pixel reads the same word. So at an edge, the fragments of the other surfaces read bits traced from the nearest one. Where the two surfaces are shadowed differently, along a silhouette, those fragments' samples take the wrong answer: a fringe one pixel wide at most, as strong as those fragments' share of the pixel's samples. And within a surface the rays now start at a sample rather than at the pixel's centre, so a shadow's edge can move by up to half a pixel. PR #277 lists a third visual change: the terminators of coarse smooth-shaded meshes follow their faces, since a face that turns from a light hides it (chapter 5).

sphere, lit sphere, in shadow ground, lit ground, in shadow the pixel's nearest sample a pixel the mask changes
Shadows from:
Sphere:
Pixels both surfaces cover
Of those, pixels the mask changes
Ground pixels it changes along the shadow's edge

Live demo. A patch of 14 × 8 pixels where the sphere, near the camera, stands in front of the ground, each pixel's four samples at the standard 4x offsets. Each sample has the colour of the answer it takes, and each pixel the mean of its samples' colours. Inline, each surface covering some of a pixel's samples runs a fragment whose ray starts at the centroid of the samples it covers, so every sample takes its own surface's answer. With the mask, each pixel traces once, from its nearest sample, ringed, and every fragment there reads that answer: along the outline, the ground's samples take the sphere's. Outlined: the pixels whose colour the mask changes, in either view. The slider moves the edge of a shadow across the ground. Simplified: one light, the sphere lit or in shadow as a whole, depths made up only to rank each pixel's samples, each centroid taken as the mean of its covered samples, and each pixel's colour as the mean of its samples' colours as displayed, where Praxis's resolve averages exposed colour weighted by 1/(1 + luminance).

Against main's images, the mask's differ in five Goldens and in the helmets' capture, by the counts below, and tests/shadows.rs, chapter 7's analytic oracle, passes unchanged:

ScenePixels off by more than one stepLargest change, in 8-bit steps
pbr-sweep928221
shadows504116
normal-tangent429110
orrery11313
uv-grid22
far-coplanar, minification, texture-wall01 at most
helmet, fetched, with no Golden15,415 of 921,600198
Where the images differ, on Metal: main's Goldens at 640 × 360, 230,400 pixels each, against the branch's captures, and main's capture of the helmets against the branch's, at 1280 × 720. A pixel counts when one of its channels moves by more than one 8-bit step.

What it costs

The table gives, per scene, the lit pass with rays on and off in both forms, the shadow mask pass, and the rays' cost in each form: the GPU frame with rays on minus rays off.

SceneLit pass, inline: rays on / off, µsRays' cost, inlineLit pass, mask: rays on / off, µsShadow mask pass, µsRays' cost, maskGPU frame, inline → mask, µs
far-coplanar2,822 / 312+2.53 ms333 / 311989+1.05 ms3,555 → 2,074
minification3,811 / 461+3.36 ms487 / 4591,048+1.11 ms4,547 → 2,292
uv-grid3,502 / 404+3.11 ms433 / 4061,175+1.23 ms4,184 → 2,310
orrery5,694 / 289+5.69 ms328 / 2871,115+1.20 ms6,645 → 2,317
normal-tangent8,203 / 439+7.80 ms466 / 4381,347+1.40 ms8,943 → 2,545
shadows11,233 / 439+10.81 ms462 / 4351,881+1.94 ms12,012 → 3,136
pbr-sweep9,454 / 404+9.07 ms448 / 4041,368+1.44 ms10,152 → 2,528
helmet35,313 / 712+34.61 ms760 / 7112,520+2.59 ms36,226 → 4,207
texture-wall3,387 / 966+0.13 ms585 / 9632,388+0.34 ms15,623 → 15,756
Inline against the mask, at 2560 × 1440 with 4x MSAA on the M4 Pro: main at d12da41 and #260's branch on it, each with the shadow_rays switch on and off, medians over three interleaved rounds on an otherwise idle machine (PR #277, and docs/performance.md). The rays' cost is the GPU frame with rays on minus rays off. These inline figures come from #260's rounds, not chapter 9's, which ran on an earlier main. texture-wall's rows are within noise: its frame is its 10,000 Textures (#183), and its timestamps swing by up to 1 ms between rounds.
  • The rays cost 2.4 to 13.4 times less. From the mask, they cost the GPU frame 2.4 times less on far-coplanar, and 13.4 times less on the helmets. The mask traces one ray per pixel and light that reaches it; inline traced one per fragment and light.
  • The lit pass is back near its rays-off time. With the mask it takes 22 to 49 µs more than with rays off: the cost of reading the word.
  • Phase two's depth pass reads 12 µs as timed: Metal overlaps it with the passes beside it.
  • Rays off changes nothing. With rays off, neither new pass runs, and the branch's GPU frame is main's within ±4 µs on every scene but two: texture-wall, within its spread, and the orrery, +160 µs, its dynamic TLAS's build, which main read as 0 with rays off and the branch now counts.
  • The helmets' frame, the sun's rays and four point lights' included, goes from 36.2 ms to 4.2 ms, against ADR 0013's 12 ms; shadows drops to 3.1 ms.

What the mask pass costs with many lights, and whether 59 bits are enough, is for town (#206) to show, with the sun and 1,024 point lights. It has merged since. On the shadow mask its GPU frame median was 17.6 ms in its first M4 Pro Baseline (#275), and has been 18.1 ms since #290 re-recorded the Baselines (since #299, merged on 2026-10-26 as 800c0d2, 13.6 ms, with the dynamic TLAS's build counted from the end of the previous frame's last pass); with inline rays, on #206's branch, it was 92.7 ms (Town Explained). Frame stats count any light past the bits.

Reference

Reading list

Grouped by source, each with what it is good for here. Repo links are pinned to the merge commits: #258's, 0db5e46, #205's, 5a5f075, and #260's, bd7b988. The books' chapter and section numbers were checked on 2026-10-22 against pbr-book.org, realtimerendering.com and its Ray Tracing Gems pages, and KHR_lights_punctual's text on 2026-10-23.

This ticket and its PRs

In the repo

  • Ticket #203 the ask, its acceptance criteria and its split
  • PR #259 part 1: the shadows Reference scene
  • PR #258 part 2: the sun's shadow rays
  • The decision on map #17 item 3, the static TLAS's frame; item 4, a ray skipped only where its light adds nothing
  • ADR 0020 one hard shadow ray per pixel per light, class (a); why soft shadows wait
  • ADR 0014 Anchors, camera-relative composition, the precision bar
  • ADR 0021 the Capability floor, ray queries required
  • The GI survey, docs/research/global-illumination-and-lighting.md the ray-cost probe on the M4 Pro, and what wgpu 30 allows
  • tests/shadows.rs and main.wgsl the oracle, and the ray

Point lights: #205

In the repo

  • Ticket #205 the ask: a ray toward every point light that reaches a pixel, ending at the light, with no cap
  • PR #265 the point lights' rays, merged as 5a5f075
  • PR #264 #204's first part: the dynamic TLAS of Moving Instances, which shadowed queries where the static TLAS finds nothing
  • main.wgsl and shading.wgsl the point lights' loop, and point_light_attenuation, the range window
  • tests/shadows.rs and renderer.rs the oracle with both lights, and a_point_lights_shadow_ray_ends_at_the_light
  • froxel_pass.rs the Froxels, and the 127 lights a list holds

The spec

The shadow mask: #260

In the repo

Self-intersection and the terminator

  • Wächter and Binder, "A Fast and Robust Method for Avoiding Self-Intersection", Ray Tracing Gems (2019), chapter 6 offsetting ray origins in depth; the book is open access under CC BY-NC-ND 4.0, so Praxis copies none of its code
  • Pharr, Jakob and Humphreys, Physically Based Rendering: 4th edition, §6.8 Managing Rounding Error §6.8.6, Robust Spawned Ray Origins; 3rd edition, §3.9 §3.9.5, the same
  • Hanika, "Hacking the Shadow Terminator", Ray Tracing Gems II (2021), chapter 4 the general fix for the terminator; it needs the triangle's vertices and normals

Books

  • Akenine-Möller et al., Real-Time Rendering, 4th edition, chapter 7, Shadows the shadow-map families ADR 0020 set aside, and the vocabulary of hard and soft shadows
  • Chapter 26, Real-Time Ray Tracing, an online chapter, free from realtimerendering.com the hardware ray tracing this guide builds on
Reference

Glossary

Shadow ray
A ray from a surface point toward a light that asks only whether anything lies in between.
Hard shadow
A shadow from a light treated as one point or direction: each point lit or not, the edge sharp.
Penumbra
A shadow's soft edge, where an extended light such as the sun's disc is partly hidden.
Temporal class
How a lighting term reaches its value over time: (a) each frame; (b) converging in a world-space or probe cache; (c) reusing screen-space history.
Denoiser
A filter that turns a few noisy rays per pixel into a clean image; class (c) in ADR 0020.
N·L
The cosine between the surface normal and the direction to the light; above 0 the surface faces it.
BLAS
Bottom-level acceleration structure: the GPU's search structure over one Mesh's triangles.
TLAS
Top-level acceleration structure: the GPU's search structure over Instances, each placing a BLAS.
Static TLAS
The TLAS of every static Instance whose Mesh rays trace, tagged with its slot, in a frame anchored at load (#202).
Ray query
A ray traced inline from an ordinary shader: initialise, proceed, read the committed hit.
Committed intersection
The hit a ray query has accepted; an opaque hit is committed at once.
Opaque
Geometry whose every hit counts; every Praxis BLAS is built OPAQUE.
Cull mask
The ray's 8-bit mask; it meets only TLAS entries whose mask shares a bit with it.
Residency set
Metal's set of allocations to keep resident, reachable by the GPU; wgpu makes one per command buffer that uses or builds a TLAS.
Anchor
The integer coordinates of the 64 m grid cell that holds a World position.
The TLAS's frame
The f32 frame whose origin is the corner of the camera's Anchor in the first Frame data.
Frame data
The per-frame changed Instances, lights and camera the renderer consumes in place of the ECS World.
Shadow acne
Speckled false shadow where rays starting on a surface hit it because of rounding.
Normal offset
Moving a ray's origin off its surface along the normal, by more than the rounding error.
f32 rounding
f32 keeps 24 significant bits: a coordinate is stored within 2−24 of its magnitude.
Smooth shading
Shading flat triangles with normals interpolated from their vertices.
Terminator
The line where a curved surface turns from facing a light to facing away.
Fog line
A line under "Not yet specified" on the map issue: known work, not yet a ticket.
MSAA
Multisample anti-aliasing: here 4 samples per pixel, one fragment per covering triangle, averaged by the resolve.
Fragment
One triangle's shading of one pixel; with MSAA it writes the samples the triangle covers.
Centroid interpolation
Interpolating at a point within the samples the fragment covers, not at the pixel's centre.
Concave crease
An edge where two faces meet in a valley, so each face's plane, extended, runs under the other.
Oracle
A test's independent computation of the right answer, here the true sphere and plane in f64.
One-pixel band
Pixels whose centre sees something other than one of its 8 neighbours' centres; not judged.
Render switches
The variants a Renderer is created with and keeps; their defaults are what ships.
Moving Instance
An Instance whose entity the fixed schedule moves; in no TLAS until #204, then in the dynamic TLAS, rebuilt each frame.
Capability floor
The GPU features and limits the engine requires to start; ray queries are among them.
Reference scene
A Scene with a fixed camera path and injected time, the same frames every run.
Golden
A committed image that a Reference scene must reproduce within tolerance.
Point light
A light at one position that shines in all directions, its intensity in candela (KHR_lights_punctual).
Range
The distance past which a point light adds nothing: its attenuation window reaches zero there.
Froxel
A cell of the view frustum, bounded in screen space and depth, that holds the list of lights reaching it.
Dynamic TLAS
The TLAS of the Moving Instances, rebuilt each frame; a ray the static TLAS lets through meets it (#204).
Brute force
Lighting each pixel with every point light, with the froxel_lights Render switch off.
Popping
A visible jump between frames that nothing in the scene caused, such as a shadow appearing as the camera moves.
Shadow mask
A bit per light at each pixel, set where that light is hidden from the surface the pixel's nearest sample shows, by the surface itself where it faces away or by what its shadow ray finds, in the order the lit pass sums the pixel's lights, up to 59 of them. A compute pass traces it from the depth target; the lit pass reads it.
Compute pass
GPU work that runs a shader over a grid of invocations and draws nothing.
Nearest sample
Of a pixel's four depth samples, the one nearest the camera: under reverse-Z, the greatest depth.
Reverse-Z
Depth cleared to 0 and compared with Greater, with no far plane: the nearer a surface, the greater its depth (ADR 0015).
Face normal
The normal of a surface's flat face, here rebuilt from the positions neighbouring pixels show.
Froxel slice
One of the 32 ranges of depth into which each of the Froxels' screen rectangles is cut.
Occupancy
How many threads a GPU core keeps in flight; a shader that keeps more registers live leaves room for fewer.
Built from ticket #203 and PRs #259 and #258, merged as c4454af and 0db5e46, as verified during the work on #203; from ticket #205 and PR #265 at 5a5f075, as verified during the work on #205 on 2026-10-23; and from ticket #260 and PR #277 at bd7b988, with its final measurements, checked against main at 69e02dc on 2026-10-24; with ADRs 0014, 0019, 0020 and 0021 and the decisions on map #17. Where the guide does its own arithmetic, it says so. The demos are this page's models, computed in f64, and say where they simplify; the oracle demo is a port of tests/shadows.rs as #203 left it and reproduces its 5,869 judged shadowed-ground pixels, and a port of the test at 5a5f075, run for this guide but not on the page, reproduces its 5,851 and 18,719. The three images are the shadows scene's Metal Goldens before the rays, after the sun's and after every light's, re-encoded as JPEG.