Vulkan presentation, frame pacing and back-buffer readback

CNA snapshot 009d40f5  ·  Deep Dives › Renderers  ·  source links pinned to 009d40f5

✓

Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. No Vulkan test was executed for this page; present-mode cadence is the Vulkan specification's contract, not a CNA measurement, and driver-specific observations in CNA's source comments (lavapipe, RADV) are evidence for those drivers only.

This page explains how the VULKAN renderer turns a finished CNA frame into pixels on screen, and how GraphicsDevice::GetBackBufferData reads those pixels back without racing the presentation engine. It covers the swapchain format decision and why it deliberately avoids an sRGB format, the depth image every back buffer owns, the mapping from XNA's PresentInterval to a VkPresentModeKHR, the two-frames-in-flight synchronisation model, and the deferred-present and readback-cache mechanisms. It is written for anyone porting a game to Vulkan, writing pixel tests against it, or changing VulkanRenderer's frame code.

The swapchain format: UNORM on purpose

VulkanRenderer::CreateSwapchain() in VulkanRenderer.cpp makes two decisions once, when the swapchain is created, instead of leaving them to whatever the driver lists first: the pixel format the swapchain presents through, and the present mode that governs frame pacing. A physical device usually reports several (VkFormat, VkColorSpaceKHR) pairs for a surface. Many Vulkan tutorials prefer an sRGB variant such as VK_FORMAT_B8G8R8A8_SRGB, because the hardware then gamma-encodes every stored pixel automatically. CNA does the opposite on purpose.

The reason is XNA's own format contract. SurfaceFormat::Color, the default back-buffer format, is a plain 8-bit UNORM byte format; the gamma-encoded variant is a separate CNAEXT value, SurfaceFormat::ColorSrgbEXT. An sRGB swapchain would apply a linear-to-sRGB encode to every presented pixel that XNA never performed, so every frame would come out lighter than the same game under XNA. The renderer therefore searches for the exact pair it wants and falls back to the first listed pair only when the device does not offer it:

// VulkanRenderer::CreateSwapchain() at 009d40f5 (abridged)
VkSurfaceFormatKHR fmt = fmts[0];                 // fallback: whatever the device lists first
for (auto& f : fmts)
    if (f.format == VK_FORMAT_B8G8R8A8_UNORM &&
        f.colorSpace == VK_COLOR_SPACE_SRGB_NONLINEAR_KHR)
    { fmt = f; break; }

The search is a preference, not a guarantee: a surface that does not list B8G8R8A8_UNORM with the non-linear colour space gets its first reported pair, which may be an sRGB format. The readback code (below) is written for either byte order and either transfer function.

Two fields, two questions

The pair looks contradictory at first sight: a UNORM format together with VK_COLOR_SPACE_SRGB_NONLINEAR_KHR. It is not. The two fields answer different questions.

FieldQuestion it answersWhat CNA asks for
colorSpaceWhat colour space does the display expect the presented bytes to be in?VK_COLOR_SPACE_SRGB_NONLINEAR_KHR, the near-universal default of consumer displays and Vulkan implementations
formatDoes the swapchain transform values when a shader stores them?_UNORM: the bytes a draw writes are the bytes that are presented; an _SRGB sibling with the same byte layout would encode on write

So the choice means "present to an ordinary monitor, but do not add an encode step that XNA's Color format never had". A porting engineer who changes this one line because "sRGB is the correct colour space" introduces a visible brightness change in every scene of every game, not an edge case. WebGPU reached the same conclusion later and for the same reason; see the WebGPU surface-format policy, which also shows how a renderer can keep Color byte-exact on a surface that can only be configured as sRGB.

What PresentationParameters reports back

The swapchain format is fixed renderer policy, not a response to the caller's PresentationParameters.BackBufferFormat. The renderer constructor reads the surface snapshot, the Vulkan surface service, the virtual size, the swap interval and the requested sample count from GraphicsRendererCreateArgs; it does not read the backBufferFormat, depthStencilFormat or isFullScreen fields of that structure (IGraphicsRenderer.hpp documents those fields as optional for renderers that do not need format fidelity). Instead of silently ignoring the request, the renderer now reports what is really in effect: GetAppliedBackBufferFormatEXT() always answers SurfaceFormat::Color and GetAppliedDepthStencilFormatEXT() answers the depth format the renderer actually created (CNA's tracking item VULKAN-348). GraphicsDevice writes both answers back into its PresentationParameters in NormalizeAppliedPresentationFormats (GraphicsDevice.cpp), so a game that reads the parameters after device creation sees the formats it got, not the ones it asked for.

