DOTS//CORE local · not synced

m05 · Baking · lab · 25 min

Price the bake — what a TransformUsageFlag costs per chunk

The brief

Two claims from this module were stated but never priced.

The first: baking decisions are archetype decisions — a TransformUsageFlag isn’t a formality, it changes chunk shape, and chunk shape is throughput. The second: large shared data must leave the chunk — a payload copied per entity collapses capacity, while a blob handle costs a few bytes.

Both are chunk-capacity claims, and Module 1 taught you to measure capacity directly. So you will. Four measurements:

  1. Capacity of a lean archetype baked with TransformUsageFlags.None.
  2. Capacity of the same archetype baked Dynamic — the transform tax.
  3. The ratio between them.
  4. Capacity with a 1 KB payload inline versus the same data behind a blob reference.

You are not measuring frame time here. You are measuring how many entities fit in 16 KB, because that number is the ceiling every system that touches these entities will be iterating against.

Before you start

  • A Unity project with the Entities package. Console window open.
  • A scene you can add GameObjects to, and the Entities → Hierarchy window available for sanity checking (optional but useful).
  • This lab needs a SubScene, because bakers only run on authored content inside one. If you’ve never made one: right-click in the Hierarchy → New Sub Scene → Empty Scene, save it. All authoring GameObjects below go inside that SubScene, not in the main scene.
  • Baking runs automatically when the SubScene content changes. You’ll see results by pressing Play and reading the Console.

Where this runs

Three files. One authoring component with its baker (edited between parts), one payload authoring component with its baker, and one probe system that prints capacities.

File 1 — PriceAuthoring.cs. Create it, and put a GameObject with PriceAuthoring on it inside your SubScene.

using Unity.Entities;
using UnityEngine;

public class PriceAuthoring : MonoBehaviour
{
    public float Speed = 5f;
    public int Health = 100;
}

public struct PriceSpeed  : IComponentData { public float Value; }
public struct PriceHealth : IComponentData { public int   Value; }

// Ballast: 128 bytes to push the archetype past the ~120-byte cap line
// (Module 1). Without it, BOTH bakes clamp at kMaximumEntitiesPerChunk = 128
// and the flag's cost is invisible in capacity. The ballast makes the
// division live so the measurement can see it — designing a probe around
// the cap is itself the Module 1 lesson, applied.
public struct BakeBallast : IComponentData
{
    public Unity.Mathematics.float4x4 A;   // 64 bytes
    public Unity.Mathematics.float4x4 B;   // 64 bytes
}

public class PriceBaker : Baker<PriceAuthoring>
{
    public override void Bake(PriceAuthoring authoring)
    {
        // >>> PART 1/2: change the TransformUsageFlags on the line below <<<
        var e = GetEntity(TransformUsageFlags.None);

        AddComponent(e, new PriceSpeed  { Value = authoring.Speed  });
        AddComponent(e, new PriceHealth { Value = authoring.Health });
        AddComponent<BakeBallast>(e);   // keep in BOTH parts — see comment above
    }
}

File 2 — PayloadAuthoring.cs. Create it, but do not add it to a GameObject yet — Part 3 tells you when.

using Unity.Entities;
using UnityEngine;

public class PayloadAuthoring : MonoBehaviour { }

// 1 KB copied into every entity: 256 floats x 4 bytes.
public unsafe struct InlinePayload : IComponentData
{
    public fixed float Data[256];
}

public struct BlobPayload      { public BlobArray<float> Data; }
public struct BlobPayloadRef   : IComponentData
{
    public BlobAssetReference<BlobPayload> Value;
}

public class PayloadBaker : Baker<PayloadAuthoring>
{
    public override void Bake(PayloadAuthoring authoring)
    {
        var e = GetEntity(TransformUsageFlags.None);

        // >>> PART 3: inline payload — comment this out for Part 4 <<<
        AddComponent<InlinePayload>(e);

        // >>> PART 4: blob handle — uncomment for Part 4 <<<
        // var builder = new Unity.Entities.BlobBuilder(Unity.Collections.Allocator.Temp);
        // ref var root  = ref builder.ConstructRoot<BlobPayload>();
        // var  array    = builder.Allocate(ref root.Data, 256);
        // for (int i = 0; i < 256; i++) array[i] = i;
        // var blob = builder.CreateBlobAssetReference<BlobPayload>(
        //     Unity.Collections.Allocator.Persistent);
        // builder.Dispose();
        // AddBlobAsset(ref blob, out _);
        // AddComponent(e, new BlobPayloadRef { Value = blob });
    }
}

File 3 — PricingProbe.cs. The probe. It reports the archetype and capacity of every baked entity it finds, once, then disables itself.

using Unity.Entities;
using Unity.Collections;

public partial class PricingProbe : SystemBase
{
    protected override void OnUpdate()
    {
        Enabled = false; // one-shot: report and stop

        var em = EntityManager;

        Report<PriceSpeed>(em, "P1/P2");
        Report<InlinePayload>(em, "P3");
        Report<BlobPayloadRef>(em, "P4");
    }

