Graphics architecture

CNA snapshot 009d40f5  ·  Development › Architecture Maps  ·  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. 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. 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 (four families need neither: HEADLESS, SOFTWARE, STUB, PORTABLEGL), the high-DPI policy (only Metal sets wantsHighDpi), the GL framebuffer bits that must be fixed before the window exists (OPENGL4 and FNA3D request 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

Game::BeginDraw
  └─ GraphicsDeviceManager::BeginDraw
      ├─ IGraphicsRenderer::CanBeginDrawEXT()          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; the GL families (EasyGL and OpenGL4) override it so that a complete operation runs with the renderer's GL context owned by the calling thread, serialized against other threads. 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; five GL-profile identities share one family, so one source change can move five 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, six families use that callback: Direct2D, DirectX 9, DirectX 11, DirectX 12, 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 11, DirectX 12, WebGPU, EasyGL) keeps the game's Update running while it refuses Draw.
  • 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 two OpenGL families

CNA exposes 25 public renderer identities over 21 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. Two relationships matter when reading GL code. OPENGLES2, OPENGLES3, OPENGL33, WEBGL1 and WEBGL2 are five 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. OPENGL4 is not a sixth EasyGL profile: it is a separate family with its own directory, target (cna_renderer_opengl4), namespace and descriptor, its own gl4_-prefixed loader for desktop GL 4.1 core, and no dependency on easy-gl. The two families share GL semantics through headers in the graphics module (GlStockShaderSources.hpp for the stock-shader corpus, GlPresentationSurfaceState.hpp for the presentation transform, PlatformGlRendererState.hpp for the context owner), so an XNA-semantic change to one usually has to be checked in the other. PORTABLEGL cannot be linked beside either, because it defines the global gl* symbols; no configure rule forbids OPENGL4 beside an EasyGL identity, but none of CNA's multi-renderer CI sets contains that pair.

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.