WebGPU notes / 44
WebGPU ReSTIR Direct Illumination in Rust and WGSL
Trace the Crytek Sponza atrium and an animated 11,960-triangle Jax character, stream twelve local-light candidates into a 48-byte reservoir per pixel, reuse survivors across time and neighboring pixels, and reconstruct the noisy HDR estimate with temporal accumulation and four a-trous passes.
- Static triangles
- 262,266
- Default DI candidates
- 12 per pixel
- Normal compute passes
- 8 per frame
Resample direct-light candidates instead of shading every light
The previous Metropolis renderer combines a broad GPU-driven feature set. This example isolates one harder lighting problem: choosing a useful local-light sample for every visible point without keeping every candidate. Each pixel streams weighted point- and spot-light samples into one reservoir. Temporal reprojection contributes a prior-frame survivor, spatial reuse contributes compatible neighboring survivors, and a final visibility test is reserved for the selected sample.
The finite sun follows a separate deterministic disk estimator. The ReSTIR DI reservoir therefore chooses among four point and four spot lights, not all nine configured lights. This separation avoids spending stochastic reservoir strata on the dominant source and supplies a stable soft-shadow coverage guide for accumulation and denoising.
The introducing Metropolis and ReSTIR commit added both wrappers, the shared renderer, scene loader, BVH code, three shaders, model assets, and captures. The later geometry and presentation update revised the shared implementation. The small restirdi wrapper selects RestirMode::DirectIllumination; rendering runs through sib::render.
Load a static Sponza BVH and refit animated Jax bounds
The loader reads Sponza's glTF, binary geometry, 69 referenced textures, and committed BVH. The static scene contains 262,266 triangles, 25 materials, and 524,531 BVH nodes—one leaf per triangle in a full binary hierarchy. Every compact node is 32 bytes. Its header records magic RSTBVH01, format version 1, node stride, triangle and node counts, and a geometry fingerprint, so a stale hierarchy is rejected instead of silently pairing with different geometry.
Jax adds one material and 11,960 animated triangles. Rust builds its 23,919-node topology once, advances skeletal animation every update, and at most every 1/30 second regenerates posed triangles and refits bounds without changing child indices. Before the new pose is uploaded, the prior 128-byte triangle records are packed two per 16-texel row in an Rgba32Float texture. The primary shader uses that pose to generate object motion for temporal reprojection.
Scroll sideways to see all table columns.
| Input | Stored bytes | Runtime treatment | Role or provenance |
|---|---|---|---|
sponza.gltf + sponza.bin | 166,406 + 9,528,220 | 262,266 triangles; 25 materials | Crytek Sponza geometry and material graph |
sponza.bvh | 16,785,024 | 524,531 versioned 32-byte nodes | Prebuilt static SAH hierarchy |
| Sponza texture set | 38,292,133 | 69 files resampled into material arrays | Model files use the CryEngine Limited License Agreement |
jax.gltf + jax.bin + base color | 68,324 + 1,957,752 + 13,008 | 11,960 skinned triangles; one appended material | Dynamic occluder and receiver; no separate Jax license is documented here |
restir.wgsl | 80,724 | Primary, DI temporal, spatial, and DI shade entries | Ray traversal, reservoirs, lighting, and accumulation |
| A-trous WGSL + present WGSL | 12,123 + 12,846 | Four wavelet dispatches + full-screen triangle | Variance filtering, reconstruction, and ACES fit |
| Vazirmatn font | 122,752 | Embedded into the executable | egui controls; SIL OFL 1.1 |
Native loads the Sponza resources in parallel filesystem threads. WebAssembly fetches the batch concurrently inside a temporary Worker and then starts sib::render asynchronously. Both paths consume the same prebuilt static BVH; the browser does not rebuild a half-million-node hierarchy at startup.
Trace primary rays and write a 64-byte G-buffer
The camera uses a 55-degree perspective projection with 0.05 and 120 near and far planes. Each frame applies one point from a 16-step Halton(2,3) jitter sequence, reconstructs a world ray, and tests the static and dynamic BVHs. Traversal uses an explicit 128-entry stack, visits the nearer child first, and alpha-tests non-opaque material samples at mip zero. The closest hit is expanded into world position, geometric or normal-mapped normal, base color, roughness, metallic value, triangle index, and object class.
Four vec4 values form each 64-byte G-buffer record. The fourth vector stores previous-frame UV, triangle index, and validity. Static motion comes from the previous view-projection matrix. A Jax hit also reconstructs the same barycentric point from the prior-pose triangle texture before projecting it, so camera motion and skeletal motion both participate in reprojection.
Scroll sideways to see all table columns.
| Record or resource | Stride or format | Fields or count | Purpose |
|---|---|---|---|
| Triangle | 128 bytes | 3 positions, 3 normals, UVs, material and object flag | Static and animated ray geometry |
| BVH node | 32 bytes | AABB + two child or leaf words | Software ray traversal |
| G-buffer pixel | 64 bytes | Position/depth, normal/roughness, albedo/metallic, reprojection data | Reuse, filtering, and presentation guides |
| Reservoir pixel | 48 bytes | Sample position/normal/radiance, target, weight sum, packed metadata | One selected local-light sample |
| Reservoir metadata | 32 bits | 8-bit kind, 16-bit count, 8-bit age | Light index, represented sample count, temporal age |
| Scene uniforms | 256 bytes | 2 matrices, camera/time, counts, resolution, 5 settings vectors | One complete queue write per rendered frame |
Stream twelve unshadowed local-light candidates into one reservoir
For a valid surface, the DI candidate pass randomly chooses among light indices 1 through 8: four finite point emitters and four disk-shaped spot emitters. Each candidate samples a position on its source, evaluates the unoccluded GGX metallic-roughness BRDF contribution, converts it to a luminance target, and multiplies by eight to account for uniform light selection. The default loop considers twelve candidates; the UI exposes 1 through 16, while the shader clamps programmatic input to 32.
let previous_weight = reservoir.weight_sum;
let total_weight = previous_weight + candidate_weight;
if (xi * total_weight < candidate_weight) {
reservoir.sample_position = sample.sample_position;
reservoir.sample_normal = sample.sample_normal;
reservoir.radiance = sample.radiance;
reservoir.target_value = sample.target_value;
}
reservoir.weight_sum = total_weight;
reservoir.count += candidate_count;
The actual WGSL packs count and age into metadata rather than exposing a count field. Candidate generation does not cast a shadow ray. Temporal and spatial re-evaluation also use an unshadowed target. Final shading tests only the surviving local-light sample, avoiding separate random visibility decisions at every reuse stage.
Reproject history, reject mismatches, and rank reciprocal neighbors
Temporal reuse begins at the stored previous UV. It rejects invalid history, static/dynamic class changes, material changes, normal disagreement, and excessive position separation. Dynamic hits must retain the same triangle index. If a newly visible static point reprojects onto Jax's old footprint, a dual-motion fallback follows the occluder's prior displacement once more and accepts only compatible background. The old DI sample is refreshed against the current light before its target and reuse weight are recomputed.
The temporal result and twelve fresh candidates pass through an 8×8 workgroup boiling filter. A contribution weight above ten times the active workgroup average has its weight cleared before it can spread. The normal history cap is 20 represented samples; animating lights shortens the effective DI cap to six.
Spatial reuse generates eight reciprocal XOR neighbors that rotate over frames and remain within the default 12-pixel radius. It scores their depth, normal, roughness, metallic, albedo, and static/dynamic compatibility, then merges the best two valid reservoirs. Reciprocal offsets broaden coverage without atomics or cross-pixel output writes. The standard merge normalization is preserved:
let weight = sample.target_value * history.weight_sum
/ max(history.target_value, 0.000001);
reservoir_stream(
&result,
sample,
weight,
reservoir_count(history),
random(&seed),
);
Resolve visibility, accumulate HDR lighting, and denoise irradiance
The shade pass converts the reservoir into contribution * weight_sum / (count * selected_target), clamps its luminance to 4.5, and samples the selected emitter two to four times according to ray scale. The sun is evaluated independently with twice that base count. At native scale it begins with eight low-discrepancy disk samples and, only at a mixed penumbra pixel, adds eight more. Emission and adjustable environment-derived ambient fill complete the DI signal.
Before filtering, the shader divides lighting by max(albedo, 0.06). It reprojects the prior HDR value and luminance moments, applies visibility, coverage, variance, and motion reactivity, caps history, and stores demodulated lighting plus history count in one Rgba16Float target. Four 5×5 a-trous dispatches use strides 1, 2, 4, and 8. Their depth, normal, metallic, static/dynamic, luminance, and shadow-coverage guides limit bleeding; high variance relaxes filtering, while later stable pixels can skip work.
A three-vertex full-screen pass removes the Halton offset, combines Catmull-Rom reconstruction with geometry-aware filtering at edges, remodulates albedo, performs a restrained variance-aware sharpen, and applies the shared ACES filmic fit. This is the only core raster draw. The traced scene itself is compute generated.
Scroll sideways to see all table columns.
| Order | Pass | Dispatch or draw | Main output |
|---|---|---|---|
| 1 | Primary visibility | ceil(width/8) × ceil(height/8) | Current G-buffer |
| 2 | DI candidates + temporal | Same 8×8 grid | Temporal reservoir buffer |
| 3 | Spatial reuse | Same 8×8 grid | Current/history reservoir buffer |
| 4 | DI shade + temporal accumulation | Same 8×8 grid | HDR output + moments |
| 5–8 | A-trous strides 1, 2, 4, 8 | Four same-size dispatches | Denoise ping/pong; final pong |
| 9 | Present | 3 vertices, 1 instance | Swapchain color |
| After core | Light gizmos, joystick overlay, egui | Optional or generated draws | Loaded swapchain color |
Budget per-pixel history and tune the renderer live
Two 64-byte G-buffers, three 48-byte reservoir buffers, and six Rgba16Float textures—two HDR, two denoise, and two moment targets—consume 320 logical bytes per ray pixel before alignment and descriptors. The target preserves surface aspect ratio, caps the largest dimension at 1080, then applies the 0.35-to-1.0 ray scale. Resizing or changing scale rebuilds all size-dependent resources and clears history. At 1080×608, those listed frame resources alone occupy 210,124,800 bytes, about 200.4 MiB.
The geometry buffer reserves 35,100,928 bytes for 274,226 triangle records. Static and dynamic BVHs reserve 17,550,400 bytes, and the previous-pose texture reserves 1,530,880 bytes. The 26-layer base-color, normal, and metallic-roughness arrays generate complete CPU mip chains and contain 54,525,848 logical texel bytes. These figures describe declared payload, not measured VRAM after driver padding, egui, the surface, staging, or implementation overhead.
Scroll sideways to see all table columns.
| Control | Default | UI range or choices | Effect |
|---|---|---|---|
| Candidates / neighbors / radius | 12 / 2 / 12 px | 1–16 / 1–8 / 2–36 | Fresh work and spatial reuse breadth |
| History cap | 20 | 1–64 | Reservoir and radiance memory length |
| Temporal / spatial reuse | On / on | Checkboxes | Enable either reuse stage |
| Jax / lights / gizmos | On / off / off | Checkboxes | Scene motion and light diagnostics |
| Sun / point / spot intensity | 5.25 / 28 / 72 | 0–12 / 0–100 / 0–180 | Source radiance controls |
| Denoise / exposure / ambient | On / 0.95 / 0.38 | Toggle / 0.25–3 / 0–1 | Reconstruction and final brightness |
| Ray scale | 1.0 | Fast 0.65, Balanced 0.85, Native 1.0; slider 0.35–1 | Trace resolution and visibility samples |
| Debug view | Off | Off, Albedo, Reservoir light, Stable direct, Ambient | Isolates terms; non-off views bypass denoising |
Use W/A/S/D to move and the arrow keys to look. Mouse or touch dragging creates a movement stick on the left half and a look stick on the right. The camera moves at 3.5 units per second and looks at 1.45 radians per second; frame delta is clamped to 1/15 second. UI interaction consumes input before the joystick. Changing any non-scale setting invalidates history, and “Reset history” does the same explicitly.
Native requests TIMESTAMP_QUERY and reports the eight compute-pass timings through three asynchronous readback buffers. The WebAssembly configuration requests no timestamp feature, so GPU timing can be unavailable while the rendering path remains the same. Every normal frame writes the 256-byte uniform block and all 64 reserved light records (4,096 bytes); a 30 Hz Jax pose update additionally writes prior triangles, current triangles, and refit BVH nodes.
Understand the estimator's deliberate limits
This is a software ray tracer in portable compute shaders, not hardware ray tracing. Every primary, shadow, and reuse visibility ray traverses separate static and dynamic BVHs with a fixed 128-node stack. The renderer has no adaptive ray queue, compaction, hardware traversal, or out-of-core scene path. Large per-pixel history buffers make native resolution costly, and GI in the next article adds many more ray traversals.
The output is intentionally stabilized rather than mathematically unbiased. Luminance clamps, a boiling filter, finite history, deterministic sun sampling, ambient fill, temporal neighborhood clamps, demodulation bounds, and a-trous filtering all trade strict Monte Carlo convergence for an interactive image. Alpha-mask and alpha-blend materials both become a cutoff test during traversal, and that test samples mip zero. The texture LOD is a distance heuristic rather than a ray differential.
Reservoir metadata limits represented count to 65,535 and age to 255, although the UI history cap is at most 64. Animated-light history is shorter still. The capture's high candidate and neighbor counts do not represent current defaults or a measured recommendation. Device timing, memory residency, browser limits, and visual stability will vary substantially across adapters.
Run and extend the example
Run ReSTIR DI natively from the repository root:
cargo run --example restirdi
Build and serve the WebAssembly page:
scripts/build-wasm.sh --release restirdi
cargo run --bin serve
Open http://127.0.0.1:8080/restirdi/ in a browser with WebGPU support. Keep the Sponza, Jax, texture, and BVH asset paths intact. Useful experiments include plotting reservoir age, comparing reuse stages independently, measuring ray scale against the 320-byte-per-pixel history cost, replacing the alpha cutoff with stochastic transparency, or validating the estimator against a small offline reference.