Fullscreen is a window operation performed by the platform layer, not by this renderer. When the window changes size, a later out-of-date or suboptimal result rebuilds the swapchain at the surface's new extent (see Vulkan internals: swapchain policy, resize and reset).

The back buffer always owns a depth-stencil image

CreateDepthResources() calls FindDepthFormat() unconditionally, so a Vulkan back buffer always has a depth attachment, even when the game asked for DepthFormat::None. FindDepthFormat() takes the first of VK_FORMAT_D24_UNORM_S8_UINT, VK_FORMAT_D32_SFLOAT_S8_UINT and VK_FORMAT_D32_SFLOAT that supports DEPTH_STENCIL_ATTACHMENT with optimal tiling, and throws "Vulkan: no suitable depth format" if none does.

The order matters. An earlier version tried VK_FORMAT_D32_SFLOAT first; because that format is mandatory on essentially all hardware, a stencil-capable format was almost never chosen, and DepthStencilState.StencilEnable could not work however correctly the pipeline mapped it. The stencil-capable formats are now tried first. Two public answers follow the format that was really chosen rather than the request:

  • SupportsCapability(GraphicsCapability::StencilBuffer) is true only for a format with a stencil aspect (D24_UNORM_S8_UINT, D32_SFLOAT_S8_UINT, D16_UNORM_S8_UINT, S8_UINT), so a device that fell back to D32_SFLOAT honestly reports no stencil.
  • GetAppliedDepthStencilFormatEXT() reports that format, which is why a DepthFormat::None request comes back as the depth format that exists: reporting None would describe a buffer that exists as absent.

Render targets are different. RenderTarget2D and RenderTargetCube select their own depth format per instance, and DepthFormat::None on a render target means no depth attachment. Do not generalise the back-buffer rule to off-screen targets.

PresentInterval and VkPresentModeKHR

The swap interval that CreateSwapchain() reads comes from PresentationParameters.PresentationInterval: Immediate is 0, Default and One are 1, Two is 2. The renderer maps it onto the modes the surface actually offers, with an explicit fallback chain for every case:

IntervalPreferred modeFallbacksMeaning
0 (Immediate)VK_PRESENT_MODE_IMMEDIATE_KHRVK_PRESENT_MODE_MAILBOX_KHR, then FIFONo wait for vertical blank; IMMEDIATE may tear, MAILBOX replaces the queued image without tearing
1 (Default, One)VK_PRESENT_MODE_FIFO_KHRnone neededReal vertical synchronisation
2 (Two)VK_PRESENT_MODE_FIFO_RELAXED_KHRFIFOVertical synchronisation that tolerates a late frame (see below)

FIFO is the only present mode the Vulkan specification guarantees on every conformant implementation. Starting from it, and ending every fallback chain with it, is what makes swapchain creation never fail for lack of a particular mode. The mode the swapchain was really created with is recorded separately from the request and exposed as GetAppliedPresentModeEXT(); SupportsUnsynchronisedPresentModeEXT() says whether the surface offered IMMEDIATE or MAILBOX at all. Both exist because "the mode is still FIFO with vsync off" is a correct answer on some surfaces and a broken renderer on others, and a test that could not tell the two apart would excuse the defect.

Why Two is not half the refresh rate

On this renderer PresentInterval::Two asks for FIFO_RELAXED, which is a different request from Immediate. FIFO relaxed still waits for vertical blank while the application keeps up; only on a frame that would otherwise miss the blank does it present immediately and accept a single tear. It is the Vulkan-native form of "prefer vsync, but do not let one slow frame turn into a visible stutter". It is not the Direct3D 9 reading of "present interval two" as half the display refresh rate. A game that sets Two expecting a fixed 30 Hz cadence on a 60 Hz display will not get it here; check the real frame rate on the target machine instead of assuming that interval semantics carried over from a Direct3D background. The SDL_GPU renderer differs again: SDL_gpu has no half-rate or relaxed mode, so Two behaves like One there (SDL_GPU present timing).

Changing the interval at run time

