Skip to content

Instantly share code, notes, and snippets.

@matthewjberger
Last active November 12, 2024 06:07
Show Gist options
  • Save matthewjberger/da8c14b5d49ce1621fdea42a58e4db67 to your computer and use it in GitHub Desktop.
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
#[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
};
}
// 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