    void Report<T>(EntityManager em, string tag) where T : unmanaged, IComponentData
    {
        var q = em.CreateEntityQuery(ComponentType.ReadOnly<T>());
        var entities = q.ToEntityArray(Allocator.Temp);
        if (entities.Length == 0)
        {
            UnityEngine.Debug.Log($"[{tag}] no entities with {typeof(T).Name}");
            entities.Dispose();
            return;
        }

        var archetype = em.GetChunk(entities[0]).Archetype;
        var types     = archetype.GetComponentTypes(Allocator.Temp);

        UnityEngine.Debug.Log(
            $"[{tag}] ChunkCapacity = {archetype.ChunkCapacity} | " +
            $"components = {types.Length} | entities found = {entities.Length}");

        for (int i = 0; i < types.Length; i++)
            UnityEngine.Debug.Log($"[{tag}]   - {types[i]}");

        types.Dispose();
        entities.Dispose();
    }
}

The probe compiles and runs with nothing baked — it will simply log “no entities” for each tag. An empty Console is not expected; three lines are.

Part 1 — the lean bake

Step 1. With PriceAuthoring on a GameObject inside your SubScene, and the baker reading GetEntity(TransformUsageFlags.None), press Play.

Step 2. Read the [P1/P2] line. Record ChunkCapacity as capacity_transform_none.

Step 3. Read the component list underneath it. You should see your two components plus a small amount of ECS bookkeeping — and no transform components. Confirm that before continuing; if LocalTransform appears here, your edit didn’t take and every later number will be wrong.

Step 4. Sanity-check the arithmetic with Module 1’s full model. Per-slot cost: PriceSpeed (4) + PriceHealth (4) + BakeBallast (128) + the Entity handle (8) = 144 bytes — past the ~120-byte cap line, so the division is live: ⌊(16384 − header) / 144⌋ ≈ 113. Expect ~112–113 (the last entity or two goes to per-array alignment). This is also why the ballast is there at all: without it, per-slot cost is 16 bytes, the naive capacity is ~1000, and the chunk clamps at 128 — for both flags — hiding exactly the cost you came to measure.

Part 2 — the transform tax

Step 1. Edit the line at the Part 1/2 marker — do not add a second baker:

var e = GetEntity(TransformUsageFlags.Dynamic);

Step 2. Save, let the SubScene re-bake, press Play.

Step 3. Read the [P1/P2] line again. Record the new ChunkCapacity as capacity_transform_dynamic.

Step 4. Read the component list. New entries appeared that you never wrote — transform and hierarchy components the flag requested on your behalf. Count them.

Step 5. Predict before you compute. Dynamic added LocalTransform (32) and LocalToWorld (64): per-slot cost 144 → 240 bytes, so capacity falls to ⌊16320 / 240⌋ ≈ 68, and since both archetypes are past the cap line the ratio should match the byte ratio: 240 / 144 ≈ 1.67. Compute capacity_transform_none ÷ capacity_transform_dynamic and record it as transform_density_ratio — expect ~1.6–1.7.

Sit with that ratio. You changed one enum value in one baker. Every system that iterates these entities now touches that many times more chunks for the same entity count — and if those entities never move, you bought exactly nothing with it.

Part 3 — the inline payload

Step 1. Add PayloadAuthoring to a second GameObject inside the SubScene, with the AddComponent<InlinePayload>(e) line active and the Part 4 block still commented out. Press Play.

Step 2. Read the [P3] line. Record ChunkCapacity as capacity_inline_payload.

Step 3. Predict before you look: 1 KB per entity into 16 KB should fit about 16, minus header and other components. Check your prediction against the number. A gap means something else in the archetype is charging you — read the component list to find it.

Part 4 — the blob handle

Step 1. In PayloadBaker, comment out the Part 3 AddComponent<InlinePayload> line and uncomment the entire Part 4 block. Both must happen; leaving the inline component in place measures a chunk carrying both.

Step 2. Save, let it re-bake, press Play.

Step 3. Read the [P4] line. Record ChunkCapacity as capacity_blob_handle. The [P3] line should now report no entities — confirmation your comment-out took effect.

Step 4. Compare capacity_blob_handle against capacity_inline_payload. The blob reference is a handle of a few bytes; the payload it points at is the same 1 KB of data, stored once rather than per entity. You did not delete any information. You moved it out of the iteration path.

Step 5. Multiply out the memory side: for 10,000 entities, inline costs 10,000 × 1 KB. The blob costs 1 KB plus 10,000 handles. Both numbers are worth writing down next to the capacity ratio, because the capacity story and the memory story are separate wins from the same decision.

What you proved

These four numbers join your dataset. capacity_transform_none, capacity_transform_dynamic, transform_density_ratio, capacity_inline_payload and capacity_blob_handle sit alongside Module 1’s density measurements — same units, same 16 KB budget, measured at the other end of the pipeline. Module 13 charts them together: M1 showed you what chunk density costs at runtime, and M5 shows you that the decision which sets it was made at bake time.

m05.l07