Headless renderer 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. Read from source, test sources and CMake registrations at the TARGET snapshot; no test was executed. Not every shared fixture branch and no concurrent use of the modes were audited; HEADLESS is a no-pixel oracle by design.

The HEADLESS graphics identity runs CNA's real public Game and GraphicsDevice paths without a native window or GPU. Its private renderer in modules/renderers/headless checks arguments and state, records resource lifetimes and calls, and deliberately produces no pixels. A passing HEADLESS test can establish control flow, lifetime bookkeeping and rejection behaviour; it cannot establish shader output, blending, rasterisation, swapchain or native-driver behaviour. This page is for the maintainer who uses it as a state and lifetime oracle, or who has to change it without turning it into an image oracle it is not.

Three different meanings of “headless”

The word names three separate mechanisms, and the platform-versus-renderer distinction is the one that is easiest to blur, because HEADLESS is a value of both CNA_PLATFORM and CNA_GRAPHICS_RENDERER:

  • The HEADLESS platform (CNA_PLATFORM=HEADLESS) implements platform services with an in-memory window and event model; see headless platform internals, which carries the matching three-way table. It says nothing about drawing.
  • The HEADLESS renderer (CNA_GRAPHICS_RENDERER=HEADLESS, this page) is a separate IGraphicsRenderer implementation. HeadlessRendererDescriptor.cpp declares RendererWindowKind::None, needsWindow=false, needsVideoSubsystem=false and AlwaysAvailable, and leaves needsSurfacePresenter, needsGlContext and needsVulkanSurface at their false defaults, so GraphicsDevice::createOrAttachWindow resets the platform window and returns before it asks the platform for anything: not even a TERMINAL platform with surface presentation gives this renderer a presenter. The factory then creates HeadlessRenderer from the virtual dimensions. GraphicsAdapter::UseNullDevice requires exactly this renderer (GraphicsAdapter::GetRequiredRendererEXT maps the null device type to Headless and the reference type to Software), and GraphicsDevice refuses the device when the renderer is not compiled in or the selection is already latched to another (device flags).
  • A windowless device mode of other renderers: PresentationParameters::HeadlessEXT, or the CNA_FORCE_HEADLESS_DEVICE_EXT renderer list, asks a renderer that normally wants a window (SDL_GPU, Direct3D 12) to render into its own off-screen back buffer. That mode renders real pixels and is unrelated to this renderer; a renderer that cannot run without a swap chain refuses by name from its own constructor. See SDL_gpu setup.

Platform and renderer are independent build axes: selecting one does not select the other, and nothing at configure time pairs them: a context-backed renderer handed the HEADLESS platform is refused at run time, as the platform page describes. The STUB renderer has the same descriptor properties as HEADLESS but intentionally almost no validation or bookkeeping. Follow the descriptor fields and GraphicsDevice construction before assuming an enum alone explains the running implementation.

Game / GraphicsDevice public checks and state
  -> IGraphicsRenderer descriptor: no native window / video requirement
  -> HeadlessRenderer: validate + record, never rasterise
       -> shared HeadlessSharedState (shared_ptr)
          -> mode, resource registry, statistics, optional trace log, debug-label stack
       -> per-resource Headless*Renderer: shadow data or state only
  -> no GPU, swapchain, native surface or presenter

Two build facts follow from the design. The target is a plain cna_add_renderer() with no native SDK (the family CMakeLists.txt). And HEADLESS is the default renderer of the multi-renderer configure preset in CMakePresets.json (HEADLESS;SOFTWARE;STUB, chosen at run time through CNA::GraphicsRendererSelection), which is the preset in which HEADLESS shares one binary with SOFTWARE and STUB and the private tests below are registered next to them.

Strictness, diagnostics and their limits

