Last active
November 12, 2024 06:07
-
-
Save matthewjberger/da8c14b5d49ce1621fdea42a58e4db67 to your computer and use it in GitHub Desktop.
an archetypal statically dispatched macro-based ecs - formal library here: https://github.com/matthewjberger/freecs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#[macro_export] | |
macro_rules! world { | |
( | |
$world:ident { | |
components { | |
$($name:ident: $type:ty => $mask:ident),* $(,)? | |
}$(,)? | |
$resources:ident { | |
$($resource_name:ident: $resource_type:ty),* $(,)? | |
} | |
} | |
) => { | |
/// Component masks | |
#[repr(u32)] | |
#[allow(clippy::upper_case_acronyms)] | |
#[allow(non_camel_case_types)] | |
pub enum Component { | |
$($mask,)* | |
All, | |
} | |
pub const ALL: u32 = 0; | |
$(pub const $mask: u32 = 1 << (Component::$mask as u32);)* | |
pub const COMPONENT_COUNT: usize = { Component::All as usize }; | |
/// Entity ID, an index into storage and a generation counter to prevent stale references | |
#[derive(Default, Clone, Copy, Debug, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)] | |
pub struct EntityId { | |
pub id: u32, | |
pub generation: u32, | |
} | |
impl std::fmt::Display for EntityId { | |
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
let Self { id, generation } = self; | |
write!(f, "Id: {id} - Generation: {generation}") | |
} | |
} | |
// Handles allocation and reuse of entity IDs | |
#[derive(Default, serde::Serialize, serde::Deserialize)] | |
pub struct EntityAllocator { | |
next_id: u32, | |
free_ids: Vec<(u32, u32)>, // (id, next_generation) | |
} | |
#[derive(Copy, Clone, Default, serde::Serialize, serde::Deserialize)] | |
struct EntityLocation { | |
generation: u32, | |
table_index: u16, | |
array_index: u16, | |
allocated: bool, | |
} | |
/// Entity location cache for quick access | |
#[derive(Default, serde::Serialize, serde::Deserialize)] | |
pub struct EntityLocations { | |
locations: Vec<EntityLocation>, | |
} | |
/// A collection of component tables and resources | |
#[derive(Default, serde::Serialize, serde::Deserialize)] | |
pub struct $world { | |
pub entity_locations: EntityLocations, | |
pub tables: Vec<ComponentArrays>, | |
pub allocator: EntityAllocator, | |
#[serde(skip)] | |
#[allow(unused)] | |
pub resources: $resources, | |
table_edges: Vec<TableEdges>, | |
pending_despawns: Vec<EntityId>, | |
} | |
/// Resources | |
#[derive(Default)] | |
pub struct $resources { | |
$(pub $resource_name: $resource_type,)* | |
} | |
/// Component Table | |
#[derive(Default, serde::Serialize, serde::Deserialize)] | |
pub struct ComponentArrays { | |
$(pub $name: Vec<$type>,)* | |
pub entity_indices: Vec<EntityId>, | |
pub mask: u32, | |
} | |
#[derive(Copy, Clone, Default, serde::Serialize, serde::Deserialize)] | |
struct TableEdges { | |
add_edges: [Option<usize>; COMPONENT_COUNT], | |
remove_edges: [Option<usize>; COMPONENT_COUNT], | |
} | |
fn get_component_index(mask: u32) -> Option<usize> { | |
match mask { | |
$($mask => Some(Component::$mask as _),)* | |
_ => None, | |
} | |
} | |
/// Spawn a batch of new entities with the same component mask | |
pub fn spawn_entities(world: &mut $world, mask: u32, count: usize) -> Vec<EntityId> { | |
let mut entities = Vec::with_capacity(count); | |
let table_index = get_or_create_table(world, mask); | |
world.tables[table_index].entity_indices.reserve(count); | |
// Reserve space in components | |
$( | |
if mask & $mask != 0 { | |
world.tables[table_index].$name.reserve(count); | |
} | |
)* | |
for _ in 0..count { | |
let entity = create_entity(world); | |
add_to_table( | |
&mut world.tables[table_index], | |
entity, | |
( | |
$( | |
if mask & $mask != 0 { | |
Some(<$type>::default()) | |
} else { | |
None | |
}, | |
)* | |
), | |
); | |
entities.push(entity); | |
location_insert( | |
&mut world.entity_locations, | |
entity, | |
(table_index, world.tables[table_index].entity_indices.len() - 1), | |
); | |
} | |
entities | |
} | |
/// Query for all entities that match the component mask | |
pub fn query_entities(world: &$world, mask: u32) -> Vec<EntityId> { | |
let total_capacity = world | |
.tables | |
.iter() | |
.filter(|table| table.mask & mask == mask) | |
.map(|table| table.entity_indices.len()) | |
.sum(); | |
let mut result = Vec::with_capacity(total_capacity); | |
for table in &world.tables { | |
if table.mask & mask == mask { | |
// Only include allocated entities | |
result.extend( | |
table | |
.entity_indices | |
.iter() | |
.copied() | |
.filter(|&e| world.entity_locations.locations[e.id as usize].allocated), | |
); | |
} | |
} | |
result | |
} | |
/// Query for the first entity that matches the component mask | |
/// Returns as soon as a match is found, instead of running for all entities | |
pub fn query_first_entity(world: &$world, mask: u32) -> Option<EntityId> { | |
for table in &world.tables { | |
if !has_components!(table, mask) { | |
continue; | |
} | |
let indices = table | |
.entity_indices | |
.iter() | |
.copied() | |
.filter(|&e| world.entity_locations.locations[e.id as usize].allocated) | |
.collect::<Vec<_>>(); | |
if let Some(entity) = indices.first() { | |
return Some(*entity); | |
} | |
} | |
None | |
} | |
/// Get a specific component for an entity | |
pub fn get_component<T: 'static>(world: &$world, entity: EntityId, mask: u32) -> Option<&T> { | |
let (table_index, array_index) = location_get(&world.entity_locations, entity)?; | |
// Early return if entity is despawned | |
if !world.entity_locations.locations[entity.id as usize].allocated { | |
return None; | |
} | |
let table = &world.tables[table_index]; | |
if table.mask & mask == 0 { | |
return None; | |
} | |
$( | |
if mask == $mask && std::any::TypeId::of::<T>() == std::any::TypeId::of::<$type>() { | |
// SAFETY: This operation is safe because: | |
// 1. We verify the component type T exactly matches $type via TypeId | |
// 2. We confirm the table contains this component via mask check | |
// 3. array_index is valid from location_get bounds check | |
// 4. The reference is valid for the lifetime of the return value | |
// because it's tied to the table reference lifetime | |
// 5. No mutable aliases can exist during the shared borrow | |
// 6. The type cast maintains proper alignment as types are identical | |
return Some(unsafe { &*(&table.$name[array_index] as *const $type as *const T) }); | |
} | |
)* | |
None | |
} | |
/// Get a mutable reference to a specific component for an entity | |
pub fn get_component_mut<T: 'static>(world: &mut $world, entity: EntityId, mask: u32) -> Option<&mut T> { | |
let (table_index, array_index) = location_get(&world.entity_locations, entity)?; | |
let table = &mut world.tables[table_index]; | |
if table.mask & mask == 0 { | |
return None; | |
} | |
$( | |
if mask == $mask && std::any::TypeId::of::<T>() == std::any::TypeId::of::<$type>() { | |
// SAFETY: This operation is safe because: | |
// 1. We verify the component type T exactly matches $type via TypeId | |
// 2. We confirm the table contains this component via mask check | |
// 3. array_index is valid from location_get bounds check | |
// 4. We have exclusive access through the mutable borrow | |
// 5. The borrow checker ensures no other references exist | |
// 6. The pointer cast is valid as we verified the types are identical | |
// 7. Proper alignment is maintained as the types are the same | |
return Some(unsafe { &mut *(&mut table.$name[array_index] as *mut $type as *mut T) }); | |
} | |
)* | |
None | |
} | |
/// Despawn a batch of entities | |
pub fn despawn_entities(world: &mut $world, entities: &[EntityId]) -> Vec<EntityId> { | |
let mut despawned = Vec::with_capacity(entities.len()); | |
let mut tables_to_update = Vec::new(); | |
// First pass: mark entities as despawned and collect their table locations | |
for &entity in entities { | |
let id = entity.id as usize; | |
if id < world.entity_locations.locations.len() { | |
let loc = &mut world.entity_locations.locations[id]; | |
if loc.allocated && loc.generation == entity.generation { | |
// Get table info before marking as despawned | |
let table_idx = loc.table_index as usize; | |
let array_idx = loc.array_index as usize; | |
// Mark as despawned | |
loc.allocated = false; | |
loc.generation = loc.generation.wrapping_add(1); | |
world.allocator.free_ids.push((entity.id, loc.generation)); | |
// Collect table info for updates | |
tables_to_update.push((table_idx, array_idx)); | |
despawned.push(entity); | |
} | |
} | |
} | |
// Second pass: remove entities from tables in reverse order to maintain indices | |
for (table_idx, array_idx) in tables_to_update.into_iter().rev() { | |
if table_idx >= world.tables.len() { | |
continue; | |
} | |
let table = &mut world.tables[table_idx]; | |
let last_idx = table.entity_indices.len() - 1; | |
// If we're not removing the last element, update the moved entity's location | |
if array_idx < last_idx { | |
let moved_entity = table.entity_indices[last_idx]; | |
if let Some(loc) = world.entity_locations.locations.get_mut(moved_entity.id as usize) { | |
if loc.allocated { | |
loc.array_index = array_idx as u16; | |
} | |
} | |
} | |
// Remove the entity's components | |
$( | |
if table.mask & $mask != 0 { | |
table.$name.swap_remove(array_idx); | |
} | |
)* | |
table.entity_indices.swap_remove(array_idx); | |
} | |
despawned | |
} | |
/// Add components to an entity | |
pub fn add_components(world: &mut $world, entity: EntityId, mask: u32) -> bool { | |
if let Some((table_index, array_index)) = location_get(&world.entity_locations, entity) { | |
let current_mask = world.tables[table_index].mask; | |
if current_mask & mask == mask { | |
return true; | |
} | |
let target_table = if mask.count_ones() == 1 { | |
get_component_index(mask).and_then(|idx| world.table_edges[table_index].add_edges[idx]) | |
} else { | |
None | |
}; | |
let new_table_index = | |
target_table.unwrap_or_else(|| get_or_create_table(world, current_mask | mask)); | |
move_entity(world, entity, table_index, array_index, new_table_index); | |
true | |
} else { | |
false | |
} | |
} | |
/// Remove components from an entity | |
pub fn remove_components(world: &mut $world, entity: EntityId, mask: u32) -> bool { | |
if let Some((table_index, array_index)) = location_get(&world.entity_locations, entity) { | |
let current_mask = world.tables[table_index].mask; | |
if current_mask & mask == 0 { | |
return true; | |
} | |
let target_table = if mask.count_ones() == 1 { | |
get_component_index(mask) | |
.and_then(|idx| world.table_edges[table_index].remove_edges[idx]) | |
} else { | |
None | |
}; | |
let new_table_index = | |
target_table.unwrap_or_else(|| get_or_create_table(world, current_mask & !mask)); | |
move_entity(world, entity, table_index, array_index, new_table_index); | |
true | |
} else { | |
false | |
} | |
} | |
/// Get the current component mask for an entity | |
pub fn component_mask(world: &$world, entity: EntityId) -> Option<u32> { | |
location_get(&world.entity_locations, entity) | |
.map(|(table_index, _)| world.tables[table_index].mask) | |
} | |
/// Convert an old entity ID to its new ID after merging | |
pub fn remap_entity(entity_mapping: &[(EntityId, EntityId)], old_entity: EntityId) -> Option<EntityId> { | |
entity_mapping | |
.iter() | |
.find(|(old, _)| *old == old_entity) | |
.map(|(_, new)| *new) | |
} | |
/// Copy entities from source world to destination world | |
pub fn merge_worlds(dest: &mut $world, source: &$world) -> Vec<(EntityId, EntityId)> { | |
let mut entity_mapping = Vec::with_capacity(query_entities(source, ALL).len()); | |
// First pass: copy all entities and build mapping | |
for source_table in &source.tables { | |
if source_table.entity_indices.is_empty() { | |
continue; | |
} | |
let entities_to_spawn = source_table.entity_indices.len(); | |
let entity_mask = source_table.mask; | |
// Spawn new entities and copy component data | |
let new_entities = spawn_entities(dest, entity_mask, entities_to_spawn); | |
// Record old->new entity mappings | |
for (i, &old_entity) in source_table.entity_indices.iter().enumerate() { | |
entity_mapping.push((old_entity, new_entities[i])); | |
} | |
// Copy component data | |
let index = dest.tables.len() - 1; | |
let dest_table = &mut dest.tables[index]; | |
let start_idx = dest_table.entity_indices.len() - entities_to_spawn; | |
// Copy all components that exist in this table | |
$( | |
if entity_mask & $mask != 0 { | |
for (i, component) in source_table.$name.iter().enumerate() { | |
dest_table.$name[start_idx + i] = component.clone(); | |
} | |
} | |
)* | |
} | |
entity_mapping | |
} | |
/// Update entity references in components after merging | |
pub fn remap_entity_refs<T: FnMut(&[(EntityId, EntityId)], &mut ComponentArrays)>( | |
world: &mut $world, | |
entity_mapping: &[(EntityId, EntityId)], | |
mut remap: T | |
) { | |
for table in &mut world.tables { | |
remap(entity_mapping, table); | |
} | |
} | |
fn remove_from_table(arrays: &mut ComponentArrays, index: usize) -> Option<EntityId> { | |
let last_index = arrays.entity_indices.len() - 1; | |
let mut swapped_entity = None; | |
if index < last_index { | |
swapped_entity = Some(arrays.entity_indices[last_index]); | |
} | |
$( | |
if arrays.mask & $mask != 0 { | |
arrays.$name.swap_remove(index); | |
} | |
)* | |
arrays.entity_indices.swap_remove(index); | |
swapped_entity | |
} | |
fn move_entity( | |
world: &mut $world, | |
entity: EntityId, | |
from_table: usize, | |
from_index: usize, | |
to_table: usize, | |
) { | |
let components = get_components(&world.tables[from_table], from_index); | |
add_to_table(&mut world.tables[to_table], entity, components); | |
let new_index = world.tables[to_table].entity_indices.len() - 1; | |
location_insert(&mut world.entity_locations, entity, (to_table, new_index)); | |
if let Some(swapped) = remove_from_table(&mut world.tables[from_table], from_index) { | |
location_insert( | |
&mut world.entity_locations, | |
swapped, | |
(from_table, from_index), | |
); | |
} | |
} | |
fn get_components( | |
arrays: &ComponentArrays, | |
index: usize, | |
) -> ( $(Option<$type>,)* ) { | |
( | |
$( | |
if arrays.mask & $mask != 0 { | |
Some(arrays.$name[index].clone()) | |
} else { | |
None | |
}, | |
)* | |
) | |
} | |
fn location_get(locations: &EntityLocations, entity: EntityId) -> Option<(usize, usize)> { | |
let id = entity.id as usize; | |
if id >= locations.locations.len() { | |
return None; | |
} | |
let location = &locations.locations[id]; | |
// Only return location if entity is allocated AND generation matches | |
if !location.allocated || location.generation != entity.generation { | |
return None; | |
} | |
Some((location.table_index as usize, location.array_index as usize)) } | |
fn location_insert( | |
locations: &mut EntityLocations, | |
entity: EntityId, | |
location: (usize, usize), | |
) { | |
let id = entity.id as usize; | |
if id >= locations.locations.len() { | |
locations | |
.locations | |
.resize(id + 1, EntityLocation::default()); | |
} | |
locations.locations[id] = EntityLocation { | |
generation: entity.generation, | |
table_index: location.0 as u16, | |
array_index: location.1 as u16, | |
allocated: true, | |
}; | |
} | |
fn create_entity(world: &mut $world) -> EntityId { | |
if let Some((id, next_gen)) = world.allocator.free_ids.pop() { | |
let id_usize = id as usize; | |
if id_usize >= world.entity_locations.locations.len() { | |
world.entity_locations.locations.resize( | |
(world.entity_locations.locations.len() * 2).max(64), | |
EntityLocation::default(), | |
); | |
} | |
world.entity_locations.locations[id_usize].generation = next_gen; | |
EntityId { | |
id, | |
generation: next_gen, | |
} | |
} else { | |
let id = world.allocator.next_id; | |
world.allocator.next_id += 1; | |
let id_usize = id as usize; | |
if id_usize >= world.entity_locations.locations.len() { | |
world.entity_locations.locations.resize( | |
(world.entity_locations.locations.len() * 2).max(64), | |
EntityLocation::default(), | |
); | |
} | |
EntityId { id, generation: 0 } | |
} | |
} | |
fn add_to_table( | |
arrays: &mut ComponentArrays, | |
entity: EntityId, | |
components: ( $(Option<$type>,)* ), | |
) { | |
let ($($name,)*) = components; | |
$( | |
if arrays.mask & $mask != 0 { | |
arrays | |
.$name | |
.push($name.unwrap_or_default()); | |
} | |
)* | |
arrays.entity_indices.push(entity); | |
} | |
fn get_or_create_table(world: &mut $world, mask: u32) -> usize { | |
if let Some((index, _)) = world | |
.tables | |
.iter() | |
.enumerate() | |
.find(|(_, t)| t.mask == mask) | |
{ | |
return index; | |
} | |
let table_index = world.tables.len(); | |
world.tables.push(ComponentArrays { | |
mask, | |
..Default::default() | |
}); | |
world.table_edges.push(TableEdges::default()); | |
// Remove table registry updates and only update edges | |
for comp_mask in [ | |
$($mask,)* | |
] { | |
if let Some(comp_idx) = get_component_index(comp_mask) { | |
for (idx, table) in world.tables.iter().enumerate() { | |
if table.mask | comp_mask == mask { | |
world.table_edges[idx].add_edges[comp_idx] = Some(table_index); | |
} | |
if table.mask & !comp_mask == mask { | |
world.table_edges[idx].remove_edges[comp_idx] = Some(table_index); | |
} | |
} | |
} | |
} | |
table_index | |
} | |
}; | |
} | |
#[macro_export] | |
macro_rules! has_components { | |
($table:expr, $mask:expr) => { | |
$table.mask & $mask == $mask | |
}; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Example usage | |
// [dependencies] | |
// macroquad = "0.4.13" | |
// rayon = "1.10.0" | |
// serde = { version = "1.0.214", features = ["derive"] } | |
mod ecs; | |
use ecs::{world, has_components}; | |
use rayon::prelude::*; | |
world! { | |
World { | |
components { | |
position: Position => POSITION, | |
velocity: Velocity => VELOCITY, | |
health: Health => HEALTH, | |
}, | |
Resources { | |
delta_time: f32 | |
} | |
} | |
} | |
pub fn main() { | |
let mut world = World::default(); | |
// Inject resources for systems to use | |
world.resources.delta_time = 0.016; | |
// Spawn entities with components | |
let entity = spawn_entities(&mut world, POSITION | VELOCITY, 1)[0]; | |
println!( | |
"Spawned {} with position and velocity", | |
total_entities(&world) | |
); | |
// Read a component | |
let position = get_component::<Position>(&world, entity, POSITION); | |
println!("Position: {:?}", position); | |
// Mutate a component | |
if let Some(position) = get_component_mut::<Position>(&mut world, entity, POSITION) { | |
position.x += 1.0; | |
} | |
// Get an entity's component mask | |
println!( | |
"Component mask before adding health component: {:b}", | |
component_mask(&world, entity).unwrap() | |
); | |
// Add a new component to an entity | |
add_components(&mut world, entity, HEALTH); | |
println!( | |
"Component mask after adding health component: {:b}", | |
component_mask(&world, entity).unwrap() | |
); | |
// Query all entities with a specific component | |
let players = query_entities(&world, POSITION | VELOCITY | HEALTH); | |
println!("Player entities: {players:?}"); | |
// Query the first entity with a specific component, | |
// returning early instead of checking remaining entities | |
let first_player_entity = query_first_entity(&world, POSITION | VELOCITY | HEALTH); | |
println!("First player entity : {first_player_entity:?}"); | |
// Remove a component from an entity | |
remove_components(&mut world, entity, HEALTH); | |
// This runs the systems once in parallel | |
// Not part of the library's public API, but a demonstration of how to run systems | |
systems::run_systems(&mut world); | |
// Despawn entities, freeing their table slots for reuse | |
despawn_entities(&mut world, &[entity]); | |
} | |
use components::*; | |
mod components { | |
#[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] | |
pub struct Position { | |
pub x: f32, | |
pub y: f32, | |
} | |
#[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] | |
pub struct Velocity { | |
pub x: f32, | |
pub y: f32, | |
} | |
#[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] | |
pub struct Health { | |
pub value: f32, | |
} | |
} | |
mod systems { | |
use super::*; | |
// Systems are functions that iterate over | |
// the component tables and transform component data. | |
// This function invokes two systems in parallel | |
// for each table in the world filtered by component mask. | |
pub fn run_systems(world: &mut World) { | |
let delta_time = world.resources.delta_time; | |
world.tables.par_iter_mut().for_each(|table| { | |
if has_components!(table, POSITION | VELOCITY | HEALTH) { | |
update_positions_system(&mut table.position, &table.velocity, delta_time); | |
} | |
if has_components!(table, HEALTH) { | |
health_system(&mut table.health); | |
} | |
}); | |
} | |
// The system itself can also access components in parallel | |
#[inline] | |
pub fn update_positions_system(positions: &mut [Position], velocities: &[Velocity], dt: f32) { | |
positions | |
.par_iter_mut() | |
.zip(velocities.par_iter()) | |
.for_each(|(pos, vel)| { | |
pos.x += vel.x * dt; | |
pos.y += vel.y * dt; | |
}); | |
} | |
#[inline] | |
pub fn health_system(health: &mut [Health]) { | |
health.par_iter_mut().for_each(|health| { | |
health.value *= 0.98; // gradually decline health value | |
}); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment