b3c6ad95540df8ef53e670d743745aee6d3f1a62
60d8484528395db2f09cf19ba15e7a45c351dd37
0cad4646be2791982f692a7a47716ec92b62eb2e
An archetype based entity component system written in Zig.
main
loosely tracks Zig master. For support for previous Zig versions, see releases.
ZCS is beta software. Once I've shipped a commercial game using ZCS, I'll start to stabilize the API and remove this disclaimer.
If there are no recent commits at the time you're reading this, the project isn't dead--I'm just working on a game!
Here's a quick look at what code using ZCS looks like:
const std = @import("std");
const zcs = @import("zcs");
const Entities = zcs.Entities;
const Entity = zcs.Entity;
const Transform = zcs.ext.Transform2D;
const Node = zcs.ext.Node;
fn main() void {
// Reserve space for the game objects and for a command buffer.
// ZCS doesn't allocate any memory after initialization, but you
// can change the default capacities here if you like--or leave
// them at their defaults as in this example. If you ever exceed
// 20% capacity you'll get a warning by default.
var es: Entities = try .init(.{ .gpa = gpa });
defer es.deinit(gpa);
var cb = try CmdBuf.init(.{
.name = "cb",
.gpa = gpa,
.es = &es,
});
defer cb.deinit(gpa, &es);
// Create an entity and associate some component data with it.
// We could do this directly, but instead we're demonstrating the
// command buffer API.
const e: Entity = .reserve(&cb);
e.add(&cb, Transform, .{});
e.add(&cb, Node, .{});
// Execute the command buffer
// We're using a helper from the `transform` extension here instead of
// executing it directly. This is part of ZCS's support for command
// buffer extensions, we'll touch more on this later.
Transform.Exec.immediate(&es, &cb);
// Iterate over entities that contain both transform and node
var iter = es.iterator(struct {
transform: *Transform,
node: *Node,
});
while (iter.next(&es)) |vw| {
// You can operate on `vw.transform.*` and `vw.node.*` here!
}
}
Full documentation available here, you can generate up to date docs yourself with zig build docs
.
I'll add example projects to the repo as soon as I've set up a renderer that's easy to build without requiring various system libraries be installed etc, tracking issue here.
For now, you're welcome to reference 2Pew. Just keep in mind that 2Pew is a side project I don't have a lot of time for right now, it's a decent reference but not a full game.
An entity component system (or "ECS") is a way to manage your game objects that often resembles a relational database.
An entity is an object in your game, a component is a piece of data that's associated with an entity (for example a sprite), and a system is a piece of code that iterates over entities with a set of components and processes them.
A simple alternative to working with an ECS would be something like std.MultiArrayList
; a growable struct of arrays.
A well implemented ECS is more complex than MultiArrayList
, but that complexity buys you a lot of convenience. Performance will be comparable.
For a discussion of what features are provided by this ECS see Key Features, for performance information see Performance, and for further elaboration on my philosophy on game engines and abstraction see my talk It's Not About The Technology - Game Engines are Art Tools.
Games often feature objects whose lifetimes are not only dynamic, but depend on user input. ZCS provides persistent keys for entities, so they're never dangling:
assert(laser.exists(es));
assert(laser.get(es, Sprite) != null);
laser.destroyImmediately(es);
assert(!laser.exists(es));
assert(laser.get(es, Sprite) == null);
This is achieved through a 32 bit generation counter on each entity slot. Slots are retired when their generations are saturated to prevent false negatives, see SlotMap for more info.
This strategy allows you to safely and easily store entity handles across frames or in component data.
For all allowed operations on an entity handle, see Entity
.
Gameplay systems often end up coupled not due to bad coding practice, but because these interdependencies often lead to dynamic and interesting gameplay.
Archetype based iteration via Entities.iterator
allows you to efficiently query for entities with a given set of components. This can be a convenient way to express this kind of coupling:
var iter = es.iterator(struct {
mesh: *const Mesh,
transform: *const Transform,
effect: ?*const Effect,
});
while (iter.next()) |vw| {
vw.mesh.render(vw.transform, vw.effect);
}
If you prefer, forEach
syntax sugar is also provided. The string argument is only used if Tracy is enabled:
fn updateMeshWithEffect(
ctx: void,
mesh: *const Mesh,
transform: *const Transform,
effect: ?*const Effect,
) void {
// ...
}
es.forEach("updateMeshWithEffect", updateMeshWithEffect, {});
Entities.chunkIterator
is also provided for iterating over contiguous chunks of component data instead of individual entities. This can be useful e.g. to optimize your systems with SIMD.
If you're already making use of std.Thread.Pool
, you can operate on your chunks in parallel with forEachThreaded
.
Have your own job system? No problem. forEachThreaded
is implemented on top of ZCS's public interface, wiring it up to your own threading model won't require a fork.
Games often want to make destructive changes to the game state while processing a frame.
Command buffers allow you to make destructive changes without invalidating iterators, including in a multithreaded context.
// Allocate a command buffer
var cb: CmdBuf = try .init(.{ .gpa = gpa, .es = &es });
defer cb.deinit(allocator, &es);
// Get the next reserved entity. By reserving entities up front, the
// command buffer allows you to create entities from background threads
// without contention.
const e = Entity.reserve(&cb);
// Schedule an archetype change for the reserved entity, this will
// assign it storage when the command buffer executes. If the component
// is comptime known and larger than pointer sized, it will
// automatically be stored by pointer instead of by value.
e.add(&cb, RigidBody, .{ .mass = 20 });
e.add(&cb, Sprite, .{ .index = .cat });
// Execute the command buffer, and then clear it for reuse. This would
// be done from the main thread.
CmdBuf.Exec.immediate(&es, &cb);
For more information, see CmdBuf
.
When working with multiple threads, you'll likely want to use CmdPool
to manage your command buffer allocations instead of creating them directly. This will allocate a large number of smaller command buffers, and hand them out on a per chunk basis.
This saves you from needing to adjust the number of command buffers you allocate or their capacities based on core count or workload distribution.
If you need to bypass the command buffer system and make changes directly, you can. Invalidating an iterator while it's in use due to having bypassed the command buffer system is safety checked illegal behavior.
Entities often have relationships to one another. As such, operations like destroying an entity may have side effects on other entities. In ZCS this is achieved through command buffer extensions.
The key idea is that external code can add extension commands with arbitrary payloads to the command buffer, and then later iterate the command buffer to execute those commands or react to the standard ones.
This allows extending the behavior of the command buffer executor without callbacks. This is important because the order of operation between various extensions and the default behavior is often important and very difficult to manage in a callback based system.
To avoid iterating the same command buffer multiple times--and to allow extension commands to change the behavior of the built in commands--you're expected to compose extension code with the default execution functions provided under CmdBuf.Exec
.
As an example of this pattern, zcs.ext
provides a number of useful components and command buffer extensions that rely only on ZCS's public API...
The Node
component allows for linking objects to other objects in parent child relationships. You can modify these relationships directly, or via command buffers:
cb.ext(Node.SetParent, .{
.child = thruster,
.parent = ship.toOptional(),
});
Helper methods are provided to query parents, iterate children, etc:
var children = ship.get(Node).?.childIterator();
while (children.next()) |child| {
// Do something with `child`
}
if (thruster.get(Node).?.parent.get(&es)) |parent| {
// Do something with `parent`
}
The full list of supported features can be found in the docs.
Node doesn't have a maximum child count, and adding children does not allocate an array. This is possible because each node has the following fields:
parent
first_child
prev_sib
next_sib
Deletion of child objects, cycle prevention, etc are all handled for you. You just need to use the provided helpers or command buffer extension command for setting the parent, and to call into Node.Exec.immediate
to execute your command buffer:
Node.Exec.immediate(&es, &cb);
Keep in mind that this will call the default exec behavior as well as implement the extended behavior provided by Node
. If you're also integrating other unrelated extensions, a lower level composable API is provided in Node.Exec
for building your own executor.
The Transform2D
component represents the position and orientation of an entity in 2D space. If an entity also has a Node
and relative
is true
, its local space is relative to that of its parent.
vw.transform.move(es, vw.rb.vel.scaled(delta_s));
vw.transform.rotate(es, .fromAngle(vw.rb.rotation_vel * delta_s));
Transform children are immediately synchronized by these helpers, but you can defer synchronization until a later point by bypassing the helpers and then later calling transform.sync(es)
.
Transform2D
depends on geom for math.
Deferred work can be hard to profile. As such, ZCS provides an extension ZoneCmd
that allows you to start and end Tracy zones from within a command buffer:
const exec_zone = ZoneCmd.begin(&cb, .{
.src = @src(),
.name = "zombie pathfinding",
});
defer exec_zone.end(&cb);
ZCS integrates with Tracy via tracy_zig. ZCS shouldn't be your bottleneck, but with this integration you can be sure of it--and you can track down where the bottleneck is.
In particular, ZCS...
ZoneCmd
extensionMost ECS implementations use some form of generics to provide a friendly interface. ZCS is no exception, and Zig makes this easier than ever.
However, when important types become generic, it infects the whole code base--everything that needs to interact with the ECS also needs to become generic, or at least depend on an instantiation of a generic type. This makes it hard to write modular/library code, and presumably will hurt incremental compile times in the near future.
As such, while ZCS uses generic methods where it's convenient, types at API boundaries are typically not generic. For example, Entities
which stores all the ECS data is not a generic type, and libraries are free to add new component types to entities without an explicit registration step.
ZCS is archetype based.
An "archetype" is a unique set of component types--for example, all entities that have both a RigidBody
and a Mesh
component share an archetype, whereas an entity that contains a RigidBody
a Mesh
and a MonsterAi
has a different archetype.
Archetypes are packed tightly in memory into chunks with the following layout:
Component data is laid out in AAABBBCCC
order within the chunk, sorted from greatest to least alignment requirements to minimize padding. Chunks size is configurable but must be a power of two, in practice this results in chunk sizes that are a multiple of the cache line size which prevents false sharing when operating on chunks in parallel.
A simple acceleration structure is provided to make finding all chunks compatible with a given archetype efficient.
Comparing performance with something like MultiArrayList
:
MultiArrayList
was somehow preprocessed to remove all the undesired results and then tightly packed before starting the timerMultiArrayList
or leaving a hole in it since more bookkeeping is involved for the aforementioned acceleration and persistent handlesMultiArrayList
as the persistent handles introduce a layer of indirectionNo dynamic allocation is done after initialization.
Contributions are welcome! If you'd like to add a major feature, please file a proposal or leave a comment on the relevant issue first.