Tutorial 72: Choosing a Renderer

CNA Tutorials  ·  Architecture

ℹ

What you’ll learn

  • How CNA's 14 renderer identities group, and what each group is for.
  • Which renderers are platform-gated, which are 2D-only, and which never produce a pixel.
  • Selecting one renderer or compiling several for pre-device runtime selection.
  • Why SupportsCapability() is a summary and not a proof, and what the richer capability profile adds.

Before you start — Tutorial 20: Building and Running Your Game. The default remains a single renderer; Tutorial 126 covers the opt-in multi-renderer workflow in full.

In this snapshot (CNA c1c316b9) CNA exposes 14 renderer identities, implemented by 12 renderer families. The three GL-profile identities share one family, EasyGL. The default mode compiles one. An opt-in CNA_GRAPHICS_RENDERERS list can compile several compatible families; the application chooses one before the first graphics device is created, after which the choice latches. This is startup selection and optional fallback, not hot device switching.

The count is not a coverage claim. The 14 identities are not equally complete: one is deliberately 2D-only, several produce no pixels, and each carries a declared maturity (Production, Supported or Experimental) that is CNA's own classification, not a measurement. The set is curated: an identity earns a place only with meaningful platform coverage, compatibility value, architectural value or a capability the rest of the set does not cover. For reference-level capability and platform detail, see the Renderers guide.

Selecting a renderer

For the recommended single-renderer build, use either the named value or exactly one legacy boolean. Do not mix the two forms.

# Form 1 — name the renderer directly (case-sensitive)
cmake -S . -B build -DCNA_GRAPHICS_RENDERER=OPENGLES3

# Form 2 — switch exactly one CNA_RENDERER_<NAME> option ON
cmake -S . -B build -DCNA_RENDERER_OPENGLES3=ON

With form 2, turning on more than one option is a hard FATAL_ERROR: "Exactly one renderer option must be ON when using CNA_RENDERER_* options."

For a multi-renderer build, provide a semicolon-separated list and keep the singular value as the default member:

cmake -S . -B build-multi \
  -DCNA_GRAPHICS_RENDERERS="OPENGLES3;VULKAN;SOFTWARE" \
  -DCNA_GRAPHICS_RENDERER=OPENGLES3

Defaults when you pass nothing: Emscripten gets WEBGL2, Linux gets OPENGLES3, and every other platform (Windows, macOS, iOS, Android) gets SDL_RENDERER. Because the Linux default is OPENGLES3, an ordinary Linux build needs the ../sharp-runtime (branch apple/m4-stabilization), ../easy-gl and ../meta-gl sibling checkouts present next to cna/ — they are separate repositories, not submodules. Windows and macOS therefore default to the 2D-only SDL_RENDERER; a 3D renderer has to be chosen explicitly there.

⚠

Names that no longer exist. EASYGL, D3D9, D3D11 and ASCII are old spellings, not accepted values. Any name outside the 14 passed to CNA_GRAPHICS_RENDERER or listed in CNA_GRAPHICS_RENDERERS stops the configure in the root CMakeLists.txt, before any dependency is configured, with "unknown graphics renderer '<name>' (requested through <route>)" followed by the list of supported names. There are no aliases and no silent fallback on those two routes, and matching is case-sensitive (vulkan is unknown; VULKAN is not). An old-style -DCNA_RENDERER_D3D9=ON switch is different: it is not read by anything and the default renderer is configured, so name the renderer with CNA_GRAPHICS_RENDERER. The old EASYGL is now one of three GL profiles, D3D9/D3D11 became DIRECTX9/DIRECTX11, and ASCII is now the CNAEXT effect AsciiPostProcessEffect.

Group 1 — the OpenGL family (3 identities)

All three share one internal implementation, called EasyGL, which lives in the ../easy-gl sibling library (itself needing ../meta-gl). "EasyGL" is the implementation's name; it is not a selectable renderer value. Selecting any of the three compiles the same tree and adds a CNA_GL_PROFILE_* define.

ValueWhat it isPlatform
OPENGLES3OpenGL ES 3.0 (GLSL ES 3.00) via EasyGL. The Linux default. Declared Production.Native hosts (not Emscripten)
OPENGL33Desktop OpenGL 3.3 core profile via EasyGL. Declared Production. On macOS it runs on Apple’s OpenGL 4.1 core context; CNA’s full test tree passed there on a physical Mac mini M4, compiled effects included.Native hosts (not Emscripten)
WEBGL2WebGL 2 via EasyGL. The Emscripten default. No compute or indirect draw in the browser.Emscripten only

