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:
- Define a Vertex struct that stores position, normal, colour, UV, and tangent data as packed float arrays
- Declare the Mesh class with unique_ptr buffer members, virtual destructor, and correct Rule of Five declarations
- Implement SetupMesh() to create and configure a VertexArray, VertexBuffer, and IndexBuffer from the stored vertex and index data
- Implement Draw() to delegate rendering to the Renderer class
- Build a TRS transform matrix from position, rotation, and scale using GLM
- Explain why move operations must be declared explicitly when a class owns unique_ptr members
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:
position[3]- X, Y, Z world positionnormal[3]- surface normal NX, NY, NZcolour[3]- RGB colour in the 0.0 to 1.0 rangetexCoords[2]- UV texture coordinatestangent[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).
#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.
#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?
A class declares a virtual destructor. What does this suppress in C++11?
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.
- Guard against empty data with an early return and a message to
std::cerr. - Create all three buffer objects with
std::make_unique. TheVertexBuffersize argument ism_Vertices.size() * sizeof(Vertex). - Build a
VertexBufferLayoutand push each attribute in the same order as the fields inVertex.h. - Call
m_VAO->AddBuffer(*m_VBO, layout). - Unbind all three objects so no state leaks into subsequent draw calls.
#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.
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?
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?
Solution
Try the tasks above before revealing the solution.