Tutorial 107: CPU-Only Renderers: Software, Headless and Stub

CNA Tutorials  ·  Renderers

ℹ

What you’ll learn

  • How to verify rendering in CI with no GPU, no display server and no Xvfb.
  • Why nothing is presented to a window on any of these renderers (with one deliberate exception, SOFTWARE on a terminal), and where the pixels actually come from.
  • What each of SOFTWARE, HEADLESS and STUB genuinely proves — and what none of them proves.
  • A render-target trap these renderers exposed, and why it argues for honest capability reporting.

Before you start — Tutorial 99: Unit Testing CNA Game Logic covers the GoogleTest side of CNA testing. This tutorial is about the other half: getting a real frame out of a machine that has no graphics stack.

Three of CNA's 14 renderers are built to run where there is no graphics stack at all: SOFTWARE, HEADLESS and STUB. None of them needs a window (their descriptors ask for none), none of them needs the platform's video subsystem, and none of them links a GPU library. A build selecting any of them needs nothing beyond what every CNA build already needs (the sibling sharp-runtime checkout and the platform layer).

They are not three flavours of the same thing. One of them rasterises real pixels you can assert on; one records what your code did without drawing anything; one does nothing whatsoever, on purpose. Picking the wrong one is the main way this group disappoints people, so this tutorial spends most of its length on the differences.

The three at a glance

RendererWhat it doesPixels you can read back
SOFTWARE A hand-written CPU rasteriser — edge functions, perspective-correct interpolation, a real per-pixel depth test — writing into an RGBA8 framebuffer CNA owns. Yes, real ones.
HEADLESS Validates arguments, counts calls, tracks resource lifetimes and (optionally) logs every call. Rasterises nothing. No — readback throws rather than fabricating a frame.
STUB Nothing. Every method is a no-op or returns a fixed value. No counters, no validation, no framebuffer. No — readback throws.

Nobody presents; you read the back buffer instead

On the windowing platform (SDL3, on any of its video drivers) these renderers stay off-screen: there is no window to swap into, so a CNA game running on any of them draws frame after frame that nothing displays. The frame is not lost on the rasterising renderer, SOFTWARE — it is sitting in a CPU buffer, and the way you get at it is XNA's own back-buffer readback. (The one deliberate exception is SOFTWARE on CNA_PLATFORM=TERMINAL; see below.)

#include "Microsoft/Xna/Framework/Color.hpp"
#include "Microsoft/Xna/Framework/Rectangle.hpp"
#include "Microsoft/Xna/Framework/Graphics/GraphicsDevice.hpp"

// Read a 4x4 patch out of the finished frame and assert on it.
auto& device = getGraphicsDeviceProperty();
device.Clear(Color(20, 40, 60, 255));

const Rectangle region(0, 0, 4, 4);
std::vector<Color> pixels(4 * 4, Color(0, 0, 0, 0));
device.GetBackBufferData(&region, pixels.data(), 0, static_cast<int>(pixels.size()));

for (const Color& p : pixels)
{
    assert(p.getRProperty() == 20 && p.getGProperty() == 40
        && p.getBProperty() == 60 && p.getAProperty() == 255);
}

Three overloads exist — whole buffer, whole buffer at an offset, and the rectangle form above. Rows come back top-first.

ⓘ

Readback refuses rather than lies. On HEADLESS and STUB, GetBackBufferData() raises instead of returning something. That is deliberate: HEADLESS used to fill the caller's buffer with the last Clear() colour, which a test could not tell apart from a genuine black frame. A test that silently passes against a fabricated frame is worse than no test.

SOFTWARE: real pixels, deliberately narrow

cmake -S . -B build-software \
  -DCNA_GRAPHICS_RENDERER=SOFTWARE \
  -DCMAKE_BUILD_TYPE=Debug \
  -DCNA_BUILD_TESTS=ON
cmake --build build-software --parallel

This is the one to reach for when you want to prove “this triangle, with this effect state, this texture and this blend mode, produces these exact pixels”. The rasteriser is independently written, so comparing its output against a GPU renderer's is a genuine cross-check rather than two views of the same code.

It is explicitly not a gameplay renderer: there is no SIMD, no multithreading, no tiling or binning. Correctness and determinism are the goals. CNA declares SOFTWARE Experimental (its own classification, not a measurement), and it has been substantially extended since alpha.1, so treat older descriptions of it as out of date.

