Overview
This worksheet derives the view matrix from first principles. You will compute the three camera basis vectors from an eye position and a look-at point using cross products, assemble the look-at matrix, and verify the results numerically. Understanding what glm::lookAt computes explains why the view matrix appears to move the world rather than the camera, and why its translation column is negated. This worksheet builds on the Homogeneous Coordinates lab (WS-M03) and the Column-Major Convention lab (WS-M04).
Learning Objectives
By the end of this lab you will be able to:
- Derive the three camera basis vectors (forward, right, up) from eye, centre, and worldUp using cross products
- Assemble a look-at matrix from those basis vectors and verify it numerically
- Explain why the translation column of the view matrix is negated
- Identify and explain the gimbal singularity that occurs when the forward vector is parallel to worldUp
Concept
The camera is positioned at a point in the scene and aimed at a target. The vertex shader needs all scene vertices expressed relative to the camera. Students often think the view matrix moves the camera to where it belongs, but it actually does the opposite: it moves the entire world so the camera ends up at the origin looking down -Z. The vertices do not move in world space. The coordinate frame shifts.
Three basis vectors describe the camera's orientation. GLM computes them as follows. The forward vector f is the normalised direction from eye to centre: f = normalise(centre - eye). The right vector s is perpendicular to both forward and worldUp: s = normalise(cross(f, worldUp)). The corrected up vector u is then recomputed as u = cross(s, f) to ensure perfect orthogonality. The view matrix combines a rotation block (rows built from s, u, -f) with a negated translation that shifts the world so the camera sits at the origin. The translation values are negated because the world moves opposite to the camera's position.
The function glm::lookAt(eye, centre, worldUp) computes this matrix. Its three arguments map directly to the three inputs of the derivation. One critical edge case: if the camera is aimed straight up or straight down, f and worldUp are parallel. Their cross product is the zero vector and normalising it is undefined. This gimbal singularity must be avoided in camera code.
Tasks
Task 1 - Deriving a Look-At Matrix Step by Step
Use: eye = (0, 0, 5), centre = (0, 0, 0), worldUp = (0, 1, 0). These values produce clean integer results.
Step 1 - Forward vector f
f = normalise(centre - eye) = normalise((0,0,0) - (0,0,5)) = normalise((0, 0, -5)) = (0, 0, -1)
Step 2 - Right vector s
s = normalise(cross(f, worldUp)) = normalise(cross((0,0,-1), (0,1,0)))
Cross product expansion (u x v = (uy*vz - uz*vy, uz*vx - ux*vz, ux*vy - uy*vx)):
cross((0,0,-1), (0,1,0)): x = (0)(0) - (-1)(1) = 0 + 1 = 1 y = (-1)(0) - (0)(0) = 0 - 0 = 0 z = (0)(1) - (0)(0) = 0 - 0 = 0 Result: (1, 0, 0) -- already unit length, so s = (1, 0, 0)
Step 3 - Corrected up vector u
u = cross(s, f) = cross((1,0,0), (0,0,-1)): x = (0)(-1) - (0)(0) = 0 y = (0)(0) - (1)(-1) = 1 z = (1)(0) - (0)(0) = 0 Result: u = (0, 1, 0)
Step 4 - Assemble the view matrix
The look-at matrix has the form: | s.x s.y s.z -dot(s, eye) | | u.x u.y u.z -dot(u, eye) | | -f.x -f.y -f.z dot(f, eye) | | 0 0 0 1 | Compute the translation column: -dot(s, eye) = -(1*0 + 0*0 + 0*5) = 0 -dot(u, eye) = -(0*0 + 1*0 + 0*5) = 0 dot(f, eye) = (0*0 + 0*0 + (-1)*5) = -5 Final look-at matrix: | 1 0 0 0 | | 0 1 0 0 | | 0 0 1 -5 | | 0 0 0 1 |
Interpretation: this is a pure translation of -5 on Z. The camera is at z = 5, so the world must shift by -5 to bring the camera to the origin. Because the camera has no rotation relative to the world axes, the rotation block of the matrix is the identity. The -5 in the translation column comes from dot(f, eye) = (0)(0)+(0)(0)+(-1)(5) = -5, placed at row 2, column 3 directly.
Task 2 - Derive a Different Configuration
Use: eye = (4, 0, 0), centre = (0, 0, 0), worldUp = (0, 1, 0). The camera is to the right of the scene, looking left toward the origin. Follow the same four steps and fill in the blanks.
Step 1: f = normalise(centre - eye) = normalise((0,0,0) - (4,0,0)) = normalise((-4,0,0)) = (, 0, 0)
Step 2: cross(f, worldUp) = cross((-1,0,0), (0,1,0)). x component = (0)(0) - (0)(1) =
Step 2 continued: y component = (0)(0) - (-1)(0) =
Step 2 continued: z component = (-1)(1) - (0)(0) =
So s = normalise((0, 0, -1)) = (0, 0, )
Step 3: u = cross(s, f) = cross((0,0,-1), (-1,0,0)). x component = (0)(0) - (-1)(0) =
Step 3 continued: y component = (-1)(-1) - (0)(0) =
Step 3 continued: z component = (0)(0) - (0)(-1) = . So u = (0, 1, 0).
Step 4: -dot(s, eye) = -(0*4 + 0*0 + (-1)*0) =
Step 4: -dot(u, eye) = -(0*4 + 1*0 + 0*0) =
Step 4: dot(f, eye) = (-1)(4) + (0)(0) + (0)(0) =
The completed view matrix for eye = (4, 0, 0), centre = (0, 0, 0), worldUp = (0, 1, 0):
| 0 0 -1 0 | | 0 1 0 0 | | 1 0 0 -4 | | 0 0 0 1 |
The right vector s = (0, 0, -1): the camera looks left (-X), so its right side points into the screen (-Z in world space). Row 2 of the view matrix is (-f.x, -f.y, -f.z, dot(f,eye)) = (1, 0, 0, -4): f = (-1,0,0) so -f = (1,0,0), and dot(f,eye) = (-1)(4) = -4, encoding that the origin is -4 units along the camera's forward axis.
Task 3 - Conceptual Questions
The camera is pointing straight up. worldUp = (0,1,0) and (centre - eye) points in the same direction (0,1,0). What goes wrong in Step 2, and what does this failure mean physically?
Why is the translation column of the look-at matrix negated?
What does the right vector s represent, and why is the cross product the correct tool for computing it?
Solution
Try the tasks above before revealing the solution.