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>
This commit is contained in:
Jorijn van der Graaf 2026-10-31 08:51:18 +01:00
commit 234fcce099
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
29 changed files with 1378 additions and 550 deletions

View file

@ -31,7 +31,7 @@ Partitions, by what they are for. Every partition file exists on every target; o
crafter-build is the only build tool: the Flatpak (`alias crafter-build='flatpak run net.catcrafts.CrafterBuild'`; a locally built one runs with `--branch=local`) or a native build of the sibling checkout. `crafter-build --version` prints the compiler it resolved. Loading `project.cpp` clones Crafter.Event, Crafter.Math and Crafter.Asset into the crafter-build cache and, since they are unpinned, `git pull`s them on every invocation, and clones the two Khronos header repositories, so a build needs the network. `--local` takes the sibling checkouts (`../Crafter.Event`, `../Crafter.Math`, `../Crafter.Asset`) instead and hands the flag on, so Crafter.Asset picks the sibling Crafter.Math too; two copies of one module on the path do not link.
The host is Linux on Wayland. The native Linux backend is Wayland only (no X11), so every test that calls `Device::Initialize` needs `WAYLAND_DISPLAY`: it connects to the display right after creating the Vulkan instance, window or no window. The build links `wayland-client`, `xkbcommon`, `udev` and the Vulkan loader from the host or the sysroot; libevdev is vendored and built as its own static library, `Crafter.Graphics-libevdev`, once per build. A Vulkan 1.3 driver is the floor; `VK_EXT_descriptor_heap` (with `shaderUntypedPointers`) and ray tracing are optional groups detected at start-up. Recent Mesa's llvmpipe advertises the heap, so a machine without a GPU runs the UI path. The Khronos validation layer is optional and off by default.
The host is Linux on Wayland. The native Linux backend is Wayland only (no X11), so every test that calls `Device::Initialize` needs `WAYLAND_DISPLAY`: it connects to the display right after creating the Vulkan instance, window or no window. The build links `wayland-client`, `xkbcommon`, `udev` and the Vulkan loader from the host or the sysroot; libevdev is vendored and built as its own static library, `Crafter.Graphics-libevdev`, once per build. A Vulkan 1.3 driver is the floor; `VK_EXT_descriptor_heap` (with `shaderUntypedPointers`), ray tracing and `swapchain_maintenance1` (with the instance's `surface_maintenance1`, for per-present-mode image counts) are optional groups detected at start-up. Recent Mesa's llvmpipe advertises the heap, so a machine without a GPU runs the UI path. The Khronos validation layer is optional and off by default.
Targets, picked with `--target=` and passed down to the dependencies:
@ -70,9 +70,9 @@ Tests are declared in `project.cpp`, not discovered, through the `LibraryTest(na
A test is an `int main()`: exit 0 passes, nonzero fails, 77 skips (for a missing capability, never for a failed check). Each runs in its own process, in parallel, with a 60 s default timeout, and the output of a failed, crashed or timed-out test is in `build/test-logs/<name>.log`; a later pass does not delete the log, so check its timestamp. What the tests need, in three groups:
- Pure logic, no GPU: they stage `Device`'s statics or feed synthetic data to the decision functions that were factored out for them (`Device::ResolveFeatures`, `ChooseDevice`, `CheckShaderCapabilities`, `GetMemoryType`, `PreferDirectDeviceWrite`, `PipelineCacheDataCompatible`, `AlignMappedFlushRange`, `BuildMipChainFinalBarriers`, `SwapchainStageUnion`, `BuildTlasWriteBarrier`, `UIRenderer::ClipFlags`, `WorkaroundNvidiaAS::Patch`, `DescriptorHeapVulkan::ComputeLayout`, the font and input-field helpers). `PushConstantRewrite` and `UIFusedShader` compile GLSL with glslang and check the SPIR-V with spirv-val at runtime, and `ExampleShaders` compiles every GLSL shader under `examples/` the way crafter-build would, the descriptor-set build included, so a shader contract change that breaks an example fails here without building the example; `MouseScroll` and `PointerEnterLeave` drive the Wayland listener callbacks directly.
- Pure logic, no GPU: they stage `Device`'s statics or feed synthetic data to the decision functions that were factored out for them (`Device::ResolveFeatures`, `ChooseDevice`, `CheckShaderCapabilities`, `GetMemoryType`, `PreferDirectDeviceWrite`, `PipelineCacheDataCompatible`, `AlignMappedFlushRange`, `BuildMipChainFinalBarriers`, `SwapchainStageUnion`, `BuildTlasWriteBarrier`, `UIRenderer::ClipFlags`, `Window::SwapchainMinImageCount`, `WorkaroundNvidiaAS::Patch`, `DescriptorHeapVulkan::ComputeLayout`, the font and input-field helpers). `PushConstantRewrite` and `UIFusedShader` compile GLSL with glslang and check the SPIR-V with spirv-val at runtime, and `ExampleShaders` compiles every GLSL shader under `examples/` the way crafter-build would, the descriptor-set build included, so a shader contract change that breaks an example fails here without building the example; `MouseScroll` and `PointerEnterLeave` drive the Wayland listener callbacks directly.
- A headless device: `Device::Initialize()` and no `Window` (`HeapSlotReuse`, `DeferredDeletion`, `MeshLifetime`, `ImageStagingRelease`, `BLASBuildOptions`, the `TLAS*` tests, ...). Headless still means a Wayland display on Linux. Most of the RT ones check `Device::rayTracingSupported` and return 77 without it; `BLASBuildOptions`, `TLASInstanceDirtyTracking` and `MeshDecompressStagingRelease` do not, and crash on a GPU without ray tracing (`Device::RequireRayTracing` throws out of `main`). `DeviceSelection` skips with one GPU.
- A window and a live compositor: `FrameLoopSync`, `SetupCmdBufferReuse`, `UIFallbackParity`, `SwapchainTransferDst`, `WindowUpdateClock`, `HeapASRead`, `HeapDynamicIndexHit`, `RTPassPushData`, `WindowOptionalGlobals` (hides xdg-decoration, viewporter and fractional scale from the window in turn), and `FeatureRequests` for its retry case. They render, read the frame back with `Window::CaptureNextFrame` (a PNG, decoded with a private `STB_IMAGE_STATIC` copy of stb_image) and check pixels.
- A window and a live compositor: `FrameLoopSync`, `SetupCmdBufferReuse`, `UIFallbackParity`, `SwapchainTransferDst`, `WindowUpdateClock`, `HeapASRead`, `HeapDynamicIndexHit`, `RTPassPushData`, `WindowOptionalGlobals` (hides xdg-decoration, viewporter and fractional scale from the window in turn), `SwapchainImageCount` (every present mode, and swapchains with more images than frames in flight, in child processes on llvmpipe where the host's GPU never makes one), and `FeatureRequests` for its retry case. They render, read the frame back with `Window::CaptureNextFrame` (a PNG, decoded with a private `STB_IMAGE_STATIC` copy of stb_image) or a transfer pass of their own, and check pixels.
Conventions the existing tests follow:
@ -109,7 +109,7 @@ These were decided deliberately. Do not undo them without the maintainer's say-s
4. **The UI contracts are frozen.** `UIDispatchHeader` is 48 bytes and starts every UI push block; `QuadItem` is 64, `CircleItem` 48, `ImageItem` 64 and `GlyphItem` 48 bytes, byte for byte with `ui-shared.glsl` under `std430`. They grow only through the reserved `flags` bits and `_pad`. `UIFusedHeader` (128 bytes) is a separate contract for `DispatchFused`.
5. **Three tiers, and the source is the customisation API.** Tier 1 is `ComputeShader` and `UIRenderer::Dispatch`; Tier 2 the standard kernels, item structs and `UIRenderer` helpers; Tier 3 stateless functions that append items to user-owned buffers and never dispatch. No library `Theme` type (colours come as `*Colors` aggregates), no widget tree, no layout engine beyond `Rect::SubRect` and `Inset`, no hit-testing, no focus management; state lives in user-owned PODs (`InputField` is the model). A component that does not fit is copied and edited, not hooked: no virtual overrides, no extension points. Additive changes (another kernel, another stateless component) are fine.
6. **Standard kernels dispatch one workgroup per 8x8 screen tile**, each thread owning one pixel and iterating every item in array order into a local accumulator, stored once. Array order is draw order and there is no inter-workgroup race. Never go back to a workgroup per item: Vulkan orders nothing between the workgroups of one dispatch, and overlapping items then race on the image (striped fills, stale pixels). The cost is `O(W*H*N)` and documented as such; the shared-memory tile cull in `ui-shared.glsl` must stay a strict superset of the per-pixel test.
7. **Three frames in flight, a fence per image, no steady-state wait-idle.** The owners of buffers, acceleration structures, shader modules and pipelines (`VulkanBuffer`, `Mesh`, `TlasWithBuffer`, `VulkanShader`, `ComputeShader`, `PipelineRTVulkan`) release them through `Device::EnqueueDeletion`, which has overloads for exactly those handle kinds; `ReclaimDeletions` frees an entry `framesInFlight` frames later and `DrainDeletions` after a wait-idle. Owners of handles the queue has no overload for wait for the GPU first and then destroy directly: `~DescriptorHeapVulkan` in descriptor-set mode, `~Window`, `~FontAtlas` through `ImageVulkan::Destroy`. A destructor never destroys a handle without one or the other: a frame in flight may still use it, and a static owner of a queued kind is destroyed after the driver has gone, which only the queue survives. `Clear()` is for a GPU known to be idle. The deletion queue, its mutex and `RenderingElement3D::tlases` are leaked on purpose for the same reason. Staged re-uploads go through the per-frame staging ring in `VulkanBuffer::Stage`.
7. **Three frames in flight, a fence per frame, no steady-state wait-idle.** Frames in flight are not swapchain images: the swapchain has as many images as its present mode needs, what lives per frame (command buffer, fence, acquire semaphore, heap copy, TLAS) is keyed by `Window::currentFrame` and what lives per image by `currentImage`, and the image a frame draws into reaches shaders through the heap's swapchain slots (`DescriptorHeapVulkan::AddSwapchainSlot`, #160). The owners of buffers, acceleration structures, shader modules and pipelines (`VulkanBuffer`, `Mesh`, `TlasWithBuffer`, `VulkanShader`, `ComputeShader`, `PipelineRTVulkan`) release them through `Device::EnqueueDeletion`, which has overloads for exactly those handle kinds; `ReclaimDeletions` frees an entry `framesInFlight` frames later and `DrainDeletions` after a wait-idle. Owners of handles the queue has no overload for wait for the GPU first and then destroy directly: `~DescriptorHeapVulkan` in descriptor-set mode, `~Window`, `~FontAtlas` through `ImageVulkan::Destroy`. A destructor never destroys a handle without one or the other: a frame in flight may still use it, and a static owner of a queued kind is destroyed after the driver has gone, which only the queue survives. `Clear()` is for a GPU known to be idle. The deletion queue, its mutex and `RenderingElement3D::tlases` are leaked on purpose for the same reason. Staged re-uploads go through the per-frame staging ring in `VulkanBuffer::Stage`.
8. **The library enables only the device features its own shaders use.** Everything an application's shaders need (`shaderInt16` for a `uint16_t` spec constant, fp16, 16-bit storage, `scalarBlockLayout`) is the application's to ask for through `Device::Initialize(required, optional)`, and `VulkanShader` refuses a module whose capabilities nothing enabled, naming the field. Do not add a feature to the `library` set for an example's convenience; have the example ask.
9. **Barriers are scoped and derived.** A `RenderPass` reports its `SwapchainStage()`; the frame loop's acquire, inter-pass and present barriers take their stage and access masks from the passes (`SwapchainStageUnion`, `SwapchainWriterAccess`, `BuildSwapchainInterPassBarrier`), masked by `Device::stageMask` so the RT stage never appears on a device without it. In the frame loop there is no `ALL_COMMANDS` and no queue-wide memory barrier, and a ranged flush rounds to `nonCoherentAtomSize`. The acceleration-structure builds and the GPU decompression do use global `VkMemoryBarrier`s on purpose (`BuildTlasWriteBarrier`, `BuildTlasReadBarrier`, `Decompress::DecompressOnGPU`): execution order alone never makes a build's writes visible to the shaders that read them. The window never clears the swapchain image (`TRANSFER_DST` is not on every surface); passes draw their own background.
10. **Driver workarounds are fenced and written to be deleted.** The NVIDIA descriptor-heap AS-read rewrite is one block in `ShaderVulkan.cppm` gated on `Device::workaroundDescriptorHeapAS`, with its push-data hook in `RTPass` and `ComputeShader`, a kill switch (`CRAFTER_GRAPHICS_NO_AS_WORKAROUND`), a probe test and a status note in the README and `examples/VulkanTriangle/README.md`. A new workaround gets the same: a `Device` flag, one fenced block, a probe, a note. A fault the library cannot hide (dynamic descriptor indexing in hit shaders) is documented with the consumer-side pattern instead (`examples/Sponza/README.md`).
@ -141,11 +141,11 @@ These were decided deliberately. Do not undo them without the maintainer's say-s
- `Device::Initialize` on the Wayland backend connects to the display and throws without one; "headless" tests still need a compositor.
- Buffer slot indices: `AllocateBufferSlots` returns an index relative to the heap's buffer region, while GLSL `descriptor_heap` SSBO arrays index in buffer-descriptor units from heap byte 0. `BufferSlot` converts to `bufferStartElement + raw`; raw code (the RT examples) passes `heap.bufferStartElement` as a `uint16_t` spec constant, which needs `shaderInt16`. In descriptor-set mode `bufferStartElement` is 0. Image and sampler slots are used as they are. A wrong index reads zeros, which looks like "every item field is zero".
- `UIRenderer::RegisterBuffer` refuses a buffer created without `VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT`. Freed slots are handed out again at once, so a slot may only be freed when no frame in flight reads it (after `vkQueueWaitIdle`, or at a scene switch). In descriptor-set mode a slot write destroys the previous view immediately.
- The swapchain image stays in `VK_IMAGE_LAYOUT_GENERAL` across the pass list and is never cleared; `Window::clearColor` is unused. `swapchainUsage` says whether `TRANSFER_DST` was available. `onResize` fires after a swapchain rebuild; anything holding descriptors of `window.imageViews[]` rewrites them there (`UIRenderer` does its own).
- The swapchain image stays in `VK_IMAGE_LAYOUT_GENERAL` across the pass list and is never cleared; `Window::clearColor` is unused. `swapchainUsage` says whether `TRANSFER_DST` was available. `onResize` fires after a swapchain rebuild; slots listed with `AddSwapchainSlot` follow by themselves, and anything else holding the old images is made again there. A rebuild asked for during a frame (`Resize` or `SetPresentMode` from a listener) waits for that frame's present, since the rest of the frame indexes the swapchain it acquired from. `RenderPass::Record`'s `frameIdx` is the frame slot, not the image.
- `Device::frameCounter` is one clock for every window, so the deletion queue and the staging rings assume one rendering window.
- `ImageVulkan::Create`'s default `consumerStage` is the RT stage, corrected to compute on a device without ray tracing; the font atlas passes compute explicitly. `VulkanTransition`'s `GENERAL` mapping does the same.
- `UIRenderer::ShapeText` and `FontAtlas` key their caches on the `Font*`: call `UIRenderer::InvalidateFont` before destroying a `Font`, or a new one at the same address aliases stale runs.
- `RenderingElement3D::elements` stores raw pointers; a `std::vector<RenderingElement3D>` that grows after `Add` dangles them (Sponza reserves up front). `BuildTLAS` on an index whose earlier submission has not completed overwrites a TLAS in use; building `tlases[window.currentBuffer]` from `onUpdate` is safe, and `tlases[i].address` changes when a TLAS outgrows its storage.
- `RenderingElement3D::elements` stores raw pointers; a `std::vector<RenderingElement3D>` that grows after `Add` dangles them (Sponza reserves up front). `BuildTLAS` on an index whose earlier submission has not completed overwrites a TLAS in use; building `tlases[window.currentFrame]` from `onUpdate` is safe, and `tlases[i].address` changes when a TLAS outgrows its storage.
- `PipelineRTVulkan::Init` defaults `maxRecursionDepth` to 1; a closest-hit that traces needs 2, and tracing deeper than the pipeline allows loses the device.
- `BuildTlasWriteBarrier`'s source access mask carries no read bit on purpose: synchronization validation 1.4.363 otherwise reports two builds in one command buffer as a write-after-write hazard.
- NVIDIA, as of driver `615.78.08`: reading a TLAS through the heap still faults (worked around); dynamic descriptor indexing in hit shaders works there and faulted on `610.43.02`, so the Sponza pattern (a spec-constant slot, a dynamic index within the resource) stays the portable one. `HeapDynamicIndexHit` skips on the older drivers.