Quaternions
A quaternion is a four-number object (s, x, y, z) that represents a
3D rotation. Compared to rotation matrices and Euler angles, quaternions:
- interpolate beautifully
slerpgives perfectly smooth rotation between two orientations, which is why animation systems store rotations as quaternions. - never suffer gimbal lock the "stuck axis" problem that Euler angles (separate x/y/z rotations) run into.
- are compact 4 floats instead of a matrix's 9 or 16.
You don't need to understand the four numbers themselves (they come from the mathematics of complex numbers extended to 4D). You only need the recipes on this page.
Naming reminder (grammar guide):
normalized()returns a new quaternion;conjugate()andinverse()are nouns naming the new object they return; onlyset()mutates.
Creating quaternions
It is best to never type the four numbers by hand, rather build quaternions from something meaningful:
from ncca.ngl import Quaternion, Vec3
# the identity quaternion: "no rotation"
q = Quaternion() # (s=1, x=0, y=0, z=0)
# THE way to make a rotation: an axis and an angle (in degrees)
q = Quaternion.from_axis_angle(Vec3(0.0, 1.0, 0.0), 90.0) # 90° about y
# from an existing rotation matrix
from ncca.ngl import Mat4
q = Quaternion.from_mat4(Mat4.rotate_y(90.0))
And convert back to a matrix when the GPU needs one:
m = q.to_mat4() # Quaternion -> Mat4, ready to combine with @ and upload
from_list / from_numpy / to_list / to_numpy / to_tuple / copy
work exactly as they do on vectors. Components are accessible as q.s,
q.x, q.y, q.z or by index.
Rotating a vector
This is the one deliberate exception to PyNGL's "* is scalar-only" rule:
Quaternion * Vec3 rotates the vector and returns a new Vec3.
from ncca.ngl import Quaternion, Vec3
q = Quaternion.from_axis_angle(Vec3(0.0, 1.0, 0.0), 90.0)
v = Vec3(1.0, 0.0, 0.0)
rotated = q * v
print(rotated) # [0.0, 0.0, -1.0] x axis swung 90° about y onto -z
(Right-hand rule: point your right thumb along +y; your fingers curl from +x towards −z.)
Combining rotations with @
Just like matrices, quaternions combine with @, and the combination reads
right to left — the right-hand rotation is applied first:
yaw = Quaternion.from_axis_angle(Vec3(0.0, 1.0, 0.0), 45.0)
pitch = Quaternion.from_axis_angle(Vec3(1.0, 0.0, 0.0), 30.0)
look = yaw @ pitch # pitch first, then yaw
Why
@and not*? The quaternion product is a linear-algebra product, so it uses the same operator as matrix multiplication. Writingq1 * q2raises aTypeErrortelling you to use@the library keeps*for "scale by a number" and the one specialQuaternion * Vec3case.
After combining many rotations, floating-point error slowly makes the quaternion drift away from unit length. Re-normalize occasionally:
q = q.normalized() # a new, exactly-unit-length quaternion
slerp — the reason quaternions exist
Spherical linear interpolation blends smoothly from one orientation to another at constant angular speed. This is the tool for animating rotation:
from ncca.ngl import Quaternion, Vec3
start = Quaternion() # no rotation
end = Quaternion.from_axis_angle(Vec3(0.0, 1.0, 0.0), 90.0) # 90° about y
halfway = start.slerp(end, 0.5) # exactly 45° about y
Worked example — turning a character to face a new direction
frames = 60
start = Quaternion.from_axis_angle(Vec3(0.0, 1.0, 0.0), 0.0)
end = Quaternion.from_axis_angle(Vec3(0.0, 1.0, 0.0), 180.0)
for frame in range(frames + 1):
t = frame / frames # 0.0 -> 1.0
q = start.slerp(end, t)
model = q.to_mat4() # use as the model's rotation
Why not just lerp the Euler angles? For a single-axis turn it happens to
work, but between two arbitrary 3D orientations, interpolating angles gives
wobbling, speed changes, and can hit gimbal lock. slerp takes the
shortest arc at constant speed, always.
Conjugate and inverse — undoing a rotation
q = Quaternion.from_axis_angle(Vec3(0.0, 1.0, 0.0), 90.0)
back = q.inverse() # the rotation that undoes q
conj = q.conjugate() # flips the axis: (s, -x, -y, -z)
For a unit quaternion (which every rotation should be), the conjugate is the inverse, same idea as "for a pure rotation matrix, the transpose is the inverse", and just as cheap.
v = Vec3(1.0, 0.0, 0.0)
w = q.inverse() * (q * v) # rotate, then un-rotate
print(w) # [1.0, 0.0, 0.0] (within float precision)
Other operations
Quaternions also support +, -, unary -, scalar * and /, dot,
length / length_squared, ==, and iteration — the same value-like
behaviour as vectors. You will rarely need the arithmetic ones directly
(slerp uses them internally), but dot has a handy meaning:
q1.dot(q2) # close to 1.0 (or -1.0) -> the two orientations are similar
Common mistakes
Mistake 1 :- using * for the quaternion product.
q1 * q2 # wrong :- TypeError — the message tells you what to do
q1 @ q2 # Correct
Mistake 2 :- forgetting angles are degrees.
Quaternion.from_axis_angle(axis, math.pi / 2) # wrong that's 1.57 DEGREES
Quaternion.from_axis_angle(axis, 90.0) #
Mistake 3 :- a non-unit axis. from_axis_angle expects a direction —
keep the axis normalized:
Quaternion.from_axis_angle(Vec3(0.0, 1.0, 0.0), 90.0) # unit axis
Quaternion.from_axis_angle(Vec3(1.0, 1.0, 0.0).normalized(), 90.0) #
Mistake 4 :- expecting normalized() to mutate. As everywhere in
PyNGL: assign the result — q = q.normalized().
Quick reference
| Operation | Code | Returns |
|---|---|---|
| no rotation | Quaternion() |
identity quaternion |
| axis + angle | Quaternion.from_axis_angle(axis, deg) |
new quaternion |
| from / to matrix | Quaternion.from_mat4(m), q.to_mat4() |
conversions |
| rotate a vector | q * v |
new Vec3 |
| combine | q1 @ q2 (right one first) |
new quaternion |
| blend | q1.slerp(q2, t) |
new quaternion |
| undo | q.inverse(), q.conjugate() |
new quaternion |
| unit length | q.normalized() |
new quaternion |
| similarity | q1.dot(q2) |
float |
| mutate | q.set(s, x, y, z) |
— (changes q) |
Next: The Transform Class — position, rotation, and scale bundled into one object.