HeadlessRenderer.hpp defines the dial. Each HeadlessRenderer constructs one HeadlessSharedState and reads CNA_HEADLESS_MODE through ParseHeadlessModeFromEnvironment: Fast, Validation or Trace, case-insensitive; unset or unknown means Validation. SetMode changes the shared mode for every subsequent call (the enum comment says “before Game::Run()”, the method comment “before or during a run”; the code simply stores the value).

ModeArgument checks (Require)Extra recording
Fastskippedcounters only
Validation (default)throw HeadlessValidationException naming the violated rulecounters
Traceas Validationstructured call log (HeadlessTraceEntry: call index, frame index, method, argument summary) and creation-site labels from PushDebugLabel/PopDebugLabel

The dial gates only the Require helper. Some refusals are plain throws and hold in every mode: mixing a cube face into a multi-target SetRenderTargets throws std::runtime_error, and ReadBackbuffer always throws System::NotSupportedException. No mode changes the fundamental absence of rasterisation, and the public neutral layer still performs its own checks regardless of this private dial. What the checks cover in the validating modes:

  • Buffers: a vertex or index count above the declared capacity, a non-positive stride, and (for index buffers) uploading 16-bit data into a 32-bit buffer or the reverse.
  • State: a negative scissor or viewport size or origin, a rectangle that exceeds the size of the currently bound target (the bound render target's real size, otherwise the virtual dimensions), minDepth above maxDepth, and a sampler slot outside 0 to 15.
  • Draws: primitiveCount above zero and no more vertices or indices than the bound buffers hold; for the extended draw calls also TextureEnabled without a first texture (except for stock alpha-test, skinned and PBR draws, which may legitimately carry none), DualTexture without a second texture, EnvMapping without an environment map, and a skinned draw with no bones; an instanced draw needs instanceCount above zero.
  • Other objects: a SpriteBatch Begin without a matching End (and the reverse), a cube face outside 0 to 5, and an effect compile with both source strings empty. SpriteBatch::Draw itself does not check that the batch was begun.

The resource registry and statistics

HeadlessResourceRegistry gives each resource renderer a monotonic debug id: constructors register, destructors unregister, and AliveResources() and AssertNoLeaks() (which throws HeadlessValidationException listing every live record) expose the set. The type names it records are VertexBuffer, IndexBuffer, Texture2D, RenderTarget2D, RenderTargetCube, TextureCube, Texture3D, Effect, SpriteBatch and OcclusionQuery; each has a matching ...Created counter in HeadlessStatistics, next to the draw, primitive, clear and present counters and the blend, depth-stencil, rasteriser, sampler, viewport and scissor change counters. The shared_ptr<HeadlessSharedState> lets a resource renderer keep its diagnostic state independently of the parent renderer; it does not make the graphics API thread-safe. Only the registry map has a mutex; statistics and the trace log do not.

The smoke test, headless_smoke_test.cpp, is the worked example. It runs a real Game on a 64×64 back buffer and, on the third frame, asserts (Check C) exactly six draw calls (three frames of one DrawPrimitives plus one SpriteBatch draw), three clears, presentCount of two (frame three's own automatic EndDraw present has not happened yet inside its Draw) and three vertex buffers created. It then takes a baseline alive count, creates an extra VertexBuffer, checks that AssertNoLeaks() throws, disposes it and compares the alive count with the baseline, not with zero, because long-lived resources legitimately exist during a frame. Finally it shows the dial changing behaviour: an indexed draw needing 30 indices from a 3-index buffer throws in Validation and is accepted in Fast. For a teardown leak, assert only after normal owner cleanup; otherwise the diagnostic reports expected live resources.

Clear updates the last clear colour and a counter (ClearDepth, ClearStencil and their combinations count and trace but keep no colour). Present, and PresentRegionEXT, which HEADLESS accepts unconditionally because there is no frame to show part of, increment the present counter, copy the cumulative statistics into statsAtLastPresent, advance the frame index and trace; the region variant also records the clipped source and destination rectangles and any override window. GetLastFrameStatistics() subtracts that snapshot from the current cumulative counters, so it measures work since the last Present (the frame in progress), not a frozen report of the last finished frame; Headless_CoverageGaps checks zero at the start of a frame and exactly two draw calls later in the same frame. The header's own comment describes it as the statistics “for the frame ending at the most recent Present()”, which the code and that test contradict; trust the code. An instanced draw counts instanceCount draw calls but its primitiveCount only once. CompareTraceLogs compares frame index, method and argument summary (not the call index) and reports the first diverging position, or the shorter log's length when one is a strict prefix; FormatTraceLogDiff renders it and FormatTraceLog / DumpTraceLog print one [frame N #index] method: args line per call. It detects deterministic call-sequence drift; it cannot tell whether two backends shaded the same fragment. The user-facing workflow is in Tutorial 107.

Resource and draw path

In HeadlessRenderer.cpp the vertex and index implementations keep byte shadows and counts (ShadowData()), and uploads validate capacity and element type in the validating modes. The 2D texture keeps the uploaded RGBA bytes (Pixels()) for level 0, so a test can assert upload state; UpdatePixelsLevel only validates and traces. Cube and 3D texture writes validate and trace, then return false rather than claim stored content, and the public TextureCube::SetData turns that false into a System::NotSupportedException (“did not store the complete requested cube face region”), so a content path that uploads a cube on this renderer refuses deterministically instead of silently discarding data. Texture3D is reported unsupported so the public constructor fails cleanly. Render-target objects track dimensions, binding and lifetime, but there is no rendered colour to read: HeadlessRenderTargetRenderer::GetData, the cube GetData and the volume GetData all return false (so the shared layer raises NotSupportedException where it has no CPU shadow to answer from, leaving the caller's destination untouched), and ReadBackbuffer throws System::NotSupportedException after GraphicsDevice::GetBackBufferData has finished its own argument validation (null destination, rectangle bounds, element count, format) and before any byte of the caller's buffer is written. These refusals are deliberate: returning zeros or the last clear colour would fabricate an image, which is what the earlier implementation did. A plain Texture2D::GetData is different: with no renderer readback it is answered from the public layer's own CPU shadow, which is why HEADLESS is still useful for texture upload tests.

SetRenderTargets unbinds the previous set, binds 2D targets as a set and throws when a cube face is mixed with other targets. GetViewportSize uses the first bound target's dimensions, otherwise the virtual dimensions, falling back to 1024×768. The binding path checks some state transitions, but cannot validate attachment layout, MSAA resolve or GPU barriers. Draw methods validate as listed above, then increment counters and optionally trace; they never execute vertex or fragment programs, and the effect renderer accepts any non-empty source without compiling it (IsValid() becomes true, GetCompileError() is empty) while recording each uniform's last value in UniformValues() so a test can assert “the game set this uniform”. SpriteBatch draws are recorded in LastBatch() until the next Begin. An OcclusionQuery always reports one visible pixel, a conservative control-flow answer rather than measured coverage. SupportsCapability answers false for Texture3D, AdditiveBlending and MultiStreamVertexInput and true for every other member, ThreeD included; that simulates API reachability, not hardware capability (the user-level table is in the capability matrix). Read the exact branch before using any capability as a test gate.

What a passing test proves

examples/CMakeLists.txt registers tests only when CNA_BUILD_TESTS is on and HEADLESS is the build's default renderer: the guard compares CNA_GRAPHICS_RENDERER with _cna_default_renderer_identity because the family loop in modules/renderers/CMakeLists.txt re-points CNA_GRAPHICS_RENDERER while entering each family, and a multi-renderer build with HEADLESS as a mere member once produced targets that could not even find this renderer's private header. The file registers 49 Headless_* tests: seven private ones and 42 shared graphics-contract fixtures. The executables link SDL3 and define CNA_HEADLESS_TEST_HAS_SDL only where a target exists, and the smoke test's SDL check (that SDL_INIT_VIDEO was never initialised) exists only under that definition, which is why it expects 10 checks with SDL and 9 without.

  • Private tests: Headless_Smoke, Headless_ResourceRenderers, Headless_ValidationExtras, Headless_CoverageGaps, Headless_Effects, Headless_ModeDial and Headless_TraceDiff. Between them they cover: the per-resource renderers (cube, 3D, render-target and effect creation, the exact creation counters, a rejected Texture3D, the refused cube upload, a viewport that follows the bound target); the mode dial gating each rule (vertex and index capacity, a dual-texture draw without its second texture, a negative scissor origin, a sampler slot of 16) in Validation versus Fast; viewport bounds against the bound target's real size; debug-label creation sites appearing in a leak report; trace coverage of the state-change and Clear variants; GetLastFrameStatistics()'s diff arithmetic; the CNA_HEADLESS_MODE parsing cases (Fast, mixed-case Trace and Validation, an unrecognised value, unset); and end-to-end draws of the stock effects and a procedural Model (dual-texture and environment-map effects must have their extra texture, alpha-test and skinned effects need none).
  • Shared graphics-contract fixtures registered as Headless_* (sampler contracts, render-target producer/consumer and lifetime, winding, GetData contracts, ordered clear, deferred viewport and scissor, back-buffer readback). For several, the CMake file states that the pixel oracle is a declared boundary on this renderer: the test must observe an honest readback refusal or exercise the shared validation route, not the colour a real backend would produce.
  • Four SDL-harness controls (Headless_MsaaDepthContract, Headless_MsaaFirstReadback, Headless_MsaaMipReadback, Headless_InvalidMipLevel) are skipped, with a status message, when the configuration has no SDL3 target. Headless_XnaPixelCenter is registered with SDL_VIDEODRIVER=x11 and DISPLAY in its environment, an exception to the file header's statement that this renderer's tests set neither; the file gives no reason, so what that fixture needs from a display was not established.

Inspect the fixture branch before citing a HEADLESS pass as cross-renderer parity, and read the configure output and ctest -N. For a resource-misuse investigation start with Headless_ResourceRenderers, Headless_ValidationExtras or Headless_Smoke, select Validation or Trace, and call AssertNoLeaks only at a known-clean point. For draw-order drift compare Trace logs of the same deterministic workload. For a pixel mismatch move to Software for CPU pixels and to a real GPU renderer for API and driver behaviour. For presentation or resize on a real host, a HEADLESS pass says nothing about native surface integration. The automatic multi-renderer workflow (multi-renderer-ci.yml) configures the three-renderer set with tests on and builds CnaTests, the renderer-selection demo and the descriptor gate; the family example executables above are not among the targets it names, so its result is not evidence for the Headless_* registrations. What to test after changing X makes the evidence levels explicit; no test was executed for this page.

Human change procedure and source route

  1. Read the descriptor and the target (cna_add_renderer(), no native SDK): the no-window promise and the configuration.
  2. Read HeadlessRenderer.hpp from HeadlessSharedState through the affected resource type: which state is stored and which operations are intentionally unreal.
  3. Follow constructors and destructors, Require, draw/bind/Present and AssertNoLeaks in HeadlessRenderer.cpp. Write down the expected change to registry, counters, trace and error behaviour before editing.
  4. Read headless_smoke_test.cpp and the focused resource, mode or trace test, confirm the registered names in examples/CMakeLists.txt, and add an assertion that fails before the intended change.
  5. If the change began in GraphicsDevice or IGraphicsRenderer, run the neutral graphics tests plus another physical renderer: HEADLESS can reveal validation and lifetime mistakes but cannot certify native translation or pixels.
⚠

Do not "fix" a failing pixel fixture by inventing readback values in this backend. A renderer with no rasterisation must keep refusing data it cannot produce. Some source comments still describe old design plans or contradict the code (the statistics comment above, an older SetMode note); the current code and tests, not plan prose, establish the contract described here.

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