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

WebGPU notes   /   45

WebGPU ReSTIR Global Illumination in Rust and WGSL

Launch three cosine-weighted indirect paths per visible pixel, store one secondary surface or environment direction in a compact reservoir, reconnect it through visibility and a solid-angle Jacobian across frames and neighboring pixels, then combine the reused bounce with separately sampled direct light.

Default GI candidates
3 per pixel
Indirect bounce setting
2
Reservoir stride
48 bytes

Reuse indirect path samples without pretending they are direct lights

The previous ReSTIR direct illumination example stores a selected point or spot emitter in each reservoir. ReSTIR GI changes the sample domain. A candidate is a cosine-weighted direction from the primary surface, followed by either an environment miss or a hit on Sponza or Jax. Its reservoir stores that secondary position, normal, estimated outgoing radiance, kind, target value, accumulated weight, represented count, and age.

That distinction changes the cost and reuse rules. DI can form twelve unshadowed candidates cheaply and defer visibility to the winner. Every GI candidate already traces a bounce and evaluates lighting at the secondary point, so the default pool is only three. Temporal and spatial reconnection must test visibility, transform the old sampling measure with a Jacobian, and reject animated secondary samples whose radiance is stale.

The shared implementation arrived in the Metropolis and ReSTIR introduction. The later geometry update revised its renderer, scene handling, and shaders. The restirgi entry selects RestirMode::GlobalIllumination; DI and GI otherwise share the current Rust renderer, reservoir WGSL, denoiser, presentation code, assets, controls, and sib::render host.

Animated Jax character crossing the Crytek Sponza atrium with warm WebGPU ReSTIR global illumination and reservoir controls visible.
Warm indirect light reaches the Sponza arches and the animated Jax character in this 1280×720 capture. The image is a 299,076-byte JPEG and 117,570-byte WebP. Its panel shows 16 candidates and eight neighbors, not the current GI defaults of three and two. The displayed five FPS, Apple M2 Max timing, and 1080×678 ray target are capture context rather than a benchmark.

Share the Sponza hierarchy, skinned Jax refit, and material arrays

GI traces exactly the same geometry as DI: 262,266 static Sponza triangles in a committed 524,531-node BVH plus 11,960 Jax triangles in a 23,919-node dynamic hierarchy. The scene loader validates the static asset against triangle count and a geometry fingerprint. The acceleration module stores each AABB node in 32 bytes, marks a leaf in the top bit of its first word, and preserves topology while refitting the animated bounds at 30 Hz.

Primary hits evaluate base-color, normal, and metallic-roughness texture arrays with generated mip chains. Twenty-five Sponza materials become 26 when Jax is appended. Jax receives a base-color layer, neutral normal and material-detail layers, roughness 0.62, and metallic 0.05. Its previous triangle pose is kept in a 16-texel-wide Rgba32Float texture so the G-buffer can store skeletal motion as well as camera motion.

Scroll sideways to see all table columns.

Shared ReSTIR GI scene and acceleration payload
ResourceLogical elementsLogical bytesUpdate behavior
Triangle storage262,266 static + 11,960 dynamic at 128 bytes35,100,928Sponza once; Jax pose at most 30 Hz
BVH storage524,531 static + 23,919 dynamic at 32 bytes17,550,400Prebuilt static nodes; dynamic bound refit
Previous Jax pose16×5,980 Rgba32Float texels1,530,880Prior triangles uploaded before each new pose
Material texture arrays26 layers at 512² + two arrays at 256², full mips54,525,848Uploaded once; base color is sRGB
Materials + light capacity64 materials + 64 lights at 64 bytes8,192Materials once; 64-light region every frame
Scene uniformTwo matrices and eight 16-byte vectors256Completely rewritten every frame

The stored model package includes sponza.gltf, the prebuilt BVH, 69 Sponza textures, jax.gltf, and Jax base color. Sponza's model files carry the CryEngine Limited License Agreement. This repository does not document a separate license for Jax, so reuse outside the project needs an independent provenance check.

Trace three cosine-weighted paths and estimate secondary radiance

After the 8×8 primary pass fills a 64-byte G-buffer, cs_temporal_gi generates three fresh candidates per valid pixel. It samples a cosine-weighted hemisphere about the primary normal, offsets the origin with geometric-normal and scale-aware epsilon terms, and traverses both BVHs. A miss stores a point 1,000 units along the direction, kind zero, an opposite normal, and the procedural environment multiplied by 1.5.

A hit stores the secondary surface position and normal. Kind one marks Sponza and kind two marks Jax. Its radiance begins with material emission plus direct lighting selected from eight RIS candidates. At the default two-bounce setting, the shader launches one more cosine-weighted ray from that secondary point. A tertiary hit adds emission and direct light selected from four candidates; a miss uses 1.5 times the environment. The secondary BRDF multiplies that incoming term by π, and final secondary radiance is clamped to luminance 4.

