Merge pull request 'feat(window): multi-frame-in-flight frame pacing (#40)' (#81) from claude/issue-40 into master

This commit is contained in:
catbot 2026-06-16 17:42:13 +02:00
commit 1451e3aeb2
4 changed files with 272 additions and 31 deletions

View file

@ -434,27 +434,41 @@ Window::Window(std::uint32_t width, std::uint32_t height) : width(width), height
cmdBufAllocateInfo.commandBufferCount = numFrames;
Device::CheckVkResult(vkAllocateCommandBuffers(Device::device, &cmdBufAllocateInfo, drawCmdBuffers));
// Per-frame-in-flight synchronisation objects (issue #40). The fences are
// created signaled so the first wait on each image's fence — before that
// image has ever been submitted — returns immediately instead of
// deadlocking. The submitInfo is rebuilt per frame in Render() now that
// the wait/signal semaphores vary by frame.
VkSemaphoreCreateInfo semaphoreCreateInfo {};
semaphoreCreateInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
Device::CheckVkResult(vkCreateSemaphore(Device::device, &semaphoreCreateInfo, nullptr, &semaphores.presentComplete));
Device::CheckVkResult(vkCreateSemaphore(Device::device, &semaphoreCreateInfo, nullptr, &semaphores.renderComplete));
// Set up submit info structure
// Semaphores will stay the same during application lifetime
// Command buffer submission info is set by each example
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.pWaitDstStageMask = &submitPipelineStages;
submitInfo.waitSemaphoreCount = 1;
submitInfo.pWaitSemaphores = &semaphores.presentComplete;
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = &semaphores.renderComplete;
submitInfo.pNext = VK_NULL_HANDLE;
VkFenceCreateInfo fenceCreateInfo {};
fenceCreateInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
fenceCreateInfo.flags = VK_FENCE_CREATE_SIGNALED_BIT;
for (std::uint8_t i = 0; i < numFrames; i++) {
Device::CheckVkResult(vkCreateSemaphore(Device::device, &semaphoreCreateInfo, nullptr, &renderFinishedSemaphores[i]));
Device::CheckVkResult(vkCreateFence(Device::device, &fenceCreateInfo, nullptr, &waitFences[i]));
}
for (std::uint8_t i = 0; i < numAcquireSemaphores; i++) {
Device::CheckVkResult(vkCreateSemaphore(Device::device, &semaphoreCreateInfo, nullptr, &imageAcquiredSemaphores[i]));
}
// Per-frame info structs: everything that never varies between frames is
// set here once. Render() only patches the barriers' image/oldLayout.
// set here once. Render() patches the barriers' image/oldLayout and the
// sync handles that follow currentBuffer/frameCounter (submitInfo's
// wait/signal semaphores + command buffer, presentInfo's wait semaphore).
cmdBufInfo = {};
cmdBufInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
// Submit: stage mask + the 1/1/1 counts are invariant; the wait/signal
// semaphore handles and the command buffer are patched per frame.
submitInfo = {};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.pNext = VK_NULL_HANDLE;
submitInfo.pWaitDstStageMask = &submitPipelineStages;
submitInfo.waitSemaphoreCount = 1;
submitInfo.signalSemaphoreCount = 1;
submitInfo.commandBufferCount = 1;
const VkImageSubresourceRange range {
.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
.baseMipLevel = 0,
@ -491,8 +505,9 @@ Window::Window(std::uint32_t width, std::uint32_t height) : width(width), height
};
// pImageIndices/pSwapchains point at members, so they follow value changes
// (currentBuffer each frame, swapChain across recreation) on their own.
// The render-complete wait semaphore is fixed for the window's lifetime.
// (currentBuffer each frame, swapChain across recreation) on their own. The
// wait semaphore is the per-image render-finished semaphore, patched per
// frame in Render() (it follows currentBuffer).
presentInfo = {};
presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
presentInfo.pNext = NULL;
@ -500,7 +515,6 @@ Window::Window(std::uint32_t width, std::uint32_t height) : width(width), height
presentInfo.pSwapchains = &swapChain;
presentInfo.pImageIndices = &currentBuffer;
presentInfo.waitSemaphoreCount = 1;
presentInfo.pWaitSemaphores = &semaphores.renderComplete;
lastMousePos = {0,0};
mouseDelta = {0,0};
@ -744,22 +758,33 @@ void Window::Render() {
// Acquire the next image from the swap chain. If the surface has
// changed size out from under us (compositor/Win32 resize delivered
// between Render calls), recreate and retry once.
// Pick this frame's acquire semaphore from a free-running CPU counter: the
// image index (currentBuffer) isn't known until acquire returns, so the
// acquire's signal semaphore can't be keyed by the image.
const std::uint32_t acquireSlot = static_cast<std::uint32_t>(frameCounter % numAcquireSemaphores);
VkSemaphore imageAcquired = imageAcquiredSemaphores[acquireSlot];
{
VkResult acquire = vkAcquireNextImageKHR(Device::device, swapChain, UINT64_MAX,
semaphores.presentComplete, (VkFence)nullptr, &currentBuffer);
imageAcquired, (VkFence)nullptr, &currentBuffer);
if (acquire == VK_ERROR_OUT_OF_DATE_KHR) {
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
RecreateSwapchainAndImages();
onResize.Invoke();
acquire = vkAcquireNextImageKHR(Device::device, swapChain, UINT64_MAX,
semaphores.presentComplete, (VkFence)nullptr, &currentBuffer);
imageAcquired, (VkFence)nullptr, &currentBuffer);
}
if (acquire != VK_SUBOPTIMAL_KHR) {
Device::CheckVkResult(acquire);
}
}
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &drawCmdBuffers[currentBuffer];
// drawCmdBuffers[currentBuffer] and this image's descriptor-heap slot are
// about to be re-recorded. Wait on the fence guarding this image's
// previous submission (keyed by image index, since acquire returns an
// arbitrary index), then reset it for this submit. This — not a full-queue
// wait-idle — is what bounds frames-in-flight and gives the CPU/GPU overlap.
Device::CheckVkResult(vkWaitForFences(Device::device, 1, &waitFences[currentBuffer], VK_TRUE, UINT64_MAX));
Device::CheckVkResult(vkResetFences(Device::device, 1, &waitFences[currentBuffer]));
Device::CheckVkResult(vkBeginCommandBuffer(drawCmdBuffers[currentBuffer], &cmdBufInfo));
@ -854,20 +879,38 @@ void Window::Render() {
Device::CheckVkResult(vkEndCommandBuffer(drawCmdBuffers[currentBuffer]));
Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &submitInfo, VK_NULL_HANDLE));
// Patch the per-frame fields of the cached submitInfo/presentInfo (the
// invariant fields were set once in the constructor): submit waits on this
// frame's acquire semaphore and signals this image's render-finished
// semaphore, present waits on that same render-finished semaphore. The
// fence (keyed by image index) lets a future Render() reuse this image's
// command buffer + heap slot safely. The semaphore handles live in member
// arrays, so taking their addresses here is stable for the call.
submitInfo.pWaitSemaphores = &imageAcquiredSemaphores[acquireSlot];
submitInfo.pSignalSemaphores = &renderFinishedSemaphores[currentBuffer];
submitInfo.pCommandBuffers = &drawCmdBuffers[currentBuffer];
Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &submitInfo, waitFences[currentBuffer]));
presentInfo.pWaitSemaphores = &renderFinishedSemaphores[currentBuffer];
VkResult result = vkQueuePresentKHR(Device::queue, &presentInfo);
if (result == VK_SUBOPTIMAL_KHR || result == VK_ERROR_OUT_OF_DATE_KHR) {
// Surface size changed mid-present. Drain the queue, rebuild the
// swapchain, and let dependents (descriptors holding old image
// handles) re-bind via onResize before the next frame.
// Surface size changed mid-present. Drain the queue (the only place we
// still wait-idle, alongside Resize() and the acquire OUT_OF_DATE
// path), rebuild the swapchain, and let dependents (descriptors
// holding old image handles) re-bind via onResize before the next
// frame. The wait-idle here also re-settles every per-frame fence to a
// signaled state, so the next Render()'s fence wait passes through.
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
RecreateSwapchainAndImages();
onResize.Invoke();
} else {
Device::CheckVkResult(result);
}
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
// No vkQueueWaitIdle in the steady-state path: per-frame fences now gate
// command-buffer reuse, so the CPU is free to acquire/record frame N+1
// while the GPU is still executing frame N.
frameCounter++;
}
#ifdef CRAFTER_TIMING