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

Lab 14b - Mesh Class - Combining Buffer Abstractions

Overview

By the end of the Renderer Refactoring lab (Lab 14), you have four individual buffer abstractions: VertexBuffer, IndexBuffer, VertexArray, and VertexBufferLayout. Every time you want to draw a new piece of geometry you must create all four objects separately, keep them in sync, and manage their lifetimes by hand. The Mesh class solves this by bundling vertex data, index data, and the three buffer objects into a single unit with one Draw() call. This lab walks you through defining the Vertex data struct and implementing Mesh.h and Mesh.cpp, completing the abstraction layer before the test harness and camera labs that follow.

Learning Objectives

By the end of this lab you will be able to:

Concept

Without a Mesh class, every piece of geometry in the scene requires its own VertexBuffer, IndexBuffer, VertexArray, and layout to be set up inline. As the number of objects grows this becomes unmanageable. The Mesh class acts as an owner: it receives raw geometry as a std::vector<Vertex> and a std::vector<unsigned int>, then calls a private SetupMesh() method that allocates and configures all three buffer objects. From that point on, the caller only needs to call Draw().

The Vertex struct is a plain data type that packs every per-vertex attribute into a single flat C struct: three floats for position, three for the surface normal, three for colour, two for UV texture coordinates, and three for the tangent vector used in normal mapping in later labs. Packing the data this way means one VertexBuffer upload copies the whole struct array, and the VertexBufferLayout pushes each attribute in field order using Push<float>(n).

Because Mesh owns three std::unique_ptr members and declares a virtual destructor, the Rule of Five applies. In C++11, declaring a user-defined destructor suppresses the implicit generation of the move constructor and move assignment operator. Without explicit move operations, returning a Mesh from a factory function fails to compile. Declaring both as = default lets the compiler generate them so that ownership of the unique_ptr members transfers cleanly. Copy operations are explicitly = delete because unique_ptr cannot be copied.

Tasks

Task 1 - Define the Vertex Struct

Create src/Mesh/Vertex.h. The Vertex struct holds every per-vertex attribute as a packed flat array so that one VertexBuffer upload copies all geometry data in a single contiguous block. Attributes are stored in this order:

  1. position[3] - X, Y, Z world position
  2. normal[3] - surface normal NX, NY, NZ
  3. colour[3] - RGB colour in the 0.0 to 1.0 range
  4. texCoords[2] - UV texture coordinates
  5. tangent[3] - tangent vector for normal mapping in later labs

Provide two constructors. The full constructor accepts all 14 float values; the tangent parameters default to (1.0f, 0.0f, 0.0f). The convenience constructor accepts only position, colour, and UV, defaulting the normal to (0.0f, 0.0f, 1.0f) and the tangent to (1.0f, 0.0f, 0.0f).

src/Mesh/Vertex.h
#pragma once

struct Vertex {
    float position[3];
    float normal[3];
    float colour[3];
    float texCoords[2];
    float tangent[3];   // used for normal mapping in later labs

    // TODO: Full constructor - accepts px,py,pz, nx,ny,nz, r,g,b, u,v,
    //       and optional tangent tx,ty,tz (default 1,0,0).
    Vertex(float px, float py, float pz,
           float nx, float ny, float nz,
           float r,  float g,  float b,
           float u,  float v,
           float tx = 1.f, float ty = 0.f, float tz = 0.f)
        : /* TODO: initialise all five arrays using brace initialisation */ {}

    // TODO: Convenience constructor - position, colour, UV only.
    //       Normal defaults to (0,0,1), tangent to (1,0,0).
    Vertex(float px, float py, float pz, float r, float g, float b, float u, float v)
        : /* TODO */ {}
};

The Vertex struct stores floats in total, making each vertex bytes wide at 4 bytes per float.

The tangent attribute holds floats and is used in later labs for mapping.

Task 2 - Declare the Mesh Class

