Introduction
Welcome to The Specs Book, an introduction to ECS and the Specs API. This book is targeted at beginners; guiding you through all the difficulties of setting up, building, and structuring a game with an ECS.
Specs is an ECS library that allows parallel system execution, with both low overhead and high flexibility, different storage types and a type-level system data model. It is mainly used for games and simulations, where it allows to structure code using composition over inheritance.
Additional documentation is available on docs.rs
:
There also is a reference-style documentation available here:
You don't yet know what an ECS is all about? The next section is for you! In case you already know what an ECS is, just skip it.
What's an ECS?
The term ECS is a shorthand for Entity-component system. These are the three
core concepts. Each entity is associated with some components. Those entities and
components are processed by systems. This way, you have your data (components)
completely separated from the behaviour (systems). An entity just logically
groups components; so a Velocity
component can be applied to the Position
component
of the same entity.
ECS is sometimes seen as a counterpart to Object-Oriented Programming. I wouldn't say that's one hundred percent true, but let me give you some comparisons.
In OOP, your player might look like this (I've used Java for the example):
public class Player extends Character {
private final Transform transform;
private final Inventory inventory;
}
There are several limitations here:
- There is either no multiple inheritance or it brings other problems with it, like the diamond problem; moreover, you have to think about "is the player a collider or does it have a collider?"
- You cannot easily extend the player with modding; all the attributes are hardcoded.
- Imagine you want to add a NPC, which looks like this:
public class Npc extends Character {
private final Transform transform;
private final Inventory inventory;
private final boolean isFriendly;
}
Now you have stuff duplicated; you would have to write mostly identical code for your player and the NPC, even though e.g. they both share a transform.
This is where ECS comes into play: Components are associated with entities; you can just insert components, whenever you like.
One entity may or may not have a certain component. You can see an Entity
as an ID into component tables, as illustrated in the
diagram below. We could theoretically store all the components together with the entity, but that would be very inefficient;
you'll see how these tables work in chapter 5.
This is how an Entity
is implemented; it's just
struct Entity(u32, Generation);
where the first field is the id and the second one is the generation, used to check if the entity has been deleted.
Here's another illustration of the relationship between components and entities. Force
, Mass
and Velocity
are all components here.
Entity 1
has each of those components, Entity 2
only a Force
, etc.
Now we're only missing the last character in ECS - the "S" for System
. Whereas components and entities are purely data,
systems contain all the logic of your application. A system typically iterates over all entities that fulfill specific constraints,
like "has both a force and a mass". Based on this data a system will execute code, e.g. produce a velocity out of the force and the mass.
This is the additional advantage I wanted to point out with the Player
/ Npc
example; in an ECS, you can simply add new attributes
to entities and that's also how you define behaviour in Specs (this is called data-driven programming).
By simply adding a force to an entity that has a mass, you can make it move, because a Velocity
will be produced for it.
Where to use an ECS?
In case you were looking for a general-purpose library for doing things the data-oriented way, I have to disappoint you; there are none. ECS libraries are best-suited for creating games or simulations, but they do not magically make your code more data-oriented.
Okay, now that you were given a rough overview, let's continue to Chapter 2 where we'll build our first actual application with Specs.
Hello, World
!
Setting up
First of all, thanks for trying out specs
.
Before setting up the project, please make sure you're using the latest Rust version:
rustup update
Okay, now let's set up the project!
cargo new --bin my_game
Add the following line to your Cargo.toml
:
[dependencies]
specs = "0.16.1"
Components
Let's start by creating some data:
use specs::{Component, VecStorage};
#[derive(Debug)]
struct Position {
x: f32,
y: f32,
}
impl Component for Position {
type Storage = VecStorage<Self>;
}
#[derive(Debug)]
struct Velocity {
x: f32,
y: f32,
}
impl Component for Velocity {
type Storage = VecStorage<Self>;
}
These will be our two component types. Optionally, the specs-derive
crate
provides a convenient custom #[derive]
you can use to define component types
more succinctly.
But first, you will need to enable the derive
feature:
[dependencies]
specs = { version = "0.16.1", features = ["specs-derive"] }
Now you can do this:
use specs::{Component, VecStorage};
#[derive(Component, Debug)]
#[storage(VecStorage)]
struct Position {
x: f32,
y: f32,
}
#[derive(Component, Debug)]
#[storage(VecStorage)]
struct Velocity {
x: f32,
y: f32,
}
If the #[storage(...)]
attribute is omitted, the given component will be
stored in a DenseVecStorage
by default. But for this example, we are
explicitly asking for these components to be kept in a VecStorage
instead (see
the later storages chapter for more details). But before we move on, we
need to create a world in which to store all of our components.
The World
use specs::{World, WorldExt, Builder};
let mut world = World::new();
world.register::<Position>();
world.register::<Velocity>();
This will create component storages for Position
s and Velocity
s.
let ball = world.create_entity().with(Position { x: 4.0, y: 7.0 }).build();
Now you have an Entity
, associated with a position.
Note:
World
is a struct coming fromshred
, an important dependency of Specs. Whenever you call functions specific to Specs, you will need to import theWorldExt
trait.
So far this is pretty boring. We just have some data, but we don't do anything with it. Let's change that!
The system
use specs::System;
struct HelloWorld;
impl<'a> System<'a> for HelloWorld {
type SystemData = ();
fn run(&mut self, data: Self::SystemData) {}
}
This is what a system looks like. Though it doesn't do anything (yet).
Let's talk about this dummy implementation first.
The SystemData
is an associated type
which specifies which components we need in order to run
the system.
Let's see how we can read our Position
components:
use specs::{ReadStorage, System};
struct HelloWorld;
impl<'a> System<'a> for HelloWorld {
type SystemData = ReadStorage<'a, Position>;
fn run(&mut self, position: Self::SystemData) {
use specs::Join;
for position in position.join() {
println!("Hello, {:?}", &position);
}
}
}
Note that all components that a system accesses must be registered with
world.register::<Component>()
before that system is run, or you will get a
panic. This will usually be done automatically during setup
, but we'll
come back to that in a later chapter.
There are many other types you can use as system data. Please see the System Data Chapter for more information.
Running the system
This just iterates through all the components and prints
them. To execute the system, you can use RunNow
like this:
use specs::RunNow;
let mut hello_world = HelloWorld;
hello_world.run_now(&world);
world.maintain();
The world.maintain()
is not completely necessary here. Calling maintain should be done in general, however.
If entities are created or deleted while a system is running, calling maintain
will record the changes in its internal data structure.
Full example code
Here the complete example of this chapter:
use specs::{Builder, Component, ReadStorage, System, VecStorage, World, WorldExt, RunNow};
#[derive(Debug)]
struct Position {
x: f32,
y: f32,
}
impl Component for Position {
type Storage = VecStorage<Self>;
}
#[derive(Debug)]
struct Velocity {
x: f32,
y: f32,
}
impl Component for Velocity {
type Storage = VecStorage<Self>;
}
struct HelloWorld;
impl<'a> System<'a> for HelloWorld {
type SystemData = ReadStorage<'a, Position>;
fn run(&mut self, position: Self::SystemData) {
use specs::Join;
for position in position.join() {
println!("Hello, {:?}", &position);
}
}
}
fn main() {
let mut world = World::new();
world.register::<Position>();
world.register::<Velocity>();
world.create_entity().with(Position { x: 4.0, y: 7.0 }).build();
let mut hello_world = HelloWorld;
hello_world.run_now(&world);
world.maintain();
}
This was a pretty basic example so far. A key feature we haven't seen is the
Dispatcher
, which allows us to configure systems to run in parallel (and it offers
some other nice features, too).
Let's see how that works in Chapter 3: Dispatcher.
Dispatcher
When to use a Dispatcher
The Dispatcher
allows you to automatically parallelize
system execution where possible, using the fork-join model to split up the
work and merge the result at the end. It requires a bit more planning
and may have a little bit more overhead, but it's pretty convenient,
especially when you're building a big game where you don't
want to do this manually.
Building a dispatcher
First of all, we have to build such a dispatcher.
use specs::DispatcherBuilder;
let mut dispatcher = DispatcherBuilder::new()
.with(HelloWorld, "hello_world", &[])
.build();
Let's see what this does. After creating the builder, we add a new
- system object (
HelloWorld
) - with some name (
"hello_world""
) - and no dependencies (
&[]
).
The name can be used to specify that system as a dependency of another one. But we don't have a second system yet.
Creating another system
struct UpdatePos;
impl<'a> System<'a> for UpdatePos {
type SystemData = (ReadStorage<'a, Velocity>,
WriteStorage<'a, Position>);
}
Let's talk about the system data first. What you see here is a tuple, which we are using as our SystemData
.
In fact, SystemData
is implemented for all tuples with up to 26 other types implementing SystemData
in it.
Notice that
ReadStorage
andWriteStorage
are implementors ofSystemData
themselves, that's why we could use the first one for ourHelloWorld
system without wrapping it in a tuple; for more information see the Chapter about system data.
To complete the implementation block, here's the run
method:
fn run(&mut self, (vel, mut pos): Self::SystemData) {
use specs::Join;
for (vel, pos) in (&vel, &mut pos).join() {
pos.x += vel.x * 0.05;
pos.y += vel.y * 0.05;
}
}
Now the .join()
method also makes sense: it joins the two component
storages, so that you either get no new element or a new element with
both components, meaning that entities with only a Position
, only
a Velocity
or none of them will be skipped. The 0.05
fakes the
so called delta time which is the time needed for one frame.
We have to hardcode it right now, because it's not a component (it's the
same for every entity). The solution to this are Resource
s, see
the next Chapter.
Adding a system with a dependency
Okay, we'll add two more systems after the HelloWorld
system:
.with(UpdatePos, "update_pos", &["hello_world"])
.with(HelloWorld, "hello_updated", &["update_pos"])
The UpdatePos
system now depends on the HelloWorld
system and will only
be executed after the dependency has finished. The final HelloWorld
system prints the resulting updated positions.
Now to execute all the systems, just do
dispatcher.dispatch(&mut world);
Full example code
Here the code for this chapter:
use specs::{Builder, Component, DispatcherBuilder, ReadStorage,
System, VecStorage, World, WorldExt, WriteStorage};
#[derive(Debug)]
struct Position {
x: f32,
y: f32,
}
impl Component for Position {
type Storage = VecStorage<Self>;
}
#[derive(Debug)]
struct Velocity {
x: f32,
y: f32,
}
impl Component for Velocity {
type Storage = VecStorage<Self>;
}
struct HelloWorld;
impl<'a> System<'a> for HelloWorld {
type SystemData = ReadStorage<'a, Position>;
fn run(&mut self, position: Self::SystemData) {
use specs::Join;
for position in position.join() {
println!("Hello, {:?}", &position);
}
}
}
struct UpdatePos;
impl<'a> System<'a> for UpdatePos {
type SystemData = (ReadStorage<'a, Velocity>,
WriteStorage<'a, Position>);
fn run(&mut self, (vel, mut pos): Self::SystemData) {
use specs::Join;
for (vel, pos) in (&vel, &mut pos).join() {
pos.x += vel.x * 0.05;
pos.y += vel.y * 0.05;
}
}
}
fn main() {
let mut world = World::new();
world.register::<Position>();
world.register::<Velocity>();
// Only the second entity will get a position update,
// because the first one does not have a velocity.
world.create_entity().with(Position { x: 4.0, y: 7.0 }).build();
world
.create_entity()
.with(Position { x: 2.0, y: 5.0 })
.with(Velocity { x: 0.1, y: 0.2 })
.build();
let mut dispatcher = DispatcherBuilder::new()
.with(HelloWorld, "hello_world", &[])
.with(UpdatePos, "update_pos", &["hello_world"])
.with(HelloWorld, "hello_updated", &["update_pos"])
.build();
dispatcher.dispatch(&mut world);
world.maintain();
}
The next chapter will be a really short chapter about Resource
s,
a way to share data between systems which only exist independent of
entities (as opposed to 0..1 times per entity).
Resources
This (short) chapter will explain the concept of resources, data which is shared between systems.
First of all, when would you need resources? There's actually a great example in chapter 3, where we just faked the delta time when applying the velocity. Let's see how we can do this the right way.
#[derive(Default)]
struct DeltaTime(f32);
Note: In practice you may want to use
std::time::Duration
instead, because you shouldn't usef32
s for durations in an actual game, because they're not precise enough.
Adding this resource to our world is pretty easy:
world.insert(DeltaTime(0.05)); // Let's use some start value
To update the delta time, just use
use specs::WorldExt;
let mut delta = world.write_resource::<DeltaTime>();
*delta = DeltaTime(0.04);
Accessing resources from a system
As you might have guessed, there's a type implementing system data
specifically for resources. It's called Read
(or Write
for
write access).
So we can now rewrite our system:
use specs::{Read, ReadStorage, System, WriteStorage};
struct UpdatePos;
impl<'a> System<'a> for UpdatePos {
type SystemData = (Read<'a, DeltaTime>,
ReadStorage<'a, Velocity>,
WriteStorage<'a, Position>);
fn run(&mut self, data: Self::SystemData) {
let (delta, vel, mut pos) = data;
// `Read` implements `Deref`, so it
// coerces to `&DeltaTime`.
let delta = delta.0;
for (vel, pos) in (&vel, &mut pos).join() {
pos.x += vel.x * delta;
pos.y += vel.y * delta;
}
}
}
Note that all resources that a system accesses must be registered with
world.insert(resource)
before that system is run, or you will get a
panic. If the resource has a Default
implementation, this step is usually
done during setup
, but again we will come back to this in a later chapter.
For more information on SystemData
, see the system data chapter.
Default
for resources
As we have learned in previous chapters, to fetch a Resource
in our
SystemData
, we use Read
or Write
. However, there is one issue we
have not mentioned yet, and that is the fact that Read
and Write
require
Default
to be implemented on the resource. This is because Specs will
automatically try to add a Default
version of a resource to the World
during setup
(we will come back to the setup
stage in the next chapter).
But how do we handle the case when we can't implement Default
for our resource?
There are actually three ways of doing this:
- Using a custom
SetupHandler
implementation, you can provide this inSystemData
withRead<'a, Resource, TheSetupHandlerType>
. - By replacing
Read
andWrite
withReadExpect
andWriteExpect
, which will cause the first dispatch of theSystem
to panic unless the resource has been added manually toWorld
first. - By using
Option<Read<'a, Resource>>
, if the resource really is optional. Note that the order here is important, usingRead<'a, Option<Resource>>
will not result in the same behavior (it will try to fetchOption<Resource>
fromWorld
, instead of doing an optional check ifResource
exists).
In the next chapter, you will learn about the different storages and when to use which one.
Storages
Specs contains a bunch of different storages, all built and optimized for different use cases. But let's see some basics first.
Storage basics
What you specify in a component impl
-block is an UnprotectedStorage
.
Each UnprotectedStorage
exposes an unsafe getter which does not
perform any checks whether the requested index for the component is valid
(the id of an entity is the index of its component). To allow checking them
and speeding up iteration, we have something called hierarchical bitsets,
provided by hibitset
.
Note: In case you don't know anything about bitsets, you can safely skip the following section about it. Just keep in mind that we have some mask which tracks for which entities a component exists.
How does it speed up the iteration? A hierarchical bitset is essentially
a multi-layer bitset, where each upper layer "summarizes" multiple bits
of the underlying layers. That means as soon as one of the underlying
bits is 1
, the upper one also becomes 1
, so that we can skip a whole
range of indices if an upper bit is 0
in that section. In case it's 1
,
we go down by one layer and perform the same steps again (it currently
has 4 layers).
Storage overview
Here a list of the storages with a short description and a link to the corresponding heading.
Storage Type | Description | Optimized for |
---|---|---|
BTreeStorage | Works with a BTreeMap | no particular case |
DenseVecStorage | Uses a redirection table | fairly often used components |
HashMapStorage | Uses a HashMap | rare components |
NullStorage | Can flag entities | doesn't depend on rarity |
VecStorage | Uses a sparse Vec , empty slots are uninitialized | commonly used components |
DefaultVecStorage | Uses a sparse Vec , empty slots contain Default | commonly used components |
Slices
Certain storages provide access to component slices:
Storage Type | Slice type | Density | Indices |
---|---|---|---|
DenseVecStorage | &[T] | Dense | Arbitrary |
VecStorage | &[MaybeUninit<T>] | Sparse | Entity id() |
DefaultVecStorage | &[T] | Sparse | Entity id() |
This is intended as an advanced technique. Component slices provide maximally efficient reads and writes, but they are incompatible with many of the usual abstractions which makes them more difficult to use.
BTreeStorage
It works using a BTreeMap
and it's meant to be the default storage
in case you're not sure which one to pick, because it fits all scenarios
fairly well.
DenseVecStorage
This storage uses two Vec
s, one containing the actual data and the other
one which provides a mapping from the entity id to the index for the data vec
(it's a redirection table). This is useful when your component is bigger
than a usize
because it consumes less RAM.
DenseVecStorage<T>
provides as_slice()
and as_mut_slice()
accessors
which return &[T]
. The indices in this slice do not correspond to entity
IDs, nor do they correspond to indices in any other storage, nor do they
correspond to indices in this storage at a different point in time.
HashMapStorage
This should be used for components which are associated with very few entities, because it provides a lower insertion cost and is packed together more tightly. You should not use it for frequently used components, because the hashing cost would definitely be noticeable.
NullStorage
As already described in the overview, the NullStorage
does itself
only contain a user-defined ZST (=Zero Sized Type; a struct with no data in it,
like struct Synced;
).
Because it's wrapped in a so-called MaskedStorage
, insertions and deletions
modify the mask, so it can be used for flagging entities (like in this example
for marking an entity as Synced
, which could be used to only synchronize
some of the entities over the network).
VecStorage
This one has only one vector (as opposed to the DenseVecStorage
). It
just leaves uninitialized gaps where we don't have any component.
Therefore it would be a waste of memory to use this storage for
rare components, but it's best suited for commonly used components
(like transform values).
VecStorage<T>
provides as_slice()
and as_mut_slice()
accessors which
return &[MaybeUninit<T>]
. Consult the Storage::mask()
to determine
which indices are populated. Slice indices cannot be converted to Entity
values because they lack a generation counter, but they do correspond to
Entity::id()
s, so indices can be used to collate between multiple
VecStorage
s.
DefaultVecStorage
This storage works exactly like VecStorage
, but instead of leaving gaps
uninitialized, it fills them with the component's default value. This
requires the component to impl Default
, and it results in more memory
writes than VecStorage
.
DefaultVecStorage
provides as_slice()
and as_mut_slice()
accessors
which return &[T]
. Storage::mask()
can be used to determine which
indices are in active use, but all indices are fully initialized, so the
mask()
is not necessary for safety. DefaultVecStorage
indices all
correspond with each other, with VecStorage
indices, and with
Entity::id()
s.
System Data
Every system can request data which it needs to run. This data can be specified
using the System::SystemData
type. Typical implementors of the SystemData
trait
are ReadStorage
, WriteStorage
, Read
, Write
, ReadExpect
, WriteExpect
and Entities
.
A tuple of types implementing SystemData
automatically also implements SystemData
.
This means you can specify your System::SystemData
as follows:
# #![allow(unused_variables)] #fn main() { struct Sys; impl<'a> System<'a> for Sys { type SystemData = (WriteStorage<'a, Pos>, ReadStorage<'a, Vel>); fn run(&mut self, (pos, vel): Self::SystemData) { /* ... */ } } #}
It is very important that you don't request both a ReadStorage
and a WriteStorage
for the same component or a Read
and a Write
for the same resource.
This is just like the borrowing rules of Rust, where you can't borrow something
mutably and immutably at the same time. In Specs, we have to check this at
runtime, thus you'll get a panic if you don't follow this rule.
Accessing Entities
You want to create/delete entities from a system? There is
good news for you. You can use Entities
to do that.
It implements SystemData
so just put it in your SystemData
tuple.
Don't confuse
specs::Entities
withspecs::EntitiesRes
. While the latter one is the actual resource, the former one is a type definition forRead<Entities>
.
Please note that you may never write to these Entities
, so only
use Read
. Even though it's immutable, you can atomically create
and delete entities with it. Just use the .create()
and .delete()
methods, respectively.
For example, if you wanted to delete an entity based after a period of time you could write something similar like this.
# #![allow(unused_variables)] #fn main() { pub struct Life { life: f32, } struct DecaySys; impl<'a> System<'a> for DecaySys { type SystemData = (Entities<'a>, WriteStorage<'a, Life>); fn run(&mut self, (entities, mut life): Self::SystemData) { for (e, life) in (&entities, &mut life).join() { if life < 0.0 { entities.delete(e); } else { life -= 1.0; } } } } #}
Just remember after dynamic entity deletion, a call to
World::maintain
is necessary in order to make the changes persistent and delete associated components.
Adding and removing components
Adding or removing components can be done by modifying
either the component storage directly with a WriteStorage
or lazily using the LazyUpdate
resource.
use specs::{Component, Read, LazyUpdate, NullStorage, System, Entities, WriteStorage};
struct Stone;
impl Component for Stone {
type Storage = NullStorage<Self>;
}
struct StoneCreator;
impl<'a> System<'a> for StoneCreator {
type SystemData = (
Entities<'a>,
WriteStorage<'a, Stone>,
Read<'a, LazyUpdate>,
);
fn run(&mut self, (entities, mut stones, updater): Self::SystemData) {
let stone = entities.create();
// 1) Either we insert the component by writing to its storage
stones.insert(stone, Stone);
// 2) or we can lazily insert it with `LazyUpdate`
updater.insert(stone, Stone);
}
}
Note: After using
LazyUpdate
a call toWorld::maintain
is necessary to actually execute the changes.
SetupHandler
/ Default
for resources
Please refer to the resources chapter for automatic creation of resources.
Specifying SystemData
As mentioned earlier, SystemData
is implemented for tuples up to 26 elements. Should you ever need
more, you could even nest these tuples. However, at some point it becomes hard to keep track of all the elements.
That's why you can also create your own SystemData
bundle using a struct:
extern crate specs;
use specs::prelude::*;
#[derive(SystemData)]
pub struct MySystemData<'a> {
positions: ReadStorage<'a, Position>,
velocities: ReadStorage<'a, Velocity>,
forces: ReadStorage<'a, Force>,
delta: Read<'a, DeltaTime>,
game_state: Write<'a, GameState>,
}
Make sure to enable the shred-derive
feature in your Cargo.toml
:
specs = { version = "*", features = ["shred-derive"] }
The setup
stage
So far for all our component storages and resources, we've been adding
them to the World
manually. In Specs, this is not required if you use
setup
. This is a manually invoked stage that goes through SystemData
and calls register
, insert
, etc. for all (with some exceptions)
components and resources found. The setup
function can be found in
the following locations:
ReadStorage
,WriteStorage
,Read
,Write
SystemData
System
RunNow
Dispatcher
ParSeq
During setup, all components encountered will be registered, and all
resources that have a Default
implementation or a custom SetupHandler
will be added. Note that resources encountered in ReadExpect
and WriteExpect
will not be added to the World
automatically.
The recommended way to use setup
is to run it on Dispatcher
or ParSeq
after the system graph is built, but before the first dispatch
. This will go
through all System
s in the graph, and call setup
on each.
Let's say you began by registering Components and Resources first:
use specs::prelude::*;
#[derive(Default)]
struct Gravity;
struct Velocity;
impl Component for Velocity {
type Storage = VecStorage<Self>;
}
struct SimulationSystem;
impl<'a> System<'a> for SimulationSystem {
type SystemData = (Read<'a, Gravity>, WriteStorage<'a, Velocity>);
fn run(&mut self, _: Self::SystemData) {}
}
fn main() {
let mut world = World::new();
world.insert(Gravity);
world.register::<Velocity>();
for _ in 0..5 {
world.create_entity().with(Velocity).build();
}
let mut dispatcher = DispatcherBuilder::new()
.with(SimulationSystem, "simulation", &[])
.build();
dispatcher.dispatch(&mut world);
world.maintain();
}
You could get rid of that phase by calling setup()
and re-ordering your main function:
fn main() {
let mut world = World::new();
let mut dispatcher = DispatcherBuilder::new()
.with(SimulationSystem, "simulation", &[])
.build();
dispatcher.setup(&mut world);
for _ in 0..5 {
world.create_entity().with(Velocity).build();
}
dispatcher.dispatch(&mut world);
world.maintain();
}
Custom setup
functionality
The good qualities of setup
don't end here however. We can also use setup
to create our non-Default
resources, and also to initialize our System
s!
We do this by custom implementing the setup
function in our System
.
Let's say we have a System
that process events, using shrev::EventChannel
:
struct Sys {
reader: ReaderId<Event>,
}
impl<'a> System<'a> for Sys {
type SystemData = Read<'a, EventChannel<Event>>;
fn run(&mut self, events: Self::SystemData) {
for event in events.read(&mut self.reader) {
[..]
}
}
}
This looks pretty OK, but there is a problem here if we want to use setup
.
The issue is that Sys
needs a ReaderId
on creation, but to get a ReaderId
,
we need EventChannel<Event>
to be initialized. This means the user of Sys
need
to create the EventChannel
themselves and add it manually to the World
.
We can do better!
use specs::prelude::*;
#[derive(Default)]
struct Sys {
reader: Option<ReaderId<Event>>,
}
impl<'a> System<'a> for Sys {
type SystemData = Read<'a, EventChannel<Event>>;
fn run(&mut self, events: Self::SystemData) {
for event in events.read(&mut self.reader.as_mut().unwrap()) {
[..]
}
}
fn setup(&mut self, world: &mut World) {
Self::SystemData::setup(world);
self.reader = Some(world.fetch_mut::<EventChannel<Event>>().register_reader());
}
}
This is much better; we can now use setup
to fully initialize Sys
without
requiring our users to create and add resources manually to World
!
If we override the setup
function on a System
, it is vitally important that we remember to add Self::SystemData::setup(world);
, or setup will not be performed for the System
s SystemData
.
This could cause panics during setup or during the first dispatch.
Setting up in bulk
In the case of libraries making use of specs
, it is sometimes helpful to provide
a way to add many things at once.
It's generally recommended to provide a standalone function to register multiple
Components/Resources at once, while allowing the user to add individual systems
by themselves.
fn add_physics_engine(world: &mut World, config: LibraryConfig) -> Result<(), LibraryError> {
world.register::<Velocity>();
// etc
}
Joining components
In the last chapter, we learned how to access resources using SystemData
.
To access our components with it, we can just request a ReadStorage
and use
Storage::get
to retrieve the component associated to an entity. This works quite
well if you want to access a single component, but what if you want to
iterate over many components? Maybe some of them are required, others might
be optional and maybe there is even a need to exclude some components?
If we wanted to do that using only Storage::get
, the code would become very ugly.
So instead we worked out a way to conveniently specify that. This concept is
known as "joining".
Basic joining
We've already seen some basic examples of joining in the last chapters, for example we saw how to join over two storages:
for (pos, vel) in (&mut pos_storage, &vel_storage).join() {
*pos += *vel;
}
This simply iterates over the position and velocity components of all entities that have both these components. That means all the specified components are required.
Sometimes, we want not only get the components of entities,
but also the entity value themselves. To do that, we can simply join over
&EntitiesRes
.
for (ent, pos, vel) in (&*entities, &mut pos_storage, &vel_storage).join() {
println!("Processing entity: {:?}", ent);
*pos += *vel;
}
The returned entity value can also be used to get a component from a storage as usual.
Optional components
The previous example will iterate over all entities that have all the components we need, but what if we want to iterate over an entity whether it has a component or not?
To do that, we can wrap the Storage
with maybe()
: it wraps the Storage
in a
MaybeJoin
struct which, rather than returning a component directly, returns
None
if the component is missing and Some(T)
if it's there.
for (pos, vel, mass) in
(&mut pos_storage, &vel_storage, (&mut mass_storage).maybe()).join() {
println!("Processing entity: {:?}", ent);
*pos += *vel;
if let Some(mass) = mass {
let x = *vel / 300_000_000.0;
let y = 1 - x * x;
let y = y.sqrt();
mass.current = mass.constant / y;
}
}
In this example we iterate over all entities with a position and a velocity and perform the calculation for the new position as usual. However, in case the entity has a mass, we also calculate the current mass based on the velocity. Thus, mass is an optional component here.
WARNING: Do not have a join of only MaybeJoin
s. Otherwise the join will iterate
over every single index of the bitset. If you want a join with all MaybeJoin
s,
add an EntitiesRes to the join as well to bound the join to all entities that are alive.
Manually fetching components with Storage::get()
Even though join()
ing over maybe()
should be preferred because it can optimize how entities are
iterated, it's always possible to fetch a component manually using Storage::get()
or Storage::get_mut()
.
For example, say that you want to damage a target entity every tick, but only if
it has an Health
:
for (target, damage) in (&target_storage, &damage_storage).join() {
let target_health: Option<&mut Health> = health_storage.get_mut(target.ent);
if let Some(target_health) = target_health {
target_health.current -= damage.value;
}
}
Even though this is a somewhat contrived example, this is a common pattern when entities interact.
Excluding components
If you want to filter your selection by excluding all entities
with a certain component type, you can use the not operator (!
)
on the respective component storage. Its return value is a unit (()
).
for (ent, pos, vel, ()) in (
&*entities,
&mut pos_storage,
&vel_storage,
!&frozen_storage,
).join() {
println!("Processing entity: {:?}", ent);
*pos += *vel;
}
This will simply iterate over all entities that
- have a position
- have a velocity
- do not have a
Frozen
component
How joining works
You can call join()
on everything that implements the Join
trait.
The method call always returns an iterator. Join
is implemented for
&ReadStorage
/&WriteStorage
(gives back a reference to the components)&mut WriteStorage
(gives back a mutable reference to the components)&EntitiesRes
(returnsEntity
values)- bitsets
We think the last point here is pretty interesting, because it allows for even more flexibility, as you will see in the next section.
Joining over bitsets
Specs is using hibitset
, a library which provides layered bitsets
(those were part of Specs once, but it was decided that a separate
library could be useful for others).
These bitsets are used with the component storages to determine
which entities the storage provides a component value for. Also,
Entities
is using bitsets, too. You can even create your
own bitsets and add or remove entity ids:
use hibitset::{BitSet, BitSetLike};
let mut bitset = BitSet::new();
bitset.add(entity1.id());
bitset.add(entity2.id());
BitSet
s can be combined using the standard binary operators,
&
, |
and ^
. Additionally, you can negate them using !
.
This allows you to combine and filter components in multiple ways.
This chapter has been all about looping over components; but we can do more than sequential iteration! Let's look at some parallel code in the next chapter.
Parallel Join
As mentioned in the chapter dedicated to how to dispatch systems,
Specs automatically parallelizes system execution when there are non-conflicting
system data requirements (Two System
s conflict if their SystemData
needs access
to the same resource where at least one of them needs write access to it).
Basic parallelization
What isn't automatically parallelized by Specs are the joins made within a single system:
fn run(&mut self, (vel, mut pos): Self::SystemData) {
use specs::Join;
// This loop runs sequentially on a single thread.
for (vel, pos) in (&vel, &mut pos).join() {
pos.x += vel.x * 0.05;
pos.y += vel.y * 0.05;
}
}
This means that, if there are hundreds of thousands of entities and only a few systems that actually can be executed in parallel, then the full power of CPU cores cannot be fully utilized.
To fix this potential inefficiency and to parallelize the joining, the join
method call can be exchanged for par_join
:
fn run(&mut self, (vel, mut pos): Self::SystemData) {
use rayon::prelude::*;
use specs::ParJoin;
// Parallel joining behaves similarly to normal joining
// with the difference that iteration can potentially be
// executed in parallel by a thread pool.
(&vel, &mut pos)
.par_join()
.for_each(|(vel, pos)| {
pos.x += vel.x * 0.05;
pos.y += vel.y * 0.05;
});
}
There is always overhead in parallelization, so you should carefully profile to see if there are benefits in the switch. If you have only a few things to iterate over then sequential join is faster.
The par_join
method produces a type implementing rayon's ParallelIterator
trait which provides lots of helper methods to manipulate the iteration,
the same way the normal Iterator
trait does.
Rendering
Rendering is often a little bit tricky when you're dealing with a multi-threaded ECS. That's why we have something called "thread-local systems".
There are two things to keep in mind about thread-local systems:
- They're always executed at the end of dispatch.
- They cannot have dependencies; you just add them in the order you want them to run.
Adding one is a simple line added to the builder code:
DispatcherBuilder::new()
.with_thread_local(RenderSys);
Amethyst
As for Amethyst, it's very easy because Specs is already integrated. So there's no special effort required, just look at the current examples.
Piston
Piston has an event loop which looks like this:
while let Some(event) = window.poll_event() {
// Handle event
}
Now, we'd like to do as much as possible in the ECS, so we feed in input as a resource. This is what your code could look like:
struct ResizeEvents(Vec<(u32, u32)>);
world.insert(ResizeEvents(Vec::new()));
while let Some(event) = window.poll_event() {
match event {
Input::Resize(x, y) => world.write_resource::<ResizeEvents>().0.push((x, y)),
// ...
}
}
The actual dispatching should happen every time the Input::Update
event occurs.
If you want a section for your game engine added, feel free to submit a PR!
Advanced strategies for components
So now that we have a fairly good grasp on the basics of Specs, it's time that we start experimenting with more advanced patterns!
Marker components
Say we want to add a drag force to only some entities that have velocity, but let other entities move about freely without drag.
The most common way is to use a marker component for this. A marker component
is a component without any data that can be added to entities to "mark" them
for processing, and can then be used to narrow down result sets using Join
.
Some code for the drag example to clarify:
#[derive(Component)]
#[storage(NullStorage)]
pub struct Drag;
#[derive(Component)]
pub struct Position {
pub pos: [f32; 3],
}
#[derive(Component)]
pub struct Velocity {
pub velocity: [f32; 3],
}
struct Sys {
drag: f32,
}
impl<'a> System<'a> for Sys {
type SystemData = (
ReadStorage<'a, Drag>,
ReadStorage<'a, Velocity>,
WriteStorage<'a, Position>,
);
fn run(&mut self, (drag, velocity, mut position): Self::SystemData) {
// Update positions with drag
for (pos, vel, _) in (&mut position, &velocity, &drag).join() {
pos += vel - self.drag * vel * vel;
}
// Update positions without drag
for (pos, vel, _) in (&mut position, &velocity, !&drag).join() {
pos += vel;
}
}
}
Using NullStorage
is recommended for marker components, since they don't contain
any data and as such will not consume any memory. This means we can represent them using
only a bitset. Note that NullStorage
will only work for components that are ZST (i.e. a
struct without fields).
Modeling entity relationships and hierarchy
A common use case where we need a relationship between entities is having a third person camera following the player around. We can model this using a targeting component referencing the player entity.
A simple implementation might look something like this:
#[derive(Component)]
pub struct Target {
target: Entity,
offset: Vector3,
}
pub struct FollowTargetSys;
impl<'a> System<'a> for FollowTargetSys {
type SystemData = (
Entities<'a>,
ReadStorage<'a, Target>,
WriteStorage<'a, Transform>,
);
fn run(&mut self, (entity, target, transform): Self::SystemData) {
for (entity, t) in (&*entity, &target).join() {
let new_transform = transform.get(t.target).cloned().unwrap() + t.offset;
*transform.get_mut(entity).unwrap() = new_transform;
}
}
}
We could also model this as a resource (more about that in the next section), but it could
be useful to be able to have multiple entities following targets, so modeling this with
a component makes sense. This could in extension be used to model large scale hierarchical
structure (scene graphs). For a generic implementation of such a hierarchical system, check
out the crate specs-hierarchy
.
Entity targeting
Imagine we're building a team based FPS game, and we want to add a spectator mode, where the spectator can pick a player to follow. In this scenario each player will have a camera defined that is following them around, and what we want to do is to pick the camera that we should use to render the scene on the spectator screen.
The easiest way to deal with this problem is to have a resource with a target entity, that we can use to fetch the actual camera entity.
pub struct ActiveCamera(Entity);
pub struct Render;
impl<'a> System<'a> for Render {
type SystemData = (
Read<'a, ActiveCamera>,
ReadStorage<'a, Camera>,
ReadStorage<'a, Transform>,
ReadStorage<'a, Mesh>,
);
fn run(&mut self, (active_cam, camera, transform, mesh) : Self::SystemData) {
let camera = camera.get(active_cam.0).unwrap();
let view_matrix = transform.get(active_cam.0).unwrap().invert();
// Set projection and view matrix uniforms
for (mesh, transform) in (&mesh, &transform).join() {
// Set world transform matrix
// Render mesh
}
}
}
By doing this, whenever the spectator chooses a new player to follow, we simply change
what Entity
is referenced in the ActiveCamera
resource, and the scene will be
rendered from that viewpoint instead.
Sorting entities based on component value
In a lot of scenarios we encounter a need to sort entities based on either a component's
value, or a combination of component values. There are a couple of ways to deal with this
problem. The first and most straightforward is to just sort Join
results.
let data = (&entities, &comps).join().collect::<Vec<_>>();
data.sort_by(|&a, &b| ...);
for entity in data.iter().map(|d| d.0) {
// Here we get entities in sorted order
}
There are a couple of limitations with this approach, the first being that we will always
process all matched entities every frame (if this is called in a System
somewhere). This
can be fixed by using FlaggedStorage
to maintain a sorted Entity
list in the System
.
We will talk more about FlaggedStorage
in the next chapter.
The second limitation is that we do a Vec
allocation every time, however this can be
alleviated by having a Vec
in the System
struct that we reuse every frame. Since we
are likely to keep a fairly steady amount of entities in most situations this could work well.
FlaggedStorage
and modification events
In most games you will have many entities, but from frame to frame there will usually be components that will only need to updated when something related is modified.
To avoid a lot of unnecessary computation when updating components it would be nice if we could somehow check for only those entities that are updated and recalculate only those.
We might also need to keep an external resource in sync with changes to
components in Specs World
, and we only want to propagate actual changes, not
do a full sync every frame.
This is where FlaggedStorage
comes into play. By wrapping a component's actual
storage in a FlaggedStorage
, we can subscribe to modification events, and
easily populate bitsets with only the entities that have actually changed.
Let's look at some code:
pub struct Data {
[..]
}
impl Component for Data {
type Storage = FlaggedStorage<Self, DenseVecStorage<Self>>;
}
#[derive(Default)]
pub struct Sys {
pub dirty: BitSet,
pub reader_id: Option<ReaderId<ComponentEvent>>,
}
impl<'a> System<'a> for Sys {
type SystemData = (
ReadStorage<'a, Data>,
WriteStorage<'a, SomeOtherData>,
);
fn run(&mut self, (data, mut some_other_data): Self::SystemData) {
self.dirty.clear();
let events = data.channel().read(self.reader_id.as_mut().unwrap());
// Note that we could use separate bitsets here, we only use one to
// simplify the example
for event in events {
match event {
ComponentEvent::Modified(id) | ComponentEvent::Inserted(id) => {
self.dirty.add(*id);
}
// We don't need to take this event into account since
// removed components will be filtered out by the join;
// if you want to, you can use `self.dirty.remove(*id);`
// so the bit set only contains IDs that still exist
ComponentEvent::Removed(_) => (),
}
}
for (d, other, _) in (&data, &mut some_other_data, &self.dirty).join() {
// Mutate `other` based on the update data in `d`
}
}
fn setup(&mut self, res: &mut Resources) {
Self::SystemData::setup(res);
self.reader_id = Some(
WriteStorage::<Data>::fetch(&res).register_reader()
);
}
}
There are three different event types that we can receive:
ComponentEvent::Inserted
- will be sent when a component is added to the storageComponentEvent::Modified
- will be sent when a component is fetched mutably from the storageComponentEvent::Removed
- will be sent when a component is removed from the storage
Gotcha: Iterating FlaggedStorage
Mutably
Because of how ComponentEvent
works, if you iterate mutably over a
component storage using Join
, all entities that are fetched by the Join
will
be flagged as modified even if nothing was updated in them.
For example, this will cause all comps
components to be flagged as modified:
// **Never do this** if `comps` uses `FlaggedStorage`.
//
// This will flag all components as modified regardless of whether the inner
// loop actually modified the component.
for comp in (&mut comps).join() {
// ...
}
Instead, you will want to either:
- Restrict the components mutably iterated over, for example by joining with a
BitSet
or another component storage. - Iterating over the components use a
RestrictedStorage
and only fetch the component as mutable if/when needed.
RestrictedStorage
If you need to iterate over a FlaggedStorage
mutably and don't want every
component to be marked as modified, you can use a RestrictedStorage
and only
fetch the component as mutable if/when needed.
for (entity, mut comp) in (&entities, &mut comps.restrict_mut()).join() {
// Check whether this component should be modified, without fetching it as
// mutable.
if comp.get_unchecked().condition < 5 {
let mut comp = comp.get_mut_unchecked();
// ...
}
}
Start and Stop event emission
Sometimes you may want to perform some operations on the storage, but you don't want that these operations produce any event.
You can use the function storage.set_event_emission(false)
to suppress the
event writing for of any action. When you want to re activate them you can
simply call storage.set_event_emission(true)
.
See FlaggedStorage Doc for more into.
Saveload
saveload
is a module that provides mechanisms to serialize and deserialize a
World
, it makes use of the popular serde
library and requires the feature
flag serde
to be enabled for specs
in your Cargo.toml
file.
At a high level, it works by defining a Marker
component as well as a
MarkerAllocator
resource. Marked entities will be the only ones subject to
serialization and deserialization.
saveload
also defines SerializeComponents
and DeserializeComponents
,
these do the heavy lifting of exporting and importing.
Let's go over everything, point by point:
Marker
and MarkerAllocator
Marker
and MarkerAllocator<M: Marker>
are actually traits, simple
implementations are available with SimpleMarker<T: ?Sized>
and
SimpleMarkerAllocator<T: ?Sized>
, which you may use multiple times with
Zero Sized Types.
struct NetworkSync;
struct FilePersistent;
fn main() {
let mut world = World::new();
world.register::<SimpleMarker<NetworkSync>>();
world.insert(SimpleMarkerAllocator::<NetworkSync>::default());
world.register::<SimpleMarker<FilePersistent>>();
world.insert(SimpleMarkerAllocator::<FilePersistent>::default());
world
.create_entity()
.marked::<SimpleMarker<NetworkSync>>()
.marked::<SimpleMarker<FilePersistent>>()
.build();
}
You may also roll your own implementations like so:
use specs::{prelude::*, saveload::{MarkedBuilder, Marker, MarkerAllocator}};
#[derive(Copy, Clone, Debug, Hash, Eq, PartialEq)]
#[derive(serde::Serialize, serde::Deserialize)]
struct MyMarker(u64);
impl Component for MyMarker {
type Storage = VecStorage<Self>;
}
impl Marker for MyMarker {
type Identifier = u64;
type Allocator = MyMarkerAllocator;
fn id(&self) -> u64 {
self.0
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct MyMarkerAllocator(std::collections::HashMap<u64, Entity>);
impl MarkerAllocator<MyMarker> for MyMarkerAllocator {
fn allocate(&mut self, entity: Entity, id: Option<u64>) -> MyMarker {
let id = id.unwrap_or_else(|| self.unused_key()));
self.0.insert(id, entity);
MyMarker(id)
}
fn retrieve_entity_internal(&self, id: u64) -> Option<Entity> {
self.0.get(&id).cloned()
}
fn maintain(
&mut self,
entities: &EntitiesRes,
storage: &ReadStorage<MyMarker>,
) {
// naive and possibly costly implementation, the techniques in
// chapter 12 would be useful here!
self.0 = (entities, storage)
.join()
.map(|(entity, marker)| (marker.0, entity))
.collect();
}
}
fn main() {
let mut world = World::new();
world.register::<MyMarker>();
world.insert(MyMarkerAllocator::default());
world
.create_entity()
.marked::<MyMarker>()
.build();
}
Note that the trait MarkedBuilder
must be imported to mark entities during
creation, it is implemented for EntityBuilder
and LazyBuilder
. Marking an
entity that is already present is straightforward:
fn mark_entity(
entity: Entity,
mut allocator: Write<SimpleMarkerAllocator<A>>,
mut storage: WriteStorage<SimpleMarker<A>>,
) {
use MarkerAllocator; // for MarkerAllocator::mark
match allocator.mark(entity, &mut storage) {
None => println!("entity was dead before it could be marked"),
Some((_, false)) => println!("entity was already marked"),
Some((_, true)) => println!("entity successfully marked"),
}
}
Serialization and Deserialization
As previously mentioned, SerializeComponents
and DeserializeComponents
are
the two heavy lifters. They're traits as well, however, that's just an
implementation detail, they are used like functions. They're implemented over
tuples of up to 16 ReadStorage
/WriteStorage
.
Here is an example showing how to serialize:
specs::saveload::SerializeComponents
::<Infallible, SimpleMarker<A>>
::serialize(
&(position_storage, mass_storage), // tuple of ReadStorage<'a, _>
&entities, // Entities<'a>
&marker_storage, // ReadStorage<'a, SimpleMarker<A>>
&mut serializer, // serde::Serializer
) // returns Result<Serializer::Ok, Serializer::Error>
and now, how to deserialize:
specs::saveload::DeserializeComponents
::<Infallible, SimpleMarker<A>>
::deserialize(
&mut (position_storage, mass_storage), // tuple of WriteStorage<'a, _>
&entities, // Entities<'a>
&mut marker_storage, // WriteStorage<'a SimpleMarker<A>>
&mut marker_allocator, // Write<'a, SimpleMarkerAllocator<A>>
&mut deserializer, // serde::Deserializer
) // returns Result<(), Deserializer::Error>
As you can see, all parameters but one are SystemData
, the easiest way to
access those would be through systems (chapter on this subject) or by
calling World::system_data
:
let (
entities,
mut marker_storage,
mut marker_allocator,
mut position_storage,
mut mass_storage,
) = world.system_data::<(
Entities,
WriteStorage<SimpleMarker<A>>,
Write<SimpleMarkerAllocator<A>>,
WriteStorage<Position>,
WriteStorage<Mass>,
)>();
Each Component
that you will read from and write to must implement
ConvertSaveload
, it's a benign trait and is implemented for all Component
s
that are Clone + serde::Serialize + serde::DeserializeOwned
, however you may
need to implement it (or derive it using specs-derive
). In which case, you
may introduce more bounds to the first generic parameter and will need to
replace Infallible
with a custom type, this custom type must implement
From<<TheComponent as ConvertSaveload>::Error>
for all Component
s,
basically.
Troubleshooting
Tried to fetch a resource, but the resource does not exist.
This is the most common issue you will face as a new user of Specs.
This panic will occur whenever a System
is first dispatched, and one or
more of the components and/or resources it uses is missing from World
.
There are a few main reasons for this occurring:
- Forgetting to call
setup
after building aDispatcher
orParSeq
. Make sure this is always run before the first dispatch. - Not adding mandatory resources to
World
. You can usually find these by searching for occurrences ofReadExpect
andWriteExpect
. - Manually requesting components/resources from
World
(not inside aSystem
), where the component/resource is not used by anySystem
s, which is most common when using theEntityBuilder
. This is an artifact of howsetup
works, it will only add what is found inside the usedSystem
s. If you use other components/resources, you need to manually register/add these toWorld
.