Commit graph Crafter.Graphics/interfaces
Author SHA1 Message Date
1498a5bf4b
fix(wayland): text input enables once per focus; touches route by their finger's window
enable resets the input method's state, so sending it with every caret
move (an application keeps the cursor rectangle current while typing)
dropped the composition at each keystroke: enable now goes once per
focus and a moved caret sends its rectangle alone; disable only takes
back an enable. The compositor's leave ends both, and a window still
showing composing text hears onPreedit("") then. A finger's up and motion
go to the window it went down on, not the window of the latest down. The
seat creates its pointer and keyboard once, as it already did the touch:
capabilities come again when a device appears or goes, and a second
keyboard proxy delivered every key twice.
2026-10-18 03:28:02 +02:00
c8b5739e1a
fix(window): the destructor cancels the frame callback; a window without a display or swapchain
wl_surface_frame_done re-armed the frame callback through its local and
left the member stale, so the destructor could not cancel it and a done
already queued would dispatch into the freed window; the member now
names the live callback and the destructor destroys it. The destructor
also flushed a null display for a window created without
Device::Initialize (tests do that) and freed never-created command
buffers (a default-constructed Window after Initialize); both guarded.
And wp_scale was one inline static for every window, which the destructor
destroyed on behalf of all of them: it is a member now, like the viewport.
2026-10-18 03:27:50 +02:00
cc04d4e121
fix(wayland): constant-initialised statics in Device, for clang 23.1 on aarch64-musl
With the touch positions in an inline static std::unordered_map and the
text input's strings in inline static std::strings, clang 23.1 crashed
compiling Device.cpp for aarch64-alpine-linux-musl against that sysroot's
libc++ 22 — in LazyCallGraph at -O3, in the ThinLTO bitcode writer at -O0
(a dangling global); the native build was fine. The fingers now live in a
constant-initialised array of slots and the strings in function-local
statics of the implementation unit; nothing else changes.
2026-10-18 02:06:30 +02:00
0d8287c122
feat(wayland): Window::SetAppId
xdg_toplevel.set_app_id, so the compositor can match the window to its
.desktop file: the icon, the task switcher, activation.
2026-10-18 01:57:09 +02:00
678f38964f
feat(wayland): text input through zwp_text_input_v3
A window can now say that a text field has the focus and where its caret
is — Window::SetTextInput(active, caret rectangle in physical pixels,
purpose) — which is what opens the on-screen keyboard on a phone (Maliit
under KWin, squeekboard under phoc). What the input method produces comes
back as events: committed text as onTextInput, the composing text as
onPreedit (empty clears it), deletions around the cursor as
onTextInputDelete; done() applies them in the protocol's order. The state
a window wants is remembered and sent again whenever the keyboard focus
returns to it. The protocol's client code is generated with wayland-scanner
1.26.0 from wayland-protocols' text-input-unstable-v3.xml, under lib/ like
the others.
2026-10-18 01:57:09 +02:00
aed6142cce
feat(wayland): touch, with the first finger standing in for the pointer
The seat's wl_touch was never bound, so a touchscreen could not drive a
window at all. Every finger is now reported through onTouchDown, onTouchMove,
onTouchUp and onTouchCancel (TouchPoint: the compositor's id and the
position in physical pixels). The first finger down also enters the window —
Device::focusedWindow and the pointer serial the clipboard needs — presses
the left button, moves it and releases it when it lifts, so an application
written for a pointer works on a phone as it is.
2026-10-18 01:48:30 +02:00
b16f1e99d5
fix(font-atlas): the atlas destroys its image
FontAtlas::Initialize creates an ImageVulkan and nothing ever called its
Destroy(): every atlas left its 1024x1024 image, memory and view behind.
The atlas now owns the image for its lifetime and is no longer copyable.
ImageVulkan's handles start as VK_NULL_HANDLE and Destroy() resets them,
so an atlas that was never initialised, or an image destroyed by hand
before the atlas goes, is a no-op in the destructor.