Create src/Mesh/Mesh.h. The class stores vertex data, index data, and three unique_ptr buffer objects in protected members so that derived classes (such as the model mesh used in later labs) can access them directly. It also stores position, rotation, and scale so a caller can reposition a mesh in world space without touching GPU memory.

Because Mesh declares a virtual destructor, you must add explicit move and copy declarations to satisfy the Rule of Five. Declare the move constructor and move assignment operator as = default. Delete the copy constructor and copy assignment operator.

src/Mesh/Mesh.h
#pragma once
#include <vector>
#include <memory>
#include <string>

#include "../VertexArray.h"
#include "../IndexBuffer.h"
#include "../VertexBuffer.h"
#include "Vertex.h"

#include "glm/glm.hpp"

class Mesh
{
protected:
    std::vector<Vertex>       m_Vertices;
    std::vector<unsigned int> m_Indices;

    std::unique_ptr<VertexArray>  m_VAO;
    std::unique_ptr<IndexBuffer>  m_EBO;
    std::unique_ptr<VertexBuffer> m_VBO;

    glm::vec3 m_Position;
    glm::vec3 m_Rotation;
    glm::vec3 m_Scale;

public:
    Mesh() : m_Position(0.0f), m_Rotation(0.0f), m_Scale(1.0f) {}
    Mesh(const std::vector<Vertex>& vertices, const std::vector<unsigned int>& indices);
    virtual ~Mesh();

    // TODO: Declare the move constructor    as = default
    // TODO: Declare the move assignment     as = default
    // TODO: Delete  the copy constructor
    // TODO: Delete  the copy assignment operator

    virtual void SetupMesh();
    virtual void Draw();

    void setPosition(const glm::vec3& position) { m_Position = position; }
    void setRotation(const glm::vec3& rotation) { m_Rotation = rotation; }
    void setScale   (const glm::vec3& scale)    { m_Scale    = scale;    }

    glm::vec3 getPosition() const { return m_Position; }
    glm::vec3 getRotation() const { return m_Rotation; }
    glm::vec3 getScale()    const { return m_Scale;    }

    glm::mat4 getTransformMatrix() const;
};

Why are the copy constructor and copy assignment operator deleted in Mesh?

std::unique_ptr deletes its own copy operations because there can only be one owner. The compiler would implicitly delete Mesh copies too, but writing = delete explicitly documents intent and gives a clearer compiler error when someone tries to copy a Mesh by accident.

A class declares a virtual destructor. What does this suppress in C++11?

In C++11, declaring any user-defined destructor suppresses the compiler-generated move constructor and move assignment operator. Copy operations are not affected by this rule. This is why Mesh must declare them explicitly as = default.

Task 3 - Implement SetupMesh

Open src/Mesh/Mesh.cpp and implement Mesh::SetupMesh(). This method creates the three buffer objects, uploads the geometry, and configures the vertex attribute layout. It mirrors the setup code you wrote manually in the VertexBuffer and Index Buffers lab (Lab 10) and the Vertex Layout and Array lab (Lab 11), but now it runs once inside the constructor and is never repeated by the caller.

  1. Guard against empty data with an early return and a message to std::cerr.
  2. Create all three buffer objects with std::make_unique. The VertexBuffer size argument is m_Vertices.size() * sizeof(Vertex).
  3. Build a VertexBufferLayout and push each attribute in the same order as the fields in Vertex.h.
  4. Call m_VAO->AddBuffer(*m_VBO, layout).
  5. Unbind all three objects so no state leaks into subsequent draw calls.
src/Mesh/Mesh.cpp
#include "Mesh.h"
#include "../VertexBufferLayout.h"
#include "../Renderer.h"

Mesh::Mesh(const std::vector<Vertex>& vertices, const std::vector<unsigned int>& indices)
    : m_Vertices(vertices), m_Indices(indices)
{
    SetupMesh();
}