For a long time the swap interval was read exactly once, when the swapchain was first created. VulkanRenderer did not override IGraphicsRenderer::SetSwapInterval(), so GraphicsDeviceManager.SynchronizeWithVerticalRetrace followed by ApplyChanges() changed nothing on Vulkan; because Game builds its device before a derived constructor can change the property, an ordinary game that disabled vsync still ran in FIFO. At this snapshot both halves of that path are repaired:

  1. GraphicsDevice::Reset forwards PresentationInterval to IGraphicsRenderer::SetSwapInterval() on every renderer. The comment beside the call in GraphicsDevice.cpp records that this forwarding was previously missing from the Reset() overload that ApplyChanges() uses. GraphicsDeviceManager turns its property into PresentInterval::One or PresentInterval::Immediate (GraphicsDeviceManager.cpp).
  2. VulkanRenderer::SetSwapInterval() (VULKAN-332) records the request first, so GetSwapIntervalEXT() can answer it even when no rebuild is possible, returns early for an unchanged interval, and otherwise calls RecreateSwapchain(), the same path a resize takes. A zero-sized drawable or an uninitialised device defers the rebuild.

The regression, Vulkan_SwapInterval (vulkan_swap_interval_test.cpp), deliberately does not use the recorded interval as its oracle, because a renderer that stored the request and rebuilt nothing would satisfy it. It reads the live VkPresentModeKHR, which only CreateSwapchain() writes, and expects FIFO with vsync on, IMMEDIATE or MAILBOX with it off, and FIFO again afterwards; a surface that offers neither unsynchronised mode is reported rather than failed. It also renders after each change and requires a silent validation layer. The companion Vulkan_PresentInterval reuses the renderer-neutral PresentationParameters round-trip from the EasyGL suite.

Two frames in flight

MaxFramesInFlight is a fixed constexpr int of 2 in VulkanRenderer.hpp. The renderer owns one VkFence, one image-available semaphore, one render-finished semaphore and one command buffer per slot, indexed by a wrapping currentFrame_. The fences are created signalled so that the first wait on each slot returns immediately. The per-frame sync objects are indexed by slot, never by swapchain image, so a swapchain rebuild that changes the image count does not recreate them.

 CPU (Present -> SubmitFrame(false))                GPU queue            presentation
 -----------------------------------                ---------            ------------
 wait inFlightFences_[slot]   <---- signalled by the submit two frames ago
 ProcessRetiredResources(false)
 vkAcquireNextImageKHR ----- signals imageAvailable[slot] when the image is free
 reset fence, reset + record command buffer
 vkQueueSubmit -------------> waits imageAvailable[slot] at colour output
                              renders, then signals renderFinished[slot]
                              and inFlightFences_[slot]
 vkQueuePresentKHR -------------------------------> waits renderFinished[slot]
 currentFrame_ = (currentFrame_ + 1) % 2
Figure. One call of SubmitFrame(false), read top to bottom. The fence throttles the CPU against the GPU work that used the same slot two frames earlier; the image-available semaphore makes the GPU wait until the acquired swapchain image may be written; the render-finished semaphore makes the presentation engine wait until rendering is done. The slot index then advances modulo two.

Each primitive answers exactly one question:

PrimitiveWaited on byQuestion
inFlightFences_[slot]the CPU, at the start of SubmitFrameMay the CPU reuse this slot's command buffer and per-frame buffers yet?
imageAvailableSemaphores_[slot]the GPU, at VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BITMay rendering write into the acquired swapchain image yet? (The presentation engine may still be showing it.)
renderFinishedSemaphores_[slot]the presentation engine, in vkQueuePresentKHRMay the finished image be displayed yet?

Two ordering details are easy to break. First, an acquire that returns VK_ERROR_OUT_OF_DATE_KHR recreates the swapchain and returns before the fence is reset; resetting it there would leave an unsignalled fence with no submission behind it and hang the next frame. Second, ProcessRetiredResources(false) runs right after the fence wait, because that is the moment a retired handle's consuming frame is provably complete. The ordinary sequence is also listed step by step in Vulkan internals: SubmitFrame and synchronization.

Deferred present: why readback cannot simply call Present

GraphicsDevice::GetBackBufferData has to answer a question the ordinary present path never asks: are the exact pixels the GPU just rendered still readable, or has the presentation engine already taken the image? Presenting first and reading afterwards would race the compositor. ReadBackbuffer() therefore uses the same SubmitFrame with deferSwap = true:

