Overview
In this lab you build the foundation of a first-person maze game. You will
construct a single unit cube mesh and use it to render an entire 3D world
by iterating a 2D character grid, positioning the cube differently for each
tile. A grounded first-person Camera gives you WASD movement
and mouse look from the moment the test loads. By the end of the lab you
will be able to walk through textured corridors and look around freely.
Wall collision is added in the Maze Collision lab (Lab 21), and the full
game - timer, collectibles, and exit door - follows in Labs 22 and 23.
Learning Objectives
By the end of this lab you will be able to:
- Build a 24-vertex unit cube mesh with per-face flat normals and 0-to-1 UV coordinates using VertexArray, VertexBuffer, and IndexBuffer.
- Render a 3D world by iterating a 2D character grid and positioning a shared cube with translate and scale model matrices.
- Configure a grounded first-person Camera with WASD movement and mouse look using setGrounded().
- Apply the Model-View-Projection pipeline and Phong lighting uniforms to a scene that draws many objects from one VAO.
- Register the new TestMaze class and verify it appears in the test selection menu.
Concept
Without a reuse strategy, a maze with hundreds of wall tiles would need
hundreds of separate VAOs - one mesh per tile. That wastes GPU memory and
makes the rendering loop complicated. The solution is to build
one unit cube and draw it many times, supplying a different
model matrix each time. OpenGL draws the same triangles
but transforms them to a new position and scale because the vertex shader
reads u_Model per draw call. This pattern - one shared mesh,
many model matrices - is sometimes called instancing, though here
we use a simple per-object uniform rather than a GPU instanced draw.
The cube uses 24 vertices rather than the minimum 8. Each
face needs its own four vertices so each can carry a different flat outward
normal and a full 0-to-1 UV range. A
corner shared between three faces would need three different normals at the
same position, which a single vertex cannot express. The vertex data is
laid out as position (3 floats), normal (3 floats), and texture coordinate
(2 floats), matching the layout expected by Maze.shader.
Indices (36 in total: 6 faces times 2 triangles times 3 vertices) are
generated with a loop rather than typed by hand.
The MazeMap class owns the tile grid. It exposes
At(col, row) to read a character, and
TileCenter(col, row) to get the world-space XZ centre of that
tile. Tile '#' is a wall and gets a full-height block.
Every other character is open floor and gets a thin slab. The
Camera is put into grounded mode via
setGrounded(true, 1.6f), which locks the Y position to eye
height and projects WASD movement onto the horizontal plane.
Tasks
Task 1 - Declare the TestMaze class
-
Create the file
src/tests/maze/TestMaze.h. Add the pragma guard and the includes listed in the skeleton below. -
Inside
namespace test, declareclass TestMaze : public Tests. -
In the public section, declare the constructor
TestMaze(GLFWwindow* window)and override the three virtual methods:Update,Render, andRenderGUI. -
In the private section, declare the two helpers
BuildCubeMesh()andDrawCube(), then add the member variables listed in the comments. Usestd::unique_ptrfor all heap-allocated objects. -
The
MazeMapclass is already complete insrc/tests/maze/MazeMap.h. The key methods you will use in later tasks are:Load(int level, unsigned int seed)- generate and store the tile gridAt(int col, int row)- read the character at a tile (e.g.'#'for wall)TileCenter(int col, int row)- world-space XZ centre of that tileTileSize()- width and depth of each tile in world unitsCols(),Rows()- grid dimensionsPlayerStart(),StartYaw()- where the player spawns
#pragma once
#include "../Tests.h"
#include "../../Renderer.h"
#include "../../Shader.h"
#include "../../Texture.h"
#include "../../VertexArray.h"
#include "../../VertexBuffer.h"
#include "../../IndexBuffer.h"
#include "../../utils/Camera.h"
#include "MazeMap.h"
#include <memory>
#include <GLFW/glfw3.h>
#include <glm/glm.hpp>
namespace test {
class TestMaze : public Tests {
public:
// TODO: Declare the constructor (takes a GLFWwindow* parameter)
// TODO: Override the three virtual methods from Tests:
// Update(float deltaTime), Render(), RenderGUI()
private:
// TODO: Declare private helpers:
// void BuildCubeMesh();
// void DrawCube(const glm::mat4& model,
// const glm::vec3& colour,
// const Texture* texture);
// TODO: Declare member variables:
// GLFWwindow* m_window;
// Renderer m_Renderer;
// std::unique_ptr<Camera> m_Camera;
// MazeMap m_Map;
// std::unique_ptr<Shader> m_Shader;
// std::unique_ptr<Texture> m_WallTexture;
// std::unique_ptr<Texture> m_FloorTexture;
// std::unique_ptr<VertexArray> m_CubeVAO;
// std::unique_ptr<VertexBuffer> m_CubeVBO;
// std::unique_ptr<IndexBuffer> m_CubeIBO;
};
}
Task 2 - Build the unit cube mesh
-
Create
src/tests/maze/TestMaze.cpp. At the top, copy the include block from the skeleton below - you will add more functions to this file in later tasks. -
Implement
BuildCubeMesh(). Declare afloat vertices[192]array (24 vertices times 8 floats each). For each of the 6 faces, write 4 rows of 8 floats: x, y, z, nx, ny, nz, u, v.- Positions span -0.5 to +0.5 on each axis, centred on the origin.
- Each face's normal points directly outward (e.g. front face:
0, 0, 1). - UV runs from
(0,0)at the bottom-left corner to(1,1)at the top-right. - Winding is counter-clockwise viewed from outside the cube.
-
Generate 36 indices programmatically. Loop from
face = 0to5. For each face, computebase = face * 4and write two triangles intoindices[face * 6 .. face * 6 + 5]:- Triangle 1:
base+0, base+1, base+2 - Triangle 2:
base+2, base+3, base+0
- Triangle 1:
-
Create a
VertexArrayandVertexBuffer. Build aVertexBufferLayout, pushfloat(3)(position),float(3)(normal), andfloat(2)(texCoord). CallAddBufferthen create theIndexBufferwith 36 indices.
void TestMaze::BuildCubeMesh()
{
// TODO: Declare a float array for 24 vertices.
// Each vertex has 8 floats: position (3), normal (3), texCoord (2).
// You need 6 faces x 4 unique vertices = 24 vertices total.
//
// Each face needs its OWN 4 vertices because each face has a different
// flat outward normal. A shared corner vertex cannot simultaneously carry
// three different normals (one per adjacent face).
//
// Vertex layout:
// x, y, z, <- position (centred on origin, spans -0.5 to +0.5)
// nx, ny, nz, <- outward-facing normal, normalised
// u, v <- UV: (0,0) bottom-left of face, (1,1) top-right
//
// Faces in CCW winding viewed from outside:
// Front (+Z): normal = ( 0, 0, 1)
// Back (-Z): normal = ( 0, 0, -1)
// Left (-X): normal = (-1, 0, 0)
// Right (+X): normal = ( 1, 0, 0)
// Top (+Y): normal = ( 0, 1, 0)
// Bottom(-Y): normal = ( 0, -1, 0)
const float vertices[] = {
// pos (x, y, z) normal (nx, ny, nz) uv (u, v)
// Front face (+Z)
// TODO: 4 vertices...
// Back face (-Z)
// TODO: 4 vertices...
// Left face (-X)
// TODO: 4 vertices...
// Right face (+X)
// TODO: 4 vertices...
// Top face (+Y)
// TODO: 4 vertices...
// Bottom face (-Y)
// TODO: 4 vertices...
};
// TODO: Generate 36 indices (6 faces x 2 triangles x 3 vertices = 36).
// For each face, the base vertex index is face * 4.
// The two CCW triangles per face use:
// triangle 1: base+0, base+1, base+2
// triangle 2: base+2, base+3, base+0
unsigned int indices[36];
for (unsigned int face = 0; face < 6; face++)
{
unsigned int base = face * 4;
unsigned int* tri = &indices[face * 6];
// TODO: Fill tri[0], tri[1], tri[2] with triangle 1
// TODO: Fill tri[3], tri[4], tri[5] with triangle 2
}
// TODO: Create the VAO and VBO
// TODO: Build a VertexBufferLayout and push: float(3), float(3), float(2)
// TODO: Call m_CubeVAO->AddBuffer(*m_CubeVBO, layout)
// TODO: Create the IBO with 36 indices
}
Task 3 - Initialise the scene
-
Still in
TestMaze.cpp, add the constructor and theUpdateandRenderGUImethod bodies using the skeleton below as your starting point. -
In the constructor, create
m_Camerawithstd::make_unique<Camera>. Pass the window, start position(0, 1.6, 0), world-up(0,1,0), yaw 0, pitch 0, FOV 60. Immediately callm_Camera->setGrounded(true, 1.6f). This locks Y to eye height and projects WASD onto the horizontal plane. Without it, looking up while pressing W would fly the camera into the ceiling. -
Load the shader from
"res/Shaders/Maze.shader"and the two textures from"res/Textures/maze/wall.png"and"res/Textures/maze/floor.png". (These are placeholder images you can replace later.) -
Call
BuildCubeMesh(), enable depth testing withglEnable(GL_DEPTH_TEST), then load the level:m_Map.Load(0, 1337u). Teleport the camera to the player start usingsetPositionandsetYawPitch. -
In
Update, forward input to the camera each frame: callprocessInput(deltaTime)thenUpdate(deltaTime). -
In
RenderGUI, callm_Camera->cameraGUI()and add anImGui::TextWrappedcontrols hint.
#include "TestMaze.h"
#include "../../VertexBufferLayout.h"
#include "../../vendor/imgui/imgui.h"
#include <glm/gtc/matrix_transform.hpp>
namespace test {
static const float WallHeight = 2.5f;
TestMaze::TestMaze(GLFWwindow* window)
: m_window(window)
{
// TODO: Create m_Camera using std::make_unique<Camera>.
// Pass: window, start position (0, 1.6, 0), world-up (0,1,0),
// yaw = 0, pitch = 0, FOV = 60.
// Then call m_Camera->setGrounded(true, 1.6f) to lock Y movement.
// TODO: Load the Maze shader from "res/Shaders/Maze.shader"
// TODO: Load m_WallTexture from "res/Textures/maze/wall.png"
// TODO: Load m_FloorTexture from "res/Textures/maze/floor.png"
// TODO: Call BuildCubeMesh()
// TODO: Enable depth testing with glEnable(GL_DEPTH_TEST)
// TODO: Load level 0 with a fixed seed (e.g. 1337u):
// m_Map.Load(0, 1337u);
// TODO: Teleport the camera to the map's player start:
// m_Camera->setPosition(m_Map.PlayerStart());
// m_Camera->setYawPitch(m_Map.StartYaw(), 0.0f);
}
void TestMaze::Update(float deltaTime)
{
// TODO: Forward input to the camera each frame
// m_Camera->processInput(deltaTime);
// m_Camera->Update(deltaTime);
}
void TestMaze::RenderGUI()
{
// TODO: Show the camera debug GUI and a controls hint
// m_Camera->cameraGUI();
// ImGui::TextWrapped("WASD: move | Mouse: look | ESC: pause + cursor");
}
} // namespace test
Task 4 - Render the tile grid
-
Add
DrawCubeandRendertoTestMaze.cppusing the skeleton below. -
In
DrawCube, bind the shader and setu_Modelandu_ObjectColouruniforms. If a texture is supplied, bind it to slot 0, setu_Texture = 0andu_UseTexture = 1. Otherwise setu_UseTexture = 0. Callm_Renderer.Drawat the end. -
In
Render, clear the framebuffer, compute the framebuffer aspect ratio, and build the perspective projection matrix withglm::perspective(FOV fromm_Camera->getFOV(), near 0.1, far 100). Retrieve the view matrix from the camera. -
Bind the shader once and set all the frame-constant uniforms:
u_View,u_Projection,u_Light.Position(above the maze centre at y = 8),u_Light.Colour(white),u_CameraPosition, and the four Phong scalars (ambient 0.35, diffuse 0.8, specular 0.2, shininess 32). -
Write the tile loop: iterate every
(row, col)pair. Get the world-space centre withm_Map.TileCenter(col, row)and the tile character withm_Map.At(col, row). For'#', translate up by half the wall height and scale to(tile, WallHeight, tile). For everything else, translate down slightly and scale to(tile, 0.1f, tile)for a floor slab. -
Register the test in
src/Main.cpp:
Build and run. Select "Maze" in the menu. You should see a first-person view of a textured maze. WASD moves along the floor, mouse look rotates freely. You will be able to walk through walls - that is expected. Collision is added in the next lab.#include "tests/maze/TestMaze.h" // ... TestMenu->RegisterTest<test::TestMaze>("Maze", window);
// Add DrawCube and Render inside the namespace test { ... } block
// in TestMaze.cpp, after RenderGUI().
void TestMaze::DrawCube(const glm::mat4& model,
const glm::vec3& colour,
const Texture* texture)
{
m_Shader->Bind();
// TODO: Set the model matrix uniform "u_Model"
// TODO: Set the colour uniform "u_ObjectColour" (r, g, b components)
if (texture)
{
// TODO: Bind the texture to slot 0
// TODO: Set "u_Texture" = 0 and "u_UseTexture" = 1
}
else
{
// TODO: Set "u_UseTexture" = 0 (flat colour, no texture)
}
// TODO: Call m_Renderer.Draw(*m_CubeVAO, *m_CubeIBO, *m_Shader)
}
void TestMaze::Render()
{
m_Renderer.ClearColour_Black();
m_Renderer.Clear();
// TODO: Get framebuffer size with glfwGetFramebufferSize.
// Compute aspect ratio (cast to float before dividing).
// TODO: Build a perspective projection matrix (FOV from m_Camera->getFOV(),
// near = 0.1f, far = 100.0f) and retrieve the view matrix.
// TODO: Compute the maze centre tile and camera position for uniforms.
// TODO: Bind the shader and set these frame-constant uniforms:
// "u_View" <- view matrix
// "u_Projection" <- projection matrix
// "u_Light.Position" <- mazeCenter.x, 8.0f, mazeCenter.z
// "u_Light.Colour" <- 1, 1, 1 (white)
// "u_CameraPosition" <- camera world position
// "u_AmbientIntensity" = 0.35f
// "u_DiffuseIntensity" = 0.8f
// "u_SpecularIntensity" = 0.2f
// "u_Shininess" = 32.0f
float tile = m_Map.TileSize();
// TODO: Iterate every (row, col) in the grid.
// Get the tile's world-space centre with m_Map.TileCenter(col, row).
// Read the tile character with m_Map.At(col, row).
//
// '#' -> wall cube:
// Translate to center + (0, WallHeight * 0.5f, 0)
// Scale by (tile, WallHeight, tile)
// DrawCube(..., glm::vec3(1.0f), m_WallTexture.get())
//
// anything else -> floor slab:
// Translate to center + (0, -0.05f, 0)
// Scale by (tile, 0.1f, tile)
// DrawCube(..., glm::vec3(1.0f), m_FloorTexture.get())
}
Solution
Try the tasks above before revealing the solution.