Table of Contents

Entity Clusters (Batched SoA Storage)

GPU-inspired batched SoA storage that turns per-entity hashmap/page lookups into sequential array scans.

Status: โœ… Implemented ยท Visibility: Public ยท Level: ๐ŸŸฃ Advanced ยท Category: Ecs

Assumes: Storage Modes

๐ŸŽฏ What it solves

Per-entity storage pays a hash-map lookup plus a scattered page fetch for every component, on every entity, every tick. At 100K+ entities that indirection โ€” not the actual computation โ€” dominates iteration cost (profiling found 66% of per-entity time in lookup/fetch overhead) and blows the working set past L2/L3 cache. Entity Clusters eliminate that indirection for bulk iteration (movement systems, AI ticks, batch queries) while leaving random single-entity access, MVCC isolation, indexes, and all three storage modes working exactly as before.

โš™๏ธ How it works (in brief)

Eligible archetypes auto-compute a cluster size N (8-64, chosen to maximize entities-per-page) and pack N same-archetype entities into one contiguous chunk: an occupancy bitmask, an enabled-bitmask per component, an entity-key array, then each component's data as a separate contiguous array (SoA) โ€” Component0[N], Component1[N], .... ClusterEnumerator<TArch> walks an archetype's active clusters; each ClusterRef<TArch> exposes OccupancyBits plus GetSpan<T>/Get<T> for direct, branch-free access to a component's slot array โ€” no hash lookup, no per-component page fetch. Random access (Open/OpenMut) is unchanged at the API level: it transparently resolves through the EntityMap to the same cluster + slot. SingleVersion and Versioned components live in the cluster (Versioned stores its HEAD there, the revision chain stays separate); Transient components live in a parallel heap-backed segment with identical layout so the SoA pattern is uniform across all three modes. When an archetype also declares a [SpatialIndex] field, cluster membership gains a further constraint: Spatially-Coherent Entity Clustering additionally packs entities by grid cell, not just by archetype, so per-cell spatial operations (tier assignment, dormancy, broadphase) stay per-cluster instead of per-entity โ€” this is what a "cluster migration" (mentioned in the tick fence docs) actually is: an entity crossing its cluster's cell boundary and being moved to a cluster for its new cell.

๐Ÿ’ป Usage

[Archetype]
public class Ant : Archetype<Ant>
{
    public static readonly Comp<Position> Position = Register<Position>();
    public static readonly Comp<Movement> Movement = Register<Movement>();
}

// Bulk iteration โ€” the cluster-native path, ~50x faster than per-entity Open/OpenMut.
var ants = ctx.Accessor.For<Ant>();
using var clusters = ants.GetClusterEnumerator();
while (clusters.MoveNext())
{
    var cluster = clusters.Current;
    var positions = cluster.GetSpan(Ant.Position);
    var movements = cluster.GetReadOnlySpan(Ant.Movement);

    ulong bits = cluster.OccupancyBits;
    while (bits != 0)
    {
        int idx = BitOperations.TrailingZeroCount(bits);
        bits &= bits - 1;
        positions[idx].X += movements[idx].VX * ctx.DeltaTime;
        positions[idx].Y += movements[idx].VY * ctx.DeltaTime;
    }
    clusters.MarkCurrentDirty(); // required: flags the writes for WAL/checkpoint โ€” the cluster path skips dirty tracking otherwise
}

// Random single-entity access โ€” same archetype, same API, transparently cluster-backed.
var entity = ants.OpenMut(someAntId);
ref var pos = ref entity.Write(Ant.Position);
pos.X += 1;

โš ๏ธ Guarantees & limits

  • Cluster size is auto-computed, not chosen by the caller: N โˆˆ [8, 64] is picked per archetype to maximize entities-per-page; iteration code is identical for every N.
  • Eligibility is automatic, not opt-in: an archetype clusters if it has at least one SingleVersion or Transient component. Pure-Versioned archetypes and Transient components with indexed fields stay on the legacy per-entity path โ€” there is no manual opt-out.
  • Direct GetSpan/Get writes bypass dirty tracking: call MarkCurrentDirty() (whole cluster) or MarkSlotDirty(slot) (single entity) after writing, or the change never reaches the WAL/checkpoint. Writing Versioned components through GetSpan is rejected by design (Debug.Assert) โ€” use OpenMut/Write for those, which still goes through the revision chain.
  • Measured impact (100K entities, 2-component archetype): per-entity cost 134 ns โ†’ ~2.7 ns (~50x), tick time ~10x, working set 19.2 MB โ†’ 2.5 MB (L3 โ†’ L2).
  • Random access stays correct, not just fast: MVCC visibility (BornTSN/DiedTSN), B+Tree/spatial indexes, and EnabledBits are all maintained per entity; Open/OpenMut need no code changes to benefit.
  • Trade-offs are real, not hidden: checkpoint I/O is ~1.6x larger (bigger stride per page); highly selective queries pay a zone-map scan floor (~19 ยตs); schema evolution rebuilds every cluster in the archetype (~Cร— more expensive than per-component migration, an offline operation).
  • EntityId format is unchanged โ€” clusters are a storage detail; entity identity, serialization, and WAL/network formats are unaffected.

๐Ÿงช Tests

  • ClusterStorageTests โ€” cluster sizing/cache-line-aligned stride, GetClusterEnumerator bulk iteration, random-access read/write/destroy through the cluster
  • ClusterDirtyTests โ€” MarkCurrentDirty/per-slot dirty-bitmap semantics, tick-fence snapshot clearing, writes lost if dirty isn't marked
  • ClusterVersionedTests โ€” Versioned HEAD stored in the cluster with the revision chain kept separate, bulk iteration seeing the latest HEAD after a write
  • Sibling: Storage Modes โ€” clustered component data still splits by Versioned/SingleVersion/Transient mode, just batched
  • Sibling: Spatially-Coherent Entity Clustering โ€” for archetypes with a [SpatialIndex] field, additionally constrains cluster membership to one grid cell, and defines cluster migration
  • Sibling: Cluster Spatial Queries โ€” indexes cluster bounding boxes instead of per-entity, exploiting this same batched layout
  • Sibling: Spatial Tiers & Adaptive Dispatch โ€” per-cluster simulation tiers dispatch over this storage layer