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, and what a renderer is actually handed (a platform surface snapshot, not an SDL window).
  • What a renderer must implement, and what it may inherit.
  • The generated registry and descriptor contract used by both single- and multi-renderer builds, and the places a new identity has to be registered.
  • 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 start — Tutorial 72: Choosing a Renderer (what the existing 14 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; the smallest one, modules/renderers/stub/, is under 300 lines and is the template used here.

⚠

What “custom” means in this snapshot. CNA has no runtime plug-in API for renderers: IGraphicsRenderer is an internal interface (namespace CNA::Internal::Renderers), and a renderer is compiled into CNA through a generated, link-time registry. So “a custom renderer” means a new family added to your own fork or private branch of the CNA source tree. It also is not an open invitation: CNA deliberately maintains a curated renderer set (14 identities over 12 families). A renderer earns a place only with meaningful platform coverage, compatibility value, architectural value or a capability the existing set does not cover, and CNA states that renderer count is not a goal. Its own research note on possible additions is explicit that nothing in it is planned or authorized, and that any new identity would need a fresh, explicit owner decision. Treat this tutorial as an anatomy lesson and a fork guide — the best way to learn the contract — not as a promise that an upstream renderer will be accepted.

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 a new renderer needs no frontend changes.

In this snapshot CNA ships 12 renderer implementation families behind 14 public identities (the three GL-profile identities share one family, EasyGL). A new independent implementation in a fork would become the 13th family; a new profile over an existing family would 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 frame and the presentation, 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. OnSurfaceChanged(const RendererSurfaceInfo&) and OnSurfaceInvalidated(WindowId) tell the renderer about resizes and density changes; both have inert defaults.
  • Resource factories: CreateTexture(const ImageData&), CreateSpriteBatch(), CreateVertexBuffer(int) and CreateIndexBuffer16(int) are pure virtual; CreateIndexBuffer32(int), CreateOcclusionQuery(), CreateTexture3D(…), CreateTextureCube(…), CreateRenderTarget2D(…), CreateEffectRenderer(…) and the further modern-feature factories have defaults. They return ITextureRenderer, ISpriteBatchRenderer, IVertexBufferRenderer, IIndexBufferRenderer, IOcclusionQueryRenderer, ITexture3DRenderer, ITextureCubeRenderer, IRenderTargetRenderer and IEffectRenderer objects.
  • Resource interfaces declared alongside it: IRenderTargetCubeRenderer, IStorageBufferRenderer, IComputeShaderRenderer and IGpuTimerRenderer, used only by renderers that report the matching modern capability.
  • Render targets and fixed state: SetRenderTargets(const RenderTargetBindingDescriptor*, int), SetDepthTestEnabled, SetBlendEnabled, SetDepthWriteEnabled, and the two colored-primitive draws DrawColoredPrimitives and DrawIndexedColoredPrimitives.
ℹ

No SDL escape hatches. Older write-ups of this interface described GetWindowInternal() and GetRendererInternal() accessors that hand a renderer an SDL window or renderer. They do not exist: neither accessor is declared in IGraphicsRenderer.hpp, and a renderer never receives an SDL_Window*. What it receives instead is the RendererSurfaceInfo inside GraphicsRendererCreateArgs — a snapshot owned by the platform layer: a stable windowId, a typed nativeHandle, the initial drawableSize in physical pixels and the displayScale — plus, only for the families whose descriptor asks for them, a non-owning OpenGL context service (glContext), Vulkan surface service (vulkanSurface) or CPU-frame presenter (surfacePresenter). The windowed platform behind that snapshot is SDL3, CNA’s one windowing platform, but the renderer only ever sees the snapshot and the services.

ℹ

A partial renderer is a supported thing to build. IGraphicsRenderer is a very wide interface, of which exactly 21 methods are pure virtual and must be implemented. The rest are virtual-with-a-default, deliberately: ReadBackbuffer throws unless overridden, CreateOcclusionQuery and CreateTexture3D return nullptr, the coordinate-transform pair reports that window space equals logical space, and every modern-feature query (SupportsComputeShadersEXT(), SupportsShadowSamplingEXT(), …) answers false. You override what your API can genuinely do and inherit a documented fallback for the rest. One shipped renderer is 2D-only (SDL_RENDERER), and two produce no pixels at all (HEADLESS and STUB).

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 complete renderer for a hypothetical family called MyCustom; it declares all 21 pure virtuals and is modelled on the real Stub family. The real renderers under modules/renderers/ are the authoritative reference for the remaining overrides. (The header and source shown here were syntax-checked against this snapshot's headers.)

// MyCustomRenderer.hpp
// Lives in a private, in-tree family directory: modules/renderers/mycustom/
#pragma once
#include "CNA/Internal/Renderers/Common/IGraphicsRenderer.hpp"

namespace CNA::Internal::Renderers::MyCustom
{
    // Every resource the renderer hands out is its own small object. ITextureRenderer needs
    // only the two size getters; the update/readback hooks all have defaults.
    class MyCustomTexture final : public ITextureRenderer
    {
    public:
        MyCustomTexture(int width, int height) : width_(width), height_(height) {}
        [[nodiscard]] int GetWidth() const override { return width_; }
        [[nodiscard]] int GetHeight() const override { return height_; }

    private:
        int width_;
        int height_;
    };

    class MyCustomRenderer final : public IGraphicsRenderer
    {
    public:
        // A renderer is constructed ready to draw. There is no Init()/Shutdown() pair.
        explicit MyCustomRenderer(const GraphicsRendererCreateArgs& args);

        // --- Frame and surface (pure virtual) ---
        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;

        // --- Resource factories (pure virtual) ---
        std::unique_ptr<ITextureRenderer>      CreateTexture(const ImageData& data) override;
        std::unique_ptr<ISpriteBatchRenderer>  CreateSpriteBatch() override;
        std::unique_ptr<IVertexBufferRenderer> CreateVertexBuffer(int vertex_capacity) override;
        std::unique_ptr<IIndexBufferRenderer>  CreateIndexBuffer16(int index_capacity) override;

        // --- Render targets and clears (pure virtual) ---
        void SetRenderTargets(const RenderTargetBindingDescriptor* renderTargets,
                              int count) override;
        void ClearColorAndDepth(float r, float g, float b, float a, float depth) override;
        void ClearDepth(float depth) override;
        void ClearStencil(int stencil) override;
        void ClearDepthAndStencil(float depth, int stencil) override;
        void ClearColorAndStencil(float r, float g, float b, float a, int stencil) override;
        void ClearColorDepthAndStencil(float r, float g, float b, float a,
                                       float depth, int stencil) override;

        // --- Fixed state and the two colored-primitive draws (pure virtual) ---
        void SetDepthTestEnabled(bool enabled) override;
        void SetBlendEnabled(bool enabled) override;
        void SetDepthWriteEnabled(bool enabled) override;
        void DrawColoredPrimitives(const IVertexBufferRenderer& vb, const Matrix& world,
                                   const Matrix& view, const Matrix& projection,
                                   PrimitiveType primitive, int primitiveCount) override;
        void DrawIndexedColoredPrimitives(const IVertexBufferRenderer& vb,
                                          const IIndexBufferRenderer& ib, const Matrix& world,
                                          const Matrix& view, const Matrix& projection,
                                          PrimitiveType primitive, int primitiveCount) override;

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

    private:
        int virtualWidth_;
        int virtualHeight_;
    };
}

Each factory returns an object implementing its own small interface. ITextureRenderer, for instance, requires only GetWidth() and GetHeight(); UpdatePixels, UpdatePixelsLevel, BindGL, ShareCpuPixels, GetSurfaceFormatEXT and GetData all have defaults you override when your API supports them. ISpriteBatchRenderer requires Begin, End and three Draw overloads; IVertexBufferRenderer requires SetData, SetVertexDeclaration and GetVertexCount; IIndexBufferRenderer requires SetData16 and GetIndexCount. The Stub family implements every one of them in a few lines each, which makes it the fastest way to see the minimum.

The bodies are where your target API goes. The constructor is handed everything the frontend knows, and the family ends with its factory and its descriptor (described below):

// MyCustomRenderer.cpp
#include "MyCustomRenderer.hpp"
#include "CNA/Internal/Renderers/Common/GraphicsRendererDescriptor.hpp"
#include "CNA/Internal/Renderers/Common/GraphicsRendererDescriptorHelpers.hpp"

namespace CNA::Internal::Renderers::MyCustom
{
    MyCustomRenderer::MyCustomRenderer(const GraphicsRendererCreateArgs& args)
        : virtualWidth_(args.virtualWidth), virtualHeight_(args.virtualHeight)
    {
        // args.surface tells you where to draw: a windowId, a typed nativeHandle, the
        // drawableSize in physical pixels and the displayScale. There is no SDL_Window*.
    }

    void MyCustomRenderer::GetViewportSize(int& width, int& height)
    {
        width  = virtualWidth_  > 0 ? virtualWidth_  : 1024;
        height = virtualHeight_ > 0 ? virtualHeight_ : 768;
    }

    std::unique_ptr<ITextureRenderer> MyCustomRenderer::CreateTexture(const ImageData& data)
    {
        return std::make_unique<MyCustomTexture>(data.width, data.height);
    }

    // The family's own factory, in the family's own namespace (never a shared symbol):
    std::unique_ptr<IGraphicsRenderer> CreateGraphicsRenderer(const GraphicsRendererCreateArgs& args)
    {
        return std::make_unique<MyCustomRenderer>(args);
    }

    // The pre-construction contract the registry hands to GraphicsDevice:
    const GraphicsRendererDescriptor& GetDescriptor()
    {
        static const GraphicsRendererDescriptor descriptor{
            .type                = CNA::GraphicsRendererType::MyCustom,  // the enumerator you add
            .name                = CNA::getGraphicsRendererName(CNA::GraphicsRendererType::MyCustom),
            .windowKind          = RendererWindowKind::Plain,
            .needsWindow         = true,
            .needsVideoSubsystem = true,
            .isAvailable         = &AlwaysAvailable,
            .create              = &CreateGraphicsRenderer,
        };
        return descriptor;
    }
}

Report capabilities honestly — refuse, do not no-op

⚠

The base SupportsCapability() still fails open for most entries. It delegates StencilBuffer to the renderer, returns false for MultiStreamVertexInput, CompiledEffects (unless the renderer opts in) and, new in this snapshot, FloatRenderTargets and HalfFloatRenderTargets, 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. GraphicsCapability now has 19 members, and of the 14 shipped renderers only DIRECTX9 still has no SupportsCapability() override at all; VULKAN, DIRECTX11, SDL_GPU and METAL use a switch with no permissive default arm, so an enumerator they do not list answers false. That is the shape to copy. Do not add a second renderer that inherits the permissive defaults.

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 capability) const
{
    using Cap = CNA::GraphicsCapability;
    switch (capability)
    {
        // Claim only what you have implemented AND tested. No permissive default arm:
        // a capability added to the enum later must start out false on your renderer.
        case Cap::ThreeD:                  return false;   // a 2D-only renderer
        case Cap::AdditiveBlending:        return true;    // e.g. one thing it really does
        case Cap::DepthStencilBuffer:
        case Cap::MultiSampleAntiAliasing:
        case Cap::MultipleRenderTargets:
        case Cap::AnisotropicFiltering:
        case Cap::WireFrame:
        case Cap::OcclusionQuery:
        case Cap::CustomEffects:
        case Cap::Texture3D:
        case Cap::MultiStreamVertexInput:
        case Cap::Instancing:
        case Cap::StencilBuffer:
        case Cap::CompiledEffects:
        case Cap::FloatRenderTargets:
        case Cap::HalfFloatRenderTargets:
        case Cap::HalfFloatTextureLinearFiltering:
        case Cap::ComputeShaders:
        case Cap::IndirectDraw:
            return false;
    }
    return false;   // unknown (future) enumerators are unsupported until you say otherwise
}

The coarse switch is only the first of three layers. At the device level, GraphicsDevice::SupportsCapability derives six answers (CompiledEffects, FloatRenderTargets, HalfFloatRenderTargets, HalfFloatTextureLinearFiltering, ComputeShaders, IndirectDraw) from separate, false-by-default virtuals — SupportsCompiledEffects(), SupportsComputeShadersEXT(), SupportsIndirectDrawEXT(), the render-target format classifiers and so on — and ANDs MultipleRenderTargets with the profile's MRT limit. A renderer that wants those answers to be true must override the matching virtual, not just the switch. On top of both sits the renderer capability profile (32 features, 22 limits, per-format usage and a generated English report, all built lazily from the renderer's own virtuals); Tutorial 101 and Tutorial 133 show how callers read it, and the honest hooks you override to feed it look like this:

