ecs: third major redesign/rethink of implementation

In the past:

* hexops/mach#156 was the initial ECS implementation detailed in https://devlog.hexops.com/2022/lets-build-ecs-part-1
* hexops/mach#157 was the second major redesign in which we:
    * Eliminated major limitations (e.g. inability to add/remove components at runtime)
    * Investigated sparse sets
    * Began thinking in terms of databases
    * Enabled runtime introspection

Our second revision of the ECS, however, still had _archetypes_ exposed as a public-facing
user concern. When a new component was added to an entity, say a weapon, the table storing
entities of that archetype changed to effectively have a new column `?Weapon` with a null
value for _all existing entities of that archetype_. We can say that our ECS had archetypes
as a user-facing concern AND this made performance worse: when iterating all entities with
a weapon, we needed to check if the component value was `null` or not because every column
was `?Weapon` instead of a guaranteed non-null value like `Weapon`. This was a key learning
that I got from [discussing ECS tradeoffs with the Bevy team](https://github.com/hexops/mach/pull/157#issuecomment-1022916117).

This third revision of our ECS has some big benefits:

* Entities are now just IDs proper, you can add/remove arbitrary components at runtime.
    * You don't have an "entity which always belongs to one archetype table which changes"
    * Rather, you have an "entity of one archetype" and adding a component means that entity _moves_ from one archetype table to another.
    * Archetypes are now an implementation detail, not something you worry about as a consumer of the API.
* Performance
    * We benefit from the fact that we no longer need check if a component on an entity is `null` or not.
* Introspection
    * Previously iterating the component names/values an entity had was not possible, now it is.
* Querying & multi-threading
    * Very very early stages into this, but we now have a general plan for how querying will work and multi-threading.
    * Effectively, it will look much like interfacing with a database: you have a connection (we call it an adapter)
      and you can ask for information through that. More work to be done here.
* Systems, we now have a (very) basic starting point for how systems will work.

Some examples of how the API looks today:

* 979240135b/ecs/src/main.zig (L49)
* 979240135b/ecs/src/entities.zig (L625-L656)

Much more work to do, I will do a blog post detailing this step-by-step first though.

Signed-off-by: Stephen Gutekanst <stephen@hexops.com>
This commit is contained in:
Stephen Gutekanst 2022-02-05 12:48:55 -07:00 committed by Stephen Gutekanst
parent 1428569e66
commit 0ef13eb1cc
3 changed files with 792 additions and 517 deletions

692
ecs/src/entities.zig Normal file
View file

@ -0,0 +1,692 @@
const std = @import("std");
const mem = std.mem;
const Allocator = mem.Allocator;
const testing = std.testing;
const builtin = @import("builtin");
const assert = std.debug.assert;
/// An entity ID uniquely identifies an entity globally within an Entities set.
pub const EntityID = u64;
/// Represents the storage for a single type of component within a single type of entity.
///
/// Database equivalent: a column within a table.
pub fn ComponentStorage(comptime Component: type) type {
return struct {
/// A reference to the total number of entities with the same type as is being stored here.
total_rows: *usize,
/// The actual component data. This starts as empty, and then based on the first call to
/// .set() or .setDense() is initialized as dense storage (an array) or sparse storage (a
/// hashmap.)
///
/// Sparse storage may turn to dense storage if someone later calls .set(), see that method
/// for details.
data: std.ArrayListUnmanaged(Component) = .{},
const Self = @This();
pub fn deinit(storage: *Self, allocator: Allocator) void {
storage.data.deinit(allocator);
}
// If the storage of this component is sparse, it is turned dense as calling this method
// indicates that the caller expects to set this component for most entities rather than
// sparsely.
pub fn set(storage: *Self, allocator: Allocator, row_index: u32, component: Component) !void {
if (storage.data.items.len <= row_index) try storage.data.appendNTimes(allocator, undefined, storage.data.items.len + 1 - row_index);
storage.data.items[row_index] = component;
}
/// Removes the given row index.
pub fn remove(storage: *Self, row_index: u32) void {
if (storage.data.items.len > row_index) {
_ = storage.data.swapRemove(row_index);
}
}
/// Gets the component value for the given entity ID.
pub inline fn get(storage: Self, row_index: u32) Component {
return storage.data.items[row_index];
}
pub inline fn copy(dst: *Self, allocator: Allocator, src_row: u32, dst_row: u32, src: *Self) !void {
try dst.set(allocator, dst_row, src.get(src_row));
}
pub inline fn copySparse(dst: *Self, allocator: Allocator, src_row: u32, dst_row: u32, src: *Self) !void {
// TODO: setSparse!
try dst.set(allocator, dst_row, src.get(src_row));
}
};
}
/// A type-erased representation of ComponentStorage(T) (where T is unknown).
///
/// This is useful as it allows us to store all of the typed ComponentStorage as values in a hashmap
/// despite having different types, and allows us to still deinitialize them without knowing the
/// underlying type.
pub const ErasedComponentStorage = struct {
ptr: *anyopaque,
deinit: fn (erased: *anyopaque, allocator: Allocator) void,
remove: fn (erased: *anyopaque, row: u32) void,
cloneType: fn (erased: ErasedComponentStorage, total_entities: *usize, allocator: Allocator, retval: *ErasedComponentStorage) error{OutOfMemory}!void,
copy: fn (dst_erased: *anyopaque, allocator: Allocator, src_row: u32, dst_row: u32, src_erased: *anyopaque) error{OutOfMemory}!void,
copySparse: fn (dst_erased: *anyopaque, allocator: Allocator, src_row: u32, dst_row: u32, src_erased: *anyopaque) error{OutOfMemory}!void,
pub fn cast(ptr: *anyopaque, comptime Component: type) *ComponentStorage(Component) {
var aligned = @alignCast(@alignOf(*ComponentStorage(Component)), ptr);
return @ptrCast(*ComponentStorage(Component), aligned);
}
};
/// Represents a single archetype, that is, entities which have the same exact set of component
/// types. When a component is added or removed from an entity, it's archetype changes.
///
/// Database equivalent: a table where rows are entities and columns are components (dense storage).
pub const ArchetypeStorage = struct {
allocator: Allocator,
/// The hash of every component name in this archetype, i.e. the name of this archetype.
hash: u64,
/// A mapping of rows in the table to entity IDs.
///
/// Doubles as the counter of total number of rows that have been reserved within this
/// archetype table.
entity_ids: std.ArrayListUnmanaged(EntityID) = .{},
/// A string hashmap of component_name -> type-erased *ComponentStorage(Component)
components: std.StringArrayHashMapUnmanaged(ErasedComponentStorage),
/// Calculates the storage.hash value. This is a hash of all the component names, and can
/// effectively be used to uniquely identify this table within the database.
pub fn calculateHash(storage: *ArchetypeStorage) void {
storage.hash = 0;
var iter = storage.components.iterator();
while (iter.next()) |entry| {
const component_name = entry.key_ptr.*;
storage.hash ^= std.hash_map.hashString(component_name);
}
}
pub fn deinit(storage: *ArchetypeStorage) void {
for (storage.components.values()) |erased| {
erased.deinit(erased.ptr, storage.allocator);
}
storage.entity_ids.deinit(storage.allocator);
storage.components.deinit(storage.allocator);
}
/// New reserves a row for storing an entity within this archetype table.
pub fn new(storage: *ArchetypeStorage, entity: EntityID) !u32 {
// Return a new row index
const new_row_index = storage.entity_ids.items.len;
try storage.entity_ids.append(storage.allocator, entity);
return @intCast(u32, new_row_index);
}
/// Undoes the last call to the new() operation, effectively unreserving the row that was last
/// reserved.
pub fn undoNew(storage: *ArchetypeStorage) void {
_ = storage.entity_ids.pop();
}
/// Sets the value of the named component (column) for the given row in the table. Realizes the
/// deferred allocation of column storage for N entities (storage.counter) if it is not already.
pub fn set(storage: *ArchetypeStorage, row_index: u32, name: []const u8, component: anytype) !void {
var component_storage_erased = storage.components.get(name).?;
var component_storage = ErasedComponentStorage.cast(component_storage_erased.ptr, @TypeOf(component));
try component_storage.set(storage.allocator, row_index, component);
}
/// Removes the specified row. See also the `Entity.delete()` helper.
///
/// This merely marks the row as removed, the same row index will be recycled the next time a
/// new row is requested via `new()`.
pub fn remove(storage: *ArchetypeStorage, row_index: u32) !void {
_ = storage.entity_ids.swapRemove(row_index);
for (storage.components.values()) |component_storage| {
component_storage.remove(component_storage.ptr, row_index);
}
}
/// The number of entities actively stored in this table (not counting entities which are
/// allocated in this table but have been removed)
pub fn count(storage: *ArchetypeStorage) usize {
return storage.entity_ids.items.len;
}
/// Tells if this archetype has every one of the given components.
pub fn hasComponents(storage: *ArchetypeStorage, components: []const []const u8) bool {
for (components) |component_name| {
if (!storage.components.contains(component_name)) return false;
}
return true;
}
};
pub const void_archetype_hash = std.math.maxInt(u64);
/// A database of entities. For example, all player, monster, etc. entities in a game world.
///
/// ```
/// const world = Entities.init(allocator); // all entities in our world.
/// defer world.deinit();
///
/// const player1 = world.new(); // our first "player" entity
/// const player2 = world.new(); // our second "player" entity
/// ```
///
/// Entities are divided into archetypes for optimal, CPU cache efficient storage. For example, all
/// entities with two components `Location` and `Name` are stored in the same table dedicated to
/// densely storing `(Location, Name)` rows in contiguous memory. This not only ensures CPU cache
/// efficiency (leveraging data oriented design) which improves iteration speed over entities for
/// example, but makes queries like "find all entities with a Location component" ridiculously fast
/// because one need only find the tables which have a column for storing Location components and it
/// is then guaranteed every entity in the table has that component (entities do not need to be
/// checked one by one to determine if they have a Location component.)
///
/// Components can be added and removed to entities at runtime as you please:
///
/// ```
/// try player1.set("rotation", Rotation{ .degrees = 90 });
/// try player1.remove("rotation");
/// ```
///
/// When getting a component value, you must know it's type or undefined behavior will occur:
/// TODO: improve this!
///
/// ```
/// if (player1.get("rotation", Rotation)) |rotation| {
/// // player1 had a rotation component!
/// }
/// ```
///
/// When a component is added or removed from an entity, it's archetype is said to change. For
/// example player1 may have had the archetype `(Location, Name)` before, and after adding the
/// rotation component has the archetype `(Location, Name, Rotation)`. It will be automagically
/// "moved" from the table that stores entities with `(Location, Name)` components to the table that
/// stores `(Location, Name, Rotation)` components for you.
///
/// You can have 65,535 archetypes in total, and 4,294,967,295 entities total. Entities which are
/// deleted are merely marked as "unused" and recycled
///
/// Database equivalents:
/// * Entities is a database of tables, where each table represents a single archetype.
/// * ArchetypeStorage is a table, whose rows are entities and columns are components.
/// * EntityID is a mere 32-bit array index, pointing to a 16-bit archetype table index and 32-bit
/// row index, enabling entities to "move" from one archetype table to another seamlessly and
/// making lookup by entity ID a few cheap array indexing operations.
/// * ComponentStorage(T) is a column of data within a table for a single type of component `T`.
pub const Entities = struct {
allocator: Allocator,
/// TODO!
counter: EntityID = 0,
/// A mapping of entity IDs (array indices) to where an entity's component values are actually
/// stored.
entities: std.AutoHashMapUnmanaged(EntityID, Pointer) = .{},
/// A mapping of archetype hash to their storage.
///
/// Database equivalent: table name -> tables representing entities.
archetypes: std.AutoArrayHashMapUnmanaged(u64, ArchetypeStorage) = .{},
/// Points to where an entity is stored, specifically in which archetype table and in which row
/// of that table. That is, the entity's component values are stored at:
///
/// ```
/// Entities.archetypes[ptr.archetype_index].rows[ptr.row_index]
/// ```
///
pub const Pointer = struct {
archetype_index: u16,
row_index: u32,
};
pub const Iterator = struct {
entities: *Entities,
components: []const []const u8,
archetype_index: usize = 0,
row_index: usize = 0,
pub const Entry = struct {
entity: EntityID,
pub fn unlock(e: Entry) void {
_ = e;
}
};
pub fn next(iter: *Iterator) ?Entry {
const entities = iter.entities;
// If the archetype table we're looking at does not contain the components we're
// querying for, keep searching through tables until we find one that does.
var archetype = entities.archetypes.entries.get(iter.archetype_index).value;
while (!archetype.hasComponents(iter.components) or iter.row_index >= archetype.count()) {
iter.archetype_index += 1;
iter.row_index = 0;
if (iter.archetype_index >= entities.archetypes.count()) {
return null;
}
archetype = entities.archetypes.entries.get(iter.archetype_index).value;
}
const row_entity_id = archetype.entity_ids.items[iter.row_index];
iter.row_index += 1;
return Entry{ .entity = row_entity_id };
}
};
pub fn query(entities: *Entities, components: []const []const u8) Iterator {
return Iterator{
.entities = entities,
.components = components,
};
}
pub fn init(allocator: Allocator) !Entities {
var entities = Entities{ .allocator = allocator };
try entities.archetypes.put(allocator, void_archetype_hash, ArchetypeStorage{
.allocator = allocator,
.components = .{},
.hash = void_archetype_hash,
});
return entities;
}
pub fn deinit(entities: *Entities) void {
entities.entities.deinit(entities.allocator);
var iter = entities.archetypes.iterator();
while (iter.next()) |entry| {
entry.value_ptr.deinit();
}
entities.archetypes.deinit(entities.allocator);
}
/// Returns a new entity.
pub fn new(entities: *Entities) !EntityID {
const new_id = entities.counter;
entities.counter += 1;
var void_archetype = entities.archetypes.getPtr(void_archetype_hash).?;
const new_row = try void_archetype.new(new_id);
const void_pointer = Pointer{
.archetype_index = 0, // void archetype is guaranteed to be first index
.row_index = new_row,
};
entities.entities.put(entities.allocator, new_id, void_pointer) catch |err| {
void_archetype.undoNew();
return err;
};
return new_id;
}
/// Removes an entity.
pub fn remove(entities: *Entities, entity: EntityID) !void {
var archetype = entities.archetypeByID(entity);
const ptr = entities.entities.get(entity).?;
// A swap removal will be performed, update the entity stored in the last row of the
// archetype table to point to the row the entity we are removing is currently located.
const last_row_entity_id = archetype.entity_ids.items[archetype.entity_ids.items.len - 1];
try entities.entities.put(entities.allocator, last_row_entity_id, Pointer{
.archetype_index = ptr.archetype_index,
.row_index = ptr.row_index,
});
// Perform a swap removal to remove our entity from the archetype table.
try archetype.remove(ptr.row_index);
_ = entities.entities.remove(entity);
}
/// Returns the archetype storage for the given entity.
pub inline fn archetypeByID(entities: *Entities, entity: EntityID) *ArchetypeStorage {
const ptr = entities.entities.get(entity).?;
return &entities.archetypes.values()[ptr.archetype_index];
}
/// Sets the named component to the specified value for the given entity,
/// moving the entity from it's current archetype table to the new archetype
/// table if required.
pub fn setComponent(entities: *Entities, entity: EntityID, name: []const u8, component: anytype) !void {
var archetype = entities.archetypeByID(entity);
// Determine the old hash for the archetype.
const old_hash = archetype.hash;
// Determine the new hash for the archetype + new component
var have_already = archetype.components.contains(name);
const new_hash = if (have_already) old_hash else old_hash ^ std.hash_map.hashString(name);
// Find the archetype storage for this entity. Could be a new archetype storage table (if a
// new component was added), or the same archetype storage table (if just updating the
// value of a component.)
var archetype_entry = try entities.archetypes.getOrPut(entities.allocator, new_hash);
if (!archetype_entry.found_existing) {
archetype_entry.value_ptr.* = ArchetypeStorage{
.allocator = entities.allocator,
.components = .{},
.hash = 0,
};
var new_archetype = archetype_entry.value_ptr;
// Create storage/columns for all of the existing components on the entity.
var column_iter = archetype.components.iterator();
while (column_iter.next()) |entry| {
var erased: ErasedComponentStorage = undefined;
entry.value_ptr.cloneType(entry.value_ptr.*, &new_archetype.entity_ids.items.len, entities.allocator, &erased) catch |err| {
assert(entities.archetypes.swapRemove(new_hash));
return err;
};
new_archetype.components.put(entities.allocator, entry.key_ptr.*, erased) catch |err| {
assert(entities.archetypes.swapRemove(new_hash));
return err;
};
}
// Create storage/column for the new component.
const erased = entities.initErasedStorage(&new_archetype.entity_ids.items.len, @TypeOf(component)) catch |err| {
assert(entities.archetypes.swapRemove(new_hash));
return err;
};
new_archetype.components.put(entities.allocator, name, erased) catch |err| {
assert(entities.archetypes.swapRemove(new_hash));
return err;
};
new_archetype.calculateHash();
}
// Either new storage (if the entity moved between storage tables due to having a new
// component) or the prior storage (if the entity already had the component and it's value
// is merely being updated.)
var current_archetype_storage = archetype_entry.value_ptr;
if (new_hash == old_hash) {
// Update the value of the existing component of the entity.
const ptr = entities.entities.get(entity).?;
try current_archetype_storage.set(ptr.row_index, name, component);
return;
}
// Copy to all component values for our entity from the old archetype storage
// (archetype) to the new one (current_archetype_storage).
const new_row = try current_archetype_storage.new(entity);
const old_ptr = entities.entities.get(entity).?;
// Update the storage/columns for all of the existing components on the entity.
var column_iter = archetype.components.iterator();
while (column_iter.next()) |entry| {
var old_component_storage = entry.value_ptr;
var new_component_storage = current_archetype_storage.components.get(entry.key_ptr.*).?;
new_component_storage.copy(new_component_storage.ptr, entities.allocator, new_row, old_ptr.row_index, old_component_storage.ptr) catch |err| {
current_archetype_storage.undoNew();
return err;
};
}
current_archetype_storage.entity_ids.items[new_row] = entity;
// Update the storage/column for the new component.
current_archetype_storage.set(new_row, name, component) catch |err| {
current_archetype_storage.undoNew();
return err;
};
var swapped_entity_id = archetype.entity_ids.items[archetype.entity_ids.items.len - 1];
archetype.remove(old_ptr.row_index) catch |err| {
current_archetype_storage.undoNew();
return err;
};
try entities.entities.put(entities.allocator, swapped_entity_id, old_ptr);
try entities.entities.put(entities.allocator, entity, Pointer{
.archetype_index = @intCast(u16, archetype_entry.index),
.row_index = new_row,
});
return;
}
/// gets the named component of the given type (which must be correct, otherwise undefined
/// behavior will occur). Returns null if the component does not exist on the entity.
pub fn getComponent(entities: *Entities, entity: EntityID, name: []const u8, comptime Component: type) ?Component {
var archetype = entities.archetypeByID(entity);
var component_storage_erased = archetype.components.get(name) orelse return null;
const ptr = entities.entities.get(entity).?;
var component_storage = ErasedComponentStorage.cast(component_storage_erased.ptr, Component);
return component_storage.get(ptr.row_index);
}
/// Removes the named component from the entity, or noop if it doesn't have such a component.
pub fn removeComponent(entities: *Entities, entity: EntityID, name: []const u8) !void {
var archetype = entities.archetypeByID(entity);
if (!archetype.components.contains(name)) return;
// Determine the old hash for the archetype.
const old_hash = archetype.hash;
// Determine the new hash for the archetype with the component removed
var new_hash: u64 = 0;
var iter = archetype.components.iterator();
while (iter.next()) |entry| {
const component_name = entry.key_ptr.*;
if (!std.mem.eql(u8, component_name, name)) new_hash ^= std.hash_map.hashString(component_name);
}
assert(new_hash != old_hash);
// Find the archetype storage for this entity. Could be a new archetype storage table (if a
// new component was added), or the same archetype storage table (if just updating the
// value of a component.)
var archetype_entry = try entities.archetypes.getOrPut(entities.allocator, new_hash);
if (!archetype_entry.found_existing) {
archetype_entry.value_ptr.* = ArchetypeStorage{
.allocator = entities.allocator,
.components = .{},
.hash = 0,
};
var new_archetype = archetype_entry.value_ptr;
// Create storage/columns for all of the existing components on the entity.
var column_iter = archetype.components.iterator();
while (column_iter.next()) |entry| {
if (std.mem.eql(u8, entry.key_ptr.*, name)) continue;
var erased: ErasedComponentStorage = undefined;
entry.value_ptr.cloneType(entry.value_ptr.*, &new_archetype.entity_ids.items.len, entities.allocator, &erased) catch |err| {
assert(entities.archetypes.swapRemove(new_hash));
return err;
};
new_archetype.components.put(entities.allocator, entry.key_ptr.*, erased) catch |err| {
assert(entities.archetypes.swapRemove(new_hash));
return err;
};
}
new_archetype.calculateHash();
}
// Either new storage (if the entity moved between storage tables due to having a new
// component) or the prior storage (if the entity already had the component and it's value
// is merely being updated.)
var current_archetype_storage = archetype_entry.value_ptr;
// Copy to all component values for our entity from the old archetype storage
// (archetype) to the new one (current_archetype_storage).
const new_row = try current_archetype_storage.new(entity);
const old_ptr = entities.entities.get(entity).?;
// Update the storage/columns for all of the existing components on the entity.
var column_iter = current_archetype_storage.components.iterator();
while (column_iter.next()) |entry| {
var src_component_storage = archetype.components.get(entry.key_ptr.*).?;
var dst_component_storage = entry.value_ptr;
dst_component_storage.copy(dst_component_storage.ptr, entities.allocator, new_row, old_ptr.row_index, src_component_storage.ptr) catch |err| {
current_archetype_storage.undoNew();
return err;
};
}
current_archetype_storage.entity_ids.items[new_row] = entity;
var swapped_entity_id = archetype.entity_ids.items[archetype.entity_ids.items.len - 1];
archetype.remove(old_ptr.row_index) catch |err| {
current_archetype_storage.undoNew();
return err;
};
try entities.entities.put(entities.allocator, swapped_entity_id, old_ptr);
try entities.entities.put(entities.allocator, entity, Pointer{
.archetype_index = @intCast(u16, archetype_entry.index),
.row_index = new_row,
});
return;
}
// TODO: iteration over all entities
// TODO: iteration over all entities with components (U, V, ...)
// TODO: iteration over all entities with type T
// TODO: iteration over all entities with type T and components (U, V, ...)
// TODO: "indexes" - a few ideas we could express:
//
// * Graph relations index: e.g. parent-child entity relations for a DOM / UI / scene graph.
// * Spatial index: "give me all entities within 5 units distance from (x, y, z)"
// * Generic index: "give me all entities where arbitraryFunction(e) returns true"
//
pub fn initErasedStorage(entities: *const Entities, total_rows: *usize, comptime Component: type) !ErasedComponentStorage {
var new_ptr = try entities.allocator.create(ComponentStorage(Component));
new_ptr.* = ComponentStorage(Component){ .total_rows = total_rows };
return ErasedComponentStorage{
.ptr = new_ptr,
.deinit = (struct {
pub fn deinit(erased: *anyopaque, allocator: Allocator) void {
var ptr = ErasedComponentStorage.cast(erased, Component);
ptr.deinit(allocator);
allocator.destroy(ptr);
}
}).deinit,
.remove = (struct {
pub fn remove(erased: *anyopaque, row: u32) void {
var ptr = ErasedComponentStorage.cast(erased, Component);
ptr.remove(row);
}
}).remove,
.cloneType = (struct {
pub fn cloneType(erased: ErasedComponentStorage, _total_rows: *usize, allocator: Allocator, retval: *ErasedComponentStorage) !void {
var new_clone = try allocator.create(ComponentStorage(Component));
new_clone.* = ComponentStorage(Component){ .total_rows = _total_rows };
var tmp = erased;
tmp.ptr = new_clone;
retval.* = tmp;
}
}).cloneType,
.copy = (struct {
pub fn copy(dst_erased: *anyopaque, allocator: Allocator, src_row: u32, dst_row: u32, src_erased: *anyopaque) !void {
var dst = ErasedComponentStorage.cast(dst_erased, Component);
var src = ErasedComponentStorage.cast(src_erased, Component);
return dst.copy(allocator, src_row, dst_row, src);
}
}).copy,
.copySparse = (struct {
pub fn copySparse(dst_erased: *anyopaque, allocator: Allocator, src_row: u32, dst_row: u32, src_erased: *anyopaque) !void {
var dst = ErasedComponentStorage.cast(dst_erased, Component);
var src = ErasedComponentStorage.cast(src_erased, Component);
return dst.copySparse(allocator, src_row, dst_row, src);
}
}).copySparse,
};
}
// TODO: ability to remove archetype entirely, deleting all entities in it
// TODO: ability to remove archetypes with no entities (garbage collection)
};
test "entity ID size" {
try testing.expectEqual(8, @sizeOf(EntityID));
}
test "example" {
const allocator = testing.allocator;
//-------------------------------------------------------------------------
// Create a world.
var world = try Entities.init(allocator);
defer world.deinit();
//-------------------------------------------------------------------------
// Define component types, any Zig type will do!
// A location component.
const Location = struct {
x: f32 = 0,
y: f32 = 0,
z: f32 = 0,
};
//-------------------------------------------------------------------------
// Create first player entity.
var player1 = try world.new();
try world.setComponent(player1, "name", "jane"); // add Name component
try world.setComponent(player1, "location", Location{}); // add Location component
// Create second player entity.
var player2 = try world.new();
try testing.expect(world.getComponent(player2, "location", Location) == null);
try testing.expect(world.getComponent(player2, "name", []const u8) == null);
//-------------------------------------------------------------------------
// We can add new components at will.
const Rotation = struct { degrees: f32 };
try world.setComponent(player2, "rotation", Rotation{ .degrees = 90 });
try testing.expect(world.getComponent(player1, "rotation", Rotation) == null); // player1 has no rotation
//-------------------------------------------------------------------------
// Remove a component from any entity at will.
// TODO: add a way to "cleanup" truly unused archetypes
try world.removeComponent(player1, "name");
try world.removeComponent(player1, "location");
try world.removeComponent(player1, "location"); // doesn't exist? no problem.
//-------------------------------------------------------------------------
// Introspect things.
//
// Archetype IDs, these are our "table names" - they're just hashes of all the component names
// within the archetype table.
var archetypes = world.archetypes.keys();
try testing.expectEqual(@as(usize, 6), archetypes.len);
try testing.expectEqual(@as(u64, 18446744073709551615), archetypes[0]);
try testing.expectEqual(@as(u64, 6893717443977936573), archetypes[1]);
try testing.expectEqual(@as(u64, 7008573051677164842), archetypes[2]);
try testing.expectEqual(@as(u64, 14420739110802803032), archetypes[3]);
try testing.expectEqual(@as(u64, 13913849663823266920), archetypes[4]);
try testing.expectEqual(@as(u64, 0), archetypes[5]);
// Number of (living) entities stored in an archetype table.
try testing.expectEqual(@as(usize, 0), world.archetypes.get(archetypes[2]).?.count());
// Component names for a given archetype.
var component_names = world.archetypes.get(archetypes[2]).?.components.keys();
try testing.expectEqual(@as(usize, 2), component_names.len);
try testing.expectEqualStrings("name", component_names[0]);
try testing.expectEqualStrings("location", component_names[1]);
// Component names for a given entity
var player2_archetype = world.archetypeByID(player2);
component_names = player2_archetype.components.keys();
try testing.expectEqual(@as(usize, 1), component_names.len);
try testing.expectEqualStrings("rotation", component_names[0]);
// TODO: iterating components an entity has not currently supported.
//-------------------------------------------------------------------------
// Remove an entity whenever you wish. Just be sure not to try and use it later!
try world.remove(player1);
}

View file

@ -21,540 +21,68 @@
//! optimization. This is a novel and fundamentally different process than what is described in
//! Unity Software Inc's patent US 10,599,560. This is not legal advice.
//!
const std = @import("std");
const mem = std.mem;
const Allocator = mem.Allocator;
const testing = std.testing;
const builtin = @import("builtin");
const assert = std.debug.assert;
/// An entity ID uniquely identifies an entity globally within an Entities set.
///
/// It stores the type of entity, as well the index of the entity within EntityTypeStorage, in only
/// 48 bits.
///
/// Database equivalent: a row within a table
pub const EntityID = packed struct {
/// Entity type ("table ID")
type_id: u16,
const EntityID = @import("entities.zig").EntityID;
const Entities = @import("entities.zig").Entities;
/// Entity ID ("row index")
id: u32,
};
const Adapter = @import("systems.zig").Adapter;
const System = @import("systems.zig").System;
const World = @import("systems.zig").World;
/// Entity is a thin wrapper over an entity ID that makes interacting with a specific entity nicer.
///
/// Database equivalent: a row within a table
pub const Entity = struct {
/// The ID of the entity.
id: EntityID,
// TODO:
// * Iteration
// * Querying
// * Multi threading
// * Multiple entities having one value
// * Sparse storage?
/// The entity type corresponding to id.type_id. You can look this up using Entities.byID()
///
/// Database equivalent: table of entities
entity_type: *EntityTypeStorage,
/// Adds or updates a component for this entity.
///
/// Optimized for *most* entities (of this type) having this type of component. If only a few
/// entities will have it, use `.setSparse()` instead.
pub inline fn set(entity: Entity, name: []const u8, component: anytype) !void {
var entity_type = entity.entity_type;
var storage = try entity_type.get(name, @TypeOf(component));
try storage.set(entity_type.allocator, entity.id.id, component);
test "inclusion" {
_ = Entities;
}
/// Adds or updates a component for this entity.
///
/// Optimized for *few* entities (of this type) having this type of component. If most entities
/// will have it, use `.set()` instead.
pub inline fn setSparse(entity: Entity, component_name: []const u8, component: anytype) !void {
var entity_type = entity.entity_type;
var storage = try entity_type.get(component_name, @TypeOf(component));
try storage.setSparse(entity_type.allocator, entity.id.id, component);
}
/// Gets a component for this entity, returns null if that component is not set on this entity.
pub inline fn get(entity: Entity, component_name: []const u8, comptime Component: anytype) ?Component {
var entity_type = entity.entity_type;
var storage = entity_type.getIfExists(component_name, Component) orelse return null;
return storage.get(entity.id.id);
}
/// Removes the given component from this entity, returning a boolean indicating if it did
/// exist on the entity.
pub inline fn remove(entity: Entity, component_name: []const u8) bool {
var entity_type = entity.entity_type;
var storage = entity_type.getErasedIfExists(component_name) orelse return false;
return storage.remove(storage.ptr, entity.id.id);
}
// Deletes this entity.
pub inline fn delete(entity: Entity) !void {
var entity_type = entity.entity_type;
try entity_type.delete(entity.id);
}
// TODO: iterator over all components for the entity
};
/// Represents the storage for a single type of component within a single type of entity.
///
/// Database equivalent: a column within a table.
pub fn ComponentStorage(comptime Component: type) type {
return struct {
/// A reference to the total number of entities with the same type as is being stored here.
total_entities: *u32,
/// The actual component data. This starts as empty, and then based on the first call to
/// .set() or .setDense() is initialized as dense storage (an array) or sparse storage (a
/// hashmap.)
///
/// Sparse storage may turn to dense storage if someone later calls .set(), see that method
/// for details.
data: union(StorageType) {
empty: void,
dense: std.ArrayListUnmanaged(?Component),
sparse: std.AutoArrayHashMapUnmanaged(u32, Component),
} = .{ .empty = {} },
pub const StorageType = enum {
empty,
dense,
sparse,
};
const Self = @This();
pub fn deinit(storage: *Self, allocator: Allocator) void {
switch (storage.data) {
.empty => {},
.dense => storage.data.dense.deinit(allocator),
.sparse => storage.data.sparse.deinit(allocator),
}
}
// If the storage of this component is sparse, it is turned dense as calling this method
// indicates that the caller expects to set this component for most entities rather than
// sparsely.
pub fn set(storage: *Self, allocator: Allocator, row: u32, component: ?Component) !void {
switch (storage.data) {
.empty => if (component) |c| {
var new_dense = std.ArrayListUnmanaged(?Component){};
try new_dense.ensureTotalCapacityPrecise(allocator, storage.total_entities.*);
try new_dense.appendNTimes(allocator, null, storage.total_entities.*);
new_dense.items[row] = c;
storage.data = .{ .dense = new_dense };
} else return,
.dense => |dense| {
if (dense.items.len >= row) try storage.data.dense.appendNTimes(allocator, null, dense.items.len + 1 - row);
dense.items[row] = component;
},
.sparse => |sparse| {
// Turn sparse storage into dense storage.
defer storage.data.sparse.deinit(allocator);
var new_dense = std.ArrayListUnmanaged(?Component){};
try new_dense.ensureTotalCapacityPrecise(allocator, storage.total_entities.*);
var i: u32 = 0;
while (i < storage.total_entities.*) : (i += 1) {
new_dense.appendAssumeCapacity(sparse.get(i));
}
new_dense.items[row] = component;
storage.data = .{ .dense = new_dense };
},
}
}
// If the storage of this component is dense, it remains dense.
pub fn setSparse(storage: *Self, allocator: Allocator, row: u32, component: ?Component) !void {
switch (storage.data) {
.empty => if (component) |c| {
var new_sparse = std.AutoArrayHashMapUnmanaged(u32, Component){};
try new_sparse.put(allocator, row, c);
storage.data = .{ .sparse = new_sparse };
} else return,
.dense => |dense| {
if (dense.items.len >= row) try storage.data.dense.appendNTimes(allocator, null, dense.items.len + 1 - row);
dense.items[row] = component;
},
.sparse => if (component) |c| try storage.data.sparse.put(allocator, row, c) else {
_ = storage.data.sparse.swapRemove(row);
},
}
}
/// Removes the given entity ID.
pub fn remove(storage: *Self, row: u32) bool {
return switch (storage.data) {
.empty => false,
.dense => |dense| if (dense.items.len > row and dense.items[row] != null) {
dense.items[row] = null;
return true;
} else false,
.sparse => storage.data.sparse.swapRemove(row),
};
}
/// Gets the component value for the given entity ID.
pub inline fn get(storage: Self, row: u32) ?Component {
return switch (storage.data) {
.empty => null,
.dense => |dense| if (dense.items.len > row) dense.items[row] else null,
.sparse => |sparse| sparse.get(row),
};
}
};
}
/// A type-erased representation of ComponentStorage(T) (where T is unknown).
///
/// This is useful as it allows us to store all of the typed ComponentStorage as values in a hashmap
/// despite having different types, and allows us to still deinitialize them without knowing the
/// underlying type.
pub const ErasedComponentStorage = struct {
ptr: *anyopaque,
deinit: fn (erased: *anyopaque, allocator: Allocator) void,
remove: fn (erased: *anyopaque, row: u32) bool,
pub fn cast(ptr: *anyopaque, comptime Component: type) *ComponentStorage(Component) {
var aligned = @alignCast(@alignOf(*ComponentStorage(Component)), ptr);
return @ptrCast(*ComponentStorage(Component), aligned);
}
};
/// Represents a single type of entity, e.g. a player, monster, or some other arbitrary entity type.
///
/// See the `Entities` documentation for more information about entity types and how they enable
/// performance.
///
/// Database equivalent: a table where rows are entities and columns are components (dense storage)
/// or a secondary table with entity ID -> component value relations (sparse storage.)
pub const EntityTypeStorage = struct {
allocator: Allocator,
/// This entity type storage identifier. This is used to uniquely identify this entity type
/// within the global set of Entities, and is identical to the EntityID.type_id value.
id: u16,
/// The number of entities that have been allocated within this entity type. This is identical
/// to the EntityID.id value.
count: u32 = 0,
/// A string hashmap of component_name -> type-erased *ComponentStorage(Component)
components: std.StringArrayHashMapUnmanaged(ErasedComponentStorage) = .{},
/// Free entity slots. When an entity is deleted, it is added to this map and recycled the next
/// time a new entity is requested.
free_slots: std.AutoArrayHashMapUnmanaged(u32, void) = .{},
pub fn init(allocator: Allocator, type_id: u16) EntityTypeStorage {
return .{
.allocator = allocator,
.id = type_id,
};
}
pub fn deinit(storage: *EntityTypeStorage) void {
for (storage.components.values()) |erased| {
erased.deinit(erased.ptr, storage.allocator);
}
storage.components.deinit(storage.allocator);
storage.free_slots.deinit(storage.allocator);
}
/// Creates a new entity of this type.
pub fn new(storage: *EntityTypeStorage) !Entity {
return Entity{
.id = try storage.newID(),
.entity_type = storage,
};
}
// TODO: bulk allocation of entities
/// Creates a new entity of this type.
pub fn newID(storage: *EntityTypeStorage) !EntityID {
// If there is a previously deleted entity, recycle it's ID.
// TODO: add some "debug" mode which catches use-after-delete of entities (could be super
// confusing if one system deletes it and another creates it and you don't notice!)
const free_slot = storage.free_slots.popOrNull();
if (free_slot) |recycled| return EntityID{ .type_id = storage.id, .id = recycled.key };
// Create a new entity ID and space to store it in each component array.
const new_id = storage.count;
storage.count += 1;
return EntityID{ .type_id = storage.id, .id = new_id };
}
/// Deletes the specified entity. See also the `Entity.delete()` helper.
///
/// This merely marks the entity as deleted, the same ID will be recycled the next time a new
/// entity is created.
pub fn delete(storage: *EntityTypeStorage, id: EntityID) !void {
assert(id.type_id == storage.id);
try storage.free_slots.put(storage.allocator, id.id, .{});
}
/// Returns the component storage for the given component. Creates storage for this type of
/// component if it does not exist.
///
/// Note: This is a low-level API, you probably want to use `Entity.get()` instead.
pub fn get(storage: *EntityTypeStorage, component_name: []const u8, comptime Component: type) !*ComponentStorage(Component) {
var v = try storage.components.getOrPut(storage.allocator, component_name);
if (!v.found_existing) {
var new_ptr = try storage.allocator.create(ComponentStorage(Component));
new_ptr.* = ComponentStorage(Component){
.total_entities = &storage.count,
};
v.value_ptr.* = ErasedComponentStorage{
.ptr = new_ptr,
.deinit = (struct {
pub fn deinit(erased: *anyopaque, allocator: Allocator) void {
var ptr = ErasedComponentStorage.cast(erased, Component);
ptr.deinit(allocator);
allocator.destroy(ptr);
}
}).deinit,
.remove = (struct {
pub fn remove(erased: *anyopaque, row: u32) bool {
var ptr = ErasedComponentStorage.cast(erased, Component);
return ptr.remove(row);
}
}).remove,
};
}
return ErasedComponentStorage.cast(v.value_ptr.ptr, Component);
}
/// Returns the component storage for the given component, returning null if it does not exist.
///
/// Note: This is a low-level API, you probably want to use `Entity.get()` instead.
pub fn getIfExists(storage: *EntityTypeStorage, component_name: []const u8, comptime Component: type) ?*ComponentStorage(Component) {
var v = storage.components.get(component_name);
if (v == null) return null;
return ErasedComponentStorage.cast(v.?.ptr, Component);
}
/// Returns the type-erased component storage for the given component, returning null if it does
/// not exist.
///
/// Note: This is a low-level API, you probably want to use `Entity.get()` instead.
pub inline fn getErasedIfExists(storage: *EntityTypeStorage, component_name: []const u8) ?ErasedComponentStorage {
return storage.components.get(component_name);
}
};
/// A database of entities. For example, all player, monster, etc. entities in a game world.
///
/// Entities are divided into "entity types", arbitrary named groups of entities that are likely to
/// have the same components. If you are used to archetypes from other ECS systems, know that these
/// are NOT the same as archetypes: you can add or remove components from an entity type at will
/// without getting a new type of entity. You can get an entity type using e.g.:
///
/// ```
/// const world = Entities.init(allocator); // all entities in our world
/// const players = world.get("player"); // the player entities
///
/// const player1 = players.new(); // a new entity of type "player"
/// const player2 = players.new(); // a new entity of type "player"
/// ```
///
/// Storage is optimized around the idea that all entities of the same type *generally* have the
/// same type of components. Storing entities by type also enables quickly iterating over all
/// entities with some logical type without any sorting needed (e.g. iterating over all "player"
/// entities but not "monster" entities.) This also reduces the search area for more complex queries
/// and makes filtering entities by e.g. "all entities with a Renderer component" more efficient
/// as we just *know* that if player1 has that component, then player2 almost certainly does too.
///
/// You can have 65,535 entity types in total.
///
/// Although storage is *generally* optimized for all entities within a given type having the same
/// components, you may set/remove components on an entity at will via e.g. `player1.set(component)`
/// and `player1.remove(Component)`. `player1` and `player2` may not both have a Renderer component,
/// for example.
///
/// If you use `player1.set(myRenderer);` then dense storage will be used: we will optimize for
/// *every* entity of type "player" having a Renderer component. In this case, every "player" entity
/// will pay the cost of storing a Renderer component even if they do not have one.
///
/// If you use `player1.setSparse(myRenderer);` then sparse storage will be used: we will optimize
/// for *most* entities of type "player" not having a Renderer component. In this case, only the
/// "player" entities which have a Renderer component pay a storage cost. If most entities have a
/// Renderer component, this would be the wrong type of storage and less efficient.
///
/// Database equivalents:
/// * Entities is a database of tables, where each table represents a type of entity.
/// * EntityTypeStorage is a table, whose rows are entities.
/// * EntityID is a 32-bit row ID and a 16-bit table ID, and so globally unique.
/// * ComponentStorage(T) is a column of data in a table for a specific component type
/// * Densely stored as an array of component values.
/// * Sparsely stored as a map of (row ID -> component value).
pub const Entities = struct {
allocator: Allocator,
/// A mapping of entity type names to their storage.
///
/// Database equivalent: table name -> tables representing entities.
types: std.StringArrayHashMapUnmanaged(EntityTypeStorage),
pub fn init(allocator: Allocator) Entities {
return .{
.allocator = allocator,
.types = std.StringArrayHashMapUnmanaged(EntityTypeStorage){},
};
}
pub fn deinit(entities: *Entities) void {
var iter = entities.types.iterator();
while (iter.next()) |entry| {
entry.value_ptr.deinit();
}
entities.types.deinit(entities.allocator);
}
// TODO: iteration over all entities
// TODO: iteration over all entities with components (U, V, ...)
// TODO: iteration over all entities with type T
// TODO: iteration over all entities with type T and components (U, V, ...)
// TODO: "indexes" - a few ideas we could express either within a single entity type or across
// all entities:
//
// * Graph relations index: e.g. parent-child entity relations for a DOM / UI / scene graph.
// * Spatial index: "give me all entities within 5 units distance from (x, y, z)"
// * Generic index: "give me all entities where arbitraryFunction(e) returns true"
//
/// Returns a nice helper for interfacing with the specified entity.
///
/// This is a mere O(1) array access and so is very cheap.
pub inline fn byID(entities: *const Entities, id: EntityID) Entity {
return .{
.id = id,
// TODO: entity type lookup `entities.types.entries.get(id.type_id).value`
// would not give us a pointer to the entry, which is required. I am 99% sure we can do this
// in O(1) time, but MultiArrayList (`entries`) doesn't currently expose a getPtr method.
//
// For now this is actually not O(1), but still very fast.
.entity_type = entities.types.getPtr(entities.typeName(id)).?,
};
}
/// Returns the entity type name of the entity given its ID.
///
/// This is a mere O(1) array access and so is very cheap.
pub inline fn typeName(entities: *const Entities, id: EntityID) []const u8 {
return entities.types.entries.get(id.type_id).key;
}
// Returns the storage for the given entity type name, creating it if necessary.
// TODO: copy name?
pub fn get(entities: *Entities, entity_type_name: []const u8) !*EntityTypeStorage {
const num_types = entities.types.count();
var v = try entities.types.getOrPut(entities.allocator, entity_type_name);
if (!v.found_existing) {
v.value_ptr.* = EntityTypeStorage.init(entities.allocator, @intCast(u16, num_types));
}
return v.value_ptr;
}
// TODO: ability to remove entity type entirely, deleting all entities in it
// TODO: ability to remove entity types with no entities (garbage collect)
};
test "example" {
const allocator = testing.allocator;
//-------------------------------------------------------------------------
// Create a world.
var world = Entities.init(allocator);
var world = try World.init(allocator);
defer world.deinit();
//-------------------------------------------------------------------------
// Define component types, any Zig type will do!
// A location component.
const Location = struct {
x: f32 = 0,
y: f32 = 0,
z: f32 = 0,
};
const player1 = try world.entities.new();
const player2 = try world.entities.new();
const player3 = try world.entities.new();
try world.entities.setComponent(player1, "physics", @as(u16, 1234));
try world.entities.setComponent(player1, "geometry", @as(u16, 1234));
// A name component.
const Name = []const u8;
try world.entities.setComponent(player2, "physics", @as(u16, 1234));
try world.entities.setComponent(player3, "physics", @as(u16, 1234));
//-------------------------------------------------------------------------
// Create a player entity type. Every entity with the same type ("player" here)
// will pay to store the same set of components, whether they use them or not.
var players = try world.get("player");
// Create first player entity.
var player1 = try players.new();
try player1.set("name", @as(Name, "jane")); // add Name component
try player1.set("location", Location{}); // add Location component
// Create second player entity. Note that it pays the cost of storing a Name and Location
// component regardless of whether or not we use it: all entities in the same type ("players")
// pays to store the same set of components.
var player2 = try players.new();
try testing.expect(player2.get("location", Location) == null);
try testing.expect(player2.get("name", Name) == null);
//-------------------------------------------------------------------------
// We can add new components at will. Now every player entity will pay to store a Rotation
// component.
const Rotation = struct { degrees: f32 };
try player2.set("rotation", Rotation{ .degrees = 90 });
try testing.expect(player1.get("rotation", Rotation) == null); // player1 has no rotation
//-------------------------------------------------------------------------
// Most of your entities don't have a component, but a few do? Use setSparse instead!
// This is optimized for some entities having the component, but most not having it.
const Weapon = struct { name: []const u8 };
try player1.setSparse("weapon", Weapon{ .name = "sword" });
try testing.expectEqualStrings("sword", player1.get("weapon", Weapon).?.name); // lookup is the same regardless of storage type
try testing.expect(player2.get("weapon", Weapon) == null); // player2 has no weapon
//-------------------------------------------------------------------------
// Remove a component from any entity at will. We'll still pay the cost of storing it for each
// component, it's just set to `null` now.
// TODO: add a way to "cleanup" truly unused components.
_ = player1.remove("location"); // remove Location component
_ = player1.remove("weapon"); // remove Weapon component
//-------------------------------------------------------------------------
// At runtime we can query the type of any entity.
try testing.expectEqualStrings("player", world.typeName(player1.id));
//-------------------------------------------------------------------------
// Entity IDs are all you need to store, they're 48 bits. You can always look up an entity by ID
// in O(1) time (mere array access):
const player1_by_id = world.byID(player1.id);
//-------------------------------------------------------------------------
// Introspect things.
// Entity types
var entity_types = world.types.keys();
try testing.expectEqual(@as(usize, 1), entity_types.len);
try testing.expectEqualStrings("player", entity_types[0]);
// Component types for a given entity type "player"
var component_names = (try world.get("player")).components.keys();
try testing.expectEqual(@as(usize, 4), component_names.len);
try testing.expectEqualStrings("name", component_names[0]);
try testing.expectEqualStrings("location", component_names[1]);
try testing.expectEqualStrings("rotation", component_names[2]);
try testing.expectEqualStrings("weapon", component_names[3]);
// TODO: iterating components an entity has not currently supported.
//-------------------------------------------------------------------------
// Delete an entity whenever you wish. Just be sure not to try and use it later!
try player1_by_id.delete();
const physics = (struct {
pub fn physics(adapter: *Adapter) void {
var iter = adapter.query(&.{"physics"});
std.debug.print("\nphysics ran\n", .{});
while (iter.next()) |row| {
std.debug.print("found entity: {}\n", .{row.entity});
defer row.unlock();
}
test "entity ID size" {
try testing.expectEqual(6, @sizeOf(EntityID));
}
}).physics;
try world.register("physics", physics);
const rendering = (struct {
pub fn rendering(adapter: *Adapter) void {
var iter = adapter.query(&.{"geometry"});
std.debug.print("\nrendering ran\n", .{});
while (iter.next()) |row| {
std.debug.print("found entity: {}\n", .{row.entity});
defer row.unlock();
}
}
}).rendering;
try world.register("rendering", rendering);
world.tick();
}

55
ecs/src/systems.zig Normal file
View file

@ -0,0 +1,55 @@
const std = @import("std");
const mem = std.mem;
const Allocator = mem.Allocator;
const testing = std.testing;
const Entities = @import("entities.zig").Entities;
const Iterator = Entities.Iterator;
pub const Adapter = struct {
world: *World,
pub fn query(adapter: *Adapter, components: []const []const u8) Iterator {
return adapter.world.entities.query(components);
}
};
pub const System = fn (adapter: *Adapter) void;
pub const World = struct {
allocator: Allocator,
systems: std.StringArrayHashMapUnmanaged(System) = .{},
entities: Entities,
pub fn init(allocator: Allocator) !World {
return World{
.allocator = allocator,
.entities = try Entities.init(allocator),
};
}
pub fn deinit(world: *World) void {
world.systems.deinit(world.allocator);
world.entities.deinit();
}
pub fn register(world: *World, name: []const u8, system: System) !void {
try world.systems.put(world.allocator, name, system);
}
pub fn unregister(world: *World, name: []const u8) void {
world.systems.orderedRemove(name);
}
pub fn tick(world: *World) void {
var i: usize = 0;
while (i < world.systems.count()) : (i += 1) {
const system = world.systems.entries.get(i).value;
var adapter = Adapter{
.world = world,
};
system(&adapter);
}
}
};