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>
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>
Each 8×8-tile workgroup previously had all 64 threads walk the entire
item list, re-reading every item from the SSBO 64× and running the
per-pixel reject for items that never touch the tile — O(tiles × N)
item loads dominated by SSBO traffic.
The workgroup now streams the item list in chunks of 64: each thread
loads one item, tests its AABB against the whole tile, and the survivors
are compacted — in original buffer order — into shared memory. Every
thread then runs the unchanged per-pixel accumulate over only those
survivors. This drops SSBO traffic ~64× (each item read once per
workgroup instead of once per pixel) and shrinks the inner loop to the
items that actually overlap the tile.
Draw order is preserved exactly: chunks run in array order and the
in-chunk compaction is a stable in-order scan, so later items still
overdraw earlier ones (no atomic-append nondeterminism). The inner loop
body is byte-for-byte the original per-pixel logic, so output is
pixel-identical — the cull only decides which items reach it. Threads
no longer early-return so every thread reaches the workgroup barriers.
Applied to ui-quads, ui-circles, ui-images and ui-text; shared helpers
(uiTileBounds / uiAabbOverlapsTile) live in ui-shared.glsl.
Resolves#46
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>