A first-person camera captures the virtual scene from the viewpoint of a character. The camera combines two behaviors:

  • Orbit: The character looks left, right, up, and down without tilting the head (zero roll).
  • Translation: The character moves forward, backward, left, and right along the current forward gaze direction.

Both behaviors are modeled by creating a camera coordinate space and updating its basis vectors under mouse movement.

Assuming standard world space axes:

Chosen world space: $+x$ (right), $+y$ (up), and $+z$ (backward).

Let $\mathbf{M}_{\text{upright} \leftarrow \text{camera}}$ be the rotation matrix transforming vectors from camera space to upright space. Let the gaze direction vector be $\mathbf{p}_{\text{camera}} = \begin{bmatrix} 0 & 0 & -1 \end{bmatrix}^T$.

Camera rotation combines two rotations:

  • Looking left or right: rotation around the upright $y$-axis (yaw $\alpha$).
  • Looking up or down: rotation around the upright $x$-axis (pitch $\beta$).

The intrinsic sequence $y-x^\prime$ (equivalent to extrinsic $x-y$) is represented by:

$$ \begin{aligned} \mathbf{M}_{\text{upright} \leftarrow \text{camera}} &= \mathbf{Y}(\alpha) \mathbf{X}(\beta) \\ &= \begin{bmatrix} \cos\alpha & 0 & \sin\alpha \\ 0 & 1 & 0 \\ -\sin\alpha & 0 & \cos\alpha \end{bmatrix} \begin{bmatrix} 1 & 0 & 0 \\ 0 & \cos\beta & -\sin\beta \\ 0 & \sin\beta & \cos\beta \end{bmatrix} \\ &= \begin{bmatrix} \cos\alpha & \sin\alpha\sin\beta & \sin\alpha\cos\beta \\ 0 & \cos\beta & -\sin\beta \\ -\sin\alpha & \cos\alpha\sin\beta & \cos\alpha\cos\beta \end{bmatrix} \end{aligned} $$

The angles $\alpha$ and $\beta$ update from frame-to-frame deltas:

$$ \begin{aligned} \beta &\leftarrow \beta + \Delta\beta \\ \alpha &\leftarrow \alpha + \Delta\alpha \end{aligned} $$

Where:

  • Looking upward increases pitch ($\Delta\beta > 0$).
  • Looking rightward decreases yaw ($\Delta\alpha < 0$).

Mouse Coordinates Delta to Extrinsic Rotations Delta

Window managers like GLFW report mouse positions in screen coordinates where $+x$ points right and $+y$ points down.

Given current coordinates $(x_{\text{new}}, y_{\text{new}})$ and previous coordinates $(x_{\text{old}}, y_{\text{old}})$:

$$ \begin{aligned} \Delta x &= x_{\text{new}} - x_{\text{old}} \\ \Delta y &= -(y_{\text{new}} - y_{\text{old}}) \end{aligned} $$

Negating the $y$ delta ensures that moving the mouse upward produces a positive $\Delta y$.

Updating yaw $\alpha$ and pitch $\beta$:

$$ \begin{aligned} \alpha &\leftarrow \alpha - \Delta x \\ \beta &\leftarrow \beta + \Delta y \end{aligned} $$

Pitch is clamped to $-90^\circ \leq \beta \leq 90^\circ$ to prevent the player camera from flipping upside down.

Transforming the camera gaze vector $\mathbf{p}_{\text{camera}} = [0, 0, -1]^T$ into world coordinates yields the forward direction:

$$ \begin{aligned} \mathbf{p}_{\text{world}} &= \mathbf{M}_{\text{world} \leftarrow \text{camera}} \mathbf{p}_{\text{camera}} \\ &= \begin{bmatrix} \cos\alpha & \sin\alpha\sin\beta & \sin\alpha\cos\beta \\ 0 & \cos\beta & -\sin\beta \\ -\sin\alpha & \cos\alpha\sin\beta & \cos\alpha\cos\beta \end{bmatrix} \begin{bmatrix} 0 \\ 0 \\ -1 \end{bmatrix} \\ &= \begin{bmatrix} -\sin\alpha\cos\beta \\ \sin\beta \\ -\cos\alpha\cos\beta \end{bmatrix} \end{aligned} $$

FPS Pitch Clamping vs 6-DOF Quaternion Cameras

First-person shooter (FPS) cameras function reliably with separate yaw and pitch Euler angles specifically because pitch is clamped to $[-90^\circ, 90^\circ]$ and roll is locked to zero. This constraint prevents the camera from ever entering the gimbal lock singularity.

In contrast, 6-DOF (six degrees of freedom) free flight cameras (spacecraft simulators, drone flight, and orbital tracking) allow unrestricted rotations across all three axes. Euler angle accumulation in 6-DOF cameras causes gimbal lock and axis flipping.

Free cameras therefore represent orientation using a unit quaternion $q$:

$$ q_{t+\Delta t} = \Delta q \cdot q_t $$

Where mouse and roll inputs construct incremental rotors $\Delta q = [\cos\frac{\theta}{2}, \sin\frac{\theta}{2}\hat{\mathbf{n}}]$, preserving smooth, singularity-free orientation.

For quaternion rotor mathematics and interpolation, see Quaternions .

#pragma once

class FPS_Mouse {
public:
  float sensitivity;
  float yaw
  float pitch;
  glm::vec4 target;

  static const glm::vec3 YAW_AXIS = glm::vec3(0.0f, 1.0f, 0.0f);
  static const glm::vec3 PITCH_AXIS = glm::vec3(1.0f, 0.0f, 0.0f);

  FPS_Mouse(float yaw, float pitch);
  void process_mouse_movement(double delta_x, double delta_y, bool constraint_pitch);
  glm::mat4 get_view_matrix() const;

private:
  static const glm::vec4 P = glm::vec3(0.0f, 0.0f, -1.0f, 1.0f);
  void update_target();
}

FPS_Mouse::FPS_Mouse(float yaw = 0, float pitch = 0) :
    sensitivity(0.05f) {
  this->yaw = yaw;
  this->pitch = pitch;
  this->update_target();
}

void FPS_Mouse::process_mouse_movement(double delta_x, double delta_y, bool constraint_pitch = true) {
  yaw -= delta_x * sensitivity;
  pitch += delta_y * sensitivity;

  if (constraint_pitch) {
    if (pitch > 89.0f) { pitch = 89.0f; }
    if (pitch < -89.0f) { pitch = -89.0f; }
  } 
  this->update_target();
}

void FPS_Mouse::update_target() {
  /* Y = glm::rotate(glm::mat4(1.0f), glm::radians(yaw), FPS::YAW_AXIS); */
  /* X = glm::rotate(glm::mat4(1.0f), glm::radians(pitch), FPS::PITCH_AXIS); */
  /* target = Y * X * p; */
  float yaw_radians = glm::radians(yaw);
  float pitch_radians = glm::radians(pitch);
  target.x = -sin(yaw_radians) * cos(pitch_radians);
  target.y = sin(pitch_radians);
  target.z = -cos(yaw_radians) * cos(pitch_radians);
}