let direction = sample_cosine_hemisphere(
  primary.normal,
  vec2<f32>(random(&seed), random(&seed)),
);
let hit = trace_scene_ignoring(
  origin, direction, 1000.0, primary.triangle_index,
);
if (hit.valid) {
  let secondary = surface_from_hit(origin, direction, hit);
  sample.sample_position = secondary.position;
  sample.sample_normal = secondary.normal;
  sample.radiance = secondary_radiance(secondary, view, &seed);
}
sample.target_value = luminance(gi_contribution(primary, sample));

The label “GI bounces” is easy to overread. The reservoir always represents the first indirect connection. Setting one stops at lighting evaluated on that secondary surface; setting two adds one tertiary ray inside its stored radiance. This is a bounded one- or two-bounce estimator, not an arbitrary-depth path tracer.

Scroll sideways to see all table columns.

How DI and GI use the shared reservoir machinery
PropertyReSTIR DIReSTIR GIConsequence
Default fresh candidates123GI candidates contain full bounce work
Stored sampleLocal emitter positionSecondary surface or environment directionDifferent target and reuse domain
Candidate visibilityDeferredPrimary-to-secondary inherent in path; reused connections testedGI pays more traversals
Temporal stale ruleRefresh current light sampleReject history whose secondary sample is dynamic JaxAvoids reusing old animated radiance
Reuse measureTarget ratioTarget ratio × solid-angle JacobianCorrects origin changes near geometry
Boiling threshold10× tile average25× tile averagePreserves legitimate wider GI weights
Final reservoir clampLuminance 4.5Luminance 5.5Bounds fireflies differently

Reconnect compatible paths with visibility and a solid-angle Jacobian

Temporal reprojection uses current and previous G-buffers, triangle-aware dynamic motion, and a dual-motion fallback for static background revealed behind Jax. It rejects class, normal, position, roughness, metallic, and albedo mismatches. GI also rejects any history reservoir whose selected secondary hit was dynamic; otherwise it recomputes the target at the current primary point and casts a visibility ray to the stored secondary position.

Moving a path sample from its old primary origin to a new one changes the solid-angle measure. For non-environment GI samples, the shader multiplies the reuse weight by:

let jacobian =
  (cosine_new / max(cosine_old, 0.0001))
  * (distance_old_squared / distance_new_squared);
return clamp(jacobian, 0.0, 4.0);

Either connection shorter than 0.25 world units is rejected because its squared-distance ratio can explode. Grazing results are capped at four. Environment samples are directional and use a Jacobian of one. The reused sample then enters the same weighted reservoir stream with its represented count, after which the normal history cap of 20 is applied.

Spatial reuse ranks eight reciprocal XOR candidates and merges the best two inside a default 12-pixel radius. Static and dynamic pixels never mix. Every accepted GI neighbor must pass geometry and material compatibility, current target evaluation, reconnection visibility, and the Jacobian. These tests make GI reuse more expensive than copying a nearby radiance value, but they preserve the path interpretation.

Combine reused indirect light with separately sampled direct sources

The selected GI reservoir estimates BRDF * stored_radiance * PI * weight_sum / (count * target). Final shading casts a visibility ray to the stored secondary point and clamps the result to luminance 5.5. A secondary sample on animated Jax is multiplied by 0.18 before the primary BRDF to suppress a bright contact halo; this is an explicit artistic bias, not part of the general ReSTIR GI estimator.

Direct light does not disappear in GI mode. The sun uses the same deterministic disk and penumbra refinement as DI. Local direct illumination is estimated separately: four repetitions each select among eight local-light candidates and trace the selected visibility, then their average is clamped to luminance 3. Material emission and environment-derived ambient fill complete the signal. GI defaults to exposure 1.15 and ambient 0.20, lower fill than DI because reused bounce light is expected to illuminate shadowed regions.

Scroll sideways to see all table columns.

Default ReSTIR GI shading terms
TermEstimatorClamp or scaleVisibility
Indirect reservoir3 fresh paths + temporal + 2 spatial survivorsLuminance 5.5; dynamic secondary radiance ×0.18Reconnect winner to primary
Sun directLow-discrepancy disk, 8 base samples at native scaleUp to 16 samples in a mixed penumbraOne BVH shadow ray per disk sample
Local direct4 repeated RIS estimates from up to 8 lightsLuminance 3Selected sample in each repetition
AmbientAlbedo × nonmetal factor × environment(normal)Default control 0.20; GI factors 0.45 and 0.05No ray
EmissionMaterial emissionIncluded directlyNo extra ray

Accumulate demodulated GI and adapt four a-trous passes

GI divides the combined lighting by a clamped albedo before accumulation, so the filter operates primarily on irradiance instead of smearing texture detail. Previous HDR and first two luminance moments are fetched at the reprojected pixel. History is rejected on geometry, motion, or shadow-guide changes, constrained to a current-color neighborhood, and blended with variance-driven reactivity. Fresh GI pixels receive an additional luminance-2 cap until their history count reaches 2.5.

