Tutorial 87: Writing a Custom Renderer

CNA — C++ XNA 4.0 reimplementation

What you’ll learn

  • How CNA splits the XNA-compatible frontend from the IGraphicsRenderer implementation.
  • What a renderer must implement, and what it may inherit.
  • The generated registry contract used by both single- and multi-renderer builds.
  • The texture upload and shader compilation paths, and how to test a new renderer.
  • How to report capabilities so unsupported work refuses instead of silently doing nothing.

Before you startTutorial 72: Choosing a Renderer (what the existing 50 renderers do) and Tutorial 52: Writing Custom Shaders (ShaderEffect) (renderers receive shader source, so know what that source is). Reading the real renderers under modules/renderers/ alongside this page is strongly recommended.

CNA renderer architecture

CNA's rendering pipeline is split into a frontend (the XNA-compatible API in Microsoft::Xna::Framework::Graphics) and a renderer (the actual GPU calls). The renderer interface is IGraphicsRenderer. The frontend delegates all draw work to the active renderer through this interface, so you can add a new renderer without modifying any frontend code.

CNA ships 46 renderer implementation families behind 50 public identities. A new independent implementation would become the 47th family; a new profile over an existing family may instead add only a public identity.

The IGraphicsRenderer interface

The interface lives in modules/graphics/include/CNA/Internal/Renderers/Common/IGraphicsRenderer.hpp, in namespace CNA::Internal::Renderers. That one header declares the whole contract: IGraphicsRenderer itself plus the resource interfaces it hands back.

The design is not one flat interface of opaque integer handles. IGraphicsRenderer is a factory: it owns the window and the frame, and it returns std::unique_ptrs to per-resource renderer objects, each with its own small interface.

  • Frame and surface: Clear(float r, float g, float b, float a), Present(), GetViewportSize(int&, int&), SetVirtualResolution(int, int), SetPresentationMode(int), and the Clear* depth/stencil variants.
  • Resource factories: CreateTexture(const ImageData&), CreateSpriteBatch(), CreateOcclusionQuery(), CreateTexture3D(…), CreateTextureCube(…) — returning ITextureRenderer, ISpriteBatchRenderer, IOcclusionQueryRenderer, ITexture3DRenderer, ITextureCubeRenderer.
  • Resource interfaces declared alongside it: IVertexBufferRenderer, IIndexBufferRenderer, IEffectRenderer, IRenderTargetRenderer, IRenderTargetCubeRenderer.
  • SDL escape hatches, still marked TODO: abstract later in the header: GetWindowInternal(), GetRendererInternal().

IGraphicsRenderer declares a few dozen methods, of which only a minority are pure virtual and must be implemented. The rest are virtual-with-a-default, deliberately: ReadBackbuffer throws unless overridden, SetSwapInterval is a no-op, CreateOcclusionQuery and CreateTexture3D return nullptr, and the coordinate-transform pair reports that window space equals logical space. A partial renderer is a supported thing to build — you override what your API can genuinely do and inherit a documented fallback for the rest. Thirteen shipped renderers are 2D-only, and two produce no pixels at all.

Skeleton renderer class

A renderer is constructed ready to draw — there is no separate Init/Shutdown pair, the constructor and destructor do that work. Below is the shape of a minimal renderer; the real ones under modules/renderers/ are the authoritative reference for the remaining overrides.

// MyCustomRenderer.hpp
#pragma once
#include "CNA/Internal/Renderers/Common/IGraphicsRenderer.hpp"

namespace CNA::Internal::Renderers::MyCustom
{
    class MyCustomRenderer final : public IGraphicsRenderer
    {
    public:
        explicit MyCustomRenderer(const GraphicsRendererCreateArgs& args);
        ~MyCustomRenderer() override;

        // --- Frame ---
        void Clear(float r, float g, float b, float a) override;
        void Present() override;
        void GetViewportSize(int& width, int& height) override;
        void SetVirtualResolution(int width, int height) override;
        void SetPresentationMode(int mode) override;