OPENGLES3 is the renderer CNA develops against day to day, and the one its Linux CI configures. It is the safe answer if you have no reason to pick another. Tutorial 102 walks the family in detail.

Group 2 — modern GPU APIs (4 identities)

ValueWhat it isPlatform
VULKANNative Vulkan (instance API 1.1 requested). Declared Production. Executes custom ShaderEffect shaders as SPIR-V and has the most complete modern surface: compute, indirect draw, GPU timers, float/half render targets, shadow sampling and IBL (device-conditional). See Tutorial 85.A Vulkan loader plus a Vulkan surface from the SDL3 platform (Windows, X11, Wayland, Android); Vulkan on Apple is not tested
SDL_GPURenderer on SDL3's GPU API (SPIR-V; SDL selects the driver). Custom ShaderEffect only where libshaderc exists (Linux/Android builds). See Tutorial 131.Wherever SDL3's GPU API has a device; needs the SDL3 platform
WEBGPUWebGPU through the pinned native wgpu-native (WGSL shaders) and, new in this snapshot, through the Emscripten emdawnwebgpu port in the browser. Declared Experimental. See Tutorial 132.Linux, macOS (wgpu-native on Metal; full test tree passed on a Mac mini M4), Windows (native); browser (Emscripten)
METALNative Apple Metal; SDL3 supplies only the window, CNA attaches its own CAMetalLayer view. MSAA, up to eight render targets, occlusion queries, instancing, every uncompressed surface format and DXT; custom effects are SpriteBatch-scoped MSL only, compiled XNA effects need -DCNA_METAL_COMPILED_EFFECTS=ON, no compute. Rated supported, not primary-production (full test tree passed on a physical Mac mini M4). See Tutorial 109.macOS; iOS/iPadOS (Simulator-tested)

METAL builds for macOS and iOS/iPadOS. Its evidence is a local campaign on a physical Mac mini M4 (CNA’s full test tree, 11,015 tests, no failures, console locked so presentation was not observed) plus an iOS Simulator pixel probe; metal-macos-ci.yml builds it and runs the Metal* tests on a hosted paravirtual GPU, with no green run recorded after its latest changes. iOS allows SDL_RENDERER and METAL; no physical iPhone or iPad has run either, and tvOS remains unsupported.

Group 3 — translation layers and library adapters

Four identities do not talk to one native API themselves but adapt another library, which then picks its own native API. The extra layer is the point — and also the risk, because CNA's coverage of each is narrower than the wrapped library itself. Three of the four appear in other groups; the one whose whole identity is an adapter is FNA3D, which CNA's own registry classes as an abstraction layer.

ValueWhat it adaptsNotes
FNA3DFNA-XNA's FNA3D C library, which picks SDL_GPU, Direct3D 11 or OpenGL at runtime (override with FNA3D_FORCE_DRIVER), plus MojoShader. Declared Experimental. See Tutorial 108.Cannot compile custom shaders at all; it only executes compiled Direct3D 9 Effect Framework binaries, which are always on. Its whole-corpus oracle CTest gates only that scenes render, not pixel equality. Needs SDL3.
SDL_GPUSDL3's GPU APIGroup 2.
WEBGPUWebGPU (wgpu-native or emdawnwebgpu)Group 2.
SDL_RENDERERSDL3's built-in 2D rendererGroup 6.

Three identities need SDL3 directly — SDL_RENDERER, SDL_GPU and FNA3D. Every windowed renderer takes its window, GL and Vulkan services from the SDL3 platform, CNA’s one windowing platform; a build with CNA_ENABLE_SDL=OFF is windowless (CNA_PLATFORM=HEADLESS or TERMINAL).

Group 4 — the Windows set (2 identities)

Two renderers are hard-gated to Windows: configuring them anywhere else is a FATAL_ERROR, not a warning. They are the two Direct3D generations CNA drives natively. For 2D on Windows, the default SDL_RENDERER (Group 6) is the other choice.

ValueWhat it targets
DIRECTX9Direct3D 9. Compiles HLSL at runtime. Declared Production. The pixel-exactness renderer — see below.
DIRECTX11Native Direct3D 11 (feature level 11_0+). Compiles HLSL at runtime. Declared Production.
⚠

These have manual validation paths. CNA ships cross-build and Wine scripts for DXVK, but this snapshot has no automatic Wine workflow for the Direct3D renderers. One Windows MSVC workflow (DIRECTX11) exists as manual-dispatch only. CNA’s records also include native MSVC runs of DIRECTX11 on one physical Windows 11 laptop with an Intel Iris Xe GPU, outside CI. Treat native-hardware behaviour as outside CNA's continuous gates and verify it yourself before shipping. Tutorial 103 covers the set.

