Static ECS

repository·master·Indexed 41 days ago

https://github.com/felid-force-studios/staticecs

A high-performance C# hierarchical inverted bitmap Entity Component System (ECS) framework designed for large-scale simulations and games. Version 2.2.9 focuses on zero allocations, memory stability, and high cache locality by utilizing a Structure of Arrays (SoA) layout and avoiding archetype migrations. It features a segment-based storage model (Chunk → Segment → Block), static generics to eliminate virtual calls, and a built-in change tracking system for components and tags.

Tokens
85K
Snippets
223
Records
250
Agent score
51%

What's inside Static ECS

  1. Understand Add vs Set semantics

    master

    In v2.0.0, the semantics for adding or modifying components have been clarified:

    MethodBehavior
    Add<T>()Idempotent. If the component exists, it returns a reference and hooks are NOT called. If not, it default-initializes and calls OnAdd.
    Set(value)Always overwrites. If the component exists, it calls OnDelete(old), replaces the value, and calls OnAdd(new). If not, it sets the value and calls OnAdd.
    // Sets the value and triggers OnDelete -> OnAdd
    entity.Set(new Position { Value = Vector3.One }); 
    
    // Does nothing if Position already exists
    entity.Add<Position>();
  2. Use entityType for memory grouping

    master

    Implement the IEntityType interface to group logically similar entities into adjacent memory segments. This improves CPU cache locality by ensuring that entities of the same type (e.g., all 'Units' or all 'Bullets') are co-located in memory, even if they share component types with other groups.

    struct UnitType : IEntityType { }
    struct BulletType : IEntityType { }
    
    // Units are co-located in memory
    var unit = W.NewEntity<UnitType>();
    unit.Add<Position>();
    
    // Bullets are in their own separate segments
    var bullet = W.NewEntity<BulletType>();
    bullet.Add<Position>();
  3. Systems-scoped Resources

    master

    Resources can be scoped to a specific systems pipeline (Systems<TSystemsType>) rather than the entire World. This provides isolation: resources in one pipeline (e.g., FixedSys) are invisible to others (e.g., RenderSys) and to the world-level storage.

    Lifecycle: These resources are cleared when Systems<TSystemsType>.Destroy() is called, independently of World.Destroy().

    Use Case: Use these for state that belongs to a specific system group, such as a fixed-step accumulator for a physics pipeline or a frame buffer for a rendering pipeline.

    Accessing via Handles: Inside a system, you can use Resource<T> or NamedResource<T> handles that are specifically typed to the systems-pipeline scope. These handles access the systems-scoped storage directly.

    public struct FixedSystems : ISystemsType { }
    public abstract class FixedSys : W.Systems<FixedSystems> { }
    public struct FixedTime : IResource { public float Accumulator; public float Step; }
    
    // Scoped to FixedSys
    FixedSys.SetResource(new FixedTime { Step = 1f / 60f });
    ref var time = ref FixedSys.GetResource<FixedTime>();
    
    // Inside a system using handles
    public struct PhysicsSystem : ISystem {
        private FixedSys.Resource<FixedTime> _time;
        private FixedSys.NamedResource<SolverState> _solver = new("solver_a");
    
        public void Update() {
            ref var time = ref _time.Value;          // zero-cost handle, no lookup
            ref var solver = ref _solver.Value;      // dictionary lookup on first access, then cached
        }
    }
  4. How the Hierarchical Inverted Bitmap architecture works

    master

    Static ECS uses a Hierarchical Inverted Bitmap model, which differs significantly from Prototype-based (e.g., Unity DOTS, Flecs) or Sparse Set-based (e.g., EnTT) ECS frameworks.

    Key Architectural Differences

    • No Prototypes: Unlike prototype-based ECS, adding or removing a component does not trigger an entity migration or data copying. Entities occupy fixed slots in segmented arrays and never move in memory. This prevents "prototype explosion" when component combinations grow.
    • No Sparse Sets: Unlike sparse set ECS, component access does not require indirect addressing through sparse tables, avoiding multiple cache misses per lookup.
    • Inverted Indexing: Instead of entities storing component masks, each component type owns its own entity bitmap.

    Memory Hierarchy and Performance

    Memory is organized into a hierarchy to allow for extremely fast queries via bitwise operations:

    1. Blocks: 4096 entities.
    2. Segments: 256 blocks.
    3. Chunks: 64 entities.

    Query Workflow:

    • Queries perform a single bitwise AND operation at the Block level to skip empty areas (covering up to 4096 entities at once).
    • Precise filtering is then performed at the Chunk level (64 entities).
    • Batch Operations: Methods like BatchAdd, BatchRemove, and BatchSetTag can process up to 64 entities in a single bitwise operation.

    Core Benefits

    • O(1) Add/Remove: Adding/removing components is a simple bit-flip on an existence mask.
    • Stable Entity Addresses: Because entities never move, you can use versioned identifiers (EntityGID) to implement low-cost entity relationships, even in streaming/open-world scenarios.
    • SoA Layout: Components of the same type are stored in contiguous arrays (Structure of Arrays), maximizing cache locality.
  5. Choose between QueryMode.Strict and QueryMode.Flexible

    master

    When iterating using For, Search, or Entities, you can specify a QueryMode to control how the engine handles mutations to other entities within the current iteration snapshot.

    QueryMode.Strict (Default)

    • Performance: Fastest mode; uses fast-path for fully-populated blocks.
    • Behavior: Prevents certain mutations on non-current entities that belong to the current iteration snapshot to ensure iterator stability.
    • When to use: Use this by default for maximum performance when your loop does not need to destroy or toggle the status of other entities that are also part of the current query.

    QueryMode.Flexible

    • Performance: Slower; re-reads the cached bitmask per entity.
    • Behavior: Allows Destroy, Disable, or Enable operations on other snapshot entities. These entities are correctly excluded from the remaining iteration via cached bitmask updates.
    • When to use: Use this when your iteration logic must prune child entities, bulk-deactivate entities (e.g., via an AoE effect), or otherwise toggle the status of other entities that match the current query.

    Important: Filter-Type Blockers

    Neither mode allows mutating the components used in the query filter itself on other snapshot entities. Such mutations must be deferred (e.g., by collecting them into a buffer and applying them after the loop).

    // Example of Strict mode error (in DEBUG)
    foreach (var entity in W.Query<All<Position>>().Entities()) {
        anotherEntity.Destroy(); // ERROR in DEBUG if anotherEntity is in the snapshot
    }
    
    // Example of Flexible mode success
    foreach (var entity in W.Query<All<Position>>().EntitiesFlexible()) {
        anotherEntity.Destroy(); // OK — excluded from the rest of the iteration
    }
    
    // Setting mode via parameter in For/Search
    W.Query().For(static (ref Position pos) => {
        // ...
    }, queryMode: QueryMode.Flexible);
  6. Define link types for relations

    master

    To create relations between entities, you must define link types by implementing either ILinkType (for single links) or ILinksType (for multi-links).

    • ILinkType: Used with Link<T>. It allows you to define hooks for automation, such as OnAdd, OnDelete, and CopyTo (for cloning).
    • ILinksType: Used with Links<T>. It inherits from ILinkType and is used for dynamic collections of links.

    Important: Do not provide empty implementations for hooks. If a hook is not needed, omit it entirely to avoid overhead. Unimplemented hooks are not called.

    // Single link type
    public struct Parent : ILinkType {
        public void OnAdd<TW>(World<TW>.Entity self, EntityGID link) where TW : struct, IWorldType {
            // Logic when link is added to 'self'
        }
    }
    
    // Multi-link type
    public struct Children : ILinksType {
        public void OnAdd<TW>(World<TW>.Entity self, EntityGID link) where TW : struct, IWorldType {
            // Logic when a child is added to 'self'
        }
    }
    
    // Type without hooks
    public struct FollowTarget : ILinkType { }
  7. How events work in Static ECS

    master

    Events are a mechanism for exchanging information between systems or user services using a "Sender → multiple receivers" model.

    Key Concepts:

    • Event Representation: An event is a user-defined struct that implements the IEvent marker interface.
    • Lifecycle Management: Each receiver maintains an independent read cursor. An event is automatically deleted when all registered receivers have read it or if it is explicitly suppressed.
    • Automatic Deletion: To prevent memory accumulation, ensure all registered receivers consume their events (via foreach, ReadAll, MarkAsReadAll, or SuppressAll).
    • Registration: Event types must be registered during the Created or Initialized phases of the world.
    public struct WeatherChanged : IEvent {
        public WeatherType WeatherType;
    }
    
    // Registration example
    W.Types()
        .Event<WeatherChanged>();
  8. Configure Tick-Based Tracking Buffer Size

    master

    StaticEcs uses a ring buffer to store tracking history. The size of this buffer is controlled by WorldConfig.TrackingBufferSize (default is 8).

    Choosing a Buffer Size

    The buffer must be large enough to accommodate your slowest system. If a system runs less frequently than the main game loop, it must be able to look back far enough to see all changes accumulated since its last execution.

    Formula: TrackingBufferSize >= tickRate / slowestSystemRate

    Example: If your game runs at 60 FPS but a system runs at 10 FPS (every 6th tick), you need a minimum buffer size of 6.

    Buffer Overflow Behavior

    • Debug Mode (FFS_ECS_DEBUG): A StaticEcsException is thrown if a system's tick range exceeds the buffer size.
    • Release Mode: The range is silently clamped to the available history.
    W.Create(new WorldConfig {
        TrackingBufferSize = 16, // 16 ticks of history
    });
  9. Understand the Segmented Storage Model in StaticEcs 2.0

    master

    In version 2.0, StaticEcs uses a hierarchical storage model to improve cache locality. The hierarchy is organized as follows:

    Chunk (4096 entities) → Segment (256 entities) → Block (64 entities) → Entity

    Segments serve as the fundamental unit for component memory allocation. This structure enables EntityType × Cluster two-dimensional partitioning, ensuring that entities of the same type within the same cluster are stored in adjacent memory segments, which significantly optimizes iteration performance.

  10. Use the Pessimistic Changed Tracking Model

    master

    Static ECS uses a dirty-on-access model for change tracking. Any access via Mut<T>() or ref parameters in query delegates marks the component as Changed, regardless of whether the data was actually modified. This provides high performance by avoiding field-level value comparisons.

    Data Access Methods

    MethodReturnsMarks ChangedMarks AddedNotes
    Ref<T>()ref T——Fast mutable access, no tracking (zero overhead)
    Mut<T>()ref TYes—Tracked mutable access
    Read<T>()ref readonly T——Read-only access
    Add<T>() (new)ref TYesYesComponent is new
    Add<T>() (exists)ref T——Returns ref to existing, no hooks
    Set(value) (new)voidYesYesComponent is new
    Set(value) (exists)voidYes—Overwrites existing

    Query Auto-Tracking Rules

    When iterating through queries, the access semantics determine if a component is marked Changed:

    1. For delegates: ref parameters mark Changed; in parameters do not.
    2. IQuery structs: Write<T> marks Changed; Read<T> does not.
    3. ForBlock: Block<T> (mutable) marks Changed; BlockR<T> (read-only) does not.

    Interaction with Added

    When a component is added via Add<T>() or Set(value), it is marked as BOTH Added and Changed. To target only components that were modified without being newly created, combine AllChanged<T> with NoneAdded<T>.

    // Position is marked as Changed (ref), Velocity is NOT (in)
    W.Query<All<Position, Velocity>>().For(static (ref Position pos, in Velocity vel) => {
        pos.Value += vel.Value;
    });
    
    // Truly changed, not just created
    foreach (var entity in W.Query<All<Position>, AllChanged<Position>, NoneAdded<Position>>().Entities()) {
        // process
    }
  11. Preserve entity links using the GID Store

    master

    To ensure that entity links (Link, Links, or EntityGID in data) remain valid when loading entities into their original slots (entitiesAsNew: false), you must save and restore the GID Store.

    The GID Store tracks all issued identifiers, ensuring that new entities created after restoration do not accidentally occupy the slots reserved for the loaded entities.

    // 1. Save entities and GID Store
    using var writer = W.Serializer.CreateEntitiesSnapshotWriter();
    writer.WriteAllEntities();
    byte[] entitiesSnapshot = writer.CreateSnapshot();
    byte[] gidSnapshot = W.Serializer.CreateGIDStoreSnapshot();
    W.Destroy();
    
    // 2. Restore world with GID Store
    CreateWorld();
    W.Initialize();
    W.Serializer.RestoreFromGIDStoreSnapshot(gidSnapshot);
    
    // New entities won't occupy saved entity slots
    var newEntity = W.NewEntity<Default>();
    
    // 3. Load entities into original slots
    W.Serializer.LoadEntitiesSnapshot(entitiesSnapshot, entitiesAsNew: false);