Tutorial 38: Vertex Buffers and Index Buffers

3D Rendering  ·  Intermediate

ℹ

What you’ll learn

  • Static versus dynamic VertexBuffer, and uploading with SetData<T>.
  • What a VertexDeclaration describes.
  • 16-bit versus 32-bit IndexBuffer, and DrawIndexedPrimitives.
  • What BufferUsage::WriteOnly really means, and how to update a buffer every frame with DynamicVertexBuffer and SetDataOptions::Discard.
  • Why a buffer that is currently bound cannot simply be overwritten, and why 32-bit indices need the HiDef graphics profile.

Before you start — Tutorial 31: Your First 3D Triangle — this is the depth pass over the buffer you created there. Requires a 3D-capable renderer such as OPENGLES3 or VULKAN; the 2D-only renderer (SDL_RENDERER) throws on 3D calls by default, and STUB reports no 3D capability at all.

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 argument is a readability hint, not a static/dynamic switch. It decides one thing you can observe in CNA: whether the CPU may read the data back with GetData.

BufferUsageGetDataSetDataUse it for
WriteOnlyThrows System::NotSupportedException (“Calling GetData on a resource that was created with BufferUsage.WriteOnly is not supported.”)Allowed, any number of times (subject to the in-use rule below)Buffers you will never read back — static meshes and streamed dynamic buffers alike
NoneAllowedAllowedBuffers you want to read back (tools, tests, CPU picking)

XNA documents WriteOnly as a memory-placement hint; CNA does not verify any speed benefit, so treat it as a promise about reading, nothing more. What makes a buffer dynamic is the class: use DynamicVertexBuffer (and DynamicIndexBuffer) for data you rewrite every frame, together with SetDataOptions::Discard or NoOverwrite. Both classes exist in CNA and behave like their XNA namesakes.

// Static buffer — upload once, draw many times
auto staticVB = std::make_unique<VertexBuffer>(
    gd, VertexPositionColor::getVertexDeclarationStatic(),
    vertexCount, BufferUsage::WriteOnly);
staticVB->SetData(vertices, vertexCount);

// Dynamic buffer — rewritten every frame
auto dynamicVB = std::make_unique<DynamicVertexBuffer>(
    gd, VertexPositionColor::getVertexDeclarationStatic(),
    MAX_VERTS, BufferUsage::WriteOnly);
// Each frame, in Update():
//   dynamicVB->SetData(verts, 0, count, SetDataOptions::Discard);

SetData<T>

VertexBuffer::SetData accepts the built-in vertex structs and, as a template, any trivially-copyable vertex struct of your own. Its overloads are easy to misread, so here is what each argument means:

// Full upload of the first "count" source vertices into the start of the buffer
vb->SetData(vertices, count);

// Upload a slice of the SOURCE array: source elements [startIndex, startIndex + elementCount).
// The second argument is where reading from your array starts, not where writing to the buffer starts.
vb->SetData(vertices, startIndex, elementCount);

// XNA's windowed form: write into the buffer starting at a BYTE offset
vb->SetData(offsetInBytes, vertices, startIndex, elementCount, vertexStride);

// Dynamic update each frame (inside Update()), on a DynamicVertexBuffer
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, 0, GRID_W * GRID_H, SetDataOptions::Discard);

About the stride: for the built-in vertex structs CNA packs your values into the layout their VertexDeclaration describes. For a struct of your own, sizeof(T) only sets how many source bytes each element occupies; the stride used when drawing always comes from the declaration you gave the VertexBuffer.

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::getVertexDeclarationStatic()       // Position(float3) + Color(byte4)
VertexPositionTexture::getVertexDeclarationStatic()     // Position(float3) + TexCoord(float2)
VertexPositionColorTexture::getVertexDeclarationStatic()   // Position + Color + TexCoord
VertexPositionNormalTexture::getVertexDeclarationStatic()  // Position + Normal + TexCoord