Pixel-exactness is a DIRECTX9 statement, and only that. CNA's XNA oracle corpus is 39 scenes (256×256, HiDef) with reference images captured from the genuine Microsoft XNA 4.0 runtime under Wine with DXVK on Linux. DIRECTX9, run through the same Wine+DXVK stack, is recorded in the repository as matching all 39 at tolerance 0; that run is not part of CI and has not been repeated on native Windows. No other renderer is held to that bar: EasyGL and SOFTWARE are gated on two line scenes only (the EasyGL test fails on any pixel difference; the SOFTWARE test only if a scene does not render), and FNA3D's corpus test only checks that scenes render. If bit-identical XNA 4.0 output is the goal, DIRECTX9 is the answer and nothing else is.

Group 5 — the browser (WebGL 2 and WebGPU)

Two identities run in the browser: WEBGL2 (the EasyGL profile of Group 1, Emscripten-only and the Emscripten default) and WEBGPU (Group 2), whose browser route is experimental. On the Emscripten side CI builds one WebAssembly bundle containing WEBGL2 and WEBGPU and checks that both renderers and the JavaScript selection surface are in it; it does not run the bundle.

In the browser the renderer can also be chosen at page load, without a rebuild, when several are compiled in: set Module.cnaPreferredRenderer (for example var Module = { cnaPreferredRenderer: "WEBGPU" };) before the runtime starts. It sits at the environment variable's precedence. Tutorial 105 covers the group.

⚠

Three web caveats apply to every browser build, WEBGL2 and WEBGPU alike. StorageDevice saves persist in the browser’s IndexedDB, which CNA mounts and restores before main() — except in a threaded build, which uses WasmFS by default and needs -DCNA_EMSCRIPTEN_USE_WASMFS=OFF for persistence. There is no video decoder: Video/VideoPlayer compile and link, but decoding throws NotSupportedException (the FFmpeg backend is never built for Emscripten). And Game::Run() blocks on the caller's stack through Asyncify, so external consumers must link CNA::EmscriptenAsyncify (automatic for CNA-owned app executables); the old “heap-allocate your Game” rule no longer applies. Tutorial 81 covers the web build.

Group 6 — the portable 2D renderer (1 identity)

It has no 3D pipeline at all. That is a design decision, not a gap: a 3D call raises a deterministic exception (or, if you opt in, a one-time warning and a no-op) rather than drawing nothing silently.

ValueWhat it isPlatform
SDL_RENDERERSDL3's built-in accelerated 2D renderer. The default outside Linux and Emscripten, and one of the two renderers allowed on iOS (with METAL). Declared Production.Wherever SDL3 runs

Group 7 — CPU-only and no-output (3 identities)

ValueWhat it is
SOFTWAREA genuine CPU rasteriser that owns its framebuffer. Off-screen by default — you read pixels back with GetBackBufferData() — except on CNA_PLATFORM=TERMINAL attached to a TTY, where finished frames are presented. It accepts a custom ShaderEffect but does not execute it — its own fixed CPU shading path renders instead. Compiled effects are an opt-in build option. Declared Experimental. See Tutorial 107.
HEADLESSNo GPU and no window. It validates arguments and counts or traces calls (CNA_HEADLESS_MODE) and records custom shader submissions rather than executing them. Built for fast CI of game logic; selected by GraphicsAdapter.UseNullDevice.
STUBA no-op renderer. No pixel output of any kind, all 19 capabilities false, no bookkeeping. The default of the dev and unit presets.

CNA_PLATFORM=TERMINAL accepts only these three; of them only SOFTWARE actually displays anything there.

Which renderers refuse 3D

One renderer is 2D-only and rejects the 3D pipeline: SDL_RENDERER. By default a 3D call throws a deterministic std::runtime_error; Unsupported3DGraphicsCallBehavior::WarnAndStub turns that into one warning per operation and a safe no-op. Two more — HEADLESS and STUB — produce no pixel output at all (STUB also reports 3D false), and SOFTWARE renders real pixels but does not present them to a window (except on a terminal).

Which renderers execute custom shaders

A custom ShaderEffect (see Tutorial 52) genuinely executes on the three GL profile identities, VULKAN, WEBGPU, DIRECTX9 and DIRECTX11, on SDL_GPU in builds that have libshaderc, and on METAL for SpriteBatch effects written in Metal Shading Language. HEADLESS records the submission without executing it.

