feat(window): multi-frame-in-flight frame pacing (#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>
This commit is contained in:
parent
050a44d118
commit
621016f264
4 changed files with 254 additions and 30 deletions
|
|
@ -414,21 +414,23 @@ 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]));
|
||||
}
|
||||
|
||||
lastMousePos = {0,0};
|
||||
mouseDelta = {0,0};
|
||||
|
|
@ -671,22 +673,34 @@ 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. Held in a local
|
||||
// so submitInfo below can point at the same handle.
|
||||
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, ¤tBuffer);
|
||||
imageAcquired, (VkFence)nullptr, ¤tBuffer);
|
||||
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, ¤tBuffer);
|
||||
imageAcquired, (VkFence)nullptr, ¤tBuffer);
|
||||
}
|
||||
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]));
|
||||
|
||||
VkCommandBufferBeginInfo cmdBufInfo {};
|
||||
cmdBufInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
|
||||
|
|
@ -802,32 +816,48 @@ void Window::Render() {
|
|||
|
||||
Device::CheckVkResult(vkEndCommandBuffer(drawCmdBuffers[currentBuffer]));
|
||||
|
||||
Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &submitInfo, VK_NULL_HANDLE));
|
||||
// Submit waits on this frame's acquire semaphore and signals this image's
|
||||
// render-finished semaphore; the fence (keyed by image index) lets a
|
||||
// future Render() reuse this image's command buffer + heap slot safely.
|
||||
VkSubmitInfo submitInfo{};
|
||||
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
|
||||
submitInfo.pNext = VK_NULL_HANDLE;
|
||||
submitInfo.pWaitDstStageMask = &submitPipelineStages;
|
||||
submitInfo.waitSemaphoreCount = 1;
|
||||
submitInfo.pWaitSemaphores = &imageAcquired;
|
||||
submitInfo.signalSemaphoreCount = 1;
|
||||
submitInfo.pSignalSemaphores = &renderFinishedSemaphores[currentBuffer];
|
||||
submitInfo.commandBufferCount = 1;
|
||||
submitInfo.pCommandBuffers = &drawCmdBuffers[currentBuffer];
|
||||
|
||||
Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &submitInfo, waitFences[currentBuffer]));
|
||||
VkPresentInfoKHR presentInfo = {};
|
||||
presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
|
||||
presentInfo.pNext = NULL;
|
||||
presentInfo.swapchainCount = 1;
|
||||
presentInfo.pSwapchains = &swapChain;
|
||||
presentInfo.pImageIndices = ¤tBuffer;
|
||||
// Check if a wait semaphore has been specified to wait for before presenting the image
|
||||
if (semaphores.renderComplete != VK_NULL_HANDLE)
|
||||
{
|
||||
presentInfo.pWaitSemaphores = &semaphores.renderComplete;
|
||||
presentInfo.waitSemaphoreCount = 1;
|
||||
}
|
||||
presentInfo.pWaitSemaphores = &renderFinishedSemaphores[currentBuffer];
|
||||
presentInfo.waitSemaphoreCount = 1;
|
||||
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue