Commit graph Crafter.Graphics/tests
Author SHA1 Message Date
234fcce099
fix(window): size the swapchain for its present mode, and key frames in flight by frame instead of image
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>
2026-10-31 18:14:18 +01:00
852ae8abe3
fix(descriptor-heap): round the buffer region up, align the reserved range for buffers too, and never size a heap at zero
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>
2026-10-31 16:06:44 +01:00
670df31cc6
fix(wayland): open windows on compositors without xdg-decoration, viewporter or fractional scale
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>
2026-10-31 09:59:13 +01:00
6e94cc6209
fix(rt): split Sponza's combined image sampler into a heap texture and sampler
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>
2026-10-31 08:25:52 +01:00
bcdc885de8
feat(device): applications ask for the device features their shaders use
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>
2026-10-31 06:40:02 +01:00
c62109caf3
fix(win32): restore mouse moves, add enter and leave, capture drags, and close and destroy windows cleanly
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>
2026-10-31 03:29:50 +01:00
397ff2db51
fix(rt): rebuild TLASes in place, after earlier traces, and queue their handles like a Mesh's
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>
2026-10-31 02:32:48 +01:00
079b6b0e1f
feat: windows on their own clock, deferred deletion for every handle owner, staged re-uploads, and RTPass push data
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>
2026-10-20 20:20:29 +02:00
37fe0dc49f
test(rt): pick hit-shader descriptors from the heap by a runtime index
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>
2026-10-19 03:03:16 +02:00
918b4b0eb3
test(rt): read the TLAS from the descriptor heap, with a probe for the NVIDIA fault
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>
2026-10-19 03:03:16 +02:00
e5cfbd249c
test(rt): an empty scene's TLAS builds, then grows and shrinks
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>
2026-10-19 03:03:16 +02:00
70d9c2c82a
fix(rt): never destroy the static TLASes
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>
2026-10-19 03:03:16 +02:00
5d25c1f825
test(rt): BuildTLAS's read barrier makes the build visible to TLAS reads
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>
2026-10-19 03:03:16 +02:00
2b6f90b12c test(ui): the descriptor-set fallback draws the same frame as the heap
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>
2026-10-06 17:28:46 +02:00
42a479572d SPDX license update 2026-07-22 18:09:06 +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
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
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
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
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
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
3655cd636c test(mesh): pin static-build deletion count in #67 staging-release test (#110)
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>
2026-06-17 18:50:38 +00:00
catbot
ed9b3f67a7 test+usage: device-local geometry readback + isolate #67 staging count
- 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>
2026-06-17 18:05:13 +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
801509d9d5 perf(rt): allocate TLAS metadata buffer in BAR/VRAM, not system RAM (#75)
The TLAS metadata buffer is CPU-written every frame (one userMetadata word
per element in BuildTLAS's copy loop) and read by the ray shaders as a
STORAGE_BUFFER via device address every frame. Allocated
HOST_VISIBLE | HOST_COHERENT (system RAM), every per-frame shader read
traversed PCIe — the same cost the sibling instance buffer paid before #65.

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

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

Correctness:
- DEVICE_LOCAL is a preference only (depends on #59): GetMemoryType falls back
  to plain HOST_VISIBLE (the previous behaviour) on GPUs without resizable BAR.
- HOST_COHERENT is dropped from the required set — the combined type may not be
  coherent. A FlushDevice() is added after the copy loop to make the host writes
  available on a non-coherent type; it self-gates to a no-op on a coherent type
  (#60), so the previous behaviour is preserved wherever the type ends up
  coherent. The metadata buffer is not an AS build input and has no GPU
  co-write, so it needs neither the build-stage barrier the instance buffer
  takes nor a HOST->shader command barrier: the queue submit already orders host
  writes made available before it against every command in the submission,
  exactly as it did when this buffer was HOST_COHERENT.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:40:39 +00:00
576a5eb717 Merge pull request 'perf(rt): allocate TLAS instance buffer in BAR/VRAM, not system RAM (#65)' (#105) from claude/issue-65 into master 2026-06-17 19:38:13 +02:00
catbot
d8bc8a4e45 perf(rt): allocate TLAS instance buffer in BAR/VRAM, not system RAM (#65)
The TLAS instance buffer is rebuilt/refit every frame and is co-written by
both the CPU (host-authored fields) and the GPU (the compute shader writes
the transform field in place for transformOwnedByGpu elements). Allocated
HOST_VISIBLE | HOST_COHERENT (system RAM), both GPU accesses crossed PCIe:
the compute write of the transform, and the AS build's read of the instances.

Upgrade the request to HOST_VISIBLE with a best-effort DEVICE_LOCAL preference
(BAR/VRAM), keeping the single shared, persistently-mapped buffer — no staging,
no copy, no extra barrier. A whole-buffer staging copy was rejected: it would
clobber the GPU-written transforms in this in-place co-write design. Now the
compute write and AS build stay in local VRAM; the CPU's write-only,
sequential, never-read-back field writes are the ideal write-combined BAR load.

Correctness:
- DEVICE_LOCAL is a preference only (depends on #59): GetMemoryType falls back
  to plain HOST_VISIBLE (the previous behaviour) on GPUs without resizable BAR.
- HOST_COHERENT is dropped from the required set — the combined type may not be
  coherent. The existing FlushDevice(cmd, ...) already establishes host->build
  visibility and gates its flush-skip on the *chosen* type's flags (#60), so a
  non-coherent BAR type still flushes correctly. The barrier is never removed.

metadataBuffer gets the same upgrade separately (#75).

Extends the TLASHighWaterMark hardware test to assert the chosen memory type
matches the HOST_VISIBLE | prefer-DEVICE_LOCAL request and lands on a
DEVICE_LOCAL type where one is reachable; the existing zero-validation-errors
check covers the dropped HOST_COHERENT path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:37:39 +00:00
catbot
8b008b49d5 perf(mesh): release per-mesh scratch buffer for static BLAS (#66)
A static (!allowUpdate) mesh can never refit, so its per-mesh DEVICE_LOCAL
scratch buffer is dead weight the moment the build's GPU work completes —
yet it was kept alive for the mesh's whole lifetime. In many-mesh scenes
(where static meshes dominate) this is real, accumulating VRAM waste.

Release it via scratchBuffer.DeferredClear() right after recording a fresh
build for a static mesh. The old "free-after-build is a UAF, the GPU is
still building" hazard is gone: the fence-keyed deferred-deletion queue
(#101/#102) retires the allocation only once the build's frame has passed
its fence. A later Refit on a static mesh takes the rebuild fallback, whose
Resize sees the nulled handle and re-Creates the scratch. Refit-capable
(allowUpdate) meshes keep their scratch as before — an in-place UPDATE
reuses it each frame.

Extends the BLASBuildOptions hardware test to assert the scratch is retained
for allowUpdate meshes and released for static ones (including across a
static-mesh refit rebuild), with the validation layer confirming zero errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:36:10 +00:00
catbot
cb012d7068 fix(device): fence-keyed deferred resource-deletion queue (#101)
Since #40 dropped the per-frame vkQueueWaitIdle, frames are pipelined:
a resource the CPU is done with may still be read by the GPU for up to
numFrames-1 more frames. VulkanBuffer::Resize's destroy-and-recreate
path was therefore a live use-after-free (#63), no longer masked by the
wait-idle.

Device gains a monotonic, frame-counter-keyed deletion queue:
EnqueueDeletion tags {buffer, memory} with the current frameCounter;
ReclaimDeletions (called per frame after the fence wait) frees entries
once framesInFlight frames have elapsed; DrainDeletions frees everything
after a wait-idle. VulkanBuffer::DeferredClear hands handles to the queue
and nulls the handle, and Resize uses it instead of immediate Clear().

Window::Render sets framesInFlight at init, reclaims after the per-image
fence wait, bumps Device::frameCounter once per frame, and drains on the
resize / OUT_OF_DATE wait-idle paths. The destructor keeps immediate
Clear() (callers destroying mid-flight remain responsible, unchanged).

Adds the DeferredDeletion test: drives the retire timing on a real
headless device with real buffers, stepping Device::frameCounter to pin
the exact reclaim frame, asserting validation stays silent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:21:12 +00:00
c3d7f52891 Merge pull request 'perf(ui): flush only the written descriptor range, not the whole heap (#61)' (#99) from claude/issue-61 into master 2026-06-16 20:56:46 +02:00
catbot
1f12f074b1 Merge remote-tracking branch 'origin/master' into claude/issue-61
# Conflicts:
#	interfaces/Crafter.Graphics-Device.cppm
#	interfaces/Crafter.Graphics-VulkanBuffer.cppm
#	project.cpp
2026-06-16 18:33:08 +00:00
e86facc2af Merge pull request 'perf(image): batch final mip-chain layout transition (#70)' (#100) from claude/issue-70 into master 2026-06-16 20:31:16 +02:00
catbot
518509aa0e perf(image): batch final mip-chain layout transition (#70)
The mip-generation upload path issued N+1 layout-transition barriers per
chain. The final blit's destination level is never read again, so its
dedicated TRANSFER_DST -> TRANSFER_SRC barrier was redundant: the level was
transitioned to SRC only to keep the final SRC -> consumer transition uniform.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:30:44 +00:00
catbot
6d7ad87e38 perf(ui): flush only the written descriptor range, not the whole heap (#61)
WriteBufferDescriptor / WriteSampledImageDescriptor / RegisterSampler /
WriteSwapchainDescriptors each FlushDevice()'d the entire multi-KB per-frame
descriptor heap after writing one few-byte descriptor. Add a ranged
VulkanBuffer::FlushDevice(offset, bytes) that flushes only the touched bytes,
rounding the range outward to nonCoherentAtomSize as vkFlushMappedMemoryRanges
requires (the WHOLE_SIZE path sidestepped that). The coherent-memory gate from
issue #60 is preserved — coherent memory still skips the flush entirely.

The descriptor writers now self-flush their written range, so the redundant
whole-heap flushes in UIRenderer::Initialize and the resize callback are gone.

Rounding lives in AlignMappedFlushRange (pure math) and is unit-tested without
a device in the new VulkanBufferRangedFlush test; the change was also verified
end-to-end by running HelloUI on a real GPU with validation layers (font-atlas
glyphs, sampler and storage-image descriptors all flush correctly, no VUIDs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:29:24 +00:00
00b84dd301 Merge pull request 'perf(rt): high-water-mark growth for TLAS host-input buffers (#64)' (#98) from claude/issue-64 into master 2026-06-16 20:24:48 +02:00
catbot
8e6ba49743 perf(rt): high-water-mark growth for TLAS host-input buffers (#64)
BuildTLAS reallocated the host-visible instanceBuffer and metadataBuffer
on every topology change. They only ever need to hold at least
primitiveCount entries (the AS build reads exactly primitiveCount via
tlasRangeInfo, and the copy loop writes [0, primitiveCount)), so a
shrink — or any growth that still fits the previous capacity — can reuse
the existing allocation. Gate the two Resize calls on a high-water-mark
check, removing two of the four reallocations on a count change. The AS
storage + scratch rebuild below is unchanged: it is tied to the AS
itself and dominates this path regardless.

Adds tests/TLASHighWaterMark driving the real hardware AS-build path: it
asserts a shrink (and within-capacity growth) reuses the buffers while a
growth past the high-water reallocates, that builtInstanceCount still
tracks the live count, and that feeding an oversized instance buffer to
the build produces zero Vulkan validation errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:24:09 +00:00
bd8076769a Merge pull request 'perf(pipeline): shared, disk-persisted VkPipelineCache (#69)' (#97) from claude/issue-69 into master 2026-06-16 20:24:03 +02:00
catbot
a728482731 test(pipeline): cover pipeline-cache header validation (#69)
PipelineCacheValidation drives Device::PipelineCacheDataCompatible
directly (no GPU): synthetic device identities + 32-byte cache headers
assert that a matching header is accepted while foreign vendor/device,
a bumped UUID (driver update), unknown version, undersized headerSize,
and short/empty blobs are all rejected. Mirrors the GPU-free style of
MemoryTypeFallback / UploadStrategy. Also gitignore the serialized
pipeline_cache.bin a real-device test run drops in the working dir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:23:25 +00:00
catbot
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
catbot
cfbe181d9e feat(device): runtime upload-strategy helper PreferDirectDeviceWrite (#89)
Add Device::PreferDirectDeviceWrite(size) so the memory-placement cluster
(#65/#72/#73/#75) no longer re-derives where a CPU-written, GPU-read buffer
should live. It picks the strategy at runtime from Device::memoryProperties:

  - No DEVICE_LOCAL|HOST_VISIBLE type (no resizable BAR) -> false (must stage).
  - ReBAR/UMA (BAR heap >= 90% of largest DEVICE_LOCAL heap) -> true: map and
    write directly; a staging copy would be pure overhead.
  - Small BAR window (BAR heap << VRAM) -> true only for buffers within a
    per-window budget (1/8 of the window); large buffers stage so they don't
    exhaust the window (#58).

Complements GetMemoryType (#59): #59 picks the memory *type*, this picks the
*strategy*. Upload-only — readback (#74) wants HOST_CACHED and stays separate.
A VK_EXT_memory_budget-driven remaining-space check is noted as a follow-up.

Tested by tests/UploadStrategy (pure CPU over synthetic ReBAR / UMA /
small-window / no-BAR layouts, no GPU device needed), mirroring
MemoryTypeFallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:23:27 +00:00
catbot
c670bc70b7 Merge remote-tracking branch 'origin/master' into claude/issue-47
# Conflicts:
#	project.cpp
2026-06-16 17:10:39 +00:00