Renderer selection internals

CNA snapshot 009d40f5  ·  Development › Graphics internals  ·  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. The named CMake script tests and unit tests exist at this snapshot; none was run for this page.

Two separate decisions pick a renderer. At configure time CMake decides which renderer implementation families exist in the binary and which identity is the default; at run time CNA::GraphicsRendererSelection decides which compiled-in identity the first GraphicsDevice attempts. A per-family GraphicsRendererDescriptor connects the two: it carries the pre-window policy and the factory. This page traces both halves through the source so that a maintainer adding, gating or debugging a renderer knows which file owns which rule.

ℹ

The user-facing contract (options, precedence, fallback API, device flags, debug variables) is documented in Runtime renderer selection and summarised in Architecture: renderer registry. This page is the implementation underneath it: the CMake order, the generated registry, the policy state and the resolution loop.

Configure-time sequence

The renderer is settled in several passes of the top-level CMakeLists.txt. The order matters: the identity check runs before any dependency is configured, and the registry translation unit is generated before a single module directory is entered.

CMakeLists.txt
  → cmake/RendererIdentities.cmake        refuse any name outside the 25 public identities
                                          (all three routes), before SDL or any dependency
  → cmake/PlatformSelection.cmake, AudioPlatformSelection.cmake, Sdl2OnlyConfiguration.cmake
  → cmake/SdlAvailability.cmake
      → RendererIdentityDefault.cmake     per-host default, needed to decide whether SDL is needed
  → cmake/RendererSelection.cmake
      → RendererIdentityDefault.cmake / RendererIdentities.cmake   (re-included, no-op)
      → CNA_GRAPHICS_RENDERER cache value + 25 CNA_RENDERER_<X> switches (exactly one may be ON)
      → RendererDefaultSelection.cmake    set + default first + dedupe → CNA_RENDERER_IDENTITIES
      → RendererCombinations.cmake        cna_validate_renderer_combination()
      → CNA_MULTI_RENDERER when more than one identity
      → foreach identity: cna_configure_renderer_identity()
           TERMINAL / Windows / Apple / Emscripten gates, family dir + target + defines, dependencies
  → cmake/RendererRuntime.cmake           runtime payloads beside the final application
  → cmake/RendererRegistry.cmake          cna_generate_renderer_registry()
                                          → <build>/generated/CnaRendererRegistry.generated.cpp
  → cmake/RendererDescriptorGate.cmake    option CNA_BUILD_RENDERER_DESCRIPTOR_GATE (default ON)
  → add_subdirectory(modules)
      → modules/graphics                  cna_graphics_core compiles the generated registry
                                          and links every selected renderer archive
      → modules/renderers                 enter each selected family once; descriptor gate
  → executables link the CNA umbrella (static archives) or libcna.so (CNA_SHARED_LIBRARY)

CNA_GRAPHICS_RENDERER is the default identity. It is a cache string whose default comes from cmake/RendererIdentityDefault.cmake (WEBGL2 under Emscripten, OPENGLES3 on Linux, SDL_RENDERER everywhere else) and whose STRINGS property is the public list. If CNA_GRAPHICS_RENDERERS is empty, only the default identity is compiled. The optional semicolon-separated list opts into a multi-renderer build: cmake/RendererDefaultSelection.cmake requires the default to be a member (a non-member is a FATAL_ERROR, never a silent substitution), removes it from the list, re-inserts it at the front and removes duplicates, then publishes CNA_RENDERER_IDENTITIES and prints CNA: renderer set -- <list> (default: <X>). A CNA_RENDERER_<X>=ON compatibility switch selects exactly one identity and overrides the cache value for that configure; two switches ON is a configure error.

cmake/RendererIdentities.cmake holds CNA_RENDERER_PUBLIC_IDENTITIES and a table of retired identities with their permanently reserved C ABI values. cna_validate_renderer_identity_selection() checks every route that can name a renderer — the singular option, each member of the plural list, and every retired CNA_RENDERER_<X> switch — so a stale switch left in a CMake cache is refused by name instead of sitting unread while the host default is quietly configured. Public names are matched case-sensitively (IN_LIST); retired names are recognised in any case and get their own message naming the reserved value. No retired identity is an alias for a current one.

