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를 사용한다. 핵심 변경:
World를 생성하고 400개 엔티티를 spawn (20x20 그리드)- 각 엔티티에
Transform+MeshHandle(u32)컴포넌트 추가 - 매 프레임:
world.query2::<Transform, MeshHandle>()로 순회 - 각 엔티티의 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: 두 컴포넌트를 모두 가진 엔티티만 반환