Graphics architecture

CNA snapshot c1c316b9  ·  Development › Architecture Maps  ·  source links pinned to c1c316b9

✓

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 c1c316b9; unless a sentence says otherwise, nothing here was built or executed. Creation, frame and disposal order were read from GraphicsDevice.cpp, GraphicsDeviceManager.cpp and the descriptor header; the device-loss family list comes from searching the renderer sources for the device-event callback, not from running any renderer. Named tests exist but were not executed for this page.

GraphicsDevice (GraphicsDevice.hpp, GraphicsDevice.cpp) is the public state and lifetime center of the graphics stack. A registry-selected renderer translates its resources and commands into one graphics API, and a pre-construction descriptor supplies the window and surface requirements that no renderer object can answer before it exists. This page states how those pieces are created, how a frame reaches a renderer, who owns which resource, and how much CNA can honestly promise about device loss. The selection machinery has its own tour in renderer selection internals.

Device creation

  1. Game constructs its value member GraphicsDevice_ inside the base constructor, with the default adapter, GraphicsProfile::Reach and default PresentationParameters; a failure surfaces from Game's constructor as NoSuitableGraphicsDeviceException (passed through, or wrapping the cause), as XNA's GraphicsDeviceManager.CreateDevice reports it. The GraphicsDevice constructor calls resolveRenderer().
  2. resolveRenderer() asks the selection layer for an attempt order (an explicit SetPreferred, else the CNA_GRAPHICS_RENDERER environment variable, else the compiled default; the selection latches once a device has created a renderer) and walks it. For each candidate it looks up a GraphicsRendererDescriptor in the GraphicsRendererRegistry, the table that RendererRegistry.cmake generates into cna_graphics_core. With no fallback chain opted in there is exactly one attempt and its exception propagates unchanged.
  3. The descriptor declares the early needs: the window kind (None, Plain, OpenGL, Vulkan or Metal), whether a window and the video subsystem are needed at all (three families need neither: HEADLESS, SOFTWARE and STUB), the high-DPI policy (only Metal sets wantsHighDpi), the GL framebuffer bits that must be fixed before the window exists (FNA3D requests 24-bit depth, 8-bit stencil and double buffering, because a GLX visual is chosen at window creation), and which single narrow service the family is handed: a GL context, a Vulkan surface or a surface presenter.
  4. createOrAttachWindow() lets the platform create a compatible window, or adopts a caller-supplied one through PresentationParameters.DeviceWindowHandle (borrowed, never destroyed or recreated). A CPU family that presents through the platform gets a window and a presenter only where the platform reports surface presentation and no native window handle; see the platform contract.
  5. createRenderer() builds GraphicsRendererCreateArgs (the surface snapshot, only the services the descriptor asked for, the presentation format, the device-event callback) and calls descriptor.create(args). The selection latches as soon as the renderer object exists; the constructor then applies the initial blend, rasterizer and, when the renderer supports depth and stencil, depth-stencil state objects.
  6. Later, GraphicsDeviceManager applies the game's preferences to that existing device: the requested profile, the presentation mode, then an in-place Reset (window, virtual resolution, presentation formats, MSAA and swap interval are applied to the live renderer). The device pins the resolved descriptor, so rebuilding a renderer on a live device (RecreateRendererForMultiSampleCount) reuses it instead of re-running resolution; an MSAA change cannot switch graphics APIs mid-game.

The descriptor exists because virtual calls on a renderer cannot answer requirements before that renderer and its compatible window have been constructed. Its adapter-level hooks (profile support, render-target and back-buffer format support, depth-format selection and MSAA clamping) exist for the same reason: GraphicsAdapter queries run before any device does. The identity list is checked at configure time and the descriptors are compiled into the registry, so adding a renderer is build-graph work as well as a factory class.

Frame and submission flow

(inside Game::Tick, which already holds the game-thread context lease from PollEvents to the end of the draw)
Game::BeginDraw
  └─ GraphicsDeviceManager::BeginDraw
      ├─ IGraphicsRenderer::TryBeginDrawEXT()          false → skip Draw and EndDraw for this tick
      └─ GraphicsDevice::AcquireRendererThreadContextLeaseForFrame()
          └─ game Draw: state, resource and command translation
              └─ Game::EndDraw → GraphicsDeviceManager::EndDraw → GraphicsDevice::Present
                  ├─ reject if a render target remains bound
                  ├─ short renderer-thread-context lease around IGraphicsRenderer::Present
                  └─ UpdateViewportFromWindow; the frame lease is released after Present (also on an exception)

Backend synchronization is implementation-specific. Do not infer Vulkan, WebGPU or Direct3D fences from the public API; inspect the selected renderer's frame and present implementation, starting from the indexed draw trace. A renderer that needs no context lease returns none; EasyGL, the GL family, overrides it so that a complete operation runs with the renderer's GL context owned by the calling thread, serialized against other threads. Game holds such a lease for Initialize, BeginRun, every tick (Update and Draw) and EndRun, and texture, buffer and render-target construction, SetData, GetData and Dispose take one on whatever thread they run, so a loading thread works between frames; a game thread that waits inside a frame for such a thread deadlocks (the frame trace). A game with no registered device manager still presents through Game::EndDraw calling GraphicsDevice::Present() directly.

