University of Wales Trinity Saint David
ACCA6021 - Graphics and Games Programming

Lab 11 - Vertex Layout and Array

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:

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.

  1. Create src/VertexBufferLayout.h and add #pragma once. Include <vector> and "Renderer.h".
  2. Define the VertexBufferElement struct with three fields: type (GL enum), count (number of components), and normalised. Add the static getSizeOfType helper that maps each GL type to its byte size.
  3. Define the VertexBufferLayout class with a std::vector<VertexBufferElement> and an unsigned int m_Stride. Initialise the stride to 0 in the constructor.
  4. Write the generic Push template with a static_assert that fires for any unsupported type. Then write three explicit specialisations: Push<float>, Push<unsigned int>, and Push<unsigned char>. Each must append a VertexBufferElement and increase m_Stride by count * getSizeOfType(type).
  5. Add GetElements() and GetStride() accessors.
  6. Add the header to your project. It has no .cpp file - all code lives in the header.
src/VertexBufferLayout.h
#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.

  1. Create src/VertexArray.h from the skeleton below. Note the forward declaration of VertexBufferLayout at the top - this avoids a circular include between VertexArray.h and VertexBufferLayout.h.
  2. Create src/VertexArray.cpp. Include "VertexArray.h" and "VertexBufferLayout.h" (the full definition is needed in the .cpp file for AddBuffer to access GetElements() and GetStride()).
  3. In the constructor, call glGenVertexArrays(1, &m_RendererID) to create the VAO, then glBindVertexArray(m_RendererID) to start recording.
  4. In AddBuffer, call Bind() then vb.Bind(). Then loop over layout.GetElements(). For each element, call glEnableVertexAttribArray(i), then glVertexAttribPointer with the index, count, type, normalised flag, stride, and offset. After the call, advance offset by element.count * VertexBufferElement::getSizeOfType(element.type).
  5. Add both files to your project and build.
  6. In main.cpp, replace the raw VAO setup block with:
    VertexArray vao;
    VertexBuffer vbo(vertices, sizeof(vertices));
    VertexBufferLayout layout;
    layout.Push<float>(3); // position
    layout.Push<float>(2); // UV coordinates
    vao.AddBuffer(vbo, layout);
    Build and run. The rendered quad must look identical to the Lab 08 result.
src/VertexArray.h
#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.

  1. Fill in every // TODO: in the skeleton below.
  2. Pay attention to the offset accumulation: it starts at 0 and grows by element.count * getSizeOfType(element.type) after each glVertexAttribPointer call. This is what tells GL that the second attribute starts after the first one ends.
  3. Build and run. Confirm the textured, rotating, projected quad from Lab 08 still renders correctly.
src/VertexArray.cpp
#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.