Giving fireEngine's imported transforms enough vocabulary
Extend fireEngine with robust vector normalisation, quaternion rotation, decomposed TRS transforms, Vulkan camera matrices, and stable scene-node IDs.
Release 0.7 gave fireEngine just enough maths to position its tutorial triangle. Vec3 and Vec4 represented coordinates, while a column-major Mat4 supplied identity, translation, scale, composition, and the CPU/shader layout shared by the first scene graph.
An imported glTF scene asks more of that vocabulary. Texture coordinates need two components. Nodes can rotate as well as translate and scale. Animation must change one transform channel without first recovering it from a matrix. The camera also needs view and projection conventions that agree with Vulkan before any geometry is culled or drawn.
Release 0.8 supplies those contracts through Vec2, robust Vec3 normalisation, quaternions, decomposed TRS transforms, camera matrices, and stable scene-local node identities. The loader, animation system, camera, and renderer all consume this vocabulary; this article concentrates on the maths and scene-identity layer they share.
This is the first detailed post based on release 0.8. The architectural overview describes the complete path from the structured triangle to AnimatedCube. The code and commands below target the completed fireEngine 0.8 release, while keeping the discussion focused on imported transforms.
Code for this article: fireEngine 0.8
Previous release: fireEngine 0.7
Start with Growing fireEngine into an animated glTF renderer for the release plan. This post examines the mathematical and scene-identity vocabulary used by imported transforms throughout version 0.8.
Extend the existing layer instead of replacing it
The release 0.7 maths types already fixed the important representation rules:
- matrices multiply column vectors written on their right;
- transform composition is evaluated from right to left;
- matrices use column-major storage;
parentWorld * localresolves scene hierarchy; and- a
Mat4crosses the Slang boundary without transposition or repacking.
Release 0.8 keeps those choices while extending the public CPU layer, scene resolution, import path, animation playback, and device-free tests around them:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
include/fire_engine/
├── math/
│ ├── mat4.hpp
│ ├── normalize_error.hpp
│ ├── quaternion.hpp
│ ├── transform.hpp
│ ├── vec2.hpp
│ └── vec3.hpp
└── scene/
├── scene.hpp
├── scene_node.hpp
└── scene_node_id.hpp
src/
├── main.cpp
└── scene/
├── scene.cpp
└── scene_node.cpp
tests/
├── graphics/
│ └── test_render_preparation.cpp
├── math/
│ └── test_mat4.cpp
└── scene/
└── test_scene.cpp
The new types remain Vulkan-free. They can be populated by an importer, changed by animation, resolved by the scene, and tested without creating a window or a device.
Add the two-component value the next asset step needs
glTF texture coordinates arrive as pairs. Vec3 would store a spare component and blur the distinction between a position and a UV value, so the maths layer adds the smallest matching aggregate:
1
2
3
4
5
6
7
struct Vec2
{
float x = 0.0f;
float y = 0.0f;
[[nodiscard]] constexpr bool operator==(const Vec2&) const noexcept = default;
};
Vec2{} is predictably zero, designated initialisers keep both components visible, and exact equality has the same limited role as it does for Vec3 and Vec4: values copied from the same source can be compared directly, while results of floating-point arithmetic need a tolerance.
In release 0.8, Vertex::textureCoordinate uses Vec2 directly. Keeping the pair as a small maths aggregate lets mesh descriptions and the glTF loader share it without exposing a parser or graphics-API type.
See vec2.hpp.
Give three-component vectors geometric operations
The vector operations come from vec3.hpp.
The original Vec3 only stored components. Building a camera basis requires subtraction, dot and cross products, a squared length, and normalisation, so the type gains that focused set rather than becoming a general vector library.
The right-handed cross product establishes the orientation used by the later view matrix:
1
2
3
4
5
6
7
8
[[nodiscard]] constexpr Vec3 cross(Vec3 right) const noexcept
{
return {
.x = y * right.z - z * right.y,
.y = z * right.x - x * right.z,
.z = x * right.y - y * right.x,
};
}
lengthSquared() retains the direct dot product for comparisons that do not need a square root:
1
2
3
4
[[nodiscard]] constexpr float lengthSquared() const noexcept
{
return dot(*this);
}
That fast operation is not used as the route to normalisation. Squaring very large finite components can overflow to infinity, while squaring very small ones can underflow to zero. A vector that was finite and non-zero would then be misclassified before division even began.
Report normalisation failure as a value
Normalisation has two recoverable failure categories in release 0.8:
1
2
3
4
5
enum class NormalizeError : std::uint8_t
{
eZeroLength,
eNonFinite,
};
A zero vector has no direction. A non-finite magnitude cannot produce a useful unit vector. Vec3::normalized() returns either the unit value or one of those reasons through C++23 std::expected:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[[nodiscard]] std::expected<Vec3, NormalizeError> normalized() const noexcept
{
// hypot is slower than sqrt(lengthSquared()), but avoids its avoidable overflow and
// underflow. Normalization favors accuracy; lengthSquared() remains the faster comparison.
const float magnitude = std::hypot(x, y, z);
if (magnitude == 0.0f)
{
return std::unexpected{NormalizeError::eZeroLength};
}
if (!std::isfinite(magnitude))
{
return std::unexpected{NormalizeError::eNonFinite};
}
return Vec3{.x = x / magnitude, .y = y / magnitude, .z = z / magnitude};
}
std::hypot scales its calculation to avoid the avoidable intermediate overflow and underflow of sqrt(x * x + y * y + z * z). The operation is a little more expensive than the direct sum, but normalisation favours a stable answer while lengthSquared() remains available for ordinary comparisons.
std::expected makes failure part of the function’s type without requiring dynamic allocation, logging, throwing, or an invented fallback direction. Callers decide whether a degenerate runtime value should be rejected, repaired, or propagated.
See normalize_error.hpp and vec3.hpp.
Store rotations in glTF component order
The quaternion layout and robust normalization come from quaternion.hpp.
glTF stores quaternion rotations as (x, y, z, w): three imaginary components followed by the real component. fireEngine adopts the same public order so a loader will not need to shuffle fields at the boundary:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Quaternion
{
float x = 0.0f;
float y = 0.0f;
float z = 0.0f;
float w = 1.0f;
[[nodiscard]] static constexpr Quaternion identity() noexcept
{
return {};
}
// Normalisation, interpolation, dot product, negation, and equality follow.
};
The default value is the identity rotation rather than an all-zero quaternion. That makes a default Transform useful immediately and avoids requiring every scene-node constructor to repeat the neutral rotation.
Quaternion normalisation uses the same NormalizeError domain as Vec3. Four components require pairwise std::hypot calls:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const float magnitude = std::hypot(std::hypot(x, y), std::hypot(z, w));
if (magnitude == 0.0f)
{
return std::unexpected{NormalizeError::eZeroLength};
}
if (!std::isfinite(magnitude))
{
return std::unexpected{NormalizeError::eNonFinite};
}
return Quaternion{
.x = x / magnitude,
.y = y / magnitude,
.z = z / magnitude,
.w = w / magnitude,
};
The public value can therefore represent imported components exactly, while the operation that needs a unit quaternion reports when those components do not define a usable rotation.
Interpolate equivalent rotations along the short arc
A quaternion and its negation encode the same spatial rotation. Treating their four stored components as unrelated endpoints can nevertheless interpolate the long way around, or pass through the zero quaternion when the endpoints are opposites.
normalizedLerp() checks the dot product first. A negative result means the two values lie on opposite quaternion hemispheres, so negating the right value selects the equivalent endpoint on the shorter arc:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[[nodiscard]] std::expected<Quaternion, NormalizeError>
normalizedLerp(Quaternion right, float amount) const noexcept
{
if (dot(right) < 0.0f)
{
right = -right;
}
return Quaternion{
.x = x + (right.x - x) * amount,
.y = y + (right.y - y) * amount,
.z = z + (right.z - z) * amount,
.w = w + (right.w - w) * amount,
}
.normalized();
}
Linear interpolation does not preserve unit length, so the result is normalised before it is returned. The amount is normally between zero and one, but this function does not clamp it; choosing the playback interval and interpolation amount belongs to the animation playback layer.
This is normalised linear interpolation rather than spherical interpolation. It is sufficient for the selected 0.8 animation and gives playback one small, tested rotation operation instead of embedding quaternion policy in an animation class.
See quaternion.hpp.
Preserve translation, rotation, and scale as source values
Release 0.7 stored a node’s local transform only as a Mat4. That was enough when the application composed one translation and scale once, but it is a poor source representation for animation. Updating one imported rotation channel would require retaining the original TRS values elsewhere or decomposing the matrix back into them every frame.
Transform keeps the three channels explicit:
1
2
3
4
5
6
7
8
9
10
11
12
13
struct Transform
{
Vec3 translation{};
Quaternion rotation = Quaternion::identity();
Vec3 scale{.x = 1.0f, .y = 1.0f, .z = 1.0f};
[[nodiscard]] constexpr Mat4 matrix() const noexcept
{
return Mat4::translation(translation) * Mat4::rotation(rotation) * Mat4::scale(scale);
}
[[nodiscard]] constexpr bool operator==(const Transform&) const noexcept = default;
};
Its default is the identity transform: zero translation, identity rotation, and unit scale. matrix() composes glTF’s TRS order. Because matrices multiply column vectors on their right, a point is scaled first, then rotated, then translated.
Mat4::rotation() converts the unit quaternion into the existing column-major matrix representation. It deliberately accepts the public value directly and documents that the quaternion is normalised; it does not hide another normalisation or error path inside a constexpr, noexcept matrix factory.
See transform.hpp and the rotation factory in mat4.hpp.
Keep source transforms separate from resolved matrices
SceneNode now owns both representations, but for different reasons:
1
2
3
4
5
6
std::string name_;
std::optional<SceneNodeId> id_;
Transform localTransform_;
Mat4 worldTransform_ = Mat4::identity();
std::optional<RenderObjectId> renderObject_;
std::vector<std::unique_ptr<SceneNode>> children_;
The local Transform is editable source state. The world Mat4 is derived state cached for drawing. Resolution converts TRS only when it walks the scene:
1
2
3
4
5
6
7
8
void SceneNode::resolve(const Mat4& parentWorld) noexcept
{
worldTransform_ = parentWorld * localTransform_.matrix();
for (const std::unique_ptr<SceneNode>& child : children_)
{
child->resolve(worldTransform_);
}
}
This preserves the 0.7 hierarchy rule. Animation playback can replace a node’s rotation without changing its translation or scale, then the ordinary world update rebuilds the matrices consumed by draw traversal. No renderer or render asset needs to know which channel changed.
See scene_node.hpp and scene_node.cpp.
Fix camera conventions at the maths boundary
The release 0.8 camera uses matrix operations defined in the maths layer, where their coordinate rules can be tested without a renderer or device.
Mat4::lookAt() builds a right-handed view matrix. It normalises the direction from eye to target, derives a right vector from the supplied up direction, and reconstructs an orthogonal camera-up vector:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const std::expected<Vec3, NormalizeError> forwardResult = (target - eye).normalized();
if (!forwardResult)
{
return std::unexpected{forwardResult.error()};
}
const Vec3 forward = *forwardResult;
const std::expected<Vec3, NormalizeError> rightResult = forward.cross(up).normalized();
if (!rightResult)
{
return std::unexpected{rightResult.error()};
}
const Vec3 right = *rightResult;
const Vec3 cameraUp = right.cross(forward);
Equal eye and target positions have no forward direction. An up vector parallel to the viewing direction cannot produce a right vector. Those are plausible runtime camera states, so lookAt() returns std::expected<Mat4, NormalizeError> rather than fabricating a basis.
Perspective configuration has a different failure boundary. A non-finite field of view, aspect ratio, or clipping distance, a field of view outside zero to pi, a non-positive aspect ratio or near plane, or a far plane no farther than the near plane is rejected with std::invalid_argument. These are setup errors rather than momentary geometric degeneracies.
The resulting projection fixes two Vulkan-facing conventions explicitly:
1
2
3
4
5
6
7
8
9
10
const float focalLength = 1.0f / std::tan(verticalFieldOfView * 0.5f);
Mat4 result;
result[0, 0] = focalLength / aspectRatio;
// A positive-height Vulkan viewport maps positive NDC Y downward. Flip here so the
// projection retains the conventional view-space direction where positive Y is up.
result[1, 1] = -focalLength;
result[2, 2] = farPlane / (nearPlane - farPlane);
result[2, 3] = farPlane * nearPlane / (nearPlane - farPlane);
result[3, 2] = -1.0f;
return result;
Right-handed view space looks along negative Z. After perspective division, the near plane maps to depth zero and the far plane to one. With a positive-height Vulkan viewport, positive normalized-device Y maps towards increasing framebuffer Y, which is visually downward. Negating Y in the projection compensates for that mapping.
In release 0.8, device-free maths tests pin the camera contract: a right-handed view looking down negative Z, a zero-to-one depth range, and the then-chosen projection Y inversion. The renderer decides when to build and upload the resulting camera matrix.
Editor’s note — release 0.9: I later moved this Y inversion out of
Mat4::perspective()and into Vulkan’s viewport transform. The projection still maps depth from zero to one—the clip-space convention used by Vulkan and D3D, rather than OpenGL’s conventional negative-one-to-one range. The framebuffer Y inversion is a rasterisation-stage convention, however, so release 0.9 expresses it with a negative-height viewport and leaves the projection matrix Y-up.
See the camera factories in mat4.hpp.
Give scenes stable node identities
Scene tools and systems need a stable way to find a node after loading. Names are diagnostic text and need not be unique. Traversal positions can shift as hierarchies grow. Memory addresses are awkward public keys and would expose ownership details to callers.
SceneNodeId follows the typed-ID pattern already used by render assets:
1
2
3
4
5
6
7
8
9
10
11
struct SceneNodeId
{
std::size_t value = std::numeric_limits<std::size_t>::max();
[[nodiscard]] constexpr bool valid() const noexcept
{
return value != std::numeric_limits<std::size_t>::max();
}
[[nodiscard]] constexpr bool operator==(const SceneNodeId&) const noexcept = default;
};
The value is a dense index owned by one Scene. The scene keeps the lookup table that gives it meaning:
1
2
std::vector<std::unique_ptr<SceneNode>> roots_;
std::vector<SceneNode*> nodes_;
Registered nodes must remain at stable addresses, so SceneNode remains non-copyable and becomes non-movable. Moving a unique_ptr while its owning vector grows does not move the node it points to, and the append-only hierarchy offers no node-removal operation that could leave a dangling lookup entry.
findNode() returns an optional reference rather than transferring ownership:
1
2
3
4
5
6
7
8
std::optional<SceneNodeRef> Scene::findNode(SceneNodeId id) noexcept
{
if (!id.valid() || id.value >= nodes_.size())
{
return std::nullopt;
}
return std::ref(*nodes_[id.value]);
}
C++23 has no std::optional<T&>, so SceneNodeRef and SceneNodeConstRef isolate the std::reference_wrapper substitute. Mutable and const overloads retain the scene’s ownership while letting callers update a found node or inspect it read-only.
See scene_node_id.hpp and scene.hpp.
Register complete subtrees without changing traversal identity
Adding a root registers its detached subtree immediately. The registration walk is pre-order, appending each previously unregistered node it encounters to the dense lookup:
1
2
3
4
5
6
7
8
9
10
11
12
13
void Scene::registerSubtree(SceneNode& node)
{
if (!node.id().has_value())
{
node.assignId(SceneNodeId{.value = nodes_.size()});
nodes_.push_back(&node);
}
for (const std::unique_ptr<SceneNode>& child : node.children())
{
registerSubtree(*child);
}
}
Each world update repeats the registration walk. Nodes that already have IDs are skipped, while newly added children are appended to the lookup, so existing IDs remain unchanged. Invalid-sentinel and out-of-range IDs return no node.
There is one deliberate looseness in release 0.8. A child added after its root has entered the scene does not receive an ID at mutation time. The next updateWorldTransforms() discovers and registers it before resolving the hierarchy:
1
2
3
4
5
6
7
8
void Scene::updateWorldTransforms()
{
for (const std::unique_ptr<SceneNode>& root : roots_)
{
registerSubtree(*root);
root->resolve(Mat4::identity());
}
}
That registration can grow nodes_, so the function is no longer noexcept. Code that needs a newly added child’s ID must run the world update first. A stricter mutation-time registration invariant remains later work.
SceneNodeId is also scene-local by contract rather than by encoded provenance. An in-range ID copied from another scene can name the same dense slot in this one. A SceneNodeId only has meaning when passed back to the same Scene that assigned it. It must not be reused with another scene, and should be discarded when that scene is replaced.
See the complete scene.cpp.
Carry TRS from loading into animation
The final 0.8 application loads AnimatedCube rather than constructing a triangle. GltfLoader converts each selected glTF node into a decomposed Transform, preserving translation, normalised rotation, and scale as independent source values.
Animation playback can then replace only the rotation:
1
2
3
Transform transform = node.localTransform();
transform.rotation = sampleRotation(channel, animator.playbackTime);
node.localTransform(transform);
The frame loop calls updateWorldTransforms() afterwards, resolving the current local TRS values into the matrices used for drawing. Neither animation nor ordinary movement changes the asset revision or render-object dependency list, so prepared GPU resources remain reusable while the cube rotates.
See gltf_loader.cpp and animation_playback.cpp.
Test the new contracts without a device
The release’s device-free tests cover quaternion operations, numerical stability, TRS composition, camera conventions, scene identities, and the Vec2 aggregate contract.
The magnitude test uses values that expose the difference between robust normalisation and a direct sum of squares:
1
2
3
4
5
6
7
8
9
10
constexpr float kTiny = 1.0e-30f;
constexpr float kLarge = 1.0e20f;
const auto tinyVector = Vec3{.x = kTiny, .y = 0.0f, .z = 0.0f}.normalized();
REQUIRE(tinyVector.has_value());
REQUIRE(*tinyVector == Vec3{.x = 1.0f, .y = 0.0f, .z = 0.0f});
const auto largeVector = Vec3{.x = kLarge, .y = kLarge, .z = kLarge}.normalized();
REQUIRE(largeVector.has_value());
REQUIRE(largeVector->lengthSquared() == Approx(1.0f));
The quaternion case checks identity normalisation, a halfway rotation, the shortest path to an equivalent negated identity, zero length, and non-finite input. The TRS case proves that scale, rotation, and translation apply in that order.
The camera case fixes the conventions numerically: near depth is zero, far depth is one, projection flips Y, and an origin viewed from positive Z lands at negative view-space Z. It also distinguishes invalid perspective configuration from a recoverable degenerate view basis.
The scene case proves immediate root registration, delayed child registration, stable IDs across repeated updates, invalid and out-of-range rejection, mutable lookup, and const lookup. Existing scene and preparation cases migrate to Transform as well, preserving their hierarchy and cache assertions.
See the complete test_mat4.cpp, test_scene.cpp, and test_render_preparation.cpp.
Run the transform and identity tests
Configure and build release 0.8 through its vcpkg preset. CTest can select six focused transform and identity cases by their anchored prefixes:
1
ctest --preset default -R "^(Vector aggregates|Quaternion normalization|Normalization remains|Transform composes|Mat4 camera|Scene assigns)"
The filter selects those six cases in release 0.8. The remaining maths, scene, asset, loading, animation, rendering, and Vulkan scenarios remain outside the focused run.
Diagnose the new failure boundaries
The new vocabulary makes numerical and identity failures explicit, but each still needs to be interpreted at the layer that owns it.
A finite non-zero value fails normalisation
Check that the implementation uses std::hypot, not sqrt(lengthSquared()). The direct sum can underflow or overflow before the square root even when the original components are finite and normalisable.
Normalisation returns eZeroLength or eNonFinite
Do not substitute an arbitrary direction silently. Zero length means the input has no direction; non-finite means at least one component produced an unusable magnitude. Let the importer, camera, or animation caller decide how that source should fail.
Quaternion interpolation rotates the long way around
Check the sign of the endpoint dot product. When it is negative, negate the right quaternion before interpolation; the negated value represents the same rotation on the nearer hemisphere. Normalise the interpolated result as well.
A quaternion matrix stretches or shears geometry
Mat4::rotation() expects a unit quaternion and does not normalise internally. Normalise imported or calculated input before asking for its matrix, and handle the possible NormalizeError at that boundary.
Translation or rotation happens in the wrong order
The local convention is translation * rotation * scale. With column vectors written on the right, scale applies first, then rotation, then translation. Changing the multiplication order changes the transform rather than its style.
The camera is upside down or depth leaves the zero-to-one range
Keep the negative projection Y scale used with fireEngine’s positive-height Vulkan viewport, and retain the Vulkan depth coefficients. OpenGL-style negative-one-to-one depth or a second Y flip will violate the tested contract.
lookAt() cannot build a basis
Check whether eye equals target or the supplied up vector is parallel to the view direction. Either makes one required direction zero-length. Choose a different target or up vector rather than hiding the degeneracy inside the matrix.
A newly added child has no SceneNodeId
In release 0.8, adding a child beneath an already registered node does not register it immediately. Call updateWorldTransforms() before requesting its ID. Roots and complete detached subtrees passed to addRoot() are registered at once.
A valid-looking ID finds the wrong scene’s node
SceneNodeId is a dense scene-local index, not a globally unique handle. Keep it associated with the Scene that assigned it. The value alone cannot detect that it came from another scene when both scenes contain the same slot.
What this part of release 0.8 gives us
The 0.8 overview established the path to one imported, textured, animated scene. This part of the release supplies the values and identities shared by that path:
Vec2represents the two-component coordinates needed by textured meshes;Vec3gains subtraction, dot, right-handed cross, squared-length, and normalisation operations without becoming a general-purpose maths package;NormalizeErrordistinguishes zero-length from non-finite input;- C++23
std::expectedkeeps recoverable numerical failure in the return type; std::hypotkeeps finite extreme magnitudes normalisable;Quaternionuses glTF’s(x, y, z, w)component order and defaults to identity;- shortest-arc normalised linear interpolation becomes one tested maths operation rather than animation-owned policy;
Transformretains translation, rotation, and scale as independently editable source values;- TRS composition preserves fireEngine’s column-vector and column-major matrix conventions;
- scene traversal resolves decomposed local values into cached world matrices;
- right-handed look-at reports degenerate runtime bases explicitly;
- in release 0.8, perspective validates setup and encodes both zero-to-one depth and the framebuffer Y compensation;
SceneNodeIdgives callers a typed, stable, scene-local lookup key;- dense lookup returns optional mutable or const references without transferring ownership;
- nodes become immovable so registered pointer identity remains stable;
- world updates discover descendants added after initial scene registration;
- the glTF loader preserves imported TRS values in this shared vocabulary; and
- animation playback changes rotation without rebuilding stable resources.
The rest of release 0.8 builds on these contracts. The descriptions post concentrates on Vulkan-free texture and animation descriptions, while the loader, camera, playback, and renderer remain consumers of the same maths and scene model.
Recommended reading
- Foundations of Game Engine Development, Volume 1: Mathematics — Eric Lengyel’s focused treatment of vectors, quaternions, matrices, and transform composition in a game-engine context.
- Real-Time Rendering — the broader rendering reference for transform hierarchies, viewing, projection, interpolation, and the conventions that connect CPU maths to the graphics pipeline.
- glTF 2.0 specification: Transformations — the Khronos definition of node TRS properties, matrix composition, hierarchy, and the transform rules an importer must preserve.
- C++
std::expected— cppreference’s description of the C++23 vocabulary type used to return either a normalised value or a small recoverable error.
The Reading page keeps the site-wide list in one place.