The owner waits for the GPU before the atlas goes and, in sets mode,
destroys the descriptor heap that holds the image's view before the atlas.
2026-10-18 01:12:21 +02:00
b412640fac
feat(window): a destructor for the native backends
A Window could be created but never destroyed: the constructor's command
buffers, semaphores, fences, swapchain and Vulkan surface, and on Wayland
the viewport, fractional scale, decoration, xdg toplevel, xdg surface and
wl_surface stayed alive, and Device::windows kept a dangling pointer the
pointer listeners still compared against. An app that hides its window
and shows it again — a resident messaging app closing to a notification
— needs the window to go for real.

The destructor waits for the device, frees and destroys the Vulkan objects
in dependency order (the swapchain before the surface), releases the
Wayland objects from the cursor down to the wl_surface, flushes, and
removes the window from Device::windows and the focus pointers. The
decoration object the constructor used to leak is a member now. The frame
callback member is left alone: wl_surface_frame_done destroys the callback
it is handed and re-arms through its own local, so the member may name a
freed one.
2026-10-18 00:40:25 +02:00
bfc5e108e5
fix(wayland): route keys by keyboard focus, not pointer focus
keyboard_enter and keyboard_leave were empty and keyboard_key dereferenced
Device::focusedWindow, which only a pointer-enter sets: the first key
event in a window the pointer never entered — every key on a touch phone,
or a key typed while the pointer hovers another window — dereferenced a
null pointer, and keys typed after the pointer left went nowhere.

Keyboard focus is its own thing in Wayland. wl_keyboard.enter now resolves
its surface to the Window into Device::keyboardWindow, leave clears it
together with the held keys (their releases happen elsewhere) and stops
the repeat, and keyboard_key and TickKeyRepeats deliver to that window and
drop a key that has none.
2026-10-17 23:48:01 +02:00
48aa9be5ce feat(vulkan): run the compute/UI path without VK_EXT_descriptor_heap
Device::Initialize required twelve device extensions with no capability
check. Mesa Turnip (Adreno) lacks six of them: VK_EXT_descriptor_heap,
VK_KHR_shader_untyped_pointers, VK_EXT_device_fault and the three ray
tracing extensions. vkCreateDevice failed there, so not even a UI-only
program could start, although the UI path is compute-only and needs
nothing else that Turnip is missing.

The descriptor heap, ray tracing and device fault are now optional
groups, detected at start-up from the extension list and the feature
bits (Device::descriptorHeapSupported, rayTracingSupported,
deviceFaultSupported). Ray tracing needs the heap; on a device without
it the ray tracing entry points throw a message naming the cause.

Without the heap the same bindless slots live in one update-after-bind
descriptor set per frame, owned by DescriptorHeapVulkan: binding 0
storage images, 1 sampled images, 2 samplers, 3 storage buffers. Slot
allocation, the slot handles, UIDispatchHeader and the UIRenderer API
are unchanged; buffer slots simply stop being offset. Window::Render
binds that frame's set where it binds the heap, every compute pipeline
shares the heap object's pipeline layout, and push data travels as push
constants.

Each UI kernel is built twice from one source. ui-shared.glsl declares
its resources through UI_HEAP_* macros that expand to descriptor_heap
or, with CRAFTER_GRAPHICS_DESCRIPTOR_SETS defined, to set/binding.
<name>.comp.spv and <name>.comp.sets.spv ship side by side and
ComputeShader::Load picks one. The heap build of every shader is
byte-identical to before.

Breaking:
- ComputeShader::Load takes the heap as a second argument.
- DescriptorHeapVulkan is no longer copyable or movable.
- Custom UI shaders declare heap resources with the UI_HEAP_* macros
  and are registered twice in project.cpp (see the README).
- project.cpp uses Shader::defines and Shader::variant, so it needs a
  crafter-build that has them (Crafter.Build master, fe38cc1 or later).

