3D Rendering
Implementation status: Full 3D rendering is available on the EasyGL (OpenGL ES 3.0) backend (most mature, the reference backend), the Vulkan backend (second-most mature; all five stock effects pixel-tested, open gaps in BlendState custom modes and OcclusionQuery), and the bgfx backend (reached full 2D+3D pixel-verified parity as of Phase 72, aside from custom ShaderEffect source compilation). All three also expose 3D through WebGL 2 via Emscripten where applicable — the House 3D Demo runs live in the browser. SDL_RENDERER supports 2D only by design.
Overview
CNA exposes XNA 4.0’s full 3D pipeline through the Microsoft::Xna::Framework::Graphics namespace. The pipeline follows the same pattern as the original API: you upload geometry into VertexBuffer and IndexBuffer objects, configure a GraphicsDevice render state, apply an Effect, and issue a draw call.
The EasyGL backend maps directly onto OpenGL ES 3.0 (desktop OpenGL 3.3 on platforms that support it). The Vulkan backend uses the same GLSL shaders compiled to SPIR-V and a Vulkan 1.1 pipeline. When compiled with Emscripten both backends emit WebGL 2 calls, enabling the same code to run in modern web browsers with no modification.
The SDL_RENDERER backend does not expose a programmable 3D pipeline — it is intended for 2D-only games that use SpriteBatch. The bgfx backend reached full 2D+3D pixel-verified parity with EasyGL and Vulkan as of this project's Phase 72, aside from custom ShaderEffect source compilation, which bgfx does not support.
| Backend | 3D support | Notes |
|---|---|---|
| EasyGL | Most mature | OpenGL ES 3.0 / WebGL 2 via Emscripten; reference backend for all 3D testing |
| Vulkan | Second-most mature | Vulkan 1.1; supports instancing and wireframe fill mode; open gaps: BlendState custom modes, OcclusionQuery |
| bgfx | Full parity (Phase 72) | All five stock effects pixel-tested; custom ShaderEffect source compilation not supported |
| SDL_RENDERER | 2D only | No programmable shader pipeline; use SpriteBatch only |
Vertex types
CNA provides the same four built-in vertex types as XNA 4.0. Every vertex type carries a static VertexDeclaration that describes the layout of its fields to the GPU. You can also define custom vertex structs and supply a matching VertexDeclaration.
| Type | Fields | Typical use |
|---|---|---|
VertexPositionColor |
Position (Vector3), Color (Color) |
Debug geometry, wireframes, simple colored primitives |
VertexPositionTexture |
Position (Vector3), TextureCoordinate (Vector2) |
Textured quads and simple textured meshes without lighting |
VertexPositionColorTexture |
Position (Vector3), Color (Color), TextureCoordinate (Vector2) |
Sprites in 3D space, tinted textured geometry |
VertexPositionNormalTexture |
Position (Vector3), Normal (Vector3), TextureCoordinate (Vector2) |
Lit 3D meshes with BasicEffect, EnvironmentMapEffect, SkinnedEffect |
Each vertex type exposes a static VertexDeclaration member that enumerates the VertexElement entries — their semantic (VertexElementUsage), format (VertexElementFormat), and byte offset within the struct. The GraphicsDevice reads this declaration when a vertex buffer is bound to know how to interpret the raw bytes.
// Access the declaration for a built-in type
const VertexDeclaration& decl = VertexPositionNormalTexture::VertexDeclaration;
// Inspect the stride (bytes per vertex)
int stride = decl.VertexStride; // 32 bytes for VertexPositionNormalTexture
Buffer management
VertexBuffer
A VertexBuffer stores a fixed array of vertices in GPU memory. Pass the VertexDeclaration describing the vertex layout, the number of vertices, and a BufferUsage hint when constructing.
// Allocate a static vertex buffer for 3 vertices
auto vb = std::make_shared<VertexBuffer>(
graphicsDevice,
VertexPositionColor::VertexDeclaration,
3,
BufferUsage::None);
// Upload vertex data
std::vector<VertexPositionColor> vertices = {
{ Vector3(0.0f, 0.5f, 0.0f), Color::Red },
{ Vector3(0.5f, -0.5f, 0.0f), Color::Green },
{ Vector3(-0.5f,-0.5f, 0.0f), Color::Blue },
};
vb->SetData(vertices.data(), static_cast<int>(vertices.size()));
// Read back data if needed
vb->GetData(vertices.data(), static_cast<int>(vertices.size()));
Use BufferUsage::None for static geometry that will not change after upload, and BufferUsage::WriteOnly when you intend to upload data once and never read it back (allows driver optimization).
DynamicVertexBuffer
DynamicVertexBuffer is optimised for geometry that changes every frame, such as particle systems or procedurally animated meshes. It uses a different allocation path that avoids GPU pipeline stalls on repeated SetData calls.
auto dvb = std::make_shared<DynamicVertexBuffer>(
graphicsDevice,
VertexPositionColor::VertexDeclaration,
maxParticles,
BufferUsage::WriteOnly);
// Each frame: discard old data and upload new
dvb->SetData(particleVerts.data(), static_cast<int>(liveCount),
SetDataOptions::Discard);
IndexBuffer
An IndexBuffer stores integer indices that reference vertices in a VertexBuffer, enabling vertex reuse. Choose IndexElementSize::SixteenBits (up to 65 535 unique vertices per draw) or IndexElementSize::ThirtyTwoBits (up to ~4 billion).
auto ib = std::make_shared<IndexBuffer>(
graphicsDevice,
IndexElementSize::SixteenBits,
36, // 12 triangles * 3 indices = 36 for a cube
BufferUsage::None);
std::vector<uint16_t> indices = { 0, 1, 2, 0, 2, 3, /* ... */ };
ib->SetData(indices.data(), static_cast<int>(indices.size()));
Binding and draw calls
Before issuing a draw call you must bind at least one vertex buffer to the GraphicsDevice. Index buffers and the active effect are set in the same way.
graphicsDevice->SetVertexBuffer(vb);
graphicsDevice->Indices = ib; // optional; required for indexed draws
effect->CurrentTechnique->Passes[0]->Apply(); // upload shader constants
Draw call variants
| Method | Description | Backends |
|---|---|---|
DrawPrimitives(type, startVertex, primitiveCount) |
Non-indexed draw from the bound vertex buffer | All |
DrawIndexedPrimitives(type, baseVertex, startIndex, primitiveCount) |
Indexed draw; combines vertices with index buffer | All |
DrawInstancedPrimitives(type, baseVertex, startIndex, primitiveCount, instanceCount) |
GPU instancing: draw instanceCount copies in a single call | EasyGL, Vulkan |
DrawUserPrimitives<T>(type, vertices[], vertexOffset, primitiveCount) |
Immediate-mode draw; no VertexBuffer object needed |
All |
DrawUserPrimitives uploads vertex data to a transient buffer on every call. It is convenient for small one-off shapes but inefficient for large or frequently drawn geometry. Prefer VertexBuffer for anything drawn more than once per frame.
PrimitiveType
All draw calls accept a PrimitiveType that tells the GPU how to interpret the vertex stream.
| Value | Vertices per primitive | Description |
|---|---|---|
TriangleList |
3 per triangle | Each group of three vertices forms an independent triangle. Most common for solid meshes. |
TriangleStrip |
1 per additional triangle | First triangle needs 3 vertices; each subsequent triangle reuses the last two. Reduces index count for sequential strips. |
LineList |
2 per line | Each pair of vertices forms an independent line segment. Useful for debug overlays. |
LineStrip |
1 per additional segment | Vertices form a continuous polyline. Efficient for curves and paths. |
BasicEffect 3D setup
BasicEffect is the standard effect for solid 3D geometry. It supports per-vertex and per-pixel lighting with up to three independent directional lights, a single diffuse texture, per-vertex color tinting, and distance fog. See the Effects System page for the full API reference; the sections below focus on the typical 3D setup pattern.
The minimal setup for a lit, textured mesh:
- Set the three transformation matrices:
World,View,Projection. - Enable lighting with
EnableDefaultLighting()or configureDirectionalLight0manually. - Enable and assign a texture via
TextureEnabled = trueandTexture = myTex. - Call
CurrentTechnique->Passes[0]->Apply()to upload all constants to the GPU. - Issue the draw call.
auto effect = std::make_shared<BasicEffect>(graphicsDevice);
// Matrices
effect->SetWorld(Matrix::Identity);
effect->SetView(Matrix::CreateLookAt(
Vector3(0, 2, 5), // camera position
Vector3::Zero, // look-at target
Vector3::Up));
effect->SetProjection(Matrix::CreatePerspectiveFieldOfView(
MathHelper::PiOver4, // 45 degrees
graphicsDevice->Viewport.AspectRatio(),
0.1f, 100.0f));
// Lighting
effect->EnableDefaultLighting();
// Texture
effect->SetTextureEnabled(true);
effect->SetTexture(wallTexture);
// Apply and draw
effect->CurrentTechnique->Passes[0]->Apply();
graphicsDevice->DrawIndexedPrimitives(
PrimitiveType::TriangleList, 0, 0, indexCount / 3);
Camera setup
CNA reproduces the XNA 4.0 Matrix factory methods for building camera and projection matrices. The view matrix transforms world-space coordinates into camera space; the projection matrix further transforms camera-space coordinates into clip space.
// View matrix: camera at (3, 4, 5) looking at the origin, Y is up
Matrix view = Matrix::CreateLookAt(
Vector3(3.0f, 4.0f, 5.0f), // eye position
Vector3::Zero, // target
Vector3::Up); // up direction
// Perspective projection: 60-degree vertical FOV, standard near/far
float aspectRatio = graphicsDevice->Viewport.AspectRatio();
Matrix projection = Matrix::CreatePerspectiveFieldOfView(
MathHelper::ToRadians(60.0f),
aspectRatio,
0.1f, // near plane
500.0f); // far plane
// Orthographic projection (for UI or isometric views)
Matrix ortho = Matrix::CreateOrthographic(
graphicsDevice->Viewport.Width,
graphicsDevice->Viewport.Height,
-1.0f, 1.0f);
CNA uses a right-handed coordinate system with Y-up, matching XNA 4.0. The near and far planes are in view-space distance units; keep the ratio of farPlane / nearPlane as small as practical to maintain depth buffer precision.
Depth buffer and blend state
Depth buffer
The depth buffer prevents back faces of opaque geometry from overwriting front faces. CNA initialises the GraphicsDevice with depth testing enabled by default. You can switch states explicitly when mixing 3D and 2D content in the same frame.
| State preset | Depth test | Depth write | Typical use |
|---|---|---|---|
DepthStencilState::Default |
Less | Yes | Standard opaque 3D geometry |
DepthStencilState::DepthRead |
Less | No | Transparent or decal geometry that tests but does not write depth |
DepthStencilState::None |
Off | No | SpriteBatch, HUD, and 2D overlays drawn after 3D geometry |
// 3D pass: depth enabled
graphicsDevice->DepthStencilState = DepthStencilState::Default;
Draw3DScene();
// 2D overlay pass: depth off so sprites always appear on top
graphicsDevice->DepthStencilState = DepthStencilState::None;
spriteBatch->Begin();
spriteBatch->DrawString(font, L"Score: 100", Vector2(10, 10), Color::White);
spriteBatch->End();
Blend state
Transparent 3D geometry requires an additive or alpha-blend state and should be drawn back-to-front after all opaque geometry.
// Opaque geometry first (depth writes on)
graphicsDevice->BlendState = BlendState::Opaque;
graphicsDevice->DepthStencilState = DepthStencilState::Default;
DrawOpaqueMeshes();
// Transparent geometry last (depth writes off, alpha blend)
graphicsDevice->BlendState = BlendState::AlphaBlend;
graphicsDevice->DepthStencilState = DepthStencilState::DepthRead;
DrawTransparentMeshes(); // caller responsible for back-to-front sorting
RenderTarget2D
Off-screen rendering lets you draw a scene into a texture and then use that texture in a subsequent draw call — for example for shadow maps, reflections, or post-process effects.
// Create a 512x512 off-screen target
auto rt = std::make_shared<RenderTarget2D>(
graphicsDevice, 512, 512,
false, // no mipmaps
SurfaceFormat::Color,
DepthFormat::Depth24);
// Render the scene into the target
graphicsDevice->SetRenderTarget(rt);
graphicsDevice->Clear(Color::Black);
DrawMirroredScene();
// Restore the back buffer
graphicsDevice->SetRenderTarget(nullptr);
// Use the render target as a texture in the next pass
effect->SetTextureEnabled(true);
effect->SetTexture(rt); // RenderTarget2D is-a Texture2D
effect->CurrentTechnique->Passes[0]->Apply();
DrawFullScreenQuad();
Model rendering
The Model class represents a complete 3D asset loaded by the ContentManager. It handles mesh hierarchy, bone transforms, and effect binding internally. The minimal draw call is a single line:
model->Draw(worldMatrix, viewMatrix, projectionMatrix);
Model::Draw iterates over all ModelMesh objects within the model, applies each mesh’s accumulated bone transform to the supplied world matrix, and calls the appropriate effect. Each mesh carries its own MeshPart list and associated Effect. For most assets this is a BasicEffect configured with the texture and lighting settings baked in at export time.
To override the effect on all meshes (for example to apply a custom tint or swap to a SkinnedEffect for animation):
// Replace the effect on every mesh part
for (auto& mesh : model->Meshes) {
for (auto& part : mesh->MeshParts) {
auto be = std::dynamic_pointer_cast<BasicEffect>(part->Effect);
if (be) {
be->SetEmissiveColor(Vector3(0.1f, 0.0f, 0.0f)); // subtle red tint
}
}
}
model->Draw(world, view, projection);
Skeletal animation drives the ModelBone hierarchy. Compute the final bone-pose matrices with your animation system and pass them to the effect before drawing:
// For a skinned model: update bone matrices each frame
std::vector<Matrix> boneTransforms(model->Bones.Count());
model->CopyAbsoluteBoneTransformsTo(boneTransforms);
for (auto& mesh : model->Meshes) {
for (auto& part : mesh->MeshParts) {
auto se = std::dynamic_pointer_cast<SkinnedEffect>(part->Effect);
if (se) {
se->SetBoneTransforms(boneTransforms);
}
}
mesh->Draw();
}
Texture3D and TextureCube
Texture3D (volumetric) and TextureCube (six-face cube map) are implemented on the EasyGL and Vulkan backends. TextureCube is used directly by EnvironmentMapEffect for reflection mapping. Texture3D is useful for volumetric fog, 3D lookup tables (LUTs), and density fields.
// Load a cube map through ContentManager
auto skybox = content.Load<TextureCube>("textures/skybox");
auto envEffect = std::make_shared<EnvironmentMapEffect>(graphicsDevice);
envEffect->SetEnvironmentMap(skybox);
envEffect->SetEnvironmentMapAmount(1.0f);
envEffect->SetFresnelFactor(0.5f);
Advanced topics
GPU instancing
Drawing many identical meshes (trees, rocks, crowd members) can be done in a single GPU call using DrawInstancedPrimitives. The per-instance data (world matrix, colour, etc.) is uploaded in a second vertex buffer bound to stream index 1 with a step frequency of 1 instance rather than 1 vertex.
// Second vertex buffer holds one matrix per instance
auto instanceVB = std::make_shared<VertexBuffer>(
graphicsDevice, InstanceData::VertexDeclaration,
instanceCount, BufferUsage::WriteOnly);
instanceVB->SetData(instanceMatrices.data(), instanceCount);
// Bind both streams
graphicsDevice->SetVertexBuffers({
VertexBufferBinding(meshVB, 0, 0), // stream 0: mesh geometry
VertexBufferBinding(instanceVB, 0, 1), // stream 1: per-instance data
});
graphicsDevice->Indices = meshIB;
effect->CurrentTechnique->Passes[0]->Apply();
graphicsDevice->DrawInstancedPrimitives(
PrimitiveType::TriangleList,
0, 0, triangleCount, instanceCount);
DrawInstancedPrimitives is available on EasyGL and Vulkan only. The bgfx and SDL_RENDERER backends do not currently support it.
Multiple render targets (MRT)
The EasyGL and Vulkan backends support binding multiple RenderTarget2D objects simultaneously, enabling G-buffer rendering for deferred shading. Pass an array of render targets to SetRenderTargets:
graphicsDevice->SetRenderTargets({
RenderTargetBinding(albedoRT),
RenderTargetBinding(normalRT),
RenderTargetBinding(depthRT),
});
DrawGeometryPass();
// Restore back buffer
graphicsDevice->SetRenderTarget(nullptr);
OcclusionQuery
OcclusionQuery asks the GPU how many fragments passed the depth test for a given draw. This is used for techniques like hardware-accelerated lens flares or conditional rendering of expensive effects.
auto query = std::make_shared<OcclusionQuery>(graphicsDevice);
query->Begin();
DrawProxyGeometry(); // small bounding box or billboard
query->End();
// On a later frame (avoid stalling the GPU by checking immediately)
if (query->IsComplete) {
bool visible = query->PixelCount > 0;
}
Code examples
Minimal 3D triangle
The smallest complete example: a coloured triangle rendered with BasicEffect and no index buffer.
// --- Setup (LoadContent) ---
std::vector<VertexPositionColor> verts = {
{ Vector3( 0.0f, 0.5f, 0.0f), Color::Red },
{ Vector3( 0.5f, -0.5f, 0.0f), Color::Green },
{ Vector3(-0.5f, -0.5f, 0.0f), Color::Blue },
};
auto vb = std::make_shared<VertexBuffer>(
graphicsDevice,
VertexPositionColor::VertexDeclaration,
3, BufferUsage::None);
vb->SetData(verts.data(), 3);
auto effect = std::make_shared<BasicEffect>(graphicsDevice);
effect->SetVertexColorEnabled(true);
effect->SetWorld(Matrix::Identity);
effect->SetView(Matrix::CreateLookAt(
Vector3(0, 0, 3), Vector3::Zero, Vector3::Up));
effect->SetProjection(Matrix::CreatePerspectiveFieldOfView(
MathHelper::PiOver4,
graphicsDevice->Viewport.AspectRatio(),
0.1f, 100.0f));
// --- Draw (Draw) ---
graphicsDevice->Clear(Color::CornflowerBlue);
graphicsDevice->SetVertexBuffer(vb);
effect->CurrentTechnique->Passes[0]->Apply();
graphicsDevice->DrawPrimitives(PrimitiveType::TriangleList, 0, 1);
Textured cube with camera
A complete rotating textured cube using an index buffer and a perspective camera.
// 8 unique vertices for a unit cube
std::vector<VertexPositionTexture> cubeVerts = {
// front
{ Vector3(-0.5f, 0.5f, 0.5f), Vector2(0, 0) },
{ Vector3( 0.5f, 0.5f, 0.5f), Vector2(1, 0) },
{ Vector3( 0.5f, -0.5f, 0.5f), Vector2(1, 1) },
{ Vector3(-0.5f, -0.5f, 0.5f), Vector2(0, 1) },
// back
{ Vector3( 0.5f, 0.5f, -0.5f), Vector2(0, 0) },
{ Vector3(-0.5f, 0.5f, -0.5f), Vector2(1, 0) },
{ Vector3(-0.5f, -0.5f, -0.5f), Vector2(1, 1) },
{ Vector3( 0.5f, -0.5f, -0.5f), Vector2(0, 1) },
// ... (left, right, top, bottom faces omitted for brevity)
};
std::vector<uint16_t> cubeIdx = {
0,1,2, 0,2,3, // front
4,5,6, 4,6,7, // back
// ...
};
auto cubeVB = std::make_shared<VertexBuffer>(graphicsDevice,
VertexPositionTexture::VertexDeclaration,
static_cast<int>(cubeVerts.size()), BufferUsage::None);
cubeVB->SetData(cubeVerts.data(), static_cast<int>(cubeVerts.size()));
auto cubeIB = std::make_shared<IndexBuffer>(graphicsDevice,
IndexElementSize::SixteenBits,
static_cast<int>(cubeIdx.size()), BufferUsage::None);
cubeIB->SetData(cubeIdx.data(), static_cast<int>(cubeIdx.size()));
auto cubeEffect = std::make_shared<BasicEffect>(graphicsDevice);
cubeEffect->SetTextureEnabled(true);
cubeEffect->SetTexture(crateTexture);
// In Update(): accumulate rotation angle
float angle += (float)gameTime.ElapsedGameTime.TotalSeconds();
// In Draw():
graphicsDevice->Clear(Color::CornflowerBlue);
graphicsDevice->DepthStencilState = DepthStencilState::Default;
cubeEffect->SetWorld(Matrix::CreateRotationY(angle));
cubeEffect->SetView(Matrix::CreateLookAt(
Vector3(0, 1.5f, 3), Vector3::Zero, Vector3::Up));
cubeEffect->SetProjection(Matrix::CreatePerspectiveFieldOfView(
MathHelper::PiOver4,
graphicsDevice->Viewport.AspectRatio(),
0.1f, 100.0f));
graphicsDevice->SetVertexBuffer(cubeVB);
graphicsDevice->Indices = cubeIB;
cubeEffect->CurrentTechnique->Passes[0]->Apply();
graphicsDevice->DrawIndexedPrimitives(
PrimitiveType::TriangleList, 0, 0,
static_cast<int>(cubeIdx.size()) / 3);
Model::Draw usage
Load a .cnamodel asset through the ContentManager and draw it with a single call.
// LoadContent
auto houseModel = content.Load<Model>("models/house");
// Update: animate position or rotation
Matrix houseWorld = Matrix::CreateTranslation(housePos);
// Draw
Matrix view = Matrix::CreateLookAt(cameraPos, cameraTarget, Vector3::Up);
Matrix proj = Matrix::CreatePerspectiveFieldOfView(
MathHelper::PiOver4,
graphicsDevice->Viewport.AspectRatio(),
0.5f, 1000.0f);
graphicsDevice->DepthStencilState = DepthStencilState::Default;
graphicsDevice->BlendState = BlendState::Opaque;
houseModel->Draw(houseWorld, view, proj);
Wireframe rendering (Vulkan only)
Wireframe rendering requires RasterizerState::WireFrame, which sets FillMode::WireFrame. This fill mode is not available on the EasyGL (OpenGL ES 3.0) backend because OpenGL ES does not expose a polygon fill-mode toggle. Use the Vulkan backend for wireframe output.
// Switch to wireframe (Vulkan backend required)
graphicsDevice->RasterizerState = RasterizerState::WireFrame;
effect->SetVertexColorEnabled(true);
effect->CurrentTechnique->Passes[0]->Apply();
graphicsDevice->DrawIndexedPrimitives(
PrimitiveType::TriangleList, 0, 0, triangleCount);
// Restore solid fill for the next draw
graphicsDevice->RasterizerState = RasterizerState::CullCounterClockwise;
FillMode::WireFrame is not available on the EasyGL backend (OpenGL ES 3.0 does not support polygon mode). An alternative on EasyGL is to draw your geometry with PrimitiveType::LineList or LineStrip, or to generate wireframe edge geometry explicitly in the vertex buffer.