Pass the declaration when constructing a VertexBuffer. CNA forwards it to the renderer to set up vertex attribute pointers before each draw call. The device also validates it: at most 16 elements and 255 bytes per vertex, and under the default Reach profile the element formats stop at NormalizedShort4 (HalfVector2/HalfVector4 need HiDef). A violation throws System::NotSupportedException.

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 typeAddressable verticesGraphics profile needed
SixteenBitsuint16_t65,535Reach (the default) or HiDef
ThirtyTwoBitsuint32_t4,294,967,295HiDef only

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

⚠

32-bit indices are refused by the default profile. CNA enforces XNA’s graphics-profile ceilings on every renderer, and the default profile is Reach. Under Reach, new IndexBuffer(..., IndexElementSize::ThirtyTwoBits, ...) and 32-bit user indices throw System::NotSupportedException (“Thirty-two-bit index buffers are not supported by the Reach graphics profile.”); a single draw call of more than 65,535 primitives throws too (the HiDef limit is 1,048,575); and an index buffer may not exceed 67,108,863 bytes on either profile. Ask for HiDef in the Game constructor, before Initialize() applies your preferences, with graphics_.setGraphicsProfileProperty(GraphicsProfile::HiDef);. Tutorial 152 covers this end to end. Even on HiDef the renderer itself must implement 32-bit buffers.

DrawIndexedPrimitives

gd.SetVertexBuffer(vb.get());
gd.setIndicesProperty(ib.get());   // XNA's GraphicsDevice.Indices; SetIndexBuffer(ib.get()) is a CNAEXT alias

for (auto& pass : effect->getCurrentTechniqueProperty()->getPassesProperty()) {
    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. Drawing with no effect applied, no index buffer or no vertex buffer bound throws System::InvalidOperationException.

BufferUsage::WriteOnly vs None

WriteOnly is a promise that the application will never read vertex data back. CNA enforces the promise: every GetData on such a buffer throws System::NotSupportedException (this is defined behaviour, not undefined behaviour). SetData on a WriteOnly buffer remains legal, as often as you like.

None permits GetData as well. It is not what makes a buffer dynamic, and you do not need it to call SetData after the buffer has been drawn; the streaming rules below are what matter for that.

Updating Dynamic Buffers

There is one rule that every per-frame update has to respect. A buffer that is currently bound on the device cannot be overwritten by an ordinary SetData: CNA throws System::InvalidOperationException (“The vertex buffer resource is in use.”; “The index buffer resource is in use.” for an IndexBuffer). The same applies to a Texture2D that is bound to a sampler slot. Binding lasts until you bind something else, so a buffer you drew last frame is still bound when Update() runs this frame. There are three ways out:

// 1. The XNA streaming idiom: a DynamicVertexBuffer written with Discard (or NoOverwrite).
//    This is always allowed, even while the buffer is bound.
dynamicVB->SetData(verts, 0, count, SetDataOptions::Discard);

// 2. Same for indices: DynamicIndexBuffer + Discard / NoOverwrite.

// 3. Unbind first, then use a plain SetData on any VertexBuffer.
gd.SetVertexBuffer(nullptr);
vb->SetData(verts, count);

Discard means “I am replacing everything; the old contents no longer matter” and lets a renderer hand back fresh storage while the GPU still reads the old; NoOverwrite means “I promise not to touch data still in use”, for appending to a ring buffer. Most CNA renderers honour both as real hints; a few treat every call as Discard.

A caution about partial updates. CNA keeps a CPU-side copy of each buffer and re-submits the whole logical buffer to the renderer on every SetData, including a windowed one. A window changes which bytes you rewrite, not what is transferred, so do not expect partial updates to save upload bandwidth; they save you from recomputing and copying the unchanged part on the CPU. (Under Discard, the part of the buffer you did not write becomes zeroes.)

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 rewritten every frame into a DynamicVertexBuffer with SetDataOptions::Discard, so the update is legal even though the buffer is still bound from the previous frame.
#include "Microsoft/Xna/Framework/Game.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/DynamicVertexBuffer.hpp"
#include "Microsoft/Xna/Framework/Graphics/IndexBuffer.hpp"
#include "Microsoft/Xna/Framework/Graphics/SetDataOptions.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_.setPreferredBackBufferWidthProperty(800);
        graphics_.setPreferredBackBufferHeightProperty(600);
    }

