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

Lab 20 - Maze Layout and First-Person View

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:

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

  1. Create the file src/tests/maze/TestMaze.h. Add the pragma guard and the includes listed in the skeleton below.
  2. Inside namespace test, declare class TestMaze : public Tests.
  3. In the public section, declare the constructor TestMaze(GLFWwindow* window) and override the three virtual methods: Update, Render, and RenderGUI.
  4. In the private section, declare the two helpers BuildCubeMesh() and DrawCube(), then add the member variables listed in the comments. Use std::unique_ptr for all heap-allocated objects.
  5. The MazeMap class is already complete in src/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 grid
    • At(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 tile
    • TileSize() - width and depth of each tile in world units
    • Cols(), Rows() - grid dimensions
    • PlayerStart(), StartYaw() - where the player spawns
src/tests/maze/TestMaze.h
#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

  1. 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.
  2. Implement BuildCubeMesh(). Declare a float 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.
  3. Generate 36 indices programmatically. Loop from face = 0 to 5. For each face, compute base = face * 4 and write two triangles into indices[face * 6 .. face * 6 + 5]:
    • Triangle 1: base+0, base+1, base+2
    • Triangle 2: base+2, base+3, base+0
  4. Create a VertexArray and VertexBuffer. Build a VertexBufferLayout, push float(3) (position), float(3) (normal), and float(2) (texCoord). Call AddBuffer then create the IndexBuffer with 36 indices.
src/tests/maze/TestMaze.cpp
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

  1. Still in TestMaze.cpp, add the constructor and the Update and RenderGUI method bodies using the skeleton below as your starting point.
  2. In the constructor, create m_Camera with std::make_unique<Camera>. Pass the window, start position (0, 1.6, 0), world-up (0,1,0), yaw 0, pitch 0, FOV 60. Immediately call m_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.
  3. 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.)
  4. Call BuildCubeMesh(), enable depth testing with glEnable(GL_DEPTH_TEST), then load the level: m_Map.Load(0, 1337u). Teleport the camera to the player start using setPosition and setYawPitch.
  5. In Update, forward input to the camera each frame: call processInput(deltaTime) then Update(deltaTime).
  6. In RenderGUI, call m_Camera->cameraGUI() and add an ImGui::TextWrapped controls hint.
src/tests/maze/TestMaze.cpp
#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

  1. Add DrawCube and Render to TestMaze.cpp using the skeleton below.
  2. In DrawCube, bind the shader and set u_Model and u_ObjectColour uniforms. If a texture is supplied, bind it to slot 0, set u_Texture = 0 and u_UseTexture = 1. Otherwise set u_UseTexture = 0. Call m_Renderer.Draw at the end.
  3. In Render, clear the framebuffer, compute the framebuffer aspect ratio, and build the perspective projection matrix with glm::perspective (FOV from m_Camera->getFOV(), near 0.1, far 100). Retrieve the view matrix from the camera.
  4. 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).
  5. Write the tile loop: iterate every (row, col) pair. Get the world-space centre with m_Map.TileCenter(col, row) and the tile character with m_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.
  6. Register the test in src/Main.cpp:
    #include "tests/maze/TestMaze.h"
    // ...
    TestMenu->RegisterTest<test::TestMaze>("Maze", window);
    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.
src/tests/maze/TestMaze.cpp
    // 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.