diff --git a/TODO-lbvh-sort.md b/TODO-lbvh-sort.md deleted file mode 100644 index 7204b68..0000000 --- a/TODO-lbvh-sort.md +++ /dev/null @@ -1,121 +0,0 @@ -# LBVH parallel radix sort: count-dependent corruption - -> **RESOLVED (strategy #5 — bitonic sort).** The LSD radix scatter was -> replaced with a data-oblivious workgroup **bitonic sorting network** in -> `lbvhBuildMain` (`additional/dom-webgpu.js`, Phase 2). Because a bitonic -> network's compare-exchange schedule depends only on N_PADDED — never on -> the key distribution — it cannot exhibit the count-dependent corruption -> documented below. The sort is now enabled (the old `if (false)` guard is -> gone) so TLAS leaves are Morton (Z-order) coherent again. -> -> Verified on the Firefox/Dawn WebGPU stack with a GPU unit test that diffs -> the kernel output against a CPU oracle across all three required -> distributions (all-uniform, all-one-bucket, and the "small object next to -> a tight cluster" repro) plus random/reverse/empty edge cases — all match -> bit-for-bit, with a valid index permutation. Sponza renders correctly with -> the sort live. The historical analysis below is retained for context. - -## Summary - -The parallel radix sort in `lbvhBuildMain` (additional/dom-webgpu.js) produces -incorrect output that depends on the input distribution. Symptom: geometry in -the BVH-built TLAS appears to flicker (instances missing or pointing at the -wrong entry) as soon as a small object enters the TLAS alongside a tight -cluster (e.g. a single projectile next to a 1000-brace fort in 3DForts). - -Bisected by selectively skipping each LBVH phase. Skipping only the radix -sort eliminates the corruption — every other phase (scene-AABB reduce, -Morton-key write, leaf init, sweep-tree refit) is correctness-clean. - -Current state: the sort is gated behind `if (false)` in `lbvhBuildMain`. BVH -leaves are in instance-index order with no spatial coherence. The BVH still -builds correctly and traversal still descends a real tree, just with looser -parent AABBs. - -## What we know - -- The sort is LSD radix, 8 passes × 4 bits = 32-bit key. -- Keys are `(morton16 << 16) | (tlasIndex16)`; sentinels (i >= n) get - `0xFFFFFFFF`. -- Per-pass: histogram via atomicAdd, then per-bucket parallel scatter with a - Hillis-Steele exclusive prefix scan to compute per-thread destination - offsets. -- Workgroup size 1024, K_PER 16 per thread = 16384 entries total. -- The math of the Hillis-Steele scan was verified: after `log2(THREADS)=10` - steps with the read/barrier/write/barrier pattern, `shScan[tid]` holds the - inclusive prefix sum. -- Scatter destinations are provably unique: `shOffsets[b] + exclusivePrefix - + localIdx`, where `exclusivePrefix` is per-thread and `localIdx` - increments per-element within the thread. -- All required barriers are present: - - `workgroupBarrier` between scan iterations. - - `workgroupBarrier` at end of each bucket iteration. - - `storageBarrier` at end of each radix pass. - -## What we suspect - -The bug is likely one of: - -1. **WGSL implementation issue** in the specific browser/driver. `workgroup - Barrier` semantics around `atomicLoad` on workgroup memory, or around - single-buffered Hillis-Steele where one thread reads `shScan[tid - offset]` - while a neighbor writes `shScan[tid]`. Standard pattern, but the spec is - subtle. -2. **Memory model edge case** triggered only with very unbalanced histograms - (e.g. bucket 15 holding ~94% of entries because almost everything is - sentinel-padded). Most threads have localCount ≤ 1 for non-{0, 15} - buckets and exactly 15-16 for bucket 15; that mix may surface a - compiler-introduced reordering. -3. **A logical bug in the scan or scatter** that the human review keeps - missing — re-reading the code is the last thing that helps; what's - needed is a GPU-side trace. - -## Reproducing - -1. Run 3DForts WebGPU build with normal projectile firing. -2. Aim near (not necessarily at) the fort. -3. Observe braces / panels flickering as the projectile flies past. - -## Diagnostic strategies if revisiting - -1. **GPU-side trace.** Add a debug buffer (`array` sized for all 16384 - entries × a few u32). Have each thread write its intermediate scan - values and final scatter destinations there. Read back to CPU and diff - against an expected oracle (CPU-computed reference sort of the same - input keys). -2. **Halve the search.** Reduce `PASSES` to 1 and check: does a single-pass - sort already corrupt, or does corruption only emerge after multiple - ping-pongs? -3. **Replace the scan.** Swap Hillis-Steele for a Blelloch up/down-sweep - scan or a `subgroupExclusiveAdd` variant where available. If the - replacement fixes it, the bug is in the Hillis-Steele specifically. -4. **Serialize the scatter.** Have thread 0 do all scatters by itself - (loop over all 16384 entries × 16 buckets sequentially). Slow but a - provably-correct reference. If this fixes the flicker, the parallel - scatter has the bug. -5. **Replace LSD with bitonic sort.** Different algorithm entirely. If - bitonic works, radix has a structural problem. - -## Why it's not blocking - -At the current scale (~1011 entries), the BVH still functions: - -- Sentinel half-subtrees are degenerate-AABB-rejected at the top of the - tree very cheaply (~1 AABB test per skipped subtree). -- The real-leaf subtree has ~10 levels of descent (`log2(1024)`), all of - which are real AABB tests. Without spatial coherence the AABBs are - looser than a properly-sorted BVH, but they still bound the geometry. -- Ray-vs-triangle work dominates anyway; BVH traversal is a small fraction - of the per-pixel cost. - -Headroom: LBVH_MAX = 16384. If the application pushes much past ~4000 real -entries this stops being acceptable and the sort needs to actually work. - -## Acceptance criteria for "fixed" - -- The diagnostic repro (3DForts: fire a projectile near the fort) shows - no flicker at all. -- The sort produces output ordered by `(morton16, tlasIndex)` ascending. -- A unit test (CPU oracle vs GPU output) passes for at least three - histogram distributions: all-uniform, all-in-one-bucket, and the - 3DForts-style "one small object next to a tight cluster". diff --git a/WAVEFRONT-DESIGN.md b/WAVEFRONT-DESIGN.md deleted file mode 100644 index 47ca95e..0000000 --- a/WAVEFRONT-DESIGN.md +++ /dev/null @@ -1,115 +0,0 @@ -# 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, 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>, 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, hdr:vec4)->vec4`. 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>`, 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.