m06 · Transforms · lab · 30 min
Depth costs, count doesn't — measure the hierarchy tax
The brief
Lesson 2 made a claim with a number attached to nothing: depth is the cost, not entity count. Ten thousand entities flat parallelize beautifully; two hundred in a deep chain pay a barrier per level.
That is a testable claim, and it is the kind of claim this course does not let stand unmeasured. You will hold entity count fixed at 10,000 and vary only the shape of the hierarchy:
- Flat — 10,000 roots, no parents at all.
- Shallow — 10,000 entities one level under a handful of roots.
- Deep — the same 10,000 arranged in chains 16 levels deep.
- Renderable-only — 10,000 entities with
LocalToWorldand nothing else.
Same entity count every time. Only the dependency graph changes. If lesson 2 is right, (3) is dramatically worse than (2) despite being identical in count, and (4) is nearly free.
Before you start
- Unity project with the Entities package. Console window open.
- An empty scene. Everything here is created in code — no SubScene needed, which keeps baking out of the measurement.
- Close the Entities Hierarchy window while measuring. It walks the hierarchy to draw itself, and on a 16-deep tree that is real cost landing in your numbers.
- Run in the Editor is fine for ratios; note that absolute ms will be worse than a build. The ratios are what matter.
Where this runs
One file, DepthProbe.cs. It builds a hierarchy of the chosen shape, moves
every entity each frame, and reports a rolling average frame time.
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
public struct Wobble : IComponentData { public float Phase; }
public partial struct DepthProbeSystem : ISystem
{
// >>> PARTS 1-4: change MODE, then press Play <<<
// 1 = flat (10k roots) 2 = shallow (10k at depth 1)
// 3 = deep (chains 16 deep) 4 = renderable only (LocalToWorld, no inputs)
const int MODE = 1;
const int Total = 10000;
const int ChainDepth = 16;
bool _built;
double _accum;
int _frames;
public void OnUpdate(ref SystemState state)
{
if (!_built) { Build(ref state); _built = true; return; }
// Move everything: writes the INPUT, never the output.
if (MODE != 4)
{
float t = (float)SystemAPI.Time.ElapsedTime;
foreach (var (xform, w) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<Wobble>>())
{
var p = xform.ValueRO.Position;
p.y = math.sin(t + w.ValueRO.Phase) * 0.5f;
xform.ValueRW.Position = p;
}
}
_accum += SystemAPI.Time.DeltaTime;
_frames++;
if (_frames == 200)
{
double avgMs = (_accum / _frames) * 1000.0;
UnityEngine.Debug.Log($"[MODE{MODE}] avg frame = {avgMs:F3} ms over 200 frames");
_accum = 0; _frames = 0;
}
}
void Build(ref SystemState state)
{
var em = state.EntityManager;
var rng = new Unity.Mathematics.Random(1234);
if (MODE == 4)
{
// No LocalTransform, no Parent — nothing for the transform system to derive.
var arch = em.CreateArchetype(typeof(LocalToWorld));
using var e = em.CreateEntity(arch, Total, Allocator.Temp);
for (int i = 0; i < Total; i++)
em.SetComponentData(e[i], new LocalToWorld {
Value = float4x4.Translate(rng.NextFloat3() * 50f) });
UnityEngine.Debug.Log($"[MODE4] built {Total} LocalToWorld-only entities");
return;
}
var moving = em.CreateArchetype(
typeof(LocalTransform), typeof(LocalToWorld), typeof(Wobble));
if (MODE == 1)
{
using var e = em.CreateEntity(moving, Total, Allocator.Temp);
for (int i = 0; i < Total; i++) Init(em, e[i], rng, i);
UnityEngine.Debug.Log($"[MODE1] built {Total} flat roots, depth 0");
}
else if (MODE == 2)
{
const int Roots = 10;
using var roots = em.CreateEntity(moving, Roots, Allocator.Temp);
for (int i = 0; i < Roots; i++) Init(em, roots[i], rng, i);
using var kids = em.CreateEntity(moving, Total - Roots, Allocator.Temp);
for (int i = 0; i < kids.Length; i++)
{
Init(em, kids[i], rng, i);
em.AddComponentData(kids[i], new Parent { Value = roots[i % Roots] });
}
UnityEngine.Debug.Log($"[MODE2] built {Total} entities at depth 1 under {Roots} roots");
}
else // MODE == 3
{
int chains = Total / ChainDepth;
int made = 0;
for (int c = 0; c < chains; c++)
{
Entity prev = Entity.Null;
for (int d = 0; d < ChainDepth; d++)
{
var e = em.CreateEntity(moving);
Init(em, e, rng, made++);
if (prev != Entity.Null)
em.AddComponentData(e, new Parent { Value = prev });
prev = e;
}
}
UnityEngine.Debug.Log(
$"[MODE3] built {made} entities in {chains} chains of depth {ChainDepth}");
}
}
static void Init(EntityManager em, Entity e, Unity.Mathematics.Random rng, int i)
{
em.SetComponentData(e, LocalTransform.FromPosition(rng.NextFloat3() * 5f));
em.SetComponentData(e, new Wobble { Phase = i * 0.01f });
}
}
The system auto-registers in the default world; there is nothing to place in
the scene. Press Play and a [MODEn] build line appears immediately, then an
avg frame line every 200 frames. Let it settle — read the second or third
average, not the first, since the first includes construction.
Part 1 — flat baseline
Step 1. With MODE = 1, press Play. Confirm the build line reads 10,000
flat roots.
Step 2. Let it run past two avg frame reports. Record the second as
ms_flat_10k.
Step 3. Note what the transform system is doing here: 10,000 entities, zero
parents, so every entity is a root whose LocalToWorld is a direct composition
of its own LocalTransform. One pass, fully parallel, no barriers.
Part 2 — shallow hierarchy
Step 1. Exit Play. Edit MODE to 2. Let Unity recompile. Press Play.
Step 2. Confirm the build line says depth 1 under 10 roots. Record the settled average as ms_shallow_10k.
Step 3. Compare against Part 1. You added a Parent component to 9,990
entities and one level of dependency. Expect it to be worse than flat, but not
dramatically — one barrier, and the level below it still parallelizes across
all 9,990.
The archetype changed too: parented entities carry Parent and live in
different
Part 3 — deep chains, identical count
Step 1. Exit Play. Edit MODE to 3. Recompile. Press Play.
Step 2. Confirm the build line reports chains of depth 16 totalling ~10,000 entities. Record the settled average as ms_deep_chain.
Step 3. Compute ms_deep_chain ÷ ms_shallow_10k and record it as
depth_penalty_ratio.
Step 4. This is the measurement the lab exists for. The entity count is the
same. The component composition is the same. The work per entity is the same.
The only difference is the shape of the dependency graph — 16 sequential
levels instead of 1, so 16 barriers instead of 1, and at each barrier the
available parallel work is only 10000/16 ≈ 625 entities instead of 9,990.
Your ratio is the price of depth on your machine. Write it down; it is the number to cite the next time someone proposes nesting for organizational tidiness.
Part 4 — the free case
Step 1. Exit Play. Edit MODE to 4. Recompile. Press Play.
Step 2. Record the settled average as ms_renderable_only.
Step 3. These 10,000 entities have LocalToWorld and nothing else. No
LocalTransform, no Parent, no Wobble — so the movement loop skips them
entirely and the transform system has no inputs to derive from. They are in
none of its queries.
Compare against ms_flat_10k. The gap is the entire per-frame cost of being
a transform-participating entity, which is what TransformUsageFlags.Renderable
saves you in Module 5 on every static prop in a scene.
What you proved
Five metrics into the dataset: ms_flat_10k, ms_shallow_10k, ms_deep_chain, depth_penalty_ratio and ms_renderable_only. These pair directly with Module 5’s capacity_transform_none and capacity_transform_dynamic — M5 measured what transform components cost in chunk space, and M6 measures what the resulting dependency graph costs in time. Module 13 charts both against the same baking decision.