Commit graph

294 commits

Author SHA1 Message Date
bbbfacba48 Merge pull request 'fix(image): scope sampled-image upload barriers to the real consumer stage (#54)' (#86) from claude/issue-54 into master 2026-06-16 18:01:50 +02:00
catbot
8cca1dae47 fix(image): scope sampled-image upload barriers to the real consumer stage (#54)
ImageVulkan hardcoded VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR as the
consuming stage in every layout transition. The UI font atlas is sampled by
a *compute* shader, so the upload barrier's dst scope never covered the real
consumer — a correctness gap, not just lost overlap.

Parameterize the consuming stage per image: store it once at Create
(defaulting to RT, preserving existing RT/example callers) and reuse it in
every Update / UpdateRegion / compressed-Update transition. FontAtlas passes
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:01:23 +00:00
0b00d9d680 Merge pull request 'perf(text): fold Ensure+Lookup into FontAtlas::EnsureAndGet (#53)' (#84) from claude/issue-53 into master 2026-06-16 17:44:20 +02:00
catbot
77a6501ced perf(text): fold Ensure+Lookup into FontAtlas::EnsureAndGet (#53)
Per glyph, ShapeText did cache_.contains(key) (in Ensure) then
cache_.find(key) (in Lookup) — two independent hashes + node-chases of
the same key. Add EnsureAndGet returning const Glyph* with a single
find, insert-on-miss, returning &it->second. It returns nullptr only on
ShelfPlace failure, preserving the existing `if (g==nullptr) continue;`
behavior. Ensure now delegates to it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:43:58 +00:00
991a39e094 Merge pull request 'perf(font): upload only the dirty sub-rect of the glyph atlas (#51)' (#83) from claude/issue-51 into master 2026-06-16 17:43:23 +02:00
catbot
e4f8b057f3 Merge remote-tracking branch 'origin/master' into claude/issue-51
# Conflicts:
#	project.cpp
2026-06-16 15:43:11 +00:00
1451e3aeb2 Merge pull request 'feat(window): multi-frame-in-flight frame pacing (#40)' (#81) from claude/issue-40 into master 2026-06-16 17:42:13 +02:00
catbot
2d6052ec67 Merge remote-tracking branch 'origin/master' into claude/issue-40
# Conflicts:
#	implementations/Crafter.Graphics-Window.cpp
#	interfaces/Crafter.Graphics-Window.cppm
2026-06-16 15:41:59 +00:00
29eacfb460 Merge pull request 'perf(text): cache shaped runs in ShapeText (#52)' (#82) from claude/issue-52 into master 2026-06-16 17:41:35 +02:00
catbot
ca48f79465 perf(text): cache shaped runs in ShapeText (#52)
ShapeText re-decoded UTF-8, re-looked-up every glyph, and recomputed
layout every frame for static strings (DrawText / EmitCenteredLabel run
inside per-frame onBuild). DrawText and EmitCenteredLabel also did a
second full pass over the glyphs to apply alignment.

Memoize the shaped run, keyed by (Font*, pxSize, color, utf8), as an
origin-relative vector<GlyphItem>. A cache hit skips decode/lookup/layout
and just translates the cached run into the output buffer by
(x + alignShift, baselineY). The alignment shift (Center = -advance/2,
Right = -advance) is folded into ShapeText's single pass, dropping the
second loop in both callers. TextAlign moves to the :UI partition so
ShapeText can take it.

Pure CPU memoization — the glyph buffer is fully rewritten and re-flushed
every frame, so a hit is byte-equivalent. A miss still calls Ensure so new
glyphs rasterise and dirty the atlas; hits don't (atlas entries are never
evicted). InvalidateFont drops a font's runs before it is destroyed (the
cache, like FontAtlas's glyph cache, keys on the Font pointer). A soft cap
bounds memory against pathological per-frame-unique text.

Adds the ShapeTextCache test (15 checks): hit == miss byte-for-byte, hits
don't touch the atlas, translate/alignment/truncation/InvalidateFont.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:40:52 +00:00
catbot
97c79c23ee perf(font): upload only the dirty sub-rect of the glyph atlas (#51)
FontAtlas::Update re-uploaded the entire 1024x1024 R8 atlas (1 MiB)
whenever a single glyph was rasterized, so warm-up / language-switch /
size-churn cost roughly one full-atlas copy per frame that added a
codepoint.

Accumulate a dirty bounding box (FontAtlas::DirtyRect) in MarkDirty as
glyphs are placed, and copy only that sub-rect. On Vulkan this is a new
ImageVulkan::UpdateRegion: the staging buffer keeps the full atlas row
stride, so bufferRowLength stays the atlas width and bufferOffset points
at the rect origin while imageOffset/imageExtent carve out the rect.
Layout transitions stay whole-image (cheap); only the copy extent
shrinks. WebGPU passes the rect straight to wgpuWriteAtlasRegion, which
already took x/y/w/h.

The freshly-zeroed atlas marks its whole extent dirty in Initialize so
the first Update clears the image once; thereafter every untouched texel
stays the zero it was uploaded as, keeping the image byte-identical to
the staging copy.

Tested: new FontAtlasDirtyRect unit test covers the accumulation /
clamp / lockstep-with-`dirty` math (no GPU needed); HelloUI renders text
correctly on both Vulkan (NVIDIA, validation-clean) and WebGPU.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:40:37 +00:00
catbot
621016f264 feat(window): multi-frame-in-flight frame pacing (#40)
The renderer was effectively single-buffered despite allocating
triple-buffered infrastructure: Render() ended with an unconditional
vkQueueWaitIdle, and a single (presentComplete, renderComplete)
semaphore pair with zero fences was shared for the Window's lifetime.

Rework the pacing model so up to numFrames frames overlap:
- Per-swapchain-image VkFence, signaled by the submit and waited+reset
  before that image's command buffer / descriptor-heap slot is
  re-recorded. Keyed by acquired image index (drawCmdBuffers, the heap
  slots, and the swapchain images are all image-indexed — see
  WriteSwapchainDescriptors). Created signaled so first use passes.
- Per-image render-finished (present) semaphore: the presentation engine
  holds it until the image is re-acquired, so per-image is the only safe
  key (per-CPU-frame trips VUID-vkQueueSubmit-pSignalSemaphores-00067).
- Per-CPU-frame acquire semaphores (image index unknown until acquire
  returns), sized numFrames+1: in-flight depth is bounded by the
  per-image fences, so numFrames+1 distinct acquire semaphores guarantee
  the reused one has no pending op (VUID-vkAcquireNextImageKHR-01779).
- Drop the steady-state vkQueueWaitIdle; keep it on resize / OUT_OF_DATE
  / teardown.

Add tests/FrameLoopSync: drives the real frame loop against a live
Wayland compositor for 60 frames (>> in-flight slots) and asserts the
CPU frame counter advanced, the swapchain rotated across multiple
images, and the validation layer stayed silent — the load-bearing check,
since the old single-pair design only avoided being an active race
because the wait-idle masked it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:37:11 +00:00
58e9e0a747 Merge pull request 'perf(shaders): drop nonuniformEXT on text font slots' (#80) from claude/issue-49 into master 2026-06-16 17:29:51 +02:00
catbot
a8ed369c71 perf(shaders): drop nonuniformEXT on text font slots
fontTextureSlot/fontSamplerSlot are push constants, hence provably
dynamically uniform. Wrapping them in nonuniformEXT forced the
per-invocation divergent-descriptor path and blocked hoisting the
uniform descriptor load out of the per-pixel glyph loop.

Removing the decoration is zero correctness risk — it is purely a
read-index hint. Not applied to ui-images.comp.glsl, whose slots come
from an SSBO load the compiler cannot prove uniform.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:29:29 +00:00
459f10afa8 Merge pull request 'perf(window): drop dead present-mode queries from swapchain recreate (#45)' (#79) from claude/issue-45 into master 2026-06-16 17:29:00 +02:00
catbot
9ef554b97e perf(window): drop dead present-mode queries from swapchain recreate (#45)
CreateSwapchain unconditionally used VK_PRESENT_MODE_FIFO_KHR, but still
ran two vkGetPhysicalDeviceSurfacePresentModesKHR calls plus a heap-allocated
std::vector to enumerate present modes that were never consulted. Delete
them and assign the constant directly.

compositeAlpha is a surface property that does not change across swapchain
recreation, so select it once at construction instead of re-deriving it
(with another heap-allocated vector) on every resize configure. The
vkQueueWaitIdle before recreation is left intact as required.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:28:35 +00:00
cea57f9af8 Merge pull request 'perf(window): cache per-frame heap-bind structs across frames' (#77) from claude/issue-42 into master 2026-06-16 17:27:55 +02:00
fa35d86021 Merge pull request 'perf(window): hoist invariant per-frame Vulkan info structs to members' (#78) from claude/issue-43 into master 2026-06-16 17:27:48 +02:00
catbot
8b95b7e883 perf(window): cache per-frame heap-bind structs across frames
Render() rebuilt two VkBindHeapInfoEXT structs every frame and
recomputed reservedRangeOffset via a runtime subtract + bitmask
against Device::descriptorHeapProperties. The heap address/size per
slot are stable for a given heap; only currentBuffer varies.

Precompute numFrames resource/sampler bind structs and cache them,
keyed on the descriptorHeap pointer. The cache rebuilds whenever the
heap is (re)assigned and otherwise just indexes by currentBuffer.
Pointer-keyed invalidation catches any heap reassignment — the same
point a setter hook would fire — and is independent of onResize, as
the heaps never resize today (per the issue's correctness caveat).

Resolves #42

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:27:10 +00:00
catbot
38616d81ef perf(window): hoist invariant per-frame Vulkan info structs to members (#43)
Window::Render() rebuilt cmdBufInfo, the subresource range, both
VkImageMemoryBarrier structs, and presentInfo on the stack every frame
even though only the barriers' image (and the acquire barrier's
oldLayout) ever change. Hoist them to members initialised once in the
constructor; Render() now patches only the two varying fields.

presentInfo.pImageIndices/pSwapchains point at the currentBuffer and
swapChain members, so they track value changes (including swapchain
recreation) automatically. The render-complete wait semaphore is fixed
for the window's lifetime, so the per-frame `renderComplete != NULL`
check — set once, never cleared — was dead and is removed; the
semaphore is wired into presentInfo in the constructor instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:27:04 +00:00
626cc6f5c8 Merge pull request 'perf(window): ring buffer for CRAFTER_TIMING frame times (#44)' (#76) from claude/issue-44 into master 2026-06-16 17:25:58 +02:00
catbot
41d99890c1 perf(window): ring buffer for CRAFTER_TIMING frame times (#44)
Replace the std::vector<nanoseconds> frameTimes plus
erase(begin()) with a fixed-size std::array ring buffer (head +
count). The previous code memmoved ~99 elements left every frame once
the window filled. LogTiming computes order-independent sum/avg/min/max,
so the ring's write order is irrelevant to the reported stats.

Behind CRAFTER_TIMING only; shipping builds are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:25:22 +00:00
050a44d118 Merge pull request 'feat(rt): BuildProcedural — accept a device AABB buffer as build input (#37)' (#39) from claude/issue-37 into master 2026-06-16 16:13:09 +02:00
catbot
0d1312ac09 example(RTVolume): GPU-written device-buffer procedural AABBs (#37)
A compute shader (aabbs.comp.{glsl,wgsl}) writes the procedural box into a
device buffer that BuildProcedural consumes by device address/handle with no
host copy; the WebGPU path also refits from it each frame so the box breathes.
Verified rendering on both Vulkan (native) and WebGPU (browser).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:12:12 +00:00
catbot
8fefb2caef test(rt): headless device-buffer BuildProcedural + refit (#37)
Extend BLASBuildOptions with a device-local AABB buffer filled via a staging
copy + barrier (standing in for a GPU compute producer) fed into the new
device-address BuildProcedural/RefitProcedural. Asserts the build/refit
succeed, the in-place UPDATE keeps the AS handle + blasAddr, the host
aabbBuffer is never allocated (proof of zero-copy), and the validation layer
reports no errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:12:12 +00:00
catbot
f14441942a feat(rt): BuildProcedural/RefitProcedural from a device AABB buffer (#37)
Add zero-copy procedural BLAS overloads that take the AABB build input
straight from an existing device buffer instead of memcpy-ing a host span
through Mesh::aabbBuffer — the input-source companion to #36's refit work.
A GPU compute producer (e.g. a GPU-resident particle system) can now feed a
moving procedural BLAS that builds/refits each frame with no host round-trip.

Vulkan: new BuildProcedural(VkDeviceAddress, count, ...) and
RefitProcedural(VkDeviceAddress, count, ...) feed the device address directly
into VkAccelerationStructureGeometryAabbsDataKHR; the host-span path is
refactored to share RecordProceduralBuildFromAddress and is otherwise
unchanged.

WebGPU: device-buffer BuildProcedural/RefitProcedural copy the boxes GPU->GPU
into the mesh heap (new wgpuRegisterMeshBLASDeviceAabbs / wgpuRefitMeshBLAS-
DeviceAabbs bridge fns) and wrap them in a single root leaf bounded by a
caller-supplied worldBounds — no wasm round-trip, blasAddr stable across refit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:12:04 +00:00
15ce47f422 removed stale docs, added docs folder 2026-06-16 15:50:56 +02:00
00d14d67cb tlas build options 2026-06-16 15:49:17 +02:00
e3940d99c3 Merge pull request 'feat(vulkan-rt): BLAS build options — fast-build/fast-trace + in-place refit (#36)' (#38) from claude/issue-36 into master 2026-06-16 15:38:35 +02:00
catbot
ce0114607a test(vulkan-rt): headless BLAS build-options + refit test (#36)
Exercises the real hardware AS-build path: builds and refits triangle
and procedural BLASes with fast-build/fast-trace + allow-update flags
via one-time command buffers, asserting the requested flags land, that
an allowUpdate refit keeps the same AS handle/blasAddr (in-place
UPDATE), that the no-update / topology-change fallback still produces a
valid BLAS, and that the validation layer reports zero errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:37:46 +00:00
catbot
1a81f115c3 feat(vulkan-rt): BLAS build options — fast-build/fast-trace + in-place refit (#36)
Mesh::Build / BuildProcedural now take an RTBuildOptions { preference,
allowUpdate }. `preference` maps to PREFER_FAST_TRACE (default) or
PREFER_FAST_BUILD; `allowUpdate` sets ALLOW_UPDATE so the BLAS can be
refit later.

Adds Mesh::Refit / RefitProcedural: when the original build opted into
allowUpdate and the topology is unchanged, they record an in-place
UPDATE-mode build (src == dst) — much cheaper than a rebuild, and the
AS handle + blasAddr are preserved so TLAS instances stay valid. They
fall back to a full rebuild otherwise. The shared build tail also now
destroys a stale AS handle on re-Build (previously leaked) and guards
the in-place update with an AS read→write barrier.

The portable RTBuildPreference/RTBuildOptions types and Refit methods
also exist on the WebGPU backend for API symmetry; the software BVH has
no hardware AS, so the preference is a no-op and a "refit" rebuilds the
BVH (re-registering the handle).

Also adds Device::validationErrorCount, bumped by the debug-messenger
callback on ERROR-severity messages, so tests can assert a Vulkan
operation produced no validation errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:37:46 +00:00
27bfb36e60 Merge pull request 'feat(vulkan-rt): Mesh::BuildProcedural — AABB (procedural) BLAS build path (#33)' (#35) from claude/issue-33 into master 2026-06-13 02:18:30 +02:00
catbot
40c4184d41 test(vulkan-rt): port RTVolume example to native Vulkan (#33)
Regression test for the procedural BLAS path: the same 3x3x3 grid of
unit-box AABBs runs through a PROCEDURAL_HIT_GROUP_KHR group whose GLSL
intersection shader (reportIntersectionEXT) turns each box into a
radius-1 sphere, the any-hit shader punches the spherical-checkerboard
cut-out (visible proof non-opaque geometry runs any-hit), and the
closest-hit shades per-instance tints — the WebGPU example behavior
reproduced natively. Fixed camera in raygen.glsl; the WebGPU/DOM path is
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:17:37 +00:00
catbot
a9e42f9c90 feat(vulkan-rt): Mesh::BuildProcedural — AABB (procedural) BLAS build path (#33)
Adds the native counterpart of the WebGPU Mesh::BuildProcedural: uploads
the boxes as a 24-byte-stride VkAabbPositionsKHR-compatible device buffer
(AS-build-input read-only + device address) and builds the BLAS from one
VK_GEOMETRY_TYPE_AABBS_KHR geometry. opaque maps to
VK_GEOMETRY_OPAQUE_BIT_KHR, default-false semantics matching the WebGPU
path so any-hit shaders run. The BLAS sizing/create/build tail is factored
into a geometry-type-agnostic helper shared with the triangle path; no
TLAS/instance changes needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:17:29 +00:00
64a2a1fbd8 Merge pull request 'feat(input): wire native mouse-wheel scroll on Wayland + Win32, normalize all backends to ±1/detent' (#34) from claude/issue-32 into master 2026-06-13 02:10:53 +02:00
catbot
2402f35e6c fix(wayland): guard null focusedWindow in pointer handlers
Sway delivers pointer events in surprising orders around input-device
hot-plug — observed: a second wl_pointer.leave after focus was already
cleared, which crashed in PointerListenerHandleLeave (null deref), and
motion/button would crash the same way if an enter never matched one of
our surfaces. Early-return instead. Found while end-to-end-testing the
scroll chain for #32 with a zwlr_virtual_pointer_v1 injector.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 15:16:57 +00:00
catbot
419730f46b feat(input): wire native mouse-wheel scroll on Wayland + Win32, normalize all backends to ±1/detent (#32)
Window::onMouseScroll now fires on every backend and speaks one unit:
signed whole detents packed in the uint32 payload, +1 = wheel down
(DOM deltaY sign).

- Wayland: implement the previously-stubbed PointerListenerHandleAxis —
  vertical axis only, wl_fixed_to_double / 15 (libinput's units-per-
  detent), sub-detent remainder accumulated and reset on pointer leave.
- Win32: add the missing WM_MOUSEWHEEL case — -GET_WHEEL_DELTA_WPARAM /
  WHEEL_DELTA with a remainder accumulator for free-spinning wheels.
- DOM: dom-env.js normalizes WheelEvent.deltaY per deltaMode (100px /
  3 lines / 1 page per detent) with the same remainder scheme, so wasm
  delivers the identical unit instead of raw browser-specific deltas.
- Document the contract on Window::onMouseScroll.
- tests/MouseScroll: compositor-free regression test driving the
  Wayland axis handler directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 15:02:28 +00:00
c608d75a36 Merge pull request 'feat(webgpu-rt): atomic pixel accumulator + raysPerPixel — >1 ray per pixel per bounce (#30)' (#31) from claude/issue-30 into master 2026-06-10 00:46:23 +02:00
catbot
f7fc441253 test(webgpu-rt): RTMultiShadow example exercising N shadow rays/pixel/bounce (#30)
Five pillars, four colored point lights; closest-hit emits one shadow ray
per light from the same invocation (raysPerPixel = 4, maxDepth = 2), so up
to four rays per pixel rtAccumulate in a single SHADE pass — the contention
case #30 exists for. Each pillar casts four separable colored shadows; an
accumulator race or capacity drop shows as flickering dark noise or a
missing shadow color. Two frames a second apart diff at 2 px / 1.85 M
(last-ulp CAS ordering), confirming no lost updates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:45:33 +00:00
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
c58424d519 Merge pull request 'feat(webgpu): HDR post-process primitives for bloom (#27)' (#28) from claude/issue-27 into master 2026-06-09 14:56:02 +02:00
catbot
bbe1b21c22 test(webgpu): HDRBloom example exercising the HDR post-process primitives (#27)
RT→rgba16float→threshold→blur→composite, end-to-end proof of the three
primitives. threshold/blur run from onBeforeUpdate (one submit each) so the
storage-write→sampled-read dependency gets WebGPU's per-submit barrier
(there is none between dispatches within a compute pass); the composite is a
UI custom shader so it owns the canvas ping-pong. Documents the Vulkan
symmetry (gap 4): the native present path records passes generically and
barriers between them, so the same chain is wireable today.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 12:55:14 +00:00
catbot
097cc37347 feat(webgpu): HDR post-process primitives — float textures, storage-texture bindings, RESOLVE→HDR target (#27)
Adds the minimal WebGPU-backend surface an HDR bloom/post chain needs:

1. wgpuCreateStorageImage2D + StorageImage2D<>: runtime rgba16float (or
   other format) textures with STORAGE_BINDING|TEXTURE_BINDING usage, no
   CompressedTextureAsset upload — GPU-produced HDR targets.
2. UICustomBindingKind::StorageTexture (kind 5): write-only storage-texture
   binding carrying a texel format (the freed UICustomBinding::format byte),
   wired through all three binding paths (UI custom / RT / plain compute).
   Float textures already sample via SampledTexture (sampleType float).
3. PipelineRTWebGPU::Init(..., hdrOutputFormat): RESOLVE writes the linear
   accumulator into a user rgba16float texture (RTPass::outTexHandle)
   instead of the rgba8unorm canvas, leaving the composite→swapchain pass to
   the app. Default RGBA8Unorm keeps the canvas path byte-identical.

Existing RT examples updated for the _pad→format field rename; the default
paths are unchanged (verified RTStress builds + renders).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 12:55:14 +00:00
2b266262ee Merge pull request 'fix(webgpu-rt): dynamic rayQuery TLAS leaf-start so picks hit for realistic instance counts (#25)' (#26) from claude/issue-25 into master 2026-06-04 15:33:55 +02:00
catbot
b645746c8c test(webgpu-rt): RayQueryPick example exercising the rayQuery TLAS shim (#25)
Adds an 8^3 = 512-instance TLAS pick test that shoots one analytically
determined ray through a rayQuery=true PlainComputeShader and checks the
read-back committed hit (customIndex 484, t 40.75). 512 instances sit in
the < 8193 regime that the hardcoded 16384-leaf start used to miss, so the
example fails fast if the shim regresses. Verified in Firefox/WebGPU:
"[RayQueryPick] PASS".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 13:33:04 +00:00
catbot
8f6a52a460 fix(webgpu-rt): derive rayQuery TLAS leaf-start from dynamic nPadded (#25)
The software rayQuery shim's _rqTraverseTlas detected BVH leaves with a
compile-time constant TLAS_BVH_LEAVES_START = 16384 - 1, while the actual
TLAS sweep tree is built at depth log2(next_pow2(instanceCount)). For any
scene with fewer than 8193 instances the padded leaf count is far below
16384, so no node index ever reached 16383: every node looked internal,
the descent walked into zeroed out-of-tree AABBs, and the pick reported a
permanent miss. This broke every rayQuery=true compute shader (builder
picking, splash queries) on the WebGPU backend.

Pass the per-build padded leaf count to the shim the same way the
megakernel _rtwTraverseTlas reads wfParams.tlasNPadded: a small uniform
(RqTlasMeta.nPadded) at @group(1) @binding(10), written each wgpuBuildTLAS
from wfNextPow2(instanceCount), and bound by both rayQuery dispatch paths.
_rqTraverseTlas now computes leavesStart = nPadded - 1 dynamically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 13:32:58 +00:00
f9d23cd1f9 Merge pull request 'docs(vulkan-rt): document dynamic descriptor_heap-index hit-shader fault (#23)' (#24) from claude/issue-23 into master 2026-06-03 22:05:45 +02:00
catbot
d08c7cea11 docs(vulkan-rt): document dynamic descriptor_heap-index hit-shader fault (#23)
Indexing a `layout(descriptor_heap)` array with a runtime (non-constant)
index inside a ray-tracing hit shader device-losts on NVIDIA 610.43.02,
for both SSBO and sampled-image descriptors. A constant/spec-constant
index is fine, and the same dynamic pattern works in fragment shaders, so
it's an RT-stage-specific driver fault — the same family as #7/#15
(descriptor-heap AS reads) and #21/#22 (RT recursion + compute TLAS push).

Unlike the AS-read fault, this cannot be worked around transparently: a
sampled image has no device-address escape hatch the way an acceleration
structure does (OpConvertUToAccelerationStructureKHR), and a buffer-only
buffer_reference rewrite would need a whole address-table architecture
while still leaving the texture half broken. So the resolution is the
documented-limitation path (the precedent set by #7).

Records the fault and its isolation in README's Native RT status and in
the Sponza example README (the textured-closest-hit example, which already
reads its albedo through a spec-constant slot for exactly this reason).
Documents the recommended consumer pattern: bind one resource and index
*within* it dynamically (single geometry SSBO / buffer_reference at a
spec-constant slot; one texture2DArray indexed by layer) rather than
selecting a descriptor dynamically — what the WebGPU path already does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:05:12 +00:00
5358aee2f6 Merge pull request 'fix(vulkan-rt): configurable recursion depth + per-shader TLAS push for compute (#21)' (#22) from claude/issue-21 into master 2026-06-03 20:36:22 +02:00
catbot
1c310762a7 fix(vulkan-rt): configurable recursion depth + per-shader TLAS push for compute (#21)
Two gaps in the Vulkan RT path that fault the device on the NVIDIA
proprietary driver with a non-trivial pipeline (simple VulkanTriangle
never hit them):

1. maxPipelineRayRecursionDepth was hardcoded to 1, so any closest-hit
   shader that traces a secondary ray (shadow ray — a very common
   pattern) recursed past the pipeline limit (UB → device fault).
   PipelineRTVulkan::Init now takes a maxRecursionDepth parameter
   (default 1, clamped to the device's maxRayRecursionDepth).

2. The NVIDIA descriptor-heap AS-read workaround rewrites every shader
   that reads an accelerationStructureEXT from the heap — including
   compute shaders — to read the TLAS device address from a push
   constant, but only RTPass pushed that address. A compute shader that
   ray-queries the TLAS (rayQueryEXT) therefore ran against an unwritten
   push slot → garbage AS handle → VK_ERROR_DEVICE_LOST.

   WorkaroundNvidiaAS::Patch now returns a per-shader PatchResult
   {patched, tlasPushOffset} instead of writing the clobber-prone global
   Device::workaroundTlasPushOffset (removed). VulkanShader stores it;
   ShaderBindingTableVulkan/PipelineRTVulkan carry it for RTPass, and
   ComputeShader tracks its own offset and pushes the caller-supplied
   TLAS address in Dispatch (new defaulted tlasAddress parameter),
   mirroring RTPass::Record.

The PushConstantRewrite regression test now asserts Patch's returned
patched/offset and adds two ray-querying compute-shader cases, proving
the rewrite is stage-agnostic and the per-shader offset is correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:35:39 +00:00