From 2d64d226a2949539ef6a934f21afabafbbd1bb5e Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Tue, 24 Mar 2026 20:05:15 +0900 Subject: [PATCH] feat(ecs): add voltex_ecs crate with Entity, EntityAllocator, and SparseSet Co-Authored-By: Claude Sonnet 4.6 --- Cargo.toml | 2 + crates/voltex_ecs/Cargo.toml | 7 + crates/voltex_ecs/src/entity.rs | 136 +++++++++++++++ crates/voltex_ecs/src/lib.rs | 5 + crates/voltex_ecs/src/sparse_set.rs | 256 ++++++++++++++++++++++++++++ 5 files changed, 406 insertions(+) create mode 100644 crates/voltex_ecs/Cargo.toml create mode 100644 crates/voltex_ecs/src/entity.rs create mode 100644 crates/voltex_ecs/src/lib.rs create mode 100644 crates/voltex_ecs/src/sparse_set.rs diff --git a/Cargo.toml b/Cargo.toml index 7987cca..91a9741 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/voltex_math", "crates/voltex_platform", "crates/voltex_renderer", + "crates/voltex_ecs", "examples/triangle", "examples/model_viewer", ] @@ -12,6 +13,7 @@ members = [ voltex_math = { path = "crates/voltex_math" } voltex_platform = { path = "crates/voltex_platform" } voltex_renderer = { path = "crates/voltex_renderer" } +voltex_ecs = { path = "crates/voltex_ecs" } wgpu = "28.0" winit = "0.30" bytemuck = { version = "1", features = ["derive"] } diff --git a/crates/voltex_ecs/Cargo.toml b/crates/voltex_ecs/Cargo.toml new file mode 100644 index 0000000..fdd7ad7 --- /dev/null +++ b/crates/voltex_ecs/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "voltex_ecs" +version = "0.1.0" +edition = "2021" + +[dependencies] +voltex_math.workspace = true diff --git a/crates/voltex_ecs/src/entity.rs b/crates/voltex_ecs/src/entity.rs new file mode 100644 index 0000000..fe1113e --- /dev/null +++ b/crates/voltex_ecs/src/entity.rs @@ -0,0 +1,136 @@ +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Entity { + pub id: u32, + pub generation: u32, +} + +struct EntityEntry { + generation: u32, + alive: bool, +} + +pub struct EntityAllocator { + entries: Vec, + free_list: Vec, + alive_count: usize, +} + +impl EntityAllocator { + pub fn new() -> Self { + Self { + entries: Vec::new(), + free_list: Vec::new(), + alive_count: 0, + } + } + + pub fn allocate(&mut self) -> Entity { + self.alive_count += 1; + if let Some(id) = self.free_list.pop() { + let entry = &mut self.entries[id as usize]; + // generation was already incremented on deallocate + entry.alive = true; + Entity { + id, + generation: entry.generation, + } + } else { + let id = self.entries.len() as u32; + self.entries.push(EntityEntry { + generation: 0, + alive: true, + }); + Entity { id, generation: 0 } + } + } + + pub fn deallocate(&mut self, entity: Entity) -> bool { + let Some(entry) = self.entries.get_mut(entity.id as usize) else { + return false; + }; + if !entry.alive || entry.generation != entity.generation { + return false; + } + entry.alive = false; + entry.generation = entry.generation.wrapping_add(1); + self.free_list.push(entity.id); + self.alive_count -= 1; + true + } + + pub fn is_alive(&self, entity: Entity) -> bool { + self.entries + .get(entity.id as usize) + .map_or(false, |e| e.alive && e.generation == entity.generation) + } + + pub fn alive_count(&self) -> usize { + self.alive_count + } +} + +impl Default for EntityAllocator { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_allocate() { + let mut alloc = EntityAllocator::new(); + let e0 = alloc.allocate(); + let e1 = alloc.allocate(); + assert_eq!(e0.id, 0); + assert_eq!(e1.id, 1); + assert_eq!(e0.generation, 0); + assert_eq!(e1.generation, 0); + } + + #[test] + fn test_deallocate_and_reuse() { + let mut alloc = EntityAllocator::new(); + let e0 = alloc.allocate(); + let _e1 = alloc.allocate(); + assert!(alloc.deallocate(e0)); + let e0_new = alloc.allocate(); + assert_eq!(e0_new.id, 0); + assert_eq!(e0_new.generation, 1); + } + + #[test] + fn test_is_alive() { + let mut alloc = EntityAllocator::new(); + let e = alloc.allocate(); + assert!(alloc.is_alive(e)); + alloc.deallocate(e); + assert!(!alloc.is_alive(e)); + } + + #[test] + fn test_stale_entity_rejected() { + let mut alloc = EntityAllocator::new(); + let e = alloc.allocate(); + alloc.deallocate(e); + // stale entity not alive + assert!(!alloc.is_alive(e)); + // double-delete fails + assert!(!alloc.deallocate(e)); + } + + #[test] + fn test_alive_count() { + let mut alloc = EntityAllocator::new(); + assert_eq!(alloc.alive_count(), 0); + let e0 = alloc.allocate(); + let e1 = alloc.allocate(); + assert_eq!(alloc.alive_count(), 2); + alloc.deallocate(e0); + assert_eq!(alloc.alive_count(), 1); + alloc.deallocate(e1); + assert_eq!(alloc.alive_count(), 0); + } +} diff --git a/crates/voltex_ecs/src/lib.rs b/crates/voltex_ecs/src/lib.rs new file mode 100644 index 0000000..9c3c17c --- /dev/null +++ b/crates/voltex_ecs/src/lib.rs @@ -0,0 +1,5 @@ +pub mod entity; +pub mod sparse_set; + +pub use entity::{Entity, EntityAllocator}; +pub use sparse_set::SparseSet; diff --git a/crates/voltex_ecs/src/sparse_set.rs b/crates/voltex_ecs/src/sparse_set.rs new file mode 100644 index 0000000..3699519 --- /dev/null +++ b/crates/voltex_ecs/src/sparse_set.rs @@ -0,0 +1,256 @@ +use std::any::Any; +use crate::entity::Entity; + +pub struct SparseSet { + sparse: Vec>, + dense_entities: Vec, + dense_data: Vec, +} + +impl SparseSet { + pub fn new() -> Self { + Self { + sparse: Vec::new(), + dense_entities: Vec::new(), + dense_data: Vec::new(), + } + } + + pub fn insert(&mut self, entity: Entity, value: T) { + let id = entity.id as usize; + // Grow sparse vec if needed + if id >= self.sparse.len() { + self.sparse.resize(id + 1, None); + } + + if let Some(dense_idx) = self.sparse[id] { + // Overwrite existing + self.dense_data[dense_idx] = value; + self.dense_entities[dense_idx] = entity; + } else { + let dense_idx = self.dense_data.len(); + self.sparse[id] = Some(dense_idx); + self.dense_entities.push(entity); + self.dense_data.push(value); + } + } + + pub fn remove(&mut self, entity: Entity) -> Option { + let id = entity.id as usize; + let dense_idx = *self.sparse.get(id)?.as_ref()?; + + // Check entity matches (generation safety) + if self.dense_entities[dense_idx] != entity { + return None; + } + + let last_idx = self.dense_data.len() - 1; + self.sparse[id] = None; + + if dense_idx == last_idx { + self.dense_entities.pop(); + Some(self.dense_data.pop().unwrap()) + } else { + // Swap with last + let swapped_entity = self.dense_entities[last_idx]; + self.sparse[swapped_entity.id as usize] = Some(dense_idx); + self.dense_entities.swap_remove(dense_idx); + Some(self.dense_data.swap_remove(dense_idx)) + } + } + + pub fn get(&self, entity: Entity) -> Option<&T> { + let id = entity.id as usize; + let dense_idx = self.sparse.get(id)?.as_ref().copied()?; + if self.dense_entities[dense_idx] != entity { + return None; + } + Some(&self.dense_data[dense_idx]) + } + + pub fn get_mut(&mut self, entity: Entity) -> Option<&mut T> { + let id = entity.id as usize; + let dense_idx = self.sparse.get(id)?.as_ref().copied()?; + if self.dense_entities[dense_idx] != entity { + return None; + } + Some(&mut self.dense_data[dense_idx]) + } + + pub fn contains(&self, entity: Entity) -> bool { + let id = entity.id as usize; + self.sparse + .get(id) + .and_then(|opt| opt.as_ref()) + .map_or(false, |&dense_idx| { + self.dense_entities[dense_idx] == entity + }) + } + + pub fn len(&self) -> usize { + self.dense_data.len() + } + + pub fn is_empty(&self) -> bool { + self.dense_data.is_empty() + } + + pub fn iter(&self) -> impl Iterator { + self.dense_entities.iter().copied().zip(self.dense_data.iter()) + } + + pub fn iter_mut(&mut self) -> impl Iterator { + self.dense_entities.iter().copied().zip(self.dense_data.iter_mut()) + } + + pub fn entities(&self) -> &[Entity] { + &self.dense_entities + } + + pub fn data(&self) -> &[T] { + &self.dense_data + } + + pub fn data_mut(&mut self) -> &mut [T] { + &mut self.dense_data + } +} + +impl Default for SparseSet { + fn default() -> Self { + Self::new() + } +} + +pub trait ComponentStorage: Any { + fn as_any(&self) -> &dyn Any; + fn as_any_mut(&mut self) -> &mut dyn Any; + fn remove_entity(&mut self, entity: Entity); + fn storage_len(&self) -> usize; +} + +impl ComponentStorage for SparseSet { + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + fn remove_entity(&mut self, entity: Entity) { + self.remove(entity); + } + fn storage_len(&self) -> usize { + self.dense_data.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + + fn make_entity(id: u32, generation: u32) -> Entity { + Entity { id, generation } + } + + #[test] + fn test_insert_and_get() { + let mut set: SparseSet = SparseSet::new(); + let e = make_entity(0, 0); + set.insert(e, 42); + assert_eq!(set.get(e), Some(&42)); + assert_eq!(set.len(), 1); + } + + #[test] + fn test_overwrite() { + let mut set: SparseSet = SparseSet::new(); + let e = make_entity(0, 0); + set.insert(e, 1); + set.insert(e, 99); + assert_eq!(set.get(e), Some(&99)); + assert_eq!(set.len(), 1); + } + + #[test] + fn test_remove() { + let mut set: SparseSet = SparseSet::new(); + let e0 = make_entity(0, 0); + let e1 = make_entity(1, 0); + let e2 = make_entity(2, 0); + set.insert(e0, 10); + set.insert(e1, 20); + set.insert(e2, 30); + // Remove middle + let removed = set.remove(e1); + assert_eq!(removed, Some(20)); + assert_eq!(set.len(), 2); + assert!(set.get(e1).is_none()); + // Remaining still accessible + assert_eq!(set.get(e0), Some(&10)); + assert_eq!(set.get(e2), Some(&30)); + } + + #[test] + fn test_remove_nonexistent() { + let mut set: SparseSet = SparseSet::new(); + let e = make_entity(5, 0); + assert_eq!(set.remove(e), None); + } + + #[test] + fn test_iter() { + let mut set: SparseSet = SparseSet::new(); + let e0 = make_entity(0, 0); + let e1 = make_entity(1, 0); + set.insert(e0, 100); + set.insert(e1, 200); + let mut values: Vec = set.iter().map(|(_, v)| *v).collect(); + values.sort(); + assert_eq!(values, vec![100, 200]); + } + + #[test] + fn test_iter_mut() { + let mut set: SparseSet = SparseSet::new(); + let e0 = make_entity(0, 0); + let e1 = make_entity(1, 0); + set.insert(e0, 1); + set.insert(e1, 2); + for (_, v) in set.iter_mut() { + *v *= 10; + } + assert_eq!(set.get(e0), Some(&10)); + assert_eq!(set.get(e1), Some(&20)); + } + + #[test] + fn test_contains() { + let mut set: SparseSet = SparseSet::new(); + let e = make_entity(3, 0); + assert!(!set.contains(e)); + set.insert(e, 7); + assert!(set.contains(e)); + set.remove(e); + assert!(!set.contains(e)); + } + + #[test] + fn test_swap_remove_correctness() { + let mut set: SparseSet = SparseSet::new(); + let e0 = make_entity(0, 0); + let e1 = make_entity(1, 0); + let e2 = make_entity(2, 0); + set.insert(e0, 10); + set.insert(e1, 20); + set.insert(e2, 30); + // Remove first (triggers swap with last) + let removed = set.remove(e0); + assert_eq!(removed, Some(10)); + assert_eq!(set.len(), 2); + assert!(set.get(e0).is_none()); + // Remaining still accessible + assert_eq!(set.get(e1), Some(&20)); + assert_eq!(set.get(e2), Some(&30)); + } +}