        // --- SDL escape hatches (still required by the interface) ---
        SDL_Window*   GetWindowInternal()   const override { return window_; }
        SDL_Renderer* GetRendererInternal() const override { return nullptr; }

        // --- Resource factories ---
        std::unique_ptr<ITextureRenderer>     CreateTexture(const ImageData& data) override;
        std::unique_ptr<ISpriteBatchRenderer> CreateSpriteBatch() override;

        // --- Capabilities: the ONE thing you must not leave at its default ---
        [[nodiscard]] bool SupportsCapability(CNA::GraphicsCapability c) const override;

    private:
        SDL_Window* window_        = nullptr;
        int         virtualWidth_  = 0;
        int         virtualHeight_ = 0;
    };
}

Each factory returns an object implementing its own small interface. ITextureRenderer, for instance, requires only GetWidth(), GetHeight() and GetNativeTexture(); UpdatePixels, UpdatePixelsLevel, BindGL, ShareCpuPixels and GetData all have defaults you override when your API supports them.

Report capabilities honestly — refuse, do not no-op

The base SupportsCapability() fails open for most entries. It opts out of MultiStreamVertexInput and CompiledEffects, delegates StencilBuffer, and returns true for the rest. If you inherit it and your renderer has no occlusion queries, no MRT and no Texture3D, CNA will confidently tell every caller that you do. Overriding this truthfully is not optional polish.

This matters because CNA has learned the lesson the expensive way. Four of its own shipped renderers — DIRECTX9, DIRECTX11, DIRECTX12 and SDL_GPU — still have no override at all, so a capability query against them answers "yes" to questions nobody verified. Do not add a fifth.

The design principle running through the whole interface is deterministic refusal over silent success. A renderer that cannot do something should make that visible at the call site — through a truthful capability answer, or through the exception the shared layer raises on your behalf. The alternative, quietly returning as though the work happened, produces bugs that surface frames or screens later with nothing to trace them to. ITextureRenderer::GetData is the canonical example: its default returns false rather than leaving the destination untouched, precisely because a silent no-op used to hand callers a complete, uniformly transparent-black frame that passed every check. The shared layer now converts only on true and raises System::NotSupportedException on false, so an unimplemented renderer can never answer with content it never read.

bool MyCustomRenderer::SupportsCapability(CNA::GraphicsCapability c) const
{
    using Cap = CNA::GraphicsCapability;
    switch (c)
    {
        // Say no to everything you have not actually implemented.
        case Cap::ThreeD:                  return false;   // 2D-only renderer
        case Cap::MultipleRenderTargets:   return false;
        case Cap::OcclusionQuery:          return false;
        case Cap::Texture3D:               return false;
        case Cap::CustomEffects:           return false;
        case Cap::MultiSampleAntiAliasing: return false;
        case Cap::WireFrame:               return false;
        case Cap::Instancing:              return false;
        default:                           return IGraphicsRenderer::SupportsCapability(c);
    }
}

Selecting a renderer

CNA has a generated, fixed-at-link-time renderer registry, not a plugin registration call in main(). A default build contains the one identity named by CNA_GRAPHICS_RENDERER; CNA_GRAPHICS_RENDERERS can compile several compatible families into one binary. CMake generates the descriptor table explicitly so static-library linkers cannot discard self-registering renderer objects.

Each implementation exposes a namespaced factory that a generated GraphicsRendererDescriptor points to. GraphicsDevice resolves the selected descriptor, then calls its factory:

// MyCustomRenderer.cpp
namespace CNA::Internal::Renderers::MyCustom
{
    std::unique_ptr<IGraphicsRenderer> CreateGraphicsRenderer(
        const GraphicsRendererCreateArgs& args)
    {
        return std::make_unique<MyCustom::MyCustomRenderer>(args);
    }
}

