Wavefront RT: atomic pixel accumulator to allow >1 ray per pixel per bounce #30

Closed
opened 2026-06-10 00:25:00 +02:00 by jorijnvdgraaf · 0 comments

Summary

The WebGPU wavefront ray tracer's per-pixel accumulator (wfAccum) is
non-atomic by design, which caps the renderer at one ray per pixel per
bounce
. This blocks multi-light shadowing — where a SHADE invocation wants to
emit one shadow ray per light and have each resolve independently — because
the multiple shadow rays for a single pixel resolve in the same SHADE pass and
race on the non-atomic read-modify-write.

Request: an atomic accumulate path so a closest-hit shader can safely emit
N shadow rays per pixel in a single bounce, matching what the hardware (Vulkan)
RT backend already does trivially.

Where the limitation lives

  • additional/dom-webgpu.js:1677@group(1) @binding(13) var<storage,read_write> wfAccum : array<vec4<f32>>;
  • additional/dom-webgpu.js:1701-1703rtAccumulate:
    // Safe without atomics: at most one ray per pixel per bounce, and bounces
    // run in separate passes (implicit barrier between them).
    fn rtAccumulate(rgb: vec3<f32>) {
        wfAccum[_wfPixel] = wfAccum[_wfPixel] + vec4<f32>(rgb, 0.0);
    }
    
  • implementations/Crafter.Graphics-PipelineRTWebGPU.cpp — codegen sites that
    touch wfAccum directly:
    • GENERATE clear (~L271): wfAccum[pixel] = vec4<f32>(0.0, 0.0, 0.0, 0.0);
    • RESOLVE read (~L301): runResolve(gid.xy, wfAccum[pixel])

Why it matters (downstream)

3DForts issue #153: a nuclear blast should be a real shadow-casting light, and
every dynamic light up to the maxLights budget should cast a shadow ray
(today only the single brightest light per surface is shadowed; the rest leak
in as unshadowed fill, so blast light passes straight through walls). On the
Vulkan backend this is a trivial loop of synchronous traceRayEXT calls
returning a shadowValue. On the WebGPU wavefront backend it requires emitting
one shadow ray per light from the same closest-hit invocation — i.e. several
rays for the same pixel in one bounce, whose miss-stage rtAccumulate(payload.color)
calls then race.

Proposed change

Make the accumulator atomic (float add via CAS), so >1 ray/pixel/bounce is safe:

  • Change wfAccum to array<atomic<u32>> with 4 u32 slots per pixel (RGBA;
    same 16 B/pixel footprint, no host-side allocation change).
  • Add a float-atomic-add helper and rewrite rtAccumulate to CAS each channel:
    fn _wfAtomicAddF32(slot: u32, v: f32) {
        var old = atomicLoad(&wfAccum[slot]);
        loop {
            let sum = bitcast<u32>(bitcast<f32>(old) + v);
            let r = atomicCompareExchangeWeak(&wfAccum[slot], old, sum);
            if (r.exchanged) { break; }
            old = r.old_value;
        }
    }
    fn rtAccumulate(rgb: vec3<f32>) {
        let base = _wfPixel * 4u;
        _wfAtomicAddF32(base + 0u, rgb.x);
        _wfAtomicAddF32(base + 1u, rgb.y);
        _wfAtomicAddF32(base + 2u, rgb.z);
    }
    
  • Update the two codegen sites in PipelineRTWebGPU.cpp:
    • GENERATE: atomicStore 0 to the 4 slots (bitcast(0.0) == 0u).
    • RESOLVE: atomicLoad the 4 slots, bitcast<f32>, assemble the vec4<f32>
      passed to runResolve.

All existing RT consumers (Sponza, RTStress, …) emit ≤1 ray/pixel/bounce, so
their output is unchanged — this is strictly a correctness-preserving widening.

Tradeoffs / open questions

  • Cost: every rtAccumulate becomes a CAS loop (3 channels). Under low
    contention the CAS succeeds first try; only the multi-shadow-ray bounce sees
    real contention. If the per-accumulate cost is a concern for simple scenes,
    the atomic path could be gated behind a pipeline flag (e.g. an
    RTPass/pipeline option) so single-ray consumers keep the plain add.
  • Alternative considered: chaining shadow rays across bounces (each light's
    ray emits the next on resolve), which needs no accumulator change but raises
    the required max ray-recursion depth to ~N and serializes N barriered passes.
    Heavier at runtime and more shader complexity; the atomic accumulator is the
    cleaner primitive.

