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

Lab 19 - Camera - WASD Movement and Mouse Look

Overview

Up to this point the view in every test scene has been fixed. This lab introduces the Camera class from src/utils/Camera.h, which provides first-person WASD movement and mouse-look using Euler angles (yaw and pitch). You will construct a camera in a new test scene, drive it from the main loop using delta time, and connect its view matrix to the shader uniforms. By the end you will be able to fly freely around a 3D scene at the same speed regardless of frame rate. This camera is also used in the Maze Game series that follows (Labs 20-23).

Learning Objectives

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

Concept

Without a camera system, your scene is viewed from a hardcoded position. Adding one requires solving three problems: how to represent orientation, how to derive a view matrix from it, and how to make movement consistent across different frame rates.

The Camera class stores orientation as two Euler angles: yaw (horizontal rotation around the world Y-axis) and pitch (vertical tilt around the camera-local X-axis). Each frame, mouse movement increments these angles. The private UpdateCameraVectors method converts yaw and pitch into three orthonormal basis vectors using a spherical-to-Cartesian formula: front is the direction the camera faces, right is its local X-axis computed via a cross product with world-up, and up is the corrected local Y-axis. Pitch is clamped to 89 degrees in either direction to prevent the front vector from becoming parallel to world-up, which would make the cross product undefined (a gimbal singularity).

getViewMatrix() calls glm::lookAt(position, position + front, up), which builds the view matrix that moves the entire world so the camera sits at the origin looking down -Z. WASD movement is handled in ProcessKeyboard by adding or subtracting front and right scaled by movementSpeed * deltaTime. Multiplying by delta time (the elapsed seconds since the last frame) makes movement frame-rate independent: at 30 fps each frame lasts longer, so each step is larger, covering the same distance per second as at 120 fps.

Integration requires two calls per frame: processInput(deltaTime) reads GLFW keyboard and mouse state and updates position and angles, and Update(deltaTime) handles smooth interpolation when the camera is detached for GUI use. Press Escape to release the cursor and interact with ImGui. Press Escape again to reattach and resume flying.

Tasks

Task 1 - Construct the Camera

The diagram below shows how yaw and pitch map to the camera basis vectors front, right, and up. Study it before writing the constructor call.

Create a new test called TestCamera. In the constructor, declare a std::unique_ptr<Camera> member and initialise it with std::make_unique<Camera>. The Camera constructor signature is:

Camera(GLFWwindow* window,
       glm::vec3 startPosition,
       glm::vec3 startUp,
       float startYaw,
       float startPitch,
       float startFOV,
       bool invertPitch = true);

Use these starting values: position (0, 0, 3) (behind the origin), world-up (0, 1, 0) (Y-up), yaw -180.0f (facing -Z toward the origin), pitch 0.0f (level), and FOV 45.0f degrees. Also enable depth testing with glEnable(GL_DEPTH_TEST) so geometry is sorted correctly in 3D.

The skeleton below shows the header and constructor body. Fill in the make_unique call and the depth test enable.

src/tests/testCamera.h
#pragma once
#include "Tests.h"
#include "../Shader.h"
#include "../Mesh/GeometryFactory.h"
#include "../utils/Camera.h"
#include <memory>
#include <GL/glew.h>
#include <GLFW/glfw3.h>
#include <glm/glm.hpp>

namespace test {
    class TestCamera : public Tests {
    public:
        TestCamera(GLFWwindow* window);
        void Update(float deltaTime) override;
        void Render() override;
        void RenderGUI() override;

    private:
        GLFWwindow* m_window;

        // TODO: declare m_Camera as a unique_ptr<Camera>
        std::unique_ptr<Mesh>   m_Cube;
        std::unique_ptr<Shader> m_Shader;
    };
}
src/tests/testCamera.cpp (constructor)
TestCamera::TestCamera(GLFWwindow* window)
    : m_window(window)
{
    m_Cube = GeometryFactory::CreateCube();
    m_Cube->setPosition(glm::vec3(0.5f, 0.0f, 0.0f));

    // TODO: construct m_Camera using make_unique<Camera>
    // Use: window, position (0,0,3), world-up (0,1,0),
    //      yaw -180.0f, pitch 0.0f, FOV 45.0f
    m_Camera = /* TODO */;

    m_Shader = std::make_unique<Shader>(R"(res/Shaders/MVPVertexColour.shader)");

    // TODO: enable depth testing
}

The parameter controls left-right rotation of the camera (horizontal look).

Pitch is clamped to within degrees of the horizon to prevent a gimbal singularity.

Task 2 - Drive the Camera Each Frame

