feat(window): multi-frame-in-flight frame pacing (#40) #81

Merged
catbot merged 2 commits from claude/issue-40 into master 2026-06-16 17:42:14 +02:00
Member

Summary

The renderer was effectively single-buffered despite allocating triple-buffered infrastructure (numFrames=3 command buffers, swapchain images, per-image descriptor heaps):

  1. Render() ended with an unconditional vkQueueWaitIdle after present — zero CPU/GPU overlap.
  2. A single (presentComplete, renderComplete) semaphore pair, baked into a shared submitInfo, with zero fences, was shared for the Window's lifetime.

This reworks the frame-pacing model as one coherent change so up to numFrames frames overlap.

What changed

The deciding constraint is that drawCmdBuffers, the per-frame descriptor-heap slots, and the swapchain images are all keyed by the acquired image indexUIRenderer::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. The sync objects follow from that:

  • Per-image VkFence — signaled by the submit, waited + reset before that image's command buffer / heap slot is re-recorded. Created signaled so the first use of each image passes through. This (not a queue drain) bounds frames-in-flight and gives the overlap.
  • Per-image render-finished (present) semaphore — the presentation engine holds it until the image is re-acquired, so per-image is the only safe key. Keying it per-CPU-frame trips VUID-vkQueueSubmit-pSignalSemaphores-00067.
  • Per-CPU-frame acquire semaphores — the image index isn't known until acquire returns, so these can't be image-keyed. Sized numFrames+1: in-flight depth is bounded by the per-image fences, so numFrames+1 distinct acquire semaphores guarantee the one being reused has no pending operation (VUID-vkAcquireNextImageKHR-semaphore-01779).
  • Dropped the steady-state vkQueueWaitIdle; kept it on the resize / OUT_OF_DATE / teardown paths.

Note on the prescribed design: the merged issue suggested keying both semaphores by a CPU frame counter. The modern validation layer rejects that for the present-wait semaphore (it must be per-image) — caught by the new test below. The fences-and-semaphores changes remain a single mutually-load-bearing deliverable.

Downstream (#65, #73, #75): paths that relied on the implicit full-queue drain for host-write → device-read ordering now need explicit barriers.

Testing

crafter-build test4 passed (MouseScroll, PushConstantRewrite, BLASBuildOptions, and the new FrameLoopSync).

New tests/FrameLoopSync drives the real frame loop against the live Wayland compositor for 60 frames (much more than the in-flight slot count) and asserts: the CPU frame counter advanced once per Render(), the swapchain rotated across multiple images (3/3), and the validation layer reported zero errors. This is the load-bearing check — the old single-pair design only avoided being an active race because the wait-idle masked it.

Also ran the VulkanTriangle example end-to-end (0 validation errors).

Screenshots

VulkanTriangle rendering under the new multi-frame-in-flight loop:

VulkanTriangle

Resolves #40

## Summary The renderer was effectively single-buffered despite allocating triple-buffered infrastructure (`numFrames=3` command buffers, swapchain images, per-image descriptor heaps): 1. `Render()` ended with an unconditional `vkQueueWaitIdle` after present — zero CPU/GPU overlap. 2. A single `(presentComplete, renderComplete)` semaphore pair, baked into a shared `submitInfo`, with **zero fences**, was shared for the Window's lifetime. This reworks the frame-pacing model as one coherent change so up to `numFrames` frames overlap. ## What changed The deciding constraint is that `drawCmdBuffers`, the per-frame descriptor-heap slots, and the swapchain images are **all keyed by the acquired image index** — `UIRenderer::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. The sync objects follow from that: - **Per-image `VkFence`** — signaled by the submit, waited + reset before that image's command buffer / heap slot is re-recorded. Created signaled so the first use of each image passes through. This (not a queue drain) bounds frames-in-flight and gives the overlap. - **Per-image render-finished (present) semaphore** — the presentation engine holds it until the image is re-acquired, so per-image is the only safe key. Keying it per-CPU-frame trips `VUID-vkQueueSubmit-pSignalSemaphores-00067`. - **Per-CPU-frame acquire semaphores** — the image index isn't known until acquire returns, so these can't be image-keyed. Sized `numFrames+1`: in-flight depth is bounded by the per-image fences, so `numFrames+1` distinct acquire semaphores guarantee the one being reused has no pending operation (`VUID-vkAcquireNextImageKHR-semaphore-01779`). - **Dropped the steady-state `vkQueueWaitIdle`**; kept it on the resize / `OUT_OF_DATE` / teardown paths. > Note on the prescribed design: the merged issue suggested keying *both* semaphores by a CPU frame counter. The modern validation layer rejects that for the present-wait semaphore (it must be per-image) — caught by the new test below. The fences-and-semaphores changes remain a single mutually-load-bearing deliverable. > Downstream (#65, #73, #75): paths that relied on the implicit full-queue drain for host-write → device-read ordering now need explicit barriers. ## Testing `crafter-build test` — **4 passed** (MouseScroll, PushConstantRewrite, BLASBuildOptions, and the new FrameLoopSync). New `tests/FrameLoopSync` drives the **real** frame loop against the live Wayland compositor for 60 frames (much more than the in-flight slot count) and asserts: the CPU frame counter advanced once per `Render()`, the swapchain rotated across multiple images (3/3), and the validation layer reported **zero** errors. This is the load-bearing check — the old single-pair design only avoided being an active race because the wait-idle masked it. Also ran the VulkanTriangle example end-to-end (0 validation errors). ## Screenshots VulkanTriangle rendering under the new multi-frame-in-flight loop: ![VulkanTriangle](https://forgejo.catcrafts.net/attachments/af0fb454-dc39-4ce4-82bd-3dbe34ac1da4) Resolves #40
The renderer was effectively single-buffered despite allocating
triple-buffered infrastructure: Render() ended with an unconditional
vkQueueWaitIdle, and a single (presentComplete, renderComplete)
semaphore pair with zero fences was shared for the Window's lifetime.

Rework the pacing model so up to numFrames frames overlap:
- Per-swapchain-image VkFence, signaled by the submit and waited+reset
  before that image's command buffer / descriptor-heap slot is
  re-recorded. Keyed by acquired image index (drawCmdBuffers, the heap
  slots, and the swapchain images are all image-indexed — see
  WriteSwapchainDescriptors). Created signaled so first use passes.
- Per-image render-finished (present) semaphore: the presentation engine
  holds it until the image is re-acquired, so per-image is the only safe
  key (per-CPU-frame trips VUID-vkQueueSubmit-pSignalSemaphores-00067).
- Per-CPU-frame acquire semaphores (image index unknown until acquire
  returns), sized numFrames+1: in-flight depth is bounded by the
  per-image fences, so numFrames+1 distinct acquire semaphores guarantee
  the reused one has no pending op (VUID-vkAcquireNextImageKHR-01779).
- Drop the steady-state vkQueueWaitIdle; keep it on resize / OUT_OF_DATE
  / teardown.

Add tests/FrameLoopSync: drives the real frame loop against a live
Wayland compositor for 60 frames (>> in-flight slots) and asserts the
CPU frame counter advanced, the swapchain rotated across multiple
images, and the validation layer stayed silent — the load-bearing check,
since the old single-pair design only avoided being an active race
because the wait-idle masked it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts:
#	implementations/Crafter.Graphics-Window.cpp
#	interfaces/Crafter.Graphics-Window.cppm
catbot merged commit 1451e3aeb2 into master 2026-06-16 17:42:14 +02:00
catbot deleted branch claude/issue-40 2026-06-16 17:42:14 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Catcrafts/Crafter.Graphics!81
No description provided.