Documentation · 3D Guide
4 · Turning things
Rotation is the part of 3D that people meet as three angles and leave as four numbers they do not understand. This chapter is why the swap happens, what the four numbers are, and — mostly — how to avoid ever looking at them.
Angles are fine until they are not
"Turn 30° about X, then 45° about Y, then 10° about Z" is easy to picture and easy to type. It is also three separate problems waiting:
- The order changes the answer. X-then-Y is a different orientation from Y-then-X. Two pieces of code that both say "pitch 30, yaw 45" can disagree about where a thing ends up.
- Two of the axes can collide. Pitch straight up and the yaw and roll axes line up: one whole degree of freedom vanishes, and no combination of the three can get it back. It is called gimbal lock, and it is the reason a camera pitch is clamped to ±89° here rather than ±90°.
- They do not interpolate. Halfway between two sets of Euler angles is very rarely halfway between the two orientations, and something animated through them wobbles on the way.
For placing a crate at 45° none of that matters, which is why Node.RotationDegrees exists and
is perfectly good. For anything that turns, follows or aims, it all matters at once.
What a quaternion actually is
Any orientation in 3D, however you got to it, is the same as one rotation about one axis. That is not obvious and it is true: pitch, yaw and roll in any combination can always be replaced by a single turn about some tilted axis you did not choose.
A quaternion is that fact written down as four numbers: three that encode the axis, one that encodes how far around it. Turning it into a matrix is a handful of multiplications, combining two of them is one multiply, and blending between two is a smooth arc with no wobble and no locked axis.
You are not expected to read them. Nobody looks at (0.183, 0.0, 0.0, 0.983)
and sees "21° of pitch". You build quaternions from things you do understand — an axis and an
angle, or two directions — and combine them. In eight years of using them you may never inspect a
component.
Building one from what you have
System.Numerics.Quaternion provides the two that come from angles, and
Rotations adds the two that come from directions — which is what a scene
usually has:
// From an axis and an angle: a wheel turning about its own hub.
var spin = Quaternion.CreateFromAxisAngle(Vector3.UnitX, seconds * 2f);
// From angles, when angles are genuinely what you mean.
var tilt = Quaternion.CreateFromYawPitchRoll(yaw, pitch, roll);
// From one direction to another: the shortest arc between them.
var lay = Rotations.FromTo(Vector3.UnitY, surfaceNormal);
// From a heading: point the nose that way and keep the top level.
var aim = Rotations.LookAlong(velocity);
FromTo is the one people do not know they wanted. Lay a decal flat on a sloped surface, swing a
hinge onto its axis, ask how far one attitude is from another — none of those is expressible in Euler
angles at all, and all three are one call. It takes vectors of any length and calls no trigonometric
function.
LookAlong is the named case of aiming: give it a direction and it produces the rotation that
points a model's nose down it, with the model's top as level as it can be. The optional second argument is
what "level" means, if world +Y is not it.
Combining, and undoing
var both = outer * inner; // `inner` happens first — see below
var same = Quaternion.Concatenate(inner, outer); // identical, and says so
var back = Quaternion.Conjugate(both); // the same turn, backwards (for a unit quaternion)
var half = Quaternion.Slerp(a, b, 0.5f); // genuinely halfway, along the shortest arc
Quaternions multiply right to left; matrices in the same library multiply left to right.
This catches everyone once. Matrix4x4 here uses the row-vector convention, so
rotate * translate means rotate then translate. Quaternion's
* is the ordinary Hamilton product, so a * b applies b first
and a second — which is why Quaternion.Concatenate(a, b) exists and is
documented as "a followed by b". If a rotation comes out backwards, swap the operands before
suspecting anything subtler.
Multiplication is not commutative — a * b is not b * a — for the same reason
turning left then walking is not walking then turning left. And after a few thousand multiplications
rounding will have crept in, so a quaternion you keep accumulating wants an occasional
Quaternion.Normalize.
Aiming a node at something
Most of the time you do not need any of the above, because the thing you actually want is on the node:
turret.LookAt(target.WorldPosition);
camera_mount.LookAt(target.WorldPosition, up: shipUp);
Node.LookAt turns the node so its nose — −Z, the convention from chapter 1 — points at a world-space position. Any rotation the parents are applying is solved out, so a turret bolted to a hull that is itself rolling still ends up looking at what it was told to look at.
If your model aims sideways, its nose is not down −Z. That is a modelling convention, not
a bug here — fix it in the exporter, or bake the correction into the geometry once with
mesh.Transformed(Matrix4x4.CreateRotationY(MathF.PI)) rather than adding a fudge rotation to
every node that uses it.
Banking into a turn
An aircraft does not turn flat; it rolls into the turn and the turn follows. That reads as one line once you have a heading and how fast it is changing:
var forward = Vector3.Normalize(Spline.Direction(path, t)); // which way it is going
var bank = Math.Clamp(-turnRate * gain, -0.96f, 0.96f); // ±55°, so it never lies on its back
var level = Rotations.LookAlong(forward); // nose on the heading, wings level
ship.Rotation = bank == 0f
? level
: Quaternion.CreateFromAxisAngle(forward, -bank) * level;
Read that multiplication right to left, as the box above says: level happens first and
puts the nose on the heading, and the roll is applied after it, about forward — which by then
is the nose. That is the whole trick. Bank about the world's Z axis instead and a ship heading
north would corkscrew sideways through its own turn.
Run it
Six ships fly Catmull–Rom paths and bank out of their turn rate; a turret on a rolling hull tracks its target through it.
AVA3D_SCENE=Contact dotnet run --project samples/Ava3D.Demo.Desktop