m01 · Entities Core · lab · 30 min
Watch density move
The brief
You proved the
- Baseline capacity. Build an archetype that sums to ~48 bytes per
entity. Read its
Archetype.ChunkCapacityand record it. - The cold-component tax. Add a 128-byte component the query never reads. Measure how far capacity falls.
- The shared-component cliff. Put a high-cardinality value into a
shared component across 5,000 entities and count the resulting
chunks .
You are checking three predictions from the readings against numbers your build actually produces. Where they disagree, the disagreement is the lesson — recount your bytes.
Before you start
You need a Unity project with the Entities package installed (any recent version — the whole point is to read your numbers, not match mine). You’ll run everything from a single system that fires once on play and prints to the Console. No scene setup, no GameObjects, no Inspector wiring — three log lines are the entire deliverable.
Checklist:
- A Unity project open, Entities package present.
- The Console window visible (Window → General → Console).
- An empty scene. Pressing Play is all it takes to trigger the probe.
Where this runs
Every snippet below lives inside one file. Create a C# script called
DensityProbe.cs anywhere in Assets/ and paste this scaffold in. It’s
a system that runs a single time, does its measurements, then disables
itself so it never spams the Console:
using Unity.Entities;
using Unity.Mathematics;
// ── Component definitions ──────────────────────────────────────────────
// ~48 bytes: float3 (12) + float3 (12) + float3 (12) + int (4) + int (4) + int (4) = 48
public struct Pos : IComponentData { public float3 Value; }
public struct Vel : IComponentData { public float3 Value; }
public struct Accel : IComponentData { public float3 Value; }
public struct Health : IComponentData { public int Value; }
public struct Armor : IComponentData { public int Value; }
public struct Faction : IComponentData { public int Value; }
// The cold component: 128 bytes the hot query will never request.
public struct ColdBlob : IComponentData
{
public float4x4 A; // 64 bytes
public float4x4 B; // 64 bytes
}
// The shared component you'll abuse with high cardinality.
public struct GridCell : ISharedComponentData { public int Value; }
// ── The probe ──────────────────────────────────────────────────────────
public partial class DensityProbe : SystemBase
{
protected override void OnCreate()
{
var em = EntityManager;
// >>> PART 1 code goes here <<<
// >>> PART 2 code goes here <<<
// >>> PART 3 code goes here <<<
Enabled = false; // run once, then stop
}
protected override void OnUpdate() { }
}
Press Play. Right now it does nothing but compile — that’s expected.
You’ll drop each Part’s code into the marked spot, press Play, read the
Console, then move on. Use Debug.Log (from UnityEngine) for the
output; it’s already available inside a system.
Part 1 — baseline capacity
Step 1. Paste this where Part 1 is marked, then press Play:
var lean = em.CreateArchetype(
typeof(Pos), typeof(Vel), typeof(Accel),
typeof(Health), typeof(Armor), typeof(Faction));
UnityEngine.Debug.Log($"[P1] lean bytes/entity ≈ 48, ChunkCapacity = {lean.ChunkCapacity}");
Step 2. Read the [P1] line in the Console. Record that
ChunkCapacity as chunk_capacity_48b.
Step 3. Check it against the prediction
min( (16384 − header) / (48 + 8) , 128 ). The +8 is the Entity
handle every chunk stores per slot; the 128 is
kMaximumEntitiesPerChunk, a hard cap that exists because enableable
components keep their on/off state in fixed 128-bit masks in the chunk
header — one bit per slot, so no chunk may hold a 129th entity. The
byte math says ~291 fit. The cap says 128, and 128 is what you’ll
read. You just measured a constraint that isn’t about bytes at all.
Part 2 — the cold-component tax
Step 1. Add this at the Part 2 marker (keep Part 1 above it) and press Play:
var fat = em.CreateArchetype(
typeof(Pos), typeof(Vel), typeof(Accel),
typeof(Health), typeof(Armor), typeof(Faction),
typeof(ColdBlob)); // +128 bytes, never queried
UnityEngine.Debug.Log($"[P2] fat ChunkCapacity = {fat.ChunkCapacity}");
Step 2. Read the [P2] line. Compute
capacity_ratio_fat = lean.ChunkCapacity / fat.ChunkCapacity.
Step 3. Sanity-check both numbers. The fat archetype’s per-slot cost
is 48 + 128 + 8 = 184 bytes — past the ~120-byte cap line — so here the
division is live: ⌊(16384 − header) / 184⌋ ≈ 88, and you’ll read 87
or 88 (the gap is per-array alignment padding, since each 16384 − capacity × 184 — the lean one can’t tell you, because
its capacity is a clamp, not a division.
So capacity_ratio_fat lands near 128 / 87 ≈ 1.5 — not the
184 / 56 ≈ 3.3 the raw bytes predict. Sit with that gap: the cap was
already discarding most of the lean archetype’s theoretical density, so
it absorbs most of the cold tax. The tax didn’t shrink — 184 bytes
per entity is 3.3× the memory traffic of 56, every frame, forever. The
cap just hides it from the capacity number until the day the lean side
crosses the line too. A query over the fat archetype still spreads the
same entities across ~1.5× as many chunks and pays 3.3× the bandwidth.
Part 3 — the shared-component cliff
Step 1. Add this at the Part 3 marker and press Play:
var shared = em.CreateArchetype(typeof(Pos), typeof(GridCell));
// HIGH cardinality: a near-unique value per entity.
for (int i = 0; i < 5000; i++)
{
var e = em.CreateEntity(shared);
em.SetSharedComponentManaged(e, new GridCell { Value = i }); // 5000 distinct values
}
var q = em.CreateEntityQuery(typeof(Pos), typeof(GridCell));
UnityEngine.Debug.Log($"[P3] high-cardinality chunk count = {q.CalculateChunkCount()}");
Step 2. Read the [P3] line and record it as shared_chunk_count.
With 5,000 distinct shared values you should see a chunk count in the
thousands — approaching one chunk per value, each holding roughly one
entity, each burning 16 KB. That’s the fragmentation cliff from the
reading, made concrete.
Step 3. Now change one thing — make the value low cardinality — and watch it collapse. Edit the line inside the loop to:
em.SetSharedComponentManaged(e, new GridCell { Value = i % 16 }); // only 16 values
Step 4. Press Play again and derive the [P3] number before you
read it. Shared values can’t mix in a chunk, so each of the 16 values
gets its own chunks: 5000 / 16 ≈ 313 entities per value. Pos + GridCell is only 12 + 8 = 20 bytes per slot — the bytes would allow
~800 per chunk, but the cap says 128 — so each value needs
⌈313 / 128⌉ = 3 chunks. 16 × 3 = 48. If you read 48, you’ve just
measured kMaximumEntitiesPerChunk a second way, from a direction that
has nothing to do with component sizes. (Without the cap you’d have
seen 16.) Same feature, same code, cardinality is the only variable —
and it’s the difference between 48 chunks and 5,000.
What you just proved
Log your three numbers — chunk_capacity_48b, capacity_ratio_fat,
and shared_chunk_count. Module 13 charts chunk_capacity_48b
against your future archetypes, so this baseline is the first point in a
dataset about your own hardware and Entities version — not anyone
else’s.