Tutorial 31: Your First 3D Triangle

3D Rendering  ·  Beginner

What you’ll learn

  • The 3D coordinate system CNA uses and how it differs from screen space.
  • Filling a VertexBuffer with VertexPositionColor data.
  • Setting world, view and projection on a BasicEffect.
  • Drawing with DrawPrimitives, and why SpriteBatch has no role here.

Before you startTutorial 06: Drawing Your First 2D Shape — and note that the matrices used here get their full treatment in Tutorial 33: Matrices and Transformations, so a rough idea is enough for now. Requires a 3D-capable renderer such as OPENGLES3 or VULKAN; the 2D-only renderers (SDL_RENDERER, DIRECT2D, CANVAS, HTML_DOM, SKIA, BLEND2D, FREEDIRECT, DIRECTX1, GDI, SVG_DOM, OPENVG, NANOVG, PIXIJS) throw on 3D calls.

3D rendering in CNA requires the OPENGLES3 or VULKAN renderer. It is not available on SDL_RENDERER. Build with -DCNA_GRAPHICS_RENDERER=OPENGLES3 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::getVertexDeclarationStatic(),
    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->VertexColorEnabled = true;   // use per-vertex Color
effect->setLightingEnabledProperty(false);     // no lighting for this example

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

// View: camera at (0,0,3) looking at origin
effect->setViewProperty(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->setProjectionProperty(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_.get());

// 2. For each pass in the technique, apply and draw
for (auto& pass : effect_->getCurrentTechniqueProperty()->getPassesProperty()) {
    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.

A single triangle on a cornflower-blue background with a red bottom-left vertex, a blue bottom-right vertex and a green top vertex, their colours blended smoothly across the face.

This is what the code above produces: one VertexPositionColor triangle, its three vertex colours interpolated by the rasteriser. The image is genuine Microsoft XNA 4.0 runtime output from CNA's oracle corpus (tools/xna-oracle/), which CNA's DIRECTX9 renderer is diffed against at --tolerance 0 — so this is literally the frame the original runtime drew.

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_.get());
    for (auto& pass : effect_->getCurrentTechniqueProperty()->getPassesProperty()) {
        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/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_.setPreferredBackBufferWidthProperty(800);
        graphics_.setPreferredBackBufferHeightProperty(600);
    }

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

        // Fixed view and projection (don't change each frame)
        effect_->setViewProperty(Matrix::CreateLookAt(
            Vector3(0.0f, 0.0f, 3.0f),
            Vector3::Zero,
            Vector3::Up));
        effect_->setProjectionProperty(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::getVertexDeclarationStatic(),
            3, BufferUsage::None);
        vb_->SetData(verts, 3);
    }

    void Update(GameTime& gameTime) override {
        float dt = (float)gameTime.getElapsedGameTimeProperty().getTotalSecondsProperty();
        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_->setWorldProperty(Matrix::CreateRotationY(angle_));

        gd.SetVertexBuffer(vb_.get());
        for (auto& pass : effect_->getCurrentTechniqueProperty()->getPassesProperty()) {
            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.