removed stale docs, added docs folder

This commit is contained in:
Jorijn van der Graaf 2026-06-16 15:50:56 +02:00
commit 15ce47f422
2 changed files with 0 additions and 236 deletions

View file

@ -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<u32>` 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".

View file

@ -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<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.