Everywhere else you get one of three different outcomes, and the difference matters:

  • SOFTWARE accepts the effect and silently ignores the source. No error, no shader, no clue — this is the one to watch for.
  • METAL refuses a 3D draw with a custom effect (NotSupportedException); its custom effects are a SpriteBatch facility, and an uncompilable MSL source gives an invalid effect with the compiler’s message.
  • FNA3D has no custom-effect path (no effect renderer is created). FNA3D can still run compiled XNA effects; see below.

The shading language also varies: GLSL on the GL family, SPIR-V on VULKAN and SDL_GPU, HLSL compiled at runtime on DIRECTX9/DIRECTX11, WGSL on WEBGPU, and MSL on METAL. One source string is not portable across all of them.

Separately, compiled XNA effects (Direct3D 9 Effect Framework bytecode, .fxb) run on nine renderer families (11 identities): FNA3D always, and eight opt-in build options — CNA_EASYGL_COMPILED_EFFECTS (all three GL profiles), CNA_VULKAN_COMPILED_EFFECTS, CNA_WEBGPU_COMPILED_EFFECTS, CNA_SOFTWARE_COMPILED_EFFECTS, CNA_DIRECTX9_COMPILED_EFFECTS, CNA_DIRECTX11_COMPILED_EFFECTS, CNA_METAL_COMPILED_EFFECTS and CNA_SDL_GPU_COMPILED_EFFECTS. All eight default to OFF, so a default configure reports CompiledEffects false on 13 of the 14 identities.

SupportsCapability() fails open

⚠

GraphicsCapability now has 19 members (14 in alpha.1; the new ones are FloatRenderTargets, HalfFloatRenderTargets, HalfFloatTextureLinearFiltering, ComputeShaders and IndirectDraw). The renderer-interface default still returns true for the original members except stencil (delegated to the renderer), multi-stream input and compiled effects (false unless the renderer opts in), and, new, the two float render-target answers (false). Only DIRECTX9 supplies no override at all and relies on those inherited defaults wholesale; VULKAN, DIRECTX11, SDL_GPU and METAL now have switches with no permissive default arm, while WEBGPU, HEADLESS, SOFTWARE, the GL profiles and FNA3D mix explicit answers with a default: true. At the device level, six answers are derived from separate probes (compiled effects, float and half-float render targets, half-float linear filtering, compute, indirect draw) and multiple render targets is limited by the profile's MRT limit. Treat a true as "not known to be unsupported", not as a guarantee, and test the renderer you ship.

For a richer answer, ask the device for its renderer capability profile: 32 named features, 22 numeric limits, per-format usage for all 27 SurfaceFormat members and a readable report, each feature answering Unknown, Unsupported, Supported or Restricted. Tutorial 101 and Tutorial 133 show the calls.

One gap worth knowing before you design around it

Cube faces inside a multiple-render-target set are unimplemented on three otherwise-capable 3D renderers: DIRECTX9 and SDL_GPU throw "cube faces in a multi-target set are not implemented by this CNA renderer", WEBGPU refuses with its own message, and HEADLESS refuses the same combination. Plain 2D MRT works on the renderers that report multiple render targets; binding a RenderTargetCube face as one slot of an MRT set is the case to check on your renderer before you depend on it (the GL family, VULKAN, DIRECTX11, SOFTWARE and METAL contain code for it in this snapshot). See Tutorial 62.

Decision table

SituationChoose
Not sure / just startingOPENGLES3 — CNA's day-to-day development renderer and the Linux default
Pure 2D game, maximum portabilitySDL_RENDERER (needs only SDL3; the default off Linux and the web)
3D game on LinuxOPENGLES3, or VULKAN for explicit GPU control and the widest modern-feature surface
3D game on WindowsDIRECTX11 (Windows-only), VULKAN, or OPENGL33; note that the Windows default SDL_RENDERER is 2D-only
Bit-identical XNA 4.0 outputDIRECTX9 (Windows-only) — the only renderer recorded in the repository as matching all 39 oracle scenes
macOSMETAL (native; turn on CNA_METAL_COMPILED_EFFECTS for compiled XNA effects) — or the default SDL_RENDERER for 2D; OPENGL33, SDL_GPU, WEBGPU and FNA3D also passed CNA’s full tests on a Mac mini M4
Browser, 3D or 2DWEBGL2 (the Emscripten default). Read the web caveats above first.
Browser with WebGPUWEBGPU (experimental browser route) — Tutorial 105
CI without a GPU, game logic onlyHEADLESS (or STUB)
Deterministic, GPU-free pixel comparisonSOFTWARE (read back with GetBackBufferData())
Running an FNA-shaped compiled-effect workflowFNA3D (always-on compiled effects; no custom shaders) — Tutorial 108
Anything that needs a custom shaderNot FNA3D, SOFTWARE (silently ignored) or SDL_RENDERER; METAL runs custom MSL in SpriteBatch only and throws on a 3D custom-effect draw

