Tutorial 31: Your First 3D Triangle

3D Rendering  ·  Beginner

3D rendering in CNA requires the EASYGL or VULKAN backend. It is not available on SDL_RENDERER. Build with -DCNA_GRAPHICS_BACKEND=EASYGL to follow this tutorial.

3D Coordinate System

CNA (and XNA) use a right-handed coordinate system: X points right, Y points up, Z points toward the viewer (out of the screen). This is the same as OpenGL.

To get a triangle onto the screen, three spaces are involved:

SpaceMatrixDescription
World spaceWorldWhere objects sit in the scene
View (camera) spaceViewScene from the camera's perspective
Clip / NDC spaceProjectionPerspective divide, maps to [-1,1] NDC

The GPU multiplies each vertex position by World × View × Projection to arrive at Normalized Device Coordinates (NDC), then maps those to the viewport pixels. BasicEffect does this multiplication for you once you set the three matrices.

VertexPositionColor Struct

The simplest 3D vertex type carries a position and a colour:

#include "Microsoft/Xna/Framework/Graphics/VertexPositionColor.hpp"
// Also available:
// VertexPositionTexture.hpp          — position + UV
// VertexPositionNormalTexture.hpp    — position + normal + UV (required for lighting)
// VertexPositionColorTexture.hpp     — position + colour + UV

VertexPositionColor v;
v.Position = Vector3(0.0f, 0.5f, 0.0f);
v.Color    = Color::Red;

Each vertex type has a static VertexDeclaration member that describes its layout to the GPU. You pass this to the VertexBuffer constructor.

VertexBuffer Creation

A VertexBuffer stores vertex data on the GPU. Create it with the device, the vertex layout, the count, and a usage hint:

#include "Microsoft/Xna/Framework/Graphics/VertexBuffer.hpp"