// The dialect custom ShaderEffect sources must be written in (Unknown until you declare one).
[[nodiscard]] ShaderDialectEXT GetShaderDialectEXT() const override { return ShaderDialectEXT::SpirV; }

// Formats you really store. Anything left at Defer gets the framework rule: Color only.
[[nodiscard]] RendererFormatVerdict ClassifySurfaceFormatEXT(int surfaceFormat) const override;

// Qualitative notes that end up in the generated capability report.
[[nodiscard]] std::string_view GetAdditionalLimitationsTextEXT() const override;

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 CnaRendererRegistry.generated.cpp explicitly so static-library linkers cannot discard self-registering renderer objects.

Each family owns exactly one descriptor translation unit: a GraphicsRendererDescriptor value, returned by the family's own GetDescriptor(), that answers everything GraphicsDevice must know before a renderer object exists — and points at the family's factory. The factory is declared in the family's own namespace (CNA::Internal::Renderers::MyCustom::CreateGraphicsRenderer), never as a shared symbol: a build gate fails any family that defines the bare shared name, because that is exactly what makes two renderer archives unlinkable into one binary. The source listing above ends with both the factory and the descriptor.

Descriptor fieldWhat it decides
type, nameThe public identity and its exact CNA_GRAPHICS_RENDERER spelling.
windowKindNone, Plain, OpenGL, Vulkan or Metal. A fallback between two different kinds must destroy and recreate a window CNA owns; a caller-supplied window cannot be recreated and yields WindowKindConflict.
needsWindow, needsVideoSubsystemfalse for the window-free renderers (HEADLESS, SOFTWARE, STUB), which is what lets them run with no display server.
needsGlContext, needsVulkanSurface, needsSurfacePresenterWhich non-owning platform service the renderer is handed in GraphicsRendererCreateArgs.
wantsHighDpi, glFramebufferWindow attributes that must be fixed before the window exists (Metal asks for a high-density backing; a GL framebuffer’s depth, stencil and multisample bits are fixed when the window is created).
isAvailableA cheap, side-effect-free probe. Every shipped family returns true (AlwaysAvailable), so “available” means “compiled in”; real failures surface as InitializationFailed from the constructor.
createThe family's own CreateGraphicsRenderer.
adapterQueriesOptional adapter-level answers (profile support, format support, MSAA clamping) for questions asked before a device exists; null members mean “use the framework's rule”. Only a minority of families supply any (the two Direct3D renderers, two of the three EasyGL identities, OPENGLES3 and WEBGL2, which answer only the render-target format query while OPENGL33 registers no hook, and Vulkan's MSAA clamp).

