#073 – Implementing RAII for graphics: vertex and index buffers

Graphics APIs were designed for C, and they show their lineage in the same place every C-style API does: resource lifetimes are the caller’s problem. glGenBuffers hands you a GPU resource identified by an integer handle. glDeleteBuffers releases it. Forget to call the second one — or take an exception path that skips it, or copy the handle into two variables and let both go out of scope — and you have a GPU memory leak that won’t show up in your heap profiler, and may not show up at all until your draw calls start failing on a long-running app.

RAII is the C++ answer: wrap the handle in a class whose constructor acquires it and whose destructor releases it. This Nibble walks through that pattern for the two most common graphics resources, vertex buffers and index buffers, and the rule-of-five details that make the wrapper actually useful.

Three things to take away:

  • An RAII wrapper for a graphics buffer pairs glGenBuffers in the constructor with glDeleteBuffers in the destructor — the resource is alive iff the C++ object is.
  • Copying must be deleted; moving must be implemented. Two C++ objects owning the same handle is a double-free waiting to happen.
  • The sentinel value 0 (which OpenGL treats as “no buffer”) marks the moved-from state and lets the destructor be safe to run on it.

Why the C-style API is fragile

The naive way to use a vertex buffer mirrors how you’d write it in C:

GLuint vbo = 0;
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeBytes, data, GL_STATIC_DRAW);

uploadGeometry(vbo);   // might throw

glDeleteBuffers(1, &vbo);

Three problems sit in this code, in order of severity:

  1. If uploadGeometry throws, the glDeleteBuffers call never runs and the GPU resource leaks. The host-side stack unwinds; the GPU side does not.
  2. The handle and its lifetime live in two different places — the variable vbo and the matched call pair. A future reader has to verify by eye that every path between acquisition and release is covered.
  3. Every additional buffer in the function multiplies the cleanup obligation. Five buffers and three early-return paths is fifteen calls to keep straight.

These are the same problems heap memory had before std::unique_ptr, with the same shape of solution.

The basic wrapper

Wrap the handle in a class whose lifetime matches the resource:

class VertexBuffer {
public:
    VertexBuffer(const void* data, std::size_t bytes,
                 GLenum usage = GL_STATIC_DRAW)
    {
        glGenBuffers(1, &m_id);
        glBindBuffer(GL_ARRAY_BUFFER, m_id);
        glBufferData(GL_ARRAY_BUFFER, bytes, data, usage);
    }

    ~VertexBuffer() {
        if (m_id != 0)
            glDeleteBuffers(1, &m_id);
    }

    void bind() const{ glBindBuffer(GL_ARRAY_BUFFER, m_id); }
    GLuint id() const{ return m_id; }

private:
    GLuint m_id{ 0 };
};

The constructor acquires the GPU resource and uploads data; the destructor releases it. A user of this class cannot leak a buffer by forgetting to call delete, and cannot leak it on an exception path because stack unwinding runs the destructor automatically.

But this class as written has a quiet bug.

Why copies must go

The compiler will happily synthesise a copy constructor that copies m_id. That is exactly the wrong thing to do:

VertexBuffer a{ data, size };   // m_id = 17, owns GPU buffer 17
VertexBuffer b = a;             // m_id = 17, also "owns" GPU buffer 17

// At end of scope:
// b.~VertexBuffer() → glDeleteBuffers(1, &17) — releases buffer 17
// a.~VertexBuffer() → glDeleteBuffers(1, &17) — releases nothing,
//                                                 or releases a recycled
//                                                 handle, or crashes

Two C++ objects holding the same GPU handle is a double-free in the GPU’s allocator. Even when it doesn’t crash, the handle might have been recycled by a later glGenBuffers, and the second glDeleteBuffers releases someone else’s buffer. This class of bug is hard to track down because the symptom — wrong data on screen, missing geometry — appears far from the cause.

The fix is to delete the copy operations. The wrapper cannot be copied:

VertexBuffer(const VertexBuffer&)            = delete;
VertexBuffer& operator=(const VertexBuffer&) = delete;

Once copies are gone, you also need to give the wrapper a way to move — otherwise it can’t be returned from factory functions or stored in std::vector.

Implementing the move

The move constructor steals the source’s handle and leaves the source in a state safe to destruct:

VertexBuffer(VertexBuffer&& other) noexcept
    : m_id{ other.m_id }
{
    other.m_id = 0;
}

VertexBuffer& operator=(VertexBuffer&& other) noexcept {
    if (this != &other) {
        if (m_id != 0) glDeleteBuffers(1, &m_id);
        m_id = other.m_id;
        other.m_id = 0;
    }
    return *this;
}