VertexPositionColor vertices[3] = {
    { 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_unique<VertexBuffer>(
    getGraphicsDeviceProperty(),
    VertexPositionColor::VertexDeclaration,
    3,                    // vertex count
    BufferUsage::None     // None = CPU can read back; WriteOnly = GPU-only (faster)
);
vb->SetData(vertices, 3);  // upload to GPU

BasicEffect Setup

BasicEffect is CNA's built-in shader for unlit and lit geometry. For a coloured triangle set VertexColorEnabled and provide the three transform matrices:

#include "Microsoft/Xna/Framework/Graphics/BasicEffect.hpp"

auto effect = std::make_unique<BasicEffect>(getGraphicsDeviceProperty());
effect->setVertexColorEnabled(true);   // use per-vertex Color
effect->setLightingEnabled(false);     // no lighting for this example

// World: identity — triangle is at the origin
effect->setWorld(Matrix::Identity);

// View: camera at (0,0,3) looking at origin
effect->setView(Matrix::CreateLookAt(
    Vector3(0.0f, 0.0f, 3.0f),   // camera position
    Vector3::Zero,                 // look-at target
    Vector3::Up));                 // up direction

// Projection: 45-degree FOV, aspect 800/600, near 0.1, far 100
effect->setProjection(Matrix::CreatePerspectiveFieldOfView(
    MathHelper::PiOver4,
    800.0f / 600.0f,
    0.1f, 100.0f));

DrawPrimitives Call

The draw sequence is always: bind vertex buffer → apply effect pass → draw:

auto& gd = getGraphicsDeviceProperty();

// 1. Bind the vertex buffer
gd.setVertexBuffer(*vb_);

// 2. For each pass in the technique, apply and draw
for (auto& pass : effect_->getCurrentTechnique().Passes) {
    pass.Apply();
    // PrimitiveType::TriangleList: every 3 vertices = 1 triangle
    // startVertex = 0, primitiveCount = 1
    gd.DrawPrimitives(PrimitiveType::TriangleList, 0, 1);
}

For PrimitiveType::TriangleList, primitiveCount is the number of triangles, not vertices. Two triangles (a quad) would be primitiveCount = 2 with 6 vertices.

Why No SpriteBatch in 3D

SpriteBatch is a high-level 2D helper — it internally creates its own vertex buffer and sets an orthographic projection. In 3D you manage these directly so you can control every aspect of the pipeline.

You can mix them: render the 3D scene first, then wrap the HUD in a SpriteBatch::Begin() / End() pair. SpriteBatch restores all graphics state it changes, so it won't corrupt your 3D setup as long as you set the effect matrices again after End().

void Draw(const GameTime&) override {
    auto& gd = getGraphicsDeviceProperty();
    gd.Clear(Color::CornflowerBlue);

    // --- 3D scene ---
    gd.setVertexBuffer(*vb_);
    for (auto& pass : effect_->getCurrentTechnique().Passes) {
        pass.Apply();
        gd.DrawPrimitives(PrimitiveType::TriangleList, 0, 1);
    }

    // --- 2D HUD on top ---
    spriteBatch_->Begin();
    // draw HUD sprites / text here
    spriteBatch_->End();

    gd.Present();
}

Complete Example — Rotating Coloured Triangle

#include <memory>
#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/GameTime.hpp"
#include "Microsoft/Xna/Framework/Color.hpp"
#include "Microsoft/Xna/Framework/MathHelper.hpp"
#include "Microsoft/Xna/Framework/Matrix.hpp"
#include "Microsoft/Xna/Framework/Vector3.hpp"
#include "Microsoft/Xna/Framework/Graphics/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/BasicEffect.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexBuffer.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexPositionColor.hpp"
#include "Microsoft/Xna/Framework/Graphics/PrimitiveType.hpp"
#include "Microsoft/Xna/Framework/Graphics/BufferUsage.hpp"

using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;

class TriangleGame final : public Game {
public:
    TriangleGame() : graphics_(this) {
        graphics_.setPreferredBackBufferWidth(800);
        graphics_.setPreferredBackBufferHeight(600);
    }

protected:
    void LoadContent() override {
        effect_ = std::make_unique<BasicEffect>(getGraphicsDeviceProperty());
        effect_->setVertexColorEnabled(true);
        effect_->setLightingEnabled(false);

        // Fixed view and projection (don't change each frame)
        effect_->setView(Matrix::CreateLookAt(
            Vector3(0.0f, 0.0f, 3.0f),
            Vector3::Zero,
            Vector3::Up));
        effect_->setProjection(Matrix::CreatePerspectiveFieldOfView(
            MathHelper::PiOver4,
            800.0f / 600.0f,
            0.1f, 100.0f));

        VertexPositionColor verts[3] = {
            { Vector3( 0.0f,  0.6f, 0.0f), Color::Red   },
            { Vector3( 0.5f, -0.4f, 0.0f), Color::Lime  },
            { Vector3(-0.5f, -0.4f, 0.0f), Color::Blue  },
        };

        vb_ = std::make_unique<VertexBuffer>(
            getGraphicsDeviceProperty(),
            VertexPositionColor::VertexDeclaration,
            3, BufferUsage::None);
        vb_->SetData(verts, 3);
    }

    void Update(GameTime& gameTime) override {
        float dt = (float)gameTime.getElapsedGameTime().TotalSeconds();
        angle_ += dt * 1.2f;  // ~1.2 radians per second
        if (angle_ > MathHelper::TwoPi) angle_ -= MathHelper::TwoPi;
    }

    void Draw(const GameTime&) override {
        auto& gd = getGraphicsDeviceProperty();
        gd.Clear(Color(20, 20, 35, 255));

        // Rotate around Y axis
        effect_->setWorld(Matrix::CreateRotationY(angle_));

        gd.setVertexBuffer(*vb_);
        for (auto& pass : effect_->getCurrentTechnique().Passes) {
            pass.Apply();
            gd.DrawPrimitives(PrimitiveType::TriangleList, 0, 1);
        }

        gd.Present();
    }

private:
    GraphicsDeviceManager          graphics_;
    std::unique_ptr<BasicEffect>   effect_;
    std::unique_ptr<VertexBuffer>  vb_;
    float                          angle_ = 0.0f;
};

int main() {
    TriangleGame game;
    game.Run();
    return 0;
}

Key Points

  • CNA uses a right-handed coordinate system: X right, Y up, Z toward you.
  • VertexPositionColor is the simplest 3D vertex. Use VertexPositionNormalTexture when you need lighting or textures.
  • Always set World, View, and Projection before calling pass.Apply().
  • primitiveCount in DrawPrimitives counts triangles, not vertices.
  • SpriteBatch and 3D can coexist in the same frame — draw 3D first, then the HUD.