wfAccum becomes array<atomic<u32>> (4 slots/pixel, f32 bit patterns — same 16 B/pixel footprint) and rtAccumulate CASes each channel, so N rays for one pixel may resolve in the same SHADE pass without the read-modify-write racing. GENERATE clears with atomicStore (bitcast(0.0) == 0u); RESOLVE atomicLoads + bitcasts the vec4 it hands runResolve. Capacity half: RTPass::raysPerPixel (default 1) scales the wavefront ray/hit/payload buffers to raysPerPixel·W·H rays per bounce so the per-light emits actually fit instead of being dropped by rtEmitRay's capacity guard. The accumulator stays per-pixel. No flag-gating: uncontended the CAS succeeds first try — RTStress SHADE stays at its documented ~1.0 ms and master-vs-branch renders are byte-identical, so single-ray consumers pay nothing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6.9 KiB
WebGPU wavefront RT rewrite — design & progress (issue #3)
Replaces the single megakernel (main, 8×8 tile, per-pixel
raygen→traceRay→CH/miss→store) with a streaming wavefront tracer:
GENERATE → PREP → (TRACE → SHADE → PREP)×maxDepth → RESOLVE, each its own
compute pass, dispatch sizes driven by dispatchWorkgroupsIndirect.
Kernels (all generated/assembled the same megakernel way, just split)
- GENERATE (1 thread/pixel, 8×8): runs user
raygen_main(gid)which callsrtEmitPrimaryRay(...). Clears accum slot + payload slot for the pixel. - PREP (1 thread): reads emit counter for the just-filled ray buffer,
writes indirect args
[ceil(n/64),1,1], publishestraceCount=n, swaps cur/next ray buffer, resets next emit counter. One PREP before first TRACE and one after each SHADE. - TRACE (1 thread/ray, 64-wide, indirect): ZERO user code. Reads ray i,
runs
_rtTraverseTlas, writesHitResulti (t/instanceId/primId/hg/attribs /objToWorld/customIndex/missFlag). - SHADE (1 thread/ray, 64-wide, indirect): reads ray i + hit i + payload
slot p. miss→
runMiss, hit→runClosestHit(unless SKIP_CLOSEST_HIT). User code callsrtAccumulate(pixel,rgb)andrtEmitRay(...). - RESOLVE (1 thread/pixel, 8×8): reads accum slot, runs user
resolve_mainif present else passthrough; writes outImage.
Buffers (rtState; per-bounce ray capacity = RTPass::raysPerPixel·W·H)
wfRaysA,wfRaysB: array, ping/pong. WfRay = origin,tMin,dir,tMax, pixel,flags,cullMask,missIndex,sbtOffset,payloadSlot,kind,_pad. Each holds one bounce's rays;raysPerPixel(default 1) scales them so closest-hit can emit several rays per pixel in one bounce before rtEmitRay drops.wfHits: array (sized = ray capacity).wfPayload: array — declared in CODEGEN region after user Payload.wfAccum: array<atomic>, 4 slots per pixel (W*H, RGBA as f32 bit patterns — 16 B/pixel).rtAccumulateCASes each channel, so a SHADE invocation may emit several rays for the same pixel in one bounce (one shadow ray per light, issue #30) without the accumulates racing.wfCounters: atomic counters: emitA, emitB, trace dispatch args, etc.wfIndirect: INDIRECT dispatch-args buffer.
API (new, breaking)
- raygen:
rtEmitPrimaryRay(origin,tMin,dir,tMax,flags,cullMask,sbtOff,missIdx)→ allocates payloadSlot=pixel, writes ray to current buffer (atomic bump). - CH/miss:
rtEmitRay(origin,tMin,dir,tMax,flags,cullMask,sbtOff,missIdx,payload)spawns into NEXT buffer carrying a payload slot;rtAccumulate(pixel,rgb). rtGetPayload(slot)/ payload passed by value into CH/miss via slot.
Tonemap / resolve
Accum buffer is linear. Optional user WebGPURTStage::Resolve entry
resolve_main(coord:vec2<u32>, hdr:vec4<f32>)->vec4<f32>. None → passthrough.
VulkanTriangle: no resolve (exact match). Sponza: resolve does Reinhard+gamma.
HDR output target (issue #27)
PipelineRTWebGPU::Init(..., hdrOutputFormat) (default RGBA8Unorm = the
canvas ping-pong path, unchanged) can instead point RESOLVE at a user
rgba16float storage texture (RTPass::outTexHandle). The JS side swaps
binding(6)'s WGSL declaration + bind-group-layout format and skips the
ping-pong flip, since the canvas is untouched. With no resolve shader the
default passthrough writes raw linear radiance — the HDR input an app's own
bloom/composite chain (threshold → blur → tonemap → swapchain) reads. See
examples/HDRBloom.
Indirect dispatch (Phase 2 de-risk)
Prove dispatchWorkgroupsIndirect + cross-pass atomic visibility with a toy
"emit N → dispatch N" before wiring real kernels. WebGPU inserts an implicit
barrier between compute passes in one submit, so atomics written in PREP are
visible to TRACE.
maxDepth
Compile/runtime knob. JS unrolls the chain to maxDepth. VulkanTriangle maxDepth=1 (primary only). Sponza maxDepth=2 (primary + shadow).
Status / progress
- baseline VulkanTriangle renders (megakernel)
- wavefront prelude + codegen (5 entry points share one module)
- VulkanTriangle on wavefront (maxDepth=1) — bit-identical to baseline
- indirect-dispatch bounce loop + PREP (cross-pass atomics proven)
- RTStress example (N³ cube grid) + GPU timestamp-query per-pass HUD
- Sponza port (shadow ray in SHADE) — renders the atrium correctly
- ordered (nearest-child-first) traversal
- dynamic TLAS sweep-tree depth (next_pow2 instances)
- device limits (maxBufferSize / maxStorageBufferBindingSize / maxComputeWorkgroupsPerDimension) + timestamp-query feature
- megakernel dead path removed (RT pipeline builds only wavefront)
- atomic pixel accumulator (#30) —
rtAccumulateis a per-channel f32 CAS overarray<atomic<u32>>, lifting the old one-ray-per-pixel- per-bounce cap so closest-hit can emit one shadow ray per light in a single bounce (multi-light shadowing; 3DForts #153). Same 16 B/pixel footprint; uncontended CAS succeeds first try, so single-ray scenes (VulkanTriangle/Sponza/RTStress) are unaffected. Capacity side:RTPass::raysPerPixel(default 1) scales the ray/hit/payload buffers so those N rays/pixel actually fit a bounce instead of being dropped by rtEmitRay's capacity guard. Exercised byexamples/RTMultiShadow. - [~] binding packing (Phase 7): SKIPPED — target device reports 64 storage buffers/stage (≥12), so the merge is unnecessary (issue makes it conditional on <12). NOTE: this only holds because dom-webgpu.js now requests the adapter's reported maxStorageBuffersPerShaderStage at device creation (was hardcoded to 16, which left room for ~1 user storage buffer and broke RT pipelines with ≥2). Devices that genuinely report <12 storage buffers/stage still need this packing.
Measured (this container's GPU, via timestamp-query; NOT a 4090)
Per-pass GPU time, 1920×995, primary+shadow (maxDepth=2):
- RTStress 512 inst: GEN ~0.80ms TRACE ~1.63ms SHADE ~1.00ms total ~3.52ms (~280 fps)
- RTStress 4096 inst: GEN ~0.80ms TRACE ~1.95ms SHADE ~1.00ms total ~3.85ms (~260 fps)
- Sponza: GEN ~0.79ms TRACE ~1.81ms SHADE ~1.00ms total ~3.69ms 8× the instances costs only ~16% more TRACE — the spatial TLAS + ordered descent scale sub-linearly. NOTE: a 4090 number and the TRACE-kernel register/occupancy delta require hardware + a profiler not available in this CI container; the architectural win (TRACE carries zero user code, so its register footprint is the traversal loop alone) is structural.
Files
additional/dom-webgpu.js— prelude (rtWgsl*),wgpuLoadRTPipeline,wgpuDispatchRT, LBVH build, rtState/buffers, device-limit clamp (~L131).implementations/Crafter.Graphics-PipelineRTWebGPU.cpp— assembles user WGSL + entry glue; must emit 5 entry points + payloadStore binding.- examples/{VulkanTriangle,Sponza,RTStress}/*.wgsl + main.cpp.