Tutorial 87: Writing a Custom Rendering Backend
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.