From 5f70579360d8fb65bced3ce6638fbaec85e74582 Mon Sep 17 00:00:00 2001 From: Stephen Gutekanst Date: Mon, 4 Mar 2024 10:27:56 -0700 Subject: [PATCH] src/ecs: move mach-ecs@83a3ed801008a976dd79e10068157b02c3b76a36 package to here Helps hexops/mach#1165 Signed-off-by: Stephen Gutekanst --- src/ecs/Archetype.zig | 241 +++++++++++ src/ecs/StringTable.zig | 105 +++++ src/ecs/comptime.zig | 64 +++ src/ecs/entities.zig | 880 ++++++++++++++++++++++++++++++++++++++++ src/ecs/main.zig | 119 ++++++ src/ecs/modules.zig | 166 ++++++++ src/ecs/query.zig | 116 ++++++ src/ecs/systems.zig | 200 +++++++++ 8 files changed, 1891 insertions(+) create mode 100644 src/ecs/Archetype.zig create mode 100644 src/ecs/StringTable.zig create mode 100644 src/ecs/comptime.zig create mode 100644 src/ecs/entities.zig create mode 100644 src/ecs/main.zig create mode 100644 src/ecs/modules.zig create mode 100644 src/ecs/query.zig create mode 100644 src/ecs/systems.zig diff --git a/src/ecs/Archetype.zig b/src/ecs/Archetype.zig new file mode 100644 index 00000000..a977151e --- /dev/null +++ b/src/ecs/Archetype.zig @@ -0,0 +1,241 @@ +//! Represents a single archetype. i.e., entities which have a specific set of components. When a +//! component is added or removed from an entity, it's archetype changes because the archetype is +//! the set of components an entity has. +//! +//! Database equivalent: a table where rows are entities and columns are components (dense storage). + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const testing = std.testing; +const assert = std.debug.assert; +const builtin = @import("builtin"); +const StringTable = @import("StringTable.zig"); +const comp = @import("comptime.zig"); + +const Archetype = @This(); + +/// Describes a single column of the archetype (table); i.e. a single type of component +pub const Column = struct { + /// The unique name of the component this column stores. + name: StringTable.Index, + + /// A unique identifier for the programming-language type this column stores. In the case of Zig + /// this is a comptime type identifier. For other languages, it may be something else or simply + /// zero if unused. + /// + /// This value need only uniquely identify the column type for the duration of a single build of + /// the program. + type_id: u32, + + /// The size of the component this column stores. + size: u32, + + /// The alignment of the component type this column stores. + alignment: u16, + + /// The actual memory where the values are stored. The length/capacity is Archetype.len and + /// Archetype.capacity, as all columns in an Archetype have identical lengths/capacities. + values: []u8, +}; + +/// The length of the table (in-use number of rows) +len: u32, + +/// The capacity of the table (total allocated number of rows) +capacity: u32, + +/// Describes the columns in this table. Each column stores all rows for that column. +columns: []Column, + +/// A reference to the string table that can be used to identify Column.name's +component_names: *StringTable, + +/// A hash composed of all Column.name's, effectively acting as the unique name of this table. +hash: u64, + +/// An index to Entities.archetypes, used in the event of a *bucket* hash collision (not a collision +/// of the .hash field) - see Entities.archetypeOrPut for details. +next: ?u32 = null, + +pub fn deinit(storage: *Archetype, gpa: Allocator) void { + if (storage.capacity > 0) { + for (storage.columns) |column| gpa.free(column.values); + } + gpa.free(storage.columns); +} + +/// appends a new row to this table, with all undefined values. +pub fn appendUndefined(storage: *Archetype, gpa: Allocator) !u32 { + try storage.ensureUnusedCapacity(gpa, 1); + assert(storage.len < storage.capacity); + const row_index = storage.len; + storage.len += 1; + return row_index; +} + +// TODO: comptime: missing a runtime variant of this function +pub fn append(storage: *Archetype, gpa: Allocator, row: anytype) !u32 { + comp.debugAssertRowType(storage, row); + + try storage.ensureUnusedCapacity(gpa, 1); + assert(storage.len < storage.capacity); + storage.len += 1; + + storage.setRow(storage.len - 1, row); + return storage.len; +} + +pub fn undoAppend(storage: *Archetype) void { + storage.len -= 1; +} + +/// Ensures there is enough unused capacity to store `num_rows`. +pub fn ensureUnusedCapacity(storage: *Archetype, gpa: Allocator, num_rows: usize) !void { + return storage.ensureTotalCapacity(gpa, storage.len + num_rows); +} + +/// Ensures the total capacity is enough to store `new_capacity` rows total. +pub fn ensureTotalCapacity(storage: *Archetype, gpa: Allocator, new_capacity: usize) !void { + var better_capacity = storage.capacity; + if (better_capacity >= new_capacity) return; + + while (true) { + better_capacity +|= better_capacity / 2 + 8; + if (better_capacity >= new_capacity) break; + } + + return storage.setCapacity(gpa, better_capacity); +} + +/// Sets the capacity to exactly `new_capacity` rows total +/// +/// Asserts `new_capacity >= storage.len`, if you want to shrink capacity then change the len +/// yourself first. +pub fn setCapacity(storage: *Archetype, gpa: Allocator, new_capacity: usize) !void { + assert(new_capacity >= storage.len); + + // TODO: ensure columns are sorted by type_id + for (storage.columns) |*column| { + const old_values = column.values; + const new_values = try gpa.alloc(u8, new_capacity * column.size); + if (storage.capacity > 0) { + @memcpy(new_values[0..old_values.len], old_values); + gpa.free(old_values); + } + column.values = new_values; + } + storage.capacity = @as(u32, @intCast(new_capacity)); +} + +// TODO: comptime: missing a runtime variant of this function +/// Sets the entire row's values in the table. +pub fn setRow(storage: *Archetype, row_index: u32, row: anytype) void { + comp.debugAssertRowType(storage, row); + + const fields = std.meta.fields(@TypeOf(row)); + inline for (fields, 0..) |field, index| { + const ColumnType = field.type; + if (@sizeOf(ColumnType) == 0) continue; + + const column = storage.columns[index]; + const column_values = @as([*]ColumnType, @ptrCast(@alignCast(column.values.ptr))); + column_values[row_index] = @field(row, field.name); + } +} + +/// Sets the value of the named components (columns) for the given row in the table. +pub fn set(storage: *Archetype, row_index: u32, name: StringTable.Index, component: anytype) void { + const ColumnType = @TypeOf(component); + if (@sizeOf(ColumnType) == 0) return; + if (comp.is_debug) comp.debugAssertColumnType(storage, storage.columnByName(name).?, @TypeOf(component)); + storage.setDynamic( + row_index, + name, + std.mem.asBytes(&component), + @alignOf(@TypeOf(component)), + comp.typeId(@TypeOf(component)), + ); +} + +pub fn setDynamic(storage: *Archetype, row_index: u32, name: StringTable.Index, component: []const u8, alignment: u16, type_id: u32) void { + if (comp.is_debug) { + // TODO: improve error messages + assert(storage.len != 0 and storage.len >= row_index); + assert(storage.columnByName(name).?.size == component.len); + assert(storage.columnByName(name).?.alignment == alignment); + assert(storage.columnByName(name).?.type_id == type_id); + } + + const values = storage.getColumnValuesRaw(name) orelse @panic("no such component"); + const start = component.len * row_index; + @memcpy(values[start .. start + component.len], component); +} + +pub fn get(storage: *Archetype, row_index: u32, name: StringTable.Index, comptime ColumnType: type) ?ColumnType { + if (@sizeOf(ColumnType) == 0) return {}; + if (comp.is_debug) comp.debugAssertColumnType(storage, storage.columnByName(name) orelse return null, ColumnType); + + const bytes = storage.getDynamic(row_index, name, @sizeOf(ColumnType), @alignOf(ColumnType), comp.typeId(ColumnType)) orelse return null; + return @as(*ColumnType, @alignCast(@ptrCast(bytes.ptr))).*; +} + +pub fn getDynamic(storage: *Archetype, row_index: u32, name: StringTable.Index, size: u32, alignment: u16, type_id: u32) ?[]u8 { + const values = storage.getColumnValuesRaw(name) orelse return null; + if (comp.is_debug) { + // TODO: improve error messages + assert(storage.columnByName(name).?.size == size); + assert(storage.columnByName(name).?.alignment == alignment); + assert(storage.columnByName(name).?.type_id == type_id); + } + + const start = size * row_index; + const end = start + size; + return values[start..end]; +} + +/// Swap-removes the specified row with the last row in the table. +pub fn remove(storage: *Archetype, row_index: u32) void { + if (storage.len > 1) { + for (storage.columns) |column| { + const dstStart = column.size * row_index; + const dst = column.values[dstStart .. dstStart + column.size]; + const srcStart = column.size * (storage.len - 1); + const src = column.values[srcStart .. srcStart + column.size]; + @memcpy(dst, src); + } + } + storage.len -= 1; +} + +/// Tells if this archetype has every one of the given components. +pub fn hasComponents(storage: *Archetype, names: []const u32) bool { + for (names) |name| { + if (!storage.hasComponent(name)) return false; + } + return true; +} + +/// Tells if this archetype has a component with the specified name. +pub fn hasComponent(storage: *Archetype, name: StringTable.Index) bool { + return storage.columnByName(name) != null; +} + +pub fn getColumnValues(storage: *Archetype, name: StringTable.Index, comptime ColumnType: type) ?[]ColumnType { + const values = storage.getColumnValuesRaw(name) orelse return null; + if (comp.is_debug) comp.debugAssertColumnType(storage, storage.columnByName(name).?, ColumnType); + var ptr = @as([*]ColumnType, @ptrCast(@alignCast(values.ptr))); + const column_values = ptr[0..storage.capacity]; + return column_values; +} + +pub fn getColumnValuesRaw(storage: *Archetype, name: StringTable.Index) ?[]u8 { + const column = storage.columnByName(name) orelse return null; + return column.values; +} + +pub inline fn columnByName(storage: *Archetype, name: StringTable.Index) ?*Column { + for (storage.columns) |*column| { + if (column.name == name) return column; + } + return null; +} diff --git a/src/ecs/StringTable.zig b/src/ecs/StringTable.zig new file mode 100644 index 00000000..97f91754 --- /dev/null +++ b/src/ecs/StringTable.zig @@ -0,0 +1,105 @@ +//! Stores null-terminated strings and maps them to unique 32-bit indices. +//! +//! Lookups are omnidirectional: both (string -> index) and (index -> string) are supported +//! operations. +//! +//! The implementation is based on: +//! https://zig.news/andrewrk/how-to-use-hash-map-contexts-to-save-memory-when-doing-a-string-table-3l33 + +const std = @import("std"); + +const StringTable = @This(); + +string_bytes: std.ArrayListUnmanaged(u8) = .{}, + +/// Key is string_bytes index. +string_table: std.HashMapUnmanaged(u32, void, IndexContext, std.hash_map.default_max_load_percentage) = .{}, + +pub const Index = u32; + +/// Returns the index of a string key, if it exists. +pub fn index(table: *StringTable, key: []const u8) ?Index { + const slice_context: SliceAdapter = .{ .string_bytes = &table.string_bytes }; + const found_entry = table.string_table.getEntryAdapted(key, slice_context); + if (found_entry) |e| return e.key_ptr.*; + return null; +} + +/// Returns the index of a string key, inserting if not exists. +pub fn indexOrPut(table: *StringTable, allocator: std.mem.Allocator, key: []const u8) !Index { + const slice_context: SliceAdapter = .{ .string_bytes = &table.string_bytes }; + const index_context: IndexContext = .{ .string_bytes = &table.string_bytes }; + const entry = try table.string_table.getOrPutContextAdapted(allocator, key, slice_context, index_context); + if (!entry.found_existing) { + entry.key_ptr.* = @intCast(table.string_bytes.items.len); + try table.string_bytes.appendSlice(allocator, key); + try table.string_bytes.append(allocator, '\x00'); + } + return entry.key_ptr.*; +} + +/// Returns a null-terminated string given the index +pub fn string(table: *StringTable, idx: Index) [:0]const u8 { + return std.mem.span(@as([*:0]const u8, @ptrCast(table.string_bytes.items.ptr)) + idx); +} + +pub fn deinit(table: *StringTable, allocator: std.mem.Allocator) void { + table.string_bytes.deinit(allocator); + table.string_table.deinit(allocator); +} + +const IndexContext = struct { + string_bytes: *std.ArrayListUnmanaged(u8), + + pub fn eql(ctx: IndexContext, a: u32, b: u32) bool { + _ = ctx; + return a == b; + } + + pub fn hash(ctx: IndexContext, x: u32) u64 { + const x_slice = std.mem.span(@as([*:0]const u8, @ptrCast(ctx.string_bytes.items.ptr)) + x); + return std.hash_map.hashString(x_slice); + } +}; + +const SliceAdapter = struct { + string_bytes: *std.ArrayListUnmanaged(u8), + + pub fn eql(adapter: SliceAdapter, a_slice: []const u8, b: u32) bool { + const b_slice = std.mem.span(@as([*:0]const u8, @ptrCast(adapter.string_bytes.items.ptr)) + b); + return std.mem.eql(u8, a_slice, b_slice); + } + + pub fn hash(adapter: SliceAdapter, adapted_key: []const u8) u64 { + _ = adapter; + return std.hash_map.hashString(adapted_key); + } +}; + +test { + const gpa = std.testing.allocator; + + var table: StringTable = .{}; + defer table.deinit(gpa); + + const index_context: IndexContext = .{ .string_bytes = &table.string_bytes }; + _ = index_context; + + // "hello" -> index 0 + const hello_index = try table.indexOrPut(gpa, "hello"); + try std.testing.expectEqual(@as(Index, 0), hello_index); + + try std.testing.expectEqual(@as(Index, 6), try table.indexOrPut(gpa, "world")); + try std.testing.expectEqual(@as(Index, 12), try table.indexOrPut(gpa, "foo")); + try std.testing.expectEqual(@as(Index, 16), try table.indexOrPut(gpa, "bar")); + try std.testing.expectEqual(@as(Index, 20), try table.indexOrPut(gpa, "baz")); + + // index 0 -> "hello" + try std.testing.expectEqualStrings("hello", table.string(hello_index)); + + // Lookup "hello" -> index 0 + try std.testing.expectEqual(hello_index, table.index("hello").?); + + // Lookup "foobar" -> null + try std.testing.expectEqual(@as(?Index, null), table.index("foobar")); +} diff --git a/src/ecs/comptime.zig b/src/ecs/comptime.zig new file mode 100644 index 00000000..30320019 --- /dev/null +++ b/src/ecs/comptime.zig @@ -0,0 +1,64 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +const Archetype = @import("Archetype.zig"); +const StringTable = @import("StringTable.zig"); + +pub const is_debug = builtin.mode == .Debug; + +/// Returns a unique comptime usize integer representing the type T. Value will change across +/// different compilations. +pub fn typeId(comptime T: type) u32 { + _ = T; + return @truncate(@intFromPtr(&struct { + var x: u8 = 0; + }.x)); +} + +/// Asserts that T matches the type of the column. +pub inline fn debugAssertColumnType(storage: *Archetype, column: *Archetype.Column, comptime T: type) void { + if (is_debug) { + if (typeId(T) != column.type_id) std.debug.panic("unexpected type: {s} expected: {s}", .{ + @typeName(T), + storage.component_names.string(column.name), + }); + } +} + +/// Asserts that a tuple `row` to be e.g. appended to an archetype has values that actually match +/// all of the columns of the archetype table. +pub inline fn debugAssertRowType(storage: *Archetype, row: anytype) void { + if (is_debug) { + inline for (std.meta.fields(@TypeOf(row)), 0..) |field, index| { + debugAssertColumnType(storage, &storage.columns[index], field.type); + } + } +} + +// TODO: comptime refactor +pub fn ArchetypeSlicer(comptime all_components: anytype) type { + return struct { + archetype: *Archetype, + + pub fn slice( + slicer: @This(), + comptime namespace_name: std.meta.FieldEnum(@TypeOf(all_components)), + comptime component_name: std.meta.DeclEnum(@field(all_components, @tagName(namespace_name))), + ) []@field( + @field(all_components, @tagName(namespace_name)), + @tagName(component_name), + ) { + const Type = @field( + @field(all_components, @tagName(namespace_name)), + @tagName(component_name), + ); + if (namespace_name == .entity and component_name == .id) { + const name_id = slicer.archetype.component_names.index("id").?; + return slicer.archetype.getColumnValues(name_id, Type).?[0..slicer.archetype.len]; + } + const name = @tagName(namespace_name) ++ "." ++ @tagName(component_name); + const name_id = slicer.archetype.component_names.index(name).?; + return slicer.archetype.getColumnValues(name_id, Type).?[0..slicer.archetype.len]; + } + }; +} diff --git a/src/ecs/entities.zig b/src/ecs/entities.zig new file mode 100644 index 00000000..09b42d84 --- /dev/null +++ b/src/ecs/entities.zig @@ -0,0 +1,880 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const testing = std.testing; +const builtin = @import("builtin"); +const assert = std.debug.assert; +const query_mod = @import("query.zig"); +const Archetype = @import("Archetype.zig"); +const StringTable = @import("StringTable.zig"); +const comp = @import("comptime.zig"); + +/// An entity ID uniquely identifies an entity globally within an Entities set. +pub const EntityID = u64; + +fn byTypeId(context: void, lhs: Archetype.Column, rhs: Archetype.Column) bool { + _ = context; + return lhs.type_id < rhs.type_id; +} + +/// 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. +/// * Archetype 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 fn Entities(comptime all_components: anytype) type { + // TODO: validate all_components is a namespaced component set in the form we expect + return 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) = .{}, + + // All archetypes are stored in a bucket. The number of buckets is configurable, and which + // bucket an archetype will be stored in is based on the hash of all its columns / component + // names. + seed: u64 = 0xdeadbeef, + buckets: []?u32, // indices into archetypes + archetypes: std.ArrayListUnmanaged(Archetype) = .{}, + + /// Maps component names <-> unique IDs + component_names: *StringTable, + id_name: StringTable.Index = 0, + + const Self = @This(); + + /// 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.items[ptr.archetype_index].rows[ptr.row_index] + /// ``` + /// + pub const Pointer = struct { + archetype_index: u32, + row_index: u32, + }; + + /// A complex query for entities matching a given criteria + pub const Query = query_mod.Query(all_components); + pub const QueryTag = query_mod.QueryTag; + + pub fn init(allocator: Allocator) !Self { + const component_names = try allocator.create(StringTable); + errdefer allocator.destroy(component_names); + component_names.* = .{}; + + const buckets = try allocator.alloc(?u32, 1024); // TODO: configurable size + errdefer allocator.free(buckets); + for (buckets) |*b| b.* = null; + + var entities = Self{ + .allocator = allocator, + .component_names = component_names, + .buckets = buckets, + }; + entities.id_name = try entities.component_names.indexOrPut(allocator, "id"); + + const columns = try allocator.alloc(Archetype.Column, 1); + columns[0] = .{ + .name = entities.id_name, + .type_id = comp.typeId(EntityID), + .size = @sizeOf(EntityID), + .alignment = @alignOf(EntityID), + .values = undefined, + }; + + const archetype_entry = try entities.archetypeOrPut(columns); + archetype_entry.ptr.* = .{ + .len = 0, + .capacity = 0, + .columns = columns, + .component_names = entities.component_names, + .hash = archetype_entry.hash, + }; + return entities; + } + + pub fn deinit(entities: *Self) void { + entities.entities.deinit(entities.allocator); + entities.component_names.deinit(entities.allocator); + entities.allocator.destroy(entities.component_names); + entities.allocator.free(entities.buckets); + for (entities.archetypes.items) |*archetype| archetype.deinit(entities.allocator); + entities.archetypes.deinit(entities.allocator); + } + + fn archetypeOrPut( + entities: *Self, + columns: []const Archetype.Column, + ) !struct { + found_existing: bool, + hash: u64, + index: u32, + ptr: *Archetype, + } { + var hasher = std.hash.XxHash64.init(entities.seed); + for (columns) |column| { + hasher.update(std.mem.asBytes(&column.name)); + } + const hash = hasher.final(); + const bucket_index = hash % entities.buckets.len; + if (entities.buckets[bucket_index]) |bucket| { + // Bucket already exists + const archetype = &entities.archetypes.items[bucket]; + if (archetype.next) |_| { + // Multiple archetypes in bucket (there were collisions) + while (archetype.next) |collision_index| { + const collision = &entities.archetypes.items[collision_index]; + if (collision.hash == hash) { + // Probably a match + // TODO: technically a hash collision could occur here, so maybe check + // column IDs are equal here too? + return .{ .found_existing = true, .hash = hash, .index = collision_index, .ptr = collision }; + } + } + + // New collision + try entities.archetypes.append(entities.allocator, undefined); + const index = entities.archetypes.items.len - 1; + const ptr = &entities.archetypes.items[index]; + archetype.next = @intCast(index); + return .{ .found_existing = false, .hash = hash, .index = @intCast(index), .ptr = ptr }; + } else if (archetype.hash == hash) { + // Exact match + return .{ .found_existing = true, .hash = hash, .index = bucket, .ptr = archetype }; + } + + // New collision + try entities.archetypes.append(entities.allocator, undefined); + const index = entities.archetypes.items.len - 1; + const ptr = &entities.archetypes.items[index]; + archetype.next = @intCast(index); + return .{ .found_existing = false, .hash = hash, .index = @intCast(index), .ptr = ptr }; + } + + // Bucket doesn't exist + try entities.archetypes.append(entities.allocator, undefined); + const index = entities.archetypes.items.len - 1; + const ptr = &entities.archetypes.items[index]; + entities.buckets[bucket_index] = @intCast(index); + return .{ .found_existing = false, .hash = hash, .index = @intCast(index), .ptr = ptr }; + } + + /// Returns a new entity. + pub fn new(entities: *Self) !EntityID { + const new_id = entities.counter; + entities.counter += 1; + + // TODO: could skip this lookup if we store pointer + const archetype_entry = try entities.archetypeOrPut(&.{ + .{ + .name = entities.id_name, + .type_id = comp.typeId(EntityID), + .size = @sizeOf(EntityID), + .alignment = @alignOf(EntityID), + .values = undefined, + }, + }); + assert(archetype_entry.found_existing); + + var void_archetype = archetype_entry.ptr; + const new_row = try void_archetype.append(entities.allocator, .{ .id = new_id }); + const void_pointer = Pointer{ + .archetype_index = 0, // void archetype is guaranteed to be first index + .row_index = new_row, + }; + errdefer void_archetype.undoAppend(); + + try entities.entities.put(entities.allocator, new_id, void_pointer); + return new_id; + } + + /// Removes an entity. + pub fn remove(entities: *Self, 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. + if (archetype.len > 1) { + const last_row_entity_id = archetype.get(archetype.len - 1, entities.id_name, EntityID).?; + 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. + archetype.remove(ptr.row_index); + + _ = entities.entities.remove(entity); + } + + /// Given a component name, returns its ID. A new ID is created if needed. + /// + /// The set of components used is expected to be static for the lifetime of the Entities, + /// and as such this function allocates component names but there is no way to release that + /// memory until Entities.deinit() is called. + pub fn componentName(entities: *Self, name_str: []const u8) StringTable.Index { + return entities.component_names.indexOrPut(entities.allocator, name_str) catch @panic("TODO: implement stateful OOM"); + } + + /// Returns the archetype storage for the given entity. + pub inline fn archetypeByID(entities: *Self, entity: EntityID) *Archetype { + const ptr = entities.entities.get(entity).?; + return &entities.archetypes.items[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: *Self, + entity: EntityID, + comptime namespace_name: std.meta.FieldEnum(@TypeOf(all_components)), + comptime component_name: std.meta.DeclEnum(@field(all_components, @tagName(namespace_name))), + component: @field( + @field(all_components, @tagName(namespace_name)), + @tagName(component_name), + ), + ) !void { + const name_str = @tagName(namespace_name) ++ "." ++ @tagName(component_name); + const name_id = try entities.component_names.indexOrPut(entities.allocator, name_str); + + const prev_archetype_idx = entities.entities.get(entity).?.archetype_index; + var prev_archetype = &entities.archetypes.items[prev_archetype_idx]; + var archetype: ?*Archetype = if (prev_archetype.hasComponent(name_id)) prev_archetype else null; + var archetype_idx: u32 = if (archetype != null) prev_archetype_idx else 0; + + if (archetype == null) { + // TODO: eliminate the need for allocation and sorting here, since this can occur + // if an archetype already exists (found_existing case below) + const columns = try entities.allocator.alloc(Archetype.Column, prev_archetype.columns.len + 1); + @memcpy(columns[0 .. columns.len - 1], prev_archetype.columns); + for (columns) |*column| { + column.values = undefined; + } + columns[columns.len - 1] = .{ + .name = name_id, + .type_id = comp.typeId(@TypeOf(component)), + .size = @sizeOf(@TypeOf(component)), + .alignment = if (@sizeOf(@TypeOf(component)) == 0) 1 else @alignOf(@TypeOf(component)), + .values = undefined, + }; + std.sort.pdq(Archetype.Column, columns, {}, byTypeId); + + const archetype_entry = try entities.archetypeOrPut(columns); + if (!archetype_entry.found_existing) { + archetype_entry.ptr.* = .{ + .len = 0, + .capacity = 0, + .columns = columns, + .component_names = entities.component_names, + .hash = archetype_entry.hash, + }; + } else { + entities.allocator.free(columns); + } + archetype = archetype_entry.ptr; + archetype_idx = archetype_entry.index; + } + + // 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.?; + + if (archetype_idx == prev_archetype_idx) { + // Update the value of the existing component of the entity. + const ptr = entities.entities.get(entity).?; + current_archetype_storage.set(ptr.row_index, name_id, 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.appendUndefined(entities.allocator); + const old_ptr = entities.entities.get(entity).?; + + // Update the storage/columns for all of the existing components on the entity. + current_archetype_storage.set(new_row, entities.id_name, entity); + for (prev_archetype.columns) |column| { + if (column.name == entities.id_name) continue; + for (current_archetype_storage.columns) |corresponding| { + if (column.name == corresponding.name) { + const old_value_raw = prev_archetype.getDynamic(old_ptr.row_index, column.name, column.size, column.alignment, column.type_id).?; + current_archetype_storage.setDynamic(new_row, corresponding.name, old_value_raw, corresponding.alignment, corresponding.type_id); + break; + } + } + } + + // Update the storage/column for the new component. + current_archetype_storage.set(new_row, name_id, component); + + prev_archetype.remove(old_ptr.row_index); + if (prev_archetype.len > 0) { + const swapped_entity_id = prev_archetype.get(old_ptr.row_index, entities.id_name, EntityID).?; + try entities.entities.put(entities.allocator, swapped_entity_id, old_ptr); + } + + try entities.entities.put(entities.allocator, entity, Pointer{ + .archetype_index = archetype_idx, + .row_index = new_row, + }); + return; + } + + /// 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. + /// + /// For tags, set component.len = 0 and alignment = 1 + pub fn setComponentDynamic( + entities: *Self, + entity: EntityID, + name_id: StringTable.Index, + component: []const u8, + alignment: u16, + type_id: u32, + ) !void { + const prev_archetype_idx = entities.entities.get(entity).?.archetype_index; + var prev_archetype = &entities.archetypes.items[prev_archetype_idx]; + var archetype: ?*Archetype = if (prev_archetype.hasComponent(name_id)) prev_archetype else null; + var archetype_idx: u32 = if (archetype != null) prev_archetype_idx else 0; + + if (archetype == null) { + // TODO: eliminate the need for allocation and sorting here, since this can occur + // if an archetype already exists (found_existing case below) + const columns = try entities.allocator.alloc(Archetype.Column, prev_archetype.columns.len + 1); + @memcpy(columns[0 .. columns.len - 1], prev_archetype.columns); + for (columns) |*column| { + column.values = undefined; + } + columns[columns.len - 1] = .{ + .name = name_id, + .type_id = type_id, + .size = @intCast(component.len), + .alignment = alignment, + .values = undefined, + }; + std.sort.pdq(Archetype.Column, columns, {}, byTypeId); + + const archetype_entry = try entities.archetypeOrPut(columns); + if (!archetype_entry.found_existing) { + archetype_entry.ptr.* = .{ + .len = 0, + .capacity = 0, + .columns = columns, + .component_names = entities.component_names, + .hash = archetype_entry.hash, + }; + } else { + entities.allocator.free(columns); + } + archetype = archetype_entry.ptr; + archetype_idx = archetype_entry.index; + } + + // 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.?; + + if (archetype_idx == prev_archetype_idx) { + // Update the value of the existing component of the entity. + const ptr = entities.entities.get(entity).?; + current_archetype_storage.setDynamic(ptr.row_index, name_id, component, alignment, type_id); + 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.appendUndefined(entities.allocator); + const old_ptr = entities.entities.get(entity).?; + + // Update the storage/columns for all of the existing components on the entity. + current_archetype_storage.set(new_row, entities.id_name, entity); + for (prev_archetype.columns) |column| { + if (column.name == entities.id_name) continue; + for (current_archetype_storage.columns) |corresponding| { + if (column.name == corresponding.name) { + const old_value_raw = prev_archetype.getDynamic(old_ptr.row_index, column.name, column.size, column.alignment, column.type_id).?; + current_archetype_storage.setDynamic(new_row, corresponding.name, old_value_raw, corresponding.alignment, corresponding.type_id); + break; + } + } + } + + // Update the storage/column for the new component. + current_archetype_storage.setDynamic(new_row, name_id, component, alignment, type_id); + + prev_archetype.remove(old_ptr.row_index); + if (prev_archetype.len > 0) { + const swapped_entity_id = prev_archetype.get(old_ptr.row_index, entities.id_name, EntityID).?; + try entities.entities.put(entities.allocator, swapped_entity_id, old_ptr); + } + + try entities.entities.put(entities.allocator, entity, Pointer{ + .archetype_index = archetype_idx, + .row_index = new_row, + }); + return; + } + + /// Gets the named component of the given type. + /// Returns null if the component does not exist on the entity. + pub fn getComponent( + entities: *Self, + entity: EntityID, + comptime namespace_name: std.meta.FieldEnum(@TypeOf(all_components)), + comptime component_name: std.meta.DeclEnum(@field(all_components, @tagName(namespace_name))), + ) ?@field( + @field(all_components, @tagName(namespace_name)), + @tagName(component_name), + ) { + const Component = comptime @field( + @field(all_components, @tagName(namespace_name)), + @tagName(component_name), + ); + const name_str = @tagName(namespace_name) ++ "." ++ @tagName(component_name); + const name_id = entities.component_names.index(name_str) orelse return null; + + var archetype = entities.archetypeByID(entity); + const ptr = entities.entities.get(entity).?; + return archetype.get(ptr.row_index, name_id, Component); + } + + /// Gets the named component of the given type. + /// Returns null if the component does not exist on the entity. + /// + /// For tags, set size = 0 and alignment = 1 + pub fn getComponentDynamic( + entities: *Self, + entity: EntityID, + name_id: StringTable.Index, + size: u32, + alignment: u16, + type_id: u32, + ) ?[]u8 { + var archetype = entities.archetypeByID(entity); + const ptr = entities.entities.get(entity).?; + return archetype.getDynamic(ptr.row_index, name_id, size, alignment, type_id); + } + + /// Removes the named component from the entity, or noop if it doesn't have such a component. + pub fn removeComponent( + entities: *Self, + entity: EntityID, + comptime namespace_name: std.meta.FieldEnum(@TypeOf(all_components)), + comptime component_name: std.meta.DeclEnum(@field(all_components, @tagName(namespace_name))), + ) !void { + const name_str = @tagName(namespace_name) ++ "." ++ @tagName(component_name); + const name_id = try entities.component_names.indexOrPut(entities.allocator, name_str); + return entities.removeComponentDynamic(entity, name_id); + } + + /// Removes the named component from the entity, or noop if it doesn't have such a component. + pub fn removeComponentDynamic( + entities: *Self, + entity: EntityID, + name_id: StringTable.Index, + ) !void { + const prev_archetype_idx = entities.entities.get(entity).?.archetype_index; + var prev_archetype = &entities.archetypes.items[prev_archetype_idx]; + var archetype: ?*Archetype = if (prev_archetype.hasComponent(name_id)) prev_archetype else return; + var archetype_idx: u32 = if (archetype != null) prev_archetype_idx else 0; + + // Determine which archetype the entity will move to. + // TODO: eliminate this allocation in the found_existing case below + const columns = try entities.allocator.alloc(Archetype.Column, prev_archetype.columns.len - 1); + var i: usize = 0; + for (prev_archetype.columns) |old_column| { + if (old_column.name == name_id) continue; + columns[i] = old_column; + columns[i].values = undefined; + i += 1; + } + + const archetype_entry = try entities.archetypeOrPut(columns); + if (!archetype_entry.found_existing) { + archetype_entry.ptr.* = .{ + .len = 0, + .capacity = 0, + .columns = columns, + .component_names = entities.component_names, + .hash = archetype_entry.hash, + }; + } else { + entities.allocator.free(columns); + } + archetype = archetype_entry.ptr; + archetype_idx = archetype_entry.index; + + var current_archetype_storage = archetype.?; + + // Copy 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.appendUndefined(entities.allocator); + const old_ptr = entities.entities.get(entity).?; + + // Update the storage/columns for all of the existing components on the entity that exist in + // the new archetype table (i.e. excluding the component to remove.) + current_archetype_storage.set(new_row, entities.id_name, entity); + for (current_archetype_storage.columns) |column| { + if (column.name == entities.id_name) continue; + for (prev_archetype.columns) |corresponding| { + if (column.name == corresponding.name) { + const old_value_raw = prev_archetype.getDynamic(old_ptr.row_index, column.name, column.size, column.alignment, column.type_id).?; + current_archetype_storage.setDynamic(new_row, column.name, old_value_raw, column.alignment, column.type_id); + break; + } + } + } + + prev_archetype.remove(old_ptr.row_index); + if (prev_archetype.len > 0) { + const swapped_entity_id = prev_archetype.get(old_ptr.row_index, entities.id_name, EntityID).?; + try entities.entities.put(entities.allocator, swapped_entity_id, old_ptr); + } + + try entities.entities.put(entities.allocator, entity, Pointer{ + .archetype_index = archetype_idx, + .row_index = new_row, + }); + } + + // Queries for archetypes matching the given query. + pub fn query( + entities: *Self, + q: Query, + ) ArchetypeIterator(all_components) { + return ArchetypeIterator(all_components).init(entities, q); + } + + // TODO: queryDynamic + + // 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" + // + + // TODO: ability to remove archetype entirely, deleting all entities in it + // TODO: ability to remove archetypes with no entities (garbage collection) + }; +} + +// TODO: move this type somewhere else +pub fn ArchetypeIterator(comptime all_components: anytype) type { + const EntitiesT = Entities(all_components); + return struct { + entities: *EntitiesT, + query: EntitiesT.Query, + index: usize, + + const Self = @This(); + + pub fn init(entities: *EntitiesT, query: EntitiesT.Query) Self { + return Self{ + .entities = entities, + .query = query, + .index = 0, + }; + } + + // TODO: all_components is a superset of queried items, not type-safe. + pub fn next(iter: *Self) ?comp.ArchetypeSlicer(all_components) { + while (iter.index < iter.entities.archetypes.items.len) { + const archetype = &iter.entities.archetypes.items[iter.index]; + iter.index += 1; + if (iter.match(archetype)) return comp.ArchetypeSlicer(all_components){ .archetype = archetype }; + } + return null; + } + + pub fn match(iter: *Self, consideration: *Archetype) bool { + if (consideration.len == 0) return false; + var buf: [2048]u8 = undefined; + switch (iter.query) { + .all => { + for (iter.query.all) |namespace| { + switch (namespace) { + inline else => |components| { + for (components) |component| { + if (@typeInfo(@TypeOf(component)).Enum.fields.len == 0) continue; + const name = switch (component) { + inline else => |c| std.fmt.bufPrint(&buf, "{s}.{s}", .{ @tagName(namespace), @tagName(c) }) catch break, + }; + const name_id = iter.entities.componentName(name); + var has_column = false; + for (consideration.columns) |column| { + if (column.name == name_id) { + has_column = true; + break; + } + } + if (!has_column) return false; + } + }, + } + } + return true; + }, + .any => @panic("TODO"), + } + } + }; +} + +test { + std.testing.refAllDeclsRecursive(Entities(.{})); +} + +// TODO: require "one big registration of components" even when using dynamic API? Would alleviate +// some of the confusion about using world.componentName, and would perhaps improve GUI editor +// compatibility in practice. +test "dynamic" { + const allocator = testing.allocator; + const asBytes = std.mem.asBytes; + + const Location = struct { + x: f32 = 0, + y: f32 = 0, + z: f32 = 0, + }; + + const Rotation = struct { degrees: f32 }; + + // Create a world. + var world = try Entities(.{}).init(allocator); + defer world.deinit(); + + // Create an entity and add dynamic components. + const player1 = try world.new(); + try world.setComponentDynamic(player1, world.componentName("game.name"), "jane", @alignOf([]const u8), 100); + try world.setComponentDynamic(player1, world.componentName("game.name"), "joey", @alignOf([]const u8), 100); + try world.setComponentDynamic(player1, world.componentName("game.location"), asBytes(&Location{ .x = 1, .y = 2, .z = 3 }), @alignOf(Location), 101); + + // Get components + try testing.expect(world.getComponentDynamic(player1, world.componentName("game.rotation"), @sizeOf(Rotation), @alignOf(Rotation), 102) == null); + const loc = world.getComponentDynamic(player1, world.componentName("game.location"), @sizeOf(Location), @alignOf(Location), 101); + try testing.expectEqual(Location{ .x = 1, .y = 2, .z = 3 }, std.mem.bytesToValue(Location, @as(*[12]u8, @ptrCast(loc.?.ptr)))); + try testing.expectEqualStrings(world.getComponentDynamic(player1, world.componentName("game.name"), 4, @alignOf([]const u8), 100).?, "joey"); +} + +test "entity ID size" { + try testing.expectEqual(8, @sizeOf(EntityID)); +} + +test "example" { + const allocator = testing.allocator; + + const Location = struct { + x: f32 = 0, + y: f32 = 0, + z: f32 = 0, + }; + + const Rotation = struct { degrees: f32 }; + + const all_components = .{ + .entity = struct { + pub const id = EntityID; + }, + .game = struct { + pub const location = Location; + pub const name = []const u8; + pub const rotation = Rotation; + }, + }; + + //------------------------------------------------------------------------- + // Create a world. + var world = try Entities(all_components).init(allocator); + defer world.deinit(); + + //------------------------------------------------------------------------- + // Create first player entity. + const player1 = try world.new(); + try world.setComponent(player1, .game, .name, "jane"); // add .name component + try world.setComponent(player1, .game, .name, "joe"); // update .name component + try world.setComponent(player1, .game, .location, .{}); // add .location component + + // Create second player entity. + const player2 = try world.new(); + try testing.expect(world.getComponent(player2, .game, .location) == null); + try testing.expect(world.getComponent(player2, .game, .name) == null); + + //------------------------------------------------------------------------- + // We can add new components at will. + try world.setComponent(player2, .game, .rotation, .{ .degrees = 90 }); + try world.setComponent(player2, .game, .rotation, .{ .degrees = 91 }); // update .rotation component + try testing.expect(world.getComponent(player1, .game, .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, .game, .name); + try world.removeComponent(player1, .game, .location); + // try world.removeComponent(player1, .game, .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. + const archetypes = world.archetypes.items; + try testing.expectEqual(@as(usize, 5), archetypes.len); + // TODO: better table names, based on columns + // try testing.expectEqual(@as(u64, 0), archetypes[0].hash); + // try testing.expectEqual(@as(u32, 4), archetypes[1].name); + // try testing.expectEqual(@as(u32, 14), archetypes[2].name); + // try testing.expectEqual(@as(u32, 28), archetypes[3].name); + // try testing.expectEqual(@as(u32, 14), archetypes[4].name); + + // Number of (living) entities stored in an archetype table. + try testing.expectEqual(@as(usize, 1), archetypes[0].len); + try testing.expectEqual(@as(usize, 0), archetypes[1].len); + try testing.expectEqual(@as(usize, 0), archetypes[2].len); + try testing.expectEqual(@as(usize, 1), archetypes[3].len); + + // Resolve archetype by entity ID and print column names + const columns = world.archetypeByID(player2).columns; + try testing.expectEqual(@as(usize, 2), columns.len); + try testing.expectEqualStrings("id", world.component_names.string(columns[0].name)); + try testing.expectEqualStrings("game.rotation", world.component_names.string(columns[1].name)); + + //------------------------------------------------------------------------- + // Query for archetypes that have all of the given components + var iter = world.query(.{ .all = &.{ + .{ .game = &.{.rotation} }, + } }); + while (iter.next()) |archetype| { + const ids = archetype.slice(.entity, .id); + try testing.expectEqual(@as(usize, 1), ids.len); + try testing.expectEqual(player2, ids[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); +} + +test "empty_world" { + const allocator = testing.allocator; + //------------------------------------------------------------------------- + var world = try Entities(.{}).init(allocator); + // Create a world. + defer world.deinit(); +} + +test "many entities" { + const allocator = testing.allocator; + + const Location = struct { + x: f32 = 0, + y: f32 = 0, + z: f32 = 0, + }; + + const Rotation = struct { degrees: f32 }; + + const all_components = .{ + .entity = struct { + pub const id = EntityID; + }, + .game = struct { + pub const location = Location; + pub const name = []const u8; + pub const rotation = Rotation; + }, + }; + + // Create many entities + var world = try Entities(all_components).init(allocator); + defer world.deinit(); + for (0..8192) |_| { + const player = try world.new(); + try world.setComponent(player, .game, .name, "jane"); + try world.setComponent(player, .game, .location, .{}); + } + + // Confirm the number of archetypes created + const archetypes = world.archetypes.items; + try testing.expectEqual(@as(usize, 3), archetypes.len); + + // Confirm archetypes + var columns = archetypes[0].columns; + try testing.expectEqual(@as(usize, 1), columns.len); + try testing.expectEqualStrings("id", world.component_names.string(columns[0].name)); + + columns = archetypes[1].columns; + try testing.expectEqual(@as(usize, 2), columns.len); + try testing.expectEqualStrings("id", world.component_names.string(columns[0].name)); + try testing.expectEqualStrings("game.name", world.component_names.string(columns[1].name)); + + columns = archetypes[2].columns; + try testing.expectEqual(@as(usize, 3), columns.len); + try testing.expectEqualStrings("id", world.component_names.string(columns[0].name)); + try testing.expectEqualStrings("game.name", world.component_names.string(columns[1].name)); + try testing.expectEqualStrings("game.location", world.component_names.string(columns[2].name)); +} diff --git a/src/ecs/main.zig b/src/ecs/main.zig new file mode 100644 index 00000000..71b5d0a5 --- /dev/null +++ b/src/ecs/main.zig @@ -0,0 +1,119 @@ +//! mach/ecs is an Entity component system implementation. +//! +//! ## Design principles: +//! +//! * Initially a 100% clean-room implementation, working from first-principles. Later informed by +//! research into how other ECS work, with advice from e.g. Bevy and Flecs authors at different +//! points (thank you!) +//! * Solve the problems ECS solves, in a way that is natural to Zig and leverages Zig comptime. +//! * Fast. Optimal for CPU caches, multi-threaded, leverage comptime as much as is reasonable. +//! * Simple. Small API footprint, should be natural and fun - not like you're writing boilerplate. +//! * Enable other libraries to provide tracing, editors, visualizers, profilers, etc. +//! + +const std = @import("std"); +const testing = std.testing; + +pub const EntityID = @import("entities.zig").EntityID; +pub const Entities = @import("entities.zig").Entities; +pub const Archetype = @import("Archetype.zig"); + +pub const Module = @import("modules.zig").Module; +pub const Modules = @import("modules.zig").Modules; +pub const World = @import("systems.zig").World; + +// TODO: +// * Iteration +// * Querying +// * Multi threading +// * Multiple entities having one value +// * Sparse storage? + +test "inclusion" { + std.testing.refAllDeclsRecursive(@This()); + std.testing.refAllDeclsRecursive(@import("Archetype.zig")); + std.testing.refAllDeclsRecursive(@import("entities.zig")); + std.testing.refAllDeclsRecursive(@import("query.zig")); + std.testing.refAllDeclsRecursive(@import("StringTable.zig")); + std.testing.refAllDeclsRecursive(@import("systems.zig")); + std.testing.refAllDeclsRecursive(@import("modules.zig")); +} + +test "example" { + const allocator = testing.allocator; + + comptime var Renderer = type; + comptime var Physics = type; + Physics = Module(struct { + pointer: u8, + + pub const name = .physics; + pub const components = struct { + pub const id = u32; + }; + + pub fn tick(physics: *World(.{ Renderer, Physics }).Mod(Physics)) !void { + _ = physics; + } + }); + + Renderer = Module(struct { + pub const name = .renderer; + pub const components = struct { + pub const id = u16; + }; + + pub fn tick( + physics: *World(.{ Renderer, Physics }).Mod(Physics), + renderer: *World(.{ Renderer, Physics }).Mod(Renderer), + ) !void { + _ = renderer; + _ = physics; + } + }); + + //------------------------------------------------------------------------- + // Create a world. + var world = try World(.{ Renderer, Physics }).init(allocator); + defer world.deinit(); + + // Initialize module state. + var physics = &world.mod.physics; + var renderer = &world.mod.renderer; + physics.state = .{ .pointer = 123 }; + _ = physics.state.pointer; // == 123 + + const player1 = try physics.newEntity(); + const player2 = try physics.newEntity(); + const player3 = try physics.newEntity(); + try physics.set(player1, .id, 1001); + try renderer.set(player1, .id, 1001); + + try physics.set(player2, .id, 1002); + try physics.set(player3, .id, 1003); + + //------------------------------------------------------------------------- + // Querying + var iter = world.entities.query(.{ .all = &.{ + .{ .physics = &.{.id} }, + } }); + + var archetype = iter.next().?; + var ids = archetype.slice(.physics, .id); + try testing.expectEqual(@as(usize, 2), ids.len); + try testing.expectEqual(@as(usize, 1002), ids[0]); + try testing.expectEqual(@as(usize, 1003), ids[1]); + + archetype = iter.next().?; + ids = archetype.slice(.physics, .id); + try testing.expectEqual(@as(usize, 1), ids.len); + try testing.expectEqual(@as(usize, 1001), ids[0]); + + // TODO: can't write @as type here easily due to generic parameter, should be exposed + // ?comp.ArchetypeSlicer(all_components) + try testing.expectEqual(iter.next(), null); + + //------------------------------------------------------------------------- + // Send events to modules + try world.send(null, .tick, .{}); +} diff --git a/src/ecs/modules.zig b/src/ecs/modules.zig new file mode 100644 index 00000000..b0d2ba3e --- /dev/null +++ b/src/ecs/modules.zig @@ -0,0 +1,166 @@ +const std = @import("std"); +const testing = std.testing; +const StructField = std.builtin.Type.StructField; + +const EntityID = @import("entities.zig").EntityID; + +/// Verifies that T matches the expected layout of an ECS module +pub fn Module(comptime T: type) type { + if (@typeInfo(T) != .Struct) @compileError("Module must be a struct type. Found:" ++ @typeName(T)); + if (!@hasDecl(T, "name")) @compileError("Module must have `pub const name = .foobar;`"); + if (@typeInfo(@TypeOf(T.name)) != .EnumLiteral) @compileError("Module must have `pub const name = .foobar;`, found type:" ++ @typeName(T.name)); + if (@hasDecl(T, "components")) { + if (@typeInfo(T.components) != .Struct) @compileError("Module.components must be `pub const components = struct { ... };`, found type:" ++ @typeName(T.components)); + } + return T; +} + +fn NamespacedComponents(comptime modules: anytype) type { + var fields: []const StructField = &[0]StructField{}; + inline for (modules) |M| { + const components = if (@hasDecl(M, "components")) M.components else struct {}; + fields = fields ++ [_]std.builtin.Type.StructField{.{ + .name = @tagName(M.name), + .type = type, + .default_value = &components, + .is_comptime = true, + .alignment = @alignOf(@TypeOf(components)), + }}; + } + + // Builtin components + const entity_components = struct { + pub const id = EntityID; + }; + fields = fields ++ [_]std.builtin.Type.StructField{.{ + .name = "entity", + .type = type, + .default_value = &entity_components, + .is_comptime = true, + .alignment = @alignOf(@TypeOf(entity_components)), + }}; + + return @Type(.{ + .Struct = .{ + .layout = .Auto, + .is_tuple = false, + .fields = fields, + .decls = &[_]std.builtin.Type.Declaration{}, + }, + }); +} + +fn NamespacedState(comptime modules: anytype) type { + var fields: []const StructField = &[0]StructField{}; + inline for (modules) |M| { + const state_fields = std.meta.fields(M); + const State = if (state_fields.len > 0) @Type(.{ + .Struct = .{ + .layout = .Auto, + .is_tuple = false, + .fields = state_fields, + .decls = &[_]std.builtin.Type.Declaration{}, + }, + }) else struct {}; + fields = fields ++ [_]std.builtin.Type.StructField{.{ + .name = @tagName(M.name), + .type = State, + .default_value = null, + .is_comptime = false, + .alignment = @alignOf(State), + }}; + } + return @Type(.{ + .Struct = .{ + .layout = .Auto, + .is_tuple = false, + .fields = fields, + .decls = &[_]std.builtin.Type.Declaration{}, + }, + }); +} + +pub fn Modules(comptime mods: anytype) type { + inline for (mods) |M| _ = Module(M); + return struct { + pub const modules = mods; + + pub const components = NamespacedComponents(mods){}; + + pub const State = NamespacedState(mods); + }; +} + +test "module" { + _ = Module(struct { + // Physics module state + pointer: usize, + + // Globally unique module name + pub const name = .engine_physics; + + /// Physics module components + pub const components = struct { + /// A location component + pub const location = @Vector(3, f32); + }; + + pub fn tick(adapter: anytype) void { + _ = adapter; + } + }); +} + +test "modules" { + const Physics = Module(struct { + // Physics module state + pointer: usize, + + // Globally unique module name + pub const name = .engine_physics; + + /// Physics module components + pub const components = struct { + /// A location component + pub const location = @Vector(3, f32); + }; + + pub fn tick(adapter: anytype) void { + _ = adapter; + } + }); + + const Renderer = Module(struct { + pub const name = .engine_renderer; + + /// Renderer module components + pub const components = struct {}; + + pub fn tick(adapter: anytype) void { + _ = adapter; + } + }); + + const Sprite2D = Module(struct { + pub const name = .engine_sprite2d; + }); + + const modules = Modules(.{ + Physics, + Renderer, + Sprite2D, + }); + testing.refAllDeclsRecursive(modules); + testing.refAllDeclsRecursive(Physics); + testing.refAllDeclsRecursive(Renderer); + testing.refAllDeclsRecursive(Sprite2D); + + // access namespaced components + try testing.expectEqual(Physics.components.location, modules.components.engine_physics.location); + try testing.expectEqual(Renderer.components, modules.components.engine_renderer); + + // implicitly generated + _ = modules.components.entity.id; + + Physics.tick(null); +} diff --git a/src/ecs/query.zig b/src/ecs/query.zig new file mode 100644 index 00000000..a25ebcbf --- /dev/null +++ b/src/ecs/query.zig @@ -0,0 +1,116 @@ +const std = @import("std"); +const testing = std.testing; + +pub const QueryTag = enum { + any, + all, +}; + +/// A complex query for entities matching a given criteria +pub fn Query(comptime all_components: anytype) type { + return union(QueryTag) { + /// Enum matching a namespace. e.g. `.game` or `.physics2d` + pub const Namespace = std.meta.FieldEnum(@TypeOf(all_components)); + + /// Enum matching a component within a namespace + /// e.g. `var a: Component(.physics2d) = .location` + pub fn Component(comptime namespace: Namespace) type { + const components = @field(all_components, @tagName(namespace)); + if (@typeInfo(components).Struct.decls.len == 0) return enum {}; + return std.meta.DeclEnum(components); + } + + /// Slice of enums matching a component within a namespace + /// e.g. `&.{.location, .rotation}` + pub fn ComponentList(comptime namespace: Namespace) type { + return []const Component(namespace); + } + + /// Tagged union of namespaces matching lists of components + /// e.g. `.physics2d = &.{ .location, .rotation }` + pub const NamespaceComponent = T: { + const namespaces = std.meta.fields(Namespace); + var fields: [namespaces.len]std.builtin.Type.UnionField = undefined; + for (namespaces, 0..) |namespace, i| { + const ns = std.meta.stringToEnum(Namespace, namespace.name).?; + fields[i] = .{ + .name = namespace.name, + .type = ComponentList(ns), + .alignment = @alignOf(ComponentList(ns)), + }; + } + + break :T @Type(.{ .Union = .{ + .layout = .Auto, + .tag_type = Namespace, + .fields = &fields, + .decls = &.{}, + } }); + }; + + /// Matches any of these components + any: []const NamespaceComponent, + + /// Matches all of these components + all: []const NamespaceComponent, + }; +} + +test "query" { + const Location = struct { + x: f32 = 0, + y: f32 = 0, + z: f32 = 0, + }; + + const Rotation = struct { degrees: f32 }; + + const all_components = .{ + .game = struct { + pub const name = []const u8; + }, + .physics = struct { + pub const location = Location; + pub const rotation = Rotation; + }, + .renderer = struct {}, + }; + + const Q = Query(all_components); + + // Namespace type lets us select a single namespace. + try testing.expectEqual(@as(Q.Namespace, .game), .game); + try testing.expectEqual(@as(Q.Namespace, .physics), .physics); + + // Component type lets us select a single component within a namespace. + try testing.expectEqual(@as(Q.Component(.physics), .location), .location); + try testing.expectEqual(@as(Q.Component(.game), .name), .name); + + // ComponentList type lets us select multiple components within a namespace. + const x: Q.ComponentList(.physics) = &.{ + .location, + .rotation, + }; + _ = x; + + // NamespaceComponent lets us select multiple components within multiple namespaces. + const y: []const Q.NamespaceComponent = &.{ + .{ .physics = &.{ .location, .rotation } }, + .{ .game = &.{.name} }, + }; + _ = y; + + // Query matching entities with *any* of these components + const z: Q = .{ .any = &.{ + .{ .physics = &.{ .location, .rotation } }, + .{ .game = &.{.name} }, + } }; + _ = z; + + // Query matching entities with *all* of these components. + const w: Q = .{ .all = &.{ + .{ .physics = &.{ .location, .rotation } }, + .{ .game = &.{.name} }, + } }; + _ = w; +} diff --git a/src/ecs/systems.zig b/src/ecs/systems.zig new file mode 100644 index 00000000..5d81bbbe --- /dev/null +++ b/src/ecs/systems.zig @@ -0,0 +1,200 @@ +const std = @import("std"); +const mem = std.mem; +const StructField = std.builtin.Type.StructField; + +const Entities = @import("entities.zig").Entities; +const Modules = @import("modules.zig").Modules; +const EntityID = @import("entities.zig").EntityID; +const comp = @import("comptime.zig"); + +pub fn World(comptime mods: anytype) type { + const modules = Modules(mods); + + return struct { + allocator: mem.Allocator, + entities: Entities(modules.components), + mod: Mods(), + + const Self = @This(); + + pub fn Mod(comptime Module: anytype) type { + const module_tag = Module.name; + const State = @TypeOf(@field(@as(modules.State, undefined), @tagName(module_tag))); + const components = @field(modules.components, @tagName(module_tag)); + return struct { + state: State, + entities: *Entities(modules.components), + allocator: mem.Allocator, + + /// 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 inline fn set( + m: *@This(), + entity: EntityID, + comptime component_name: std.meta.DeclEnum(components), + component: @field(components, @tagName(component_name)), + ) !void { + const mod_ptr: *Self.Mods() = @alignCast(@fieldParentPtr(Mods(), @tagName(module_tag), m)); + const world = @fieldParentPtr(Self, "mod", mod_ptr); + try world.entities.setComponent(entity, module_tag, component_name, component); + } + + /// 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 inline fn get( + m: *@This(), + entity: EntityID, + comptime component_name: std.meta.DeclEnum(components), + ) ?@field(components, @tagName(component_name)) { + const mod_ptr: *Self.Mods() = @alignCast(@fieldParentPtr(Mods(), @tagName(module_tag), m)); + const world = @fieldParentPtr(Self, "mod", mod_ptr); + return world.entities.getComponent(entity, module_tag, component_name); + } + + /// Removes the named component from the entity, or noop if it doesn't have such a component. + pub inline fn remove( + m: *@This(), + entity: EntityID, + comptime component_name: std.meta.DeclEnum(components), + ) !void { + const mod_ptr: *Self.Mods() = @alignCast(@fieldParentPtr(Mods(), @tagName(module_tag), m)); + const world = @fieldParentPtr(Self, "mod", mod_ptr); + try world.entities.removeComponent(entity, module_tag, component_name); + } + + pub fn send(m: *@This(), comptime msg_tag: anytype, args: anytype) !void { + const mod_ptr: *Self.Mods() = @alignCast(@fieldParentPtr(Mods(), @tagName(module_tag), m)); + const world = @fieldParentPtr(Self, "mod", mod_ptr); + return world.sendStr(module_tag, @tagName(msg_tag), args); + } + + /// Returns a new entity. + pub fn newEntity(m: *@This()) !EntityID { + const mod_ptr: *Self.Mods() = @alignCast(@fieldParentPtr(Mods(), @tagName(module_tag), m)); + const world = @fieldParentPtr(Self, "mod", mod_ptr); + return world.entities.new(); + } + + /// Removes an entity. + pub fn removeEntity(m: *@This(), entity: EntityID) !void { + const mod_ptr: *Self.Mods() = @alignCast(@fieldParentPtr(Mods(), @tagName(module_tag), m)); + const world = @fieldParentPtr(Self, "mod", mod_ptr); + try world.entities.removeEntity(entity); + } + }; + } + + fn Mods() type { + var fields: []const StructField = &[0]StructField{}; + inline for (modules.modules) |M| { + fields = fields ++ [_]std.builtin.Type.StructField{.{ + .name = @tagName(M.name), + .type = Mod(M), + .default_value = null, + .is_comptime = false, + .alignment = @alignOf(Mod(M)), + }}; + } + return @Type(.{ + .Struct = .{ + .layout = .Auto, + .is_tuple = false, + .fields = fields, + .decls = &[_]std.builtin.Type.Declaration{}, + }, + }); + } + + pub fn init(allocator: mem.Allocator) !Self { + return Self{ + .allocator = allocator, + .entities = try Entities(modules.components).init(allocator), + .mod = undefined, + }; + } + + pub fn deinit(world: *Self) void { + world.entities.deinit(); + } + + /// Broadcasts an event to all modules that are subscribed to it. + /// + /// The message tag corresponds with the handler method name to be invoked. For example, + /// if `send(.tick)` is invoked, all modules which declare a `pub fn tick` will be invoked. + /// + /// Events sent by Mach itself, or the application itself, may be single words. To prevent + /// name conflicts, events sent by modules provided by a library should prefix their events + /// with their module name. For example, a module named `.ziglibs_imgui` should use event + /// names like `.ziglibsImguiClick`, `.ziglibsImguiFoobar`. + pub fn send(world: *Self, comptime optional_module_tag: anytype, comptime msg_tag: anytype, args: anytype) !void { + return world.sendStr(optional_module_tag, @tagName(msg_tag), args); + } + + pub fn sendStr(world: *Self, comptime optional_module_tag: anytype, comptime msg: anytype, args: anytype) !void { + // Check for any module that has a handler function named msg (e.g. `fn init` would match "init") + inline for (modules.modules) |M| { + const EventHandlers = blk: { + switch (@typeInfo(@TypeOf(optional_module_tag))) { + .Null => break :blk M, + .EnumLiteral => { + // Send this message only to the specified module + if (M.name != optional_module_tag) continue; + if (!@hasDecl(M, "local")) @compileError("Module ." ++ @tagName(M.name) ++ " does not have a `pub const local` event handler for message ." ++ msg); + if (!@hasDecl(M.local, msg)) @compileError("Module ." ++ @tagName(M.name) ++ " does not have a `pub const local` event handler for message ." ++ msg); + break :blk M.local; + }, + .Optional => if (optional_module_tag) |v| { + // Send this message only to the specified module + if (M.name != v) continue; + if (!@hasDecl(M, "local")) @compileError("Module ." ++ @tagName(M.name) ++ " does not have a `pub const local` event handler for message ." ++ msg); + if (!@hasDecl(M.local, msg)) @compileError("Module ." ++ @tagName(M.name) ++ " does not have a `pub const local` event handler for message ." ++ msg); + break :blk M.local; + }, + else => @panic("unexpected optional_module_tag type: " ++ @typeName(@TypeOf(optional_module_tag))), + } + }; + if (!@hasDecl(EventHandlers, msg)) continue; + + // Determine which parameters the handler function wants. e.g.: + // + // pub fn init(eng: *mach.Engine) !void + // pub fn init(eng: *mach.Engine, mach: *mach.Engine.Mod) !void + // + const handler = @field(EventHandlers, msg); + + // Build a tuple of parameters that we can pass to the function, based on what + // *mach.Mod(T) types it expects as arguments. + var params: std.meta.ArgsTuple(@TypeOf(handler)) = undefined; + comptime var argIndex = 0; + inline for (@typeInfo(@TypeOf(params)).Struct.fields) |param| { + comptime var found = false; + inline for (@typeInfo(Mods()).Struct.fields) |f| { + if (param.type == *f.type) { + // TODO: better initialization place for modules + @field(@field(world.mod, f.name), "entities") = &world.entities; + @field(@field(world.mod, f.name), "allocator") = world.allocator; + + @field(params, param.name) = &@field(world.mod, f.name); + found = true; + break; + } else if (param.type == *Self) { + @field(params, param.name) = world; + found = true; + break; + } else if (param.type == f.type) { + @compileError("Module handler " ++ @tagName(M.name) ++ "." ++ msg ++ " should be *T not T: " ++ @typeName(param.type)); + } + } + if (!found) { + @field(params, param.name) = args[argIndex]; + argIndex += 1; + } + } + + // Invoke the handler + try @call(.auto, handler, params); + } + } + }; +}