Files
game_engine/docs/superpowers/plans/2026-03-24-phase3a-ecs.md
tolelom 96cebecc6d docs: add Phase 3a ECS implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 20:02:50 +09:00

42 KiB

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<T> 제네릭 스토리지
        ├── 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 작성
# 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 작성
// 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<u32>,
    free_ids: Vec<u32>,
    alive: Vec<bool>,
}

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 작성
// 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: 커밋
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 작성
// crates/voltex_ecs/src/sparse_set.rs
use crate::Entity;

/// Entity → T 매핑. Dense 배열로 빠른 순회, Sparse 배열로 O(1) 접근.
pub struct SparseSet<T> {
    sparse: Vec<Option<usize>>,  // entity.id → dense index
    dense_entities: Vec<Entity>, // dense index → entity
    dense_data: Vec<T>,          // dense index → component
}

impl<T> SparseSet<T> {
    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<T> {
        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<Item = (Entity, &T)> {
        self.dense_entities.iter().copied().zip(self.dense_data.iter())
    }

    /// Dense 배열 가변 순회 (entity, &mut T)
    pub fn iter_mut(&mut self) -> impl Iterator<Item = (Entity, &mut T)> {
        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<i32> = 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<i32> = 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 업데이트
// 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: 커밋
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 파일 맨 위에 추가:

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<T: 'static> ComponentStorage for SparseSet<T> {
    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 작성
// 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<TypeId, Box<dyn ComponentStorage>>,
}

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<T: 'static>(&mut self, entity: Entity, component: T) {
        let type_id = TypeId::of::<T>();
        let storage = self.storages
            .entry(type_id)
            .or_insert_with(|| Box::new(SparseSet::<T>::new()));
        let set = storage.as_any_mut().downcast_mut::<SparseSet<T>>().unwrap();
        set.insert(entity, component);
    }

    /// 컴포넌트 읽기
    pub fn get<T: 'static>(&self, entity: Entity) -> Option<&T> {
        let type_id = TypeId::of::<T>();
        self.storages.get(&type_id)
            .and_then(|s| s.as_any().downcast_ref::<SparseSet<T>>())
            .and_then(|set| set.get(entity))
    }

    /// 컴포넌트 수정
    pub fn get_mut<T: 'static>(&mut self, entity: Entity) -> Option<&mut T> {
        let type_id = TypeId::of::<T>();
        self.storages.get_mut(&type_id)
            .and_then(|s| s.as_any_mut().downcast_mut::<SparseSet<T>>())
            .and_then(|set| set.get_mut(entity))
    }

    /// 컴포넌트 제거
    pub fn remove<T: 'static>(&mut self, entity: Entity) -> Option<T> {
        let type_id = TypeId::of::<T>();
        self.storages.get_mut(&type_id)
            .and_then(|s| s.as_any_mut().downcast_mut::<SparseSet<T>>())
            .and_then(|set| set.remove(entity))
    }

    /// 특정 컴포넌트의 SparseSet 참조 (쿼리용)
    pub fn storage<T: 'static>(&self) -> Option<&SparseSet<T>> {
        let type_id = TypeId::of::<T>();
        self.storages.get(&type_id)
            .and_then(|s| s.as_any().downcast_ref::<SparseSet<T>>())
    }

    /// 특정 컴포넌트의 SparseSet 가변 참조
    pub fn storage_mut<T: 'static>(&mut self) -> Option<&mut SparseSet<T>> {
        let type_id = TypeId::of::<T>();
        self.storages.get_mut(&type_id)
            .and_then(|s| s.as_any_mut().downcast_mut::<SparseSet<T>>())
    }

    /// 단일 컴포넌트 쿼리: T를 가진 모든 엔티티 순회
    pub fn query<T: 'static>(&self) -> impl Iterator<Item = (Entity, &T)> {
        self.storage::<T>()
            .map(|s| s.iter())
            .into_iter()
            .flatten()
    }

    /// 2-컴포넌트 쿼리: A와 B를 모두 가진 엔티티 순회
    /// 더 작은 스토리지를 기준으로 순회하고, 다른 쪽에서 lookup.
    pub fn query2<A: 'static, B: 'static>(&self) -> Vec<(Entity, &A, &B)> {
        let sa = match self.storage::<A>() {
            Some(s) => s,
            None => return Vec::new(),
        };
        let sb = match self.storage::<B>() {
            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::<Position>(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::<Position>(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::<Position>(e) {
            pos.x = 10.0;
        }
        assert_eq!(world.get::<Position>(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::<Position>(e);
        assert_eq!(removed, Some(Position { x: 1.0, y: 2.0 }));
        assert_eq!(world.get::<Position>(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::<Position>(e), None);
        assert_eq!(world.get::<Name>(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::<Position>().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::<Position, Velocity>();
        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 업데이트
// 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: 커밋
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 작성
// 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 업데이트
// 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: 커밋
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 작성
# 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::<Transform, MeshHandle>()로 순회
  4. 각 엔티티의 Transform.matrix()를 모델 행렬로 사용하여 draw_indexed
// 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<AppState>,
}

struct AppState {
    window: VoltexWindow,
    gpu: GpuContext,
    mesh_pipeline: wgpu::RenderPipeline,
    meshes: Vec<Mesh>,  // 메시 풀 (인덱스로 참조)
    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::<Transform, MeshHandle>();

                    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: 커밋
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: 두 컴포넌트를 모두 가진 엔티티만 반환