Window
- updateInterval runs Update on the window's own clock while the
compositor sends no frame callbacks (hidden, minimized): the Wayland loop
is a poll() loop, and the Win32 loop sleeps until the next update. Both
read the interval each pass.
- SetPresentMode (FIFO, Mailbox, Immediate). A mode the surface lacks, or
whose swapchain has other than numFrames images (Mesa's Mailbox has four),
falls back to FIFO until the mode is set again.
- AddPollFd / RemovePollFd wake the Wayland loop on the app's descriptors.
- The swapchain has TRANSFER_DST where the surface allows it.
- CaptureNextFrame replaces SaveFrame: it records the copy in the frame,
before the present, where SaveFrame read an image already presented. A
frame unwound past before the write still frees the readback buffer.
- Inter-pass barriers on the swapchain image carry the access masks of the
stages the passes use.
Deferred deletion (issue #101)
- VulkanBuffer, Mesh, VulkanShader, ComputeShader and PipelineRTVulkan hand
their handles to the deletion queue from their destructors and moves, as
a frame in flight may still use them; a static owner goes after the
driver has torn down. VulkanShader and PipelineRTVulkan move and can't be
copied; re-Load and re-Init queue the old pipeline.
- The queue and its mutex are never destroyed, and the queue takes handles
from any thread; Device::frameCounter is atomic. Both the queue and the
staging rings count frames as one window renders them (documented).
- DrainDeletions also lets every buffer's staging ring start over: without
a Window nothing else reclaims either, so an app without one calls it
after waiting for its work.
Meshes and buffers
- A Mesh rebuild queues the old acceleration structure and builds into the
same storage after a barrier, so the BLAS address stays put when it fits.
The refit and rebuild barriers cover compute ray queries too.
- RTBuildOptions::noDuplicateAnyHit. Mesh::geometryFlags replaces opaque,
and the refit and rebuild fallbacks keep the flags.
- A re-upload into a buffer a frame in flight may read (UploadDeviceLocal
of the same size, UploadDeviceLocalRange) copies on the GPU, after those
reads, through a per-frame staging ring; on ReBAR it used to write the
buffer in place. Empty uploads record nothing.
- A compressed Mesh::Build that keeps its buffers decompresses into them
after the reads of frames in flight, and the hit shaders and ray queries
wait for the decompress as well as the BLAS build. Its vertex buffer
takes a ranged refit's copies.
- Mesh move-assigns (queueing the BLAS it replaces), so it can be replaced
in place or erased from a vector.
- VulkanBuffer::Create asks for a device address only for buffers with
SHADER_DEVICE_ADDRESS usage, as vkGetBufferDeviceAddress requires; the
others have address 0. UIRenderer::RegisterBuffer refuses those.
- Build inputs are synchronized as shader reads.
Other
- RTPass pushes the app's own push data and traces at an explicit size.
- DescriptorHeapVulkan hands freed slot ranges out again (they leaked), and
keeps its free lists sorted.
- ImageVulkan's mip chain clamps each level to 1 texel: a non-square
image's shorter side went to 0.
- VulkanShader throws on a missing file instead of failing in bad_alloc.
- CRAFTER_GRAPHICS_DEVICE picks the GPU by its number among the distinct
GPUs Vulkan lists; anything but such a number is an error.
- The debug messenger prints info messages only with
CRAFTER_GRAPHICS_VALIDATION set.
- Windows: the WinRT gamepad code moved to a C file (mingw-w64's WinRT
headers don't compile as C++), -lcombase dropped, a reference leak fixed.
- project.cpp honours --local for Crafter.Event, Math and Asset, as Asset
does for Math, and builds every test through one helper, projectArgs
included.
- Tests: DebugMessengerSeverity, DeletionQueueThreads, DeviceSelection,
HeapSlotReuse, MeshLifetime, MipChainNonSquare, PipelineLifetime,
RTPassPushData, SwapchainTransferDst, UploadInFlight, VulkanBufferAddress,
VulkanShaderLifetime, WindowUpdateClock; others extended.
Breaking: SaveFrame is CaptureNextFrame; Mesh::opaque is geometryFlags; a
buffer without SHADER_DEVICE_ADDRESS usage has no address; frameCounter is
std::atomic.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SPIR-V rewrite for NVIDIA's descriptor-heap acceleration-structure
read fault (#7, #15) was keyed on the driver ID alone, so nothing could
check whether a newer driver still needs it. With
CRAFTER_GRAPHICS_NO_AS_WORKAROUND set, to any value like the other
CRAFTER_GRAPHICS_* switches, Device leaves it off. On 615.78.08 it is
still needed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The TLAS instance and metadata buffers were sized to the instance
count, so building before anything was in the scene created zero-size
buffers. That is invalid usage: NVIDIA fails the allocation with
VK_ERROR_OUT_OF_DEVICE_MEMORY and BuildTLAS threw, leaving the TLAS with
a buffer handle that has no memory behind it.
Both buffers now get at least one slot, and the scratch buffer at least
one byte, since an empty TLAS may report no scratch at all. An empty
TLAS is valid to trace against; every ray misses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BuildTLAS ended with an execution-only barrier from the
acceleration-structure build to the ray-tracing and compute shader
stages. That orders the commands, but without a memory barrier the
build's writes are never made available and visible to the traceRayEXT
and rayQueryEXT reads recorded after it in the same command buffer,
which is the documented per-frame flow (BuildTLAS in onUpdate, then
RTPass): the trace may read a stale or half-written BVH.
The barrier now carries ACCELERATION_STRUCTURE_WRITE ->
ACCELERATION_STRUCTURE_READ. BuildTlasReadBarrier builds it as pure CPU
logic, so a test can check it without a GPU, as with the swapchain
barriers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
~FontAtlas destroyed the image while frames that sample it could still
run (HelloUI declares the atlas after the window, so it goes first); it
waits on the queue now, as the descriptor heap does. The DOM build got
the two new Window methods as no-ops, like the other backend-specific
ones.
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.
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.
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.
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.
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.
ComputeShader::Load built a VulkanShader on the stack and let it go with
its VkShaderModule still alive: nothing in the library destroys one. A
process that loads the UI shaders once never noticed; one that creates a
window again and again (an app that hides and re-shows its window) leaked
five modules per window. Vulkan allows destroying the module as soon as the
pipeline is created, so Load does, on success and on failure alike.
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.
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.
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>
CRAFTER_PRESENT_IMMEDIATE selects IMMEDIATE (then MAILBOX) when the
surface offers it, instead of the default FIFO. Needed to measure
steady-state frame throughput without the compositor's vblank cap; FIFO
remains the default when the variable is unset.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
When `updating` is false (paused / minimized / stopped), the FIFO
present no longer paces StartSync()'s message loop, so the bare
PeekMessage(PM_REMOVE) spin pinned a core at 100% while uselessly
re-running Gamepad::Tick() (mutex + per-pad COM reads) and
onBeforeUpdate every iteration.
Block on MsgWaitForMultipleObjectsEx with a short timeout while
!updating: the loop now wakes on input or every ~16ms, eliminating the
busy-spin while preserving gamepad polling / onBeforeUpdate at a sane
rate (e.g. to detect a controller button asking to resume). The
updating path is unchanged — present remains vsync-gated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BuildTLASUpload pushed GPU-driven (transformOwnedByGpu) runs one element
at a time — a separate FlushDeviceRange of the 16 strided metadata bytes
per element — each paying WebGPU validation / encode / JS-boundary cost,
while the CPU-driven arm already batched contiguously.
Upload the whole active instance range in a single writeBuffer instead.
Pushing the (stale) transform bytes for GPU-driven slots is harmless: the
only supported way to drive a transform from the GPU is the manual
Upload -> physics compute pass -> Build sequence, and that compute pass
runs after this upload and rewrites the transform on the GPU before the
TLAS build reads it. For the in-repo (all-CPU) usage the bytes uploaded
are identical to before — with one CPU-driven run the old loop already
emitted exactly this single FlushDeviceRange(0, 0, primitiveCount*64).
Verified: native suite (24 passed) and the RTStress wasm example (512
ray-traced instances) render correctly through the new path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The metadata mirror is padded to kNPadded (65536) but only primitiveCount
slots are live. metadataBuffer.FlushDevice() wgpuWriteBuffer'd all 256 KB
through the WASM->JS staging path every frame (~100-250x waste for a
few-hundred-instance scene). Switch to the existing FlushDeviceRange
overload — the same one the instanceBuffer loop directly above uses —
sized to primitiveCount * sizeof(uint32_t). The Vulkan parallel already
sizes its flush to primitiveCount, so this was WebGPU-specific.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
UIRenderer::Dispatch built a fresh std::vector<uint32_t> with reserve on
every call, paying one malloc+free per dispatch. Custom compute dispatches
run per-frame, so make the vector thread_local and clear() it each call:
capacity grows to the high-water mark once and is reused thereafter,
eliminating the per-frame allocation churn. Behavior is identical.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- Add TRANSFER_SRC to RT geometry usage so device-local geometry (now in
VRAM, no longer host-mappable) stays copyable/inspectable.
- MeshDecompressStagingRelease: read decompressed vertex/index back via a
device->host copy instead of the removed host-mapped .value/FlushHost, and
build the mesh with allowUpdate=true so the retained per-mesh scratch (#66)
doesn't also land in the deletion queue — isolating the assertion to the
released compressed staging (#67).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>