Tutorial 38: Vertex Buffers and Index Buffers

3D Rendering  ·  Intermediate

Previous tutorials used VertexBuffer informally. Here we go deeper: static vs. dynamic buffers, index buffers for vertex reuse, VertexDeclaration, and how to stream per-frame data into a dynamic buffer for procedural geometry.

VertexBuffer Creation (Static vs Dynamic)

The BufferUsage enum controls how the GPU driver allocates and manages the buffer memory:

BufferUsageCPU accessUse case
WriteOnlyWrite-only, once at creationStatic terrain, buildings, any mesh that never changes
NoneRead and write at any timeParticle systems, cloth simulation, procedural animation
// Static buffer — upload once, draw many times
auto staticVB = std::make_unique<VertexBuffer>(
    gd, VertexPositionColor::VertexDeclaration,
    vertexCount, BufferUsage::WriteOnly);
staticVB->SetData(vertices, vertexCount);

// Dynamic buffer — updated every frame
auto dynamicVB = std::make_unique<VertexBuffer>(
    gd, VertexPositionColor::VertexDeclaration,
    MAX_VERTS, BufferUsage::None);
// Call SetData again in Update() each frame

SetData<T>

VertexBuffer::SetData is a template that accepts any vertex struct. The element stride is derived automatically from sizeof(T):

// Full upload
vb->SetData(vertices, count);

// Partial upload — update only elements [offset, offset+count)
vb->SetData(vertices + offset, count, offset);

// Dynamic update each frame (inside Update())
for (int i = 0; i < GRID_W * GRID_H; ++i) {
    gridVerts[i].Position.y =
        std::sin(totalTime + gridVerts[i].Position.x * 2.0f) * 0.3f;
}
dynamicVB->SetData(gridVerts, GRID_W * GRID_H);

VertexDeclaration

A VertexDeclaration describes the memory layout of a single vertex to the GPU — which attributes exist, their formats, and their byte offsets within the struct. Built-in vertex types expose a static VertexDeclaration member:

VertexPositionColor::VertexDeclaration       // Position(float3) + Color(byte4)
VertexPositionTexture::VertexDeclaration     // Position(float3) + TexCoord(float2)
VertexPositionNormal::VertexDeclaration      // Position(float3) + Normal(float3)
VertexPositionNormalTexture::VertexDeclaration // Position + Normal + TexCoord

Pass the declaration when constructing a VertexBuffer. CNA forwards it to the backend to set up vertex attribute pointers before each draw call.

IndexBuffer (16-bit vs 32-bit)

An IndexBuffer holds an ordered list of indices into the vertex buffer. Instead of duplicating vertex data for shared edges, you store each vertex once and reference it multiple times:

// A quad: 4 vertices, 6 indices (2 triangles)
VertexPositionColor verts[4] = { /* corners */ };
uint16_t indices[6] = { 0,1,2,  0,2,3 };

auto ib = std::make_unique<IndexBuffer>(
    gd, IndexElementSize::SixteenBits, 6, BufferUsage::WriteOnly);
ib->SetData(indices, 6);
IndexElementSizeIndex typeMax vertices
SixteenBitsuint16_t65,535
ThirtyTwoBitsuint32_t4,294,967,295

Prefer 16-bit indices for small meshes: they use half the GPU memory bandwidth and can improve cache utilisation.

DrawIndexedPrimitives

gd.setVertexBuffer(*vb);
gd.setIndexBuffer(*ib);

for (auto& pass : effect->getCurrentTechnique().Passes) {
    pass.Apply();
    gd.DrawIndexedPrimitives(
        PrimitiveType::TriangleList,
        0,      // baseVertex — added to every index value
        0,      // minVertexIndex — smallest index in this call
        4,      // numVertices — how many vertices are referenced
        0,      // startIndex — first index in the index buffer
        2       // primitiveCount — number of triangles
    );
}

Always bind both VertexBuffer and IndexBuffer on the device before calling DrawIndexedPrimitives. Forgetting either results in undefined behaviour or a runtime error.

BufferUsage::WriteOnly vs None

WriteOnly signals to the driver that the application will never read back vertex data from the GPU. This allows the driver to place the buffer in the most efficient GPU-local memory region. Attempting to read a WriteOnly buffer from the CPU is undefined behaviour.

None permits CPU reads and repeated writes. Use it whenever you call SetData after the buffer has been drawn at least once (typical for dynamic meshes).

Updating Dynamic Buffers

For large meshes that change every frame, use partial updates to avoid uploading unchanged regions:

// Only update the portion that changed
vb->SetData(dirtyVerts, dirtyCount, dirtyOffset);

// For streaming data (ring-buffer pattern): advance offset each frame
// and wrap around when you reach the end of the buffer

Never recreate the VertexBuffer object each frame — allocation is expensive. Allocate once with the maximum size you will ever need and update its contents.

Full Example: Static Quad + Dynamic Sine-Wave Grid

The demo shows two techniques. Press Space to toggle between them:

  • Static quad — 4 vertices, 6 indices, BufferUsage::WriteOnly, drawn with DrawIndexedPrimitives.
  • Dynamic grid — 16×16 sine-wave surface whose vertex Y positions are updated every frame via SetData, BufferUsage::None.
#include "Microsoft/Xna/Framework/Game.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/IndexBuffer.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexPositionColor.hpp"
#include "Microsoft/Xna/Framework/Input/Keyboard.hpp"
#include <cmath>
#include <vector>

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

