Overview
The Vertex and Index Buffers lab (Lab 10) created classes that allocate raw bytes on the GPU. Raw bytes alone are meaningless to the shader - it has no way to know that bytes 0-11 are a position and bytes 12-19 are UV coordinates. This lab introduces the two classes that solve that problem. VertexBufferLayout builds a description of your vertex format by recording the type and size of each attribute. VertexArray (VAO) uses that description to call glVertexAttribPointer once during setup, and then records the result so the entire vertex format can be replayed later with a single Bind() call. Together they replace the manual glEnableVertexAttribArray loop that appeared directly in main.cpp in earlier labs.
Learning Objectives
By the end of this lab you will be able to:
- Explain what stride and offset mean and how they describe the position of each attribute within a packed vertex
- Use VertexBufferLayout::Push to build a vertex description and observe how m_Stride accumulates with each call
- Explain why the VAO is called a recording device and what exactly it records during AddBuffer
- Implement VertexArray::AddBuffer, iterating the layout elements and calling glVertexAttribPointer for each
- Replace the manual attribute setup in main.cpp with a VertexArray and VertexBufferLayout
Concept
A vertex buffer holds a flat array of bytes. Your CPU code knows that the first 12 bytes are a 3-float position, the next 8 bytes are 2-float UV coordinates, and so on - but the GPU does not. To bridge that gap, OpenGL needs you to call glVertexAttribPointer once per attribute, telling it: the attribute index (matching layout(location = N) in the shader), how many components the attribute has, the data type, whether to normalise, the total byte distance between one vertex and the next (stride), and the byte offset from the start of the vertex to this particular attribute (offset). Without these calls, every vertex shader invocation reads garbage.
The VertexBufferLayout class captures the vertex description as a list of VertexBufferElement structs. Each element stores its GL type, component count, and normalisation flag. The Push<float>(3) call appends a three-component float element and adds 3 * 4 = 12 bytes to the running stride. Calling Push<float>(2) afterwards appends a two-component element and adds 8 more bytes. When all attributes have been pushed, GetStride() returns the total vertex size in bytes and GetElements() returns the ordered list. Template specialisations for float, unsigned int, and unsigned char cover the most common attribute types. A general template with a static assertion catches any unsupported type at compile time rather than at runtime.
A Vertex Array Object (VAO) is a recording device built into the GPU driver. While a VAO is bound, every call to glEnableVertexAttribArray and glVertexAttribPointer is saved inside that VAO. The next time you bind the VAO, the driver replays all those calls instantly - no loop, no manual stride arithmetic. VertexArray::AddBuffer exploits this by binding both the VAO and the VBO, iterating the layout elements, and calling glVertexAttribPointer once per element with the accumulated byte offset. When AddBuffer returns, the VAO remembers exactly which buffer holds the data and how to interpret each attribute. Binding the VAO in the render loop is then the only setup needed before a draw call.
Tasks
Task 1 - Study the Vertex Buffer Layout (Reference)
Create src/VertexBufferLayout.h. This is a header-only class that uses C++ template specialisations, so read it carefully before typing. The reference code below is the complete file.
- Create
src/VertexBufferLayout.hand add#pragma once. Include<vector>and"Renderer.h". - Define the
VertexBufferElementstruct with three fields:type(GL enum),count(number of components), andnormalised. Add the staticgetSizeOfTypehelper that maps each GL type to its byte size. - Define the
VertexBufferLayoutclass with astd::vector<VertexBufferElement>and anunsigned int m_Stride. Initialise the stride to 0 in the constructor. - Write the generic
Pushtemplate with astatic_assertthat fires for any unsupported type. Then write three explicit specialisations:Push<float>,Push<unsigned int>, andPush<unsigned char>. Each must append aVertexBufferElementand increasem_Stridebycount * getSizeOfType(type). - Add
GetElements()andGetStride()accessors. - Add the header to your project. It has no
.cppfile - all code lives in the header.
#pragma once
#include <vector>
#include "Renderer.h"
// Describes a single vertex attribute: its GL type, component count, and whether to normalise
struct VertexBufferElement
{
unsigned int type;
unsigned int count;
unsigned char normalised;
// Returns the byte size of one component of the given GL type
static unsigned int getSizeOfType(unsigned int type)
{
switch (type)
{
case GL_FLOAT: return 4;
case GL_UNSIGNED_INT: return 4;
case GL_UNSIGNED_BYTE: return 1;
default: throw std::runtime_error("Unknown type!");
}
ASSERT(false);
return 0;
}
VertexBufferElement(unsigned int type, unsigned int count, unsigned char normalised)
: type(type), count(count), normalised(normalised)
{
}
};
// Describes the full layout of one vertex: ordered list of attributes and the total stride
class VertexBufferLayout
{
private:
std::vector<VertexBufferElement> m_Elements;
unsigned int m_Stride;
public:
VertexBufferLayout() : m_Stride(0) {}
~VertexBufferLayout() {}
// Generic Push - compile error for any type without a specialisation below
template<typename t>
void Push(unsigned int count)
{
static_assert(sizeof(t) == 0, "Unsupported type for VertexBufferLayout::Push.");
}
// Push a float attribute (e.g. Push<float>(3) for a vec3 position)
template<>
void Push<float>(unsigned int count)
{
m_Elements.push_back({ GL_FLOAT, count, GL_FALSE });
m_Stride += count * VertexBufferElement::getSizeOfType(GL_FLOAT);
}
// Push an unsigned int attribute (e.g. for integer vertex IDs)
template<>
void Push<unsigned int>(unsigned int count)
{
m_Elements.push_back({ GL_UNSIGNED_INT, count, GL_FALSE });
m_Stride += count * VertexBufferElement::getSizeOfType(GL_UNSIGNED_INT);
}
// Push a normalised unsigned byte attribute (e.g. packed colour 0-255 mapped to 0.0-1.0)
template<>
void Push<unsigned char>(unsigned int count)
{
m_Elements.push_back({ GL_UNSIGNED_BYTE, count, GL_TRUE });
m_Stride += count * VertexBufferElement::getSizeOfType(GL_UNSIGNED_BYTE);
}
inline const std::vector<VertexBufferElement> GetElements() const { return m_Elements; }
inline unsigned int GetStride() const { return m_Stride; }
};
Task 2 - Implement the Vertex Array Object
Create src/VertexArray.h and src/VertexArray.cpp. The skeleton for the header is shown below. The VAO is the most important class in this lab - concentrate on understanding what AddBuffer does.
- Create
src/VertexArray.hfrom the skeleton below. Note the forward declaration ofVertexBufferLayoutat the top - this avoids a circular include betweenVertexArray.handVertexBufferLayout.h. - Create
src/VertexArray.cpp. Include"VertexArray.h"and"VertexBufferLayout.h"(the full definition is needed in the.cppfile forAddBufferto accessGetElements()andGetStride()). - In the constructor, call
glGenVertexArrays(1, &m_RendererID)to create the VAO, thenglBindVertexArray(m_RendererID)to start recording. - In
AddBuffer, callBind()thenvb.Bind(). Then loop overlayout.GetElements(). For each element, callglEnableVertexAttribArray(i), thenglVertexAttribPointerwith the index, count, type, normalised flag, stride, and offset. After the call, advanceoffsetbyelement.count * VertexBufferElement::getSizeOfType(element.type). - Add both files to your project and build.
- In
main.cpp, replace the raw VAO setup block with:
Build and run. The rendered quad must look identical to the Lab 08 result.VertexArray vao; VertexBuffer vbo(vertices, sizeof(vertices)); VertexBufferLayout layout; layout.Push<float>(3); // position layout.Push<float>(2); // UV coordinates vao.AddBuffer(vbo, layout);
#pragma once
#include "VertexBuffer.h"
#include <iostream>
class VertexBufferLayout; // forward declare to avoid circular includes
class VertexArray
{
private:
// TODO: unsigned int m_RendererID
public:
// TODO: constructor - generate and immediately bind the VAO
VertexArray();
// TODO: destructor - delete the VAO
~VertexArray();
// TODO: record the VBO and layout into this VAO by calling glVertexAttribPointer
void AddBuffer(const VertexBuffer& vb, const VertexBufferLayout& layout);
void Bind() const;
void unBind() const;
};
Task 3 - Implement the Vertex Array .cpp
Implement src/VertexArray.cpp from the skeleton below. The most important part is the AddBuffer loop - each iteration handles one vertex attribute.
- Fill in every
// TODO:in the skeleton below. - Pay attention to the offset accumulation: it starts at 0 and grows by
element.count * getSizeOfType(element.type)after eachglVertexAttribPointercall. This is what tells GL that the second attribute starts after the first one ends. - Build and run. Confirm the textured, rotating, projected quad from Lab 08 still renders correctly.
#include "VertexArray.h"
#include "VertexBufferLayout.h"
VertexArray::VertexArray()
{
// TODO: glGenVertexArrays(1, &m_RendererID)
// TODO: glBindVertexArray(m_RendererID)
}
VertexArray::~VertexArray()
{
// TODO: glDeleteVertexArrays(1, &m_RendererID)
}
void VertexArray::AddBuffer(const VertexBuffer& vb, const VertexBufferLayout& layout)
{
// TODO: Bind() this VAO so the following calls are recorded into it
// TODO: vb.Bind() so the VBO is the source for attribute data
const auto& Elements = layout.GetElements();
unsigned int offset = 0;
for (unsigned int i = 0; i < Elements.size(); i++)
{
const auto& element = Elements[i];
// TODO: glEnableVertexAttribArray(i)
// TODO: glVertexAttribPointer(i, element.count, element.type,
// element.normalised, layout.GetStride(), (const void*)offset)
// TODO: advance offset by element.count * VertexBufferElement::getSizeOfType(element.type)
}
}
void VertexArray::Bind() const
{
// TODO: glBindVertexArray(m_RendererID)
}
void VertexArray::unBind() const
{
// TODO: glBindVertexArray(0)
}
Solution
Try the tasks above before revealing the solution.