Filter Queries using Tags
masterAll<>, None<>, and Any<>.repository·master·Indexed 41 days ago
https://github.com/felid-force-studios/staticecsA 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.
All<>, None<>, and Any<>.In v2.0.0, the semantics for adding or modifying components have been clarified:
| Method | Behavior |
|---|---|
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>();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>();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
}
}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.
Memory is organized into a hierarchy to allow for extremely fast queries via bitwise operations:
Query Workflow:
AND operation at the Block level to skip empty areas (covering up to 4096 entities at once).BatchAdd, BatchRemove, and BatchSetTag can process up to 64 entities in a single bitwise operation.EntityGID) to implement low-cost entity relationships, even in streaming/open-world scenarios.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.
Destroy, Disable, or Enable operations on other snapshot entities. These entities are correctly excluded from the remaining iteration via cached bitmask updates.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);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 { }Events are a mechanism for exchanging information between systems or user services using a "Sender → multiple receivers" model.
Key Concepts:
struct that implements the IEvent marker interface.foreach, ReadAll, MarkAsReadAll, or SuppressAll).Created or Initialized phases of the world.public struct WeatherChanged : IEvent {
public WeatherType WeatherType;
}
// Registration example
W.Types()
.Event<WeatherChanged>();StaticEcs uses a ring buffer to store tracking history. The size of this buffer is controlled by WorldConfig.TrackingBufferSize (default is 8).
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.
FFS_ECS_DEBUG): A StaticEcsException is thrown if a system's tick range exceeds the buffer size.W.Create(new WorldConfig {
TrackingBufferSize = 16, // 16 ticks of history
});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.
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.
| Method | Returns | Marks Changed | Marks Added | Notes |
|---|---|---|---|---|
Ref<T>() | ref T | — | — | Fast mutable access, no tracking (zero overhead) |
Mut<T>() | ref T | Yes | — | Tracked mutable access |
Read<T>() | ref readonly T | — | — | Read-only access |
Add<T>() (new) | ref T | Yes | Yes | Component is new |
Add<T>() (exists) | ref T | — | — | Returns ref to existing, no hooks |
Set(value) (new) | void | Yes | Yes | Component is new |
Set(value) (exists) | void | Yes | — | Overwrites existing |
When iterating through queries, the access semantics determine if a component is marked Changed:
ref parameters mark Changed; in parameters do not.Write<T> marks Changed; Read<T> does not.Block<T> (mutable) marks Changed; BlockR<T> (read-only) does not.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
}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);