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

Lab 21 - AABB Player-Wall Collision

Overview

In the Maze Layout lab (Lab 20) you built a navigable first-person maze, but the camera passes through walls. This lab fixes that by introducing the Player class. Player wraps the Camera in a small Axis-Aligned Bounding Box (AABB) and resolves collisions using a technique called move and slide: the frame's WASD movement is applied one axis at a time, and any wall overlap is corrected after each axis independently. The result is that walking diagonally into a wall cancels the blocked component of the motion but keeps the free component, so the player slides smoothly along the surface rather than sticking to it.

Learning Objectives

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

Concept

Without collision, the camera moves wherever the input directs it - through walls and all. The standard game-development fix is to give the player a bounding volume and test it against the world each frame. Here the player is an AABB: a box whose sides are aligned to the world axes. An AABB is defined by a centre position and a half-extent (half the box's width) on each axis. Two AABBs overlap on one axis when the distance between their centres is less than the sum of their half-extents. If they overlap on every axis simultaneously, they are colliding.

Resolving the collision naively - just push the player back to their previous position - causes them to stick to the wall when moving diagonally. The correct approach is move and slide: save the position before input, let the camera apply this frame's movement to get the desired position, then re-apply the movement one axis at a time and resolve any overlap after each axis separately. If the X axis is blocked the player is pushed back on X, but the Z movement (which may be clear) is still applied, so the player slides along the wall face.

Testing every tile in the maze for overlap each frame would be expensive. The broad-phase optimisation exploits the fact that the player box is much smaller than one tile: only the 3x3 block of tiles around the player's current tile can possibly touch the bounding box. MazeMap::WorldToTile converts the player's world position to a tile index, and the 3x3 loop uses MazeMap::IsWall and MazeMap::TileCenter to check and position each candidate tile. Both methods are already implemented and ready to use.

Tasks

Task 1 - Study the Player interface

  1. Read src/tests/maze/Player.h below. This file is already complete - you do not need to edit it.
  2. Note the three public methods you will implement in the next tasks:
    • Player(Camera*, MazeMap*) - stores pointers and enables grounded mode
    • Update(float deltaTime) - runs move-and-slide each frame
    • Respawn(const glm::vec3& start, float yaw) - teleports to a spawn point
  3. Note the private ResolveAxis(glm::vec3& position, int axis) helper. It takes a position by reference and modifies it in place. The axis parameter selects which component of glm::vec3 to test and push: 0 for X, 2 for Z.
  4. Note m_HalfExtent = 0.3f: the player is a 0.6 x 0.6 unit box on the XZ plane, centred on the camera position. This is narrow enough to fit through a corridor but wide enough to prevent the near clipping plane from clipping into a wall.
src/tests/maze/Player.h
/**
 * Player.h
 *
 * First-person player: wraps the Camera in grounded mode and stops it
 * walking through maze walls.
 *
 * KEY CONCEPTS:
 * 1. AABB (Axis-Aligned Bounding Box) overlap testing
 * 2. AXIS-SEPARATED collision resolution ("move and slide"):
 *    movement is applied one axis at a time; each axis is resolved
 *    independently. Walking diagonally into a wall cancels the blocked
 *    component but keeps the parallel one - so you slide along the wall.
 * 3. Broad-phase optimisation: only the 3x3 tiles around the player are
 *    tested, not the whole grid.
 */

#pragma once
#include <glm/glm.hpp>

#include "../../utils/Camera.h"
#include "MazeMap.h"

namespace test
{
    class Player
    {
    public:
        Player(Camera* camera, MazeMap* map);

        // Runs camera input for this frame, then corrects the resulting
        // position so the player's bounding box never overlaps a wall tile.
        void Update(float deltaTime);

        // Teleport to a level's start tile, facing along its corridor.
        void Respawn(const glm::vec3& start, float yaw);

        float EyeHeight() const { return m_EyeHeight; }

    private:
        // Resolves one axis of movement.
        // @param position - player position with the axis already moved;
        //                   pushed back out of any overlapping wall IN PLACE
        // @param axis     - 0 = X, 2 = Z  (glm::vec3 component index)
        void ResolveAxis(glm::vec3& position, int axis) const;

        Camera*   m_Camera;
        MazeMap*  m_Map;

        // Half-size of the player's AABB in the XZ plane.
        // 0.6 x 0.6 box: small enough to fit corridors, big enough that
        // walls never clip through the camera near plane.
        float m_HalfExtent = 0.3f;

        float m_EyeHeight  = 1.6f;
    };
}

Task 2 - Implement construction and Respawn

  1. Create src/tests/maze/Player.cpp with the skeleton below.
  2. In the constructor, call m_Camera->setGrounded(true, m_EyeHeight). This tells the Camera to lock its Y position to eye height and project WASD movement onto the horizontal XZ plane. Because Player owns this call, TestMaze must not call setGrounded directly (you will remove that call in Task 4).
  3. In Respawn, teleport the camera to the spawn point. Use m_EyeHeight for the Y coordinate - the spawn position passed in has Y at floor level (0), but the camera eye sits 1.6 units above the floor.
  4. After filling in both methods, leave the rest of the file open - you will add Update and ResolveAxis in Task 3.
src/tests/maze/Player.cpp
#include "Player.h"

#include <cmath>

namespace test
{
    Player::Player(Camera* camera, MazeMap* map)
        : m_Camera(camera),
          m_Map(map)
    {
        // TODO: Enable grounded FPS mode so the camera stays at eye height
        // and WASD movement projects onto the horizontal XZ plane.
        // Use: m_Camera->setGrounded(true, m_EyeHeight);
        //
        // After this call, looking up while pressing W will no longer
        // make the camera fly into the ceiling.
    }

    void Player::Respawn(const glm::vec3& start, float yaw)
    {
        // TODO: Teleport the camera to the level start position.
        //
        // IMPORTANT: use m_EyeHeight for Y, not start.y.
        // start.y is at floor level (y = 0); the camera eye sits 1.6 units up.
        //
        // Use: m_Camera->setPosition(glm::vec3(start.x, m_EyeHeight, start.z));
        //      m_Camera->setYawPitch(yaw, 0.0f);
    }

} // namespace test

Task 3 - Implement move and slide

  1. Add Update and ResolveAxis to src/tests/maze/Player.cpp inside the namespace test block, after Respawn.
  2. In Update:
    1. Save the camera's current position as previous.
    2. Call m_Camera->processInput(deltaTime) to apply WASD.
    3. Save the result as desired.
    4. Build resolved = previous, then apply each axis in turn:
      resolved.x = desired.x;  ResolveAxis(resolved, 0);
      resolved.z = desired.z;  ResolveAxis(resolved, 2);
      resolved.y = desired.y;  // grounded camera pins this already
    5. Write resolved back with m_Camera->setPosition(resolved).
  3. In ResolveAxis:
    1. Compute the player's current tile with m_Map->WorldToTile(position, playerCol, playerRow).
    2. Loop over the 3x3 neighbourhood (row = playerRow - 1 to playerRow + 1, same for col). Skip tiles where m_Map->IsWall returns false.
    3. For each wall tile, compute the AABB overlap on X and Z:
      float overlapX = (m_HalfExtent + wallHalf) - std::abs(position.x - wallCenter.x);
      float overlapZ = (m_HalfExtent + wallHalf) - std::abs(position.z - wallCenter.z);
      If either overlap is zero or negative there is a gap - skip with continue.
    4. Push the player out along the axis parameter only. If position[axis] > wallCenter[axis], push to the positive face; otherwise push to the negative face. Add a small epsilon nudge to avoid floating-point re-entry next frame.
src/tests/maze/Player.cpp
    // Add these two methods inside namespace test { ... } in Player.cpp,
    // after the Respawn implementation.

    void Player::Update(float deltaTime)
    {
        // MOVE AND SLIDE - the core idea:
        //   1. Record where the player WAS this frame (before input)
        //   2. Let the Camera apply WASD movement (desired position)
        //   3. Start a "resolved" position equal to the PREVIOUS position
        //   4. Apply the X component of the desired move, then push out
        //      of any wall overlap on X only
        //   5. Apply the Z component of the desired move, then push out
        //      of any wall overlap on Z only
        //   6. Apply Y (the grounded camera already pins this to eye height)
        //   7. Write the resolved position back to the camera
        //
        // Resolving axes separately is what produces SLIDING: if you walk
        // diagonally into a wall the blocked axis is cancelled but the free
        // axis continues moving.

        // TODO: Save the camera's current position as 'previous'
        // TODO: Call m_Camera->processInput(deltaTime) to apply WASD
        // TODO: Save the resulting position as 'desired'

        // TODO: glm::vec3 resolved = previous;
        // TODO: resolved.x = desired.x;  ResolveAxis(resolved, 0);
        // TODO: resolved.z = desired.z;  ResolveAxis(resolved, 2);
        // TODO: resolved.y = desired.y;

        // TODO: m_Camera->setPosition(resolved);
    }

    void Player::ResolveAxis(glm::vec3& position, int axis) const
    {
        // Each wall tile is tileSize wide; the wall surface is tileSize/2
        // from the tile centre on each side.
        const float wallHalf = m_Map->TileSize() * 0.5f;

        // Epsilon nudge prevents floating-point error leaving the player
        // microscopically overlapping a wall surface after resolution.
        const float epsilon  = 0.001f;

        // BROAD PHASE: the player box is much smaller than a tile, so only
        // the 3x3 block of tiles around the player's current tile can
        // possibly touch the bounding box.
        int playerCol, playerRow;
        m_Map->WorldToTile(position, playerCol, playerRow);

        for (int row = playerRow - 1; row <= playerRow + 1; row++)
        {
            for (int col = playerCol - 1; col <= playerCol + 1; col++)
            {
                if (!m_Map->IsWall(col, row))
                    continue;

                glm::vec3 wallCenter = m_Map->TileCenter(col, row);

                // AABB OVERLAP TEST:
                // Two axis-aligned boxes overlap when the distance between
                // their centres is LESS than the sum of their half-extents,
                // on EVERY axis simultaneously. If either axis has a gap,
                // there is no collision and we skip this tile.
                //
                // overlap = (halfA + halfB) - |centreA - centreB|
                // overlap > 0  =>  boxes overlap on that axis
                //
                // TODO: float overlapX = (m_HalfExtent + wallHalf)
                //                        - std::abs(position.x - wallCenter.x);
                // TODO: float overlapZ = (m_HalfExtent + wallHalf)
                //                        - std::abs(position.z - wallCenter.z);
                // TODO: if (overlapX <= 0.0f || overlapZ <= 0.0f) continue;

                // PUSH OUT along the axis being resolved:
                // If the player centre is to the POSITIVE side of the wall
                // centre, push to the positive face; otherwise push negative.
                //
                // TODO: if (position[axis] > wallCenter[axis])
                //           position[axis] = wallCenter[axis]
                //                            + wallHalf + m_HalfExtent + epsilon;
                //       else
                //           position[axis] = wallCenter[axis]
                //                            - wallHalf - m_HalfExtent - epsilon;
            }
        }
    }

Task 4 - Wire Player into TestMaze

  1. Open src/tests/maze/TestMaze.h. Add the Player include after the MazeMap include, and add m_Player as a private member:
    #include "Player.h"   // add this line
    
    // in the private section, after m_Camera:
    std::unique_ptr<Player> m_Player;
  2. Open src/tests/maze/TestMaze.cpp. In the constructor, remove the setGrounded call (Player does this internally). Create m_Player immediately after m_Camera:
    // REMOVE: m_Camera->setGrounded(true, 1.6f);
    m_Player = std::make_unique<Player>(m_Camera.get(), &m_Map);
  3. Still in the constructor, replace the two camera-position calls at the end with a single Respawn call:
    // REMOVE: m_Camera->setPosition(m_Map.PlayerStart());
    // REMOVE: m_Camera->setYawPitch(m_Map.StartYaw(), 0.0f);
    m_Player->Respawn(m_Map.PlayerStart(), m_Map.StartYaw());
  4. In TestMaze::Update, replace the processInput call with m_Player->Update. Keep the m_Camera->Update call - it handles smooth interpolation and the ESC detach toggle:
    // REPLACE: m_Camera->processInput(deltaTime);
    m_Player->Update(deltaTime);
    m_Camera->Update(deltaTime); // keep this line
  5. Build and run the project. Select the Maze test. Walk towards a wall - the camera should stop at the surface. Walking diagonally into a corner should slide along whichever face is parallel to the movement direction rather than stopping dead.
src/tests/maze/TestMaze.cpp
// === Changes to TestMaze.h ===
// 1. Add the Player include (after MazeMap.h):
//      #include "Player.h"
//
// 2. Add a Player member in the private section (after m_Camera):
//      std::unique_ptr<Player> m_Player;
//
// No other changes to TestMaze.h are needed.

// === Changes to TestMaze.cpp - constructor ===
// Replace the grounded-mode call and the two camera-position calls:

TestMaze::TestMaze(GLFWwindow* window)
    : m_window(window)
{
    m_Camera = std::make_unique<Camera>(
        window,
        glm::vec3(0.0f, 1.6f, 0.0f),
        glm::vec3(0.0f, 1.0f, 0.0f),
        0.0f, 0.0f, 60.0f
    );
    // REMOVED: m_Camera->setGrounded(true, 1.6f);
    // The Player constructor calls setGrounded internally.

    // TODO: Create m_Player AFTER m_Camera:
    //   m_Player = std::make_unique<Player>(m_Camera.get(), &m_Map);

    m_Shader       = std::make_unique<Shader>("res/Shaders/Maze.shader");
    m_WallTexture  = std::make_unique<Texture>("res/Textures/maze/wall.png");
    m_FloorTexture = std::make_unique<Texture>("res/Textures/maze/floor.png");

    BuildCubeMesh();
    glEnable(GL_DEPTH_TEST);

    m_Map.Load(0, 1337u);

    // REMOVED: m_Camera->setPosition(m_Map.PlayerStart());
    // REMOVED: m_Camera->setYawPitch(m_Map.StartYaw(), 0.0f);
    // TODO: Replace both lines with:
    //   m_Player->Respawn(m_Map.PlayerStart(), m_Map.StartYaw());
}

// === Changes to TestMaze.cpp - Update() ===

void TestMaze::Update(float deltaTime)
{
    // REMOVED: m_Camera->processInput(deltaTime);
    // TODO: Replace with m_Player->Update(deltaTime);
    //       Player::Update calls processInput internally AND resolves collisions.

    m_Camera->Update(deltaTime); // keep this: handles smooth interp and GUI detach
}

Solution

Try the tasks above before revealing the solution.