protected:
    void LoadContent() override {
        effect_ = std::make_unique<BasicEffect>(getGraphicsDeviceProperty());
        effect_->VertexColorEnabled = 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.getElapsedGameTimeProperty().getTotalSecondsProperty());

        // 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);
            }
        }
        // Discard is what makes this legal: on the frames when the grid is showing,
        // dynamicVB_ is still bound to the device from the previous Draw().
        dynamicVB_->SetData(gridVerts_.data(), 0, static_cast<int>(gridVerts_.size()),
                            SetDataOptions::Discard);
    }

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

        effect_->setViewProperty(Matrix::CreateLookAt(
            {0, 2, 4}, Vector3::Zero, Vector3::Up));
        effect_->setProjectionProperty(Matrix::CreatePerspectiveFieldOfView(
            MathHelper::PiOver4, 800.0f / 600.0f, 0.1f, 100.0f));
        effect_->setWorldProperty(Matrix::getIdentityProperty());

        if (!showGrid_) {
            // --- Static quad via DrawIndexedPrimitives ---
            gd.SetVertexBuffer(staticVB_.get());
            gd.setIndicesProperty(staticIB_.get());
            for (auto& pass : effect_->getCurrentTechniqueProperty()->getPassesProperty()) {
                pass.Apply();
                gd.DrawIndexedPrimitives(PrimitiveType::TriangleList,
                    0, 0, 4, 0, 2);
            }
        } else {
            // --- Dynamic sine-wave grid ---
            gd.SetVertexBuffer(dynamicVB_.get());
            gd.setIndicesProperty(gridIB_.get());
            for (auto& pass : effect_->getCurrentTechniqueProperty()->getPassesProperty()) {
                pass.Apply();
                int triCount = (GW - 1) * (GH - 1) * 2;
                gd.DrawIndexedPrimitives(PrimitiveType::TriangleList,
                    0, 0, GW * GH, 0, triCount);
            }
        }
        // No gd.Present(): Game presents in EndDraw, after Draw() returns.
    }

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::getVertexDeclarationStatic(), 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<DynamicVertexBuffer>(gd,
            VertexPositionColor::getVertexDeclarationStatic(),
            GW * GH, BufferUsage::WriteOnly);   // dynamic: rewritten every frame
        dynamicVB_->SetData(gridVerts_.data(), 0, GW * GH, SetDataOptions::Discard);

        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_;
    std::unique_ptr<DynamicVertexBuffer> 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

  • BufferUsage only controls readability: WriteOnly makes GetData throw; SetData stays legal. Use DynamicVertexBuffer/DynamicIndexBuffer with SetDataOptions::Discard or NoOverwrite for data you rewrite every frame.
  • An ordinary SetData on a buffer that is currently bound (or on a texture bound to a sampler) throws InvalidOperationException; Discard/NoOverwrite on a dynamic buffer, or unbinding first, avoid it.
  • Never recreate a VertexBuffer each frame — allocate once at max capacity and call SetData to stream new data. Partial updates re-submit the whole buffer, so they do not save bandwidth.
  • 16-bit index buffers (SixteenBits) are sufficient for most meshes and use half the memory of 32-bit; 32-bit indices (and draws over 65,535 primitives) need the HiDef graphics profile — see Tutorial 152.
  • Bind both vertex and index buffer before calling DrawIndexedPrimitives.
  • DrawIndexedPrimitives accepts a baseVertex offset so multiple meshes can share a single large vertex buffer.