Filed from 3DForts (#153). Happy to send the PR if the approach (and the
flag-gating question) looks right.

## Summary The WebGPU wavefront ray tracer's per-pixel accumulator (`wfAccum`) is non-atomic by design, which caps the renderer at **one ray per pixel per bounce**. This blocks multi-light shadowing — where a SHADE invocation wants to emit one shadow ray *per light* and have each resolve independently — because the multiple shadow rays for a single pixel resolve in the same SHADE pass and race on the non-atomic read-modify-write. Request: an **atomic accumulate path** so a closest-hit shader can safely emit N shadow rays per pixel in a single bounce, matching what the hardware (Vulkan) RT backend already does trivially. ## Where the limitation lives - `additional/dom-webgpu.js:1677` — `@group(1) @binding(13) var<storage,read_write> wfAccum : array<vec4<f32>>;` - `additional/dom-webgpu.js:1701-1703` — `rtAccumulate`: ```wgsl // Safe without atomics: at most one ray per pixel per bounce, and bounces // run in separate passes (implicit barrier between them). fn rtAccumulate(rgb: vec3<f32>) { wfAccum[_wfPixel] = wfAccum[_wfPixel] + vec4<f32>(rgb, 0.0); } ``` - `implementations/Crafter.Graphics-PipelineRTWebGPU.cpp` — codegen sites that touch `wfAccum` directly: - GENERATE clear (~L271): `wfAccum[pixel] = vec4<f32>(0.0, 0.0, 0.0, 0.0);` - RESOLVE read (~L301): `runResolve(gid.xy, wfAccum[pixel])` ## Why it matters (downstream) 3DForts issue #153: a nuclear blast should be a real shadow-casting light, and **every** dynamic light up to the `maxLights` budget should cast a shadow ray (today only the single brightest light per surface is shadowed; the rest leak in as unshadowed fill, so blast light passes straight through walls). On the Vulkan backend this is a trivial loop of synchronous `traceRayEXT` calls returning a `shadowValue`. On the WebGPU wavefront backend it requires emitting one shadow ray per light from the same closest-hit invocation — i.e. several rays for the same pixel in one bounce, whose miss-stage `rtAccumulate(payload.color)` calls then race. ## Proposed change Make the accumulator atomic (float add via CAS), so >1 ray/pixel/bounce is safe: - Change `wfAccum` to `array<atomic<u32>>` with 4 u32 slots per pixel (RGBA; same 16 B/pixel footprint, no host-side allocation change). - Add a float-atomic-add helper and rewrite `rtAccumulate` to CAS each channel: ```wgsl fn _wfAtomicAddF32(slot: u32, v: f32) { var old = atomicLoad(&wfAccum[slot]); loop { let sum = bitcast<u32>(bitcast<f32>(old) + v); let r = atomicCompareExchangeWeak(&wfAccum[slot], old, sum); if (r.exchanged) { break; } old = r.old_value; } } fn rtAccumulate(rgb: vec3<f32>) { let base = _wfPixel * 4u; _wfAtomicAddF32(base + 0u, rgb.x); _wfAtomicAddF32(base + 1u, rgb.y); _wfAtomicAddF32(base + 2u, rgb.z); } ``` - Update the two codegen sites in `PipelineRTWebGPU.cpp`: - GENERATE: `atomicStore` 0 to the 4 slots (bitcast(0.0) == 0u). - RESOLVE: `atomicLoad` the 4 slots, `bitcast<f32>`, assemble the `vec4<f32>` passed to `runResolve`. All existing RT consumers (Sponza, RTStress, …) emit ≤1 ray/pixel/bounce, so their output is unchanged — this is strictly a correctness-preserving widening. ### Tradeoffs / open questions - **Cost:** every `rtAccumulate` becomes a CAS loop (3 channels). Under low contention the CAS succeeds first try; only the multi-shadow-ray bounce sees real contention. If the per-accumulate cost is a concern for simple scenes, the atomic path could be gated behind a pipeline flag (e.g. an `RTPass`/pipeline option) so single-ray consumers keep the plain add. - **Alternative considered:** chaining shadow rays across bounces (each light's ray emits the next on resolve), which needs no accumulator change but raises the required max ray-recursion depth to ~N and serializes N barriered passes. Heavier at runtime and more shader complexity; the atomic accumulator is the cleaner primitive. Filed from 3DForts (#153). Happy to send the PR if the approach (and the flag-gating question) looks right.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Catcrafts/Crafter.Graphics#30
No description provided.