//SPDX-License-Identifier: LGPL-3.0-only //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // Issue #40: multi-frame-in-flight frame pacing. The renderer used to be // effectively single-buffered — Render() ended with an unconditional // vkQueueWaitIdle and shared a single (presentComplete, renderComplete) // semaphore pair with zero fences for the Window's whole lifetime. This // reworks the pacing model: // // - one VkFence per in-flight frame slot (a free-running CPU frame counter // % numFrames), passed to vkQueueSubmit and waited on before the slot's // command buffer is re-recorded — before the acquire, so the slot's // acquire semaphore is free again too. Until #160 the fences were keyed // by the acquired image index, which tied the frame loop to a swapchain // of exactly numFrames images; // - one acquire semaphore per frame slot, and one render-complete // semaphore per swapchain image (the present holds it until the image // comes back), so frame N's render-complete and frame N+1's acquire can // be pending at once; // - the steady-state vkQueueWaitIdle is gone (kept only on resize / // OUT_OF_DATE / teardown). // // The fence and semaphore changes are mutually load-bearing: dropping the // wait-idle while reusing one binary semaphore pair across in-flight frames is // a textbook Vulkan race, and per-frame semaphores without per-frame fences is // a command-buffer use-after-free. So this test drives the *real* frame loop — // a real Wayland surface, a real swapchain, real acquire/submit/present — for // many more frames than there are in-flight slots, then asserts: // // - every Render() completed and the CPU frame counter advanced by the // expected amount (no deadlock on the per-frame fence wait); // - the frame slot followed the frame counter, and the acquired image // index rotated across more than one image (proof the swapchain is // genuinely multi-buffered, not pinned to image 0); // - the Vulkan validation layer reported ZERO errors over the whole run. // This is the load-bearing check: the old singleton-pair design only // avoided being an active race because the wait-idle masked it. Reusing a // binary semaphore across overlapping frames, or recording into a command // buffer still in flight, both light up the validation layer immediately. // // Needs a live Wayland compositor + a Vulkan device at runtime (same as the // windowed examples), so it shares the native build settings and is Linux-only. #include "vulkan/vulkan.h" #include import Crafter.Graphics; import std; using namespace Crafter; namespace { int failures = 0; void Check(bool ok, std::string_view what) { std::println("{} {}", ok ? "PASS" : "FAIL", what); if (!ok) ++failures; } } // namespace int main() { Device::Initialize(); // A real, configured window against the running compositor. Construction // performs the xdg configure handshake and creates the swapchain + the // per-frame fences/semaphores under test. Window window(640, 480, "FrameLoopSync test"); // Render() with no passes still exercises the full pacing path: per-frame // fence wait → acquire (signals the slot's acquire semaphore) → fence // reset → barriers → submit (signals the image's render-complete // semaphore, fences the command buffer) → present. Run well past // numFrames so every semaphore and every per-frame fence is reused // several times — exactly the condition the old single-pair / wait-idle // design could not survive without draining the queue every frame. constexpr int kFrames = 60; static_assert(kFrames > Window::numFrames * 3, "must loop enough to reuse each in-flight slot multiple times"); std::vector imageSeen(window.images.size()); bool framesFollowCounter = true; for (int i = 0; i < kFrames; ++i) { window.Render(); framesFollowCounter = framesFollowCounter && window.currentFrame == static_cast(i % Window::numFrames); if (window.currentImage < imageSeen.size()) { imageSeen[window.currentImage] = true; } } // Drain before inspecting — no steady-state wait-idle means GPU work may // still be in flight when the loop exits. Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); Check(window.frameCounter == static_cast(kFrames), std::format("CPU frame counter advanced once per Render() ({} of {})", window.frameCounter, kFrames)); Check(framesFollowCounter, "each Render() used frame slot frameCounter % numFrames"); int distinctImages = 0; for (bool seen : imageSeen) if (seen) ++distinctImages; Check(distinctImages > 1, std::format("swapchain rotated across multiple images ({} of {} images used)", distinctImages, window.images.size())); Check(Device::validationErrorCount == 0, std::format("no Vulkan validation errors across {} frames ({} seen)", kFrames, Device::validationErrorCount)); if (failures != 0) { std::println("{} check(s) failed", failures); return EXIT_FAILURE; } std::println("all checks passed"); return EXIT_SUCCESS; }