Tutorial 126: Build Several Renderers and Select One at Runtime
What you’ll learn: when to keep the smaller single-renderer default, how CNA_GRAPHICS_RENDERERS changes the build, how selection and fallback behave before the first graphics device, how to select from a browser page or a test, and which combinations CNA’s own CI builds.
Start with the architectural choice
A normal build compiles exactly one renderer selected by CNA_GRAPHICS_RENDERER. That remains the recommended path for a fixed deployment: fewer dependencies, a smaller binary and no runtime decision.
Multi-renderer mode is opt-in (it arrived in alpha.1 and is unchanged in its runtime API at this snapshot). It compiles a compatible list of identities, keeps one member as the default, and publishes the complete list to CNA::GraphicsRendererSelection. The 18 public renderer names are the only valid list entries; a name outside them stops the configure step with an error that lists the supported names.
Configure a multi-renderer build
cmake -S ../cna -B build-multi \
-DCNA_GRAPHICS_RENDERER=OPENGLES3 \
-DCNA_GRAPHICS_RENDERERS="OPENGLES3;VULKAN;HEADLESS" \
-DCNA_BUILD_TESTS=ON
cmake --build build-multi --parallel
CNA_GRAPHICS_RENDERER names the default and must be a member of the plural list; otherwise configure fails with a message that tells you to add it or to pick the list’s first entry — CNA never quietly substitutes a different default. Duplicates are removed and the default is moved to the front of the list. Configure prints one status line so a CI log shows what was really built:
CNA: renderer set -- OPENGLES3;VULKAN;HEADLESS (default: OPENGLES3)
Only the default identity’s macro (CNA_RENDERER_<IDENTITY>) is defined project-wide, so the legacy CNA_RENDERER_* booleans still describe the default, not every compiled family. When more than one renderer is linked, CNA_MULTI_RENDERER is defined, and CNA’s own test targets receive CNA_RENDERER_PRESENT_<IDENTITY> for each linked renderer — a convenient guard for code that should only compile when a particular renderer is present. Each identity also brings its own dependencies: the example above needs the ../easy-gl and ../meta-gl siblings (for OPENGLES3) and a Vulkan SDK, on top of ../sharp-runtime on its next branch.
The repository ships a dependency-free reference preset, multi-renderer, whose set is HEADLESS;SOFTWARE;STUB: no window, no GPU library, no third-party graphics dependency.
The combination rule
Every selected renderer brings its build dependencies and code size. CMake rejects one demonstrated conflict at configure time, with a stated reason: identities from different platform partitions — Windows-only (DIRECTX9, DIRECTX11, DIRECTX12), Emscripten-only (WEBGL1, WEBGL2, CANVAS) and macOS-only (METAL) — because one toolchain cannot target two of them. Separately, CNA_PLATFORM=TERMINAL accepts only SOFTWARE, HEADLESS and STUB (Tutorial 127).
Several of the five GL-profile identities may share one binary, because their profile is a runtime value inside the single EasyGL implementation; only the native/Emscripten partition still separates them.
Select before the device exists
#include "CNA/GraphicsRendererSelection.hpp"
#include "CNA/GraphicsRendererType.hpp"
using CNA::GraphicsRendererSelection;
using CNA::GraphicsRendererType;
int main()
{
for (auto renderer : GraphicsRendererSelection::GetAvailable())
{
// Populate a settings menu or diagnostic here.
(void)renderer;
}
GraphicsRendererSelection::SetPreferred(GraphicsRendererType::Vulkan);
MyGame game;
game.Run(); // the first successfully created renderer latches the choice
}
The resolution order is explicit SetPreferred(), then the CNA_GRAPHICS_RENDERER environment variable, then the compiled default. Names passed to the string overload or read from the environment use the public spelling case-insensitively at runtime (vulkan works), whereas CMake compares the names case-sensitively at configure time.
GetAvailable()reports what was compiled into this binary, andIsAvailable()answers for one identity.GetSelected()reports what CNA will try first and does not latch the decision.GetActive()reports what was actually created and throws before creation.IsLatched()becomes true once the firstGraphicsDevicehas successfully created a renderer.
An unknown public name is an argument error (SetPreferred(name) throws System::ArgumentException; a bad CNA_GRAPHICS_RENDERER value throws System::InvalidOperationException). A known but uncompiled renderer is a hard error System::InvalidOperationException while fallback remains disabled, and the message lists what is available. Calling SetPreferred(), SetFallbackChain() or EnableAutomaticFallback() after latching is also an error.
What latching does and does not freeze
- A failed creation does not latch. If the first
GraphicsDevicethrows, the selection stays open: a game can catch the error and retry with a different renderer or configuration. - Recreating the same renderer is fine.
GraphicsDevice::Reset(), a multisample change and even a secondGraphicsDevicekeep the selection; latching forbids changing it. - Selection is process-wide, so make every call before any graphics thread starts.
Select from a browser page
A browser has no environment variables, so an Emscripten build reads a page property instead. It is consulted at the environment variable’s precedence — below an explicit SetPreferred() call in your program, above the compiled default:
var Module = { cnaPreferredRenderer: "CANVAS" };
The value must be one of the renderers compiled into that bundle, for example WEBGL2, WEBGL1 or CANVAS in the three-renderer bundle CNA’s CI links. A bundle also exports cna_set_preferred_renderer(name) for pages that drive the module directly; it returns 1 on success and 0 (with a logged warning) for an unknown, uncompiled or too-late request rather than throwing across the WebAssembly boundary.
Make fallback an explicit policy
const GraphicsRendererType chain[] = {
GraphicsRendererType::OpenGLES3,
GraphicsRendererType::Software
};
GraphicsRendererSelection::SetPreferred(GraphicsRendererType::Vulkan);
GraphicsRendererSelection::SetFallbackChain(chain);
Fallback is off by default so a request for Vulkan cannot silently become CPU rendering. SetFallbackChain() takes a span, so pass a named array (a braced list will not convert). A custom chain may include identities absent from a particular build; they are skipped and recorded. EnableAutomaticFallback(true) instead derives an order from CNA’s declared maturity and category data, with CPU rasterizers ranked later and STUB always last; by our reading of that ranking code the diagnostic HEADLESS renderer, which draws nothing, sorts ahead of SOFTWARE, so a chain you write yourself is the safer choice when you want pixels. An exhausted chain throws “CNA: no graphics renderer could be created.” with the first failure as the primary cause.
After creation, inspect the history for the honest result. Each record carries the identity, a reason — NotCompiledIn, ProbeUnavailable, InitializationFailed or WindowKindConflict — and a message:
#include <iostream>
#include "CNA/GraphicsRendererFallbackRecord.hpp"
std::cout << "active: "
<< CNA::getGraphicsRendererName(GraphicsRendererSelection::GetActive()) << '\n';
for (const auto& skipped : GraphicsRendererSelection::GetFallbackHistory())
{
std::cout << " skipped " << CNA::getGraphicsRendererName(skipped.type)
<< " (" << CNA::getGraphicsRendererFallbackReasonName(skipped.reason)
<< "): " << skipped.message << '\n';
}
Every skipped renderer is also logged at warning level. Every family’s availability probe is unconditional, so “available” means “compiled in”; a genuine failure to start arrives as InitializationFailed from the renderer’s constructor.
Know the window boundary
Selection happens before device creation. An externally owned window cannot necessarily be recreated across incompatible window kinds (none, plain, OpenGL, Vulkan, Metal); CNA records that as a WindowKindConflict fallback failure. A window CNA created itself is destroyed and recreated for the next candidate. Build-time compatibility therefore does not promise that every renderer can replace every other renderer inside a pre-existing native window.
Null and reference devices
XNA’s GraphicsAdapter.UseNullDevice and UseReferenceDevice flags are honoured by renderer choice: a null device requires the HEADLESS renderer and a reference device requires SOFTWARE (null wins if both are set). If the required renderer is compiled in and the selection is still open, CNA selects it for you; if it is not compiled in, or the selection has already latched to something else, device creation is refused with a NoSuitableGraphicsDeviceException. A fallback chain cannot satisfy the flag with a different renderer.
#include "Microsoft/Xna/Framework/Graphics/GraphicsAdapter.hpp"
using Microsoft::Xna::Framework::Graphics::GraphicsAdapter;
// Before the first GraphicsDevice (multi-renderer build containing HEADLESS):
GraphicsAdapter::setUseNullDeviceProperty(true);
// ...or a CPU-rasterized reference device (build containing SOFTWARE):
GraphicsAdapter::setUseReferenceDeviceProperty(true);
Exercise the fallback path deliberately
Three environment variables let a test rehearse failure without breaking a real driver. Each takes a comma-separated, case-insensitive list of renderer names:
| Variable | Effect |
|---|---|
CNA_DEBUG_UNAVAILABLE_RENDERERS | Treat the listed renderers as unavailable; each is skipped with ProbeUnavailable. |
CNA_DEBUG_FAIL_RENDERER_INIT | Make the listed renderers fail during initialization; each is recorded as InitializationFailed. |
CNA_FORCE_HEADLESS_DEVICE_EXT | Create the listed renderers’ device without a window or video subsystem (as if PresentationParameters.HeadlessEXT were set), for CI machines with no display. |
The HEADLESS renderer has its own CNA_HEADLESS_MODE (Fast, Validation as the default, or Trace).
Prove the choice with the selection demo
CNA ships a small example, cna_demo_renderer_selection (built with the examples, which are on by default), that lists what was compiled in, applies the renderer name you pass as its first argument, treats any further names as a fallback chain, creates a device and prints the active renderer and the fallback history. CNA’s own CI runs it for every member of the reference set, and the same check works for your build:
cmake --preset multi-renderer
cmake --build cmake-build-multi --target cna_demo_renderer_selection
for r in HEADLESS SOFTWARE STUB; do
./cmake-build-multi/cna_demo_renderer_selection "$r" # prints "Active renderer: $r"
done
CNA_GRAPHICS_RENDERER=SOFTWARE ./cmake-build-multi/cna_demo_renderer_selection
The multi-renderer configure preset is exactly -DCNA_GRAPHICS_RENDERER=HEADLESS -DCNA_GRAPHICS_RENDERERS="HEADLESS;SOFTWARE;STUB" in a Debug tree at cmake-build-multi. Asking the demo for a renderer that is not in your build (say VULKAN here) prints a refusal that lists what is available — the honest outcome, not a silent downgrade.
What CNA’s CI actually builds
| Set | Where | What it does |
|---|---|---|
HEADLESS;SOFTWARE;STUB | Multi-renderer CI | Builds, selects each renderer at runtime, checks the environment-variable route and the “default must be a member” refusal, and runs the test suite; also runs a single-renderer control build. |
OPENGL33;VULKAN;SOFTWARE;HEADLESS | Platform CI, native X11 with no SDL | Builds two real demo games and the audio tests, and proves no SDL was linked. |
WEBGL2;WEBGL1;CANVAS | Emscripten multi-renderer CI | Builds one WebAssembly bundle and asserts the JavaScript selection surface exists; it does not run the three renderers. |
These are configure-and-build evidence for the sets listed, not a claim that every combination of the 18 renderers has been built.