Computer Graphics · Graphics Tooling
Axis Conventions, Handedness, and Unit Scale on Import
Which geometry formats declare their coordinate convention, which leave it implicit, and why converting between them is a reflection rather than a rotation.
Positions in a mesh file are triples of numbers with no attached meaning. Nothing in an OBJ file says which of the three axes points up, whether the coordinate frame is right- or left-handed, or whether a unit is a metre or a centimetre. The authoring package knew; the file mostly does not record it. Every consumer therefore reconstructs the convention from somewhere: from the specification, from metadata the container happens to carry, or from an assumption baked into the importer. When the assumption is wrong the model arrives rotated, mirrored, or scaled by a hundred, and the resulting bug report describes a rendering problem whose cause is a metadata gap.
What each container actually promises
The formats differ enormously in how much they commit to.
glTF is the strictest. Its specification fixes a right-handed coordinate system, $+Y$ up, $+Z$ toward the viewer, with the front face of a triangle determined by counter-clockwise winding, and units of metres with no scale factor anywhere in the JSON. There is no units field and no up-axis field, because the format defines them away. An exporter must convert into that frame, and a loader may assume it. Two glTF files always agree with each other, which is why glTF works well as an interchange target between tools that disagree.
COLLADA carries the convention explicitly. A <unit> element declares how many metres one unit represents, and an <up_axis> element declares X_UP, Y_UP, or Z_UP, with Y_UP as the default when the element is absent. Note what is not declared: handedness is fixed by the specification rather than stored per file, so a COLLADA document cannot express a left-handed frame at all. The information is enough to place a model, not enough to describe an arbitrary authoring frame.
USD puts convention on the stage as metadata. A stage states an up axis and a metres-per-unit value, and inheriting or referencing a layer with a different setting is a composition-time question rather than a per-file one. A value of $0.01$ means the stage’s units are centimetres, a common authoring choice. Because the metadata lives on the stage rather than in every prim, a layer that omits it takes whatever the surrounding composition provides.
FBX records both an axis system and a unit scale in its global settings section, as a set of enumerations and a scale factor. This is more information than OBJ or STL carry, and less than a loader would like: the values describe the authoring application’s configuration, and a file can be written in any of them.
OBJ and STL carry nothing. OBJ holds vertex positions, texture coordinates, normals, and face indices as text, with no header field for units or orientation. STL holds triangles with normals and no index buffer at all, again with no convention. For these formats the convention is entirely a pipeline policy: the importer needs a project-wide setting that says what to assume, and that setting is a lie in general. A file from a Z-up CAD package and a file from a Y-up modelling package are indistinguishable on disk.
The design lesson is that “which way is up” should be metadata, and the historical formats that omitted it force every consumer to guess. Sharing an assumption across a whole asset set makes the assumption invisible until a new source appears.
Why converting a handedness is a reflection, not a rotation
Converting between two right-handed frames that differ in axis assignment is a rotation. Swapping the roles of $Y$ and $Z$ while preserving the handedness is a 90-degree rotation about $X$, a proper transform with determinant $+1$, and it maps the model onto itself without changing any distances, angles, or triangle windings.
Converting a right-handed frame to a left-handed one is different in kind. There is no rotation that does it, because rotations preserve handedness. The conversion must negate one axis, for example mapping $(x, y, z) \to (x, y, -z)$, and that matrix has determinant $-1$. Three consequences follow immediately and all three are visible in the output:
- Triangle winding reverses. A counter-clockwise triangle becomes clockwise when viewed from the same side, so either the front-face convention must flip on the consumer or the index order of every triangle must be inverted. If neither is done, back-face culling removes the entire model.
- Normals that were computed from the geometry now point inward. Flipping the vertex data does not flip the normals stored alongside it, because those are separate arrays. A model with computed normals and a model with authored normals fail differently, and one can look correct while the other is lit from behind.
- Interpolation of derived quantities changes sign where a cross product is involved. Tangents built from positions and UVs inherit the flip, so a normal map authored for the original frame produces inverted shading gradients.
A pipeline that treats handedness conversion as “one more axis rotation” therefore produces a model that is geometrically in the right place and shaded wrongly. The reliable pattern is to keep the axis remap and the handedness reflection as separate steps, apply the reflection to positions and to normals and to tangents, and flip winding at the same time — or, more simply, represent a left-handed source by applying the reflection to a root transform node and leaving the arrays alone. The root-transform approach gets winding and normals right for free because a determinant of $-1$ is automatically accounted for by the transform, at the cost of requiring every downstream operation to compose through the root.
Unit scale and the cost of assuming one
Scale errors are far more common than orientation errors, and harder to notice on inspection. A model authored in centimetres and imported as metres is 100 times too large; in a viewer with a fixed camera it appears to be missing, and in a scene with several assets it appears as one object that dwarfs the rest.
The deeper problem is numeric. Positions are usually stored as 32-bit floats, which have a 24-bit significand. At a magnitude near $1000$, one representable step is $2^{-14}$, roughly $6 \times 10^{-5}$ of the unit. That is fine at metre scale, where the step is about a twentieth of a millimetre, but the same object expressed in millimetres has coordinate values near $10^6$ and a step of about $6 \times 10^{-2}$ millimetres. Skinning blends are computed from differences of positions, and a bind-pose offset that is only a few representable steps from zero loses most of its significant bits. Nothing in the format prevents this; the numbers are simply reinterpreted at a scale the authoring tool did not choose them for.
The practical rule that most pipelines adopt: perform all conditioning in one unit that the authoring tools agree on, convert to the delivery format’s declared unit as the last step, and keep the intermediate representations in a wider type if the pipeline does any inverse-kinematics or deformation solving. When a format does declare a scale, as COLLADA and USD do, the conversion arithmetic should use the declared factor rather than a hard-coded 0.01 or 100, because the declared factor is the only piece of evidence about the author’s intent.
Fixing the convention in one place
A convention mismatch can be repaired at three different points, and only one of them scales.
At the exporter, where the author’s intent is known, the conversion is exact and can be recorded as a root transform, which costs one matrix and no per-vertex work. This is the cheapest place and the one that can also carry an explicit declaration forward: glTF has no place to put a unit field, but a root node can hold a scale.
At the importer, conventions are inferred from the container’s metadata where available and from policy otherwise. This is where OBJ and STL must be handled, and the cost is that the policy is global while the files are not. Per-file overrides in the import settings exist precisely because the policy is known to be wrong for some fraction of the inputs.
At the runtime, in the scene graph, the conversion becomes a per-frame transform. A node with a scale of $0.01$ is applied to the model matrix on every draw, which is one extra matrix multiply per node and a permanent risk that some other system — physics, collision, occlusion culling — forgot to apply it. This placement is common because it is the easiest to add late, and it is the one that produces bounds computed in the wrong space.
The symptom that identifies where a mismatch lives is scale-invariance. A rotation error shows up identically at all scales and is usually attributable to a per-file convention decision; a pure scale error that grows with distance, or that produces seams when two assets touch, indicates a unit conversion applied at the wrong layer, or applied twice.
Reading the convention from a file that does not state it
The conventions above interact with the rest of the pipeline at the point where numbers leave the file. Texture coordinates follow their own origin rule, and whether the importer flips $v$ as part of the same conversion decides which way a normal map’s green channel should point, an interaction examined in channel packing conventions. UV islands get packed into an atlas whose padding is computed in texel units, so a scale change that alters the texel density changes the correct gutter width as well, the problem treated in UV chart packing and gutter space. And when a pipeline stores positions as quantized integers, the quantization grid is a range derived from the model’s bounding box, so changing the unit changes the grid and therefore the quantization error, which is the subject of vertex attribute quantization.
References
- glTF 2.0 Specification, Coordinate System and Units — Khronos Group
- COLLADA Specification 1.5.0 — Khronos Group
- Universal Scene Description: UsdGeom Stage Metadata — Academy Software Foundation