Rotation and orientation
Storing where a thing is takes three numbers and no argument. Storing which way it faces takes a choice, and the choice has consequences: pitch-and-yaw is simple and breaks at the poles, quaternions never break and are harder to read. This note is about reading the first one correctly and knowing when to reach for the second.
../math/trig-and-vector-heuristics.md has cosine and sine as pure maths — "how horizontal" and "how vertical". Everything here builds on that.
Pitch shrinks the horizontal, yaw spins it
cos(pitch) is how much horizontal reach is left; sin(pitch) is how much became vertical. A Y-up camera forward vector is three lines:
cp := math.cos_f32(pitch)
sp := math.sin_f32(pitch)
forward := [3]f32{ cp * math.cos_f32(yaw), sp, cp * math.sin_f32(yaw) }
That cp * on x and z is what keeps the vector unit length, courtesy of cp² + sp² = 1. The useful picture: pitch chooses a horizontal slice of the unit sphere, and yaw walks you around that slice. cp is the slice's radius. Level, it's 1 and you have full reach. Looking straight up, it's 0 and the slice has shrunk to a point.
Two things people get wrong here. First, cos(pitch) is not "how far along" anything — it starts at 1 and shrinks, non-linearly, so at 30° you still have 87% of your horizontal reach and at 60° only 50%. Second, the argument is radians, always. Odin's core:math follows C.
Cosine throws away the sign
cos(pitch) == 0 means straight up or straight down. Cosine is even — cos(-x) == cos(x) — so it cannot tell the poles apart. Sine is odd and keeps the sign, which is why sp (equivalently forward.y) is the component any up-or-down test has to read.
The same asymmetry is why atan2(y, x) exists. Going backwards from a vector to an angle, atan(y/x) destroys the quadrant in the division and collapses opposite directions onto one answer. atan2 sees both components and keeps all four quadrants.
The poles are the parameterisation, not a bug
At exactly ±90° the horizontal component is zero and the basis collapses. You build right from a cross product with world-up; when forward is world-up, that cross product has no defined direction and roll becomes arbitrary. Clamp pitch to about ±89° and the problem disappears — which is also why a first-person camera wants Euler angles: the constraint is the feature that keeps the horizon level.
Every Euler system has this degeneracy somewhere. Quaternions are the usual answer when you genuinely need orientation without a privileged axis.
Quaternions, briefly
A quaternion is an axis and an angle, stored half-cooked:
q = (w, x, y, z) = (cos(θ/2), axis * sin(θ/2))
The half-angle is the one thing to memorise. It explains why a full 360° turn gives q = -1 rather than 1, and why q and −q name the same rotation. That double cover bites during interpolation: check dot(q1, q2) and negate one of the pair if it's negative, or your slerp takes the 358° route.
The rest: composition is multiplication, right-to-left, non-commutative. For unit quaternions the inverse is the conjugate, so it's cheap. They drift off unit length as products accumulate, so renormalise. Use them for free-flying cameras, skeletal blending and rigid-body orientation — not for a standard FPS camera, where you want the gimbal constraint.
Odin has quaternion128 and quaternion256 as built-in types, so * is already the Hamilton product. core:math/linalg supplies quaternion_slerp and matrix4_from_quaternion.
Demo
A forward vector in side and plan view at once — drag pitch and watch the plan-view circle shrink to nothing; that circle's radius is cos(pitch). Then unclamp the pitch limit and watch the right vector lose its direction at the pole.
Gotchas
| Symptom | Cause |
|---|---|
| Camera spins wildly when looking near-vertical | Pitch unclamped; right degenerating at the pole |
| Horizon rolls during normal mouselook | Building right from local up instead of world up |
| Movement speeds up when looking down | Using forward for walking instead of its normalized horizontal part |
| Angles correct in one quadrant, mirrored in others | atan(y/x) instead of atan2(y, x) |
| Rotation is 2× or ½× what you expect | Half-angle forgotten when building the quaternion by hand |
| Slerp takes the long way round | Sign of the pair not fixed; negate one when dot(q1,q2) < 0 |
| Orientation slowly skews after many frames | Quaternion not renormalised after accumulating products |
| Everything is off by a factor of ~57 | Degrees passed where radians were wanted |
In KoldEngine
The camera module already computes cp := math.cos(g_lvl_pitch) and reuses it on both horizontal components — that is this note's first section in practice. Two things worth checking there: that g_lvl_pitch is clamped before use rather than after, and that the unsuffixed math.cos isn't quietly promoting an f32 to f64 every frame — math.cos_f32 avoids it.