From 96cebecc6d85ac0713b05199d2a4eb59fe7d4797 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Tue, 24 Mar 2026 20:02:50 +0900 Subject: [PATCH] docs: add Phase 3a ECS implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-24-phase3a-ecs.md | 1350 +++++++++++++++++ 1 file changed, 1350 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-24-phase3a-ecs.md diff --git a/docs/superpowers/plans/2026-03-24-phase3a-ecs.md b/docs/superpowers/plans/2026-03-24-phase3a-ecs.md new file mode 100644 index 0000000..3e49f57 --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-phase3a-ecs.md @@ -0,0 +1,1350 @@ +# 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: 두 컴포넌트를 모두 가진 엔티티만 반환