ecs: add very early-stages entity component system
This is the very, _very_, early stages of an entity component system for Mach. It's not ready for any real use, but rather is a starting point for further development. This spawned after [this research issue](https://github.com/hexops/mach/issues/127) in which I got tons of great information, tips, etc. and much more research that I did after the discussion in that issue. The idea is to start with this, and continue moulding it into what we want. As development continues.. * I've created [a room in the Matrix server for anyone who wants to chat](https://matrix.to/#/#ecs:matrix.org) * I'll be maintaining a blog detailing eerything I've learned and how I'm approaching development of this (as I plan to do for all key parts of Mach.) The first article in the series: [Let's build an Entity Component System from scatch (part 1)](https://devlog.hexops.com/2022/lets-build-ecs-part-1) Signed-off-by: Stephen Gutekanst <stephen@hexops.com>
This commit is contained in:
parent
3d392c8c74
commit
a75b599279
3 changed files with 401 additions and 0 deletions
49
ecs/README.md
Normal file
49
ecs/README.md
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# mach/ecs, an Entity Component System for Zig <a href="https://hexops.com"><img align="right" alt="Hexops logo" src="https://raw.githubusercontent.com/hexops/media/master/readme.svg"></img></a>
|
||||||
|
|
||||||
|
`mach/ecs` is an Entity Component System for Zig built from first-principles.
|
||||||
|
|
||||||
|
## Design principles:
|
||||||
|
|
||||||
|
* Clean-room implementation (author has not read any other ECS implementation code.)
|
||||||
|
* Solve the problems ECS solves, in a way that is natural to Zig and leverages Zig comptime.
|
||||||
|
* Avoid patent infringement upon Unity ECS patent claims.
|
||||||
|
* 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.
|
||||||
|
|
||||||
|
## ⚠️ in-development ⚠️
|
||||||
|
|
||||||
|
Under heavy development, not ready for use!
|
||||||
|
|
||||||
|
As development continues, we're publishing a blog series ["Let's build an Entity Component System from scatch"](https://devlog.hexops.com/categories/lets-build-an-ecs).
|
||||||
|
|
||||||
|
Join us in developing it, give us advice, etc. [on Matrix chat](https://matrix.to/#/#ecs:matrix.org) or [follow updates on Twitter](https://twitter.com/machengine).
|
||||||
|
|
||||||
|
## Known issues
|
||||||
|
|
||||||
|
There are plenty of known issues, and things that just aren't implemented yet. And certainly many unknown issues, too.
|
||||||
|
|
||||||
|
* Missing multi-threading!
|
||||||
|
* Currently only handles entity management, no world management or scheduling. No global data, etc.
|
||||||
|
* Lack of API documentation (see "example" test)
|
||||||
|
* Missing hooks that would enable visualizing memory usage, # of entities, components, etc. and otherwise enable integration of editors/visualizers/profilers/etc.
|
||||||
|
* TypedEntities does not allow for storage of sparse data yet, and also specifically comptime defined sparse data - both are needed. e.g. if only a handful of entities need a component but there are hundreds of thousands.
|
||||||
|
* If many entities are deleted, TypedEntities iteration becomes slower due to needing to skip over entities in the free_slots set, we should add a .compact() method that allows for remediating this as well as exposing .
|
||||||
|
* If *tons* of entities are deleted, even with .compact(), memory would not be free'd / returned to the OS by the underlying MultiArrayList. We could add a .compactAndFree() method to correct this.
|
||||||
|
* It would be nicer if there were configuration options for performing .compactAndFree() automatically, e.g. if the number of free entity slots is particularly high or something.
|
||||||
|
* Currently we do not expose an API for pre-allocating entities (i.e. allocating capacity up front) but that's very important for perf and memory usage in the real world.
|
||||||
|
* When TypedEntities is deinit'd, entity Archetypes - or maybe systems via an event/callback, need a way to be notified of destruction.
|
||||||
|
* Entities.get() currently operates on the archetype type name, but we should perhaps enable also getting a TypedEntities instance via passing a unique string name. e.g. if you want to store two separate team's Player entities in distinct TypedEntities collections.
|
||||||
|
* There should exist a Merge/Combine function for composing Archetypes from components and other Archetypes without explicitly listing every single component out in a new struct.
|
||||||
|
|
||||||
|
## Copyright & patent mitigation
|
||||||
|
|
||||||
|
The initial implementation was a clean-room implementation by Stephen Gutekanst without having
|
||||||
|
read other ECS implementations' code, but with speaking to people familiar with other ECS
|
||||||
|
implementations. Contributions past the initial implementation may be made by individuals in
|
||||||
|
non-clean-room settings (familiar with open source implementations only.)
|
||||||
|
|
||||||
|
Critically, this entity component system stores components for a classified archetype using both
|
||||||
|
a multi array list (independent arrays allocated per component) as well as hashmaps for sparse
|
||||||
|
component data for optimization. This is a novel and fundamentally different process than what
|
||||||
|
is described Unity Software Inc's patent US 10,599,560. This is not legal advice.
|
||||||
17
ecs/build.zig
Normal file
17
ecs/build.zig
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
pub fn build(b: *std.build.Builder) void {
|
||||||
|
const mode = b.standardReleaseOptions();
|
||||||
|
const target = b.standardTargetOptions(.{});
|
||||||
|
|
||||||
|
const lib = b.addStaticLibrary("ecs", "src/main.zig");
|
||||||
|
lib.setBuildMode(mode);
|
||||||
|
lib.setTarget(target);
|
||||||
|
lib.install();
|
||||||
|
|
||||||
|
const main_tests = b.addTest("src/main.zig");
|
||||||
|
main_tests.setBuildMode(mode);
|
||||||
|
|
||||||
|
const test_step = b.step("test", "Run library tests");
|
||||||
|
test_step.dependOn(&main_tests.step);
|
||||||
|
}
|
||||||
335
ecs/src/main.zig
Normal file
335
ecs/src/main.zig
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
//! mach/ecs is an Entity component system implementation.
|
||||||
|
//!
|
||||||
|
//! ## Design principles:
|
||||||
|
//!
|
||||||
|
//! * Clean-room implementation (author has not read any other ECS implementation code.)
|
||||||
|
//! * Solve the problems ECS solves, in a way that is natural to Zig and leverages Zig comptime.
|
||||||
|
//! * Avoid patent infringement upon Unity ECS patent claims.
|
||||||
|
//! * 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.
|
||||||
|
//!
|
||||||
|
//! ## Copyright & patent mitigation
|
||||||
|
//!
|
||||||
|
//! The initial implementation was a clean-room implementation by Stephen Gutekanst without having
|
||||||
|
//! read other ECS implementations' code, but with speaking to people familiar with other ECS
|
||||||
|
//! implementations. Contributions past the initial implementation may be made by individuals in
|
||||||
|
//! non-clean-room settings.
|
||||||
|
//!
|
||||||
|
//! Critically, this entity component system stores components for a classified archetype using both
|
||||||
|
//! a multi array list (independent arrays allocated per component) as well as hashmaps for sparse
|
||||||
|
//! component data for optimization. This is a novel and fundamentally different process than what
|
||||||
|
//! is described 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 is_debug = builtin.mode == .Debug;
|
||||||
|
|
||||||
|
const Entity = u32;
|
||||||
|
|
||||||
|
pub fn TypedEntities(comptime Archetype: type) type {
|
||||||
|
return struct {
|
||||||
|
allocator: Allocator,
|
||||||
|
components: std.MultiArrayList(Archetype),
|
||||||
|
free_slots: std.AutoHashMap(Entity, void),
|
||||||
|
|
||||||
|
pub const Archetype = Archetype;
|
||||||
|
const Self = @This();
|
||||||
|
|
||||||
|
pub const Iterator = struct {
|
||||||
|
e: *Self,
|
||||||
|
index: Entity = 0,
|
||||||
|
|
||||||
|
pub fn next(it: *Iterator) ?Archetype {
|
||||||
|
std.debug.assert(it.index <= it.e.components.len);
|
||||||
|
if (it.e.components.len == 0) return null;
|
||||||
|
|
||||||
|
while (it.index < it.e.components.len) {
|
||||||
|
if (it.e.contains(it.index)) {
|
||||||
|
const current = it.index;
|
||||||
|
it.index += 1;
|
||||||
|
return it.e.get(current);
|
||||||
|
}
|
||||||
|
it.index += 1;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn init(allocator: Allocator) Self {
|
||||||
|
return .{
|
||||||
|
.allocator = allocator,
|
||||||
|
.components = std.MultiArrayList(Archetype){},
|
||||||
|
.free_slots = std.AutoHashMap(Entity, void).init(allocator),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add(self: *Self, components: Archetype) error{OutOfMemory}!Entity {
|
||||||
|
// Reuse a free slot, if available.
|
||||||
|
if (self.free_slots.count() > 0) {
|
||||||
|
var iter = self.free_slots.keyIterator();
|
||||||
|
const taken = iter.next().?.*;
|
||||||
|
_ = self.free_slots.remove(taken);
|
||||||
|
self.components.set(taken, components);
|
||||||
|
return taken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new slot, potentially allocating.
|
||||||
|
try self.components.append(self.allocator, components);
|
||||||
|
return @intCast(Entity, self.components.len-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In debug builds, panics if the entity does not exist.
|
||||||
|
pub fn update(self: *Self, entity: Entity, partial_components: anytype) void {
|
||||||
|
if (is_debug and !self.contains(entity)) @panic("no such entity");
|
||||||
|
inline for (@typeInfo(@TypeOf(partial_components)).Struct.fields) |field, i| {
|
||||||
|
const fieldEnum = @intToEnum(std.MultiArrayList(Archetype).Field, i);
|
||||||
|
self.components.items(fieldEnum)[entity] = @field(partial_components, field.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In debug builds, panics if the entity does not exist.
|
||||||
|
pub fn set(self: *Self, entity: Entity, components: Archetype) void {
|
||||||
|
if (is_debug and !self.contains(entity)) @panic("no such entity");
|
||||||
|
self.components.set(entity, components);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In debug builds, panics if the entity does not exist.
|
||||||
|
pub fn remove(self: *Self, entity: Entity) error{OutOfMemory}!void {
|
||||||
|
if (is_debug and !self.contains(entity)) @panic("no such entity");
|
||||||
|
try self.free_slots.put(entity, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn contains(self: *Self, entity: Entity) bool {
|
||||||
|
if (entity > self.components.len-1) return false;
|
||||||
|
return !self.free_slots.contains(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In debug builds, panics if the entity does not exist.
|
||||||
|
pub fn get(self: *Self, entity: Entity) Archetype {
|
||||||
|
if (is_debug and !self.contains(entity)) @panic("no such entity");
|
||||||
|
return self.components.get(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a copy using the same allocator
|
||||||
|
pub inline fn clone(self: Self) !Self {
|
||||||
|
return self.cloneWithAllocator(self.allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a copy using a specified allocator
|
||||||
|
pub inline fn cloneWithAllocator(self: Self, new_allocator: Allocator) !Self {
|
||||||
|
return Self{
|
||||||
|
.allocator = new_allocator,
|
||||||
|
.components = try self.components.clone(new_allocator),
|
||||||
|
.free_slots = try self.free_slots.cloneWithAllocator(new_allocator),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn iterator(self: *Self) Iterator {
|
||||||
|
return .{ .e = self };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deinit(self: *Self) void {
|
||||||
|
self.components.deinit(self.allocator);
|
||||||
|
self.free_slots.deinit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn Entities(comptime archetypes: anytype) type {
|
||||||
|
comptime var fields: []const std.builtin.TypeInfo.StructField = &.{};
|
||||||
|
inline for (archetypes) |Archetype| {
|
||||||
|
fields = fields ++ [_]std.builtin.TypeInfo.StructField{.{
|
||||||
|
.name = @typeName(Archetype),
|
||||||
|
.field_type = TypedEntities(Archetype),
|
||||||
|
.is_comptime = false,
|
||||||
|
.default_value = null,
|
||||||
|
.alignment = 0,
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
const ComptimeEntities = @Type(.{
|
||||||
|
.Struct = .{
|
||||||
|
.layout = .Auto,
|
||||||
|
.is_tuple = false,
|
||||||
|
.decls = &.{},
|
||||||
|
.fields = fields,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const RuntimeEntities = struct {
|
||||||
|
erased: *anyopaque,
|
||||||
|
deinit: fn(Allocator, *anyopaque) void,
|
||||||
|
clone: fn(Allocator, *anyopaque) error{OutOfMemory}!*anyopaque,
|
||||||
|
};
|
||||||
|
|
||||||
|
return struct {
|
||||||
|
allocator: Allocator,
|
||||||
|
comptime_entities: ComptimeEntities,
|
||||||
|
runtime_entities: std.StringHashMap(RuntimeEntities),
|
||||||
|
|
||||||
|
const Self = @This();
|
||||||
|
|
||||||
|
pub fn init(allocator: Allocator) Self {
|
||||||
|
var comptime_entities: ComptimeEntities = undefined;
|
||||||
|
inline for (archetypes) |Archetype| {
|
||||||
|
@field(comptime_entities, @typeName(Archetype)) = TypedEntities(Archetype).init(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
return .{
|
||||||
|
.allocator = allocator,
|
||||||
|
.comptime_entities = comptime_entities,
|
||||||
|
.runtime_entities = std.StringHashMap(RuntimeEntities).init(allocator),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(self: *Self, comptime Archetype: type) !*TypedEntities(Archetype) {
|
||||||
|
// Comptime archetype lookup
|
||||||
|
if (@hasField(ComptimeEntities, @typeName(Archetype))) {
|
||||||
|
return &@field(self.comptime_entities, @typeName(Archetype));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime archetype lookup
|
||||||
|
var v = try self.runtime_entities.getOrPut(@typeName(Archetype));
|
||||||
|
if (!v.found_existing) {
|
||||||
|
const new = try self.allocator.create(TypedEntities(Archetype));
|
||||||
|
new.* = TypedEntities(Archetype).init(self.allocator);
|
||||||
|
v.value_ptr.* = RuntimeEntities{
|
||||||
|
.erased = new,
|
||||||
|
.deinit = (struct{
|
||||||
|
pub fn deinit(allocator: Allocator, erased: *anyopaque) void {
|
||||||
|
const aligned = @alignCast(@alignOf(*TypedEntities(Archetype)), erased);
|
||||||
|
const entities = @ptrCast(*TypedEntities(Archetype), aligned);
|
||||||
|
entities.deinit();
|
||||||
|
allocator.destroy(entities);
|
||||||
|
}
|
||||||
|
}.deinit),
|
||||||
|
.clone = (struct {
|
||||||
|
pub fn clone(new_allocator: Allocator, erased: *anyopaque) error{OutOfMemory}!*anyopaque {
|
||||||
|
const aligned = @alignCast(@alignOf(*TypedEntities(Archetype)), erased);
|
||||||
|
const entities = @ptrCast(*TypedEntities(Archetype), aligned);
|
||||||
|
const new_erased = try new_allocator.create(TypedEntities(Archetype));
|
||||||
|
new_erased.* = try entities.cloneWithAllocator(new_allocator);
|
||||||
|
return new_erased;
|
||||||
|
}
|
||||||
|
}).clone,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const aligned = @alignCast(@alignOf(*TypedEntities(Archetype)), v.value_ptr.erased);
|
||||||
|
return @ptrCast(*TypedEntities(Archetype), aligned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a copy using the same allocator
|
||||||
|
pub inline fn clone(self: Self) !Self {
|
||||||
|
return self.cloneWithAllocator(self.allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a copy using a specified allocator
|
||||||
|
pub fn cloneWithAllocator(self: Self, new_allocator: Allocator) !Self {
|
||||||
|
var comptime_entities: ComptimeEntities = undefined;
|
||||||
|
inline for (archetypes) |Archetype| {
|
||||||
|
const field = @field(self.comptime_entities, @typeName(Archetype));
|
||||||
|
@field(comptime_entities, @typeName(Archetype)) = try field.cloneWithAllocator(new_allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
var runtime_entities = try self.runtime_entities.cloneWithAllocator(new_allocator);
|
||||||
|
var iter = runtime_entities.valueIterator();
|
||||||
|
while (iter.next()) |entities| {
|
||||||
|
entities.erased = try entities.clone(new_allocator, entities.erased);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Self{
|
||||||
|
.allocator = new_allocator,
|
||||||
|
.comptime_entities = comptime_entities,
|
||||||
|
.runtime_entities = runtime_entities,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deinit(self: *Self) void {
|
||||||
|
inline for (archetypes) |Archetype| {
|
||||||
|
@field(self.comptime_entities, @typeName(Archetype)).deinit();
|
||||||
|
}
|
||||||
|
var runtime_iter = self.runtime_entities.valueIterator();
|
||||||
|
while (runtime_iter.next()) |runtime_entities| {
|
||||||
|
runtime_entities.deinit(self.allocator, runtime_entities.erased);
|
||||||
|
}
|
||||||
|
self.runtime_entities.deinit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test "example" {
|
||||||
|
// A location component.
|
||||||
|
const Location = struct {
|
||||||
|
x: f32 = 0,
|
||||||
|
y: f32 = 0,
|
||||||
|
z: f32 = 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// A name component.
|
||||||
|
const Name = []const u8;
|
||||||
|
|
||||||
|
// A player archetype.
|
||||||
|
const Player = struct {
|
||||||
|
name: Name,
|
||||||
|
location: Location = .{},
|
||||||
|
};
|
||||||
|
|
||||||
|
const allocator = testing.allocator;
|
||||||
|
|
||||||
|
// Entities for e.g. a world. Stores multiple archetypes!
|
||||||
|
var entities = Entities(.{
|
||||||
|
// Predeclare your archetypes up front here and you get Archetype lookups at comptime / for free!
|
||||||
|
Player,
|
||||||
|
}).init(allocator);
|
||||||
|
defer entities.deinit();
|
||||||
|
|
||||||
|
// Get the player entities
|
||||||
|
var players = try entities.get(Player);
|
||||||
|
|
||||||
|
// A monster archetype
|
||||||
|
const Monster = struct {
|
||||||
|
name: Name,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the monster entities - note that we didn't declare this archetype up front in Entities.init!
|
||||||
|
// This archetype lookup will be done at runtime via a type name hashmap
|
||||||
|
var monsters = try entities.get(Monster);
|
||||||
|
|
||||||
|
// Let's add some entities!
|
||||||
|
const carrot = try monsters.add(.{.name = "carrot"});
|
||||||
|
const tomato = try monsters.add(.{.name = "tomato"});
|
||||||
|
const potato = try monsters.add(.{.name = "potato"});
|
||||||
|
|
||||||
|
// Remove some of our entities
|
||||||
|
try monsters.remove(carrot);
|
||||||
|
try monsters.remove(tomato);
|
||||||
|
|
||||||
|
// Change an entity's components
|
||||||
|
monsters.set(potato, .{.name = "totally real potato"});
|
||||||
|
|
||||||
|
// Don't want to set all the components of an entity? i.e. just want to set one field? This can
|
||||||
|
// be more efficient:
|
||||||
|
monsters.update(potato, .{.name = "secretly tomato"});
|
||||||
|
|
||||||
|
// Get all components of an entity
|
||||||
|
try testing.expectEqual(Monster{.name = "secretly tomato"}, monsters.get(potato));
|
||||||
|
|
||||||
|
// Iterate entities.
|
||||||
|
_ = try players.add(.{.name = "jane"});
|
||||||
|
_ = try players.add(.{.name = "bob"});
|
||||||
|
var iter = players.iterator();
|
||||||
|
try testing.expectEqualStrings("jane", iter.next().?.name);
|
||||||
|
try testing.expectEqualStrings("bob", iter.next().?.name);
|
||||||
|
|
||||||
|
// We can clone sets of entities, if needed.
|
||||||
|
var players2 = try players.clone();
|
||||||
|
defer players2.deinit();
|
||||||
|
|
||||||
|
// Or maybe clone ALL entities!
|
||||||
|
var entities2 = try entities.clone();
|
||||||
|
defer entities2.deinit();
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue