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,7 +61,10 @@ export namespace Crafter {
// All bindings are PARTIALLY_BOUND | UPDATE_AFTER_BIND |
// UPDATE_UNUSED_WHILE_PENDING: most slots are never written, and a
// slot is written into every frame's set at once while earlier frames
// may still be executing with their set bound.
// may still be executing with their set bound. A swapchain slot too
// is written into a set another window's frame in flight is bound
// to, and when listed during a frame, into the set that frame has
// already bound.
static constexpr std::uint32_t storageImageBinding = 0;
static constexpr std::uint32_t sampledImageBinding = 1;
static constexpr std::uint32_t samplerBinding = 2;
@ -77,12 +80,116 @@ export namespace Crafter {
VkDescriptorSet sets[Window::numFrames] {};
// Descriptor sets reference VkImageView / VkSampler objects, where the
// heap takes create-infos, so in sets mode this struct creates and
// owns them. setImageViews[frame][slot] is the view frame's set uses
// at that image slot; a view shared by all frames is stored under
// frame 0 only. setSamplers[slot] likewise.
std::vector<VkImageView> setImageViews[Window::numFrames];
// owns them: setImageViews[slot] is the view every frame's set uses
// at that image slot, setSamplers[slot] likewise. The swapchain's
// views are its window's (Window::imageViews).
std::vector<VkImageView> setImageViews;
std::vector<VkSampler> setSamplers;
// ─── Swapchain slots ────────────────────────────────────────────
// Image slots that name a window's swapchain image. A window's
// swapchain images are not its frames: it keeps Window::numFrames
// frames in flight over however many images the driver made, and
// which one a frame draws into is known only once it has been
// acquired (#160). So each frame, after the acquire and before
// onUpdate, Window::Render writes the image it acquired into the slots
// listed here for it, as a storage image in GENERAL, in that frame's
// heap copy (or descriptor set), through WriteSwapchainImage. A shader
// reads the image it draws into from one fixed slot, and nothing has
// to be written again after a resize. Each slot is one window's, so
// two windows can share a heap: neither writes the other's slots,
// which the other's frames in flight may be reading. UIRenderer lists
// its output slot; a pass of your own lists its own with
// AddSwapchainSlot. Freeing a slot takes it off the list.
struct SwapchainSlot {
std::uint16_t slot = 0;
// Compared, never dereferenced: a window gone takes nothing down.
const Window* window = nullptr;
// What each frame's copy holds at the slot, so a frame whose image
// hasn't changed writes nothing: the image's index in the
// swapchain of that Window::swapchainGeneration, which starts at
// 1, so generation 0 means nothing yet.
struct Written {
std::uint64_t generation = 0;
std::uint32_t image = 0;
};
std::array<Written, Window::numFrames> written {};
};
std::vector<SwapchainSlot> swapchainSlots;
// Lists an image slot, raw as AllocateImageSlots returned it, as one
// that names `window`'s swapchain image. Every frame's copy gets it
// from that frame on; a slot listed during a frame `window` records
// with this heap gets that frame's image at once, so a pass set up
// mid-frame draws into it the same frame. Listing a slot again moves
// it to `window`. Throws std::invalid_argument for a slot past the
// heap's image slots, which an empty ImageSlot (0xFFFF) is.
void AddSwapchainSlot(std::uint16_t slot, const Window& window) {
if (slot >= imageCapacity) {
throw std::invalid_argument(std::format(
"DescriptorHeapVulkan::AddSwapchainSlot: image slot {} is not one of the heap's {} image slots", slot, imageCapacity));
}
std::erase_if(swapchainSlots, [slot](const SwapchainSlot& listed) { return listed.slot == slot; });
swapchainSlots.push_back({ .slot = slot, .window = &window });
if (window.recording && window.descriptorHeap == this) {
WriteSwapchainImage(window);
}
}
// Writes window.imageViewInfos[window.currentImage] (in sets mode,
// window.imageViews[window.currentImage]) into window's swapchain
// slots in the copy of window.currentFrame. Window::Render calls it
// once the frame's fence has been waited, so none of window's frames
// still reads that copy, and other windows' frames don't read these
// slots.
void WriteSwapchainImage(const Window& window) {
for (SwapchainSlot& listed : swapchainSlots) {
if (listed.window != &window) {
continue;
}
SwapchainSlot::Written& written = listed.written[window.currentFrame];
if (written.generation == window.swapchainGeneration && written.image == window.currentImage) {
continue;
}
if (Device::descriptorHeapSupported) {
const VkImageDescriptorInfoEXT imageInfo {
.sType = VK_STRUCTURE_TYPE_IMAGE_DESCRIPTOR_INFO_EXT,
.pView = &window.imageViewInfos[window.currentImage],
.layout = VK_IMAGE_LAYOUT_GENERAL,
};
const VkResourceDescriptorInfoEXT resource {
.sType = VK_STRUCTURE_TYPE_RESOURCE_DESCRIPTOR_INFO_EXT,
.type = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE,
.data = { .pImage = &imageInfo },
};
VulkanBuffer<std::uint8_t, true>& heap = resourceHeap[window.currentFrame];
const std::uint32_t size = Device::descriptorHeapProperties.imageDescriptorSize;
const VkHostAddressRangeEXT destination {
.address = heap.value + ImageByteOffset(listed.slot),
.size = size,
};
Device::vkWriteResourceDescriptorsEXT(Device::device, 1, &resource, &destination);
heap.FlushDevice(ImageByteOffset(listed.slot), size);
} else {
const VkDescriptorImageInfo imageInfo {
.imageView = window.imageViews[window.currentImage],
.imageLayout = VK_IMAGE_LAYOUT_GENERAL,
};
const VkWriteDescriptorSet write {
.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
.dstSet = sets[window.currentFrame],
.dstBinding = storageImageBinding,
.dstArrayElement = listed.slot,
.descriptorCount = 1,
.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE,
.pImageInfo = &imageInfo,
};
vkUpdateDescriptorSets(Device::device, 1, &write, 0, nullptr);
}
written = { .generation = window.swapchainGeneration, .image = window.currentImage };
}
}
DescriptorHeapVulkan() = default;
DescriptorHeapVulkan(const DescriptorHeapVulkan&) = delete;
DescriptorHeapVulkan& operator=(const DescriptorHeapVulkan&) = delete;
@ -105,12 +212,10 @@ export namespace Crafter {
// Safe on a partly built heap: destroying VK_NULL_HANDLE is a no-op.
void DestroySets() noexcept {
vkDestroyDescriptorPool(Device::device, setPool, nullptr);
for (std::vector<VkImageView>& views : setImageViews) {
for (VkImageView view : views) {
vkDestroyImageView(Device::device, view, nullptr);
}
views.clear();
for (VkImageView view : setImageViews) {
vkDestroyImageView(Device::device, view, nullptr);
}
setImageViews.clear();
for (VkSampler sampler : setSamplers) {
vkDestroySampler(Device::device, sampler, nullptr);
}
@ -224,6 +329,8 @@ export namespace Crafter {
bufferFreelist.reserve(buffers);
samplerFreelist.clear();
samplerFreelist.reserve(samplers);
// Every slot is unallocated again, and the new heaps hold nothing.
swapchainSlots.clear();
// The heaps are written by the CPU every frame (HOST_VISIBLE is
// mandatory) and read by the GPU, so DEVICE_LOCAL is a perf
@ -272,6 +379,8 @@ export namespace Crafter {
bufferFreelist.reserve(buffers);
samplerFreelist.clear();
samplerFreelist.reserve(samplers);
// Every slot is unallocated again, as in heap mode.
swapchainSlots.clear();
// At least one descriptor per binding even when a pool was sized
// zero (Initialize(1, 1, 0) is common): a zero-count pool size is
@ -347,9 +456,7 @@ export namespace Crafter {
};
Device::CheckVkResult(vkAllocateDescriptorSets(Device::device, &allocInfo, sets));
for (std::vector<VkImageView>& views : setImageViews) {
views.assign(imageCount, VK_NULL_HANDLE);
}
setImageViews.assign(imageCount, VK_NULL_HANDLE);
setSamplers.assign(samplerCount, VK_NULL_HANDLE);
rollback.heap = nullptr;
}
@ -364,30 +471,8 @@ export namespace Crafter {
// destroys it immediately. That relies on the same contract as slot
// reuse in heap mode: no frame in flight may still reference the slot
// (true after the queue drain that precedes Window::onResize, and for
// a slot that was freed at a scene-switch barrier).
// Writes `type` (STORAGE_IMAGE or SAMPLED_IMAGE) at an image slot of
// one frame's set, for images that differ per frame: the swapchain.
// A slot written this way must be written for every frame.
void WriteImageSet(std::uint8_t frame, std::uint16_t slot, VkDescriptorType type,
const VkImageViewCreateInfo& viewInfo, VkImageLayout layout) {
VkImageView view = VK_NULL_HANDLE;
Device::CheckVkResult(vkCreateImageView(Device::device, &viewInfo, nullptr, &view));
vkDestroyImageView(Device::device, setImageViews[frame][slot], nullptr);
setImageViews[frame][slot] = view;
const VkDescriptorImageInfo imageInfo { .imageView = view, .imageLayout = layout };
const VkWriteDescriptorSet write {
.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
.dstSet = sets[frame],
.dstBinding = type == VK_DESCRIPTOR_TYPE_STORAGE_IMAGE ? storageImageBinding : sampledImageBinding,
.dstArrayElement = slot,
.descriptorCount = 1,
.descriptorType = type,
.pImageInfo = &imageInfo,
};
vkUpdateDescriptorSets(Device::device, 1, &write, 0, nullptr);
}
// a slot that was freed at a scene-switch barrier). The swapchain
// image is written per frame by WriteSwapchainImage instead.
// Writes the same image at an image slot of every frame's set, through
// one shared view.
@ -395,11 +480,8 @@ export namespace Crafter {
const VkImageViewCreateInfo& viewInfo, VkImageLayout layout) {
VkImageView view = VK_NULL_HANDLE;
Device::CheckVkResult(vkCreateImageView(Device::device, &viewInfo, nullptr, &view));
for (std::vector<VkImageView>& views : setImageViews) {
vkDestroyImageView(Device::device, views[slot], nullptr);
views[slot] = VK_NULL_HANDLE;
}
setImageViews[0][slot] = view;
vkDestroyImageView(Device::device, setImageViews[slot], nullptr);
setImageViews[slot] = view;
const VkDescriptorImageInfo imageInfo { .imageView = view, .imageLayout = layout };
VkWriteDescriptorSet writes[Window::numFrames];
@ -543,7 +625,12 @@ export namespace Crafter {
// freeing. Multi-slot ranges are decomposed into their individual slots,
// each put in its sorted place (TakeRun looks for runs).
// noexcept: capacity is reserved at Initialize so inserting never reallocates.
// A freed swapchain slot stops being one, so the window doesn't write
// the swapchain image over the slot's next owner.
void FreeImageSlots(ImageSlotRange r) noexcept {
std::erase_if(swapchainSlots, [r](const SwapchainSlot& listed) {
return listed.slot >= r.firstElement && listed.slot - r.firstElement < r.count;
});
Release(imageFreelist, r.firstElement, r.count);
}