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!