# Phase 3a: ECS (Entity Component System) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** SparseSet 기반 ECS로 수백 개의 엔티티를 생성하고, 각각 Transform + MeshHandle 컴포넌트를 가지며, 모두 렌더링할 수 있다. **Architecture:** 새 `voltex_ecs` crate를 만든다. Entity는 id+generation 쌍. 컴포넌트 스토리지는 SparseSet. World가 type-erased 스토리지를 관리하고, Query로 컴포넌트 조합을 순회한다. Transform 컴포넌트는 voltex_ecs에 정의하고 (voltex_math 의존), 렌더링 관련 컴포넌트(MeshHandle)는 앱 레벨에서 정의한다. **Tech Stack:** Rust 1.94, voltex_math (Vec3, Mat4) **Spec:** `docs/superpowers/specs/2026-03-24-voltex-engine-design.md` Phase 3 섹션 (3-1: ECS 프레임워크) **스코프 제한 (Phase 3a):** Archetype 마이그레이션, 시스템 스케줄러, 씬 그래프, 에셋 매니저는 Phase 3b/3c로 분리. 이번에는 SparseSet 기반 ECS + 다수 엔티티 렌더링만 구현한다. --- ## File Structure ``` crates/ └── voltex_ecs/ ├── Cargo.toml └── src/ ├── lib.rs # 모듈 re-export ├── entity.rs # Entity ID + EntityAllocator ├── sparse_set.rs # SparseSet 제네릭 스토리지 ├── world.rs # World (타입 소거 스토리지 관리) ├── query.rs # 1~2 컴포넌트 쿼리 이터레이터 └── transform.rs # Transform 컴포넌트 examples/ └── many_cubes/ # ECS 데모 (NEW) ├── Cargo.toml └── src/ └── main.rs ``` --- ## Task 1: 프로젝트 설정 + Entity **Files:** - Create: `crates/voltex_ecs/Cargo.toml` - Create: `crates/voltex_ecs/src/lib.rs` - Create: `crates/voltex_ecs/src/entity.rs` - Modify: `Cargo.toml` (워크스페이스에 voltex_ecs 추가) Entity는 경량 ID. id(u32) + generation(u32)으로 dangling 참조를 방지한다. EntityAllocator는 엔티티를 생성/삭제하고 free list로 ID를 재활용한다. - [ ] **Step 1: Cargo.toml 작성** ```toml # crates/voltex_ecs/Cargo.toml [package] name = "voltex_ecs" version = "0.1.0" edition = "2021" [dependencies] voltex_math.workspace = true ``` - [ ] **Step 2: 워크스페이스 루트 Cargo.toml 업데이트** members에 `"crates/voltex_ecs"` 추가. workspace.dependencies에 `voltex_ecs = { path = "crates/voltex_ecs" }` 추가. - [ ] **Step 3: entity.rs 작성** ```rust // crates/voltex_ecs/src/entity.rs /// 엔티티 식별자. id + generation으로 dangling 참조 방지. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct Entity { pub id: u32, pub generation: u32, } impl Entity { pub fn new(id: u32, generation: u32) -> Self { Self { id, generation } } } /// 엔티티 생성/삭제 관리. Free list로 ID 재활용. pub struct EntityAllocator { generations: Vec, free_ids: Vec, alive: Vec, } impl EntityAllocator { pub fn new() -> Self { Self { generations: Vec::new(), free_ids: Vec::new(), alive: Vec::new(), } } /// 새 엔티티 생성 pub fn allocate(&mut self) -> Entity { if let Some(id) = self.free_ids.pop() { self.alive[id as usize] = true; Entity::new(id, self.generations[id as usize]) } else { let id = self.generations.len() as u32; self.generations.push(0); self.alive.push(true); Entity::new(id, 0) } } /// 엔티티 삭제. generation 증가시켜 이전 참조 무효화. pub fn deallocate(&mut self, entity: Entity) -> bool { let id = entity.id as usize; if id < self.alive.len() && self.alive[id] && self.generations[id] == entity.generation { self.alive[id] = false; self.generations[id] += 1; self.free_ids.push(entity.id); true } else { false } } /// 엔티티가 살아있는지 확인 pub fn is_alive(&self, entity: Entity) -> bool { let id = entity.id as usize; id < self.alive.len() && self.alive[id] && self.generations[id] == entity.generation } /// 현재 살아있는 엔티티 수 pub fn alive_count(&self) -> usize { self.alive.iter().filter(|&&a| a).count() } } #[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); } #[test] fn test_deallocate_and_reuse() { let mut alloc = EntityAllocator::new(); let e0 = alloc.allocate(); assert!(alloc.deallocate(e0)); let e0_reused = alloc.allocate(); assert_eq!(e0_reused.id, e0.id); assert_eq!(e0_reused.generation, 1); // generation 증가 } #[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)); // 삭제 후 dead } #[test] fn test_stale_entity_rejected() { let mut alloc = EntityAllocator::new(); let e_old = alloc.allocate(); alloc.deallocate(e_old); let _e_new = alloc.allocate(); // 같은 id, generation 1 assert!(!alloc.is_alive(e_old)); // old generation은 dead assert!(!alloc.deallocate(e_old)); // 이중 삭제 방지 } #[test] fn test_alive_count() { let mut alloc = EntityAllocator::new(); let e0 = alloc.allocate(); let _e1 = alloc.allocate(); assert_eq!(alloc.alive_count(), 2); alloc.deallocate(e0); assert_eq!(alloc.alive_count(), 1); } } ``` - [ ] **Step 4: lib.rs 작성** ```rust // crates/voltex_ecs/src/lib.rs pub mod entity; pub use entity::{Entity, EntityAllocator}; ``` - [ ] **Step 5: 테스트 통과 확인** Run: `cargo test -p voltex_ecs` Expected: 5개 테스트 PASS - [ ] **Step 6: 커밋** ```bash git add Cargo.toml crates/voltex_ecs/ git commit -m "feat(ecs): add Entity and EntityAllocator with generation tracking" ``` --- ## Task 2: SparseSet **Files:** - Create: `crates/voltex_ecs/src/sparse_set.rs` - Modify: `crates/voltex_ecs/src/lib.rs` SparseSet은 Entity → Component 매핑을 제공하는 핵심 자료구조. Sparse 배열(entity id → dense index)과 Dense 배열(연속 메모리의 컴포넌트 + 엔티티 역참조)로 구성. 장점: O(1) insert/remove/lookup, dense 순회가 캐시 친화적. - [ ] **Step 1: sparse_set.rs 작성** ```rust // crates/voltex_ecs/src/sparse_set.rs use crate::Entity; /// Entity → T 매핑. Dense 배열로 빠른 순회, Sparse 배열로 O(1) 접근. pub struct SparseSet { sparse: Vec>, // entity.id → dense index dense_entities: Vec, // dense index → entity dense_data: Vec, // dense index → component } 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; // sparse 배열 확장 if id >= self.sparse.len() { self.sparse.resize(id + 1, None); } if let Some(dense_idx) = self.sparse[id] { // 이미 존재 — 덮어쓰기 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); } } /// 컴포넌트 제거. swap-remove로 dense 배열 유지. pub fn remove(&mut self, entity: Entity) -> Option { let id = entity.id as usize; if id >= self.sparse.len() { return None; } if let Some(dense_idx) = self.sparse[id].take() { let last_idx = self.dense_data.len() - 1; // swap-remove let removed = self.dense_data.swap_remove(dense_idx); self.dense_entities.swap_remove(dense_idx); // swap된 마지막 요소의 sparse 인덱스 업데이트 if dense_idx < self.dense_data.len() { let swapped_id = self.dense_entities[dense_idx].id as usize; self.sparse[swapped_id] = Some(dense_idx); } Some(removed) } else { None } } /// 엔티티의 컴포넌트 참조 pub fn get(&self, entity: Entity) -> Option<&T> { let id = entity.id as usize; if id >= self.sparse.len() { return None; } self.sparse[id].map(|idx| &self.dense_data[idx]) } /// 엔티티의 컴포넌트 가변 참조 pub fn get_mut(&mut self, entity: Entity) -> Option<&mut T> { let id = entity.id as usize; if id >= self.sparse.len() { return None; } self.sparse[id].map(|idx| &mut self.dense_data[idx]) } /// 엔티티가 이 스토리지에 존재하는지 pub fn contains(&self, entity: Entity) -> bool { let id = entity.id as usize; id < self.sparse.len() && self.sparse[id].is_some() } /// 저장된 컴포넌트 수 pub fn len(&self) -> usize { self.dense_data.len() } pub fn is_empty(&self) -> bool { self.dense_data.is_empty() } /// Dense 배열 순회 (entity, &T) pub fn iter(&self) -> impl Iterator { self.dense_entities.iter().copied().zip(self.dense_data.iter()) } /// Dense 배열 가변 순회 (entity, &mut T) pub fn iter_mut(&mut self) -> impl Iterator { self.dense_entities.iter().copied().zip(self.dense_data.iter_mut()) } /// Dense 엔티티 슬라이스 (쿼리용) pub fn entities(&self) -> &[Entity] { &self.dense_entities } /// Dense 데이터 슬라이스 (쿼리용) pub fn data(&self) -> &[T] { &self.dense_data } /// Dense 데이터 가변 슬라이스 pub fn data_mut(&mut self) -> &mut [T] { &mut self.dense_data } } #[cfg(test)] mod tests { use super::*; fn e(id: u32) -> Entity { Entity::new(id, 0) } #[test] fn test_insert_and_get() { let mut set = SparseSet::new(); set.insert(e(5), "hello"); assert_eq!(set.get(e(5)), Some(&"hello")); assert_eq!(set.get(e(0)), None); } #[test] fn test_overwrite() { let mut set = SparseSet::new(); set.insert(e(0), 10); set.insert(e(0), 20); assert_eq!(set.get(e(0)), Some(&20)); assert_eq!(set.len(), 1); } #[test] fn test_remove() { let mut set = SparseSet::new(); set.insert(e(0), "a"); set.insert(e(1), "b"); set.insert(e(2), "c"); let removed = set.remove(e(0)); assert_eq!(removed, Some("a")); assert_eq!(set.len(), 2); assert!(!set.contains(e(0))); // swap-remove: 남은 요소들 여전히 접근 가능 assert!(set.contains(e(1))); assert!(set.contains(e(2))); } #[test] fn test_remove_nonexistent() { let mut set: SparseSet = SparseSet::new(); assert_eq!(set.remove(e(99)), None); } #[test] fn test_iter() { let mut set = SparseSet::new(); set.insert(e(0), 10); set.insert(e(1), 20); set.insert(e(2), 30); let values: Vec = set.iter().map(|(_, v)| *v).collect(); assert_eq!(values.len(), 3); assert!(values.contains(&10)); assert!(values.contains(&20)); assert!(values.contains(&30)); } #[test] fn test_iter_mut() { let mut set = SparseSet::new(); set.insert(e(0), 1); set.insert(e(1), 2); for (_, v) in set.iter_mut() { *v *= 10; } assert_eq!(set.get(e(0)), Some(&10)); assert_eq!(set.get(e(1)), Some(&20)); } #[test] fn test_contains() { let mut set = SparseSet::new(); assert!(!set.contains(e(0))); set.insert(e(0), 42); assert!(set.contains(e(0))); } #[test] fn test_swap_remove_correctness() { let mut set = SparseSet::new(); set.insert(e(0), "first"); set.insert(e(1), "second"); set.insert(e(2), "third"); // Remove middle — last swaps in set.remove(e(0)); assert_eq!(set.get(e(1)), Some(&"second")); assert_eq!(set.get(e(2)), Some(&"third")); assert_eq!(set.len(), 2); } } ``` - [ ] **Step 2: lib.rs 업데이트** ```rust // crates/voltex_ecs/src/lib.rs pub mod entity; pub mod sparse_set; pub use entity::{Entity, EntityAllocator}; pub use sparse_set::SparseSet; ``` - [ ] **Step 3: 테스트 통과 확인** Run: `cargo test -p voltex_ecs` Expected: 5 + 8 = 13개 테스트 PASS - [ ] **Step 4: 커밋** ```bash git add crates/voltex_ecs/ git commit -m "feat(ecs): add SparseSet component storage" ``` --- ## Task 3: World **Files:** - Create: `crates/voltex_ecs/src/world.rs` - Modify: `crates/voltex_ecs/src/sparse_set.rs` (ComponentStorage trait 추가) - Modify: `crates/voltex_ecs/src/lib.rs` World는 모든 엔티티와 컴포넌트 스토리지를 관리하는 중앙 저장소. TypeId로 type-erased 스토리지를 관리한다. - [ ] **Step 1: sparse_set.rs에 ComponentStorage trait 추가** sparse_set.rs 파일 맨 위에 추가: ```rust use std::any::Any; /// 타입 소거된 컴포넌트 스토리지 인터페이스 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 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 len(&self) -> usize { self.dense_data.len() } } ``` - [ ] **Step 2: world.rs 작성** ```rust // crates/voltex_ecs/src/world.rs use std::any::TypeId; use std::collections::HashMap; use crate::entity::{Entity, EntityAllocator}; use crate::sparse_set::{SparseSet, ComponentStorage}; pub struct World { allocator: EntityAllocator, storages: HashMap>, } impl World { pub fn new() -> Self { Self { allocator: EntityAllocator::new(), storages: HashMap::new(), } } /// 새 엔티티 생성 pub fn spawn(&mut self) -> Entity { self.allocator.allocate() } /// 엔티티 삭제. 모든 컴포넌트도 제거. pub fn despawn(&mut self, entity: Entity) -> bool { if self.allocator.deallocate(entity) { for storage in self.storages.values_mut() { storage.remove_entity(entity); } true } else { false } } /// 엔티티가 살아있는지 pub fn is_alive(&self, entity: Entity) -> bool { self.allocator.is_alive(entity) } /// 살아있는 엔티티 수 pub fn entity_count(&self) -> usize { self.allocator.alive_count() } /// 컴포넌트 추가 (스토리지 자동 등록) pub fn add(&mut self, entity: Entity, component: T) { let type_id = TypeId::of::(); let storage = self.storages .entry(type_id) .or_insert_with(|| Box::new(SparseSet::::new())); let set = storage.as_any_mut().downcast_mut::>().unwrap(); set.insert(entity, component); } /// 컴포넌트 읽기 pub fn get(&self, entity: Entity) -> Option<&T> { let type_id = TypeId::of::(); self.storages.get(&type_id) .and_then(|s| s.as_any().downcast_ref::>()) .and_then(|set| set.get(entity)) } /// 컴포넌트 수정 pub fn get_mut(&mut self, entity: Entity) -> Option<&mut T> { let type_id = TypeId::of::(); self.storages.get_mut(&type_id) .and_then(|s| s.as_any_mut().downcast_mut::>()) .and_then(|set| set.get_mut(entity)) } /// 컴포넌트 제거 pub fn remove(&mut self, entity: Entity) -> Option { let type_id = TypeId::of::(); self.storages.get_mut(&type_id) .and_then(|s| s.as_any_mut().downcast_mut::>()) .and_then(|set| set.remove(entity)) } /// 특정 컴포넌트의 SparseSet 참조 (쿼리용) pub fn storage(&self) -> Option<&SparseSet> { let type_id = TypeId::of::(); self.storages.get(&type_id) .and_then(|s| s.as_any().downcast_ref::>()) } /// 특정 컴포넌트의 SparseSet 가변 참조 pub fn storage_mut(&mut self) -> Option<&mut SparseSet> { let type_id = TypeId::of::(); self.storages.get_mut(&type_id) .and_then(|s| s.as_any_mut().downcast_mut::>()) } /// 단일 컴포넌트 쿼리: T를 가진 모든 엔티티 순회 pub fn query(&self) -> impl Iterator { self.storage::() .map(|s| s.iter()) .into_iter() .flatten() } /// 2-컴포넌트 쿼리: A와 B를 모두 가진 엔티티 순회 /// 더 작은 스토리지를 기준으로 순회하고, 다른 쪽에서 lookup. pub fn query2(&self) -> Vec<(Entity, &A, &B)> { let sa = match self.storage::() { Some(s) => s, None => return Vec::new(), }; let sb = match self.storage::() { Some(s) => s, None => return Vec::new(), }; let mut result = Vec::new(); if sa.len() <= sb.len() { for (entity, a) in sa.iter() { if let Some(b) = sb.get(entity) { result.push((entity, a, b)); } } } else { for (entity, b) in sb.iter() { if let Some(a) = sa.get(entity) { result.push((entity, a, b)); } } } result } } #[cfg(test)] mod tests { use super::*; #[derive(Debug, PartialEq)] struct Position { x: f32, y: f32 } #[derive(Debug, PartialEq)] struct Velocity { dx: f32, dy: f32 } #[derive(Debug, PartialEq)] struct Name(String); #[test] fn test_spawn_and_add() { let mut world = World::new(); let e = world.spawn(); world.add(e, Position { x: 1.0, y: 2.0 }); assert_eq!(world.get::(e), Some(&Position { x: 1.0, y: 2.0 })); } #[test] fn test_get_missing() { let world = World::new(); let e = Entity::new(0, 0); assert_eq!(world.get::(e), None); } #[test] fn test_get_mut() { let mut world = World::new(); let e = world.spawn(); world.add(e, Position { x: 0.0, y: 0.0 }); if let Some(pos) = world.get_mut::(e) { pos.x = 10.0; } assert_eq!(world.get::(e), Some(&Position { x: 10.0, y: 0.0 })); } #[test] fn test_remove_component() { let mut world = World::new(); let e = world.spawn(); world.add(e, Position { x: 1.0, y: 2.0 }); let removed = world.remove::(e); assert_eq!(removed, Some(Position { x: 1.0, y: 2.0 })); assert_eq!(world.get::(e), None); } #[test] fn test_despawn() { let mut world = World::new(); let e = world.spawn(); world.add(e, Position { x: 1.0, y: 2.0 }); world.add(e, Name("test".into())); assert!(world.despawn(e)); assert!(!world.is_alive(e)); assert_eq!(world.get::(e), None); assert_eq!(world.get::(e), None); } #[test] fn test_query_single() { let mut world = World::new(); let e0 = world.spawn(); let e1 = world.spawn(); let e2 = world.spawn(); world.add(e0, Position { x: 0.0, y: 0.0 }); world.add(e1, Position { x: 1.0, y: 1.0 }); // e2 has no Position world.add(e2, Name("no pos".into())); let positions: Vec<_> = world.query::().collect(); assert_eq!(positions.len(), 2); } #[test] fn test_query2() { let mut world = World::new(); let e0 = world.spawn(); let e1 = world.spawn(); let e2 = world.spawn(); world.add(e0, Position { x: 0.0, y: 0.0 }); world.add(e0, Velocity { dx: 1.0, dy: 0.0 }); world.add(e1, Position { x: 5.0, y: 5.0 }); // e1 has no Velocity world.add(e2, Velocity { dx: 2.0, dy: 2.0 }); // e2 has no Position let results = world.query2::(); assert_eq!(results.len(), 1); assert_eq!(results[0].1, &Position { x: 0.0, y: 0.0 }); assert_eq!(results[0].2, &Velocity { dx: 1.0, dy: 0.0 }); } #[test] fn test_entity_count() { let mut world = World::new(); assert_eq!(world.entity_count(), 0); let e0 = world.spawn(); let _e1 = world.spawn(); assert_eq!(world.entity_count(), 2); world.despawn(e0); assert_eq!(world.entity_count(), 1); } } ``` - [ ] **Step 3: lib.rs 업데이트** ```rust // crates/voltex_ecs/src/lib.rs pub mod entity; pub mod sparse_set; pub mod world; pub use entity::{Entity, EntityAllocator}; pub use sparse_set::SparseSet; pub use world::World; ``` - [ ] **Step 4: 테스트 통과 확인** Run: `cargo test -p voltex_ecs` Expected: 13 + 8 = 21개 테스트 PASS - [ ] **Step 5: 커밋** ```bash git add crates/voltex_ecs/ git commit -m "feat(ecs): add World with type-erased component storage and queries" ``` --- ## Task 4: Transform 컴포넌트 **Files:** - Create: `crates/voltex_ecs/src/transform.rs` - Modify: `crates/voltex_ecs/src/lib.rs` Transform은 ECS에서 사용하는 기본 컴포넌트. position, rotation (euler), scale을 가지며 Mat4 모델 행렬을 계산한다. - [ ] **Step 1: transform.rs 작성** ```rust // crates/voltex_ecs/src/transform.rs use voltex_math::{Vec3, Mat4}; #[derive(Debug, Clone, Copy)] pub struct Transform { pub position: Vec3, pub rotation: Vec3, // euler angles (radians): pitch, yaw, roll pub scale: Vec3, } impl Transform { pub fn new() -> Self { Self { position: Vec3::ZERO, rotation: Vec3::ZERO, scale: Vec3::ONE, } } pub fn from_position(position: Vec3) -> Self { Self { position, ..Self::new() } } pub fn from_position_scale(position: Vec3, scale: Vec3) -> Self { Self { position, scale, ..Self::new() } } /// 모델 행렬 계산: Translation * RotY * RotX * RotZ * Scale pub fn matrix(&self) -> Mat4 { let t = Mat4::translation(self.position.x, self.position.y, self.position.z); let rx = Mat4::rotation_x(self.rotation.x); let ry = Mat4::rotation_y(self.rotation.y); let rz = Mat4::rotation_z(self.rotation.z); let s = Mat4::scale(self.scale.x, self.scale.y, self.scale.z); t * ry * rx * rz * s } } impl Default for Transform { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; use voltex_math::Vec4; fn approx_eq(a: f32, b: f32) -> bool { (a - b).abs() < 1e-5 } #[test] fn test_identity_transform() { let t = Transform::new(); let m = t.matrix(); let p = m * Vec4::new(1.0, 2.0, 3.0, 1.0); assert!(approx_eq(p.x, 1.0)); assert!(approx_eq(p.y, 2.0)); assert!(approx_eq(p.z, 3.0)); } #[test] fn test_translation() { let t = Transform::from_position(Vec3::new(10.0, 20.0, 30.0)); let m = t.matrix(); let p = m * Vec4::new(0.0, 0.0, 0.0, 1.0); assert!(approx_eq(p.x, 10.0)); assert!(approx_eq(p.y, 20.0)); assert!(approx_eq(p.z, 30.0)); } #[test] fn test_scale() { let t = Transform::from_position_scale(Vec3::ZERO, Vec3::new(2.0, 3.0, 4.0)); let m = t.matrix(); let p = m * Vec4::new(1.0, 1.0, 1.0, 1.0); assert!(approx_eq(p.x, 2.0)); assert!(approx_eq(p.y, 3.0)); assert!(approx_eq(p.z, 4.0)); } #[test] fn test_rotation_y() { let mut t = Transform::new(); t.rotation.y = std::f32::consts::FRAC_PI_2; let m = t.matrix(); let p = m * Vec4::new(1.0, 0.0, 0.0, 1.0); assert!(approx_eq(p.x, 0.0)); assert!(approx_eq(p.z, -1.0)); } } ``` - [ ] **Step 2: lib.rs 업데이트** ```rust // crates/voltex_ecs/src/lib.rs pub mod entity; pub mod sparse_set; pub mod world; pub mod transform; pub use entity::{Entity, EntityAllocator}; pub use sparse_set::SparseSet; pub use world::World; pub use transform::Transform; ``` - [ ] **Step 3: 테스트 통과 확인** Run: `cargo test -p voltex_ecs` Expected: 21 + 4 = 25개 테스트 PASS - [ ] **Step 4: 커밋** ```bash git add crates/voltex_ecs/ git commit -m "feat(ecs): add Transform component" ``` --- ## Task 5: many_cubes 데모 **Files:** - Create: `examples/many_cubes/Cargo.toml` - Create: `examples/many_cubes/src/main.rs` - Modify: `Cargo.toml` (워크스페이스에 many_cubes 추가) ECS를 사용하여 400개의 큐브를 그리드 배치하고 렌더링하는 데모. 각 엔티티는 Transform + MeshHandle(u32) 컴포넌트를 가진다. MeshHandle은 앱 레벨에서 정의하는 단순 인덱스 타입. - [ ] **Step 1: Cargo.toml 워크스페이스 업데이트** members에 `"examples/many_cubes"` 추가. - [ ] **Step 2: many_cubes Cargo.toml 작성** ```toml # examples/many_cubes/Cargo.toml [package] name = "many_cubes" version = "0.1.0" edition = "2021" [dependencies] voltex_math.workspace = true voltex_platform.workspace = true voltex_renderer.workspace = true voltex_ecs.workspace = true wgpu.workspace = true winit.workspace = true bytemuck.workspace = true pollster.workspace = true env_logger.workspace = true log.workspace = true ``` - [ ] **Step 3: many_cubes main.rs 작성** 이 파일은 model_viewer를 기반으로 하되, ECS World를 사용한다. 핵심 변경: 1. `World`를 생성하고 400개 엔티티를 spawn (20x20 그리드) 2. 각 엔티티에 `Transform` + `MeshHandle(u32)` 컴포넌트 추가 3. 매 프레임: `world.query2::()`로 순회 4. 각 엔티티의 Transform.matrix()를 모델 행렬로 사용하여 draw_indexed ```rust // examples/many_cubes/src/main.rs use winit::{ application::ApplicationHandler, event::WindowEvent, event_loop::{ActiveEventLoop, EventLoop}, keyboard::{KeyCode, PhysicalKey}, window::WindowId, }; use voltex_math::{Vec3, Mat4}; use voltex_platform::{VoltexWindow, WindowConfig, InputState, GameTimer}; use voltex_renderer::{ GpuContext, Mesh, Camera, FpsController, CameraUniform, LightUniform, GpuTexture, pipeline, obj, }; use voltex_ecs::{World, Transform}; use wgpu::util::DeviceExt; /// 앱 레벨 컴포넌트: 메시 인덱스 (이 데모에서는 모두 0 = cube) #[derive(Debug, Clone, Copy)] struct MeshHandle(pub u32); struct ManyCubesApp { state: Option, } struct AppState { window: VoltexWindow, gpu: GpuContext, mesh_pipeline: wgpu::RenderPipeline, meshes: Vec, // 메시 풀 (인덱스로 참조) world: World, camera: Camera, fps_controller: FpsController, camera_uniform: CameraUniform, light_uniform: LightUniform, camera_buffer: wgpu::Buffer, light_buffer: wgpu::Buffer, camera_light_bind_group: wgpu::BindGroup, diffuse_texture: GpuTexture, input: InputState, timer: GameTimer, time: f32, } fn camera_light_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("Camera+Light BGL"), entries: &[ wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None, }, count: None, }, wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None, }, count: None, }, ], }) } impl ApplicationHandler for ManyCubesApp { fn resumed(&mut self, event_loop: &ActiveEventLoop) { let config = WindowConfig { title: "Voltex - Many Cubes (ECS)".to_string(), width: 1280, height: 720, ..Default::default() }; let window = VoltexWindow::new(event_loop, &config); let gpu = GpuContext::new(window.handle.clone()); // Load cube mesh let obj_src = include_str!("../../../assets/cube.obj"); let obj_data = obj::parse_obj(obj_src); let cube_mesh = Mesh::new(&gpu.device, &obj_data.vertices, &obj_data.indices); let meshes = vec![cube_mesh]; // ECS: spawn 400 cubes in a 20x20 grid let mut world = World::new(); let grid_size = 20; let spacing = 1.5; let offset = (grid_size as f32 - 1.0) * spacing * 0.5; for x in 0..grid_size { for z in 0..grid_size { let entity = world.spawn(); let pos = Vec3::new( x as f32 * spacing - offset, 0.0, z as f32 * spacing - offset, ); world.add(entity, Transform::from_position(pos)); world.add(entity, MeshHandle(0)); } } // Uniform buffers let camera_uniform = CameraUniform::new(); let light_uniform = LightUniform::new(); let camera_buffer = gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("Camera UBO"), contents: bytemuck::cast_slice(&[camera_uniform]), usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, }); let light_buffer = gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("Light UBO"), contents: bytemuck::cast_slice(&[light_uniform]), usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, }); // Bind groups let cl_layout = camera_light_bind_group_layout(&gpu.device); let tex_layout = GpuTexture::bind_group_layout(&gpu.device); let camera_light_bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("Camera+Light BG"), layout: &cl_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: camera_buffer.as_entire_binding() }, wgpu::BindGroupEntry { binding: 1, resource: light_buffer.as_entire_binding() }, ], }); let diffuse_texture = GpuTexture::white_1x1(&gpu.device, &gpu.queue, &tex_layout); let mesh_pipeline = pipeline::create_mesh_pipeline( &gpu.device, gpu.surface_format, &cl_layout, &tex_layout, ); let aspect = gpu.config.width as f32 / gpu.config.height as f32; let mut camera = Camera::new(Vec3::new(0.0, 15.0, 25.0), aspect); camera.pitch = -0.5; // look slightly down self.state = Some(AppState { window, gpu, mesh_pipeline, meshes, world, camera, fps_controller: FpsController::new(), camera_uniform, light_uniform, camera_buffer, light_buffer, camera_light_bind_group, diffuse_texture, input: InputState::new(), timer: GameTimer::new(60), time: 0.0, }); } fn window_event( &mut self, event_loop: &ActiveEventLoop, _window_id: WindowId, event: WindowEvent, ) { let state = match &mut self.state { Some(s) => s, None => return, }; match event { WindowEvent::CloseRequested => event_loop.exit(), WindowEvent::KeyboardInput { event: winit::event::KeyEvent { physical_key: PhysicalKey::Code(key_code), state: key_state, .. }, .. } => { let pressed = key_state == winit::event::ElementState::Pressed; state.input.process_key(key_code, pressed); if key_code == KeyCode::Escape && pressed { event_loop.exit(); } } WindowEvent::Resized(size) => { state.gpu.resize(size.width, size.height); if size.width > 0 && size.height > 0 { state.camera.aspect = size.width as f32 / size.height as f32; } } WindowEvent::CursorMoved { position, .. } => { state.input.process_mouse_move(position.x, position.y); } WindowEvent::MouseInput { state: btn_state, button, .. } => { let pressed = btn_state == winit::event::ElementState::Pressed; state.input.process_mouse_button(button, pressed); } WindowEvent::MouseWheel { delta, .. } => { let y = match delta { winit::event::MouseScrollDelta::LineDelta(_, y) => y, winit::event::MouseScrollDelta::PixelDelta(pos) => pos.y as f32, }; state.input.process_scroll(y); } WindowEvent::RedrawRequested => { state.timer.tick(); let dt = state.timer.frame_dt(); state.time += dt; // Input if state.input.is_mouse_button_pressed(winit::event::MouseButton::Right) { let (dx, dy) = state.input.mouse_delta(); state.fps_controller.process_mouse(&mut state.camera, dx, dy); } let mut forward = 0.0f32; let mut right = 0.0f32; let mut up = 0.0f32; if state.input.is_key_pressed(KeyCode::KeyW) { forward += 1.0; } if state.input.is_key_pressed(KeyCode::KeyS) { forward -= 1.0; } if state.input.is_key_pressed(KeyCode::KeyD) { right += 1.0; } if state.input.is_key_pressed(KeyCode::KeyA) { right -= 1.0; } if state.input.is_key_pressed(KeyCode::Space) { up += 1.0; } if state.input.is_key_pressed(KeyCode::ShiftLeft) { up -= 1.0; } state.fps_controller.process_movement(&mut state.camera, forward, right, up, dt); state.input.begin_frame(); // Update view-projection (shared across all entities) let view_proj = state.camera.view_projection(); state.camera_uniform.view_proj = view_proj.cols; state.camera_uniform.camera_pos = [ state.camera.position.x, state.camera.position.y, state.camera.position.z, ]; // Render let output = match state.gpu.surface.get_current_texture() { Ok(t) => t, Err(wgpu::SurfaceError::Lost) => { let (w, h) = state.window.inner_size(); state.gpu.resize(w, h); return; } Err(wgpu::SurfaceError::OutOfMemory) => { event_loop.exit(); return; } Err(_) => return, }; let view = output.texture.create_view(&wgpu::TextureViewDescriptor::default()); let mut encoder = state.gpu.device.create_command_encoder( &wgpu::CommandEncoderDescriptor { label: Some("Render Encoder") }, ); { let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("Render Pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &view, resolve_target: None, depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color { r: 0.1, g: 0.1, b: 0.15, a: 1.0, }), store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { view: &state.gpu.depth_view, depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Store, }), stencil_ops: None, }), occlusion_query_set: None, timestamp_writes: None, multiview_mask: None, }); render_pass.set_pipeline(&state.mesh_pipeline); render_pass.set_bind_group(0, &state.camera_light_bind_group, &[]); render_pass.set_bind_group(1, &state.diffuse_texture.bind_group, &[]); // ECS query: iterate all entities with Transform + MeshHandle let entities = state.world.query2::(); for (_, transform, mesh_handle) in &entities { // Per-entity model matrix (add slight Y rotation based on time + position) let mut t = *transform; t.rotation.y = state.time * 0.5 + t.position.x * 0.1 + t.position.z * 0.1; let model = t.matrix(); state.camera_uniform.model = model.cols; state.gpu.queue.write_buffer( &state.camera_buffer, 0, bytemuck::cast_slice(&[state.camera_uniform]), ); let mesh = &state.meshes[mesh_handle.0 as usize]; render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); render_pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32); render_pass.draw_indexed(0..mesh.num_indices, 0, 0..1); } } state.gpu.queue.submit(std::iter::once(encoder.finish())); output.present(); } _ => {} } } fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { if let Some(state) = &self.state { state.window.request_redraw(); } } } fn main() { env_logger::init(); let event_loop = EventLoop::new().unwrap(); let mut app = ManyCubesApp { state: None }; event_loop.run_app(&mut app).unwrap(); } ``` 참고: write_buffer를 draw call 사이에 호출하면 wgpu가 각 draw의 uniform 상태를 올바르게 처리한다. 이 접근은 draw call이 수백 개 수준에서는 동작하지만, 추후 인스턴싱이나 동적 UBO로 최적화해야 한다. - [ ] **Step 4: 빌드 확인** Run: `cargo build --workspace` Expected: 빌드 성공 - [ ] **Step 5: 테스트 확인** Run: `cargo test --workspace` Expected: 모든 테스트 통과 - [ ] **Step 6: 실행 확인** Run: `cargo run -p many_cubes` Expected: 20x20=400개 큐브가 그리드 형태로 렌더링됨. 각 큐브가 서로 다른 속도로 Y축 회전. FPS 카메라 조작 가능. ESC 종료. - [ ] **Step 7: 커밋** ```bash git add Cargo.toml examples/many_cubes/ git commit -m "feat: add many_cubes ECS demo with 400 entities" ``` --- ## Phase 3a 완료 기준 체크리스트 - [ ] `cargo build --workspace` 성공 - [ ] `cargo test --workspace` — 모든 테스트 통과 (기존 41 + ECS 25 = 66개) - [ ] `cargo run -p triangle` — 기존 삼각형 데모 동작 - [ ] `cargo run -p model_viewer` — 기존 모델 뷰어 동작 - [ ] `cargo run -p many_cubes` — 400개 큐브 렌더링, 카메라 조작 - [ ] Entity generation 검증: 삭제 후 재할당 시 stale 참조 감지 - [ ] SparseSet O(1) insert/remove/get - [ ] World type-erased 스토리지: 임의 타입의 컴포넌트 등록 가능 - [ ] query2: 두 컴포넌트를 모두 가진 엔티티만 반환