The TLAS instance buffer is rebuilt/refit every frame and is co-written by
both the CPU (host-authored fields) and the GPU (the compute shader writes
the transform field in place for transformOwnedByGpu elements). Allocated
HOST_VISIBLE | HOST_COHERENT (system RAM), both GPU accesses crossed PCIe:
the compute write of the transform, and the AS build's read of the instances.
Upgrade the request to HOST_VISIBLE with a best-effort DEVICE_LOCAL preference
(BAR/VRAM), keeping the single shared, persistently-mapped buffer — no staging,
no copy, no extra barrier. A whole-buffer staging copy was rejected: it would
clobber the GPU-written transforms in this in-place co-write design. Now the
compute write and AS build stay in local VRAM; the CPU's write-only,
sequential, never-read-back field writes are the ideal write-combined BAR load.
Correctness:
- DEVICE_LOCAL is a preference only (depends on #59): GetMemoryType falls back
to plain HOST_VISIBLE (the previous behaviour) on GPUs without resizable BAR.
- HOST_COHERENT is dropped from the required set — the combined type may not be
coherent. The existing FlushDevice(cmd, ...) already establishes host->build
visibility and gates its flush-skip on the *chosen* type's flags (#60), so a
non-coherent BAR type still flushes correctly. The barrier is never removed.
metadataBuffer gets the same upgrade separately (#75).
Extends the TLASHighWaterMark hardware test to assert the chosen memory type
matches the HOST_VISIBLE | prefer-DEVICE_LOCAL request and lands on a
DEVICE_LOCAL type where one is reachable; the existing zero-validation-errors
check covers the dropped HOST_COHERENT path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A static (!allowUpdate) mesh can never refit, so its per-mesh DEVICE_LOCAL
scratch buffer is dead weight the moment the build's GPU work completes —
yet it was kept alive for the mesh's whole lifetime. In many-mesh scenes
(where static meshes dominate) this is real, accumulating VRAM waste.
Release it via scratchBuffer.DeferredClear() right after recording a fresh
build for a static mesh. The old "free-after-build is a UAF, the GPU is
still building" hazard is gone: the fence-keyed deferred-deletion queue
(#101/#102) retires the allocation only once the build's frame has passed
its fence. A later Refit on a static mesh takes the rebuild fallback, whose
Resize sees the nulled handle and re-Creates the scratch. Refit-capable
(allowUpdate) meshes keep their scratch as before — an in-place UPDATE
reuses it each frame.
Extends the BLASBuildOptions hardware test to assert the scratch is retained
for allowUpdate meshes and released for static ones (including across a
static-mesh refit rebuild), with the validation layer confirming zero errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A hardware acceleration-structure UPDATE cannot change topology, so the
index buffer is immutable on the Refit UPDATE path. Re-uploading it was a
wasted memcpy + FlushDevice + barrier of unchanged data every refit —
genuinely per-frame for deforming meshes.
Skip the index memcpy and its FlushDevice on the UPDATE branch; the
RecordBLASBuild call still reads the stable, unchanged indexBuffer.address.
The `indicies` span is now read only for its count. Document the API
nuance: a bitwise-different but topologically-equivalent index array is
ignored on refit, consistent with "a refit may only move vertex positions."
The full-rebuild fallback path still re-uploads both buffers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Since #40 dropped the per-frame vkQueueWaitIdle, frames are pipelined:
a resource the CPU is done with may still be read by the GPU for up to
numFrames-1 more frames. VulkanBuffer::Resize's destroy-and-recreate
path was therefore a live use-after-free (#63), no longer masked by the
wait-idle.
Device gains a monotonic, frame-counter-keyed deletion queue:
EnqueueDeletion tags {buffer, memory} with the current frameCounter;
ReclaimDeletions (called per frame after the fence wait) frees entries
once framesInFlight frames have elapsed; DrainDeletions frees everything
after a wait-idle. VulkanBuffer::DeferredClear hands handles to the queue
and nulls the handle, and Resize uses it instead of immediate Clear().
Window::Render sets framesInFlight at init, reclaims after the per-image
fence wait, bumps Device::frameCounter once per frame, and drains on the
resize / OUT_OF_DATE wait-idle paths. The destructor keeps immediate
Clear() (callers destroying mid-flight remain responsible, unchanged).
Adds the DeferredDeletion test: drives the retire timing on a real
headless device with real buffers, stepping Device::frameCounter to pin
the exact reclaim frame, asserting validation stays silent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
WriteBufferDescriptor / WriteSampledImageDescriptor / RegisterSampler /
WriteSwapchainDescriptors each FlushDevice()'d the entire multi-KB per-frame
descriptor heap after writing one few-byte descriptor. Add a ranged
VulkanBuffer::FlushDevice(offset, bytes) that flushes only the touched bytes,
rounding the range outward to nonCoherentAtomSize as vkFlushMappedMemoryRanges
requires (the WHOLE_SIZE path sidestepped that). The coherent-memory gate from
issue #60 is preserved — coherent memory still skips the flush entirely.
The descriptor writers now self-flush their written range, so the redundant
whole-heap flushes in UIRenderer::Initialize and the resize callback are gone.
Rounding lives in AlignMappedFlushRange (pure math) and is unit-tested without
a device in the new VulkanBufferRangedFlush test; the change was also verified
end-to-end by running HelloUI on a real GPU with validation layers (font-atlas
glyphs, sampler and storage-image descriptors all flush correctly, no VUIDs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BuildTLAS reallocated the host-visible instanceBuffer and metadataBuffer
on every topology change. They only ever need to hold at least
primitiveCount entries (the AS build reads exactly primitiveCount via
tlasRangeInfo, and the copy loop writes [0, primitiveCount)), so a
shrink — or any growth that still fits the previous capacity — can reuse
the existing allocation. Gate the two Resize calls on a high-water-mark
check, removing two of the four reallocations on a count change. The AS
storage + scratch rebuild below is unchanged: it is tied to the AS
itself and dominates this path regardless.
Adds tests/TLASHighWaterMark driving the real hardware AS-build path: it
asserts a shrink (and within-capacity growth) reuses the buffers while a
growth past the high-water reallocates, that builtInstanceCount still
tracks the live count, and that feeding an oversized instance buffer to
the build produces zero Vulkan validation errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
vkCreateComputePipelines and vkCreateRayTracingPipelinesKHR were passed
VK_NULL_HANDLE, so the driver couldn't reuse compiled shader binaries
across the pipelines built at startup (4 UI shaders + user/RT pipelines)
or across runs.
Add one process-wide Device::pipelineCache. LoadPipelineCache (called
from Initialize) seeds it from pipeline_cache.bin when the file's
VkPipelineCacheHeaderVersionOne header matches this device's
vendorID / deviceID / pipelineCacheUUID; a stale or foreign blob is
discarded so the driver never rejects it. SavePipelineCache is registered
with std::atexit, serialising the cache at process shutdown without any
explicit teardown hook. Both create sites now pass the cache; a null
handle stays valid, so no create-site null check is needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add Device::PreferDirectDeviceWrite(size) so the memory-placement cluster
(#65/#72/#73/#75) no longer re-derives where a CPU-written, GPU-read buffer
should live. It picks the strategy at runtime from Device::memoryProperties:
- No DEVICE_LOCAL|HOST_VISIBLE type (no resizable BAR) -> false (must stage).
- ReBAR/UMA (BAR heap >= 90% of largest DEVICE_LOCAL heap) -> true: map and
write directly; a staging copy would be pure overhead.
- Small BAR window (BAR heap << VRAM) -> true only for buffers within a
per-window budget (1/8 of the window); large buffers stage so they don't
exhaust the window (#58).
Complements GetMemoryType (#59): #59 picks the memory *type*, this picks the
*strategy*. Upload-only — readback (#74) wants HOST_CACHED and stays separate.
A VK_EXT_memory_budget-driven remaining-space check is noted as a follow-up.
Tested by tests/UploadStrategy (pure CPU over synthetic ReBAR / UMA /
small-window / no-BAR layouts, no GPU device needed), mirroring
MemoryTypeFallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a fifth UI compute path — DispatchFused — alongside the four
per-element Dispatch* calls. It composites quads → circles → images →
text (canonical back-to-front order) in ONE dispatch that loads the
destination image once and stores once, eliminating the per-pass
load+store and the inter-pass VkMemoryBarriers a run of consecutive
Dispatch* calls would pay. The win materialises at >= 2 non-empty
categories; an absent category is a zero-trip, push-constant-uniform
loop (~free).
The new uber-kernel (shaders/ui-fused.comp.glsl) reuses each category's
exact cooperative shared-memory tile-cull + per-pixel accumulate, so a
fused category is pixel-identical to its standalone pass. Categories run
sequentially per thread and share one set of shared-memory scratch, so
the VGPR/shared high-water mark stays at the per-category level, not the
sum.
Additive and non-breaking: the per-element Dispatch* calls and the
frozen 48-byte UIDispatchHeader are untouched. DispatchFused gets its own
128-byte push-constant layout (UIFusedHeader: four uvec4 headers + four
per-category clip vec4s). To interleave a custom ui.Dispatch() between
standard passes, bracket it with two DispatchFused calls — the dispatch
boundary is the explicit, app-declared flush point.
WebGPU (Vulkan-second): DispatchFused falls back to the per-element
Dispatch* calls in canonical order — one texture/sampler per dispatch
there can't feed the bindless image phase — so the result matches; only
the load/store fusion is Vulkan-only.
HelloUI now composites its background quads, mouse circle, and label
text in a single DispatchFused. Verified rendering identical on a live
Vulkan/Wayland session.
Resolves#47
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Font::GetLineWidth called stbtt_GetCodepointHMetrics for every glyph on
every invocation. InputField_HitTestCursor rescans the whole field once
per character, making caret hit-testing O(n²) in HMetrics lookups.
Memoise advances per Font via Font::AdvanceUnits: ASCII in a flat array,
the rest in a map. Advances are cached in unscaled font *units* and
rescaled per call — Glyph::advance in the FontAtlas is baked at kBaseSize
and is wrong at any other size, so it cannot be reused. The per-glyph
(int) truncation in GetLineWidth is preserved exactly.
Adds the FontAdvanceCache CPU-only regression test covering size
independence of the cached unit advance, the per-glyph-truncated rescale
pipeline, cache-hit determinism, width scaling with size, and the
non-ASCII map path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
uiResolveScreenPixel ran four float compares against hdr.clipRectPx for
every pixel of every UI dispatch, even though the clip rect is the
full-surface default {0,0,1e9,1e9} in the overwhelmingly common case.
Reserve the high bit of the header flags word (UI_FLAG_CLIP) and have
FillHeader set it only when the clip rect is narrower than the surface
(extracted into the unit-testable UIRenderer::ClipFlags). The shader
gates the compares on the bit; the branch is push-constant-uniform so it
never diverges. When the rect covers the surface the skipped compares
could never reject an in-surface pixel, so output is pixel-identical.
Mirrored on the WebGPU/WGSL path (dom-webgpu.js) for parity. Adds the
UIClipFlag CPU test covering the gate decision.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
InputField_HitTestCursor mapped a click x to a cursor byte offset by
calling Font::GetLineWidth on every prefix value[0..i] for i=1..size.
Each call re-walks from byte 0, so the hit test was O(n^2) glyph-metric
lookups (each an uncached stbtt cmap binary search). It also iterated
raw byte boundaries, so on multibyte UTF-8 input it could return an
offset splitting a codepoint — an invalid position for the byte-based
cursorPos.
Add Font::NearestCursorByte: one left-to-right cumulative-advance pass
(O(n) metrics, scale computed once) over codepoint boundaries, returning
the byte offset of the boundary nearest the target. Cumulative advance is
monotonic, so the scan stops past the closest boundary. The hit test now
delegates to it.
Adds tests/InputFieldHitTest, a pure-CPU regression test (no Vulkan
device/window): a px sweep across ASCII and multibyte strings compared
against an independent brute-force nearest-boundary oracle, plus the
left/right/empty/end-of-text edge cases and a codepoint-boundary invariant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
UIRenderer::Dispatch inserted a queue-wide VkMemoryBarrier
(SHADER_WRITE -> SHADER_READ|WRITE) before every dispatch after the
first, flushing/invalidating all shader caches even though the only
hazard is the read-modify-write of the single swapchain storage image
that every UI pass shares.
Narrow it to a VkImageMemoryBarrier scoped to that image's subresource
(COMPUTE -> COMPUTE, GENERAL -> GENERAL, no layout transition). Same
correctness — later passes still observe earlier passes' writes — but
only this image's caches are flushed; unrelated resources are no longer
invalidated.
This is a narrower cache flush only: the execution dependency stays
compute->compute queue-wide, so passes still do not overlap. Eliminating
the barriers entirely requires pass fusion (#47) and is intentionally
out of scope here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GetMemoryType was a bare first-superset match that threw whenever no
memory type satisfied the requested property flags. Callers combining a
mandatory flag with a perf-only one (HOST_VISIBLE | DEVICE_LOCAL for the
descriptor heaps) would then fail allocation on any device without a
host-visible device-local heap (no resizable BAR).
It now takes a `preferred` mask distinct from `required`: a type
satisfying both is chosen first, falling back to a required-only match
when the preference is unavailable, and throwing only when even
`required` cannot be met. VulkanBuffer::Create/Resize gain an optional
trailing `preferredPropertyFlags`, and the descriptor heaps now treat
DEVICE_LOCAL as a preference on top of mandatory HOST_VISIBLE.
This does not touch any flush/barrier behaviour — coherency is not
assumed anywhere here.
Adds tests/MemoryTypeFallback, a pure-CPU test that installs synthetic
memory layouts and exercises selection, preference, fallback, and the
unsatisfiable-required throw.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>