3D Rendering

Microsoft::Xna::Framework::Graphics — vertex buffers, draw calls, effects, cameras, and the full 3D pipeline

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:

  1. Set the three transformation matrices: World, View, Projection.
  2. Enable lighting with EnableDefaultLighting() or configure DirectionalLight0 manually.
  3. Enable and assign a texture via TextureEnabled = true and Texture = myTex.
  4. Call CurrentTechnique->Passes[0]->Apply() to upload all constants to the GPU.
  5. 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.