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
Both are chunk-capacity claims, and Module 1 taught you to measure capacity directly. So you will. Four measurements:
- Capacity of a lean archetype baked with
TransformUsageFlags.None. - Capacity of the same archetype baked
Dynamic— the transform tax. - The ratio between them.
- 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.