m06 · Transforms · reading · 9 min
LocalToWorld — the derived value, and who owns the write
Two components that look redundant
An entity in a hierarchy carries LocalTransform — its offset from its parent
— and LocalToWorld, a 4×4 matrix giving its absolute position in the world.
The last lesson argued against storing a matrix. This lesson stores one. That
is not a contradiction, and untangling it is the point: LocalTransform is
input, LocalToWorld is output. One is authored and written by gameplay; the
other is computed and written by the transform system. They have opposite
roles, opposite writers, and different correct representations.
The question worth asking is why the output is stored at all. The transform system knows how to compute it. Why not have consumers compute it on demand and save 64 bytes per entity?
The fan-out argument
Count the consumers. In a real frame, an entity’s world matrix is read by rendering (to place the mesh), culling (to test the bounds), physics (to position the collider), spatial queries, audio positioning, and any gameplay system asking where something is.
If each consumer computed it, each would walk the entity’s parent chain to the
root — reading every ancestor’s LocalTransform and composing. For an entity
at depth 5, that’s five reads and five compositions, per consumer, per
frame. Six consumers means thirty reads of data scattered across the
Compute it once and store it, and the cost is one walk per entity per frame, then a flat 64-byte read for every consumer — contiguous, in the entity’s own chunk, no pointer chasing.
This is caching, with the standard trade: memory for time, and a coherence obligation. It is worth it here because the fan-out is high, the recomputation is a chain walk rather than a local calculation, and the value is stable for the whole frame after the transform system runs.
And note that the matrix form is now the right choice, precisely because the
earlier argument doesn’t apply. Nobody interpolates LocalToWorld — it’s a
frame-local result, thrown away and recomputed next frame. The two objections
to storing a matrix were size (still real, and paid deliberately) and
interpolation (irrelevant for a derived value nobody blends). Consumers want a
matrix, so a matrix is what’s cached.
The write ownership rule
Here is where people get burned, and the burn is instructive.
LocalToWorld is a normal component. Nothing in the type system stops you
writing to it. So a reasonable-looking system sets an entity’s LocalToWorld
to place it somewhere — and the entity does not move. Or worse, it moves for
one frame and snaps back.
The reason is that LocalToWorld is derived data, and the transform system
recomputes it from LocalTransform and the parent chain every frame. Your
write lands, and then the transform system overwrites it with the value implied
by the actual inputs. You wrote to the output of a function and expected the
function to change.
The rule that follows: write the inputs, read the output. Move an entity by
writing LocalTransform. Read LocalToWorld when you need to know where
something ended up. Any system that writes LocalToWorld and expects it to
stick is fighting the dataflow.
Recognize the shape. Module 5 said the pipeline trusts your declared reads; Module 4 said the safety system trusts your declared access. Here the system trusts nothing — it simply recomputes, and your write is not so much rejected as irrelevant. Derived data has an owner, and the owner is whatever computes it.
The exception that proves the rule
There is one case where writing LocalToWorld is not only allowed but correct,
and it falls straight out of everything above.
An entity with only LocalToWorld and no LocalTransform and no Parent
has no inputs for the transform system to derive from. The system skips it
entirely — there’s nothing to compute. Its matrix is whatever it was baked or
written as, and it stays.
This is exactly what TransformUsageFlags.Renderable produces in Module 5: an
entity that needs to be placed for rendering but never moves and has no
parent. It carries the 64-byte matrix the renderer wants and skips the 32-byte
LocalTransform it would never use, and it costs the transform system zero
work per frame because it isn’t in any of its queries.
So the flags from Module 5 now decode completely:
| Flag | Components | Transform system cost |
|---|---|---|
| None | neither | none — no spatial existence |
| Renderable | LocalToWorld only | none — no inputs to derive from |
| Dynamic | LocalTransform + LocalToWorld | one composition per frame |
| child of a parent | + Parent, and a Child buffer above | composition, ordered by depth |
That table is the module’s practical summary, and every row is derivable from “derived data needs inputs; no inputs means no work.”
What this buys you
You can now explain why a derived matrix is worth caching when the fan-out is high, why writing it is always the wrong move and why the symptom depends on system ordering, and why an entity with no transform inputs costs the transform system nothing. Next, the lab: you will measure what depth actually costs, on your own machine.