Chapter 2.1 built matrices that move, rotate, and scale a single point. A real game is never just one point — it is thousands of objects, and most of them are built out of smaller pieces that need to move together. A character's hand carries a sword. A tank's turret carries a barrel. A car carries four wheels. This chapter is about the data structure that makes "move together" work: the scene graph, a tree of transforms where every object's final position depends on everything above it.
Everything here reuses the exact Vec2, Mat3, mul, and transformPoint from chapter 2.1 — if any of those names look unfamiliar, that chapter is where they come from.
Picture a character holding a sword. The sword should move when the hand moves, the hand should move when the arm swings, and the arm should move when the whole character walks. You could store one absolute position for the sword and update it by hand every time any part of the character moves — but that means rewriting the sword's position, rotation, and scale every single frame, by hand, for every attached object, forever. Instead, every game engine stores objects in a tree (a hierarchy where each item has exactly one parent and any number of children), and lets the position ride along automatically.
This is not a special trick just for characters holding weapons. The same tree shows up everywhere in a game:
Each box in that tree is usually called a node (a general word for "one entry in the tree" — in Unity a node is a Transform, in Unreal it is a SceneComponent). Every node stores its own position, rotation, and scale relative to its parent, not relative to the world. That single design choice — describe yourself relative to your parent, not relative to the world — is what makes moving a whole group of objects as cheap as moving one number. The next section makes that "relative to parent" idea precise.
Every node in the tree has two different transforms that describe the same object:
An artist or a designer almost always sets the local transform — they place the sword's grip 1 unit forward from the hand's own origin, once, and never touch it again even while the character runs, jumps, and swings the sword all over the level. The world transform is what actually matters for drawing the sword on screen, for physics, and for "is this sword touching that enemy" checks — and it changes every single frame the character moves, even though nobody edited the sword's local transform at all.
Both transforms are stored as the same kind of data: a position, a rotation, and a scale (or, packed together, a single Mat3/Mat4). The only difference is what they are measured against. Section 3 gives the exact rule for turning a chain of local transforms into one world transform.
Here is the entire rule, and it is exactly matrix multiplication from chapter 2.1, section 8: childWorld = mul(parentWorld, childLocal). The parent's world matrix already carries "everything above me in the tree" baked in; multiplying the child's local matrix on the right adds one more step on top of that chain.
Let us trace the whole sword/hand/arm/body chain from section 1 with real numbers, reusing the Vec2, Mat3, mul, transformPoint, translateMatrix, and rotationMatrix from chapter 2.1:
#include <iostream>
#include <cmath>
struct Vec2 { float x, y; };
struct Mat3 { float m[3][3]; };
Vec2 transformPoint(const Mat3& M, Vec2 p) {
float x = M.m[0][0] * p.x + M.m[0][1] * p.y + M.m[0][2];
float y = M.m[1][0] * p.x + M.m[1][1] * p.y + M.m[1][2];
return { x, y };
}
Mat3 mul(const Mat3& A, const Mat3& B) { // A * B: apply B first, then A
Mat3 R{};
for (int row = 0; row < 3; row++)
for (int col = 0; col < 3; col++) {
float sum = 0;
for (int k = 0; k < 3; k++)
sum += A.m[row][k] * B.m[k][col];
R.m[row][col] = sum;
}
return R;
}
Mat3 translateMatrix(float tx, float ty) {
return { { {1, 0, tx}, {0, 1, ty}, {0, 0, 1} } };
}
Mat3 rotationMatrix(float degrees) {
float radians = degrees * 3.14159265f / 180.0f;
float c = std::cos(radians);
float s = std::sin(radians);
return { { {c, -s, 0}, {s, c, 0}, {0, 0, 1} } };
}
int main() {
Mat3 rootWorld = { { {1,0,0}, {0,1,0}, {0,0,1} } }; // identity: root has no parent
Mat3 bodyLocal = translateMatrix(5, 0); // body stands at (5, 0)
Mat3 bodyWorld = mul(rootWorld, bodyLocal);
Mat3 armLocal = mul(translateMatrix(0, 2), rotationMatrix(90)); // shoulder is 2 up, arm raised 90 degrees
Mat3 armWorld = mul(bodyWorld, armLocal);
Mat3 handLocal = translateMatrix(0, 3); // forearm is 3 long, no extra rotation
Mat3 handWorld = mul(armWorld, handLocal);
Mat3 swordLocal = translateMatrix(1, 0); // grip is 1 forward of the hand's origin
Mat3 swordWorld = mul(handWorld, swordLocal);
Vec2 origin = {0, 0};
Vec2 bodyPos = transformPoint(bodyWorld, origin);
Vec2 armPos = transformPoint(armWorld, origin);
Vec2 handPos = transformPoint(handWorld, origin);
Vec2 swordPos = transformPoint(swordWorld, origin);
std::cout << "body world = (" << bodyPos.x << ", " << bodyPos.y << ")\n";
std::cout << "arm world = (" << armPos.x << ", " << armPos.y << ")\n";
std::cout << "hand world = (" << handPos.x << ", " << handPos.y << ")\n";
std::cout << "sword world = (" << swordPos.x << ", " << swordPos.y << ")\n";
}
Output:
body world = (5, 0)
arm world = (5, 2)
hand world = (2, 2)
sword world = (2, 3)
(As in chapter 2.1's own 90-degree example, the real numbers carry a tiny bit of floating-point noise around 1e-7 that is too small to show at this print precision — treat the printed values as exact.)
Trace it by hand to see why raising the arm swung everything: the body sits at world (5, 0) with no rotation. The arm's local transform puts its shoulder joint 2 units above the body's origin, then rotates 90 degrees — so the arm's world position is still directly above the body, at (5, 2), because rotating around your own origin never moves that origin. But the hand is 3 units further along the arm's local +Y axis, and the arm's 90-degree rotation has turned that "+Y axis" to point in world -X — so instead of landing at (5, 5) (straight up, as it would with no rotation), the hand lands at (2, 2), three units to the left of the shoulder. The sword inherits that same rotation one more level down, landing at (2, 3). Nobody told the hand or the sword to move sideways — they inherited the arm's rotation automatically, exactly because they are parented underneath it.
mul(childLocal, parentWorld) instead of mul(parentWorld, childLocal). Matrix multiplication is not commutative (chapter 2.1, section 8) — swapping the order does not throw an error, it just silently produces the wrong world matrix, usually with the child transformed as if it were the parent. If a child's world position looks like it "should be rotated but isn't", check the multiply order first.Section 3 computed four world matrices by writing out mul(...) four times, by hand, in a fixed order. A real scene has thousands of nodes and an unpredictable depth, so instead of writing out each level by hand, build an actual tree data structure and walk it (visit every node, in an order that always processes a parent before its children). A node needs a pointer to its parent, a list of its children, its own local matrix, and a place to store the computed world matrix:
#include <iostream>
#include <vector>
// Vec2, Mat3, mul(), transformPoint(), translateMatrix(), rotationMatrix()
// are the exact same functions from section 3 (and chapter 2.1) -- omitted
// here to keep the new code visible.
struct Node {
Mat3 local;
Mat3 world;
Node* parent = nullptr;
std::vector<Node*> children;
};
void addChild(Node& parent, Node& child) {
child.parent = &parent;
parent.children.push_back(&child);
}
void updateWorldTransform(Node& node) {
node.world = node.parent ? mul(node.parent->world, node.local) : node.local;
for (Node* child : node.children)
updateWorldTransform(*child);
}
The recursion in updateWorldTransform is exactly what makes "parent before children" automatic: a node computes its own world matrix first, using its parent's already-finished world field, and only then recurses into its children — so by the time any child runs this same function, its parent's world is guaranteed correct. Now build the same sword/hand/arm/body chain as an actual tree and let the walk compute everything:
int main() {
Node root, body, arm, hand, sword;
root.local = { { {1,0,0}, {0,1,0}, {0,0,1} } }; // identity
body.local = translateMatrix(5, 0);
arm.local = mul(translateMatrix(0, 2), rotationMatrix(90));
hand.local = translateMatrix(0, 3);
sword.local = translateMatrix(1, 0);
addChild(root, body);
addChild(body, arm);
addChild(arm, hand);
addChild(hand, sword);
updateWorldTransform(root); // one call updates the ENTIRE tree
Vec2 origin = {0, 0};
for (auto* n : { &body, &arm, &hand, &sword }) {
Vec2 p = transformPoint(n->world, origin);
std::cout << "(" << p.x << ", " << p.y << ")\n";
}
}
Output:
(5, 0)
(5, 2)
(2, 2)
(2, 3)
Same four numbers as section 3, computed by a single updateWorldTransform(root) call instead of four hand-written mul lines. This is the shape every real engine's transform update uses: one recursive (or, as section 8 shows, iterative) walk, starting at the root, always finishing a parent before touching its children.
This works, and it is correct — but look closely at what it costs. Every single call to updateWorldTransform(root) revisits every node in the entire tree, even the ones that have not moved since the last frame. Section 5 fixes that.
Most objects in a level do not move most frames — a lamppost, a rock, a building, a tree (the plant kind, not the data structure) all sit still forever after the level loads. Walking all of them every frame to recompute a world matrix that comes out identical to last frame's is wasted work. The fix is a dirty flag: a single bit per node that says "my world matrix might be wrong — recompute it before anyone reads it."
Two rules make a dirty flag correct:
struct Node {
Mat3 local;
Mat3 world;
bool dirty = true;
Node* parent = nullptr;
std::vector<Node*> children;
void setLocal(const Mat3& newLocal) {
local = newLocal;
markDirty();
}
void markDirty() {
if (dirty) return; // already dirty -- children were already marked too, stop early
dirty = true;
for (Node* child : children)
child->markDirty();
}
Mat3& getWorld() {
if (dirty) {
world = parent ? mul(parent->getWorld(), local) : local;
dirty = false;
}
return world;
}
};
The if (dirty) return; line inside markDirty is a small but important optimization: dirty flags only ever spread downward, so if a node is already dirty, every one of its descendants must already be dirty too (some earlier change already pushed the flag all the way down that branch) — walking further down again would just re-mark nodes that are already marked, for no benefit.
getWorld() is written to pull data on demand: nothing gets recomputed until some other system actually asks for a world matrix, and once it is computed, the result stays cached until something marks it dirty again. If a frame passes where nobody ever calls sword.getWorld(), the sword's world matrix simply never gets recomputed that frame at all — real, measurable work skipped, for free.
Section 4's plain recursive walk costs time proportional to the total number of nodes in the tree, every single frame, no matter how many of them actually moved. Section 5's dirty flag fixes the wasted recomputation, but on its own it does not fix a second, subtler cost: if gameplay code calls getWorld() scattered across dozens of different systems during one frame (physics asks first, then animation, then rendering, then audio), each of those calls can trigger its own little burst of recursive recomputation up the parent chain — the same nodes near the root of a busy branch might get touched, checked, and re-checked by several unrelated systems before the frame is done.
Production engines avoid this by batching: instead of recomputing world matrices the instant something asks for one, they collect every node marked dirty during the frame into one list, then process that whole list in a single dedicated pass — usually right before the systems that actually need final world matrices (culling, rendering, physics broad-phase) run. A few concrete techniques:
The overall shape does not change — you are still, fundamentally, walking parents before children — but batching turns "an unpredictable number of small, scattered recomputations triggered from all over the codebase" into "one predictable, sequential pass, run once, at a known point in the frame." That predictability matters as much as the raw work saved: it is what makes multithreading the update safe, and what makes profiling the cost of "updating transforms" a single, measurable number instead of dozens of small ones hidden inside unrelated systems.
Sometimes an object needs to switch parents at runtime — a sword gets sheathed (moves from the hand to a scabbard on the back), a picked-up item moves from the ground to the player's inventory socket, a passenger steps off a moving platform onto solid ground. If you simply change the parent pointer and leave the node's local transform untouched, the object visually teleports, because world = mul(newParent.world, local) now multiplies the same old local matrix by a completely different parent matrix.
To reparent an object without moving it, keep its world transform fixed and solve for a new local transform instead. The rule falls straight out of section 3's formula: if oldWorld = mul(newParent.world, newLocal) should hold, then multiplying both sides by the inverse of newParent.world isolates newLocal:
newLocal = mul(invertAffine(newParentWorld), oldWorld)
That needs a matrix inverse — a matrix that undoes another matrix's effect, so that mul(M, invertAffine(M)) is the identity. For a TRS-style affine matrix (the bottom row is always 0, 0, 1, exactly the matrices this whole chapter builds), the inverse has a short, direct formula — no general 3x3 inverse needed:
#include <algorithm> // std::find
// Node here is the dirty-flag version from section 5: parent pointer,
// children vector, getWorld(), markDirty().
Mat3 invertAffine(const Mat3& M) {
float a = M.m[0][0], b = M.m[0][1], tx = M.m[0][2];
float c = M.m[1][0], d = M.m[1][1], ty = M.m[1][2];
float det = a * d - b * c;
float invDet = 1.0f / det; // det == 0 means M has zero scale on some axis -- not invertible
Mat3 R{};
R.m[0][0] = d * invDet; R.m[0][1] = -b * invDet; R.m[0][2] = (-d * tx + b * ty) * invDet;
R.m[1][0] = -c * invDet; R.m[1][1] = a * invDet; R.m[1][2] = ( c * tx - a * ty) * invDet;
R.m[2][0] = 0; R.m[2][1] = 0; R.m[2][2] = 1;
return R;
}
void reparentKeepWorld(Node& node, Node& newParent) {
Mat3 oldWorld = node.getWorld(); // world transform BEFORE the switch
auto& siblings = node.parent->children;
siblings.erase(std::find(siblings.begin(), siblings.end(), &node));
newParent.children.push_back(&node);
node.parent = &newParent;
node.local = mul(invertAffine(newParent.getWorld()), oldWorld); // new local that reproduces the same world
node.markDirty();
}
Trace it through with the sword from sections 3 and 4. Before reparenting, the sword is a child of the hand, and its world position was (2, 3). Now reparent it directly onto the body (skipping arm and hand entirely) — body's world matrix is a plain translation to (5, 0), no rotation, so its inverse is just translate(-5, 0). Working through mul(invertAffine(bodyWorld), swordWorld) by hand gives a new local translation of (-3, 3) (with the sword's accumulated 90-degree rotation from the old arm now baked directly into its own local matrix, since that rotation is part of what "world" meant a moment ago, and reparenting must preserve it).
std::cout << "sword local before: (1, 0)\n";
Mat3 newLocal = mul(invertAffine(bodyWorld), swordWorld);
Vec2 newLocalPos = transformPoint(newLocal, {0, 0});
std::cout << "sword local after: (" << newLocalPos.x << ", " << newLocalPos.y << ")\n";
Mat3 checkWorld = mul(bodyWorld, newLocal);
Vec2 checkPos = transformPoint(checkWorld, {0, 0});
std::cout << "sword world after: (" << checkPos.x << ", " << checkPos.y << ")\n";
sword local before: (1, 0)
sword local after: (-3, 3)
sword world after: (2, 3)
The local numbers changed a lot — (1, 0) to (-3, 3) — because the sword is now measured against a completely different, unrotated parent. But the world position, the only thing anyone actually sees on screen, is exactly (2, 3), untouched. That is the whole point: reparenting recomputes local so that world stays put.
invertAffine does, fixes position, rotation, and scale together in one step.Section 4's Node struct — a pointer to a parent, a std::vector of pointers to children — is the easiest version of a scene graph to understand, and it is a real tree of pointers: every node is a separate heap allocation (chapter on the heap: new Node, or whatever your engine's allocator does under the hood), scattered wherever the allocator happened to put it. Walking that tree means chasing a pointer, jumping to some unpredictable address in memory, reading a few fields, then chasing the next pointer — and modern CPUs are dramatically slower at "jump to a random address" than at "read the next few bytes right after the ones I just read" (an earlier chapter's cache and memory-layout material covers exactly why).
The alternative is a flat array layout: throw away individual node allocations, and instead keep one big contiguous array of local matrices, one of world matrices, and one array of plain integer parent indices instead of pointers. The one rule that makes this work is: sort the array so every parent's index is smaller than any of its children's indices — "parent before child" as a flat ordering, not just a tree shape.
#include <vector>
struct FlatScene {
std::vector<Mat3> local;
std::vector<Mat3> world;
std::vector<int> parentIndex; // -1 means "no parent, this is a root"
};
// index 0: root parentIndex[0] = -1
// index 1: body parentIndex[1] = 0
// index 2: arm parentIndex[2] = 1
// index 3: hand parentIndex[3] = 2
// index 4: sword parentIndex[4] = 3
void updateAllWorldTransforms(FlatScene& scene) {
for (size_t i = 0; i < scene.local.size(); i++) {
int p = scene.parentIndex[i];
scene.world[i] = (p == -1) ? scene.local[i] : mul(scene.world[p], scene.local[i]);
}
}
No recursion, no pointer chasing, no function-call overhead per node — just one straight loop over three plain arrays. The "parent before child" ordering is what makes scene.world[p] always already correct by the time index i reads it: since p < i is guaranteed for every node, and the loop runs indices in increasing order, p's iteration always happened earlier in this same loop.
Two separate wins come from the same layout change:
Building the sorted array in the first place is usually done with a breadth-first walk of the tree (visit the root, then all of its children, then all of their children, and so on) — assigning array indices in that visiting order automatically guarantees every parent's index is smaller than its children's, since a parent is always visited at least one level before its children.
A uniform scale multiplies every axis by the same number (scale(2, 2): twice as big, same shape). A non-uniform scale multiplies different axes by different numbers (scale(3, 1): three times as wide, same height — a stretch). Uniform scale composed with rotation, in any order, always produces another clean rotation-plus-scale. Non-uniform scale does not: combine it with a rotation underneath it in a hierarchy, and the result can shear (skew a shape's angles, like pushing the top of a square sideways so it becomes a parallelogram) — a distortion that a simple position/rotation/scale cannot describe at all.
Here is the actual mechanism, checkable by hand. Take a scale of (2, 1) and a rotation of 45 degrees, and multiply them in both possible orders. A clean rotation-plus-scale matrix always has perpendicular columns (its first column and second column, treated as 2D vectors, meet at exactly 90 degrees) — so testing dot(column0, column1) is a direct shear test: zero means clean, nonzero means sheared.
#include <iostream>
#include <cmath>
struct Vec2 { float x, y; };
struct Mat3 { float m[3][3]; };
Mat3 mul(const Mat3& A, const Mat3& B) {
Mat3 R{};
for (int row = 0; row < 3; row++)
for (int col = 0; col < 3; col++) {
float sum = 0;
for (int k = 0; k < 3; k++)
sum += A.m[row][k] * B.m[k][col];
R.m[row][col] = sum;
}
return R;
}
Mat3 scaleMatrix(float sx, float sy) {
return { { {sx, 0, 0}, {0, sy, 0}, {0, 0, 1} } };
}
Mat3 rotationMatrix(float degrees) {
float radians = degrees * 3.14159265f / 180.0f;
float c = std::cos(radians);
float s = std::sin(radians);
return { { {c, -s, 0}, {s, c, 0}, {0, 0, 1} } };
}
float shearAmount(const Mat3& M) { // dot product of the two 2x2 columns; 0 = no shear
Vec2 col0 = { M.m[0][0], M.m[1][0] };
Vec2 col1 = { M.m[0][1], M.m[1][1] };
return col0.x * col1.x + col0.y * col1.y;
}
int main() {
Mat3 S = scaleMatrix(2.0f, 1.0f);
Mat3 R = rotationMatrix(45.0f);
Mat3 rotateInsideScale = mul(S, R); // a CHILD's rotation, under a non-uniformly SCALED parent
Mat3 scaleInsideRotate = mul(R, S); // scale applied inside a rotated frame -- one object's own S then R
std::cout << "shear of mul(S, R) = " << shearAmount(rotateInsideScale) << "\n";
std::cout << "shear of mul(R, S) = " << shearAmount(scaleInsideRotate) << "\n";
}
Output:
shear of mul(S, R) = -1.5
shear of mul(R, S) = 0
mul(R, S) — scale, then rotate, both applied to the same object — comes out perfectly clean, zero shear: that is just an ordinary "scale it, then spin the whole already-scaled shape" transform, which is exactly what section 8 of chapter 2.1 recommended (model = T * R * S). But mul(S, R) is precisely the scene-graph situation from this chapter: a parent with non-uniform scale S, and a child whose own local rotation R gets applied inside that already-stretched parent space. The nonzero shear (-1.5) proves the child's shape genuinely skews — its right angles are no longer 90 degrees in world space, and no combination of position, rotation, and scale on the child alone can undo that.
This is why engines warn about non-uniform scale on a parent that has rotating children — Unity, for example, exposes transform.lossyScale (an approximate world-space scale, and the name is a deliberate warning: "lossy" means information is lost, because a sheared matrix cannot always be decomposed back into an exact scale at all) and physics engines often refuse to build accurate colliders under non-uniform scale entirely. The practical fix is simple: keep non-uniform scale on leaf nodes (a single mesh that never has rotating children of its own) rather than on nodes partway up the hierarchy, or bake the non-uniform stretch directly into the mesh data instead of leaving it live on a Transform.
Everything in this chapter is exactly what Unity's built-in Transform component does, under a friendlier name. Every single GameObject has exactly one Transform, and every Transform optionally has a parent — the Hierarchy window in the Editor is a live picture of the scene graph this whole chapter has been building by hand.
transform.position / transform.rotation / transform.lossyScale — the world transform (section 2), computed for you, read-only for scale.transform.localPosition / transform.localRotation / transform.localScale — the local transform (section 2), the numbers actually stored on the component and shown in the Inspector.transform.localToWorldMatrix — the cached world matrix this whole chapter has been computing by hand (a Matrix4x4 in Unity, since it works in 3D).transform.childCount and transform.GetChild(int index) — walk a node's children exactly like looping over Node::children in section 4.transform.SetParent(Transform newParent, bool worldPositionStays = true) — section 7's reparenting math, built in. With worldPositionStays true (the default), Unity solves for a new local transform internally, using the same "inverse of the new parent, times the old world matrix" idea as invertAffine — the object visibly stays put. Pass false, and Unity skips that math entirely and just keeps the old local numbers unchanged under the new parent — cheaper, but the object jumps, exactly like the "forgetting to reparent properly" mistake from section 7.using UnityEngine;
public class SheathSword : MonoBehaviour
{
public Transform hand; // sword's current parent, at world (2, 2), rotated 90 degrees
public Transform body; // sword's new parent, at world (5, 0), no rotation
public Transform sword; // localPosition = (1, 0) while under hand
void Start()
{
Debug.Log("before: sword.position = " + sword.position); // world stays the same either way
Debug.Log("before: sword.localPosition = " + sword.localPosition);
sword.SetParent(body, true); // true = worldPositionStays
Debug.Log("after: sword.position = " + sword.position); // unchanged: still (2, 3)
Debug.Log("after: sword.localPosition = " + sword.localPosition); // recomputed: now (-3, 3)
for (int i = 0; i < body.childCount; i++)
Debug.Log("body's child " + i + ": " + body.GetChild(i).name);
}
}
Set the scene up with the exact same numbers as sections 3, 4, and 7 — hand at world (2, 2) carrying the arm's 90-degree rotation, sword at local (1, 0) underneath it, body at world (5, 0) with no rotation — and the Console prints sword.position staying at (2, 3) across the reparent while sword.localPosition jumps from (1, 0) to (-3, 3), exactly matching the C++ trace from section 7. Note also that transform.parent = someTransform (assigning the property directly, instead of calling the method) behaves like SetParent(someTransform, true) — world position is preserved by default either way; you have to explicitly pass false to opt out of the math.
transform.position in Awake() on an object whose parent has not finished its own Awake() yet. Unity does not guarantee Awake() runs parent-before-child across different GameObjects, so a parent's transform might still be at its default values when a child asks for its own world position — the exact bug that section 5's "parent must be clean before the child reads it" rule is designed to prevent, except here it is Unity's script execution order, not a dirty flag, that can catch you out. When the exact value matters this early, prefer Start(), or explicitly set the parent's transform before the child ever reads from it.Transform in Unity, a SceneComponent in Unreal).Root has two children, Body and Torch. Body has one child, Arm, which has one child, Hand. All five nodes start clean (not dirty). Gameplay code calls arm.setLocal(...) to swing the arm. (a) List every node that becomes dirty. (b) If the renderer then calls torch.getWorld(), does any matrix get recomputed? Why or why not? (c) If the renderer then calls hand.getWorld(), list every mul(...) that actually runs, in order.(a) arm.setLocal(...) marks Arm dirty, then propagates dirty downward to its descendants: Hand also becomes dirty. Root, Body, and Torch stay clean — dirty never spreads upward to a parent or sideways to a sibling.
(b) No. Torch is a child of Root, a completely separate branch from Body/Arm/Hand, and it was never marked dirty. torch.getWorld() sees dirty == false and returns the cached matrix immediately — this is exactly the benefit from section 5's tip: an unrelated branch costs nothing.
(c) hand.getWorld() finds itself dirty, so it needs arm.getWorld() first. arm.getWorld() finds itself dirty too, so it needs body.getWorld() — but Body is clean, so that call returns instantly with no mul. Then arm.getWorld() runs mul(body.world, arm.local) and clears its own dirty flag. Finally hand.getWorld() runs mul(arm.world, hand.local) and clears its own flag. Total: exactly two mul calls, not four or five.
X is currently a child of GroundAnchor, whose world matrix is a plain translation to (2, 0) (no rotation). X's local matrix is a plain translation to (3, 4). The player picks it up, and it needs to reparent onto HandSocket, whose world matrix is a plain 90-degree rotation (no translation) — treat cos(90) = 0 and sin(90) = 1 exactly for this exercise. Using newLocal = mul(invertAffine(newParentWorld), oldWorld), compute X's world position before the reparent, and its new local position after the reparent. Then verify: does mul(HandSocket.world, newLocal) give back the same world position you started with?X.world before: mul(GroundAnchor.world, X.local) is translate(2,0) applied on top of translate(3,4) — world position (2+3, 0+4) = (5, 4).
HandSocket.world is a pure rotation, R(90) = [[0,-1],[1,0]]. Its inverse is its transpose (true for any pure rotation): invertAffine(R(90)) = R(-90) = [[0,1],[-1,0]].
newLocal = mul(R(-90), X.world). Applying R(-90) to the point (5, 4): x' = 0*5 + 1*4 = 4, y' = -1*5 + 0*4 = -5. So X's new local position is (4, -5), with the rotation part of newLocal equal to R(-90) (the item's local matrix now carries a -90 degree rotation, even though nobody explicitly rotated it — that rotation is purely a side effect of moving under a rotated parent).
Verify: mul(HandSocket.world, newLocal) applies R(90) to (4, -5): x' = 0*4 - 1*(-5) = 5, y' = 1*4 + 0*(-5) = 4. That gives back (5, 4) — the exact same world position X started at. The reparent worked.
shearAmount from section 9 (dot(column0, column1) of the 2x2 rotation/scale part), and cos(30) is about 0.866, sin(30) = 0.5: (a) Compute mul(scaleMatrix(2, 2), rotationMatrix(30)) — a uniform scale parent with a rotated child — and its shear amount. (b) Compute mul(scaleMatrix(3, 1), rotationMatrix(30)) — a non-uniform scale parent with the same rotated child — and its shear amount. (c) Which one is safe to use on a parent with a rotating child, and which one will visibly skew?(a) scaleMatrix(2,2) is [[2,0],[0,2]]. Multiplying by R(30) = [[0.866,-0.5],[0.5,0.866]] gives columns col0 = (1.732, 1) and col1 = (-1, 1.732). dot = 1.732*(-1) + 1*1.732 = -1.732 + 1.732 = 0. No shear.
(b) scaleMatrix(3,1) is [[3,0],[0,1]]. Multiplying by the same R(30) gives columns col0 = (2.598, 0.5) and col1 = (-1.5, 0.866). dot = 2.598*(-1.5) + 0.5*0.866 = -3.897 + 0.433 = -3.464. That is nonzero — sheared.
(c) The uniform scale (2, 2) parent is safe — any rotation underneath it stays a clean rotation-plus-scale, no matter what angle. The non-uniform scale (3, 1) parent will visibly skew a rotated child, exactly the "melted" distortion from section 9's diagram — this is the case Unity's lossyScale warning exists for.
A scene graph is nothing more than section 3's one rule — childWorld = mul(parentWorld, childLocal) — applied over and over, once per node, in an order that always finishes a parent before touching its children. Everything else in this chapter is about doing that cheaply at scale: a dirty flag so unchanged branches cost nothing, batching so the whole frame's worth of updates happens in one predictable pass, reparenting math so an object can change branches without visibly moving, and a flat, sorted array layout so the CPU can walk thousands of nodes at full memory speed instead of chasing pointers. Watch for non-uniform scale sitting above a rotating child, and you have everything Unity's Transform component — and every other engine's equivalent — is built on.