Presentation modes, swap interval, native handles and back-buffer readback across renderers

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. Read from GraphicsDevice, the contract header and each family's presentation and readback bodies at 009d40f5; no pixel, vsync timing or full-screen behaviour was observed, and the Windows, macOS and browser families were not run.

The public graphics API stores one presentation request — back-buffer size, presentation mode, swap interval, formats, full-screen flag — and the selected renderer family decides whether that request changes a swapchain, a compositor, a CPU buffer, only reported state, or nothing. This page states, family by family at snapshot 009d40f5, what happens to each part of the request, how native window handles are borrowed in both directions, how input finds the renderer that owns a window, and exactly what GetBackBufferData returns. It is for anyone whose game looks right on one renderer and letterboxed, stretched, torn or unreadable on another, and for test authors who compare back-buffer pixels across renderers.

One request, many outcomes

A Game constructs its GraphicsDevice inside its own constructor with default presentation parameters, before a derived game's constructor has run. The game's preferences reach that existing device later: GraphicsDeviceManager applies the profile and presentation mode and then calls GraphicsDevice::Reset, which forwards size, formats, MSAA and swap interval to the live renderer. A family that read a value only from its construction arguments would therefore keep the default; at this snapshot the reset path forwards every part of the request through a renderer hook, and what the family does with the hook is the subject of the tables below. The manager's side — when ApplyChanges does nothing, the three kinds of truth its format getters report — is on GameWindow and GraphicsDeviceManager.

Borrowed native handles

The renderer side of the contract names no windowing library. A family receives an immutable RendererSurfaceInfo snapshot — a stable WindowId, a typed NativeWindowHandle, the drawable size in physical pixels and the display scale — plus, if its descriptor asked for one, a GL-context, Vulkan-surface or surface-presenter service. The snapshot owns nothing, and its handle is valid only while the platform window with that id is alive. Older descriptions of pure-virtual accessors that handed out a toolkit window or renderer pointer from the renderer interface describe an API that no longer exists; the renderer never sees a toolkit object that the platform does not expose through that handle.

The inverse route is PresentationParameters::DeviceWindowHandle. A nonzero value is an integer compatibility token that only the active platform interprets (IPlatform::AdoptWindowHandle); GraphicsDevice never casts it to a toolkit type. CNA marks such a window as not owned: it never destroys it, and a fallback candidate that needs a different window kind is skipped with WindowKindConflict rather than rebuilding the caller's window. An adopted window is published as the active input surface to TextInputEXT and Mouse exactly like a CNA-owned one, and destroyNativeResources clears those two global slots only if they still name this device's window. An embedding host must keep its window alive through renderer teardown.

⚠

Two lifetime hazards follow from the source. First, the input slots are process-wide with last-writer-wins semantics, so two live devices replace each other's input target; no test covers that multi-device lifecycle. Second, Game hands its GameWindow facade a borrowed IPlatformWindow* once, in the Game constructor, and nothing rebinds or clears it when the game explicitly disposes the device (which destroys that window). Do not use the window facade, or a handle obtained from GameWindow::GetNativeWindowHandleEXT(), after an explicit device disposal; the case is recorded for Known Issues review. The facade's own contract is on GameWindow: a borrowed native handle.

The window registry behind input mapping

Mouse, touch and pointer positions become logical game coordinates through a registry declared in the contract header: IGraphicsRenderer::RegisterForWindow(WindowId, IGraphicsRenderer*), UnregisterForWindow(WindowId) and GetForWindow(WindowId) over one function-local static std::unordered_map. Input asks the registered renderer to run TransformWindowToLogical (and its inverse for Mouse::SetPosition); with no registration, or a false answer, coordinates pass through unchanged. Which families register and how each maps a point is tabulated on The input model: logical coordinates.

The registry itself carries three properties that matter for anyone adding a family or embedding CNA:

  • No ownership check. Registration overwrites by key and unregistration erases by window id alone. Two devices attached to the same borrowed window therefore fight: the second overwrites the first, and destroying the first removes the second's entry.
  • No synchronisation. The map has no lock; registration, lookup and removal are assumed to run on the game thread.
  • Registration must be the last constructor step. A throwing constructor never runs its destructor, so a family that registered early and then failed would leave a dangling pointer behind. EasyGL and OpenGL4 now call RegisterForWindow as the final step of construction for exactly this reason (see EasyGL internals and OpenGL4 internals).

No test found covers a constructor failure after registration, duplicate attachment to one window, or concurrent access. An immediate Mouse::SetPosition-then-GetState round trip is a weak test of the mapping, because the round trip can pass through the stored logical value even if the physical warp is wrong; the stronger evidence injects physical motion into a scaled presentation and observes the converted point, which is what the shared mouse test does with a fake renderer.

Presentation modes