GraphicsRendererCreateArgs is what the frontend hands you at construction: the SDL_Window*, the requested virtualWidth/virtualHeight, a CnaPresentationMode, the contextRecoveryEnabled flag, multiSampleCount, the swap interval, requested back-buffer and depth/stencil formats, an isFullScreen flag, the requested GraphicsProfile, and an optional device-event callback. Every field is documented as ignorable by renderers that cannot honour it — another instance of the same honesty rule: ignore it openly, do not pretend.

Add the identity, directory, target, defines and descriptor metadata through cmake/RendererSelection.cmake and the registry-generation inputs in cmake/RendererRegistry.cmake. Identity macros apply to that renderer target; only the default identity's macros remain project-wide in a multi-renderer build. Then configure alone with -DCNA_GRAPHICS_RENDERER=MYCUSTOM, and also test it as a member of a valid CNA_GRAPHICS_RENDERERS list.

Texture upload path

Texture2D asks the renderer for one object per texture rather than juggling handles: it calls CreateTexture(imageData) and keeps the returned std::unique_ptr<ITextureRenderer> for the texture's lifetime. Later writes go through that object — UpdatePixels(rgba, stride) for a full level-0 replacement, UpdatePixelsLevel(level, rgba, w, h) for an individual mip. Readback runs the other way through GetData(…), and Texture2D only reaches for it when it has no CPU-side shadow copy of its own — in practice, for render targets.

If your renderer can lose its GPU context (WebGL, or an Android app going to background), implement ShareCpuPixels: Texture2D passes a shared_ptr to the pixel buffer it already owns, so you can restore from it after a context loss without keeping a second copy.

Shader compilation

Shaders are the responsibility of IEffectRenderer, not of IGraphicsRenderer directly. How the source reaches the GPU is entirely your renderer's business, and CNA's own renderers deliberately disagree: the EasyGL profiles consume GLSL, VULKAN and SDL_GPU consume SPIR-V, DIRECTX9/DIRECTX11/DIRECTX12 compile HLSL at runtime, and WEBGPU uses WGSL through wgpu-native. Pick whichever is closest to your target API and read that renderer first.

If your renderer cannot execute a custom ShaderEffect, throw, as GDI, METAL, HTML_DOM and SVG_DOM do. Accepting the effect and drawing without it — what BGFX does today — is the behaviour users find hardest to diagnose, and is not a pattern to copy.

Real reference implementations

This tutorial teaches the interface shape using a hypothetical MYCUSTOM renderer, but CNA ships 46 real implementation families. For a GPU renderer with a modern API, read modules/renderers/vulkan/ or modules/renderers/sdl-gpu/. For a runtime-compiled-HLSL path, read modules/renderers/directx11/. For a 2D-only renderer that refuses 3D deterministically, read modules/renderers/sdl-renderer/ or modules/renderers/gdi/. For bring-up without a GPU, read modules/renderers/headless/; for real CPU pixels, modules/renderers/software/.

Testing strategy

Configure with -DCNA_GRAPHICS_RENDERER=MYCUSTOM and run CNA's test suite. Alpha.1 contains 568 C++ test sources and 8,263 statically discoverable GoogleTest-family definitions, but the executable and CTest totals depend on the full configuration. The math tests never touch the GPU, so they exercise the frontend as soon as your renderer constructs.

Renderer pixel, smoke and integration tests are the real exercise. Register your cases under a renderer-specific label, inspect the actual configuration with ctest -N, and test both a minimal single-renderer build and at least one valid multi-renderer build containing your implementation.

The HEADLESS renderer is the model to copy for bring-up. It implements the full interface with no GPU and no window, but it still validates its arguments and tracks resource lifetimes rather than stubbing everything out — which means it catches frontend misuse instead of silently swallowing it. That is the same principle as truthful capability reporting, applied to a renderer that draws nothing at all.