Chapter 4 · shadow_mask.wgsl
Starting inside the surface
Key idea. Where a triangle grazes or turns from a light that its interpolated normal faces, the ray starts just inside the surface. It finds its nearest hit and passes it only if it is a back face, which means the ray is leaving its own mesh, then asks for any hit beyond. An occluder that is a closed mesh shows the ray a front face first, so its shadow holds.
A ray that starts outside a triangle turned from the light runs back into its own mesh. One that starts inside runs out of it, and the way out is the back of a triangle.
Three outcomes per light
For each light that reaches the pixel, the mask decodes the sample's two normals and picks one of three outcomes:
- Hidden, no ray, where the interpolated normal turns from the light by more than 2°:
dot(interpolated_normal, direction) <= -INTERPOLATED_TURN_MARGIN, sin 2°. The 2° covers the stored normal's error, so the mask never hides a light that the lit pass's interpolated normal, its geometric_normal, still faces. Between there and the horizon, a ray is traced, and the lit pass's clamp of N·L at zero darkens what is past it.
- A ray from outside, where the triangle faces the light by at least 10°, or by at least 5° when its two normals agree within 3°, as on a flat surface: the ray starts along the triangle normal by
SHADOW_RAY_OFFSET_SCALE, 2−16 of the point's largest coordinate and at least 2−16 m, as before (Shadow Rays Explained, chapter 4), and any hit hides the light.
- A ray from inside, everywhere else: the triangle faces the light by less than the margin, or turns from it, while the interpolated normal does not turn from it. The rest of this chapter is about this case.
The 5° margin is wider than the stored normals' error, for a second reason. The mask rebuilds each sample's position from depth, and that position can sit below the surface by more than the fixed ray offset. Under a grazing light, a ray leaving from there meets its own triangle. Sending those rays down the inside path hides the error, at a cost. Every flat floor that a light grazes by less than 5° traces twice. On minification, whose floor lies under grazing point lights, the mask rose from 1.05 to 3.07 ms. At a 2° margin with a 1° agreement, two pixels of the shadows sphere went wrong; an offset eight times larger cleared them. Ticket #368 will size the offset to the depth rebuild's error, after which the margin can drop toward 2°.
The inside ray
let origin = tlas_position - triangle_normal * offset;
let exit = nearest_hit(origin, direction, distance);
if exit.kind == RAY_QUERY_INTERSECTION_NONE {
return false;
}
if exit.front_face || exit.t + offset >= distance {
return exit.front_face;
}
return hit_any(origin, direction, exit.t + offset, distance);
- Start inside. The origin moves the same small offset, but into the surface, against the triangle normal.
- Take the nearest hit.
nearest_hit traces without RAY_FLAG_TERMINATE_ON_FIRST_HIT, so it returns the closest triangle rather than any. It queries the static TLAS, then the dynamic TLAS only up to the static hit, since only a nearer dynamic triangle could replace it.
- Read its face. A ray that starts just behind a triangle facing the light crosses that triangle from behind. One that starts behind a triangle turned from the light runs through the mesh and leaves through a neighbour. Either way the nearest hit is a back face. If the nearest hit is a front face instead, the ray met something else first, and the light is hidden.
- Look beyond. After passing the back face, a second, any-hit query starts at the exit plus the offset. Anything found hides the light: an occluder, or any other part of the same mesh.
For a point light, an exit within one offset of the light settles it: a front face there hides the light, and a back face does not. The second query would have nothing left to search. Starting it there also keeps every ray's tmin at or below its tmax, which both Metal and Vulkan require.
Why this does not leak
#203's rejected rule skipped the ray. The inside ray still runs the whole way to the light and passes exactly one triangle, the nearest, and only if that triangle shows its back. An occluder between the point and the light is a closed mesh seen from outside, so the ray enters it through a front face. If that face is the nearest hit, the light is hidden at once. If the ray first leaves its own mesh, the any-hit query beyond finds the occluder. Cast shadows fall into the band just as they fall outside it.
Front and back, per backend
A triangle's front face is the side its normal, taken from its vertices in index order, points toward. The ray query reports whether the ray met it in front_face. The Vulkan specification decides facing in the bottom-level acceleration structure's own space, so a mirrored Instance does not flip it. On Metal, MSL documents a clockwise default winding and naga sets none, which would seem to invert it; the sources do not settle it. tests/shadows.rs does: an inverted front_face would hide the sun across the terminator band it now judges, and it passes on Metal and on lavapipe. Culling back faces in the traversal with RAY_FLAG_CULL_BACK_FACING was not an option: naga's Metal backend ignores that flag. The shader reads front_face itself.