DOTS//CORE local · not synced

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 chunks that hold the ancestors.

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.

cache line · 64 B fetched 64 B useful · 100% of the bandwidth you paid for LocalToWorld: 64 bytes of composed matrix sitting in the entity's own chunk — every consumer reads it contiguously, nobody walks the parent chain

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.

m06.l04