Tutorial 38: Vertex Buffers and Index Buffers
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:
| BufferUsage | CPU access | Use case |
|---|---|---|
WriteOnly | Write-only, once at creation | Static terrain, buildings, any mesh that never changes |
None | Read and write at any time | Particle 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);
| IndexElementSize | Index type | Max vertices |
|---|---|---|
SixteenBits | uint16_t | 65,535 |
ThirtyTwoBits | uint32_t | 4,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 withDrawIndexedPrimitives. - 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::WriteOnlyfor static geometry;BufferUsage::Nonefor geometry updated each frame. - Never recreate a
VertexBuffereach frame — allocate once at max capacity and callSetDatato 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. DrawIndexedPrimitivesaccepts abaseVertexoffset so multiple meshes can share a single large vertex buffer.