Entity Component Scrapyard: Introduction
Hiiii person reading this.
EC-Scrapyard is a tutorial walkthrough thingy where we're going to write an ECS from scratch :3
This book is written with the assumption that the reader knows what an ECS is but not necessarily how it works or how to go about writing one. If you dont know what an ECS is there's a good writeup here
A few caveats before we start:
- We wont be using any dependencies, we will be using rust's standard library though :")
- We will only use safe code so
#![forbid(unsafe_code)]
will be placed at the top oflib.rs
- We wont touch on the Systems part of ECS because I personally feel that's better left to engines and users of the ECS to decide how best to structure the program
A lot of the ECS' in the rust ecosystem use something called 'archetypes' so we'll be writing an archetype based ECS in this book. If you dont know what archetypes are don't worry they'll be explained later on in the book when we get around to implementing them :) If you're impatient and want to look at it now you can go ahead and do that :P the section for that is here
A basic entity generator
Lets start with implementing a basic struct that will let us spawn and despawn entities. Every ECS has something like this and by starting here we can make progress without tackling some of the harder stuff immediately :)
Every ECS that has performance as a goal will not store data *in* their Entity struct but
will instead have the Entity be a handle to component data. This is because when we ask
the ECS for all components T1
and T2
if they're stored in the Entity struct they'll
never be in cache when we go to access them. (This also has many borrowck issues, maybe for another book though :>)
Whereas if we store a bunch of our T1
components in a Vec, when we iterate through it
we'll end up having the next components loaded into cache which is far more efficient.
A simple way to implement this "Entity as a handle" thing would be to create a tuple struct that wraps a u32 or u64 or whatever size integer you want e.g.
pub struct Entity(u64);
we could then add some derives
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
pub struct Entity(u64);
This would work pretty well, entities would be cheap to copy, compare with eachother,
and they're relatively small in size which is good because we'll have a *lot* of
entities in our ECS. Sequential ids hopefully will also make the code for generating new
ids not super complex, we just add 1 to a counter.
The first entity we spawn is Entity(0)
the second is Entity(1)
the third Entity(2)
etc etc etc
Well, lets try write up this entity spawning code and see if it ends up with any suprise complexity. Lets start by making a struct with that counter and our spawn method that increments it
struct EntityGenerator {
next_id: u64,
}
impl EntityGenerator {
fn spawn(&mut self) -> Entity {
let entity = Entity(self.next_id);
self.next_id += 1;
entity
}
}
Okay well thats not a lot of code although how exactly does self.next_id += 1
handle
the case where self.next_id == u64::MAX
we're unlikely to ever have this many
entities but we probably ought to atleast see what will happen...
(click the run button :3)
#![allow(unused)] fn main() { #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub struct Entity(u64); struct EntityGenerator { next_id: u64, } impl EntityGenerator { fn spawn(&mut self) -> Entity { let entity = Entity(self.next_id); self.next_id += 1; entity } } let mut entity_generator = EntityGenerator { next_id: u64::MAX }; let entity = entity_generator.spawn(); dbg!(entity); }
Oh, well that's not great...
We could explicitly use wrapping_add
to avoid this panic, but then two calls to
EntityGenerator::spawn
could return the same entity and that's definitely undesirable.
u64::MAX
is a *really* big number so whether we panic or use wrapping_add
is unlikely to make much of a difference.
For this guide I'm just going to panic but if you want to wrapping_add that's also fine :)
Note: overflow only panics in debug mode so if we want to panic on overflow we need to explicitly check for it
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub struct Entity(u64); struct EntityGenerator { next_id: u64, } impl EntityGenerator { fn spawn(&mut self) -> Entity { let entity = Entity(self.next_id); if self.next_id == u64::MAX { panic!("Attempted to spawn an entity after running out of IDs"); } self.next_id += 1; entity } } fn main() { let mut entity_generator = EntityGenerator { next_id: u64::MAX }; let entity = entity_generator.spawn(); dbg!(entity); }
If we run this hopefully we panic with our new error message...
thread 'main' panicked at 'Attempted to spawn an entity after running out of IDs'
Yep!
Okay the next thing we want to do is mark entities as dead, that's a pretty core thing for our ECS huh :P?
We'll want a despawn
function but how is this function even going to work? We cant store the
dead/alive status on the Entity itself because we can copy an entity before it's despawned and then
it would still think it's alive.
We need to store all the despawned status of entities in our EntityGenerator
There are a few options for this
- A
Vec<Entity>
where we push dead entities to it, we would have to iterate the entire vec to check if an entity is dead which could be *super* slow so this is a non-starter. - A
Vec<bool>
which we index with the entity's u64 and set to true when we despawn the entity. This would be super fast to check if an entity is dead/alive, if you're concerned about ram consumption with this you could use a bitset instead which would have 1/8th the usage ofVec<bool>
- A
HashSet<Entity>
and we just insert dead entities into it. This would also be pretty fast to check if an entity is dead/alive
Option #2 and #3 would both be valid choices but for this guide I'm going to go with the HashSet option because its simpler :) Option #2 should work well as a drop-in replacement for the hashset code though so feel free to do that on your own! :)
The Hashset method
The code here will likely speak for itself so I'll just show it-
struct EntityGenerator {
next_id: u64,
// New
dead_entities: std::collections::HashSet<Entity>,
}
impl EntityGenerator {
// -Snip
fn despawn(&mut self, entity: Entity) {
self.dead_entities.insert(entity);
}
fn is_alive(&self, entity: Entity) -> bool {
I like my ``== false`` okay
self.dead_entities.contains(&entity) == false
}
}
At first glance this seems pretty correct right? but it isn't ^^" there's an implicit assumption in this code that
we only ever receive entities that were spawned from this EntityGenerator
but we can just ignore that and cause
the generator to spawn a dead entity like so:
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub struct Entity(u64); struct EntityGenerator { next_id: u64, dead_entities: std::collections::HashSet<Entity>, } impl EntityGenerator { fn spawn(&mut self) -> Entity { let entity = Entity(self.next_id); if self.next_id == u64::MAX { panic!("Attempted to spawn an entity after running out of IDs"); } self.next_id += 1; entity } fn despawn(&mut self, entity: Entity) { self.dead_entities.insert(entity); } fn is_alive(&self, entity: Entity) -> bool { self.dead_entities.contains(&entity) == false } } fn main() { use std::collections::HashSet; let mut gen_1 = EntityGenerator { next_id: 0, dead_entities: HashSet::new() }; let mut gen_2 = EntityGenerator { next_id: 0, dead_entities: HashSet::new() }; let e1_1 = gen_1.spawn(); gen_2.despawn(e1_1); let e1_2 = gen_2.spawn(); assert!(gen_2.is_alive(e1_2)); }
This is pretty problematic to say the least :P We can spawn entities with other generators, despawn them, and then spawn a dead entity. Looking back over our code with this in mind we can also see that if we call is_alive
on an unspawned entity it would say yes... Soooooo how can we check for this?
We could store another Hashet for every spawned entity but that would be really slow and waste memory, what's better is that we can compare self.next_id
to the entity's u64
to see if its indistinguishable from an entity spawned from this generator e.g.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub struct Entity(u64); struct EntityGenerator { next_id: u64, dead_entities: std::collections::HashSet<Entity>, } impl EntityGenerator { fn spawn(&mut self) -> Entity { let entity = Entity(self.next_id); if self.next_id == u64::MAX { panic!("Attempted to spawn an entity after running out of IDs"); } self.next_id += 1; entity } // -Snip fn despawn(&mut self, entity: Entity) { if self.is_alive(entity) { self.dead_entities.insert(entity); } } fn is_alive(&self, entity: Entity) -> bool { // self.next_id is the ID for the next entity so any entities with // that ID or higher haven't been spawned yet if entity.0 >= self.next_id { panic!("Attempted to use an entity in an EntityGenerator that it was not spawned with"); } self.dead_entities.contains(&entity) == false } } #[allow(unreachable_code)] fn main() { use std::collections::HashSet; let mut gen_1 = EntityGenerator { next_id: 0, dead_entities: HashSet::new() }; let mut gen_2 = EntityGenerator { next_id: 0, dead_entities: HashSet::new() }; let e1_1 = gen_1.spawn(); assert!(gen_2.is_alive(e1_1) == false); gen_2.despawn(e1_1); let e1_2 = gen_2.spawn(); assert!(gen_2.is_alive(e1_2) == unreachable!()); }
I decided to panic
in the is_alive
method rather than return false
because its pretty safe to say that it would be unintentional to do this so we should fail loudly to bring attention to it. It would also be completely fine to return false
if you're opposed to panic'ing in libraries unnecessarily :)
To wrap this chapter up lets move all this code into a separate module, reexport the entity struct and then set everything to pub(crate) since we'll likely need this all elsewhere.
// /src/entities.rs
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
pub struct Entity(u64);
pub(crate) struct EntityGenerator {
next_id: u64,
dead_entities: std::collections::HashSet<Entity>,
}
impl EntityGenerator {
pub(crate) fn spawn(&mut self) -> Entity {
let entity = Entity(self.next_id);
if self.next_id == u64::MAX {
panic!("Attempted to spawn an entity after running out of IDs");
}
self.next_id += 1;
entity
}
pub(crate) fn despawn(&mut self, entity: Entity) {
if self.is_alive(entity) {
self.dead_entities.insert(entity);
}
}
pub(crate) fn is_alive(&self, entity: Entity) -> bool {
// self.next_id is the ID for the next entity so any entities with
// that ID or higher haven't been spawned yet
if entity.0 >= self.next_id {
panic!("Attempted to use an entity in an EntityGenerator that it was not spawned with");
}
self.dead_entities.contains(&entity) == false
}
}
// /src/lib.rs
#![forbid(unsafe_code)]
pub(crate) mod entities;
pub use entities::Entity;
The full source code for this chapter can be viewed here
Now that we have entity spawning and despawning working it's about time to start storing some components, for that we need to learn about what archetypes are. Luckily that's exactly what the next chapter is for!
What are archetypes?
Archetypes are the underlying storage method we will be using to store components in our ECS. In
our ECS we will use something like Vec<Archetype>
to store everything.
An archetype stores *all* of the components on an entity and also only stores components from entities
with the same set of components. E.G. an entity with *only* [T1, T2, T3]
components would
have all components stored in one archetype, and an entity with *only* [T1, T2, T3, T4]
components would have all the components stored in a different archetype.
Let's say we have an archetype, we'll call it A_123
. A_123
is storing
components for entities that only have components [T1, T2, T3]
.
When we spawn an entity like so:
world.spawn((
T1 { .. },
T2 { .. },
T3 { .. },
));
We need to find an archetype to place all these components into. As mentioned previously we want to find if there's
an archetype that only stores components for entities with [T1, T2, T3]
, and, luckily for us, there is! It's A_123
.
Let's take a quick look at what A_123
might look like internally and how we would go about placing these components into it.
In psuedocode we have some archetype struct that looks a bit like:
struct A_123 {
column_1: Vec<T1>,
column_2: Vec<T2>,
column_3: Vec<T3>,
}
You might be wondering why we store each component type in a separate vec rather than something like this:
struct A_123(Vec<(T1, T2, T3)>);
There are a few reasons for this,
- The borrow checker will get angry at us if we have two Query's where one wants to access components
T1
mutably and the other wants to accessT2
immutably (We cant create an iterator over only *parts* of the (T1, T2, T3) tuple afterall) - It's more performant to store the components in separate Vecs as when we iterate only
T1
we dont needlessly load theT2
andT3
components into the cpu's cache.
With that explained lets look at some psuedocode for that spawn
function we used earlier.
fn spawn(&mut self, data: (T1, T2, T3)) -> Entity {
let a_123 = /* magically get the A_123 archetype */;
let entity = self.entity_generator.spawn();
a_123.entities.push(entity);
a_123.column_1.push(data.0);
a_123.column_2.push(data.1);
a_123.column_3.push(data.2);
entity
}
...Huh interesting how all this psuedocode looks awfully like Rust... I'm sure its just a coincedence ;)
There are a few things to talk about the above code:
- What's this
entities
field that suddenly appeared? - Do we need to store any kind of metadata so that we know which entity every element in each of the component vecs corresponds to?
Both of these things are *actually* one and the same.
Because we store entities with *only* components [T1, T2, T3]
in this archetype, the component
vecs will always be the same length. This means that when we push components to the front of the vecs
they'll all end up at the same index.
What we can do with this knowledge is have have a Vec<Entity>
in the archetype and
push the spawned Entity
to the entities vec. We then have an implicit mapping where for every index in
the component columns we can access the entities
vec at the same index to check what entity this component is for.
It's for the same reason that we can also access the other component columns at this index and the component will be for
the same entity.
That last point is why Query's in archetype based ECS' are *so* fast. When we want to iterate all T1
and T2
components
in our world we can just find every archetype that has a column for T1
and also a column for T2
and then blindly iterate
both Vecs at the same time. Some psuedocode to hopefully help demonstrate what I mean:
fn iterate_T1_and_T2(archetype: &A_123) -> impl Iterator<Item = (&T1, &T2)> {
archetype.column_1.iter().zip(archetype.column_2.iter())
}
Now that you (hopefully) have an understanding of how components get stored in archetype based ECS' and the performance advantages we can get from it, it's time to talk about one of the biggest flaws of the archetype model. Adding/Removing components is *really* slow.. like.. **really** slow.
Let's continue with our previous example of spawning our entity with components [T1, T2, T3]
. It's sitting pretty comfortably in
our A_123
archetype and we'll have super fast iteration times if we add more entities to this archetype and query for their components.
Now lets try adding a component to our entity. As previously mentioned our A_123
archetype stores entities which *only* have
[T1, T2, T3]
components as this lets us have amazing iteration speeds. Now let's say we add a component T4
to out entity,
we would no longer fit this criteria which means we cant store our entity's components in A_123
, we'll have to make a second archetype-
A_1234
it will store components for entities who only have [T1, T2, T3, T4]
Lets write some quick psuedocode and see if the performance problem here speaks for itself:
fn add_T4_to_entity_in_A_123(&mut self, entity: Entity, data: T4) {
let a_123 = /* magically get the A_123 archetype */;
let index = /* magically get the index in the
component columns corresponding to the entity */;
let a_1234 = /* magically get the A_1234 archetype */;
a_1234.entities.push(entity);
a_1234.column_4.push(data);
// Oh no this doesnt look cheap at all
a_1234.column_1.push(a_123.column_1.remove(index));
a_1234.column_2.push(a_123.column_2.remove(index));
a_1234.column_3.push(a_123.column_3.remove(index));
self.archetypes.push(a_1234);
}
Aaaaaaaaand yep... we have to move each of the entity's components out of their columns in A_123
.
Then have to push the removed components to the respective columns in the other archetype (A_1234
). That's a lot of
moving around memory which isn't the fastest thing in the world- to say the least
There's not a whole lot we can do about this. The need to move all this memory around when adding/removing components is entirely necessary for the previously mentioned amazing iteration performance. Archetype ECS' are inherently about trading in add/remove performance in exchange for iteration performance.
There are others ways to model your ECS such as sparsesets which have signficantly better add/remove performance in exchange for worse iteration performance relative to archetype based ECS' (still fast though :P). There's no one best way to model an ECS, sparseset and archetype ECS' just make different tradeoffs :)
In the next part we'll actually create our Archetype
struct
Creating our struct
Before we get started, in our src/lib.rs
file we should add mod archetype;
and then make a
file at src/archetype.rs
. All of the code in this chapter will be inside of this archetype.rs
file
Our archetype struct needs to be able to store any number of component columns. The
way to do this normally would be a Vec<ComponentColumn>
however our component columns
are going to be Vec's which are generic over the type they store. This is a bit problematic
for since we want to store a set of things that aren't the same type
Luckily rust has a way of yeeting type information away- trait objects. We can implement a trait
for every type of Vec and then store a Vec<Box<dyn TypeErasedVec>>
. We can then downcase the
trait object back to a concrete type whenever we need to. e.g. when we add/remove components
#![allow(unused)] fn main() { use std::any::Any; trait ComponentColumn: Any { fn as_any(&self) -> &dyn Any; fn as_any_mut(&mut self) -> &mut dyn Any; } impl<T: 'static> ComponentColumn for Vec<T> { fn as_any(&self) -> &dyn Any { self } fn as_any_mut(&mut self) -> &mut dyn Any { self } } struct Archetype { entities: Vec<crate::Entity>, columns: Vec<Box<dyn ComponentColumn>>, } }
Here we define our ComponentColumn
trait to be a supertrait of the Any
trait. You might not
have come across the Any
trait before, it doesn't tend to come up very often when writing rust.
The general idea behind the Any
trait is that we cast a type to a trait object and then call
downcast_ref/mut
with a generic to turn it back into a concrete type.
You can see the docs for std::any::Any here
Methods for creating an instance of our Archetype struct
We need some methods for creating instances of our Archetype
struct. There are two situations that
we'll be needing to create an Archetype
in:
- When we add/remove a component we'll have to create the archetype that we need to move the entity to
- When we spawn an entity we'll have to create an archetype matching the component's given
We'll start with the add/remove situation because that's going to be easier to write.
The first thing we want to do is make a method on our ComponentColumn
trait that returns a
Box<dyn ComponentColumn>
of the same type of Vec. This will let us create an archetype with the same
set of columns as an existing one.
#![allow(unused)] fn main() { trait ComponentColumn: Any { fn as_any(&self) -> &dyn Any; fn as_any_mut(&mut self) -> &mut dyn Any; fn new_empty_column(&self) -> Box<dyn ComponentColumn>; } impl<T: 'static> ComponentColumn for Vec<T> { fn as_any(&self) -> &dyn Any { self } fn as_any_mut(&mut self) -> &mut dyn Any { self } fn new_empty_column(&self) -> Box<dyn ComponentColumn> { Box::new(Vec::<T>::new()) } } }
Now lets look at the method for creating the archetype we want to move our entity to after an add/remove op:
#![allow(unused)] fn main() { impl Archetype { fn new_from_add<T: 'static>(from_archetype: &Archetype) -> Archetype { let mut columns: Vec<_> = from_archetype .columns .iter() .map(|column| column.new_empty_column()) .collect(); todo!("Actually add the column"); Archetype { entities: Vec::new(), columns, } } fn new_from_remove<T: 'static>(from_archetype: &Archetype) -> Archetype { let mut columns: Vec<_> = from_archetype .columns .iter() .map(|column| column.new_empty_column()) .collect(); todo!("Actually remove the column"); Archetype { entities: Vec::new(), columns, } } } }
We could *technically* merge these into one method and choose whether to add or remove
depending on whether Vec<T>
is already present in the columns. However whenever we call
this method we'll *know* whether it's meant to be an add/remove, so if we make them separate
methods we can add some checks that it's valid to add/remove a component from the archetype.
The code for new_from_add
:
#![allow(unused)] fn main() { impl Archetype { fn new_from_add<T: 'static>(from_archetype: &Archetype) -> Archetype { let mut columns: Vec<_> = from_archetype .columns .iter() .map(|column| column.new_empty_column()) .collect(); /* snip */ assert!(columns .iter() .find(|column| column.as_any().is::<Vec<T>>()) .is_none()); columns.push(Box::new(Vec::<T>::new())); /* snip */ Archetype { entities: Vec::new(), columns, } } } }
We just assert!
here instead of trying to recover because it's going to be a bug in our ECS if
this assert fires and we really want that to happen loudly.
The code for new_from_remove
:
#![allow(unused)] fn main() { fn new_from_remove<T: 'static>(from_archetype: &Archetype) -> Archetype { let mut columns: Vec<_> = from_archetype .columns .iter() .map(|column| column.new_empty_column()) .collect(); /* snip */ let idx = columns .iter() .position(|column| column.as_any().is::<Vec<T>>()).unwrap(); columns.remove(idx); /* snip */ Archetype { entities: Vec::new(), columns, } } }
Same reason as above for why we just panic in here rather than try to recover :)
That should be everything we need for these two functions but before we move on lets add some tests to verify everything is working correctly
Tests for add/remove constructors
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; /* todo tests */ } }
First tests we should add are some simple ones for testing those asserts/unwraps
fire when trying to call new_from_(add/remove)
on incorrect archetypes.
use std::any::Any;
trait ComponentColumn: Any {
fn as_any(&self) -> &dyn Any;
fn as_any_mut(&mut self) -> &mut dyn Any;
fn new_empty_column(&self) -> Box<dyn ComponentColumn>;
}
impl<T: 'static> ComponentColumn for Vec<T> {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn new_empty_column(&self) -> Box<dyn ComponentColumn> {
Box::new(Vec::<T>::new())
}
}
struct Archetype {
entities: Vec<crate::Entity>,
columns: Vec<Box<dyn ComponentColumn>>,
}
impl Archetype {
fn new_from_add<T: 'static>(from_archetype: &Archetype) -> Archetype {
let mut columns: Vec<_> = from_archetype
.columns
.iter()
.map(|column| column.new_empty_column())
.collect();
assert!(columns
.iter()
.find(|column| column.as_any().is::<Vec<T>>())
.is_none());
columns.push(Box::new(Vec::<T>::new()));
Archetype {
entities: Vec::new(),
columns,
}
}
fn new_from_remove<T: 'static>(from_archetype: &Archetype) -> Archetype {
let mut columns: Vec<_> = from_archetype
.columns
.iter()
.map(|column| column.new_empty_column())
.collect();
let idx = columns
.iter()
.position(|column| column.as_any().is::<Vec<T>>())
.unwrap();
columns.remove(idx);
Archetype {
entities: Vec::new(),
columns,
}
}
fn builder() -> ColumnsBuilder {
ColumnsBuilder(Vec::new())
}
fn new_from_columns(columns: ColumnsBuilder) -> Archetype {
Archetype {
entities: Vec::new(),
columns: columns.0,
}
}
}
struct ColumnsBuilder(Vec<Box<dyn ComponentColumn>>);
impl ColumnsBuilder {
fn with_column_type<T: 'static>(mut self) -> Self {
if let Some(_) = self
.0
.iter()
.find(|col| col.as_any().type_id() == std::any::TypeId::of::<Vec<T>>())
{
panic!("Attempted to create invalid archetype");
}
self.0.push(Box::new(Vec::<T>::new()));
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_removes() {
let archetype = Archetype {
entities: Vec::new(),
columns: Vec::new(),
};
let archetype = Archetype::new_from_add::<u32>(&archetype);
assert!(archetype.columns.len() == 1);
assert!(archetype
.columns
.iter()
.find(|col| col.as_any().is::<Vec<u32>>())
.is_some());
let archetype = Archetype::new_from_add::<u64>(&archetype);
assert!(archetype.columns.len() == 2);
assert!(archetype
.columns
.iter()
.find(|col| col.as_any().is::<Vec<u32>>())
.is_some());
assert!(archetype
.columns
.iter()
.find(|col| col.as_any().is::<Vec<u64>>())
.is_some());
let archetype = Archetype::new_from_remove::<u32>(&archetype);
assert!(archetype.columns.len() == 1);
assert!(archetype
.columns
.iter()
.find(|col| col.as_any().is::<Vec<u64>>())
.is_some());
}
#[test]
#[should_panic]
fn add_preexisting() {
let archetype = Archetype {
entities: Vec::new(),
columns: Vec::new(),
};
let archetype = Archetype::new_from_add::<u32>(&archetype);
let archetype = Archetype::new_from_add::<u32>(&archetype);
}
#[test]
#[should_panic]
fn remove_unpresent() {
let archetype = Archetype {
entities: Vec::new(),
columns: Vec::new(),
};
let archetype = Archetype::new_from_remove::<u32>(&archetype);
}
#[test]
#[should_panic]
fn remove_unpresent_2() {
let archetype = Archetype {
entities: Vec::new(),
columns: Vec::new(),
};
let archetype = Archetype::new_from_add::<u64>(&archetype);
let archetype = Archetype::new_from_remove::<u32>(&archetype);
}
#[test]
fn columns_builder() {
let archetype = Archetype::new_from_columns(
Archetype::builder()
.with_column_type::<u32>()
.with_column_type::<u64>()
.with_column_type::<bool>(),
);
assert!(archetype.columns.len() == 3);
assert!(archetype
.columns
.iter()
.find(|col| col.as_any().is::<Vec<u32>>())
.is_some());
assert!(archetype
.columns
.iter()
.find(|col| col.as_any().is::<Vec<u64>>())
.is_some());
assert!(archetype
.columns
.iter()
.find(|col| col.as_any().is::<Vec<bool>>())
.is_some());
}
#[test]
#[should_panic]
fn columns_builder_duplicate() {
let archetype = Archetype::new_from_columns(
Archetype::builder()
.with_column_type::<u32>()
.with_column_type::<u32>(),
);
}
}
The next test we want is just something to call new_from_(add/remove)
a bunch and assert
that all the columns that we expect to be present are actually present:
use std::any::Any;
trait ComponentColumn: Any {
fn as_any(&self) -> &dyn Any;
fn as_any_mut(&mut self) -> &mut dyn Any;
fn new_empty_column(&self) -> Box<dyn ComponentColumn>;
}
impl<T: 'static> ComponentColumn for Vec<T> {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn new_empty_column(&self) -> Box<dyn ComponentColumn> {
Box::new(Vec::<T>::new())
}
}
struct Archetype {
entities: Vec<crate::Entity>,
columns: Vec<Box<dyn ComponentColumn>>,
}
impl Archetype {
fn new_from_add<T: 'static>(from_archetype: &Archetype) -> Archetype {
let mut columns: Vec<_> = from_archetype
.columns
.iter()
.map(|column| column.new_empty_column())
.collect();
assert!(columns
.iter()
.find(|column| column.as_any().is::<Vec<T>>())
.is_none());
columns.push(Box::new(Vec::<T>::new()));
Archetype {
entities: Vec::new(),
columns,
}
}
fn new_from_remove<T: 'static>(from_archetype: &Archetype) -> Archetype {
let mut columns: Vec<_> = from_archetype
.columns
.iter()
.map(|column| column.new_empty_column())
.collect();
let idx = columns
.iter()
.position(|column| column.as_any().is::<Vec<T>>())
.unwrap();
columns.remove(idx);
Archetype {
entities: Vec::new(),
columns,
}
}
fn builder() -> ColumnsBuilder {
ColumnsBuilder(Vec::new())
}
fn new_from_columns(columns: ColumnsBuilder) -> Archetype {
Archetype {
entities: Vec::new(),
columns: columns.0,
}
}
}
struct ColumnsBuilder(Vec<Box<dyn ComponentColumn>>);
impl ColumnsBuilder {
fn with_column_type<T: 'static>(mut self) -> Self {
if let Some(_) = self
.0
.iter()
.find(|col| col.as_any().type_id() == std::any::TypeId::of::<Vec<T>>())
{
panic!("Attempted to create invalid archetype");
}
self.0.push(Box::new(Vec::<T>::new()));
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_removes() {
let archetype = Archetype {
entities: Vec::new(),
columns: Vec::new(),
};
let archetype = Archetype::new_from_add::<u32>(&archetype);
assert!(archetype.columns.len() == 1);
assert!(archetype
.columns
.iter()
.find(|col| col.as_any().is::<Vec<u32>>())
.is_some());
let archetype = Archetype::new_from_add::<u64>(&archetype);
assert!(archetype.columns.len() == 2);
assert!(archetype
.columns
.iter()
.find(|col| col.as_any().is::<Vec<u32>>())
.is_some());
assert!(archetype
.columns
.iter()
.find(|col| col.as_any().is::<Vec<u64>>())
.is_some());
let archetype = Archetype::new_from_remove::<u32>(&archetype);
assert!(archetype.columns.len() == 1);
assert!(archetype
.columns
.iter()
.find(|col| col.as_any().is::<Vec<u64>>())
.is_some());
}
#[test]
#[should_panic]
fn add_preexisting() {
let archetype = Archetype {
entities: Vec::new(),
columns: Vec::new(),
};
let archetype = Archetype::new_from_add::<u32>(&archetype);
let archetype = Archetype::new_from_add::<u32>(&archetype);
}
#[test]
#[should_panic]
fn remove_unpresent() {
let archetype = Archetype {
entities: Vec::new(),
columns: Vec::new(),
};
let archetype = Archetype::new_from_remove::<u32>(&archetype);
}
#[test]
#[should_panic]
fn remove_unpresent_2() {
let archetype = Archetype {
entities: Vec::new(),
columns: Vec::new(),
};
let archetype = Archetype::new_from_add::<u64>(&archetype);
let archetype = Archetype::new_from_remove::<u32>(&archetype);
}
#[test]
fn columns_builder() {
let archetype = Archetype::new_from_columns(
Archetype::builder()
.with_column_type::<u32>()
.with_column_type::<u64>()
.with_column_type::<bool>(),
);
assert!(archetype.columns.len() == 3);
assert!(archetype
.columns
.iter()
.find(|col| col.as_any().is::<Vec<u32>>())
.is_some());
assert!(archetype
.columns
.iter()
.find(|col| col.as_any().is::<Vec<u64>>())
.is_some());
assert!(archetype
.columns
.iter()
.find(|col| col.as_any().is::<Vec<bool>>())
.is_some());
}
#[test]
#[should_panic]
fn columns_builder_duplicate() {
let archetype = Archetype::new_from_columns(
Archetype::builder()
.with_column_type::<u32>()
.with_column_type::<u32>(),
);
}
}
Archetype constructor for when spawning entities
Now that we've finished up the constructors for when we add/remove components on an entity we need to make one for when we spawn an entity and need to build an archetype for it.
This one is a bit tricker because we want to take any # of component types when we create an archetype to spawn our entity into. We'll want some kind of builder struct that we can repeatedly call a function on to add component columns.
Something like this:
#![allow(unused)] fn main() { struct ColumnsBuilder(Vec<Box<dyn ComponentColumn>>); impl ColumnsBuilder { fn with_column_type<T: 'static>(mut self) -> Self { if let Some(_) = self .0 .iter() .find(|col| col.as_any().type_id() == std::any::TypeId::of::<Vec<T>>()) { panic!("Attempted to create invalid archetype"); } self.0.push(Box::new(Vec::<T>::new())); self } } }
We have to check that we don't try to create an archetype with two columns for the same component
type. i.e. an archetype for entities with components [T, T]
is nonsensical as an entity cannot
have the same component added twice. Currently I just panic here if we detect that but it would
be possible to return a Result
here and propagate it up to the user
Now that we have a ColumnsBuilder
lets add some methods to Archetype
to use it
#![allow(unused)] fn main() { impl Archetype { /* snip */ fn builder() -> ColumnsBuilder { ColumnsBuilder(Vec::new()) } fn new_from_columns(columns: ColumnsBuilder) -> Archetype { Archetype { entities: Vec::new(), columns: columns.0, } } } }
An alternative way of implementing this would be to implement a trait for tuples of length 1 to some arbitrary limit and then create the columns in that trait. This has the downside of needing to use macros for the trait impl and we also would have a limit on how many components could be spawned on an entity without adding components separately. (We'll doing something like this later on when we implement iterators over our archetypes)
This should be us done with the method we'll use when creating an Archetype
to spawn an entity
into, we just need to add some tests before moving on :)
Tests for ColumnsBuilder
We'll want a test that our duplicate column checks work and also a general test that we have the
expected columns after building an archetype from the ColumnsBuilder
:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { /* snip */ #[test] fn columns_builder() { let archetype = Archetype::new_from_columns( Archetype::builder() .with_column_type::<u32>() .with_column_type::<u64>() .with_column_type::<bool>(), ); assert!(archetype.columns.len() == 3); assert!(archetype .columns .iter() .find(|col| col.as_any().is::<Vec<u32>>()) .is_some()); assert!(archetype .columns .iter() .find(|col| col.as_any().is::<Vec<u64>>()) .is_some()); assert!(archetype .columns .iter() .find(|col| col.as_any().is::<Vec<bool>>()) .is_some()); } #[test] #[should_panic] fn columns_builder_duplicate() { let archetype = Archetype::new_from_columns( Archetype::builder() .with_column_type::<u32>() .with_column_type::<u32>(), ); } } }
The full source code for this chapter can be viewed here