DOTS//CORE local · not synced

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:

  1. Flat — 10,000 roots, no parents at all.
  2. Shallow — 10,000 entities one level under a handful of roots.
  3. Deep — the same 10,000 arranged in chains 16 levels deep.
  4. Renderable-only — 10,000 entities with LocalToWorld and 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 chunks than the roots. Some of this delta is fragmentation, not depth — which is exactly why Part 3 is the real test.

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.

m06.l06