Stop Sending Your Vertices Every Frame: A Practical Guide to OpenGL VBOs
Immediate mode re-sends every vertex every frame. VBOs move that data to the GPU once — here's the minimal correct setup, the usage hints that matter, and the stride bugs that fail silently.
06 Jun 2026, 07:50 UTC

The problem: the bus is your bottleneck
If you learned OpenGL from an old tutorial, you probably started with immediate mode: glBegin(GL_TRIANGLES), a pile of glVertex3f calls, glEnd(). It works for a triangle. It falls apart for a mesh, because every single frame you re-send every vertex from CPU memory across the bus to the GPU. A 100,000-vertex model at 60 fps means 6 million vertex submissions per second, each with function-call overhead on top.
The fix is a Vertex Buffer Object (VBO): a block of GPU-accessible memory that you fill once and reference every frame. The CPU stops being a courier and becomes an occasional updater. This post walks through the minimal correct setup, the usage hints that actually matter, and the mistakes that produce garbage geometry instead of errors.
What a VBO actually is
A VBO is just a named buffer. It has no idea what a "vertex" is — it's bytes. The interpretation comes later, when you describe the layout with vertex attribute pointers. This separation trips people up: filling the buffer correctly and describing it correctly are two independent steps, and OpenGL won't warn you if they disagree.
Three calls cover the lifecycle:
glGenBuffers(1, &vbo)— reserve a name.glBindBuffer(GL_ARRAY_BUFFER, vbo)— make it the active target for subsequent calls.glBufferData(GL_ARRAY_BUFFER, size, data, usage)— allocate storage and optionally copy data in.
After glBufferData, the driver owns a copy of your data (typically in GPU memory, though the driver decides placement). You can free your CPU-side array. Every frame, you bind the buffer and issue a draw call — no vertex data crosses the bus.
Usage hints are hints, but choose them honestly
The last argument to glBufferData tells the driver how you plan to use the buffer:
GL_STATIC_DRAW— set once, drawn many times. Most meshes. The driver will likely place this in fast GPU memory.GL_DYNAMIC_DRAW— updated regularly, drawn many times. Particle systems, CPU-animated geometry.GL_STREAM_DRAW— set almost every frame and drawn few times per update.
These are hints, not contracts — the program works if you lie — but a wrong hint can put the buffer in memory that's slow for your actual access pattern. The real trap with GL_DYNAMIC_DRAW isn't the hint itself; it's rewriting a buffer the GPU is still reading from a previous frame's draw. The driver either stalls the pipeline or transparently allocates a new backing store ("orphaning"). If you update every frame, explicit orphaning — calling glBufferData with a NULL data pointer before glMapBuffer — or double-buffering two VBOs avoids the implicit stall.
Worked example: an indexed, VAO-wrapped triangle
In OpenGL Core Profile (3.3+ here), a Vertex Array Object (VAO) is mandatory. A VAO records your attribute layout and buffer bindings so you don't re-describe them every frame. Run this during initialization, after you have a current OpenGL context:
// Positions + colors, interleaved: x,y,z, r,g,b per vertex
float vertices[] = {
-0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f,
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f,
0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f
};
unsigned int indices[] = { 0, 1, 2 };
GLuint vao, vbo, ebo;
glGenVertexArrays(1, &vao);
glGenBuffers(1, &vbo);
glGenBuffers(1, &ebo);
glBindVertexArray(vao); // record everything below
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
// Attribute 0: position, 3 floats, stride = 6 floats, offset 0
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE,
6 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// Attribute 1: color, 3 floats, same stride, offset 3 floats
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE,
6 * sizeof(float), (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(1);
glBindVertexArray(0); // done recordingThe render loop then shrinks to:
glUseProgram(shader);
glBindVertexArray(vao);
glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_INT, 0);Two details deserve attention. First, the Element Buffer Object (EBO) stores indices so shared vertices are uploaded once — a cube needs 8 vertices instead of 36. Second, note that the EBO binding is captured by the VAO, so don't unbind the EBO while the VAO is still recording, or you'll detach it.
The stride-and-offset minefield
The most common VBO bug produces no error at all: wrong stride or offset in glVertexAttribPointer. The GPU happily interprets your bytes at the wrong alignment, and you get smeared colors, exploded geometry, or a crash on out-of-bounds reads. Rules of thumb:
- Stride is the byte distance between the start of one vertex's data and the next — for interleaved data, the full per-vertex size, not the attribute size.
- Offset is a byte count cast to
void*, a historical API wart. Passing3instead of(void*)(3 * sizeof(float))reads at byte 3, not float 3. - Verify with a graphics debugger. RenderDoc lets you open the VBO contents and the mesh viewer after a capture; if the buffer looks right but the mesh doesn't, your attribute pointers are wrong, not your data.
Trade-offs and when not to bother
VBOs pay off when geometry outlives frames. For a handful of debug lines drawn once, the setup cost (three objects, attribute wiring, a shader — Core Profile has no fixed-function fallback) can exceed the savings. Buffer updates also have real cost: glBufferSubData on a large region can sync with in-flight rendering, and glMapBuffer hands you a pointer whose performance depends heavily on driver and access flags (GL_MAP_INVALIDATE_BUFFER_BIT is your friend for full rewrites).
Also note this all assumes a compatibility-capable context for comparison purposes; in strict Core Profile, immediate mode doesn't exist at all, so VBOs aren't an optimization — they're the only option.
Check it actually helped
Don't take the speedup on faith. Time your render loop with immediate mode (if your context allows it) versus the VBO path on a mesh of at least tens of thousands of vertices — a single triangle hides everything in fixed overhead. Then capture a frame in RenderDoc and confirm the VBO contents match your source array and the attribute layout matches your stride math. If both check out and frame time scales with draw calls rather than vertex count, the bus is no longer your bottleneck — and you can go find the next one.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.