Crafter.Graphics/WAVEFRONT-DESIGN.md
catbot 27d7e84cb3 feat(webgpu-rt): atomic pixel accumulator + raysPerPixel for >1 ray/pixel/bounce (#30)
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>
2026-06-09 22:45:25 +00:00

115 lines
6.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 calls
`rtEmitPrimaryRay(...)`. 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]`, publishes `traceCount=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`, writes `HitResult` i (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 calls `rtAccumulate(pixel,rgb)` and `rtEmitRay(...)`.
- **RESOLVE** (1 thread/pixel, 8×8): reads accum slot, runs user `resolve_main`
if present else passthrough; writes outImage.
## Buffers (rtState; per-bounce ray capacity = `RTPass::raysPerPixel`·W·H)
- `wfRaysA`,`wfRaysB`: array<WfRay>, 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<HitResult> (sized = ray capacity).
- `wfPayload`: array<Payload> — declared in CODEGEN region after user Payload.
- `wfAccum`: array<atomic<u32>>, 4 slots per pixel (W*H, RGBA as f32 bit
patterns — 16 B/pixel). `rtAccumulate` CASes 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
- [x] baseline VulkanTriangle renders (megakernel)
- [x] wavefront prelude + codegen (5 entry points share one module)
- [x] VulkanTriangle on wavefront (maxDepth=1) — bit-identical to baseline
- [x] indirect-dispatch bounce loop + PREP (cross-pass atomics proven)
- [x] RTStress example (N³ cube grid) + GPU timestamp-query per-pass HUD
- [x] Sponza port (shadow ray in SHADE) — renders the atrium correctly
- [x] ordered (nearest-child-first) traversal
- [x] dynamic TLAS sweep-tree depth (next_pow2 instances)
- [x] device limits (maxBufferSize / maxStorageBufferBindingSize /
maxComputeWorkgroupsPerDimension) + timestamp-query feature
- [x] megakernel dead path removed (RT pipeline builds only wavefront)
- [x] atomic pixel accumulator (#30) — `rtAccumulate` is a per-channel
f32 CAS over `array<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 by `examples/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.