1351 lines
42 KiB
Markdown
1351 lines
42 KiB
Markdown
# 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<T>. 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 작성**
|
|
|
|
```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<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 작성**
|
|
|
|
```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<T>
|
|
|
|
**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<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 업데이트**
|
|
|
|
```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<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 작성**
|
|
|
|
```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<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 업데이트**
|
|
|
|
```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::<Transform, MeshHandle>()`로 순회
|
|
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<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: 커밋**
|
|
|
|
```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: 두 컴포넌트를 모두 가진 엔티티만 반환
|