static constexpr int GW = 16, GH = 16;

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

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

        BuildStaticQuad();
        BuildDynamicGrid();
    }

    void Update(GameTime& gt) override {
        auto kb = Keyboard::GetState();
        if (kb.IsKeyDown(Keys::Escape)) Exit();
        if (kb.IsKeyDown(Keys::Space) && !prevSpace_) showGrid_ = !showGrid_;
        prevSpace_ = kb.IsKeyDown(Keys::Space);

        time_ += static_cast<float>(gt.ElapsedGameTime.TotalSeconds());

        // Animate the grid vertices on the CPU
        for (int z = 0; z < GH; ++z) {
            for (int x = 0; x < GW; ++x) {
                auto& v = gridVerts_[z * GW + x];
                float wx = v.Position.x;
                float wz = v.Position.z;
                v.Position.y = std::sin(wx * 2.0f + time_) *
                               std::cos(wz * 2.0f + time_) * 0.3f;
                float t = (v.Position.y + 0.3f) / 0.6f;
                v.Color = Color::Lerp(Color::Blue, Color::Yellow, t);
            }
        }
        dynamicVB_->SetData(gridVerts_.data(), static_cast<int>(gridVerts_.size()));
    }

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

        effect_->setView(Matrix::CreateLookAt(
            {0, 2, 4}, Vector3::Zero, Vector3::Up));
        effect_->setProjection(Matrix::CreatePerspectiveFieldOfView(
            MathHelper::PiOver4, 800.0f / 600.0f, 0.1f, 100.0f));
        effect_->setWorld(Matrix::Identity);

        if (!showGrid_) {
            // --- Static quad via DrawIndexedPrimitives ---
            gd.setVertexBuffer(*staticVB_);
            gd.setIndexBuffer(*staticIB_);
            for (auto& pass : effect_->getCurrentTechnique().Passes) {
                pass.Apply();
                gd.DrawIndexedPrimitives(PrimitiveType::TriangleList,
                    0, 0, 4, 0, 2);
            }
        } else {
            // --- Dynamic sine-wave grid ---
            gd.setVertexBuffer(*dynamicVB_);
            gd.setIndexBuffer(*gridIB_);
            for (auto& pass : effect_->getCurrentTechnique().Passes) {
                pass.Apply();
                int triCount = (GW - 1) * (GH - 1) * 2;
                gd.DrawIndexedPrimitives(PrimitiveType::TriangleList,
                    0, 0, GW * GH, 0, triCount);
            }
        }
        gd.Present();
    }

private:
    void BuildStaticQuad() {
        VertexPositionColor verts[4] = {
            { {-0.5f,  0.5f, 0}, Color::Red   },
            { { 0.5f,  0.5f, 0}, Color::Green },
            { { 0.5f, -0.5f, 0}, Color::Blue  },
            { {-0.5f, -0.5f, 0}, Color::White },
        };
        uint16_t idx[6] = { 0,1,2, 0,2,3 };

        auto& gd = getGraphicsDeviceProperty();
        staticVB_ = std::make_unique<VertexBuffer>(gd,
            VertexPositionColor::VertexDeclaration, 4, BufferUsage::WriteOnly);
        staticVB_->SetData(verts, 4);

        staticIB_ = std::make_unique<IndexBuffer>(gd,
            IndexElementSize::SixteenBits, 6, BufferUsage::WriteOnly);
        staticIB_->SetData(idx, 6);
    }

    void BuildDynamicGrid() {
        gridVerts_.resize(GW * GH);
        for (int z = 0; z < GH; ++z)
            for (int x = 0; x < GW; ++x) {
                float fx = (x / float(GW - 1) - 0.5f) * 3.0f;
                float fz = (z / float(GH - 1) - 0.5f) * 3.0f;
                gridVerts_[z * GW + x] = { {fx, 0, fz}, Color::Cyan };
            }

        std::vector<uint16_t> idx;
        for (int z = 0; z < GH - 1; ++z)
            for (int x = 0; x < GW - 1; ++x) {
                uint16_t tl = z*GW+x, tr = tl+1;
                uint16_t bl = tl+GW,  br = bl+1;
                idx.insert(idx.end(), {tl,tr,br, tl,br,bl});
            }

        auto& gd = getGraphicsDeviceProperty();
        dynamicVB_ = std::make_unique<VertexBuffer>(gd,
            VertexPositionColor::VertexDeclaration,
            GW * GH, BufferUsage::None);   // dynamic!
        dynamicVB_->SetData(gridVerts_.data(), GW * GH);

        gridIB_ = std::make_unique<IndexBuffer>(gd,
            IndexElementSize::SixteenBits,
            static_cast<int>(idx.size()), BufferUsage::WriteOnly);
        gridIB_->SetData(idx.data(), static_cast<int>(idx.size()));
    }

    GraphicsDeviceManager graphics_;
    std::unique_ptr<BasicEffect>  effect_;
    std::unique_ptr<VertexBuffer> staticVB_, dynamicVB_;
    std::unique_ptr<IndexBuffer>  staticIB_, gridIB_;
    std::vector<VertexPositionColor> gridVerts_;
    float time_ = 0.0f;
    bool  showGrid_ = false;
    bool  prevSpace_ = false;
};

int main() { BufferDemoGame g; g.Run(); }

Key Points

  • Use BufferUsage::WriteOnly for static geometry; BufferUsage::None for geometry updated each frame.
  • Never recreate a VertexBuffer each frame — allocate once at max capacity and call SetData to stream new data.
  • 16-bit index buffers (SixteenBits) are sufficient for most meshes and use half the memory of 32-bit.
  • Bind both vertex and index buffer before calling DrawIndexedPrimitives.
  • DrawIndexedPrimitives accepts a baseVertex offset so multiple meshes can share a single large vertex buffer.