Loading WebGPU example...
More info
Back to the demo ↑

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.

Animated Jax character walking through the Crytek Sponza atrium under WebGPU ReSTIR direct illumination, with reservoir controls open.
The 1280×720 capture shows Jax crossing the Sponza atrium, a soft floor shadow, and the full egui panel. It is stored as a 298,874-byte JPEG and 115,138-byte WebP. The panel records tuned values—16 candidates, eight neighbors, radius 36, and history 64—rather than the current 12, 2, 12, and 20 defaults. Its Apple M2 Max timing and 1080×678 ray target describe that capture only.

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.

ReSTIR DI model, texture, shader, and font inputs
InputStored bytesRuntime treatmentRole or provenance
sponza.gltf + sponza.bin166,406 + 9,528,220262,266 triangles; 25 materialsCrytek Sponza geometry and material graph
sponza.bvh16,785,024524,531 versioned 32-byte nodesPrebuilt static SAH hierarchy
Sponza texture set38,292,13369 files resampled into material arraysModel files use the CryEngine Limited License Agreement
jax.gltf + jax.bin + base color68,324 + 1,957,752 + 13,00811,960 skinned triangles; one appended materialDynamic occluder and receiver; no separate Jax license is documented here
restir.wgsl80,724Primary, DI temporal, spatial, and DI shade entriesRay traversal, reservoirs, lighting, and accumulation
A-trous WGSL + present WGSL12,123 + 12,846Four wavelet dispatches + full-screen triangleVariance filtering, reconstruction, and ACES fit
Vazirmatn font122,752Embedded into the executableegui 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.

Core ReSTIR DI storage layouts
Record or resourceStride or formatFields or countPurpose
Triangle128 bytes3 positions, 3 normals, UVs, material and object flagStatic and animated ray geometry
BVH node32 bytesAABB + two child or leaf wordsSoftware ray traversal
G-buffer pixel64 bytesPosition/depth, normal/roughness, albedo/metallic, reprojection dataReuse, filtering, and presentation guides
Reservoir pixel48 bytesSample position/normal/radiance, target, weight sum, packed metadataOne selected local-light sample
Reservoir metadata32 bits8-bit kind, 16-bit count, 8-bit ageLight index, represented sample count, temporal age
Scene uniforms256 bytes2 matrices, camera/time, counts, resolution, 5 settings vectorsOne 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.

Default ReSTIR DI frame order
OrderPassDispatch or drawMain output
1Primary visibilityceil(width/8) × ceil(height/8)Current G-buffer
2DI candidates + temporalSame 8×8 gridTemporal reservoir buffer
3Spatial reuseSame 8×8 gridCurrent/history reservoir buffer
4DI shade + temporal accumulationSame 8×8 gridHDR output + moments
5–8A-trous strides 1, 2, 4, 8Four same-size dispatchesDenoise ping/pong; final pong
9Present3 vertices, 1 instanceSwapchain color
After coreLight gizmos, joystick overlay, eguiOptional or generated drawsLoaded 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.

Current ReSTIR DI defaults and exposed ranges
ControlDefaultUI range or choicesEffect
Candidates / neighbors / radius12 / 2 / 12 px1–16 / 1–8 / 2–36Fresh work and spatial reuse breadth
History cap201–64Reservoir and radiance memory length
Temporal / spatial reuseOn / onCheckboxesEnable either reuse stage
Jax / lights / gizmosOn / off / offCheckboxesScene motion and light diagnostics
Sun / point / spot intensity5.25 / 28 / 720–12 / 0–100 / 0–180Source radiance controls
Denoise / exposure / ambientOn / 0.95 / 0.38Toggle / 0.25–3 / 0–1Reconstruction and final brightness
Ray scale1.0Fast 0.65, Balanced 0.85, Native 1.0; slider 0.35–1Trace resolution and visibility samples
Debug viewOffOff, Albedo, Reservoir light, Stable direct, AmbientIsolates 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.