16.2 Scene Graph & Transforms

Phase 16 · Engine Programming (deep C++, optional) · Study time: 15–25 h

Hierarchical transforms with lazy, dirty-flag world-matrix updates, backed by data-oriented storage for fast iteration.

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.

1. Why games store objects in a tree

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.

Root | Body | Arm | Hand | Sword Move or rotate ARM --> HAND moves with it --> SWORD moves with it. Nobody has to touch Hand's or Sword's own numbers -- they inherit the motion just by being PARENTED underneath Arm.

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.

Tip A tree does not have to be a straight chain. Root can have many children (Body, a Camera, an AudioListener...), and each of those can have many children of their own. "Scene graph" and "hierarchy" mean the same thing — the second word is what you will see in Unity's Hierarchy window and Unreal's Outliner.

2. Local space vs world space: two ways to describe the same object

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.

LOCAL transform: measured against your PARENT's axes WORLD transform: measured against the one shared axes everyone agrees on world Y ^ | parent's local Y | ^ | | * child, at LOCAL (0, 3) | | / | |/ | parent (somewhere out in the world) | / | / +--/----------------> world X The child's LOCAL position (0,3) never changes just because the parent moves. Its WORLD position changes every time the parent does.

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.

3. The rule: a child's world matrix is parent-world times child-local

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.

Root.world = identity (root has no parent) Body.local --x Root.world--> Body.world Arm.local --x Body.world--> Arm.world Hand.local --x Arm.world ---> Hand.world Sword.local --x Hand.world--> Sword.world Each step only ever needs the PARENT's world matrix, which the previous step already finished computing.

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.

Common mistake Writing 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.

4. Walking the tree: computing every world matrix with code

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.

updateWorldTransform(root) root.world = root.local (no parent) -> updateWorldTransform(body) body.world = mul(root.world, body.local) -> updateWorldTransform(arm) arm.world = mul(body.world, arm.local) -> updateWorldTransform(hand) hand.world = mul(arm.world, hand.local) -> updateWorldTransform(sword) sword.world = mul(hand.world, sword.local)

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.

5. The dirty flag: only recompute what actually moved

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.

Frame starts: everything clean (C) Body(C) -- Arm(C) -- Hand(C) -- Sword(C) Gameplay code calls arm.setLocal(...) to raise the arm: Body(C) -- Arm(D) -- Hand(D) -- Sword(D) ^ changed here dirty PROPAGATES DOWN to every descendant Later, rendering code calls sword.getWorld(): sword dirty -> needs hand.getWorld() hand dirty -> needs arm.getWorld() arm dirty -> needs body.getWorld() body CLEAN -> return cached world instantly, no work arm recomputes world, clears its own dirty flag hand recomputes world, clears its own dirty flag sword recomputes world, clears its own dirty flag body.world was never touched -- it was already correct.

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.

Tip A dirty flag turns "cost proportional to the whole tree" into "cost proportional to what changed." In a level with 50,000 static props and 200 moving characters, that is the difference between walking 50,200 nodes and walking a few hundred, every single frame.

6. Why recomputing everything every frame does not scale, and how engines batch updates

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:

NAIVE: recompute cost every frame = ALL nodes (section 4) DIRTY FLAG: recompute cost every frame = CHANGED nodes (section 5) BATCHED: the above, done as ONE pass over a sorted list, instead of scattered recursive calls from many systems 50,000 static props: 0 nodes touched per frame (baked once, never dirty again) 200 moving actors: ~200-1000 nodes touched (only these enter the dirty list)

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.

7. Reparenting without teleporting: keeping world position across a parent change

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.

Common mistake Forgetting that this fixes up rotation and scale, not just position. A reparent that only patches the translation column while leaving the old rotation/scale untouched will keep the object in the right spot but pointing the wrong way (or the wrong size) the instant the new parent has any rotation or scale of its own. Solving for the whole matrix at once, as invertAffine does, fixes position, rotation, and scale together in one step.

8. Storage layout: a tree of pointers vs. flat sorted arrays

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).

TREE OF POINTERS -- nodes scattered across the heap address 0x1000: [Body] parent->0x4400 children->{0x2a10} address 0x2a10: [Arm] parent->0x1000 children->{0x9f30} address 0x9f30: [Hand] parent->0x2a10 children->{0x0770} address 0x0770: [Sword] parent->0x9f30 children->{} Walking Body -> Arm -> Hand -> Sword jumps all over memory -- the CPU cannot predict where the next node lives until it has already read the pointer to it.

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.

FLAT ARRAY -- one contiguous block per array, sorted parent-before-child index: 0 1 2 3 4 local[]: [root] [body] [arm] [hand] [sword] world[]: [ ? ] [ ? ] [ ? ] [ ? ] [ ? ] -> filled left to right parentIdx[]: -1 0 1 2 3 Reading local[i] and parentIdx[i] and writing world[i] all touch memory right next to the last access -- the CPU's prefetcher can see this pattern coming and load ahead, instead of guessing.

Why flat wins: cache behavior and parallelism

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.

Tip This is exactly why some engines (Unity's DOTS/ECS transform system among them) keep transforms as flat, contiguous data behind the scenes, even though the Editor still shows you a friendly tree in the Hierarchy window. The tree is for humans; the flat array is for the CPU.

9. The non-uniform scale problem: rotation and shear do not mix

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.

UNIFORM scale parent + rotated child NON-UNIFORM scale parent + rotated child (clean: still a rectangle) (sheared: corners are no longer 90 degrees) +------+ +--------+ / / / / / / <- just rotated + bigger / / <- slanted, not +------+ +--------+ just rotated Both squares were rotated by the same 45 degrees. Only the one under NON-UNIFORM scale lost its right angles.

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.

10. Unity's Transform component: local vs world, GetChild, and SetParent

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.

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.

Common mistake Reading 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.

11. Glossary

12. Exercises

Exercise 1 A scene has this tree: 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.
Show answer

(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.

Exercise 2 A pickup item 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?
Show answer

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.

Exercise 3 Using 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?
Show answer

(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.

← Back to all chapters