ECS & Storage

← Threading | Component System → | Home

</blockquote>

Overview: Four Storage Tiers

Entity component data lives in one of four tiers based on access pattern and rollback requirements. The tier is declared on the component via registration macro — not on the entity.

TierStructureFramesRollbackUse Case
ColdArchetype chunks (AoS)1NoRarely-updated config data (health, ammo caps)
StaticSeparate read-only SoA1NoGeometry/mesh data, never changes
VolatileSoA triple-buffer3NoCosmetic entities, particles, decals
TemporalSoA N-frame ringmax(8, X)YesNetworked, simulation-authoritative entities

An entity's effective tier is the highest tier of any of its components. An entity carrying a CJoltBody (Temporal) is a Temporal entity; an entity with only CColor (Volatile) is Volatile. Cold components (TNX_REGISTER_FIELDS) contribute no slab storage and do not affect the tier classification.

If TNX_ENABLE_ROLLBACK is disabled, Temporal components are treated as Volatile (3-frame triple-buffer). Games that don't need rollback pay zero memory cost for the tier system.

Why Four Tiers

A single SoA ring buffer for everything would waste memory on entities that never roll back and bandwidth on entities that never render:

TierRing depthWhy
Cold1Rarely updated config. AoS in chunks. No iteration cost.
Static1Read-only geometry. Never touched by update loops.
Volatile3Triple-buffer for Logic↔Render handoff. No rollback needed.
TemporalNRollback history. Only networked entities pay the memory cost.

Volatile = 3 Frames

Originally 5 frames were used. After moving to GPU-driven rendering with a persistent previous-frame InstanceBuffer on the GPU side, the render thread only needs to supply frame T — the GPU interpolates T-1 from its own buffer. CPU slab needs 1 frame for logic and 1 for render simultaneously, so 3 (triple-buffer) is the correct minimum.


The Global Spreadsheet Model

TrinyxEngine's SoA data is easiest to reason about as a single global spreadsheet:

  • Columns = Entities. The column index is EntityCacheIndex.
  • Rows = Fields. Each FieldProxy field (Transform.PosX, Health.Value) is one contiguous SoA array.
  • Cells = Values. The value for entity i in field Transform.PosX is the cell at (row=PosX, column=i).

Volatile and Temporal are not separate "entity spaces" — they are different row ranges in the same global model. Cold chunk storage is additional rows outside the main slab ranges, but they still use EntityCacheIndex as the column coordinate.

Chunks claim contiguous column ranges sized by EntitiesPerChunk. If one chunk claims [0..255], the next chunk claims the next range. This makes indexing uniform: every field array in every tier uses the same EntityCacheIndex.

Consequence: any operation that relocates a chunk's column range (defragmentation) changes EntityCacheIndex for all entities in that chunk and requires all Views to rehydrate their FieldProxy cursors. See Entity Lifecycle for defrag details.


Partition Layout: Dual-Ended Arenas

The slab is divided into two arenas by EngineConfig. Within each arena, two buckets grow inward from opposite ends.

Arena 1: Renderable  [0 .. MAX_RENDERABLE_ENTITIES)
  RENDER () starts at 0               render-only (particles, decals, ambient props)
  DUAL   () starts at MAX_RENDERABLE  physics + render (players, AI, physics props)

Arena 2: Cached  [MAX_RENDERABLE_ENTITIES .. MAX_CACHED_ENTITIES)
  PHYS  () starts at MAX_RENDERABLE  physics-only (triggers, invisible movers)
  LOGIC () starts at MAX_CACHED      logic/rollback-only entities

Config constraints (validated at startup):

MaxRenderEntities + MaxDualEntities <= MAX_RENDERABLE_ENTITIES
MAX_RENDERABLE_ENTITIES + MaxPhysEntities <= MAX_CACHED_ENTITIES

Why Dual-Ended Arenas

Physics must iterate players + AI + physics props densely. Rendering must iterate particles + decals + players densely. These sets overlap but are not identical.

The dual-ended layout solves this with zero padding overhead:

  • Physics iterates DUAL + PHYS contiguously at the arena boundary — a dense wall with no gap. 100% of its relevant entities, no render-only or logic-only data anywhere in its access range.
  • Render iterates RENDER + DUAL with one gap in Arena 1. The GPU predicate pass handles this at negligible cost (the gap is all-zero in the Active bitplane, so the predicate writes 0 for every slot in the gap).

No gap-skipping branches are needed in the physics loop. The render gap is handled by the GPU, not the CPU.


Entity Group Auto-Derivation

The partition group (Dual/Phys/Render/Logic) is computed automatically at TNX_REGISTER_ENTITY time from the SystemGroup tags on each component. There is no manual annotation on the entity — that would be a footgun that silently puts entities in the wrong partition.

// Component annotations drive automatic entity placement:
TNX_TEMPORAL_FIELDS(CJoltBody,  SystemGroup::Phys,   ...)  // contributes Phys membership
TNX_VOLATILE_FIELDS(CMeshRef,   SystemGroup::Render, ...)  // contributes Render membership
TNX_TEMPORAL_FIELDS(CTransform, SystemGroup::None,   ...)  // partition-agnostic

// Derivation rule:
// Has Phys AND Render → Dual
// Has Phys only       → Phys
// Has Render only     → Render
// Temporal, neither   → Logic

Examples:

Entity TypeComponentsPartition
EInstancedCTransform + CJoltBody + CMeshRef + CColor + CScaleDual
EPlayerCTransform + CVelocity + CMeshRef + CColor + CScaleDual
EPointCTransform onlyPhys

TemporalComponentCache

The temporal and volatile slabs are managed by TemporalComponentCache (base: ComponentCacheBase).

Ring buffer math: frame N for a given field is at: base + (frame % frameCount) * frameStride Cold fields degenerate to base + 0 (frameCount=1, frameStride=0) — no tier branching needed in BuildFieldArrayTable.

Key APIs:

  • GetWriteFramePtr(void*) — returns pointer to field array for the current write frame (T+1)
  • GetReadFramePtr(void*) — returns pointer to field array for the current read frame (T)
  • GetPhysicsRange() — returns [DualStart, PhysEnd) in cache index units for dense physics iteration

Dirty Bit Tracking

Three bits in TemporalFlagBits per entity:

BitNameMeaning
bit 31ActiveEntity is alive and should be processed
bit 30DirtyCumulative upload trigger — cleared when render acknowledges
bit 29DirtiedFramePer-frame flag — cleared unconditionally at frame start

The Dirty bit drives selective GPU upload. The same bit drives rollback blast radius: the dirty set from a correction propagates naturally through update logic to exactly the entities that need recomputation.


Bitplane Gap Skipping

The universal active strip is scanned 64 entities at a time (one 64-bit word):

  • Zero word (64 inactive entities): skipped instantly — no field data touched
  • Mixed word (some active/inactive): FieldProxy uses AVX2 masked loads/stores (8-wide, branchless)

This covers the unused space between RENDER and DUAL buckets in Arena 1 and sparse regions at startup with no measurable overhead.


Memory Sizing

See Configuration for full sizing tables. Quick reference:

Volatile slab = MAX_CACHED_ENTITIES × bytes/entity × 3 frames
Temporal slab = MAX_CACHED_ENTITIES × bytes/entity × TemporalFrameCount

Example (100k entities, 92 B/entity, TemporalFrameCount=8):
  Volatile: 100k × 92 × 3 = ~27.6 MB
  Temporal: 100k × 92 × 8 = ~73.6 MB

Example (TemporalFrameCount=128 for 250ms rollback @ 512Hz):
  Temporal: 100k × 92 × 128 = ~1.18 GB