You can inspect what a build contains through the registry, and ask each identity for its declared maturity and category (CNA's own classification, not a measurement):

#include "CNA/Internal/Renderers/Common/GraphicsRendererRegistry.hpp"
#include "CNA/GraphicsBackendMaturity.hpp"
#include "CNA/GraphicsBackendCategory.hpp"
#include <iostream>

using namespace CNA::Internal::Renderers;

for (const GraphicsRendererDescriptor& d : GraphicsRendererRegistry::All())
{
    std::cout << d.name << "  needsWindow=" << d.needsWindow
              << "  maturity=" << CNA::toStringView(CNA::getGraphicsBackendMaturity(d.type))
              << "  category=" << CNA::toStringView(CNA::getGraphicsBackendCategory(d.type)) << '\n';
}
if (const GraphicsRendererDescriptor* d = GraphicsRendererRegistry::Find("STUB"))
    std::cout << "found " << d->name << '\n';

GraphicsRendererCreateArgs is what the frontend hands you at construction: the surface snapshot (RendererSurfaceInfo), the optional glContext, vulkanSurface and surfacePresenter services, 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.

Registering the identity

An identity is registered in several places that must agree, and CNA holds them to one canonical table with a script (scripts/check_renderer_identities.py, run as the CTest RendererIdentityRegistry). Adding an identity fails that check until the table — and therefore the documented public count — is deliberately updated. In a fork, the work is:

  1. The C++ enum. Add an enumerator to GraphicsRendererType in modules/core/include/CNA/GraphicsRendererType.hpp, plus its name, its compile-time default arm (getCurrentGraphicsRendererType()), and its arms in getGraphicsBackendMaturity() and getGraphicsBackendCategory() (an omitted enumerator is caught by an exhaustiveness test, not by the compiler). C++ enum ordinals are dense and explicitly not a stable contract.
  2. The CMake identity. Add the name to CNA_RENDERER_PUBLIC_IDENTITIES in cmake/RendererIdentities.cmake, which feeds the CNA_GRAPHICS_RENDERER choice list and the early configure-time refusal of names outside the set.
  3. The registry map. Add an identity-to-namespace entry in cmake/RendererRegistry.cmake; that is how the generated registry finds your GetDescriptor(). Two identities may share a namespace (EasyGL serves three) but never a descriptor accessor.
  4. The selection block. Declare the CNA_RENDERER_<NAME> option and add the family's block to cmake/RendererSelection.cmake (directory, target, defines, dependencies and platform gates), announcing the identity's own macro through _cna_identity_defines so it does not leak project-wide in a multi-renderer build. Add any combination rule to cmake/RendererCombinations.cmake.
  5. The family directory. Create modules/renderers/mycustom/ with include/, src/ and a CMakeLists.txt whose body is cna_add_renderer() (as in the Stub family): it globs src/*.cpp, derives the target name cna_renderer_mycustom from the directory and applies the common include and link setup.
  6. The C ABI. The C API keeps its own explicit identity table, static_asserted against the canonical enum. Its numeric values are sparse and stable: a value, once retired, is permanently reserved, so the next new identity takes 52, not the next integer after the current maximum. Add the constant to CNA/C/graphics.h and a row to the table in CnaCApiCoreExt.cpp.

Then configure alone with -DCNA_GRAPHICS_RENDERER=MYCUSTOM, and also test it as a member of a valid CNA_GRAPHICS_RENDERERS list. CNA's descriptor gate (CNA_BUILD_RENDERER_DESCRIPTOR_GATE, on by default) compiles every registered family's descriptor even in builds that do not select it, and scripts/check_runtime_renderer_discipline.py verifies the whole chain — identity, namespace, accessor, descriptor, factory — end to end.

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.

Surface formats have a tri-state boundary. ClassifySurfaceFormatEXT (and the render-target, cube and 3D variants) return Supported, Unsupported or Defer; the default is Defer, meaning the framework's own rule applies — and that rule is SurfaceFormat::Color only, anything else throws as “not implemented by the selected graphics renderer”. That is why four shipped renderers (DIRECTX9, SDL_RENDERER, HEADLESS and STUB) accept only Color textures without writing a line of format code, and FNA3D, which classifies textures but not render targets, joins them for render targets. A renderer that stores other formats natively (VULKAN maps all 27 SurfaceFormat members and checks them against device format properties) overrides the classifiers to say exactly which.

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 (five pure virtuals: CompileProgram, Bind, Unbind, IsValid, GetCompileError), 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 consumes compiled SPIR-V (a payload that is not SPIR-V is refused), SDL_GPU consumes SPIR-V and needs libshaderc to build a custom effect from source, DIRECTX9/DIRECTX11 compile HLSL at runtime, WEBGPU uses WGSL, and METAL compiles Metal Shading Language for SpriteBatch effects. A renderer states its choice with GetShaderDialectEXT() (GlslDesktop, GlslEs, GlslVulkan, Hlsl, Msl, Wgsl or SpirV) and, per language and stage, SupportsShaderLanguageEXT, which refuses everything by default. Pick whichever dialect is closest to your target API and read that renderer first.

If your renderer cannot execute a custom ShaderEffect, report CustomEffects false and return no effect renderer, as FNA3D does, so the effect is visibly invalid; if it can execute one only in some draws, refuse the others by name, as METAL does for a 3D draw with a custom effect. Accepting the effect and drawing without it — what SOFTWARE does today, using its own fixed CPU shading path — is the behaviour users find hardest to diagnose, and is not a pattern to copy.

std::unique_ptr<IEffectRenderer> MyCustomRenderer::CreateEffectRenderer(const std::string& vertSrc,
                                                                 const std::string& fragSrc)
{
    (void)vertSrc;
    (void)fragSrc;
    throw System::NotSupportedException(
        "MyCustom does not execute custom ShaderEffect programs.");
}

A second, separate route exists for XNA-authored effects: SupportsCompiledEffects() and CreateCompiledEffect(…) let a renderer execute compiled Direct3D 9 Effect Framework bytecode (the .fxb format) through the shared MojoShader translation. Eleven renderer identities in nine implementation families implement it — FNA3D always, the other ten identities in eight families behind opt-in CNA_*_COMPILED_EFFECTS build options — and it is a large piece of work (native shaders, reflection, state and sampler application), which is why the default is off.

Real reference implementations

This tutorial teaches the interface shape using a hypothetical MYCUSTOM renderer, but CNA ships 12 real implementation families behind its 14 identities. For the minimum complete renderer, read modules/renderers/stub/ (about 300 lines). For bring-up with validation and no GPU, read modules/renderers/headless/; for real CPU pixels, modules/renderers/software/. For a GPU renderer with a modern API, read modules/renderers/vulkan/, modules/renderers/sdl-gpu/ or modules/renderers/webgpu/. For a runtime-compiled-HLSL path, read modules/renderers/directx11/. For a 2D-only renderer that refuses 3D deterministically, read modules/renderers/sdl-renderer/. For a renderer that also runs in the browser, read the WEBGL2 profile in modules/renderers/easygl/ or modules/renderers/webgpu/.

Testing strategy

Configure with -DCNA_GRAPHICS_RENDERER=MYCUSTOM and run CNA's test suite. The tree at this snapshot holds 813 C++ test source files and 11,380 statically discoverable GoogleTest-family definitions (counted by the site's published method), but the executable and CTest totals depend on the full configuration and no single CTest total is derivable. 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. CNA also offers shared checks worth wiring in: 32 renderer-neutral parity fixtures (registered per renderer through cna_register_parity_fixtures(); each states its own expected result rather than comparing against real XNA), the capability-truth test that pins what a renderer claims, and the XNA oracle scenes with reference images captured from real XNA 4.0, which only DIRECTX9 is held to at tolerance 0.

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.