Its scope is bounded, and it fails loudly at the edges rather than misrendering:

BoundaryBehaviour
PrimitiveTypeTriangle lists and strips (strips keep XNA's alternating winding, indexed or not), line lists and strips, and PointListEXT all rasterise on the CPU.
Vertex inputMulti-stream input is real: the vertex reader resolves each attribute to the stream that owns it, with that stream's own stride and binding offset. A buffer with no VertexDeclaration is read as one of the recognised stock layouts inferred from its stride; any other stride throws “unsupported vertex stride”.
Lighting and fogImplemented for the classic stock effects: CNA's own status page records FNA's three-light model for BasicEffect, EnvironmentMapEffect and SkinnedEffect (default lighting, per-vertex and PreferPerPixelLighting paths) and view-space fog. Older notes that call lighting “out of scope” describe alpha.1.
Stock effectsBasicEffect, AlphaTestEffect, DualTextureEffect, EnvironmentMapEffect and SkinnedEffect; SkinnedEffect does real per-vertex bone blending.
Multiple render targetsUp to four ordered colour attachments bind at once; the first owns depth/stencil, and the classic stock effects write COLOR0 only.
MSAAExactly one mode: 4× (a standard four-sample pattern). A request of 4 or more applies 4× (the source comment: XNA asks for the greatest supported count no larger than its preference, so an 8 clamps to 4), and a request below 4 applies as none — the applied count is what gets reported back, not the one you asked for.
Float and half-float targetsReal: the classic float and half render-target layouts keep their values through draw, blend, resolve and readback, and half-float texture linear filtering is reported true.
Volume textures and queriesTexture3D has real RGBA8 CPU storage (the flag promises storage, not sampling by stock effects), and occlusion queries count the samples that survive coverage, alpha, depth and stencil.
Compute and indirect drawNot implemented; both capabilities report false.

What sits inside that boundary is more complete than the list above might suggest. Blend state is retained in full — every Blend factor and BlendFunction ordinal, with an out-of-contract value rejected by name at state-application time rather than halfway through a draw. All nine TextureFilter ordinals are honoured, split correctly into their minification, magnification and mip components; Wrap, Clamp and Mirror are all real; and mip level selection is real, with the footprint computed once per triangle. Backface culling follows RasterizerState.CullMode in both directions, a triangle crossing the near plane is genuinely clipped rather than discarded, and the viewport and scissor rectangle both apply.

Its capability answers are authored rather than inherited, which makes them worth trusting, and they have moved a long way since alpha.1: Texture3D, Instancing, OcclusionQuery, MultiStreamVertexInput, MultipleRenderTargets and the float/half render-target capabilities are now real true answers, while CustomEffects stays a deliberate false (see the next callout) and ComputeShaders/IndirectDraw are false. Tutorial 101 explains why the negatives carry more information than the positives.

⚠

Your GLSL does not run here. SOFTWARE's effect renderer accepts a shader source string and reports success without compiling it — the CPU rasteriser's own fixed shading path is what produces the pixels. A ShaderEffect written for a GPU renderer will therefore render, but not as you wrote it. Only effects whose parameter output matches the fixed BasicEffect-subset path give a meaningful image. See Tutorial 52 for where custom shaders genuinely execute.

What SOFTWARE can execute, when you opt in, is XNA's own compiled Effect Framework bytecode. Configure with -DCNA_SOFTWARE_COMPILED_EFFECTS=ON (default OFF) and the renderer reports GraphicsCapability::CompiledEffects as true, running both shader-model 1–3 stages on the CPU through the MojoShader-based translation shared with the other compiled-effect renderers. That build pulls in the pinned FNA3D checkout for MojoShader, so it is no longer dependency-free. See Tutorial 128.

How close is it to real XNA?

The 39-scene XNA oracle corpus (reference PNGs captured from the real XNA 4.0 runtime under Wine with DXVK) renders on SOFTWARE without display or GPU through scripts/run-oracle-corpus-diff-software.sh. CNA's dated measurement (2026-09-11) is that all 39 scenes render, 18 are byte-exact and 30 match within one channel value; the remaining differences in the nine point-texture-boundary scenes are attributed to interpolation precision. The only oracle test registered as a CTest is Software_XnaLineCoverage: it renders two line scenes and diffs them at tolerance 0, but its script fails the test only if a scene does not render or has no reference image, and a pixel difference is printed, not failed (read from the script, not executed). That is a measurement, not a claim of pixel-exactness: the only renderer recorded in the repository as matching all 39 at tolerance 0 is DIRECTX9 (see Tutorial 103), and none of this runs in CI.

A complete Software pixel test

A test is an ordinary Game subclass. The only thing that makes it a Software test is the renderer chosen at configure time:

#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/BasicEffect.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexBuffer.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexPositionColor.hpp"

class TrianglePixelTest : public Game
{
    std::unique_ptr<GraphicsDeviceManager> gdm_;
    int result_ = 1;

protected:
    void Draw(const GameTime&) override
    {
        auto& device = getGraphicsDeviceProperty();
        device.Clear(Color::Black);

        BasicEffect fx(device);
        fx.VertexColorEnabled = true;   // XNA's real default is false -- opt in explicitly
        fx.Apply();

        device.SetVertexBuffer(&vertexBuffer_);
        device.DrawPrimitives(PrimitiveType::TriangleList, 0, 1);
        device.SetVertexBuffer(nullptr);

        // Real, correct pixels. No GPU, no window, no display server.
        const Rectangle centre(32, 32, 1, 1);
        Color pixel(0, 0, 0, 0);
        device.GetBackBufferData(&centre, &pixel, 0, 1);

        result_ = (pixel.getRProperty() == 255) ? 0 : 1;
        Exit();
    }

public:
    TrianglePixelTest()
    {
        gdm_ = std::make_unique<GraphicsDeviceManager>(this);
        gdm_->setPreferredBackBufferWidthProperty(64);
        gdm_->setPreferredBackBufferHeightProperty(64);
    }

    int getResult() const { return result_; }
};

int main()
{
    TrianglePixelTest game;
    game.Run();
    return game.getResult();   // 0 = pass, non-zero = fail
}

Note the VertexColorEnabled line. XNA's real default is false, and CNA reproduces that faithfully — a plain BasicEffect ignores vertex colours entirely. It is the single most common reason a first Software test reads back the clear colour and looks broken.

HEADLESS: what your code did, not what it looked like

cmake -S . -B build-headless \
  -DCNA_GRAPHICS_RENDERER=HEADLESS \
  -DCMAKE_BUILD_TYPE=Debug \
  -DCNA_BUILD_TESTS=ON
cmake --build build-headless --parallel

HEADLESS implements the entire renderer interface without touching a GPU or a window, and every call does real bookkeeping instead of real drawing. What it proves is “this code path ran, with these arguments, this many times, and nothing leaked or misused an API”. What it proves about pixels is nothing at all.

Unlike the others it has a runtime strictness dial rather than three separate builds:

ModeBehaviour
FastCounters only. Skips all argument and bounds validation. For runs that just need the game loop to execute quickly.
Validation (default)Full argument validation on top of the counters — throws HeadlessValidationException for the same misuse a real renderer would reject.
TraceEverything Validation does, plus a structured in-memory call log with method name, argument summary and frame index.

Select it with the CNA_HEADLESS_MODE environment variable (Fast, Validation, Trace; an unrecognised value falls back to Validation), or set it in code before Run():

#include "CNA/Internal/Renderers/Headless/HeadlessRenderer.hpp"

using namespace CNA::Internal::Renderers::Headless;

void MyHeadlessTest::Draw(const GameTime&)
{
    auto& device   = getGraphicsDeviceProperty();
    auto& renderer = static_cast<HeadlessRenderer&>(device.GetRenderer());

    // ... exercise real game code: VertexBuffer, SpriteBatch, Effects, RenderTargets ...

    const HeadlessStatistics& stats = renderer.GetStatistics();
    assert(stats.drawCallCount == 3);
    assert(stats.vertexBuffersCreated == 1);

    // Throws HeadlessValidationException listing anything still alive.
    renderer.AssertNoLeaks();

    Exit();
}

The statistics struct carries cumulative draw-call, primitive, clear, present and state-change counters plus a per-resource-type creation count; GetLastFrameStatistics() gives the same figures accumulated since the most recent Present() (the frame in progress, so it reads zero straight after a Present()). PushDebugLabel()/PopDebugLabel() wrap a block of resource creation so a leak report names the block that created it. All of this is CNAEXT — it is not part of the XNA surface, so keep it out of any translation unit you compile with CNA_STRICT_XNA_API.

Diffing two runs instead of two images

In Trace mode the call log becomes a regression signal that needs no pixels. Capture a log from a known-good build and one from your branch, and compare them entry by entry:

const std::vector<HeadlessTraceEntry>& baseline = rendererA.TraceLog();
const std::vector<HeadlessTraceEntry>& current  = rendererB.TraceLog();

const HeadlessTraceLogDiff diff = CompareTraceLogs(baseline, current);
if (!diff.identical)
    std::cerr << FormatTraceLogDiff(baseline, current);

Entries are compared on frame index, method name and argument summary. The output is either a one-line “identical” summary or the first diverging pair side by side. For a deterministic game this catches behavioural drift — a state change that stopped happening, a draw that moved between frames — that a pixel diff would either miss or drown in noise.

⚠

HEADLESS reports ThreeD as true and renders nothing. That is not a bug: the 3D call paths really do run, validate and count. But a capability is not a pixel. If a test needs to know what the frame looks like, this is the wrong renderer — use SOFTWARE.

Asking for HEADLESS or SOFTWARE from the XNA flags

XNA's two device-type flags map onto these renderers. GraphicsAdapter::setUseNullDeviceProperty(true) requests the HEADLESS renderer and GraphicsAdapter::setUseReferenceDeviceProperty(true) requests SOFTWARE; the null flag wins when both are set. Both are process-wide and must be set before the first GraphicsDevice is constructed:

#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/GraphicsAdapter.hpp"

using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;

int main()
{
    // Before the first device: ask for "draw nothing" (HEADLESS)...
    GraphicsAdapter::setUseNullDeviceProperty(true);
    // ...or, alternatively, for a CPU reference device (SOFTWARE).
    // GraphicsAdapter::setUseReferenceDeviceProperty(true);

    MyLogicTest game;   // an ordinary Game subclass
    game.Run();
}

Neither flag substitutes another renderer. If the required renderer is not compiled into the build, or the selection has already latched to a different renderer, device creation fails with NoSuitableGraphicsDeviceException, and a fallback chain is not allowed to satisfy the flag with something else. So these flags belong in a multi-renderer build such as -DCNA_GRAPHICS_RENDERERS="HEADLESS;SOFTWARE;STUB" (see Tutorial 126). A related environment list, CNA_FORCE_HEADLESS_DEVICE_EXT, names renderers whose device should be created as if PresentationParameters.HeadlessEXT were set (no window, no video subsystem, an off-screen back buffer); the renderer-neutral example corpus uses it to run window-bound renderers without a display.

Showing SOFTWARE frames in a terminal

The rule that these renderers stay off-screen has one deliberate exception. On the POSIX-only TERMINAL platform attached to a real TTY, SOFTWARE hands each finished RGBA8 frame to the platform's surface presenter, so a CNA game is drawn into the terminal:

cmake -S . -B build-terminal \
  -DCNA_PLATFORM=TERMINAL \
  -DCNA_GRAPHICS_RENDERER=SOFTWARE \
  -DCNA_AUDIO_PLATFORM=NULL
cmake --build build-terminal --parallel

TERMINAL accepts only the three renderers of this tutorial — SOFTWARE, HEADLESS and STUB — and any GPU renderer is a configure-time FATAL_ERROR (“Choose a CPU renderer”). Of the three, only SOFTWARE actually displays anything. CI exercises the pairing with a pseudo-TTY test of the 2D demo.

STUB: the deliberate nothing

STUB is the smallest complete renderer implementation in the project. Every method either does nothing or returns a fixed value, with no bookkeeping of any kind — no counters, no validation, no trace log, no resource registry. Three uses justify it:

  • A minimal reference implementation. Reading it end to end shows exactly which renderer members are mandatory and which already have a usable shared default, without wading through HEADLESS's validation modes or SOFTWARE's rasteriser maths. If you are writing a renderer, start here — see Tutorial 87.
  • The fastest “does the game loop even run” check. Nothing to maintain, nothing to get subtly wrong.
  • A dependency-free placeholder for build configurations that need some renderer to link.

It is also the only renderer that reports false for every capability. That makes it the honest end of the spectrum and a useful canary: code that behaves correctly under STUB is code whose capability checks are real rather than decorative.

It is named Stub rather than Null for a concrete reason — NULL is a macro, so a CNA_GRAPHICS_RENDERER=NULL value risks silent substitution in a preprocessor comparison.

Putting it in CI

This is where the group earns its keep. A container with no GPU, no DISPLAY and no Xvfb can build and run any of these renderers directly. The simplest test matrix still uses separate single-renderer build directories. This snapshot also supports an opt-in multi-renderer build, but its selection occurs before device creation and its compiled dependencies remain part of that one binary:

jobs:
  cpu-renderers:
    runs-on: ubuntu-24.04
    strategy:
      fail-fast: false
      matrix:
        renderer: [SOFTWARE, HEADLESS, STUB]
    steps:
      - uses: actions/checkout@v4
        with: { submodules: true }

      - name: Configure
        run: >
          cmake -S . -B build-${{ matrix.renderer }}
          -DCNA_GRAPHICS_RENDERER=${{ matrix.renderer }}
          -DCNA_BUILD_TESTS=ON
          -DCMAKE_BUILD_TYPE=Debug

      - name: Build
        run: cmake --build build-${{ matrix.renderer }} --parallel

      - name: Test
        # No Xvfb, no DISPLAY, no GPU driver.
        run: ctest --test-dir build-${{ matrix.renderer }} --output-on-failure

Each family registers its example tests under its own CTest label, so you can narrow a run: -L Software, -L Headless, -L Stub. Every one of those tests is a real Game that draws through the ordinary public XNA API; the rasterising SOFTWARE family reads the back buffer back rather than merely asserting that a call returned.

Two practical notes. Build only what you need — these are cheap configurations, but three of them are still three compiles. And a SOFTWARE job is the one that gives you image-level regression protection without a GPU, so if you can only afford one, make it that one.

Cross-checking a GPU renderer against the CPU one

Because SOFTWARE's rasteriser is independently written, dumping the same scene from a GPU renderer and from SOFTWARE and diffing the two localises a rendering bug: agreement points at shared code or at expected-but-undocumented behaviour, disagreement points at one specific renderer. CNA ships a renderer-agnostic diagnostic scene and a standalone comparator for exactly this. It is not a single automated test — the renderer is a compile-time choice, so comparing two means two builds and two dumps.

The render-target trap these renderers exposed

⚠

Binding a render target on a renderer that creates none used to succeed silently, and every subsequent draw landed in the real back buffer. This has been fixed — binding one now raises System::NotSupportedException — but it is worth understanding, because the shape of the bug recurs.

A renderer that keeps the shared nullptr default for render-target creation — STUB among them — produces a RenderTarget2D whose underlying resource is null. Construction is deliberately allowed to succeed, because an object that is never bound or sampled should not eagerly refuse. The single-target bind path, however, collapsed that null to the same null the “unbind” call passes. The renderer read it as an ordinary restore-the-back-buffer request, while the shared layer went on recording the target as bound and resizing the viewport to match it. Nothing threw. Your off-screen pass just quietly drew on screen.

The fix routes the single-target overload through the same multi-target path, which already checked for a null resource. Two lessons generalise:

  • Guard with the capability query, not with a try/catch you never wrote. These three renderers make it obvious how much of an API surface is optional. Tutorial 101 covers the query and its failure mode.
  • A renderer that reports its gaps honestly is what makes the shared layer able to refuse. STUB's blanket false and SOFTWARE's authored CustomEffects negative are the mechanism, not documentation about it.

Choosing between the three

  • You need to assert on an image. SOFTWARE — as long as your scene stays inside its stock-effect scope (or you opt into compiled effects); custom ShaderEffect source is not executed.
  • You need to know your game logic drives the renderer correctly — call counts, no leaks, no API misuse, no drift between commits. HEADLESS.
  • You need the loop to run and nothing else, or a reference to copy when writing your own renderer. STUB.

And the thing none of them does: prove your game looks right on the renderer you actually ship. For that you need the real one. See Verification for how CNA frames the difference between a test that exists and a test that gates.

Where to go next