In an earlier chapter you learned that a transform (the data that says where an object is in the world) stores three things: position, scale, and rotation. Position is just three numbers (x, y, z) telling you where the center of the object sits. Scale is three more numbers telling you how big it is along each axis. Both are simple: bigger numbers mean more of something, and you can average, add, or blend them without thinking too hard.
Rotation is different. There is no single obviously-correct way to store "which way something is facing" as numbers. The choice you make has real consequences: some ways to store a rotation are easy to read and type into an editor, but break in ugly, hard-to-debug ways at certain angles. Other ways are safe from that specific bug but look like meaningless algebra the first time you see them. This chapter is about that trade-off, and about quaternions (a 4-number way of storing a rotation), which is what almost every real engine -- Unity, Unreal, id Tech, all of them -- uses internally to store orientation, even when the editor shows you something friendlier.
Think about a spaceship model sitting in your scene. Its position answers "where is it?" Its orientation (the direction it's facing, including any tilt or roll) answers "which way is it pointing, and is it upside down?" In 3D, orientation has exactly 3 degrees of freedom (three independent ways you can turn something: for example, turning left/right, tipping up/down, and rolling side to side). That's the same count as position (x, y, z), but the numbers don't combine the same friendly way position does.
Here's a fact we will build the whole chapter on top of, and you don't need to prove it, just trust it and picture it: any orientation you can reach in 3D can be described as a single spin around some one axis, by some one angle. It doesn't matter how tumbled and weird the final orientation looks -- somewhere there is one imaginary line through the object and one angle such that spinning around that line by that angle gets you there in one move. This single fact is the seed that quaternions grow out of.
struct Vec3 { float x, y, z; };
struct Transform {
Vec3 position; // where the object is -- 3 numbers, easy
Vec3 scale; // how big the object is -- 3 numbers, easy
// rotation goes here -- but WHAT type should it be?
// that question is what this whole chapter answers.
};
(No console output here -- this is just a data shape, the same kind of plain struct you used for linked-list nodes and vectors back in the C chapters. A Quat will turn out to be just as plain: four floats sitting next to each other in memory, cheap to copy, no pointers, no heap allocation.)
The most obvious way to store an orientation is with three angles, one per axis. This is called Euler angles (named after the mathematician Leonhard Euler), and in games it almost always comes packaged as three friendly names borrowed from flight:
struct EulerAngles {
float yaw; // turn left/right around the up (Y) axis, in degrees
float pitch; // turn nose up/down around the side (X) axis, in degrees
float roll; // tilt side to side around the forward (Z) axis, in degrees
};
int main() {
EulerAngles cam{ 35.0f, -10.0f, 0.0f }; // turned 35 deg right, tilted down 10 deg
printf("yaw=%.1f pitch=%.1f roll=%.1f\n", cam.yaw, cam.pitch, cam.roll);
return 0;
}
This is the whole appeal of Euler angles: it's three plain numbers you can read out loud. "Turn 35 degrees right, tip down 10 degrees" is a sentence a human can say and understand instantly. That's exactly why the Unity Inspector shows rotation as three X/Y/Z degree fields, and why animators key rotation curves per axis. Nothing about quaternions will ever be this readable.
Euler angles apply as three separate spins, one after another, and each later spin happens around an axis that is attached to the object after the earlier spins already moved it. That detail -- spins stacking on top of each other -- is where the trouble starts.
Picture a real mechanical gimbal (a set of rings, each one pivoting inside the next, historically used in compasses, gyroscopes, and camera stabilizers -- this is literally where the term "gimbal lock" comes from). The outer ring spins for yaw, the middle ring spins for pitch, and the inner ring spins for roll. Normally, the three rings point in three different directions, so each one turns the object a different way.
Now tip the middle ring (pitch) all the way to 90 degrees. The inner ring, which used to point forward, has been dragged along and now points straight up -- the same direction the outer ring already points. The yaw axis and the roll axis are now the same physical line in space.
This isn't just a theory problem. Let's actually measure it. Below, upFor(yaw, pitch, roll) builds an orientation from three Euler angles (using the yaw-then-pitch-then-roll order) and reports where the object's "up" direction ends up pointing. (The helper functions matMul, matMulVec, rotX, rotY, rotZ are left out here to keep the focus on rotation, not matrices -- they're the plain 3x3 rotation matrices and matrix-multiply you'd expect, one function per axis.)
// build orientation from yaw-pitch-roll, then see where "up" (0,1,0) ends up
Vec3 upFor(float yawDeg, float pitchDeg, float rollDeg) {
Mat3 R = matMul(matMul(rotY(yawDeg*DEG), rotX(pitchDeg*DEG)), rotZ(rollDeg*DEG));
return matMulVec(R, Vec3{0,1,0});
}
int main() {
printf("-- pitch = 0: roll clearly tilts 'up' --\n");
Vec3 u1 = upFor(20, 0, 0);
Vec3 u2 = upFor(20, 0, 30);
printf("yaw=20 pitch=0 roll=0 -> up (%.3f, %.3f, %.3f)\n", u1.x, u1.y, u1.z);
printf("yaw=20 pitch=0 roll=30 -> up (%.3f, %.3f, %.3f)\n", u2.x, u2.y, u2.z);
printf("\n-- pitch = 90: yaw and roll now do the SAME thing --\n");
Vec3 g1 = upFor(20, 90, 10);
Vec3 g2 = upFor(10, 90, 0);
Vec3 g3 = upFor(30, 90, 20);
printf("yaw=20 pitch=90 roll=10 -> up (%.3f, %.3f, %.3f)\n", g1.x, g1.y, g1.z);
printf("yaw=10 pitch=90 roll=0 -> up (%.3f, %.3f, %.3f)\n", g2.x, g2.y, g2.z);
printf("yaw=30 pitch=90 roll=20 -> up (%.3f, %.3f, %.3f)\n", g3.x, g3.y, g3.z);
return 0;
}
Look closely at the second block. Three completely different (yaw, roll) pairs -- (20,10), (10,0), (30,20) -- all produce the exact same "up" direction, because in every pair yaw - roll = 10 stays fixed. As long as pitch = 90, only that one difference matters; yaw and roll have collapsed into a single effective knob. You have lost a whole degree of freedom. If you tried to smoothly turn the camera through this exact pose, you might see it visibly snap or spin unexpectedly, because the numbers the game is animating no longer map cleanly onto the rotation you want.
Remember the fact from Section 1: any orientation is reachable by a single spin around a single axis. Euler angles throw that fact away and instead force every orientation through three forced, sequential spins around fixed axes -- which is exactly what let two of those axes collide and cause gimbal lock. A quaternion (a four-number object, usually written w, x, y, z) takes the other approach: it stores that one axis and that one angle directly, packed together in a clever way that turns out to be very easy to compute with.
Do not worry about deriving where the formula below comes from -- that's a semester of linear algebra you don't need for making games. What you need is the intuition: a quaternion is "grab the object by this line, and twist it by this much." That's the entire idea. Everything else in this chapter is just: how do we build one, how do we combine two of them, and how do we use one to actually move a vector.
struct Quat {
float w, x, y, z; // w = scalar part, (x,y,z) = axis part
};
(No output yet -- this is just the shape. Four floats, exactly like the Vec3 you've already used, just one field bigger. Next section builds the first real one.)
Turning "spin around this axis by this angle" into the four numbers (w, x, y, z) uses one formula. You feed it a unit axis (a direction vector of length exactly 1) and an angle in radians, and it hands back a quaternion:
Quat quatFromAxisAngle(Vec3 axis, float angleRadians) {
float halfAngle = angleRadians * 0.5f;
float s = sinf(halfAngle);
Quat q;
q.w = cosf(halfAngle);
q.x = axis.x * s;
q.y = axis.y * s;
q.z = axis.z * s;
return q;
}
int main() {
float angle = 3.14159265358979323846f / 2.0f; // 90 degrees, in radians
Quat q = quatFromAxisAngle(Vec3{0,1,0}, angle); // 90 deg turn around the Y (up) axis
printf("q = (w=%.4f, x=%.4f, y=%.4f, z=%.4f)\n", q.w, q.x, q.y, q.z);
printf("length = %.4f\n", quatLength(q));
return 0;
}
That's a quaternion for "turn 90 degrees around the up axis." The y field is nonzero because the axis was (0,1,0) (pointing along Y); if we had spun around X instead, the x field would be the nonzero one. quatLength just does the usual "square everything, add, square-root" you'd do for a Vec3 length -- we'll define it properly in Section 9, but it's worth printing here to notice something important already: the length came out to exactly 1.0. That is not an accident, and Section 9 is entirely about why that number must always stay 1.
quatFromAxisAngle assumes the axis you pass in is already a unit vector (length 1). If you pass in a raw, un-normalized direction like (0, 5, 0), the formula still runs without crashing, but the result will not be a valid rotation -- always normalize your axis vector first.You'll notice the formula uses half the angle, not the full angle. That's the one genuinely strange thing about quaternions, and there's a clean pattern hiding in it, even if we skip proving why. Let's print a small table and see it with our own eyes: same axis (straight up), four different angles.
int main() {
const float PI = 3.14159265358979323846f;
float anglesDeg[] = {0, 90, 180, 270};
for (float d : anglesDeg) {
Quat q = quatFromAxisAngle(Vec3{0,1,0}, d * PI / 180.0f);
printf("angle=%5.1f deg -> w=%7.4f y=%7.4f\n", d, q.w, q.y);
}
return 0;
}
Two things to notice, both simple pattern-spotting, no algebra required:
w starts at 1 (no rotation at all) and shrinks toward 0 as the angle grows toward 180 degrees, then goes negative past that. Think of w as roughly "how little this rotation has turned" -- close to 1 means barely rotated, close to 0 means rotated a lot.w is (up to floating-point noise) exactly 0 -- the -0.0000 you see is just how a float spells "zero, but the sign bit happened to be negative," it is not a real negative number. And y (our axis component) has grown to its maximum, 1.0.The precise rule, for the curious, is w = cos(angle/2) and each of x, y, z = axis * sin(angle/2). You never need to hand-derive this. You only need to recognize it when you read engine source code or a debugger and see a quaternion's four numbers -- now you know they're not random, they're a packed cosine and a packed sine of half the turn.
Games constantly need to combine rotations -- "apply the character's aim rotation on top of their body rotation," "apply this frame's spin on top of last frame's orientation." With quaternions, combining two rotations is a single multiply:
Quat quatMultiply(const Quat& a, const Quat& b) {
Quat r;
r.w = a.w*b.w - a.x*b.x - a.y*b.y - a.z*b.z;
r.x = a.w*b.x + a.x*b.w + a.y*b.z - a.z*b.y;
r.y = a.w*b.y - a.x*b.z + a.y*b.w + a.z*b.x;
r.z = a.w*b.z + a.x*b.y - a.y*b.x + a.z*b.w;
return r;
}
You will basically never type this formula out by hand in real work -- your math library (or Unity's Quaternion, or Unreal's FQuat) already has it. What actually matters for a game programmer is one sharp fact: quaternion multiplication is not commutative -- a * b and b * a are usually different rotations. Order matters, exactly the same way it mattered which Euler angle you applied first.
To actually see the difference, we need to rotate a real point with each result -- that's quatRotateVec, which Section 8 covers in full. For now, just trust that quatRotateVec(q, v) means "rotate point v by quaternion q," and watch what happens when we feed it the two different orders below:
int main() {
const float PI = 3.14159265358979323846f;
Quat qYaw90 = quatFromAxisAngle(Vec3{0,1,0}, PI/2.0f); // 90 deg around Y (yaw)
Quat qPitch90 = quatFromAxisAngle(Vec3{1,0,0}, PI/2.0f); // 90 deg around X (pitch)
Vec3 v{0,0,1}; // a point 1 unit in front of us
Quat a = quatMultiply(qYaw90, qPitch90); // yaw THEN pitch (pitch applied first to v)
Quat b = quatMultiply(qPitch90, qYaw90); // pitch THEN yaw (yaw applied first to v)
Vec3 ra = quatRotateVec(a, v);
Vec3 rb = quatRotateVec(b, v);
printf("yaw * pitch applied to (0,0,1) -> (%.3f, %.3f, %.3f)\n", ra.x, ra.y, ra.z);
printf("pitch * yaw applied to (0,0,1) -> (%.3f, %.3f, %.3f)\n", rb.x, rb.y, rb.z);
return 0;
}
Same two rotations, opposite order, completely different final point -- (0,-1,0) versus (1,0,0). This is not a bug; it's the same real-world fact as "put on your socks then your shoes" giving a different (much better) result than "shoes then socks."
quatMultiply(a, b) right-to-left, the same way you'd read composed math functions like f(g(x)): it means "first apply b's rotation, then apply a's rotation on top." So "world rotation applied to a locally-rotated object" is usually written quatMultiply(parentRotation, localRotation), parent on the left.A quaternion by itself doesn't move anything -- you need a way to apply it to an actual point or direction, like a corner of a mesh or a camera's forward vector. The standard trick is a "sandwich": turn the vector into a quaternion with a zero w, multiply the rotation quaternion on the left, and its conjugate (same w, flipped-sign x, y, z) on the right.
Vec3 quatRotateVec(const Quat& q, const Vec3& v) {
Quat qv{0.0f, v.x, v.y, v.z}; // v, written as a quaternion
Quat qConj{q.w, -q.x, -q.y, -q.z}; // q's conjugate ("undo" version)
Quat result = quatMultiply(quatMultiply(q, qv), qConj);
return Vec3{result.x, result.y, result.z};
}
You don't need to prove why the sandwich shape q * v * q_conjugate cancels out into a pure rotation -- just trust that it does, the same way you trust sqrt without re-deriving Newton's method every time you call it. What matters is the recipe: sandwich the vector between the quaternion and its conjugate, and the leftover is your rotated vector.
int main() {
const float PI = 3.14159265358979323846f;
Quat q = quatFromAxisAngle(Vec3{0,1,0}, PI/2.0f); // 90 deg around Y (up)
Vec3 v{1,0,0}; // pointing right
Vec3 r = quatRotateVec(q, v);
printf("rotate (1,0,0) by 90deg around Y -> (%.3f, %.3f, %.3f)\n", r.x, r.y, r.z);
return 0;
}
A point one unit to the right, spun 90 degrees around "up," ends up one unit in front (negative Z here, following our axis convention) instead. If you rotated it another 90 degrees the same way, it would land back on the left, at (-1,0,0) -- a full 90-degree turn every time, exactly as expected.
Back in Section 5 we noticed the length of our quaternion came out to exactly 1.0. A quaternion whose length is exactly 1 is called a unit quaternion, and only unit quaternions represent pure rotations. If the length drifts away from 1 -- even slightly -- the sandwich formula from Section 8 stops being a clean rotation and starts sneaking in extra scaling, which can warp your mesh instead of just turning it.
float quatLength(const Quat& q) {
return sqrtf(q.w*q.w + q.x*q.x + q.y*q.y + q.z*q.z);
}
Quat quatNormalize(const Quat& q) {
float len = quatLength(q);
return Quat{q.w/len, q.x/len, q.y/len, q.z/len};
}
Why would the length ever drift? Every multiply, every interpolation, every frame of gameplay adds a tiny sliver of floating-point rounding error (the same kind of rounding you saw when you first learned that floats can't represent every number exactly). One tiny error per frame sounds harmless, but games run at 60, 120, sometimes hundreds of frames a second, for minutes at a time. Let's actually watch it happen:
int main() {
Quat orientation = quatFromAxisAngle(Vec3{0,1,0}, 0.0f); // identity, facing forward
Quat delta = quatFromAxisAngle(Vec3{0,1,0}, 0.001f); // tiny turn applied every frame
printf("length at frame 0 = %.8f\n", quatLength(orientation));
for (int frame = 1; frame <= 200000; frame++) {
orientation = quatMultiply(delta, orientation); // NOT normalized after each step
}
printf("length after 200000 frames = %.8f\n", quatLength(orientation));
Quat fixedUp = quatNormalize(orientation);
printf("length after normalize = %.8f\n", quatLength(fixedUp));
return 0;
}
Two hundred thousand frames (over half an hour at 100 fps) of tiny, honest, individually-correct multiplications and the length crept from a perfect 1.0 to 1.00041544. That's a small number, but it's not zero, and it only grows the longer the game runs. One call to quatNormalize snaps it straight back to exactly 1.0.
Cameras and animation constantly need to blend smoothly from one orientation to another -- a camera easing toward looking at a new target, a character's arm animating from a relaxed pose to a raised one. Naively blending each of the four numbers straight-line and hoping for the best does not, by itself, keep the result a valid unit quaternion, and (as we're about to see) doesn't even move at a constant speed. The correct tool for "constant-speed rotation blend" is SLERP (Spherical Linear intERPolation): instead of a straight line between two quaternions, it walks along the curved surface connecting them, at an even pace.
float quatDot(const Quat& a, const Quat& b) {
return a.w*b.w + a.x*b.x + a.y*b.y + a.z*b.z;
}
Quat quatSlerp(Quat a, Quat b, float t) {
float dot = quatDot(a, b);
if (dot < 0.0f) { // take the SHORT way around, not the long way
a.w = -a.w; a.x = -a.x; a.y = -a.y; a.z = -a.z;
dot = -dot;
}
if (dot > 0.9995f) { // a and b are nearly identical: fall back to a cheap blend
Quat r{ a.w + t*(b.w-a.w), a.x + t*(b.x-a.x), a.y + t*(b.y-a.y), a.z + t*(b.z-a.z) };
return quatNormalize(r);
}
float theta0 = acosf(dot); // angle between a and b
float theta = theta0 * t; // angle at parameter t
float sinTheta0 = sinf(theta0);
float sinTheta = sinf(theta);
float s0 = cosf(theta) - dot * sinTheta / sinTheta0;
float s1 = sinTheta / sinTheta0;
return Quat{ s0*a.w + s1*b.w, s0*a.x + s1*b.x, s0*a.y + s1*b.y, s0*a.z + s1*b.z };
}
if (dot < 0.0f) line at the top. Here's why it's there: q and -q (every sign flipped) represent the exact same rotation -- this quirk is called quaternions' "double cover." Without that check, SLERP might unknowingly take the long way around a 350-degree detour instead of the short 10-degree turn you actually wanted, because mathematically both paths are "valid," but only one looks right on screen.Let's actually measure the "constant speed" claim, blending from facing forward (0 degrees) to a 150-degree turn, and checking the resulting angle at each step of t. We'll compare against quatNlerp too, a second blending function that Section 11 defines in full -- for now, just know it's a cheaper, naive blend, so we can see how it stacks up against SLERP:
int main() {
const float PI = 3.14159265358979323846f;
Quat a = quatFromAxisAngle(Vec3{0,1,0}, 0.0f);
Quat b = quatFromAxisAngle(Vec3{0,1,0}, 150.0f * PI / 180.0f); // turned 150 deg around Y
printf("t slerp angle(deg) nlerp angle(deg)\n");
for (float t = 0.0f; t <= 1.0001f; t += 0.25f) {
Quat s = quatSlerp(a, b, t);
Quat n = quatNlerp(a, b, t);
float sAngle = 2.0f * acosf(s.w) * 180.0f / PI;
float nAngle = 2.0f * acosf(n.w) * 180.0f / PI;
printf("%.2f %14.2f %16.2f\n", t, sAngle, nAngle);
}
return 0;
}
Look at the slerp column: 0, 37.5, 75, 112.5, 150 -- each step is exactly 37.5 degrees, a perfectly even pace, exactly 150 * t. That's what "constant angular speed" means in practice. This is exactly what you want for a camera smoothly panning between two look directions -- no sudden speed-up or slow-down that the player would notice as jerky motion.
The nlerp column above did not move at an even pace (33.02, then jumping to 75, then 116.98) -- it starts slow and finishes fast. NLERP (Normalized Linear intERPolation) is exactly what it sounds like: blend the four numbers with a plain straight-line average, then normalize the result back onto the unit-length "legal rotation" surface.
Quat quatNlerp(const Quat& a, const Quat& b, float t) {
Quat r{ a.w + t*(b.w-a.w), a.x + t*(b.x-a.x), a.y + t*(b.y-a.y), a.z + t*(b.z-a.z) };
return quatNormalize(r);
}
Why would anyone use the uneven one? Two reasons, both about cost: quatSlerp calls acosf and two sinfs, which are noticeably slower than a handful of additions and one sqrtf in quatNlerp. And the speed unevenness you saw above only really shows up when the two orientations are far apart (like our 150-degree example) -- for the small, per-frame nudges most gameplay code actually does (a camera catching up to a target a few degrees per frame, not 150 at once), the difference between SLERP and NLERP is invisible to the player, but the CPU savings from calling NLERP instead of SLERP thousands of times a frame (imagine a crowd of NPCs all turning to look at something) are not invisible at all.
After two sections making the case for quaternions, here's the honest twist: you should still reach for Euler angles all the time, just not for the same job. Euler angles are for humans; quaternions are for math.
The pattern almost every engine follows: author and expose rotation as Euler angles at the edges (editor UI, simple scripts), but store and compute with quaternions everywhere rotations get combined, animated, or blended. Here's what that looks like in Unity, since this topic is one you'll meet constantly once you're writing Unity gameplay code:
using UnityEngine;
public class Turret : MonoBehaviour {
public float yawDegreesPerSecond = 90f;
void Update() {
// Easy to author: just spin around world up (Y) over time.
transform.Rotate(Vector3.up, yawDegreesPerSecond * Time.deltaTime, Space.World);
}
}
(No console output -- every frame this nudges the object's rotation a few more degrees around world Y. transform.Rotate is authored like an axis-angle turn, but Unity's transform.rotation is stored as a Quaternion underneath, and Quaternion.Slerp(a, b, t) is the exact SLERP from Section 10, ready-made, for whenever you need a camera or character to smoothly turn toward a target orientation.)
transform.eulerAngles, tweaking one axis, and writing it back every frame seems harmless, but each read/write round-trips through a quaternion-to-Euler conversion that isn't always unique (the same orientation can have more than one valid set of Euler angles), so values can jump or flip unexpectedly across frames. Prefer transform.Rotate, Quaternion.Euler(...) applied once, or direct quaternion math for anything animated continuously.Here's the whole chapter as one pipeline: author-friendly numbers go in one end, safe math happens in the middle, and smooth motion comes out the other end.
quatFromAxisAngle, combine two with quatMultiply (order matters!), and apply one to a point with quatRotateVec.w, x, y, z) representation of a rotation as a single axis and a single angle.w but flipped-sign x, y, z; used to "undo" a rotation.q and its negation -q represent the exact same rotation.quatFromAxisAngle, build the quaternion for a 180-degree turn around the Z axis. Before you run anything, use the half-angle pattern from Section 6 to guess what (w, x, y, z) should look like. Then write the code, print the quaternion, and use quatRotateVec to rotate the point (1, 0, 0) with it. Where do you expect that point to land, just by picturing a half-turn? Does the code agree?Half of 180 degrees is 90 degrees, so we expect w = cos(90deg) = 0 and the Z component = sin(90deg) = 1 (since the axis is (0,0,1)), with X and Y at 0. A half-turn should send (1,0,0) to its exact opposite, (-1,0,0).
int main() {
const float PI = 3.14159265358979323846f;
Quat q = quatFromAxisAngle(Vec3{0,0,1}, PI); // 180 deg around Z
printf("q = (w=%.4f, x=%.4f, y=%.4f, z=%.4f)\n", q.w, q.x, q.y, q.z);
Vec3 v{1,0,0};
Vec3 r = quatRotateVec(q, v);
printf("rotate (1,0,0) -> (%.4f, %.4f, %.4f)\n", r.x, r.y, r.z);
return 0;
}
Exactly as predicted (the -0.0000s are just floating-point noise for values that are mathematically zero): w and z match the half-angle prediction, and rotating (1,0,0) by a half-turn lands it precisely on (-1,0,0), its mirror image through the origin.
quatConjugate function (flip the sign of x, y, z, keep w). Use it to rotate a vector forward with some quaternion q, then rotate the result backward using quatConjugate(q). What do you expect to happen to the vector, and why? Confirm it with code.The conjugate of a unit quaternion is also its inverse -- it represents "the same axis, the opposite angle," which undoes the original rotation. So rotating forward then backward should return the original vector, within tiny floating-point rounding.
Quat quatConjugate(const Quat& q) {
return Quat{ q.w, -q.x, -q.y, -q.z };
}
int main() {
const float PI = 3.14159265358979323846f;
Quat q = quatFromAxisAngle(Vec3{0,1,0}, 40.0f * PI / 180.0f); // 40 deg around Y
Vec3 v{2,3,5};
Vec3 forward = quatRotateVec(q, v);
Vec3 back = quatRotateVec(quatConjugate(q), forward);
printf("original v = (%.3f, %.3f, %.3f)\n", v.x, v.y, v.z);
printf("rotated v = (%.3f, %.3f, %.3f)\n", forward.x, forward.y, forward.z);
printf("rotated back = (%.3f, %.3f, %.3f)\n", back.x, back.y, back.z);
return 0;
}
The vector comes back exactly where it started. This is genuinely useful in real code -- for example, converting a direction from world space into an object's local space is exactly "rotate backward by the object's orientation quaternion," using the conjugate.
quatSlerp and quatNlerp between them at t = 0.25 and t = 0.5, printing the resulting angle each time (same trick as Section 10: angle = 2 * acos(w) * 180 / PI). What do you notice about the two results at t = 0.5 specifically, compared to t = 0.25?int main() {
const float PI = 3.14159265358979323846f;
Quat a = quatFromAxisAngle(Vec3{0,1,0}, 0.0f);
Quat b = quatFromAxisAngle(Vec3{0,1,0}, 170.0f * PI / 180.0f); // 170 deg apart
float ts[] = {0.25f, 0.5f};
for (float t : ts) {
Quat s = quatSlerp(a, b, t);
Quat n = quatNlerp(a, b, t);
float sAngle = 2.0f * acosf(s.w) * 180.0f / PI;
float nAngle = 2.0f * acosf(n.w) * 180.0f / PI;
printf("t=%.2f slerp -> %.2f deg nlerp -> %.2f deg\n", t, sAngle, nAngle);
}
return 0;
}
At t = 0.25, NLERP clearly lags behind SLERP (35.77 vs. the "correct," even-paced 42.50 degrees). But at t = 0.5, they land on the exact same angle, 85.00 degrees. This is a real property, not a coincidence of these numbers: NLERP always matches SLERP exactly at the halfway point (t = 0.5) between any two orientations, because the straight-line midpoint between two points on a sphere always sits on the great-circle arc between them too -- it's everywhere except the midpoint where the straight-line shortcut and the curved path disagree, and that disagreement grows the farther apart the two orientations are.