Two calls are needed every frame to keep the camera responsive. Open testCamera.cpp and complete the Update method.

  1. Call m_Camera->processInput(deltaTime) to read GLFW keyboard and mouse state and update the camera position and angles.
  2. Call m_Camera->Update(deltaTime) to apply smooth interpolation when the camera is detached for GUI use.

Both calls receive deltaTime (the time in seconds since the last frame). Movement speed is multiplied by this value so the camera travels the same world-space distance per second regardless of frame rate.

velocity=movementSpeed×Δt\text{velocity} = \text{movementSpeed} \times \Delta t
src/tests/testCamera.cpp (Update)
void TestCamera::Update(float deltaTime)
{
    // TODO: call processInput to read WASD and mouse input
    // TODO: call Update for smooth interpolation
}

A student removes deltaTime from the velocity calculation: `position += front * movementSpeed`. What is the effect at high frame rates?

Without deltaTime, position is updated by movementSpeed units on every frame. At 120 fps that is 120 updates per second compared to 30 at 30 fps. The camera moves four times faster at the higher frame rate. Multiplying by deltaTime converts the per-frame increment into a per-second rate, making movement consistent.

Task 3 - Connect the View Matrix to the Shader

In the Render method, three matrices must be passed to the shader as uniforms: model, view, and projection.

  • model moves the cube from local space to world space. Use glm::mat4(1.0f) for a stationary cube, or apply a rotation with glm::rotate.
  • view comes from m_Camera->getViewMatrix(). It moves the world so the camera sits at the origin.
  • projection is built with glm::perspective using the camera FOV from m_Camera->getFOV().

Complete the skeleton below. The shader is already bound before these calls.

gl_Position=P⋅V⋅M⋅vlocal\texttt{gl\_Position} = \mathbf{P} \cdot \mathbf{V} \cdot \mathbf{M} \cdot \mathbf{v}_{\text{local}}
src/tests/testCamera.cpp (Render)
void TestCamera::Render()
{
    Renderer renderer;
    renderer.ClearColour_White();
    renderer.Clear();

    m_Shader->Bind();

    // Model matrix: rotate the cube slowly over time
    glm::mat4 model = glm::rotate(glm::mat4(1.0f),
                                  (float)glfwGetTime(),
                                  glm::vec3(0.5f, 1.0f, 0.0f));

    // TODO: build the projection matrix
    // Use glm::perspective with the camera FOV, aspect ratio 800/600,
    // near plane 0.1f and far plane 100.0f
    glm::mat4 projection = /* TODO */;

    // TODO: set the model uniform on m_Shader
    // TODO: set the view uniform using m_Camera->getViewMatrix()
    // TODO: set the projection uniform

    m_Cube->Draw();
}

The view matrix transforms vertices from space into space.

The method that returns the ready-to-use view matrix from the Camera class is .

Task 4 - Camera Maths and Design

Answer the questions below to consolidate your understanding of the Camera class internals. Use the concept section and the source files in src/utils/Camera.h and src/utils/Camera.cpp as references.

Explain why pitch is clamped to 89 degrees rather than allowing the full 90 degrees. What mathematical operation would fail at exactly 90 degrees?

At exactly 90 degrees pitch the front vector points straight up (0,1,0), which is parallel to worldUp. The right vector is computed with cross(front, worldUp). The cross product of two parallel vectors is the zero vector. Normalising a zero vector is undefined (division by zero). Clamping to 89 degrees keeps front just short of vertical, ensuring the cross product always produces a usable right vector.

The Camera class stores yaw and pitch as Euler angles rather than a quaternion. State one practical limitation of the Euler-angle approach and describe how the code works around it.

Euler angles suffer from gimbal lock: when two rotation axes align, one degree of freedom is lost and some orientations become unreachable. For an FPS camera this happens when pitch reaches 90 degrees. The code works around it by clamping pitch to 89 degrees, which sacrifices the ability to look exactly straight up or down in exchange for stable behaviour. Quaternions avoid gimbal lock entirely but are more complex to integrate with mouse input.

The front vector is derived from yaw and pitch using the formulas: x = cos(pitch)*cos(yaw), y = sin(pitch), z = cos(pitch)*sin(yaw). What does the cos(pitch) factor in the x and z components do when the camera looks straight ahead (pitch = 0)?

cos(0) = 1, so at pitch = 0 the x and z components are cos(yaw) and sin(yaw) at full magnitude, placing the front vector in the XZ plane. As pitch increases toward 90 degrees, cos(pitch) approaches 0, shrinking x and z while sin(pitch) grows toward 1, tilting the vector upward. The cos(pitch) factor keeps the vector unit-length across all pitch values.

Solution

Try the tasks above before revealing the solution.