Tutorial 107: CPU-Only Renderers: Software, PortableGL, Headless and Stub
What you’ll learn
- How to verify rendering in CI with no GPU, no display server and no Xvfb.
- Why
Present()is a no-op on all four of these renderers, and where the pixels actually come from. - What each of
SOFTWARE,PORTABLEGL,HEADLESSandSTUBgenuinely 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.
Four of CNA's renderers are built to run where there is no graphics stack at all: SOFTWARE, PORTABLEGL, HEADLESS and STUB. None of them creates an SDL_Window, none of them initialises SDL'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.
They are not four flavours of the same thing. Two of them rasterise real pixels you can assert on; one of them records what your code did without drawing anything; one of them 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 four at a glance
| Renderer | What it does | Pixels 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. |
PORTABLEGL |
Real gl* calls into PortableGL, a single-header CPU implementation of an OpenGL-3.x-shaped API. Its framebuffer is ordinary RAM. |
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
Present() is a no-op on all four. 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 two rasterising renderers — it is sitting in a CPU buffer, and the way you get at it is XNA's own back-buffer readback:
#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(®ion, 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 -j3
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.
The v1 scope is narrow, and it fails loudly at every edge rather than misrendering:
| Boundary | Behaviour |
|---|---|
PrimitiveType | TriangleList only. Strip, line and point topologies throw “only TriangleList is supported in v1”. |
| Vertex strides | Only the five stock layouts (16, 20, 24, 32 and 52 bytes). Any other stride throws, naming the stride. |
| Lighting and fog | Out of scope for v1. EnableDefaultLighting() and the fog properties have no effect on the image. The base colour is vertexColor * diffuseColor * texture0 — no per-light N·L sum, no ambient, no emissive. |
| Stock effects | BasicEffect, DualTextureEffect, EnvironmentMapEffect and SkinnedEffect all render, minus the lighting caveat above. SkinnedEffect does real per-vertex bone blending. |
| Multiple render targets | One target at a time. A second simultaneous target throws, and so does a cube-face binding. |
| MSAA | Exactly one option: 4×, a 2×2 sub-pixel coverage grid. Any other requested count applies as none — and the applied count is what gets reported back, not the one you asked for. |
| Cube sampling | Addressed face-locally with Clamp whatever mode you set, and there is no filtering across cube seams. |
| Missing resources | No Texture3D, no render-target cube maps, no occlusion queries — the factories create nothing. Plain TextureCubes are real. |
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: Texture3D and Instancing are both a deliberate false, and MultiStreamVertexInput is a real true — the vertex reader resolves each attribute to the stream that owns it, with that stream's own stride and binding offset. 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.
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(¢re, &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.
PORTABLEGL: a real GL pipeline on the CPU
cmake -S . -B build-portablegl \
-DCNA_GRAPHICS_RENDERER=PORTABLEGL \
-DCMAKE_BUILD_TYPE=Debug \
-DCNA_BUILD_TESTS=ON
cmake --build build-portablegl -j3
Where SOFTWARE is CNA's own rasteriser, PORTABLEGL delegates: every stage is a genuine PortableGL call. glGenBuffers, glBufferData, glVertexAttribPointer, pglCreateProgram with real C vertex and fragment shader callbacks, glDrawArrays/glDrawElements, glClear, glViewport, glScissor, glTexImage2D, glBlendFuncSeparate, glStencilOpSeparate, glCullFace, glPolygonMode. Upstream is pinned at tag 0.100.0 and fetched at configure time; it is MIT, header-only and nothing in it is patched. For an offline build, point FETCHCONTENT_SOURCE_DIR_PORTABLEGL at an existing checkout.
This renderer is a bounded CPU 3D path, not an XNA parity target. That is its own framing, and it is the right way to read the list below. Everything outside the boundary fails loudly — a System::NotSupportedException naming the missing capability — never a silent no-op.
Inside the boundary it is surprisingly complete: real vertexStart/startIndex/baseVertex offsets, the whole Blend enum with separate RGB and alpha equations, the full stencil half of DepthStencilState including two-sided stencil, CullMode both ways, FillMode.WireFrame, scissor, depth bias, viewport with MinDepth/MaxDepth, and textured SpriteBatch with its own resolved sampler state. It also interleaves 3D draws and sprite draws in one frame without state leaking between them.
SupportsCapability() reports true for exactly five members — ThreeD, DepthStencilBuffer, StencilBuffer, AdditiveBlending and WireFrame — each backed by an implementation and a permanent test. Everything else is a deliberate no:
| Not supported | Why, and how it fails |
|---|---|
RenderTarget2D, RenderTargetCube, MRT | A PortableGL context owns exactly one framebuffer and has no off-screen attachment mechanism. Nothing is created, so binding one raises System::NotSupportedException. |
Custom ShaderEffect | PortableGL's shader stage is a pair of C function pointers; there is no compiler to target. SpriteBatch's custom-effect setter throws. |
| Lit, textured, fogged or alpha-tested effects | Only the unlit VertexPositionColor program exists. The draw is refused before any native call. |
Any layout other than VertexPositionColor | Refused — including a different declaration that happens to share the 16-byte stride, rather than reinterpreting the bytes. |
| Instancing, multi-stream vertex input | One stream is bound. A second is rejected. |
TextureCube, Texture3D, occlusion queries | Not implemented; the factories create nothing. |
| MSAA, mip mapping, anisotropic filtering | One sample per pixel, one mip level, one filter per texture. |
One quirk worth knowing if you are diffing images: PortableGL converts float colour to bytes by truncating, and does so after blending. CNA quantises its fragment output onto the 8-bit grid first, so unblended writes are byte-exact — but a partial-alpha composite can still land one LSB below what a round-to-nearest GPU writes. Give the destination term a one-LSB tolerance and assert the rest exactly.
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 -j3
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:
| Mode | Behaviour |
|---|---|
Fast | Counters 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. |
Trace | Everything 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 diffed across the most recent 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.
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 orSOFTWARE'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. Remember that exactly one renderer is compiled into a build, so a matrix means separate build directories, not a runtime flag:
jobs:
cpu-renderers:
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
renderer: [SOFTWARE, PORTABLEGL, 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 3
- 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 PortableGL, -L Headless, -L Stub. Every one of those tests is a real Game that draws through the ordinary public XNA API; the two rasterising families read 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 four of them are still four 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 and PORTABLEGL 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 four 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 blanketfalse,SOFTWARE's authored negatives forTexture3DandInstancing, andPORTABLEGL's five-true list are the mechanism, not documentation about it.
Choosing between the four
- You need to assert on an image.
SOFTWARE— as long as your scene fits itsTriangleList, unlit, stock-effect scope. - You need a real GL pipeline's semantics on the CPU — blend equations, two-sided stencil, cull and fill modes exercised through actual GL calls.
PORTABLEGL, for unlit vertex-coloured geometry and sprites. - 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.