The shared a-trous shader runs 5×5 B3-spline kernels at strides 1, 2, 4, and 8. GI uses a wider luminance stop and larger variance gain than DI. The filter never crosses the static/dynamic boundary and weighs depth, normal, metallic value, luminance, and sun coverage. Later passes can copy stable pixels without applying the full kernel.

The present shader compensates the Halton jitter, clamps Catmull-Rom reconstruction to compatible neighbors, switches to geometry-aware bilinear filtering at silhouettes, remodulates albedo, sharpens only stable interior lighting, and applies the shared ACES fit from tonemap.wgsl.

Run eight compute passes and expose the quality tradeoffs

Normal GI rendering records primary, GI candidate plus temporal, spatial, and GI shade compute passes, followed by four a-trous dispatches. Every compute entry uses 8×8 workgroups and dispatches ceil(width/8) × ceil(height/8). A full-screen triangle presents the result; optional light gizmos, a joystick overlay, and egui load the same swapchain image afterward. Debug view Albedo skips candidate and spatial work, and any non-off debug view disables the denoiser.

The size-dependent set holds two 64-byte G-buffers, three 48-byte reservoirs, two HDR outputs, two denoise textures, and two moment textures. All six textures are Rgba16Float, making the total 320 logical bytes per ray pixel. Even-frame and odd-frame bind groups swap G-buffer, history reservoir, HDR, and moment roles without rebinding individual resources. The largest target dimension is capped at 1080 before the ray scale is applied.

Scroll sideways to see all table columns.

Current ReSTIR GI defaults and live controls
ControlDefaultUI range or choicesGI tradeoff
Candidates31–16Full secondary-path work per fresh sample
Neighbors / radius2 / 12 px1–8 / 2–36Reuse breadth and visibility/Jacobian cost
History cap201–64Convergence against lag and correlation
GI bounces21–2Optional tertiary ray inside secondary radiance
Temporal / spatial reuseOn / onCheckboxesPath reuse stages
Animate Jax / lightsOn / offCheckboxesDynamic geometry and source stability
Denoise / exposure / ambientOn / 1.15 / 0.20Toggle / 0.25–3 / 0–1Noise suppression and output balance
Ray scale1.00.35–1; Fast 0.65, Balanced 0.85, Native 1Ray count, visibility quality, and history memory

W/A/S/D moves the FPS camera and the arrow keys look. Mouse and touch drags create left-half movement and right-half look sticks. The camera uses a 55-degree field of view, 3.5-unit movement speed, and 1.45-radian look speed. Resize and ray-scale changes rebuild all frame resources; any other control change clears history. The panel also reports CPU time, FPS, adapter, target dimensions, triangle and BVH counts, light counts, and, when available, each of the eight GPU compute timings.

Native requests WebGPU timestamp queries. WebAssembly requests no timestamp feature but otherwise runs the same shader and BVH path. Native asset batches use filesystem threads; the browser fetches Sponza resources concurrently inside a Worker, awaits Jax, and starts asynchronously. Both load the committed Sponza BVH and refit only Jax at runtime.

Understand the quality and portability limits

This implementation favors a stable interactive demonstration over an unbiased reference. It clamps secondary radiance, reused estimates, direct estimates, temporal spikes, fresh GI pixels, demodulated luminance, and the Jacobian. It rejects short reconnections, suppresses dynamic-secondary bounce light, imposes finite history, adds an ambient floor, and applies temporal plus spatial denoising. Those choices control fireflies and ghosting but alter the converged result.

The bounded bounce setting, one selected indirect sample per pixel, analytic environment, simple light rig, and per-pixel wavelet filter do not reproduce a production path tracer. There is no multiple-importance sampling between BSDF and emitters, Russian roulette, emissive-triangle sampling, participating media, transmission, stochastic alpha, ray sorting, hardware traversal, or offline ground-truth comparison. Metallic-roughness BRDF evaluation is useful, but the estimator also contains artistic constants such as environment ×1.5 and the Jax bounce attenuation.

Software BVH traversal and 320 bytes of frame history per ray pixel are demanding. At a 1080×608 target, listed size-dependent resources alone occupy about 200.4 MiB before driver overhead. GI adds secondary, optional tertiary, direct-light, reuse-visibility, sun, and final-visibility traversals whose exact count varies by hits, compatibility, and penumbrae. The capture's five FPS is neither a promise nor a cross-device comparison.

Alpha-blended Sponza materials are reduced to cutoff tests, texture LOD uses a distance approximation, and traversal holds a fixed 128-node stack. Jax asset licensing is not recorded separately in the repository. These are practical boundaries to address before treating the sample as a reusable production renderer.

Run and extend the example

Run ReSTIR GI natively from the repository root:

cargo run --example restirgi

Build and serve its WebAssembly page:

scripts/build-wasm.sh --release restirgi
cargo run --bin serve

Open http://127.0.0.1:8080/restirgi/ with WebGPU enabled and preserve the model, texture, and BVH paths. Productive experiments include showing the stored secondary kind and age, comparing one and two bounce settings, disabling each reuse stage, removing clamps against a reference image, adding BSDF/light multiple-importance sampling, or moving path and filter work to adaptive queues.