Two details earn their keep here. First, the moved-from object gets m_id = 0 — the OpenGL sentinel for “no buffer”. The destructor’s if (m_id != 0) guard then makes destructing a moved-from object a no-op. Second, both move operations are noexcept. This isn’t decoration: std::vector uses move operations on element relocation only when they’re declared noexcept, falling back to copies otherwise. Without the specifier, a std::vector<VertexBuffer> would refuse to grow (because copy is deleted) and the program wouldn’t compile.

The same five operations applied to an index buffer differ only in the GL_ELEMENT_ARRAY_BUFFER target:

class IndexBuffer {
public:
    IndexBuffer(const std::uint32_t* indices, std::size_t count) {
        glGenBuffers(1, &m_id);
        glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_id);
        glBufferData(GL_ELEMENT_ARRAY_BUFFER,
                     count * sizeof(std::uint32_t),
                     indices, GL_STATIC_DRAW);
        m_count = count;
    }

    ~IndexBuffer() { if (m_id != 0) glDeleteBuffers(1, &m_id); }

    IndexBuffer(const IndexBuffer&)            = delete;
    IndexBuffer& operator=(const IndexBuffer&) = delete;

    IndexBuffer(IndexBuffer&& o) noexcept
        : m_id{ o.m_id }, m_count{ o.m_count }
    { o.m_id = 0; o.m_count = 0; }

    IndexBuffer& operator=(IndexBuffer&& o) noexcept {
        if (this != &o) {
            if (m_id != 0) glDeleteBuffers(1, &m_id);
            m_id = o.m_id; m_count = o.m_count;
            o.m_id = 0; o.m_count = 0;
        }
        return *this;
    }

    void bind() const{ glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_id); }
    std::size_t count() const{ return m_count; }

private:
    GLuint      m_id{ 0 };
    std::size_t m_count{ 0 };
};

Same five operations, same sentinel rule, different target enum. The duplication is a sign that the pattern wants to be a template — see “Generalising” below.

What the user gets

With both wrappers in place, mesh code becomes exception-safe and self-documenting:

class Mesh {
public:
    Mesh(const Vertex* verts, std::size_t vCount,
         const std::uint32_t* idx, std::size_t iCount)
        : m_vbo{ verts, vCount * sizeof(Vertex) },
          m_ibo{ idx,   iCount }
    {}

    void draw() const{
        m_vbo.bind();
        m_ibo.bind();
        glDrawElements(GL_TRIANGLES, m_ibo.count(), GL_UNSIGNED_INT, nullptr);
    }

private:
    VertexBuffer m_vbo;
    IndexBuffer  m_ibo;
};

Mesh itself needs no special copy/move handling — it follows the Rule of Zero. Its members manage their own resources, and the compiler synthesises Mesh’s destructor, move constructor, and move assignment correctly by chaining through the members. Copies are implicitly deleted because VertexBuffer‘s and IndexBuffer‘s copies are deleted, which is the right answer.

Generalising

The two classes above are nearly identical. A template parameterised on the buffer target collapses them to one:

template <GLenum Target>
class GLBuffer {
public:
    GLBuffer(const void* data, std::size_t bytes,
             GLenum usage = GL_STATIC_DRAW) {
        glGenBuffers(1, &m_id);
        glBindBuffer(Target, m_id);
        glBufferData(Target, bytes, data, usage);
    }
    ~GLBuffer() { if (m_id != 0) glDeleteBuffers(1, &m_id); }

    GLBuffer(const GLBuffer&)            = delete;
    GLBuffer& operator=(const GLBuffer&) = delete;

    GLBuffer(GLBuffer&& o) noexcept : m_id{ o.m_id } { o.m_id = 0; }
    GLBuffer& operator=(GLBuffer&& o) noexcept {
        if (this != &o) {
            if (m_id != 0) glDeleteBuffers(1, &m_id);
            m_id = o.m_id; o.m_id = 0;
        }
        return *this;
    }

    void bind() const{ glBindBuffer(Target, m_id); }

private:
    GLuint m_id{ 0 };
};

using VertexBuffer = GLBuffer<GL_ARRAY_BUFFER>;
using IndexBuffer  = GLBuffer<GL_ELEMENT_ARRAY_BUFFER>;

The same shape extends to textures, framebuffers, vertex array objects, and shaders — every API resource that follows the “gen to acquire, delete to release” pattern wants the same five operations and the same moved-from sentinel.

Takeaway

RAII applied to graphics buffers turns acquire-and-release pairs that the C API leaves to the caller into invariants the C++ type system enforces. The recipe is fixed: constructor acquires, destructor releases, copies are deleted, moves are implemented noexcept with a sentinel value marking the moved-from state. Once each individual resource is wrapped, higher-level types follow the Rule of Zero — Mesh doesn’t need to think about GPU lifetimes because its members already do. The rule is simple: every graphics handle should live inside an RAII wrapper, and the wrapper’s job is to make leaks impossible by construction