Win32
- SetTitle had a body only for Wayland, so the title passed to Window's
constructor and every later SetTitle left a Win32 window untitled. It
converts the title from UTF-8 and hands it to SetWindowTextW.
- The window class is registered with RegisterClassW, the window made
with CreateWindowExW, and WndProc hands what it does not handle to
DefWindowProcW. An ANSI class has the text of every message converted
to the process code page on its way to WndProc, so SetWindowTextW
alone would not do: under Wine, in CP1252, 日本 in a title came back
as ??. WM_CHAR, whose handler takes wParam for a UTF-16 code unit, got
€ as its CP1252 byte 0x80 and passed it on as U+0080, and 日 and both
halves of a surrogate pair as ?.
- Both message pumps use PeekMessageW and DispatchMessageW. PeekMessageA
converts WM_CHAR to the code page whatever the window's class: on the
Unicode class, an ANSI pump still turned 日 and the surrogates into ?.
Wayland
- SetTitle handed title.data() to xdg_toplevel_set_title, which reads up
to a NUL: a view into a longer string sent the whole string, and an
empty view's null data() made libwayland fail the display with EINVAL.
It copies the view into a std::string, as SetAppId does.
Tests
- TextWin32: the constructor's title, SetTitle with characters outside
the code page, with a view into a longer string and with an empty
title, read back with GetWindowTextW; and é, €, 日 and a surrogate
pair posted as WM_CHAR and delivered by StartSync's own loop, which
returns on the WM_CLOSE posted after them, so a StartSync pumping with
the A functions fails it. The window is disabled, so a real keystroke
can't reach the check. Without the fix every check but the empty title
fails. Like PointerWin32, built on Linux it runs under Wine.
- TitleWayland: SetTitle's set_title requests, read off a wl_display
connected to a socketpair, for a view into a longer string and for an
empty view. Both checks fail without the fix. No compositor.
- PointerWin32 pumps with the W functions, as StartSync now does.
Docs
- AGENTS.md names TextWin32 beside PointerWin32 as the Win32 tests, says
what their Wine runs needed (the vulkan-1 import library, -luuid, which
the link flags lack, #184, and a __libc_single_threaded definition),
and lists TitleWayland among the tests that need no GPU.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Window
- CreateSwapchain asked for exactly numFrames (3) images whatever the
surface said, read them into a three-element array, and keyed the
command buffers, the fences and the descriptor-heap copy holding "its"
swapchain image by the acquired image index. A swapchain of another
size fell back to FIFO (asked for MAILBOX or IMMEDIATE) or threw, and a
surface whose minimum is above three got a create info below it
(#160). Mesa's MAILBOX needs four images: Turnip on phoc, llvmpipe on
any Wayland compositor.
- The present mode is picked first, then the swapchain is sized by
SwapchainCapabilities: the surface's counts for that mode where the
instance has surface_maintenance1. With swapchain_maintenance1 the
swapchain names its mode (VkSwapchainPresentModesCreateInfoKHR), which
is what makes the mode's own count the one that applies, at creation and
to the forward progress of a UINT64_MAX acquire; without it the stricter
of the mode's and the surface's counts for all modes. It asks for
numFrames or that minimum where more, never past the maximum
(SwapchainMinImageCount). Turnip's FIFO keeps its three images and
MAILBOX gets four. The FIFO fallback and presentModeUnfit are gone.
- images, imageViewInfos (was imageViews: they are create-infos), the made
imageViews that descriptor sets take, imageInitialised and the
render-finished semaphores are per image, as many as the driver made.
The views are made once per swapchain. The semaphores only grow, since
a present of the old swapchain may still wait on one.
- Frames in flight stay numFrames, keyed by currentFrame, frameCounter %
numFrames: the command buffer, the fence, the acquire semaphore, the
heap copy (or set), RenderingElement3D::tlases, the frameIdx a pass is
recorded with. currentImage is the acquired image. currentBuffer is
gone, so code that used it stops compiling instead of picking the wrong
one; RenderPass::Record's frameIdx keeps its name and is now the frame
slot, as RenderPass.cppm says.
- The frame's fence is waited before the acquire. The acquire semaphores
need no spare then, and a frame waits for exactly the one numFrames
before it, which the deletion queue and the staging rings count on and
fences keyed by image did not promise once images come back out of
order. The swapchain slots are written before the fence is reset.
- From the acquire to the present the window is recording: a Resize or
SetPresentMode from a listener (a VSync key ticked in onUpdate) takes
effect after the present, through RebuildSwapchain, together with a
present that found the swapchain out of date. Rebuilding mid-frame used
to destroy the image the frame was drawing into; with images sized per
swapchain it would also index past the new one's end.
DescriptorHeapVulkan
- Frames and images pair up differently from frame to frame, so no heap
copy can keep one swapchain image. AddSwapchainSlot(slot, window) lists
an image slot as that window's swapchain image, and Window::Render
writes the image a frame acquired into its window's listed slots in that
frame's copy, or its set, before onUpdate, skipping a slot that holds it
already. A slot is one window's, so two windows can share a heap without
writing each other's slots under each other's frames in flight. A slot
listed during a frame its window records is written at once. A slot past
the heap's image slots, an empty ImageSlot's 0xFFFF among them, is
refused. Freeing a slot unlists it, as does initialising the heap in
either binding model. WriteImageSet, the one use of which this
replaces, is gone, and with it the per-frame dimension of setImageViews.
Device
- VK_KHR_surface_maintenance1 (or EXT) and VK_KHR_get_surface_capabilities2
are enabled where the loader has them (surfaceMaintenance1Supported,
vkGetPhysicalDeviceSurfaceCapabilities2KHR), and the matching
swapchain_maintenance1 with its feature where the device has it
(swapchainMaintenance1Supported).
- DistinctGpus groups the physical devices by GPU as CRAFTER_GRAPHICS_DEVICE
numbers them, for ChooseGpu and tests.
UI
- UIRenderer lists its output slot instead of writing every copy itself,
and again on every resize; the resize listener is gone on both
backends. The headers' frameIdx is currentFrame.
Examples
- VulkanTriangle, Sponza, SponzaBench and RTVolume list their output slot
and write only the per-frame TLASes. They wrote imageViews[f] into copy
f once, which a resize left pointing at destroyed images.
Tests
- SwapchainImageCount: SwapchainMinImageCount on the numbers drivers
report; every offered mode keeps its mode and gets what the window asks
for; every frame presents the colour it drew, read back a texel per
frame; a switch and a resize asked for during a frame wait for its
present; a slot listed during a frame holds its image at once; two
windows on one heap draw their own colours. The frame checks run again
in child processes, through CRAFTER_GRAPHICS_DEVICE, on a GPU whose
swapchains have more images than frames (this one, else llvmpipe), in
each binding model the parent didn't cover; a child skips only for a GPU
that can't run the library, and checks it runs the model it was asked
for.
- FrameLoopSync checks the frame slot follows the frame counter and counts
the swapchain's own images. HeapASRead, HeapDynamicIndexHit and
RTPassPushData list their swapchain slot and drop their resize rewrites.
Docs
- README, AGENTS.md and AGENTS.consumer.md: frames and images, the
swapchain slots, rebuilds held to the present, the new optional groups.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DescriptorHeapVulkan
- The buffer region started at images * imageDescriptorSize /
bufferDescriptorSize, rounded down. A descriptor_heap array indexes
the heap from byte 0 in strides of its descriptor size, so buffer slot
0 has to sit on a whole buffer descriptor past the images. Where the
image descriptors were not a whole number of buffer descriptors, the
first buffer descriptors overwrote the last image descriptors. Where
they were less than one, the start was bumped to descriptor 1, but the
heap was still sized as if the buffers began right after the images,
so the last buffer descriptors went into the implementation's reserved
range. Every descriptor size is a power of two, so both cases need
image descriptors smaller than buffer descriptors, which NVIDIA (32
and 16 bytes) and llvmpipe (64 and 16) do not have. The start now
rounds up and the heap is sized from it (#163).
- The reserved range sat at a multiple of imageDescriptorAlignment only,
where vkCmdBindResourceHeapEXT also wants bufferDescriptorAlignment
(VUID-vkCmdBindResourceHeapEXT-pBindInfo-11435 and -11436). It now
starts on a multiple of the larger. The buffer descriptors need no more
than the rounding: each alignment is at most its descriptor size, so a
whole number of descriptors is aligned.
- bufferStartElement is 16 bits and took the division unchecked, so an
image region past 65535 buffer descriptors (16384 images on llvmpipe)
wrapped the buffer region around onto the images. Slot counts whose
buffer indices do not fit 16 bits, 0xFFFF being the empty slot, now
throw, naming the counts. A heap without buffer slots has no buffer
region, which starts at 0 as in descriptor-set mode, so image-only
heaps have no index to fit.
- On a driver that reserves nothing (llvmpipe), a heap without sampler
slots was a zero-size buffer, which every Initialize(1, 1, 0) made,
and Window::Render bound its address each frame. Each heap now holds at
least one descriptor, as each binding does in descriptor-set mode. That
grows NVIDIA's sampler heap by one descriptor when no sampler slots are
asked for; the resource heaps of the slot counts in the tree keep their
layouts byte for byte.
- ComputeLayout holds the arithmetic, pure over the descriptor-heap
properties, and Initialize goes through it. GetBufferOffset and
GetBufferOffsetElement, which had their own copies of the old
arithmetic and never had a caller, are gone.
- Bind(cmd, frame) binds a frame's heaps with the reserved ranges
Initialize stored, or in descriptor-set mode the frame's descriptor
set. Window::Render and the RTVolume example worked the reserved-range
offset out from the heap's size with the image-alignment mask, and
call Bind now. The window's per-heap cache of bind structs goes with
it: keyed on the heap pointer, it kept binding the old buffers after a
re-Initialize that reallocated them. The heap still ends with its
reserved range, so code that derives the offset from the size keeps
getting the right one.
Tests
- DescriptorHeapLayout: the issue's two layouts, the reserved range where
buffer descriptors are aligned more strictly than image ones, every
legal combination of descriptor sizes and alignments up to 256 bytes,
no heap empty, the NVIDIA and llvmpipe layouts, the 16-bit limit and
image-only heaps. Pure CPU, no device. Against the old arithmetic it
fails 12 of its 18 checks.
Docs
- README.md's heap bullet and AGENTS.consumer.md say how to bind the
heap outside the frame loop, and the consumer notes that Initialize
throws when buffer indices would pass 16 bits. AGENTS.md lists
ComputeLayout among the functions the pure-logic tests drive.
Closes#163.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Window
- The constructor made a fractional scale, a toplevel decoration and a
viewport from Device::fractionalScaleManager, Device::manager and
Device::wpViewporter without looking at them. Device::handle_global
binds those globals only when the compositor advertises them, and
Initialize requires only wl_shm, wl_compositor and xdg_wm_base, so on a
compositor without one of the three (GNOME's Mutter has no
xdg-decoration) the constructor handed libwayland a null proxy and
crashed before the window existed (#162). Each object is now made only
when its global is there, which Resize and the destructor already
assumed.
- The fractional scale needs the viewporter too: the preferred scale is
applied through the viewport, which shows the buffer's physical pixels at
width/scale x height/scale. A surface without a viewport is as large as
its buffer, so there the scale stays 1, which keeps pointer and touch
positions, configure sizes and the text-input caret, all converted by
it, in physical pixels; the compositor scales the window up on a HiDPI
output. A scale that changes after the constructor is still applied only
by the next resize, which is #165.
- Without xdg-decoration the window has no title bar, since the library
draws no client-side decorations.
Device
- The optional globals say they are optional, and that a request on a
null proxy crashes.
Tests
- WindowOptionalGlobals: hides xdg-decoration, wp_viewporter and
wp_fractional_scale_v1 from the Window one at a time and all at once,
by nulling the Device statics after Initialize, and renders, resizes
and closes a window each time without a protocol error. Before the fix
it crashed on the window without xdg-decoration. A global the compositor
does not offer is reported as a SKIP line, since the path that uses it
goes untested there.
README
- Which Wayland protocols the window needs, and what it does without the
optional ones.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
examples/Sponza no longer built natively (#164). Its closest-hit declared a
descriptor_heap sampler2D, which glslang has rejected since c8253623
("Reject descriptor_heap combined image samplers"), and main.cpp wrote it
through VkResourceDescriptorDataEXT::pCombinedImageSampler, which that union
no longer has: the descriptor heap has no combined image samplers. Both went
unnoticed because nothing builds the examples.
Sponza
- closesthit.glsl declares texture2D and sampler heap arrays and joins them
where it samples, as ui-shared.glsl and HeapDynamicIndexHit do. The
sampler slot is a second spec constant beside the image slot, so the
shader assumes nothing about where the host put it, and a constant index
keeps the example running on NVIDIA 610.43.02 (see its README). The
comment no longer says the image slot is offset by bufferStartElement;
only buffer slots are.
- main.cpp gives the heap a sampler slot, writes the albedo as a
SAMPLED_IMAGE from a view create-info, and writes the sampler into the
sampler heap from a VkSamplerCreateInfo with vkWriteSamplerDescriptorsEXT,
as UIRenderer::RegisterSampler does. SamplerVulkan's VkSampler had no use
left. The sampler keeps its linear filtering and repeat addressing and
drops anisotropy, which a hit shader cannot use: it samples at LOD 0,
without derivatives.
Docs
- README.md and examples/Sponza/README.md quote the new declarations, and
the Sponza README says why the texture and the sampler are two
descriptors. Its texture2DArray materials pattern declared a
descriptor_heap sampler2DArray, which glslang rejects as well; it now
joins a texture2DArray and a sampler.
- examples/SponzaBench/README.md drops its caveat that Sponza does not
compile.
Tests
- ExampleShaders compiles every GLSL shader under examples/ with glslang as
crafter-build does for the example (Vulkan rules, SPIR-V 1.4, shaders/ on
the include path, and the descriptor-set build where the example
registers one), so a glslang that starts rejecting one fails a test
rather than an example build nobody runs. It fails on Sponza's old
closest-hit and needs glslang only.
Closes#164.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Device
- Device::Initialize(required, optional) takes DeviceFeatures, the five
core feature structs vulkan10 to vulkan14. The library enables only what
its own shaders and code use. Device::enabledFeatures says what was
enabled, supportedFeatures what the GPU has, apiVersion the version in
use.
- shaderInt16, shaderFloat16, storageBuffer16BitAccess, scalarBlockLayout
and the uniform-buffer, storage-buffer and storage-image non-uniform
indexing bits are no longer enabled for everyone. An application whose
shaders take uint16_t spec constants passes
{.vulkan10 = {.shaderInt16 = VK_TRUE}}. scalarBlockLayout stays on with
the NVIDIA workaround, whose TLAS address may sit in a struct's tail
padding.
- Every GPU is weighed before one is chosen: Vulkan 1.3, the swapchain and,
below 1.4, maintenance5 extensions, the library's needs, the required
features. One that can't run the application is skipped, saying why when
it ranks higher. A CPU device runs it only when nothing else is listed,
and CRAFTER_GRAPHICS_DEVICE never falls back. A GPU two drivers both list
is tried through each.
- When no GPU qualifies, Initialize throws naming each GPU's gaps. It
destroys the instance it made, before any Wayland connection, so it can
be called again asking for less.
- maintenance5 is vulkan14's on a 1.4 GPU and VK_KHR_maintenance5's below.
CRAFTER_GRAPHICS_NO_VULKAN_1_4 runs as a 1.3 application, to exercise
that path on a 1.4 GPU.
- VK_KHR_spirv_1_4 and VK_KHR_shader_float_controls, core since 1.2, are no
longer enabled, nor VK_EXT_shader_atomic_float, which was enabled without
its feature struct. memoryDecompression's feature bit is queried before
it is enabled.
Shaders
- VulkanShader checks a module's capabilities, declared directly or
implicitly, against the enabled core features, the GPU's subgroup
operations and the optional groups, and throws naming the field to pass,
or saying the GPU lacks it. The tables are generated from vk.xml and
SPIR-V's grammar.
- WorkaroundNvidiaAS::Patch sized a row-major matrix push member by its
columns, so the TLAS address could land inside it.
Window
- The swapchain format is one whose own feature flags allow storage reads
and writes without a format, which since Vulkan 1.3 each format says for
itself; shaderStorageImageRead/WriteWithoutFormat are no longer required.
Tests
- DeviceFeatures: ResolveFeatures, ChooseDevice and CheckShaderCapabilities,
without a GPU.
- FeatureRequests: required and optional features end to end, the 1.3
path, and a retry after a refusal.
- PushConstantRewrite: a row-major mat3x4 push member.
- HeapASRead, HeapDynamicIndexHit, RTPassPushData and the ray-tracing
examples ask for the features their shaders use.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Win32
- WM_MOUSEMOVE is handled again: c9ebd44 put the WM_CHAR case where it
was, so currentMousePos stopped following the mouse and onMouseMove
never fired. Positions are read signed (GET_X_LPARAM), since under
capture they run past the client area.
- onMouseEnter and onMouseLeave. The first move over the client area
after a leave is the enter, and arms the TME_LEAVE whose WM_MOUSELEAVE
is the leave. An enter counts only once that is armed, a leave only
after an enter, and a move past the client area is a move only.
- A press captures the mouse, so a drag that leaves the window still
sends its moves and its release, as Wayland's implicit grab does.
Losing the capture releases what is held, and a button-up without its
press fires nothing.
- WM_CLOSE fires onClose and clears open, as xdg_toplevel.close does,
instead of destroying the HWND under the swapchain. WM_DESTROY no
longer posts WM_QUIT, which nothing read.
- ~Window detaches the HWND and destroys it. It stayed on screen, and its
messages went to the freed Window. WndProc hands messages without a
Window to DefWindowProc, and looks the Window up again after an event
that more work follows, in case a listener destroyed it.
- The constructor's message pump leaves input queued for StartSync: the
WM_MOUSEMOVE sent when a window appears under the cursor made an
onMouseEnter nobody could hear yet. The mouse state is reset before
the platform code, no longer after it.
- A window after the first finds its class registered, which showed an
error box.
- The wheel remainder is the window's, no longer a function static that
every window shared, and a leave drops it.
Window
- PointerEnter, PointerMove, PointerLeave and PointerScroll hold the rules
both native backends report the pointer by. An enter puts the entry
position in currentMousePos before onMouseEnter fires, and is followed
by a move, on Wayland too now. scrollRemainder replaces
Device::scrollDetentRemainder, and mouseInside is shared.
Tests
- PointerEnterLeave: enter, move and leave through the wl_pointer
callbacks.
- PointerWin32: posts a real HWND the messages above and pumps them as
StartSync does. Built for x86_64-w64-mingw32 it runs under Wine, and is
skipped where Wine is not on PATH.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BuildTLAS
- A rebuild for a new instance count or build preference used to create a
new acceleration structure and destroy the old one at once, though the
command buffer being recorded could still use it (a scene and then a
model built in one setup). The acceleration structure now spans its whole
storage buffer, and a rebuild that fits builds into it again, so the
handle and tlases[i].address stay the same and nothing is destroyed. A
TLAS that outgrows its storage gets new storage and a new handle; the old
handle goes to the deletion queue ahead of the storage it lives in. It is
queued after the scratch is resized, and its address goes with it, so a
Resize or create that throws leaves no address of a queued acceleration
structure behind.
- The barrier before the build also orders earlier traces and compute ray
queries of the TLAS, which a refit or a rebuild in place would otherwise
overwrite while they read it (BuildTlasWriteBarrier). Its source access
mask holds no read bit: one does nothing, but makes synchronization
validation 1.4.363 report the second of two builds in one command buffer
as a write-after-write hazard.
- TlasReadBarrier is now TlasBarrier, shared by both barriers.
- Documented: call BuildTLAS once every earlier submission that used
tlases[index] has completed, and when the address stays the same.
TlasWithBuffer
- Moves hand the acceleration structure over, and destroying or assigning
over one queues it ahead of its storage. The implicit members dropped the
handle over freed storage, or left two owners and a moved-from TLAS that
refit through buffers it no longer had.
Tests
- TLASLifetime: destroying, moving, move-assigning and resetting a
TlasWithBuffer.
- TLASHighWaterMark skips without ray tracing. It asserts the acceleration
structure is rebuilt in place on a shrink or a growth within capacity,
that the old handle is queued before its storage on a growth past it, and
that builds in one command buffer keep the handle while they fit, queue
it once when one outgrows the storage, and free it only on its retire
frame.
- TLASBuildBarrier checks the barrier before the build too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
On NVIDIA 610.43.02, selecting a descriptor-heap descriptor with a
runtime index in a hit shader lost the device, for storage buffers and
sampled images alike, which is why the examples use spec constants
there. HeapDynamicIndexHit renders two instances whose closest-hit picks
a buffer and a texture by a runtime index: one instance uses the same
set across every wave, the other a different set in every other pixel
column, so neighbouring lanes select different descriptors. It passes
on 615.78.08 and on llvmpipe.
On NVIDIA drivers older than 615.78.08 it skips rather than abort the
suite. With validation on it also renders in a child process with
validation off, since GPU-assisted validation hides the fault.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HeapASRead renders a triangle, tracing half the frame with traceRayEXT
and ray querying the other half, both reading the TLAS out of the
descriptor heap, and checks the read-back pixels. The test runner runs
it through the engine's normal path, so on NVIDIA it covers the rewrite
too: an inherited CRAFTER_GRAPHICS_NO_AS_WORKAROUND is cleared, and the
test checks the rewrite is on exactly where the driver needs it.
HeapASRead --no-workaround is the check for a newer driver. Each path
renders in its own child process with the rewrite off and validation
off (GPU-assisted validation instruments the shaders), so a device loss
on one doesn't hide the other. On 615.78.08 traceRayEXT still loses the
device and the ray query renders nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TLASEmptyBuild builds frame 0's TLAS with no elements registered and
checks it is a real acceleration structure, then adds an instance and
removes it again, rebuilding each time. Before the fix the first build
threw on NVIDIA (VK_ERROR_OUT_OF_DEVICE_MEMORY).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RenderingElement3D::tlases was a plain static array, so its buffers
were freed by its static destructor at process exit. The Vulkan driver
and layers are loaded during Device::Initialize, after the array was
constructed, so their own statics are torn down first, and destroying
the buffers then called into freed driver and layer state: every
ray-traced app run with CRAFTER_GRAPHICS_VALIDATION segfaulted on exit
inside the validation layer. TLASHighWaterMark and
TLASInstanceDirtyTracking worked around it by tearing frame 0's TLAS
down by hand before returning.
tlases now refers to an array that is never destroyed, the choice
Device already makes for the device itself, and exit reclaims the
memory. Indexing it is unchanged for callers. The hand teardown in the
two tests goes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TLASBuildBarrier drives BuildTlasReadBarrier with no GPU device: the
dependency starts at the acceleration-structure build, reaches the
ray-tracing and compute shader stages, and carries the
acceleration-structure write -> read memory dependency that the old
execution-only barrier lacked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
UIFallbackParity renders one scene through both binding models and
compares the frames. It starts itself twice, once as is and once with
CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP, so the fallback cannot drift from
the heap path on a machine that never takes it. The scene reaches what
HelloUI does not: the four standalone kernels next to the fused one, a
sampled image with two samplers indexed per item, an image slot and a
sampler slot that are freed and registered again, a resize, an image
created with ImageVulkan::Create's default consumer stage, and a
generic Dispatch whose push payload is not a whole number of words.
On a driver without the heap both runs take the fallback, and the test
then only checks that the scene renders. Validation errors raised
while rendering fail a run, which only bites when
CRAFTER_GRAPHICS_VALIDATION is set. They are counted before the frame
capture because Window::SaveFrame raises one itself (#157).
UIFusedShader now also compiles and validates the descriptor-set build
of the fused kernel and pins its push-constant layout: both builds are
fed the same UIFusedHeader bytes.
Co-Authored-By: Claude Fable 5.1 <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>
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>
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>
Issue #110 reported `crafter-build test MeshDecompressStagingRelease` failing
deterministically on the GPU decompress path: the "exactly one allocation
handed to the deletion queue" assertion saw more than one entry.
Root cause (confirmed at Crafter.Graphics-Mesh.cpp:175-176): a static
(allowUpdate=false) Build cannot refit, so it DeferredClear()s its now-dead
per-mesh BLAS scratch in addition to the compressed staging (#67) — two
deferred allocations, not one. The original test built statically yet asserted
size==1, so the scratch was the unaccounted-for second entry. The behaviour
was already corrected on master by PR #109 (the #73 merge, ed9b3f6), which
switched the assertion's build to allowUpdate=true so the scratch is retained.
This hardens that fix: it adds a sibling case that Builds with allowUpdate=false
and asserts the queue holds exactly TWO entries (staging + dead scratch),
builds with zero validation errors, and that both retire on schedule. The count
the #67 assertion relies on is now a named, explicitly-tested quantity rather
than an implicit comment, so a future change to scratch deferral can't silently
shift it back.
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>
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>
The TLAS instance buffer is rebuilt/refit every frame and is co-written by
both the CPU (host-authored fields) and the GPU (the compute shader writes
the transform field in place for transformOwnedByGpu elements). Allocated
HOST_VISIBLE | HOST_COHERENT (system RAM), both GPU accesses crossed PCIe:
the compute write of the transform, and the AS build's read of the instances.
Upgrade the request to HOST_VISIBLE with a best-effort DEVICE_LOCAL preference
(BAR/VRAM), keeping the single shared, persistently-mapped buffer — no staging,
no copy, no extra barrier. A whole-buffer staging copy was rejected: it would
clobber the GPU-written transforms in this in-place co-write design. Now the
compute write and AS build stay in local VRAM; the CPU's write-only,
sequential, never-read-back field writes are the ideal write-combined BAR load.
Correctness:
- DEVICE_LOCAL is a preference only (depends on #59): GetMemoryType falls back
to plain HOST_VISIBLE (the previous behaviour) on GPUs without resizable BAR.
- HOST_COHERENT is dropped from the required set — the combined type may not be
coherent. The existing FlushDevice(cmd, ...) already establishes host->build
visibility and gates its flush-skip on the *chosen* type's flags (#60), so a
non-coherent BAR type still flushes correctly. The barrier is never removed.
metadataBuffer gets the same upgrade separately (#75).
Extends the TLASHighWaterMark hardware test to assert the chosen memory type
matches the HOST_VISIBLE | prefer-DEVICE_LOCAL request and lands on a
DEVICE_LOCAL type where one is reachable; the existing zero-validation-errors
check covers the dropped HOST_COHERENT path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A static (!allowUpdate) mesh can never refit, so its per-mesh DEVICE_LOCAL
scratch buffer is dead weight the moment the build's GPU work completes —
yet it was kept alive for the mesh's whole lifetime. In many-mesh scenes
(where static meshes dominate) this is real, accumulating VRAM waste.
Release it via scratchBuffer.DeferredClear() right after recording a fresh
build for a static mesh. The old "free-after-build is a UAF, the GPU is
still building" hazard is gone: the fence-keyed deferred-deletion queue
(#101/#102) retires the allocation only once the build's frame has passed
its fence. A later Refit on a static mesh takes the rebuild fallback, whose
Resize sees the nulled handle and re-Creates the scratch. Refit-capable
(allowUpdate) meshes keep their scratch as before — an in-place UPDATE
reuses it each frame.
Extends the BLASBuildOptions hardware test to assert the scratch is retained
for allowUpdate meshes and released for static ones (including across a
static-mesh refit rebuild), with the validation layer confirming zero errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
WriteBufferDescriptor / WriteSampledImageDescriptor / RegisterSampler /
WriteSwapchainDescriptors each FlushDevice()'d the entire multi-KB per-frame
descriptor heap after writing one few-byte descriptor. Add a ranged
VulkanBuffer::FlushDevice(offset, bytes) that flushes only the touched bytes,
rounding the range outward to nonCoherentAtomSize as vkFlushMappedMemoryRanges
requires (the WHOLE_SIZE path sidestepped that). The coherent-memory gate from
issue #60 is preserved — coherent memory still skips the flush entirely.
The descriptor writers now self-flush their written range, so the redundant
whole-heap flushes in UIRenderer::Initialize and the resize callback are gone.
Rounding lives in AlignMappedFlushRange (pure math) and is unit-tested without
a device in the new VulkanBufferRangedFlush test; the change was also verified
end-to-end by running HelloUI on a real GPU with validation layers (font-atlas
glyphs, sampler and storage-image descriptors all flush correctly, no VUIDs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BuildTLAS reallocated the host-visible instanceBuffer and metadataBuffer
on every topology change. They only ever need to hold at least
primitiveCount entries (the AS build reads exactly primitiveCount via
tlasRangeInfo, and the copy loop writes [0, primitiveCount)), so a
shrink — or any growth that still fits the previous capacity — can reuse
the existing allocation. Gate the two Resize calls on a high-water-mark
check, removing two of the four reallocations on a count change. The AS
storage + scratch rebuild below is unchanged: it is tied to the AS
itself and dominates this path regardless.
Adds tests/TLASHighWaterMark driving the real hardware AS-build path: it
asserts a shrink (and within-capacity growth) reuses the buffers while a
growth past the high-water reallocates, that builtInstanceCount still
tracks the live count, and that feeding an oversized instance buffer to
the build produces zero Vulkan validation errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>