Mesh::~Mesh()
{
    m_VAO->unBind();
    m_VBO->Unbind();
    m_EBO->Unbind();
}

void Mesh::SetupMesh()
{
    if (m_Vertices.empty() || m_Indices.empty())
    {
        std::cerr << "Mesh::SetupMesh() called with empty vertex or index data" << std::endl;
        return;
    }

    // TODO: create m_VAO  using make_unique<VertexArray>()
    // TODO: create m_VBO  using make_unique<VertexBuffer>(data ptr, byte size)
    // TODO: create m_EBO  using make_unique<IndexBuffer>(data ptr, index count)

    VertexBufferLayout layout;
    // TODO: Push 3 floats for position
    // TODO: Push 3 floats for normal
    // TODO: Push 3 floats for colour
    // TODO: Push 2 floats for texture coordinates
    // TODO: Push 3 floats for tangent

    // TODO: call m_VAO->AddBuffer(*m_VBO, layout)

    // TODO: unbind m_VAO, m_VBO, and m_EBO
}

The byte-size argument passed to VertexBuffer is m_Vertices.size() * sizeof().

The count argument passed to IndexBuffer is m_Indices.().

Attributes must be pushed in the same order as the fields declared in .h so that the layout offsets match the struct memory layout.

Task 4 - Implement Draw and getTransformMatrix

Still in src/Mesh/Mesh.cpp, implement Mesh::Draw() and Mesh::getTransformMatrix().

Draw() creates a local Renderer and calls renderer.Draw(*m_VAO, *m_EBO). Guard the call with a null check on m_VAO and m_EBO so that calling Draw() before SetupMesh() logs an error to std::cerr rather than crashing.

getTransformMatrix() builds the TRS matrix that moves the mesh from local space to world space. Start from an identity matrix and apply operations in TRS order: translate first, then rotate around X (pitch), then Y (yaw), then Z (roll), then scale. The GLM functions you need are glm::translate, glm::rotate, glm::radians, and glm::scale.

M=T⋅Rx⋅Ry⋅Rz⋅S\mathbf{M} = \mathbf{T} \cdot \mathbf{R}_x \cdot \mathbf{R}_y \cdot \mathbf{R}_z \cdot \mathbf{S}
src/Mesh/Mesh.cpp (continued)
void Mesh::Draw()
{
    if (!m_VAO || !m_EBO)
    {
        std::cerr << "Mesh::Draw() called before SetupMesh()!" << std::endl;
        return;
    }

    // TODO: create a local Renderer instance
    // TODO: call renderer.Draw(*m_VAO, *m_EBO)
}

glm::mat4 Mesh::getTransformMatrix() const
{
    glm::mat4 transform = glm::mat4(1.0f);

    // TODO: translate by m_Position
    // TODO: rotate around X axis (pitch) using m_Rotation.x
    // TODO: rotate around Y axis (yaw)   using m_Rotation.y
    // TODO: rotate around Z axis (roll)  using m_Rotation.z
    // TODO: scale by m_Scale

    return transform;
}

getTransformMatrix() writes glm::translate before glm::rotate before glm::scale. In what order are these transformations actually applied to a vertex?

GLM uses column-major matrices matching the OpenGL convention. Each glm::translate/rotate/scale call post-multiplies the current matrix on the right. When the final matrix multiplies a vertex, the rightmost operation (scale) is applied first, then rotation, then translation. This is the standard TRS order that keeps objects scaled and rotated around their local origin before being moved to world position.

The current Draw() method creates a new Renderer object on every call. Why might this be considered inefficient, and how could it be improved in a future refactor?

Constructing a Renderer every frame adds unnecessary overhead because the Renderer holds no state that changes between calls. A better design would accept a Renderer reference as a parameter (or use a shared/static instance) so the caller controls its lifetime and the same object is reused across all Draw calls in a frame.

Solution

Try the tasks above before revealing the solution.