CnaPresentationMode (CNAEXT, declared beside the contract) has five values: Letterbox (the default and the only one that reproduces XNA: the back buffer is scaled uniformly and centred), Overscan (scaled to cover, cropping edges), Stretch, NativeBackBuffer (no scaling; the client area is the logical surface and the requested back-buffer size is ignored) and FixedHeightDynamicWidth (logical width follows the window's aspect). Both the manager property and the renderer-create argument default to Letterbox; descriptions that give the fixed-height mode as the default predate this snapshot. The manager applies the mode before its reset, so a family that derives a logical width from the mode sees the intended value. The public side is on PresentationMode.

Every family implements the pure SetPresentationMode, but what the implementation does differs:

FamiliesBehaviour at this snapshot
EasyGL (five identities), OPENGL4All five modes through the shared GlPresentationSurfaceState transform (logical versus drawable size, bars, crop); GetDefaultViewportRect reports the physical sub-rectangle
VULKAN, WEBGPU, SDL_GPU, DIRECTX11, DIRECTX12, METAL, FNA3DCompute a presented rectangle per mode and override GetDefaultViewportRect; the Direct3D pair share ComputeD3DPresentationGeometry
SDL_RENDERERMaps the modes onto SDL's logical presentation (SDL_SetRenderLogicalPresentation), computing the width itself for the fixed-height mode
DIRECT2D, GDI, HTML_DOM, SVG_DOMValidate the ordinal and apply the mode through their own presentation layout (Direct2D rebuilds its main target and restores the previous mode if that fails)
CANVASStores the mode; only the fixed-height mode changes its output width
FREEDIRECTStores the mode; the wrapped free-direct presenter hard-codes letterbox, so the other modes cannot reach physical output without changing that library
DIRECTX9Stores the value; no presentation geometry override
SOFTWARE, HEADLESS, PORTABLEGL, STUBNo-op: there is no compositor, so bars and crop do not apply

Portable code configures the manager before normal initialisation and uses NativeBackBuffer when physical readback coordinates must equal logical ones. An enum round trip through the property proves nothing about output; only a pixel check on the target renderer shows bars, crop or stretch.

The executable check for that pixel question is the renderer-neutral fixture presentation_mode_contract_test.cpp (DX-217). It sets a 40 by 40 virtual resolution on a larger, aspect-distinct surface, draws a white sprite, reads the back buffer to measure where the box lands in each of the five modes, and checks the logical-to-window round trip where the family implements a transform. It is registered as a CTest for EasyGL (EasyGL_PresentationModeContract), OPENGL4 (through its EasyGL parity corpus) and the two DXGI renderers; for the other families the rows above rest on source reading plus narrower tests, for example Vulkan_PresentationMode (Letterbox against Stretch on Vulkan) and SdlGpu_PresentationSurface (SDL_GPU's presentation surface, HiDPI transform and present interval).

Swap interval

The device converts PresentInterval to a swap interval of 0 (Immediate), 1 (Default, One) or 2 (Two) and passes it on every reset through SetSwapInterval, whose default is a no-op. The manager's Boolean SynchronizeWithVerticalRetrace reaches only 0 or 1; asking for 2 needs the PreparingDeviceSettings route shown on GameTime and the timestep. GetSwapIntervalEXT() reports the last requested interval (or -1 from a family that does not record it: only DirectX 11, DirectX 12, EasyGL, OpenGL4, SDL_GPU and Vulkan do), which separates "CNA never forwarded it" from "the driver declined it".

Families0 / 1 / 2
EasyGL, OPENGL4The exact integer goes to the platform GL context's swap-interval call; whether a driver honours 2 is the driver's decision
DIRECTX11, DIRECTX12The exact integer becomes DXGI's Present sync interval, so 2 waits for two vertical blanks; 0 may tear when tearing is supported and the game did not ask for vsync
DIRECTX9Maps to D3DPRESENT_INTERVAL_IMMEDIATE, ONE or TWO and applies it through a device reset (D3D9 has no per-present knob)
FNA3DMaps to FNA3D's own interval and resets the FNA3D back buffer
SDL_RENDERERAt construction any positive value becomes 1; at run time the integer is passed to SDL_SetRenderVSync and, if the driver refuses 2, retried as 1
VULKANMaps to present modes with fallbacks and rebuilds the swapchain when the interval changes; 2 is not half-rate (see Vulkan present modes)
WEBGPU, SDL_GPUChoose immediate, mailbox or FIFO/vsync; 2 behaves as 1 (SDL_GPU has no half-rate present mode and records an applied interval of 1)
METALAny nonzero value enables display sync, so 2 behaves as 1
DIRECT2DValidates 0 to 2, stores the request and passes it as the sync interval of its DXGI swap chain's Present, so 2 waits for two vertical blanks
GDI (empty override), CANVAS, FREEDIRECT, HTML_DOM, SVG_DOM, SOFTWARE, HEADLESS, PORTABLEGL, STUBNo display-interval implementation; the public property still changes

The registered interval tests on EasyGL, SDL_RENDERER and SDL_GPU check storage, forwarding and that nothing crashes; the EasyGL test says explicitly that vsync timing cannot be verified headlessly. No test found times half-refresh presentation against ordinary vsync on any family.

Formats, full screen and the reset rollback

GraphicsDevice::Reset raises DeviceResetting, unbinds any render targets (as XNA does), asks the renderer to normalise the requested colour and depth formats (GetAppliedBackBufferFormatEXT, GetAppliedDepthStencilFormatEXT) and stages the result as public state. Applying the window and the virtual resolution is a rollback block: if either throws, the previous presentation parameters, adapter, virtual size and touch-panel dimensions are restored, the old window state is re-applied, and the original exception is rethrown. The format update (UpdatePresentationFormatEXT), the MSAA count (ApplyMultiSampleCount, whose applied value is written back) and the swap interval follow after that block, in that order, and are not rolled back.

The applied-format hooks default to echoing the request. Fixed-format families override them so that PresentationParameters reports what exists: DirectX 11, DirectX 12, Direct2D, EasyGL, GDI, Metal, OpenGL4, PortableGL, SDL_GPU, Software and Vulkan report SurfaceFormat::Color for the back buffer whatever was requested; for depth, DirectX 11 and 12 report the request unless DXGI has no matching format, SDL_GPU, Software and Vulkan report what they allocated, Metal and PortableGL always report Depth24Stencil8, and Direct2D, GDI, HTML_DOM and SVG_DOM report None. FNA3D reports FNA3D's own applied formats, and DirectX 9 honours real formats through a device reset. A family that keeps the echo default (Canvas, FreeDirect, SDL_RENDERER, Stub, and the two DOM families for the colour format only) can still report a format its 2D target does not have. Which families override UpdatePresentationFormatEXT, and why full screen is a window operation that DirectX 11 and 12 never turn into DXGI exclusive mode, is on Format and fullscreen preferences; the Vulkan back buffer's always-present depth image is on Vulkan back-buffer depth.

ℹ

A full-screen request is not silently swallowed at this snapshot: on SDL3 a refused request throws PlatformException, and the reset's rollback restores the previous presentation state before rethrowing. IsFullScreen is still requested state, not proof that a window manager or browser accepted it.

Back-buffer readback

The public contract

GraphicsDevice::GetBackBufferData (all three overloads funnel into GetBackBufferDataCore in GraphicsDevice.cpp) validates strictly before any renderer is involved:

  • it throws NotSupportedException under the Reach profile — back-buffer readback is a HiDef feature, as in XNA;
  • a null destination, a negative startIndex, a non-positive elementCount and a startIndex + elementCount that overflows int are rejected;
  • it throws InvalidOperationException while any render target is bound, so the public call can only ever read the back buffer;
  • the region comes from PresentationParameters, never from the renderer's live viewport: no rectangle means the complete logical back buffer, and a rectangle must be non-empty and inside the back-buffer bounds;
  • elementCount must describe exactly the requested pixel count (the byte counts must match), and the result is unpacked into real Color objects at startIndex.

The capacity of the caller's array after startIndex cannot be checked from a raw pointer; that remains the caller's obligation. The renderer hook itself, ReadBackbuffer(x, y, w, h, pixels), promises tightly packed RGBA8 with a top-left origin and throws by default.

What each family reads

ClassFamiliesImage returned
Physical back bufferVULKAN, WEBGPU, SDL_GPU, DIRECTX9, DIRECTX11, DIRECTX12, FNA3DThe swapchain or back-buffer resource (MSAA resolved first where it applies). Logical and physical sizes can differ under a scaled presentation or on a high-density display, which is why the region is taken from PresentationParameters. Vulkan's deferred present and readback cache are explained in Vulkan deferred present
The family's current drawing surfaceEasyGL, OPENGL4, SOFTWARE, GDI, PORTABLEGL, SDL_RENDERER, DIRECT2D, FREEDIRECT, CANVASThe bound target when one is bound, otherwise the back buffer or logical surface. Through the public API that distinction is unreachable, because a bound target makes GetBackBufferData throw first; it matters only to code that calls the renderer directly
Only a bound targetHTML_DOM, SVG_DOMThe browser rasterises the DOM scene only when asked for a render target; the back buffer itself cannot be read
Explicit refusalHEADLESS, METAL, STUBHeadless throws NotSupportedException after the public validation (it formerly filled the buffer with the last clear colour, which made a non-rasterising device indistinguishable from a real frame); Metal throws because a historical macOS run returned clear-colour-only data; Stub inherits the throwing default

DirectX 11 and DirectX 12 name a missing back buffer instead of returning the zero-filled scratch buffer as a successful read, the same "refuse, never fabricate" rule the texture transfers follow. There is no capability member for readback; a portable pixel test names the family, the source image, the coordinate space and the oracle, and runs under HiDef. Tutorial 125: pixel testing teaches the task; the CPU renderers' off-screen use is in Tutorial 107.

Evidence and limits

Checked by reading the device, platform-facing and family sources at 009d40f5; not executed. The family tables come from reading each family's SetPresentationMode, SetSwapInterval, applied-format and ReadBackbuffer bodies; no pixel, timing or full-screen behaviour was observed, and the Windows, macOS and browser families are chiefly exercised on hosts or lanes that were not examined here.

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