Resource lifetime

Buffers, textures, render targets, effects and related device resources register with GraphicsDevice. A registry entry is a raw pointer that tracks disposal; it is not an allocation claim, and each resource also holds a weak lifetime token so that one destroyed after its device does not dereference it. On disposal the device marks itself disposed first (so re-entrant disposal is a no-op), raises Disposing, moves the registry into a local list and disposes every registered resource while the renderer still exists, and only afterwards resets the renderer, the surface presenter and the window and releases its video-subsystem reference. Resource implementations must tolerate that documented disposal path and must not require already-destroyed platform services. GraphicsDeviceLifecycleTest.DisposalIsReentrantAndReleasesABoundRenderTarget and GraphicsDeviceLifecycleTest.RendererFacingOperationsRejectUseAfterDeviceDisposal pin the device side; the textures and render targets trace follows one resource end to end.

State and render targets

The public device models XNA-style graphics state. Backends translate blend, depth-stencil, rasterizer, sampler, vertex and index bindings and render targets to their native representation, so a change to a state object is cross-renderer work unless it is proven to sit entirely in public validation or caching. A device that has a render target bound refuses to present: GraphicsDevice::Present throws an InvalidOperationException, and the framework does not unbind on the application's behalf (the reasoning is written up in present_lifecycle_contract_test.cpp, which several renderer families register; not executed here). Presentation is valid only for the back-buffer path: SetRenderTargets(nullptr, 0) returns to it, and targets must be resolved or unbound first. The user guide describes the same rule from the game's side.

Renderer change checklist

  1. Locate the neutral contract and every registry identity that claims it; three GL-profile identities share one family, so one source change can move three identities.
  2. Trace creation, destruction and device-dispose behavior for the resource, including the disposal order above.
  3. Check shader and input-layout translation and coordinate and format conversions.
  4. Verify render-target transitions, swapchain resize and present behavior.
  5. Run focused renderer tests on a private display, with validation layers or debug output where the family wires them; run_gpu_tests_private.sh exists so that window and GPU tests do not touch the desktop.
  6. Compare at least one independent backend: passing only the changed backend can preserve a wrong shared assumption. CNA's cross-renderer parity fixtures exist for exactly this (see verification: renderers); their oracle is the fixtures' own assertions, not real XNA.

Device loss/reset

The repository contains backend-specific failure, recovery and resize handling, but nothing shows that every registered renderer provides one uniform, complete device-loss recovery guarantee. Treat loss and reset as backend-specific until the selected implementation and its tests demonstrate otherwise. What the source establishes is the plumbing:

  • A renderer that detects a real loss reports RendererDeviceEvent::Lost, Resetting or Reset through GraphicsRendererCreateArgs::deviceEventCallback; GraphicsDevice turns those into its DeviceLost, DeviceResetting and DeviceReset events and status, and raises content-lost notifications for resources whose contents were lost. GraphicsDeviceManagerTest.RendererDetectedDeviceLostIsForwardedToManagerListeners covers the forwarding.
  • By reading the renderer sources, four families use that callback: DirectX 9, DirectX 11, Vulkan and WebGPU, the last only from its debug simulation hooks (a real WebGPU device loss is only logged, not reported). Vulkan is the limiting case: it reports Lost once and then throws from the failing call, and by its own error text it does not attempt a reset. EasyGL has browser context-loss handling of its own. The remaining families do not use that callback, and the default DebugSimulateContextLoss and DebugRestoreContext hooks are no-ops.
  • CanBeginDrawEXT() is how a family that can lose its context or device (DirectX 9, DirectX 11, WebGPU, EasyGL) keeps the game's Update running while it refuses Draw. The manager asks it through TryBeginDrawEXT(), which DirectX 9 overrides to attempt its pending device recovery before answering.
  • An application-initiated GraphicsDevice::Reset is a separate path: it raises the same two events directly, unbinds bound render targets first, and does not imply that anything was lost.

Identities, families and the OpenGL family

CNA exposes 14 public renderer identities over 12 implementation families. The identity-to-family map is generated from RendererRegistry.cmake on the selection axes index; the family-first view with build gates and internals pages is Graphics backends. One relationship matters when reading GL code. OPENGLES3, OPENGL33 and WEBGL2 are three profiles of the single EasyGL family (the sibling easy-gl and meta-gl libraries), and the profile is a run-time value, so several of them can share one binary; every other identity is its own family. EasyGL is CNA's only OpenGL family: OPENGL33 keeps a 3.3 contract, and there is no separate desktop GL 4 renderer. The GL-specific helpers live with the family (GlStockShaderSources.hpp for the stock-shader corpus and GlPresentationSurfaceState.hpp for the presentation transform, both under modules/renderers/easygl), while PlatformGlRendererState.hpp, the context owner, stays in the graphics module's common renderer headers. Of the other families, only SDL_RENDERER is deliberately 2D-only, and STUB reports no capability at all.

Where to go deeper

  1. GraphicsDevice internals, then indexed draw trace and textures and render targets.
  2. Renderer selection internals for the registry, descriptor and latch, and Graphics backends for the family map.
  3. One family tour: EasyGL, Vulkan, SDL_gpu, Software, Headless or Stub.
  4. The user-level view: Renderers and Runtime renderer selection.

The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.