Commit graph

348 commits

Author SHA1 Message Date
1582b6ceb5 Merge pull request 'perf(rt): allocate TLAS metadata buffer in BAR/VRAM, not system RAM (#75)' (#111) from claude/issue-75 into master 2026-06-17 19:50:55 +02:00
catbot
801509d9d5 perf(rt): allocate TLAS metadata buffer in BAR/VRAM, not system RAM (#75)
The TLAS metadata buffer is CPU-written every frame (one userMetadata word
per element in BuildTLAS's copy loop) and read by the ray shaders as a
STORAGE_BUFFER via device address every frame. Allocated
HOST_VISIBLE | HOST_COHERENT (system RAM), every per-frame shader read
traversed PCIe — the same cost the sibling instance buffer paid before #65.

Upgrade the request to HOST_VISIBLE with a best-effort DEVICE_LOCAL preference
(BAR/VRAM), keeping the single persistently-mapped buffer. The CPU's writes are
write-only, sequential, never-read-back — the ideal write-combined BAR load —
and the shader reads now hit local VRAM.

Unlike the instance buffer there is no GPU co-write, so the direct upgrade is
unconditional. Staging is still deliberately avoided: this buffer is rewritten
by the CPU every frame, so a per-frame stage+copy+barrier would defeat the
purpose (#75 caveat 2).

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. A FlushDevice() is added after the copy loop to make the host writes
  available on a non-coherent type; it self-gates to a no-op on a coherent type
  (#60), so the previous behaviour is preserved wherever the type ends up
  coherent. The metadata buffer is not an AS build input and has no GPU
  co-write, so it needs neither the build-stage barrier the instance buffer
  takes nor a HOST->shader command barrier: the queue submit already orders host
  writes made available before it against every command in the submission,
  exactly as it did when this buffer was HOST_COHERENT.

Extends the TLASHighWaterMark hardware test with a metadata-buffer assertion
block mirroring #65's instance-buffer check: the chosen memory type must equal
what GetMemoryType resolves for the HOST_VISIBLE | prefer-DEVICE_LOCAL request,
and must be DEVICE_LOCAL wherever a host-visible device-local type is reachable.
The zero-validation-errors check covers the dropped HOST_COHERENT path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:50:01 +00:00
d77bd860ba Merge pull request 'perf(window): use HOST_CACHED readback staging in SaveFrame (#74)' (#108) from claude/issue-74 into master 2026-06-17 19:45:16 +02:00
catbot
1553f585a2 perf(window): use HOST_CACHED readback staging in SaveFrame (#74)
SaveFrame's readback staging buffer was allocated HOST_VISIBLE |
HOST_COHERENT, whose first matching type is write-combined (uncached) on
most discrete GPUs. The CPU then reads the entire frame back from it —
and reads from write-combined memory are pathologically slow.

Select a HOST_CACHED type instead (preferring a cached-coherent type
when one exists), falling back to any HOST_VISIBLE type if no cached type
is advertised (#59 mechanism). When the chosen type is cached-but-not-
coherent, invalidate the mapped range before the CPU read — the read-path
counterpart of FlushDevice (#60) — gated on the chosen type's actual
coherency, not the requested flags. Whole-range invalidate sidesteps
nonCoherentAtomSize.

Deliberately not routed through the #89 upload-strategy helper, which is
upload-only and steers toward write-combined DEVICE_LOCAL|HOST_VISIBLE —
the worst type for readback.

Verified by driving SaveFrame from the VulkanTriangle example: the
barycentric RGB triangle reads back with correct pixels and channel
order on an RTX 4090.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:44:37 +00:00
87264bb2ec Merge pull request 'perf(rt): place SBT in device-local memory via upload-strategy helper (#72)' (#107) from claude/issue-72 into master 2026-06-17 19:42:02 +02:00
catbot
b410f2d5aa perf(rt): place SBT in device-local memory via upload-strategy helper (#72)
The shader binding table is written once at pipeline creation and read by
the GPU on every vkCmdTraceRaysKHR thereafter, yet it was allocated plain
HOST_VISIBLE (system RAM) — so every trace dispatch read raygen/miss/hit
records over PCIe.

Route the SBT allocation through Device::PreferDirectDeviceWrite (#89):
when a DEVICE_LOCAL|HOST_VISIBLE type exists, prefer DEVICE_LOCAL on top
of the required HOST_VISIBLE so the buffer lands in mappable device
memory (GPU reads hit local VRAM; the one-time memcpy goes write-combined
over PCIe). When no combined type exists, the preferred hint is dropped
and GetMemoryType (#59) falls back to plain HOST_VISIBLE — current
behaviour. The SBT is tiny so a small BAR window's budget is never the
constraint.

The buffer stays mapped and the existing FlushDevice gates on the chosen
memory type's coherency flags (#60), so a direct-write type lacking
HOST_COHERENT is still flushed correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:41:34 +00:00
2c8a293b57 Merge pull request 'perf(decompress): release compressed staging after submit, not for the resource's life (#67)' (#106) from claude/issue-67 into master 2026-06-17 19:41:20 +02:00
catbot
b29b8f0371 test(decompress): cover compressed-staging release on the real GPU path
MeshDecompressStagingRelease drives the real VK_EXT_memory_decompression /
GDeflate path on a headless device and asserts the staging is handed to the
deferred-deletion queue (not pinned), the build is validation-clean with
byte-correct decompressed data, and the entry retires only after framesInFlight
frames. Skips the GPU-path assertions when the extension is absent (CPU fallback
never allocates the staging).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:40:39 +00:00
catbot
8a32f0d545 perf(decompress): release compressed staging after submit, not for the resource's life
The compressed Mesh::Build / ImageVulkan::Update paths kept their host-visible
`compressedStaging` (holding the GDeflate streams) alive for the whole life of
the mesh/image, pinning host-visible memory long after the single GPU decompress
that reads it has retired.

Release it via VulkanBuffer::DeferredClear() right after recording the decompress.
The recorded vkCmdDecompressMemoryEXT still references compressedStaging.address,
so it must outlive the submit — exactly what the fence-keyed deletion queue
(#101/#102) guarantees: it retires the allocation only after framesInFlight frames
have elapsed, by which point the submit's fence has cleared. Between Builds/Updates
the handle is null; the next call's Resize re-creates it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:40:39 +00:00
576a5eb717 Merge pull request 'perf(rt): allocate TLAS instance buffer in BAR/VRAM, not system RAM (#65)' (#105) from claude/issue-65 into master 2026-06-17 19:38:13 +02:00
catbot
d8bc8a4e45 perf(rt): allocate TLAS instance buffer in BAR/VRAM, not system RAM (#65)
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>
2026-06-17 17:37:39 +00:00
39b882a9cb Merge pull request 'perf(mesh): release per-mesh scratch buffer for static BLAS (#66)' (#104) from claude/issue-66 into master 2026-06-17 19:36:44 +02:00
catbot
8b008b49d5 perf(mesh): release per-mesh scratch buffer for static BLAS (#66)
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>
2026-06-17 17:36:10 +00:00
bafdd6b1e6 Merge pull request 'perf(mesh): skip immutable index re-upload on RT refit UPDATE (#68)' (#103) from claude/issue-68 into master 2026-06-17 19:34:57 +02:00
catbot
a55f15d332 perf(mesh): skip immutable index re-upload on RT refit UPDATE (#68)
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>
2026-06-17 17:34:29 +00:00
3e0a38fafb Merge pull request 'fix(device): fence-keyed deferred resource-deletion queue (#101)' (#102) from claude/issue-101 into master 2026-06-17 15:21:47 +02:00
catbot
cb012d7068 fix(device): fence-keyed deferred resource-deletion queue (#101)
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>
2026-06-17 13:21:12 +00:00
c3d7f52891 Merge pull request 'perf(ui): flush only the written descriptor range, not the whole heap (#61)' (#99) from claude/issue-61 into master 2026-06-16 20:56:46 +02:00
catbot
1f12f074b1 Merge remote-tracking branch 'origin/master' into claude/issue-61
# Conflicts:
#	interfaces/Crafter.Graphics-Device.cppm
#	interfaces/Crafter.Graphics-VulkanBuffer.cppm
#	project.cpp
2026-06-16 18:33:08 +00:00
e86facc2af Merge pull request 'perf(image): batch final mip-chain layout transition (#70)' (#100) from claude/issue-70 into master 2026-06-16 20:31:16 +02:00
catbot
518509aa0e perf(image): batch final mip-chain layout transition (#70)
The mip-generation upload path issued N+1 layout-transition barriers per
chain. The final blit's destination level is never read again, so its
dedicated TRANSFER_DST -> TRANSFER_SRC barrier was redundant: the level was
transitioned to SRC only to keep the final SRC -> consumer transition uniform.

Skip that last per-level barrier and fold the final level into the closing
transition, which now batches two VkImageMemoryBarrier entries
([0, mipLevels-1) from TRANSFER_SRC, the last level from TRANSFER_DST) into a
single vkCmdPipelineBarrier. One fewer barrier call per chain; the N-1
interleaved per-level barriers stay one-at-a-time since each is read by the
next blit. Applied to both the host-upload and GPU-decompress Update paths.

Adds the MipChainBarrierBatch regression test driving the pure barrier
builder (no GPU device needed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:30:44 +00:00
catbot
6d7ad87e38 perf(ui): flush only the written descriptor range, not the whole heap (#61)
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>
2026-06-16 18:29:24 +00:00
00b84dd301 Merge pull request 'perf(rt): high-water-mark growth for TLAS host-input buffers (#64)' (#98) from claude/issue-64 into master 2026-06-16 20:24:48 +02:00
catbot
8e6ba49743 perf(rt): high-water-mark growth for TLAS host-input buffers (#64)
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>
2026-06-16 18:24:09 +00:00
bd8076769a Merge pull request 'perf(pipeline): shared, disk-persisted VkPipelineCache (#69)' (#97) from claude/issue-69 into master 2026-06-16 20:24:03 +02:00
catbot
a728482731 test(pipeline): cover pipeline-cache header validation (#69)
PipelineCacheValidation drives Device::PipelineCacheDataCompatible
directly (no GPU): synthetic device identities + 32-byte cache headers
assert that a matching header is accepted while foreign vendor/device,
a bumped UUID (driver update), unknown version, undersized headerSize,
and short/empty blobs are all rejected. Mirrors the GPU-free style of
MemoryTypeFallback / UploadStrategy. Also gitignore the serialized
pipeline_cache.bin a real-device test run drops in the working dir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:23:25 +00:00
catbot
a3a6dd2006 perf(pipeline): shared, disk-persisted VkPipelineCache (#69)
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>
2026-06-16 18:23:25 +00:00
b7500e1fd0 Merge pull request 'perf(buffer): capacity-reuse in VulkanBuffer::Resize (#63)' (#96) from claude/issue-63 into master 2026-06-16 20:22:38 +02:00
catbot
b6d0ec5cae perf(buffer): capacity-reuse in VulkanBuffer::Resize (#63)
Resize previously always destroyed + reallocated. It now reuses the
existing allocation in place when the new request still fits the created
capacity and the immutable-at-create properties match: usage flags are
fixed at create, and the chosen memory type must still satisfy the
required property flags. preferredPropertyFlags is a best-effort perf
hint and is intentionally not part of the guard. On reuse the buffer
handle, device address and mapped pointer are preserved; only `size`
shrinks to the new logical extent.

Tracks capacity and the created usage flags on VulkanBufferBase, set at
Create and carried through the move constructor.

Adds VulkanBufferResizeReuse, a device-free regression test that drives
the reuse guard directly and asserts the in-place path issues no Vulkan
call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:22:09 +00:00
ab222ffa1f cache directwrite 2026-06-16 20:10:23 +02:00
69641f1c44 Merge pull request 'feat(device): runtime upload-strategy helper PreferDirectDeviceWrite (#89)' (#95) from claude/issue-89 into master 2026-06-16 19:23:59 +02:00
catbot
cfbe181d9e feat(device): runtime upload-strategy helper PreferDirectDeviceWrite (#89)
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>
2026-06-16 17:23:27 +00:00
a19c2934c8 Merge pull request 'perf(ui): additive DispatchFused to collapse consecutive UI passes (#47)' (#93) from claude/issue-47 into master 2026-06-16 19:12:21 +02:00
catbot
c670bc70b7 Merge remote-tracking branch 'origin/master' into claude/issue-47
# Conflicts:
#	project.cpp
2026-06-16 17:10:39 +00:00
catbot
e8f1c7cff9 Merge remote-tracking branch 'origin/master' into claude/issue-47
# Conflicts:
#	interfaces/Crafter.Graphics-UI.cppm
#	project.cpp
2026-06-16 17:09:06 +00:00
df51a81db4 Merge pull request 'perf(vulkan-buffer): skip flush/invalidate on coherent memory (#60)' (#94) from claude/issue-60 into master 2026-06-16 19:07:43 +02:00
catbot
bdbe3bf568 Merge remote-tracking branch 'origin/master' into claude/issue-60
# Conflicts:
#	project.cpp
2026-06-16 17:07:32 +00:00
bda21979fd perf(vulkan-buffer): skip flush/invalidate on coherent memory (#60)
FlushDevice/FlushHost called vkFlushMappedMemoryRanges /
vkInvalidateMappedMemoryRanges unconditionally. Record the chosen
memory type's propertyFlags at Create time (via the index GetMemoryType
returns, not the requested flags) and early-return when HOST_COHERENT.

The cmd overload still always emits its pipeline barrier — only the
redundant host-side flush/invalidate is gated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:04:26 +00:00
f07bbc20f1 Merge pull request 'perf(font): cache per-codepoint advances in font units (#57)' (#92) from claude/issue-57 into master 2026-06-16 19:04:09 +02:00
catbot
574242dd30 perf(ui): add additive DispatchFused to collapse consecutive UI passes (#47)
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>
2026-06-16 17:03:24 +00:00
catbot
ce1eaf6366 Merge remote-tracking branch 'origin/master' into claude/issue-57
# Conflicts:
#	project.cpp
2026-06-16 17:02:48 +00:00
catbot
c169986835 perf(font): cache per-codepoint advances in font units (#57)
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>
2026-06-16 17:00:48 +00:00
508f119cc6 Merge pull request 'perf(ui): gate per-pixel clip compares behind a flags bit (#50)' (#91) from claude/issue-50 into master 2026-06-16 18:59:48 +02:00
catbot
f2f5a770c0 Merge remote-tracking branch 'origin/master' into claude/issue-50
# Conflicts:
#	project.cpp
2026-06-16 16:59:05 +00:00
catbot
65c19ea84a perf(ui): gate per-pixel clip compares behind a flags bit (#50)
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>
2026-06-16 16:57:07 +00:00
79db987f83 Merge pull request 'perf(input-field): O(n) cursor hit test via Font::NearestCursorByte (#56)' (#90) from claude/issue-56 into master 2026-06-16 18:55:34 +02:00
catbot
d712218f17 perf(input-field): O(n) cursor hit test via Font::NearestCursorByte (#56)
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>
2026-06-16 16:55:03 +00:00
c1bd5fe244 Merge pull request 'perf(ui): scope inter-dispatch barrier to the swapchain image (#48)' (#88) from claude/issue-48 into master 2026-06-16 18:54:33 +02:00
catbot
c1fec9e8af perf(ui): scope inter-dispatch barrier to swapchain image (#48)
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>
2026-06-16 16:53:51 +00:00
c58b68ec40 Merge pull request 'fix(device): preferred mask + required-only fallback for GetMemoryType (#59)' (#87) from claude/issue-59 into master 2026-06-16 18:04:20 +02:00