The per-identity work is the macro cna_configure_renderer_identity() in cmake/RendererSelection.cmake. It is a macro, not a function, so its set() and add_subdirectory() calls act in the caller's scope; the loop at the end of the file sets CNA_GRAPHICS_RENDERER to each identity in turn and appends RENDERER_TARGET, RENDERER_DIR and the identity's defines to CNA_RENDERER_TARGETS, CNA_RENDERER_DIRS and CNA_RENDERER_TARGET_DEFINES. The host gates live inside the macro, so they run per identity: CNA_PLATFORM=TERMINAL admits only SOFTWARE, PORTABLEGL, HEADLESS and STUB; the Direct3D, Direct2D and GDI identities require a Windows target; METAL requires macOS; the browser identities require Emscripten; the three native GL-profile identities refuse Emscripten.

The five GL-profile identities (OPENGLES2, OPENGLES3, OPENGL33, WEBGL1, WEBGL2) all map to modules/renderers/easygl and the single target cna_renderer_easygl; each identity adds CNA_RENDERER_EASYGL plus CNA_GL_PROFILE_<IDENTITY>, and the ../easy-gl sibling is added as a subdirectory only once. OPENGL4 is a different family with its own directory and target (see OPENGL4 and EasyGL).

Only the default identity's defines are applied project-wide with add_compile_definitions(); every other family's identity macro is private to its own target (applied in cna_renderer_common_setup()). This keeps getCurrentGraphicsRendererType() and the many compile-time #ifdef sites in tests and examples describing the default in a multi-renderer build. After the loop the file restores CNA_GRAPHICS_RENDERER, RENDERER_TARGET, RENDERER_DIR and CNA_RENDERER_DEFINE to the default, because CNA_RENDERER_DEFINE travels on cna_build_config's interface to every module.

