Tutorial 87: Writing a Custom Rendering Backend

CNA — C++ XNA 4.0 reimplementation

CNA backend architecture

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

IGraphicsBackend interface overview

The interface lives in cna/backend/IGraphicsBackend.hpp. It defines pure virtual methods for all GPU operations. A concrete backend must implement every method or stub it with a no-op.

Required method categories:

  • Initialization: Init(SDL_Window*), Shutdown()
  • Frame: Clear(Color), Present()
  • Vertex/Index buffers: CreateVertexBuffer(...), SetData(...), SetVertexBuffer(...)
  • Textures: CreateTexture2D(...), SetTextureData(...), BindTexture(...)
  • Shaders/Effects: CompileEffect(...), ApplyEffect(...)
  • Pipeline state: SetBlendState(...), SetDepthStencilState(...), SetRasterizerState(...)
  • Draw calls: DrawPrimitives(...), DrawIndexedPrimitives(...)
  • Render targets: CreateRenderTarget2D(...), SetRenderTarget(...)

Skeleton custom backend class

// MyCustomBackend.hpp
#pragma once
#include "cna/backend/IGraphicsBackend.hpp"
#include "Microsoft/Xna/Framework/Graphics/Color.hpp"

class MyCustomBackend final : public CNA::IGraphicsBackend {
public:
    // --- Lifecycle ---
    bool Init(SDL_Window* window) override {
        window_ = window;
        // Initialize your GPU context here
        return true;
    }

    void Shutdown() override {
        // Release GPU resources
    }

    // --- Frame ---
    void Clear(const Microsoft::Xna::Framework::Graphics::Color& color) override {
        clearColor_ = color;
        // Submit clear command to your GPU API
    }

    void Present() override {
        // Swap front/back buffers
        SDL_GL_SwapWindow(window_);
    }

    // --- Vertex buffer ---
    BackendHandle CreateVertexBuffer(int vertexCount,
                                     int vertexStride,
                                     bool dynamic) override {
        // Allocate GPU buffer, return opaque handle
        return ++nextHandle_;
    }

    void SetVertexBufferData(BackendHandle handle,
                             const void* data,
                             int byteCount) override {
        // Upload data to GPU buffer identified by handle
    }

    void BindVertexBuffer(BackendHandle handle, int stride) override {
        // Bind buffer for next draw call
        boundVB_ = handle;
        boundVBStride_ = stride;
    }

    // --- Texture ---
    BackendHandle CreateTexture2D(int width, int height,
                                  SurfaceFormat format) override {
        return ++nextHandle_;
    }

    void SetTextureData(BackendHandle handle,
                        int level, const void* data, int byteCount) override {
        // Upload mip level data
    }

    void BindTexture(int unit, BackendHandle handle) override {
        // Bind to texture unit
    }

    // --- Draw ---
    void DrawPrimitives(PrimitiveType type,
                        int startVertex,
                        int primitiveCount) override {
        // Submit draw call with currently bound VB and effect
    }

    void DrawIndexedPrimitives(PrimitiveType type,
                               int baseVertex,
                               int startIndex,
                               int primitiveCount) override {
        // Submit indexed draw call
    }

    // --- State ---
    void SetBlendState(const BlendState& state) override {}
    void SetDepthStencilState(const DepthStencilState& state) override {}
    void SetRasterizerState(const RasterizerState& state) override {}

    // --- Shaders ---
    BackendHandle CompileEffect(const char* glslSource, size_t len) override {
        // Compile shader source, return handle
        return ++nextHandle_;
    }

    void ApplyEffect(BackendHandle handle, int pass) override {
        // Bind compiled shader program
    }

private:
    SDL_Window*   window_       = nullptr;
    BackendHandle nextHandle_   = 0;
    BackendHandle boundVB_      = 0;
    int           boundVBStride_= 0;
    Microsoft::Xna::Framework::Graphics::Color clearColor_{};
};

Registering a backend

// In your main() before creating Game:
#include "cna/backend/BackendRegistry.hpp"
#include "MyCustomBackend.hpp"

int main() {
    CNA::BackendRegistry::Register("MYCUSTOM",
        []() -> std::unique_ptr<CNA::IGraphicsBackend> {
            return std::make_unique<MyCustomBackend>();
        });

    MyGame game;
    game.Run();
    return 0;
}

Then build with -DCNA_GRAPHICS_BACKEND=MYCUSTOM.

Texture upload path

The texture upload flow: Texture2D constructor calls backend->CreateTexture2D(w, h, format) to allocate, then backend->SetTextureData(handle, 0, pixels, bytes) for the mip 0 data. For mipmaps, call SetTextureData for each level. On Draw, the SpriteBatch or Effect calls backend->BindTexture(unit, handle) before the draw call.

Shader compilation

CNA's built-in effects (BasicEffect, etc.) ship as GLSL source strings. The backend's CompileEffect receives GLSL and must return a compiled handle. For Vulkan backends, convert GLSL to SPIR-V using shaderc at compile time. For Direct3D backends, translate GLSL to HLSL using SPIRV-Cross.

Testing strategy

Test your backend by running CNA's 4,373-test suite with CNA_GRAPHICS_BACKEND=MYCUSTOM. The test suite is headless for math tests; rendering tests open a window and compare pixel outputs. A null backend that returns stub handles passes all math tests and lets you validate the frontend independently of GPU hardware.