Also:
- maintenance5 is enabled as a feature, not only as an extension:
  every VulkanBuffer already relied on VkBufferUsageFlags2CreateInfo.
- Barrier stage masks drop RAY_TRACING_SHADER on devices without ray
  tracing, and ImageVulkan::Create resolves its default consumer stage
  to COMPUTE_SHADER there.
- Window refuses a swapchain whose image count is not numFrames
  instead of writing past images[]. The limitation itself is #160.
- CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP forces the fallback on a driver
  that has the heap, so it can be exercised on a desktop GPU.

Verified on an RTX 4090 in both modes under the validation layer, and
on a Fairphone 6 (Turnip, Adreno 810, postmarketOS), where the fallback
is the only path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 17:28:46 +02:00
6aa0338c0f wasm improvements 2026-08-05 04:08:13 +02:00
42a479572d SPDX license update 2026-07-22 18:09:06 +02:00
a879c834c7 webgpu embedding 2026-07-19 01:38:25 +02:00
catbot
7316e51dca fix(window): silence per-frame and setup-path Vulkan validation errors (#153)
Two distinct validation errors the native frame loop emitted, both
originating in Crafter.Graphics with no consumer-side influence.

Problem 1 — per-frame acquire-barrier access/stage mismatch. The
acquire->GENERAL barrier hardcoded dstAccessMask =
SHADER_WRITE|TRANSFER_WRITE but used the per-pass stage union as its dst
stage mask. For an all-compute frame the union narrows to COMPUTE_SHADER,
which does not support TRANSFER_WRITE, so VUID-02820 fired every frame.
Derive the access mask from the same stage union via a new
SwapchainWriterAccess() helper (mirroring SwapchainStageUnion), and apply
it to both the acquire dst and present src masks for symmetry.

Problem 2 — mid-session StartInit/FinishInit (and GetCmd/EndCmd) reuse the
shared drawCmdBuffers[currentBuffer]. With no steady-state wait-idle the
loop's last submission of that buffer may still be in flight when scene
setup runs (building map meshes / acceleration structures), so the old
code re-began (VUID-00049) and re-submitted (VUID-00071) a pending buffer,
and resources freed in the StartInit..FinishInit bracket could still be
referenced by it. Drain the queue at the start of StartInit/GetCmd before
re-recording; setup is rare, so a wait-idle is fine (FinishInit/EndCmd
already wait-idle at the end).

Tests: extend SwapchainBarrierScope with SwapchainWriterAccess coverage
(pure CPU), and add SetupCmdBufferReuse — a real-frame-loop regression
test driving a compute pass plus interleaved mid-session StartInit rounds,
asserting the validation layer stays silent. Verified both halves fail
(reproducing the exact VUIDs) when their respective fix is reverted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:04:25 +00:00
catbot
931500ddc3 perf(webgpu): byteCount-bounded readback for over-provisioned buffers (#133)
WebGPUBuffer::EnqueueReadback / PollReadback always copied the full
buffer `size` GPU→staging→wasm. Over-provisioned event-queue buffers
(e.g. Forts3D's GPU physics event drain) paid the full-capacity copy
every drain even when only a small live prefix held data.

Add an optional `byteCount` parameter (default 0 = whole buffer, so the
existing full-buffer callers are unchanged) bounding the readback to the
live prefix. Pass the same byteCount to the paired PollReadback so the
matching number of bytes lands in `.value`.

JS bridge: the staging buffer is now sized to the full device-buffer
capacity (not the first call's byteSize) so a varying prefix length never
overflows it, while the copyBufferToBuffer + mapAsync/getMappedRange are
bounded to the requested prefix — that's where the copy saving lands.

Verified end-to-end in the browser via RayQueryPick: the full readback
still resolves correctly, and a follow-up 8-byte prefix read copies only
hit+customIndex while primitiveIndex keeps its poison sentinel, proving
the copy was bounded. Native `crafter-build test` suite: 24 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 14:00:36 +00:00
a4fdbf7d30 Merge pull request 'perf(ui): per-shelf dirty spans for tight font-atlas uploads (#129)' (#147) from claude/issue-129 into master 2026-06-18 15:36:33 +02:00
catbot
16d291b0ad perf(ui): per-shelf dirty spans for tight atlas uploads (#129)
FontAtlas::Update used a single union bounding box for the dirty rect.
Scattered new glyphs landing on different shelves produced a tall union
box that re-uploaded mostly-unchanged texels between the shelves.

Track a dirty span per shelf instead and issue one tight UpdateRegion /
wgpuWriteAtlasRegion copy per armed span. A shelf packs glyphs
left-to-right at a fixed top, so each span stays a contiguous X run
capped by the shelf height. The whole-atlas zero-clear in Initialize
keeps using the existing dirtyRect span. `dirty` is now the OR of every
span and is cleared together with them in Update.

Verified: full test suite green (23 passed); HelloUI text renders
crisply on the WebGPU backend through the new multi-span upload path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:35:54 +00:00
9cf972016c Merge pull request 'perf(ui): memoize the input-field caret prefix width (#128)' (#146) from claude/issue-128 into master 2026-06-18 15:30:32 +02:00
catbot
a45e793da4 perf(ui): memoize the input-field caret prefix width (#128)
DrawInputField re-measured the cursor prefix via Font::GetLineWidth on
every frame of a focused field, even though only the blink (caretVisible)
changes frame-to-frame — value, cursorPos and fontSize are stable across
the vast majority of frames.

Cache the measured prefix WIDTH on the InputField, keyed on (prefix bytes,
fontSize). A cheap byte compare of the cached prefix guards the expensive
per-glyph UTF-8 decode + advance accumulation. The cache is mutable so the
const-ref draw fn can refresh it.

The absolute caretX is deliberately NOT cached: it adds the layout-
dependent textX (rect.x + paddingX) each frame, so caching it would give a
relocated field a stale caret. Caching the width keeps that correct.

Adds tests/InputFieldCaretCache pinning: the cached caret matches the
uncached GetLineWidth oracle across states, a redraw (cache hit) is
identical to the miss, the cache invalidates on value/cursorPos/fontSize
changes, the blink never moves the caret, and a relocated field shifts the
caret by exactly the rect delta.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:29:55 +00:00
catbot
88541bf44e Merge remote-tracking branch 'origin/master' into claude/issue-123
# Conflicts:
#	implementations/Crafter.Graphics-UI-Shared.cpp
#	interfaces/Crafter.Graphics-UI.cppm
2026-06-18 13:29:49 +00:00
catbot
1d7d9f7b8e perf(ui): LRU-evict the shaped-run cache instead of clearing it (#123)
At kMaxShapedRuns (8192) UIRenderer::ShapeText did a full shapedRuns_.clear().
A stream of unique strings (FPS counters, timers) alongside many stable labels
periodically nuked every stable entry, forcing a full-UI reshape the next frame.

Track recency with an auxiliary std::list<const ShapedRunKey*> (front = most
recently used) holding pointers to the keys owned by the node-based map. A hit
splices its entry to the front; an overflow pops the least-recently-used entry
from the back and erases just that one — both O(1). The hot set of labels,
reshaped every frame, stays at the front and survives, so the churn of unique
strings only recycles the cold tail. Output is byte-identical either way.

InvalidateFont now drops matching entries from both the map and the LRU list.
Adds ShapedRunCacheSize()/IsShapedRunCached() introspection hooks (the policy
isn't observable through output or atlas.dirty) and a ShapeTextCache test that
asserts the cache plateaus at a fixed cap (evict-one, not clear-all), the hot
label survives a churn past the cap, and an untouched cold string is evicted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:23:34 +00:00
catbot
c8495f548b perf(ui): transparent shaped-run cache lookup, no string copy on hit (#122)
ShapeText built ShapedRunKey{..., std::string(utf8)} before find()
unconditionally — even on cache hits, on the per-frame onBuild path.
N labels meant N string copies + hashes (+ frees for non-SSO strings)
every frame, partially defeating the run cache.

Add is_transparent hash + equality functors on shapedRuns_ and probe
with a borrowing ShapedRunViewKey {const Font*, float, array<float,4>,
string_view}. The owning std::string is now materialised only on a
miss, for emplace. Cache hits copy and hash no string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:19:11 +00:00
2783e47674 Merge pull request 'perf(tlas): dirty-track the per-frame TLAS instance+metadata upload (#118)' (#140) from claude/issue-118 into master 2026-06-18 15:07:44 +02:00
catbot
b5b8c04237 perf(tlas): dirty-track the per-frame TLAS instance+metadata upload (#118)
BuildTLAS rebuilt the host instance+metadata buffers with an O(n) copy of
every 64 B VkAccelerationStructureInstanceKHR + metadata entry every frame,
unconditionally, then flushed the whole high-water capacity (VK_WHOLE_SIZE)
on both buffers. At the millions-of-instances target that copy dominates the
CPU frame, and the whole-buffer flush costs on non-coherent BAR/VRAM.

Add a generation counter so only changed host-authored fields are copied, and
feed the same dirty span into the ranged FlushDevice(offset, bytes) overload:

- RenderingElement3D::hostDataVersion + MarkHostDataDirty() (bumps a global
  monotonic counter). TlasWithBuffer::uploadedVersion records, per frame, the
  version last copied into each slot. A slot is copied only when its element
  advanced past the recorded version; version 0 ("untracked") reads dirty every
  frame, so callers that don't opt in keep the prior copy-every-frame behaviour.
  Globally-unique versions make this correct under relocation (Remove's
  swap-and-pop, and remove+add that nets the same count on the refit path)
  without tracking element identity. The reset on every topology change covers
  buffer reallocation and the reshuffled element->slot mapping.
- The dirty [first, last] envelope drives both the copy and the flush: a new
  VulkanBuffer::FlushDevice(cmd, access, stage, offset, bytes) overload flushes
  + barriers just that span for instanceBuffer, and the ranged
  FlushDevice(offset, bytes) for metadataBuffer. When nothing is dirty both are
  skipped — the skipped HOST->build barrier only ever ordered host writes, never
  the application's compute-written GPU-owned transform (that compute->build
  ordering is the caller's, and is unchanged).

Constraint honoured: transformOwnedByGpu transforms are still never host-copied.
The API field/method are mirrored on the WebGPU class for source portability
(the WebGPU build re-uploads its small mirror wholesale and ignores the version).

New test TLASInstanceDirtyTracking drives the real RT device and reads back the
host-mapped buffers to assert: tracked elements upload once then skip until
re-marked, untracked elements always upload, and relocation on the refit path
re-uploads exactly the moved slots — with zero validation-layer errors over the
ranged flush.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 19:58:45 +00:00
5a9d909f5d Merge pull request 'perf(buffer): reuse staging via a per-frame-in-flight ring in UploadDeviceLocal (#120)' (#139) from claude/issue-120 into master 2026-06-17 21:52:03 +02:00
catbot
85ca08047d perf(buffer): reuse staging via a per-frame-in-flight ring in UploadDeviceLocal (#120)
The staged branch of VulkanBuffer::UploadDeviceLocal did a full
Create (create+getreq+alloc+map) + DeferredClear (later free) per call.
Mesh::Refit / RecordProceduralBuild hit this every frame for deforming
meshes on no-/small-BAR hardware — a device-memory alloc/free cycle per
mesh per frame, on exactly the hardware the staged path targets.

Replace it with a persistent per-buffer staging ring sized to a
high-water mark, reallocated (via Resize, which defers the outgrown
allocation) only on growth — mirroring the TLAS instance/metadata reuse.

The ring is a per-frame-in-flight ring, not a single shared buffer: the
vkCmdCopyBuffer still reads staging after the call returns, and with
frames pipelined framesInFlight deep, overwriting one shared buffer next
frame would clobber data the previous frame's copy is still reading.
Indexing by frameCounter % framesInFlight gives each in-flight frame its
own slot, reused only after framesInFlight frames elapse — the same
window the #101 deletion queue relies on for GPU completion.

The ring is grown lazily, on first entry into the staged branch, so
ReBAR/UMA hardware (which always takes the direct-write branch) never
constructs a staging allocation it does not use — keeping this a runtime
no-op there and a straight upgrade on non-resizable-BAR machines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 19:51:33 +00:00
e3edb87c0f Merge pull request 'perf(mesh): dirty-range vertex upload for deforming-mesh Refit (#119)' (#138) from claude/issue-119 into master 2026-06-17 21:51:26 +02:00
catbot
1f4c77000a perf(mesh): dirty-range vertex upload for deforming-mesh Refit (#119)
Mesh::Refit re-uploaded the entire vertex array every frame on the
in-place UPDATE path (full host write + flush + barrier on the direct
path; full re-stage + copy on the staged path), even when only a handful
of vertices moved.

Add VulkanBuffer::UploadDeviceLocalRange — a dirty-range counterpart to
UploadDeviceLocal that writes/flushes/stages/copies and barriers only the
half-open element range [offset, offset+count) of an already-allocated
device-local buffer. It picks direct-map vs staged-copy from the memory
type the buffer was actually allocated with (not the sub-range size, which
PreferDirectDeviceWrite would mis-route), and the direct path's ranged
flush is rounded to nonCoherentAtomSize and clamped to the allocation size
(mappedSize is now recorded for every buffer, not just mapped ones).

Add a dirty-range Refit overload taking the full vertex/index arrays plus
a (dirtyVertexOffset, dirtyVertexCount) window. The full-span Refit now
delegates to it with the whole array as the window. On the in-place UPDATE
path only the declared window is uploaded — the rest of the device buffer
retains last refit's positions; when an UPDATE isn't possible it falls
back to the full-span rebuild, which is why the full arrays are still
passed. The WebGPU/DOM backend keeps API symmetry: it has no hardware AS,
so it ignores the window and rebuilds the host BVH from the full geometry.

BLASBuildOptions exercises the dirty-range refit on the direct path, the
staged path, and the count-change rebuild fallback, asserting AS-handle /
blasAddr stability and zero Vulkan validation errors.

Resolves #119

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 19:50:48 +00:00
82fd6916d9 Merge pull request 'perf(sync): scope frame-loop barriers to swapchain image + real per-pass stages (#115)' (#137) from claude/issue-115 into master 2026-06-17 21:45:13 +02:00
catbot
9414863cff perf(sync): scope frame-loop barriers to swapchain image + real per-pass stages (#115)
The inter-pass and acquire/present barriers in the frame loop set both
stage masks to ALL_COMMANDS, and the inter-pass dependency used a
queue-wide VkMemoryBarrier — fully serialising against every pipeline
stage and flushing all caches every frame, when all the next pass needs
is the swapchain image the previous one wrote.

Replace the inter-pass global VkMemoryBarrier with an image memory
barrier scoped to the swapchain image's single colour subresource (as
the intra-pass UI barrier already does), and derive the barrier stage
masks per pass: RenderPass::SwapchainStage() is overridden by UIRenderer
(COMPUTE_SHADER) and RTPass (RAY_TRACING_SHADER), so a compute->compute
edge only serialises COMPUTE while an RT pass pulls in RAY_TRACING — the
acquire/present frame-edge masks use the real union of the frame's
passes (SwapchainStageUnion). The base default and the empty-passes
fallback are the conservative COMPUTE | RAY_TRACING | TRANSFER union, so
a polymorphic or un-overridden pass can only be over- not
under-synchronised.

Adds SwapchainBarrierScope (pure CPU) pinning the per-pass derivation,
the union narrowing, and the inter-pass barrier scope; FrameLoopSync
already drives the real GPU frame loop with validation enabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 19:44:33 +00:00
faf1e5f5e8 Merge pull request 'perf(image): release static texture staging after upload (#114)' (#136) from claude/issue-114 into master 2026-06-17 21:41:07 +02:00
catbot
b4fa6e596e perf(image): release static texture staging after upload (#114)
ImageVulkan kept its host-visible staging `buffer` (sized w*h, persistently
mapped) alive for the whole life of the image and Destroy() never freed it, so
static textures (e.g. Sponza albedo) pinned HOST_VISIBLE / small-BAR memory
forever.

Update now releases the staging via VulkanBuffer::DeferredClear() right after
recording the buffer→image copy — the same fence-keyed deletion queue
(#101/#102) the compressed Mesh path already uses — so it is freed once the
upload submit's frame has cleared. A new `streamed` flag (set by FontAtlas)
keeps the persistent map for images re-uploaded from a CPU-side staging buffer
every frame; its uploads go through UpdateRegion, which never releases. Destroy
now also frees any staging the image still owns, gated on a live handle so a
released static texture can't double-free.

Adds tests/ImageStagingRelease driving the real upload + readback on a headless
device: asserts the staging is enqueued (not pinned), the image reads back
byte-equal (released staging outlived the submit), the entry retires only after
framesInFlight frames, and a streamed image keeps then frees its staging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 19:40:35 +00:00
catbot
2460a66296 perf(device): pop retired deletions off the front in O(ready) (#116)
ReclaimDeletions ran an O(n) walk + survivor compaction over the whole
deletion queue every frame. frameCounter is monotonic, so retireAfter is
non-decreasing down the queue and the entries ready to reclaim are always
a contiguous prefix. Switch deletionQueue from std::vector to std::deque
and pop the ready prefix off the front, stopping at the first entry still
in flight — O(ready) instead of O(queue).

Resolves #116

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 19:37:58 +00:00
catbot
aafa458d41 Merge remote-tracking branch 'origin/master' into claude/issue-73
# Conflicts:
#	interfaces/Crafter.Graphics-Mesh.cppm
2026-06-17 17:52:06 +00:00
catbot
5f858509c8 perf(mesh): place RT geometry in device-local memory via #89 upload strategy (#73)
vertexBuffer/indexBuffer/aabbBuffer were HOST_VISIBLE (system RAM), so the
BLAS build, every refit, and any hit-shader geometry fetch read them over
PCIe. The usage flags (SHADER_DEVICE_ADDRESS, plus STORAGE on the index
buffer) exist precisely to expose geometry for hit-shader fetch, the dominant
RT shading pattern.

Add VulkanBuffer::UploadDeviceLocal, which places a CPU-written/GPU-read
buffer in device-local memory and picks the upload mechanism at runtime via
the #89 strategy (Device::PreferDirectDeviceWrite):
  - ReBAR/UMA: allocate HOST_VISIBLE|DEVICE_LOCAL (DEVICE_LOCAL preferred),
    map transiently, memcpy, flush-if-non-coherent. No staging buffer.
  - no/small BAR: allocate pure DEVICE_LOCAL + TRANSFER_DST, fill a transient
    HOST_VISIBLE staging buffer, vkCmdCopyBuffer, then DeferredClear the
    staging buffer onto the fence-keyed deletion queue (#101/#102) so it
    outlives the copy submit.

The geometry buffers become non-mapped so the destination is free to be
device-local-only. Same-size re-uploads reuse the allocation, so the device
address stays stable across an in-place AS UPDATE refit. The compressed Build
path now allocates vertex/index as pure DEVICE_LOCAL — the GPU decompressor
fills them directly, no host write.

Tests: BLASBuildOptions asserts device-local placement on the triangle,
procedural, and (budget-forced) staged paths, and exercises the staged copy +
deferred-deletion + in-place refit under GPU-assisted validation with zero
validation errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:49:25 +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
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
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
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
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
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
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