// VulkanRenderer::SubmitFrame(bool deferSwap), the held-present branch (abridged)
if (deferSwap) {
    // Wait for render + readback copy to complete, but hold the image.
    vkWaitForFences(device_, 1, &inFlightFences_[currentFrame_], VK_TRUE, UINT64_MAX);
    deferredPresentImageIndex_ = imageIndex;
    hasDeferredPresent_        = true;
    return true;
}

When a readback is pending, RecordCommandBuffer copies the whole swapchain image into readbackStagingBuf_ inside the same command buffer as the frame's rendering. The second fence wait in the branch above blocks until that copy has completed on the GPU. ReadBackbuffer() then maps the staging memory and copies the requested rectangle out, and only after the CPU has finished reading does it call FinishDeferredPresent(), which performs the vkQueuePresentKHR that SubmitFrame(true) skipped and advances the frame slot. The frame is presented late but exactly once, so reading the back buffer never drops a frame from the screen. The present waits on the render-finished semaphore as usual; because the submit has already completed, that wait does not block.

The readback cache and its edges

A naive implementation would call SubmitFrame(true) on every GetBackBufferData call. The real code captures the whole back buffer once and serves every rectangle of the same frame from the cached staging buffer. It re-renders only when one of three conditions holds:

  • hasNewWork: 3D draws or sprite batches are pending since the last capture;
  • !readbackStagingValid_: the cache was invalidated, which every Clear variant does, and which RecreateSwapchain() does because the cached content describes a swapchain that no longer exists (VULKAN-404);
  • !stagingCoversFrame: the staging allocation is smaller than the current extent. This keeps the mapped byte count bounded by the allocation even if a future caller forgets to invalidate.

The cache is a correctness rule, not an optimisation. Re-submitting an already-rendered frame with an empty queue would re-render the current clear colour and destroy the content that every read after the first one should have returned. A golden-image test that reads several sub-rectangles of one frame depends on it.

Three further details are visible to callers:

  • A blank first frame is reported, not thrown. When SubmitFrame(true) fails because the swapchain was out of date (the source comment notes this is common on the first frame under Wayland with the RADV driver), the staging buffer was not written. ReadBackbuffer() zero-fills the output and returns, so a caller can detect the blank frame and retry instead of seeing stale data or an exception.
  • Byte order is checked, not assumed. Swapchain images are often VK_FORMAT_B8G8R8A8_UNORM, whose channel order is the reverse of the RGBA order Color uses. ReadBackbuffer() swaps blue and red only when swapchainFormat_ is a B8G8R8A8 format (UNORM or SRGB), and copies bytes unchanged otherwise.
  • Out-of-range pixels are zero. Requested pixels outside the current extent are written as transparent black rather than read from outside the buffer.

Tests and what they do not prove

The behaviour above is covered by example-based tests registered in the Vulkan examples CMakeLists.txt, among them Vulkan_SwapInterval and Vulkan_PresentInterval (present modes), Vulkan_PresentationParameters (the parameter round-trip, VULKAN-335), Vulkan_SwapchainOutOfDate (what CanBeginDrawEXT() answers while the swapchain is out of date, using injected acquire results), Vulkan_SwapchainChurn (twenty-two back-to-back recreations, a minimised 0x0 drawable and a restore with live resources), Vulkan_Swapchain_Sync (frame-slot wrapping and swapchain recreation with the validation layer loaded in process), Vulkan_BackbufferResize (the path VULKAN-404 found serving the pre-resize frame), Vulkan_BackbufferFirstRead and Vulkan_BackbufferReadbackDimension. In any build that contains VULKAN, every renderer test also runs under an output gate that fails on Khronos validation messages (see Vulkan internals: testing).

None of these tests was executed for this page. They need a display with a Vulkan-capable adapter; several of CNA's own notes record results on the lavapipe software driver and on an AMD RADV device, which is evidence for those drivers only. No test times the real cadence of a present mode, so the "tolerates one late frame" behaviour of FIFO_RELAXED is the Vulkan specification's contract, not a CNA measurement. How these renderers' evidence compares is summarised in Evidence tiers of the native modern GPU renderers; user-side setup is in Tutorial 85: The Vulkan Renderer.

The same subject is explained at several altitudes. These are the neighbouring pages at each one.

Maintainer workflow
Fix a renderer bug
Tests and validation
Test architecture: GPU tests