modules/renderers/CMakeLists.txt then enters the families. cna_add_renderer() derives the target name from the family directory (modules/renderers/<family> builds cna_renderer_<family>, with - replaced by _), and cna_renderer_common_setup() adds the common interface, the sharp-runtime closure, the family's include root, the family's private identity defines and real reverse edges PRIVATE cna_graphics_core cna_core cna_math. In a multi-renderer build a family is entered once even when several identities name it. Two directories sit outside that loop: modules/renderers/metal is entered unconditionally, ahead of it, so that its host-portable policy suites compile into the test corpus on every renderer (the loop skips it). The shared helpers are entered before the loop too: common/mojoshader whenever a cna_mojoshader target was configured (FNA3D, or a family's compiled-effects option), and common/d3d only when the default identity is DIRECTX11 or DIRECTX12 — the test reads CNA_GRAPHICS_RENDERER, which still names the default at that point. By code reading, a Windows multi-renderer build that lists one of those two as a non-default member would therefore reach a family file that links cna_renderer_d3dcommon without that directory having been entered; no such configuration was run for this page. The graphics core closes the cycle from the other side: modules/graphics/CMakeLists.txt compiles ${CNA_RENDERER_REGISTRY_SOURCE} into cna_graphics_core, links PRIVATE ${CNA_RENDERER_TARGETS} and sets LINK_INTERFACE_MULTIPLICITY 3 because the Direct3D families make the static-archive cycle one level deeper. This is why adding a renderer is build-graph work, not just a factory class. With CNA_SHARED_LIBRARY (the default on native ELF GNU/Clang with CMake 3.27 or newer) modules/CMakeLists.txt additionally names every compiled-in renderer target in the libcna.so closure.

Variables a family CMakeLists.txt can rely on

VariableSet byMeaning
CNA_GRAPHICS_RENDERERcache, then re-pointed per familyThe default identity; while a family directory is being entered it names the identity being entered, so family files can guard on STREQUAL "<IDENTITY>".
CNA_RENDERER_IDENTITIESRendererDefaultSelection.cmakeEvery compiled-in identity, default first.
CNA_RENDERER_TARGETS / CNA_RENDERER_DIRSthe identity loopOne entry per identity (EasyGL repeats); linked by the graphics core.
CNA_RENDERER_TARGET_DEFINEre-pointed per familyComma-joined identity defines applied PRIVATE to that family's target.
RENDERER_TARGETpublished by cna_add_renderer()Inside a family file: that family's own target. At top level: the default's. Never read it before the family has created its target; RendererTargetDiscipline checks this.
CNA_MULTI_RENDERERcompile definitionDefined when more than one identity is compiled in.

One change landed in this area in the last commits before the snapshot: the OPENGL4 arm of the identity macro now declares CNA_OPENGL4_COMPILED_EFFECTS (default OFF), which includes cmake/ThirdPartyFNA3D.cmake and calls cna_configure_mojoshader() exactly as the EasyGL option does. RendererIdentities.cmake and RendererDefaultSelection.cmake did not change.

Why the registry is generated

cmake/RendererRegistry.cmake owns the identity-to-family map (_cna_renderer_identity_map): one row per public identity, <identity> <Namespace> or <identity> <Namespace>|<accessor>, where the accessor defaults to GetDescriptor. The five GL identities map to EasyGL|GetDescriptorOpenGLES2 … EasyGL|GetDescriptorWebGL2. cna_generate_renderer_registry() walks the compiled-in identities, refuses two identities that resolve to the same Namespace::accessor, declares each accessor and fills cmake/templates/CnaRendererRegistry.generated.cpp.in into <build>/generated/CnaRendererRegistry.generated.cpp. The configure log line is CNA: renderer registry -- <n> family/families: <namespaces>. An identity with no map row is a FATAL_ERROR that names the three places a new identity must be registered.

The generated translation unit implements GraphicsRendererRegistry::All(), Count(), Find(GraphicsRendererType), Find(std::string_view) (case-insensitive) and Default() (the first entry). The table itself is a function-local static std::array, because its entries are produced by each family's own accessor and a namespace-scope constant would reintroduce static-initialisation ordering.

Why not self-registration: a static archive's linker discards an object file nothing references, so a renderer translation unit that registered itself from a static initializer would silently vanish unless the whole archive were force-linked, and that would have to coexist with the declared cna_graphics_core↔renderer archive cycles. The explicit table directly references every selected family's descriptor, avoids that trap, and makes the compiled-in set inspectable in the build tree. The one static initializer the file does use — publishing the available set and default into the selection layer through GraphicsRendererSelectionAccessEXT::PublishAvailable() before main() — is safe for a different reason: GraphicsDevice.cpp references GraphicsRendererRegistry::Find() and Default() directly, so the generated object is retained whenever graphics code is linked. Publishing eagerly is what lets GetAvailable() and SetPreferred() give real answers before any device exists.

The descriptor gate

A single-renderer build compiles exactly one family, so every other family's descriptor translation unit would otherwise never meet a compiler. cmake/RendererDescriptorGate.cmake (option CNA_BUILD_RENDERER_DESCRIPTOR_GATE, default ON) takes its inventory from cna_all_renderer_identities() — the same registry map — checks that some modules/renderers/*/src/*RendererDescriptor.cpp declares namespace CNA::Internal::Renderers::<Namespace> for every registered identity (a missing one is a FATAL_ERROR), and compiles the descriptors of families this configuration did not enter into an OBJECT library that nothing links. directx9, directx11 and directx12 are exempt because their descriptors query real Direct3D devices through Windows SDK headers; vulkan is deferred only when no Vulkan headers are found.

Runtime policy and latch

modules/core/src/GraphicsRendererSelection.cpp holds the selection policy and nothing graphical. Its function-local SelectionState records: the available identities and default (published by the registry), an explicit preference, an environment preference and whether the environment was consulted, whether fallback is enabled and the chain, the active identity, the fallback history, the cached attempt order and an atomic latched flag (atomic so that a program that violates the “select before any graphics thread starts” rule is detected rather than racing).

  • Precedence. GetSelected() returns the explicit SetPreferred() value if one exists; otherwise it consults the environment once (CNA_GRAPHICS_RENDERER; under Emscripten, when the variable is unset, Module.cnaPreferredRenderer read through GraphicsRendererSelectionEmscripten.cpp); otherwise the compiled default. GetSelected() never latches.
  • Refusal. A name that parses to no identity throws (ArgumentException from SetPreferred(std::string_view), InvalidOperationException from the environment). An identity that is not compiled in throws InvalidOperationException naming the available set — unless fallback is enabled, in which case the choice stands and the resolution loop records the skip. The environment is read once: the consulted flag is set before parsing, so by code reading a bad value throws on the first query only.
  • Fallback. SetFallbackChain() stores the chain and enables fallback; EnableAutomaticFallback(true) derives a chain from the compiled-in set, sorted by declared maturity with the Software category ten places later and STUB last; EnableAutomaticFallback(false) clears it. The attempt order is always the selected identity first, then chain entries not already present.
  • Latch. SetPreferred, SetFallbackChain and EnableAutomaticFallback throw once latched, naming the active renderer. Nothing un-latches except the test hook ResetForTestingEXT(), so the selection stays closed even after the device that latched it is gone.

GraphicsDevice::resolveRenderer, candidate by candidate

GraphicsDevice::resolveRenderer() is where the policy meets the compiled-in set. It first calls RequiredRendererForNewDevice(): if GraphicsAdapter.UseNullDevice or UseReferenceDevice is set, the required renderer (HEADLESS or SOFTWARE) must be compiled in and, when the selection is still open, becomes the preference; otherwise NoSuitableGraphicsDeviceException. It then iterates GetAttemptOrder():

  1. No descriptor in the registry → record NotCompiledIn, continue.
  2. A device-type flag requires another renderer → record ProbeUnavailable, continue (a chain may not satisfy the flag with a different device).
  3. An existing window of an incompatible RendererWindowKind → if the device owns it, discardOwnedWindow(); if the caller supplied it through DeviceWindowHandle, record WindowKindConflict and continue.
  4. Named in CNA_DEBUG_UNAVAILABLE_RENDERERS, or the descriptor's isAvailable() returns false → record ProbeUnavailable, continue. Every family's probe is AlwaysAvailable at this snapshot.
  5. Apply PresentationParameters.HeadlessEXT per candidate (the caller's value, or forced by CNA_FORCE_HEADLESS_DEVICE_EXT), pin activeDescriptor_, move the single video-subsystem reference to what this candidate needs, create or adopt a window when none exists, apply presentation parameters to it, honour CNA_DEBUG_FAIL_RENDERER_INIT, then createRenderer().
  6. If that throws: clear activeDescriptor_ and the renderer; with a one-entry order rethrow the original exception unchanged; otherwise record InitializationFailed, drop an owned window (a caller-supplied window is kept so the next candidate either accepts it or is refused with WindowKindConflict) and continue.
  7. On success: GraphicsRendererSelectionAccessEXT::Latch(candidate->type) and return.

An exhausted order throws InvalidOperationException “CNA: no graphics renderer could be created.” with the first failure as the primary cause and one line per history record. Note that the skip records in steps 1–4 are written whether or not fallback was enabled; only an initialization failure distinguishes the one-entry case. Every record is also logged at warning level by RecordFallback().

When exactly the latch closes

The latch closes when a renderer object has been created, at the end of resolveRenderer() — not when construction begins (the header comment in GraphicsRendererSelection.hpp still says the latter). A failed resolution therefore leaves the selection open and a game can catch the error and retry with another configuration, which GraphicsRendererFallbackTest.RecoveringAfterAFailedResolutionActuallyWorks exercises. By code reading, the latch precedes the constructor's remaining steps (UpdateViewportFromWindow() and the default blend, depth-stencil and rasterizer pushes); if one of those throws, the constructor tears the renderer down but the selection stays latched to it. No test found covers that edge. A renderer rebuilt later on a live device (RecreateRendererForMultiSampleCount()) reuses the pinned activeDescriptor_ and never reruns resolution, so an MSAA change cannot silently switch APIs mid-game.

What the descriptor knows before a renderer exists

modules/graphics/include/CNA/Internal/Renderers/Common/GraphicsRendererDescriptor.hpp is the pre-construction contract: everything GraphicsDevice needs before an IGraphicsRenderer exists, as data and function pointers instead of #ifdef chains. Every function pointer except the adapter queries must be non-null; families with nothing to probe use AlwaysAvailable from GraphicsRendererDescriptorHelpers.hpp.

FieldConsumed byMeaning
type, nameregistry lookup, loggingThe public identity; name equals getGraphicsRendererName(type).
windowKindresolveRenderer, createOrAttachWindowNone, Plain, OpenGL, Vulkan or Metal; AreWindowKindsCompatible() decides whether a fallback may reuse a window. Mapped to the platform's WindowRenderIntent.
needsWindow, needsVideoSubsystemwindow and video-reference decisionsFalse for the four window-free families (Headless, Software, Stub, PortableGL); a registry test asserts that needing a window implies needing video.
wantsHighDpiWindowDescription::highDpiData that replaced a former window-flag hook; only Metal sets it.
glFramebufferWindowDescription::openGlFramebufferDepth/stencil/double-buffer bits fixed before the window exists (a GLX visual is chosen at window creation). OpenGL4 and FNA3D request 24/8/double-buffered; EasyGL leaves it zeroed because it picks its config at context creation.
needsSurfacePresenter, needsGlContext, needsVulkanSurfacecreateRendererWhich narrow platform service the family receives. Only Software sets the presenter flag (Terminal display); EasyGL and OpenGL4 get the GL context service; Vulkan the Vulkan surface service.
isAvailable, createthe resolution loopCheap side-effect-free probe; the family's own CreateGraphicsRenderer (or, for EasyGL, a lambda calling CreateGraphicsRendererForProfile(args, profile)).
adapterQueriesGraphicsAdapterNullable hooks for profile support, render-target and back-buffer formats, back-buffer depth selection and MSAA clamping, answered before any device exists. Implemented at this snapshot by DirectX9 (profile, formats, MSAA), DirectX11/12 (formats, depth selection, MSAA), Vulkan (MSAA clamp only) and the four EasyGL profiles other than OPENGL33 (render-target format: Color only).

The descriptor transports neutral data only: it includes no windowing-library header and exposes no SDL flag bits to the graphics core. Declared maturity and category are not descriptor fields; they are constexpr lookups over GraphicsRendererType in GraphicsBackendMaturity.hpp and GraphicsBackendCategory.hpp. The per-family values are tabulated in the family map.

Worked change: add a renderer identity

A sequence that follows the source; it was not executed for this page.

  1. Define the public identity and its C ABI value together: append it to CNA_RENDERER_PUBLIC_IDENTITIES in cmake/RendererIdentities.cmake, the enum, name and parser in modules/core/include/CNA/GraphicsRendererType.hpp, the maturity and category switches, CNA_GRAPHICS_RENDERER_<X> (the next free value is 52; retired values stay reserved) and CNA_GRAPHICS_RENDERER_MAXIMUM in modules/c-api/include/CNA/C/graphics.h, the identity table in modules/c-api/src/CnaCApiCoreExt.cpp, and the canonical table in scripts/check_renderer_identities.py, which also checks CNA's own documents that state a count.
  2. Add the CNA_RENDERER_<X> option and an arm in cna_configure_renderer_identity() (cmake/RendererSelection.cmake): RENDERER_DIR, RENDERER_TARGET matching the directory-derived name, identity defines appended to _cna_identity_defines (never add_compile_definitions() in the arm), host gates and dependencies. Add a rule to cmake/RendererCombinations.cmake only for a demonstrated conflict, and keep scripts/check_renderer_combinations.py in step.
  3. Add the row to _cna_renderer_identity_map in cmake/RendererRegistry.cmake. If several identities share a family, give each its own accessor and let modules/renderers/CMakeLists.txt enter the family once.
  4. Create modules/renderers/<family>/CMakeLists.txt calling cna_add_renderer(), and a src/<Family>RendererDescriptor.cpp whose namespace line is exactly namespace CNA::Internal::Renderers::<Namespace> (the descriptor gate matches that line), declaring the family's own CreateGraphicsRenderer.
  5. Make the descriptor truthful: window kind, video and service needs, GL framebuffer request, availability and adapter hooks. Verify that a constructor failure releases everything it created — resolveRenderer() will try the next candidate on the same device.
  6. Configure a single-renderer build first, then a multi-renderer set with a different default; inspect generated/CnaRendererRegistry.generated.cpp and the final link line.
  7. Run RendererIdentityRegistry, RuntimeRendererDiscipline, RendererCombinationRegistry, RendererTargetDiscipline, the CnaRendererDefaultSelection_* cases, the registry, selection and fallback unit tests, then the family's focused tests and the host CI that owns it.

What tests establish

TestWhat it pins
GraphicsRendererRegistryTests.cppNon-empty table and Count(), default first, one descriptor per identity, each descriptor names its own identity and carries the required hooks, window implies video, lookup by identity and case-insensitive name, null for absent identities, selection layer sees exactly the registry.
GraphicsRendererSelectionTests.cppAvailable set equals the registry, default is the build's renderer, GetSelected() does not latch, explicit preference accepted (case-insensitive names), unknown and not-compiled-in refusal with the available list, latch on device construction, post-latch refusal, GetActive() before creation throws, reconstruction and a second device keep the selection, fallback off by default, chain and automatic-fallback behaviour, attempt order.
GraphicsRendererFallbackTests.cppSingle attempt without fallback, empty history on first-time success, real substitution with a recorded rejection, substitution still latches, exhausted chain names every attempt, failed resolution does not latch, recovery after failure works, reconstruction keeps the substituted renderer, chain order without duplicates.
ModuleProbes.cmake script testsRendererIdentityRegistry, RuntimeRendererDiscipline, RendererCombinationRegistry, RendererTargetDiscipline (Python, registered only when an interpreter is found); CnaRendererDefaultSelection_{SingleRenderer,DefaultInsideTheSet,DefaultIsListedFirst,DefaultOutsideTheSet} run RendererDefaultCase.cmake in cmake -P mode; CnaRendererRetired_* cases refuse every retired selector on every route.

The unit tests exercise the explicit-preference half of the precedence. None of the three files sets CNA_GRAPHICS_RENDERER in the process environment; that route is used, rather than asserted, by registrations such as X11_House3D_SmokeTest_<RENDERER> in modules/graphics/examples/CMakeLists.txt, which select each compiled-in renderer through the test environment on the X11 platform. None of these tests proves a new backend's draw, synchronisation or presentation behaviour; that needs the family's focused and conformance tests on a host that has the API. They are listed here as present in the source, not as executed.

Source reading order

  1. RendererIdentities.cmake, RendererIdentityDefault.cmake, RendererDefaultSelection.cmake and RendererSelection.cmake: validation, the set and its default, then the identity-to-family macro and its host gates.
  2. RendererCombinations.cmake: the three demonstrated conflicts and how their reasons are worded.
  3. modules/renderers/CMakeLists.txt: target naming, common setup and reverse edges, one entry per family, and where the descriptor gate is called.
  4. RendererRegistry.cmake, the registry template and RendererDescriptorGate.cmake: how CMake turns the compiled set into runtime evidence and keeps unselected descriptors compiling.
  5. GraphicsRendererDescriptor.hpp, then one small descriptor such as StubRendererDescriptor.cpp and the five-identity EasyGLRendererDescriptor.cpp.
  6. GraphicsRendererSelection.cpp: precedence, refusal, automatic ordering and the one-way latch.
  7. GraphicsDevice.cpp: RequiredRendererForNewDevice, resolveRenderer, createOrAttachWindow, createRenderer. Continue with GraphicsDevice internals.

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

Tests and validation
Test architecture