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

@ -61,10 +61,10 @@ export namespace Crafter {
// has. Mailbox replaces a frame still waiting to be shown, so presenting
// never waits, which suits a window that updates on its own clock
// (Window::updateInterval). Immediate shows each frame at once and may
// tear. A mode the surface lacks falls back to Fifo, as does one whose
// swapchain the driver makes with more images than the frame loop's
// Window::numFrames (Mesa's Mailbox has at least four). In the browser
// the page presents the canvas and the mode has no effect.
// tear. A mode the surface lacks falls back to Fifo. The mode decides how
// many images the swapchain has (Mesa's Mailbox needs at least four),
// which the frame loop takes as they come. In the browser the page
// presents the canvas and the mode has no effect.
enum class PresentMode {
Fifo,
Mailbox,
@ -79,10 +79,12 @@ export namespace Crafter {
Event<void> onClose;
Event<void> onBeforeUpdate;
Event<FrameTime> onUpdate;
// Fires when the swapchain has been recreated for a new size.
// width/height already reflect the new size. Passes that hold
// descriptors referring to window.imageViews[] must re-write them
// here (the old VkImage handles have been destroyed).
// Fires when the swapchain has been recreated, for a new size or a
// new present mode. width/height already reflect the new size, and
// the old images have been destroyed: anything that holds one of
// them, or is sized after the window, is made again here. The heap
// slots that name the swapchain image follow by themselves (see
// DescriptorHeapVulkan::AddSwapchainSlot).
Event<void> onResize;
bool open = true;
bool updating = false;
@ -208,7 +210,10 @@ export namespace Crafter {
void StartUpdate();
void StopUpdate();
// Makes the swapchain again with the new mode and fires onResize, as
// a resize does, so call it outside StartInit/FinishInit. The
// a resize does, so call it outside StartInit/FinishInit. Called while
// a frame is being recorded (from onUpdate, a pass, an input action
// ticked there), the new swapchain is made once that frame has been
// presented: the frame still draws into an image of the old one. The
// environment variable CRAFTER_PRESENT_IMMEDIATE overrides the mode,
// for benchmarks.
void SetPresentMode(PresentMode mode);
@ -221,6 +226,9 @@ export namespace Crafter {
// pixels, or that none has. Remembered: it is applied again whenever
// the keyboard focus comes back to this window.
void SetTextInput(bool active, std::int32_t x = 0, std::int32_t y = 0, std::int32_t width = 0, std::int32_t height = 0, TextInputPurpose purpose = TextInputPurpose::Normal);
// Like SetPresentMode, a resize asked for while a frame is being
// recorded takes effect once that frame has been presented, width and
// height included, which the rest of the frame still draws at.
void Resize(std::uint32_t width, std::uint32_t height);
void Render();
void Update();
@ -337,13 +345,35 @@ export namespace Crafter {
void FinishInit();
VkCommandBuffer GetCmd();
void EndCmd(VkCommandBuffer cmd);
// Makes the swapchain, after the old one's images have stopped being
// used: every caller has waited for the queue (or has no frames yet).
void CreateSwapchain();
// Tear-and-rebuild helper used by Resize() and the OUT_OF_DATE
// recovery in Render(). Calls CreateSwapchain() and re-issues the
// initial PRESENT_SRC_KHR layout transition for the new images so
// Render()'s barriers (which assume oldLayout = PRESENT_SRC_KHR)
// stay valid. Does NOT fire onResize — callers do that.
// The capabilities a swapchain of `mode` on `surface` is sized by. With
// surface_maintenance1 they are the surface's for that mode, which may
// differ from its answer for all modes (Mesa on Wayland: FIFO 3, all
// modes 4, because MAILBOX needs 4). Those are the ones that apply when
// the swapchain is made naming its mode, which `modePassedAlong` says
// (swapchain_maintenance1's VkSwapchainPresentModesCreateInfoKHR).
// Without that the counts for all modes bind as well, so the minimum
// and maximum are the stricter of the two. Asks for the mode last, as
// the validation layer checks minImageCount against the surface's
// last answer. Static so a test sizes swapchains the way the window
// does.
static VkSurfaceCapabilitiesKHR SwapchainCapabilities(VkPhysicalDevice gpu, VkSurfaceKHR surface,
VkPresentModeKHR mode, bool modePassedAlong);
// The minImageCount CreateSwapchain asks for, from those capabilities:
// numFrames, or the surface's minimum where that is more, and never
// more than its maximum (0 means no maximum). The driver may still
// make more. Static so a test can feed it the numbers drivers report.
static std::uint32_t SwapchainMinImageCount(const VkSurfaceCapabilitiesKHR& capabilities);
// Calls CreateSwapchain(). The new images start in UNDEFINED, and
// Render() transitions each on its first acquire (imageInitialised).
// Does NOT fire onResize — callers do that.
void RecreateSwapchainAndImages();
// What Resize and SetPresentMode do once no frame is being recorded:
// waits for the queue, frees the deletion queue, makes the swapchain
// again at width x height and fires onResize.
void RebuildSwapchain();
// Saves the frame the next Render() draws to a PNG at `path`, for
// visual regression tests and screenshots: that Render copies the
@ -353,9 +383,46 @@ export namespace Crafter {
// acquired again.
void CaptureNextFrame(std::filesystem::path path);
std::optional<std::filesystem::path> pendingCapture;
// ── Frames and images ───────────────────────────────────────────
// The frame loop keeps numFrames frames in flight, and that number is
// its own: the swapchain has however many images the driver made for
// the present mode, which may be fewer or more (Mesa's Mailbox has at
// least four; issue #160). Two indices follow from that, set by
// Render() before onUpdate:
//
// currentFrame this frame's slot, frameCounter % numFrames. It
// picks everything that lives per frame in flight:
// the command buffer, the fence and acquire
// semaphore, the descriptor heap copy (or set) the
// frame binds, RenderingElement3D::tlases, the
// frameIdx a RenderPass is recorded with.
// currentImage the swapchain image the frame acquired and draws
// into: images[currentImage].
//
// The two go together differently from frame to frame, so a heap copy
// can't hold "its" swapchain image: Render() writes the acquired one
// into the frame's copy, at the slots DescriptorHeapVulkan::
// AddSwapchainSlot lists for this window, before onUpdate.
static constexpr std::uint8_t numFrames = 3;
std::uint32_t currentFrame = 0;
std::uint32_t currentImage = 0;
// True from a frame's acquire to its present: images[currentImage]
// and the frame's heap copy are this frame's, and a swapchain rebuild
// (Resize, SetPresentMode) waits for the present. What is waiting:
// a new present mode, or a present that found the swapchain out of
// date; and the last size a resize asked for, 0 when none did.
bool recording = false;
bool swapchainRebuildPending = false;
std::uint32_t deferredWidth = 0;
std::uint32_t deferredHeight = 0;
VkSurfaceKHR vulkanSurface = VK_NULL_HANDLE;
VkSwapchainKHR swapChain = VK_NULL_HANDLE;
// Bumped from a process-wide count whenever CreateSwapchain makes a
// swapchain, so it names one swapchain for good: a new swapchain's
// handle and images may reuse the old one's handle values. The heap
// keys what it wrote into each copy on it.
std::uint64_t swapchainGeneration = 0;
// What the swapchain images were created for: STORAGE | TRANSFER_SRC,
// plus TRANSFER_DST where the surface allows it, so a pass can blit or
// clear into the image instead of only writing it from a shader.
@ -363,10 +430,6 @@ export namespace Crafter {
// The mode the swapchain was made with: presentMode, unless the
// surface lacks it or CRAFTER_PRESENT_IMMEDIATE is set.
VkPresentModeKHR swapchainPresentMode = VK_PRESENT_MODE_FIFO_KHR;
// The mode presentMode asks for made a swapchain with more images than
// numFrames once, so swapchains are FIFO until SetPresentMode is
// called again, rather than being made twice at every resize.
bool presentModeUnfit = false;
VkFormat colorFormat;
VkColorSpaceKHR colorSpace;
// Supported composite-alpha mode for vulkanSurface. A surface property
@ -374,8 +437,14 @@ export namespace Crafter {
// once at construction rather than re-derived on every CreateSwapchain
// (i.e. on every resize configure).
VkCompositeAlphaFlagBitsKHR compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR;
VkImage images[numFrames];
VkImageViewCreateInfo imageViews[numFrames];
// One entry per swapchain image, as many as vkGetSwapchainImagesKHR
// returned; remade with the swapchain. imageViewInfos are the views a
// heap descriptor takes (VkImageDescriptorInfoEXT::pView); imageViews
// the same views made, which a descriptor set takes, owned by the
// window and destroyed with its swapchain.
std::vector<VkImage> images;
std::vector<VkImageViewCreateInfo> imageViewInfos;
std::vector<VkImageView> imageViews;
// Tracks whether each swapchain image has been rendered (and thus
// left in PRESENT_SRC_KHR) at least once. Freshly created swapchain
// images start in VK_IMAGE_LAYOUT_UNDEFINED, so the first per-frame
@ -383,17 +452,18 @@ export namespace Crafter {
// Reset in CreateSwapchain(). A presentable image may only be touched
// after it has been acquired, so this initial transition happens
// lazily in Render() (post-acquire) rather than up front.
std::array<bool, numFrames> imageInitialised{};
std::vector<bool> imageInitialised;
std::thread thread;
// Per frame in flight.
VkCommandBuffer drawCmdBuffers[numFrames] = {};
// Per-frame Vulkan info structs whose contents are almost entirely
// invariant. Initialised once in the constructor; Render() patches only
// the few fields that change each frame (the two barriers' image and
// the acquire barrier's oldLayout, and — for the multi-frame-in-flight
// sync below — submitInfo/presentInfo's wait/signal semaphores plus
// submitInfo's command buffer, which follow currentBuffer/frameCounter).
// submitInfo's command buffer, which follow currentFrame/currentImage).
// presentInfo.pImageIndices and pSwapchains point at the
// currentBuffer/swapChain members, so they track value changes
// currentImage/swapChain members, so they track value changes
// (including swapchain recreation) automatically.
VkSubmitInfo submitInfo;
VkCommandBufferBeginInfo cmdBufInfo;
@ -402,14 +472,9 @@ export namespace Crafter {
VkPresentInfoKHR presentInfo;
// ── Multi-frame-in-flight synchronisation (issue #40) ──────────────
// The renderer keeps up to numFrames frames in flight, so a single
// semaphore pair + a per-frame wait-idle is no longer enough. Note
// that drawCmdBuffers, the per-frame descriptor heap slots, and the
// swapchain images are ALL keyed by the acquired image index
// (currentBuffer) — WriteSwapchainDescriptors bakes heap slot i to
// write imageViews[i], so the index that selects a command buffer /
// heap slot must equal the acquired image index. Everything below
// follows from that.
// Render() waits for its frame slot's fence before it acquires, so
// the command buffer, the heap copy and the acquire semaphore of that
// slot are free again, whichever image the acquire then returns.
// Signaled by the queue submit, waited by vkQueuePresentKHR. Keyed by
// the acquired IMAGE index: the presentation engine keeps this
@ -417,29 +482,27 @@ export namespace Crafter {
// semaphore is the only safe key. Keying it per-CPU-frame trips
// VUID-vkQueueSubmit-pSignalSemaphores-00067.
// https://docs.vulkan.org/guide/latest/swapchain_semaphore_reuse.html
std::array<VkSemaphore, numFrames> renderFinishedSemaphores{};
// Grown by CreateSwapchain to the image count and never shrunk: a
// present of the old swapchain may still wait on one, and nothing
// reports when that wait is done, so they live as long as the window.
std::vector<VkSemaphore> renderFinishedSemaphores;
// Signaled by the queue submit, waited + reset before
// drawCmdBuffers[image] and that image's descriptor-heap slot are
// re-recorded. Keyed by image index. Created signaled so the first
// wait on each image passes through instead of deadlocking.
// drawCmdBuffers[frame] and that frame's descriptor-heap copy are
// re-recorded. Keyed by frame slot. Created signaled so the first
// wait on each slot passes through instead of deadlocking.
std::array<VkFence, numFrames> waitFences{};
// Signaled by vkAcquireNextImageKHR, waited by the queue submit. Keyed
// by a free-running CPU frame counter, because the image index isn't
// known until acquire returns. Sized numFrames+1: at most numFrames
// frames are ever in flight (each image's command buffer is gated by
// its own fence, and there are numFrames images), so numFrames+1
// distinct acquire semaphores guarantees the one being reused has no
// pending operation — required by
// VUID-vkAcquireNextImageKHR-semaphore-01779.
static constexpr std::uint8_t numAcquireSemaphores = numFrames + 1;
std::array<VkSemaphore, numAcquireSemaphores> imageAcquiredSemaphores{};
// by frame slot: the slot's fence is waited before the acquire, and
// that fence follows the submit that waited on the semaphore, so the
// semaphore has no pending operation when it is reused
// (VUID-vkAcquireNextImageKHR-semaphore-01779).
std::array<VkSemaphore, numFrames> imageAcquiredSemaphores{};
// Free-running count of Render() calls; drives the acquire-semaphore
// slot (frameCounter % numAcquireSemaphores).
// Free-running count of Render() calls; currentFrame is this modulo
// numFrames.
std::uint64_t frameCounter = 0;
std::uint32_t currentBuffer = 0;
VkPipelineStageFlags submitPipelineStages = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
std::vector<RenderPass*> passes;
DescriptorHeapVulkan* descriptorHeap = nullptr;
@ -450,7 +513,7 @@ export namespace Crafter {
// holds the per-Window state Crafter::Window users expect:
// a list of render passes and a pointer to the descriptor heap.
static constexpr std::uint8_t numFrames = 1;
std::uint32_t currentBuffer = 0;
std::uint32_t currentFrame = 0;
std::vector<RenderPass*> passes;
DescriptorHeapWebGPU* descriptorHeap = nullptr;
std::optional<std::array<float, 4>> clearColor;