Reference 001·whaternion ·one sheet, front and back

Conventions & conversions

Unity ⇄ glTF / three.js. Every claim here is traced to a source, not folklore — sources at the foot. Rows marked ° are behaviour we rely on but could not pin to a quotable sentence; treat those as reported, not settled.

The two conventions

UnityglTF / three.js
HandednessLeft-handedRight-handed
Up+Y+Y
Forward axis+Z (Unity: “the positive z-axis points forward”)+Z (spec: front side faces +Z)
Asset's right is+X−X (spec: left side faces +X)
Linear unitsmetres °metres
Anglesdegrees in the public API (e.g. eulerAngles) °radians
Quaternion orderx, y, z, w °x, y, z, w (W scalar)
Local matrixT * R * S °T * R * S
World transformparent_world * local °parent_world * local
They agree on up and on forward and differ in handedness — which is exactly why the disagreement is forced onto X. Fix up and forward, and the third axis has no freedom left. (They differ elsewhere too — note the angle units — but nothing else on this table forces an axis.)

The conversion

positions, directions, translations (x, y, z) → (−x, y, z)
rotation quaternions, XYZW (x, y, z, w) → (x, −y, −z, w)
The two rules negate opposite components

Positions negate X. Rotations negate Y and Z. A conversion that negates the same letter in both places is wrong, and it looks fine until something rotates.

Why: conjugating a rotation by the mirror M = diag(−1,1,1) gives M R M, a rotation by the negated angle about the reflected axis. In q = (sin(θ⁄2)·n, cos(θ⁄2)) the two sign changes cancel on the flipped component and survive on the other two. (−x, y, z, −w) is the same rotation, since q and −q always are.

Both rules are their own inverse, so one function serves both directions:

// positions / directions
Vector3 Flip(Vector3 v)    => new Vector3(-v.x, v.y, v.z);
// rotations
Quaternion Flip(Quaternion q) => new Quaternion(q.x, -q.y, -q.z, q.w);

// Scale is unchanged — including a negative one, since
// M * diag(sx,sy,sz) * M == diag(sx,sy,sz) for any signs.
// Convert a whole node:
//   node.position = Flip(node.position)
//   node.rotation = Flip(node.rotation)
//   node.scale    = node.scale
This recipe covers transforms, not mesh data

Negative scale is legal and load-bearing in both systems, so it survives the conversion untouched — but the same (−1, 1, 1) reinterpretation applied to mesh vertex positions reverses triangle winding. glTF § 3.7.4 Instantiation: the determinant of the node's global transform defines the winding order of that primitive — and its implementation note adds, Switching the winding order to clockwise enables mirroring geometry via negative scale transforms. Khronos's UnityGLTF also flips the tangent W sign when converting mesh data ((-1, 1, 1, -1)). If you are converting a whole asset and not just a pose, budget for winding order, normals, and tangents on top of the two rules above.

Two tests worth memorising

Is a basis left- or right-handed?

Curl the fingers of one hand from +X toward +Y. If the right thumb points along +Z it is right-handed; if the left thumb does, left-handed. In numbers: right-handed exactly when (X × Y) · Z > 0, with the three axes written as components in a frame you have already established is right-handed — a drawing, or a convention you have already settled. That qualifier is the whole test. Write any basis in its own coordinates and you get the identity, whose triple product is +1; apply the formula that way and Unity comes out right-handed.

Has a body been reflected?

Take its three semantic directions and compute (forward × up) · right. Within one frame the sign is invariant under every rotation and flips under every reflection, so it answers the question from any camera angle. But it is not frame-independent: in a right-handed frame a correct body gives +1, and in a left-handed frame the same correct body gives −1. So compare against a pose you know is correct expressed in the same convention; if the signs differ, it is reflected, and no rotation will fix it. It is a direction test — it catches mirroring, not wrong limb lengths.

Diagnosing a wrong-looking skeleton

SymptomClassLook at
Right limb appears on the left; text and logos read backwards Reflection Handedness conversion missing or applied to only one of positions / rotations. No rotation can fix this.
Whole body faces backwards but is not mirrored 180° yaw Handedness flip spent on the forward axis (negating Z instead of X).
One bone rotates about the wrong axis; parent looks fine Wrong basis Rest/bind pose or sensor-to-segment offset, not handedness. The bone's local frame is not the one you think.
Error grows down the chain; the root is fine, fingers are wild Multiply order Local vs world confusion, or a correction applied on the wrong side.
Pose degrades over time; calibration stops holding Accumulation Not a convention bug. Normalization and reference-frame drift.
Identify the class before changing any signs. All five have distinct fixes; the first four are convention bugs and the last is not. Note that two of them — reflection and 180° yaw — come out of the handedness conversion, which is why getting that conversion onto the right axis matters twice.

Write these down for your own pipeline

For every boundary a pose crosses — sensor SDK, fusion filter, solver, engine, exporter — record:

A boundary whose conventions are not written down is where the next bug is. This list is the one artefact that makes these bugs cheap instead of expensive.