Setting the renderer in CMake

# Configure and build with the Vulkan renderer
cmake -S . -B build-vulkan -DCNA_GRAPHICS_RENDERER=VULKAN
cmake --build build-vulkan --target CnaTests

# Equivalent, using the per-renderer option form
cmake -S . -B build-vulkan -DCNA_RENDERER_VULKAN=ON

# Windows-only renderers need a Windows toolchain (MSVC, or MinGW-w64 from Linux)
cmake -S . -B build-d3d11 \
      -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/mingw-w64.cmake \
      -DCNA_GRAPHICS_RENDERER=DIRECTX11

# Emscripten-only renderers refuse to configure for a native target
emcmake cmake -S . -B build-webgl2 -DCNA_GRAPHICS_RENDERER=WEBGL2
⚠

cmake --build ... --target CNA no longer works. CNA is an add_library(CNA INTERFACE) umbrella with no sources, so it is not a buildable target. Build CnaTests, a demo target, or simply cmake --build build.

CTest labels follow the renderer names too: ctest -L DIRECTX9 works, ctest -L D3D9 matches nothing.

Compile-time defines in game code

The selected (default) renderer emits a CNA_RENDERER_<NAME> preprocessor define project-wide. The three GL profiles are the one exception: they all emit CNA_RENDERER_EASYGL (the shared implementation identity) plus a CNA_GL_PROFILE_<NAME> define naming the profile. In a multi-renderer build only the default's macro is defined, and CNA_MULTI_RENDERER is added.

#if defined(CNA_RENDERER_VULKAN)
    // Vulkan-specific startup work
#elif defined(CNA_RENDERER_EASYGL)
    // Any of OPENGLES3 / OPENGL33 / WEBGL2.
    // Narrow it further when the profile actually matters:
    #if defined(CNA_GL_PROFILE_WEBGL2)
        // Browser build: no compute or indirect draw.
        useGpuParticles_ = false;
    #endif
#elif defined(CNA_RENDERER_DIRECTX11)
    // Windows-only native Direct3D path
#endif

Runtime selection in a multi-renderer build

GraphicsRendererSelection::SetPreferred() has highest precedence, followed by the CNA_GRAPHICS_RENDERER environment variable (in an Emscripten build, the page property Module.cnaPreferredRenderer at the same precedence) and finally the compiled default. Selection must happen before the first graphics device is successfully created, and then latches: a failed attempt does not latch, so a game may catch the error and retry with another configuration. Unknown or uncompiled identities throw. Fallback is opt-in; a failed preferred renderer is otherwise a hard failure. Two device flags also pick a renderer: GraphicsAdapter.UseNullDevice requires HEADLESS and UseReferenceDevice requires SOFTWARE.

Not every list is legal: CMake rejects identities from different platform partitions (Windows-only, Emscripten-only, Apple-only), and CNA_PLATFORM=TERMINAL accepts only SOFTWARE, HEADLESS and STUB. Several EasyGL identities may coexist within one partition. Multi-renderer builds also carry every selected dependency and increase build and binary size. See Tutorial 126 and Runtime Renderer Selection.

What CI actually covers

This snapshot contains 18 workflow files. Automatic (push/PR) lanes cover OPENGLES3, VULKAN, SOFTWARE, HEADLESS and STUB on Linux (the SDL3 platform on private X11 and Wayland displays, plus windowless HEADLESS and TERMINAL cells), a HEADLESS;SOFTWARE;STUB multi-renderer build, SDL_RENDERER on macOS, SDL_RENDERER and METAL for iOS device and simulator, METAL on macOS, and a WebAssembly bundle build of WEBGL2;WEBGPU; the Windows Direct3D lane is manual-dispatch only. No workflow names SDL_GPU, FNA3D or DIRECTX9, there is no Android job, and no green run of the Apple workflows after their October 2026 changes is recorded. CNA’s broadest macOS evidence is local instead: every applicable renderer tree passed on a physical Mac mini M4. That is meaningful scoped evidence, not coverage of every one of the 14 identities, every valid combination, physical iOS hardware, or the full GPU pixel/oracle matrix.