docs: add Phase 3b scene graph implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
784
docs/superpowers/plans/2026-03-24-phase3b-scene-graph.md
Normal file
784
docs/superpowers/plans/2026-03-24-phase3b-scene-graph.md
Normal file
@@ -0,0 +1,784 @@
|
|||||||
|
# Phase 3b: Scene Graph + Serialization 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:** 부모-자식 트랜스폼 계층으로 씬 그래프를 구축하고, 로컬→월드 트랜스폼 자동 전파, 간단한 텍스트 포맷으로 씬 저장/로드를 구현한다.
|
||||||
|
|
||||||
|
**Architecture:** voltex_ecs에 `Parent(Entity)`, `Children(Vec<Entity>)` 컴포넌트를 추가하고, 계층 관리 함수를 `hierarchy.rs`에 구현한다. World 트랜스폼 전파는 루트에서 리프까지 top-down 순회. 씬 직렬화는 커스텀 텍스트 포맷(`.vscn`)으로 Transform/Parent/Children/커스텀 태그를 저장한다.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust 1.94, voltex_math (Vec3, Mat4), voltex_ecs (World, Entity, Transform, SparseSet)
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-03-24-voltex-engine-design.md` Phase 3 (3-2. 씬 그래프)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/voltex_ecs/src/
|
||||||
|
├── lib.rs # 모듈 re-export 업데이트
|
||||||
|
├── hierarchy.rs # Parent, Children 컴포넌트 + 계층 관리 함수 (NEW)
|
||||||
|
├── world_transform.rs # WorldTransform + 전파 함수 (NEW)
|
||||||
|
├── scene.rs # 씬 직렬화/역직렬화 (NEW)
|
||||||
|
examples/
|
||||||
|
└── hierarchy_demo/ # 씬 그래프 데모 (NEW)
|
||||||
|
├── Cargo.toml
|
||||||
|
└── src/
|
||||||
|
└── main.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: 계층 컴포넌트 + 관리 함수
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `crates/voltex_ecs/src/hierarchy.rs`
|
||||||
|
- Modify: `crates/voltex_ecs/src/lib.rs`
|
||||||
|
|
||||||
|
Parent(Entity)와 Children(Vec<Entity>) 컴포넌트. 계층을 조작하는 free function들.
|
||||||
|
|
||||||
|
- [ ] **Step 1: hierarchy.rs 작성**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// crates/voltex_ecs/src/hierarchy.rs
|
||||||
|
use crate::{Entity, World};
|
||||||
|
|
||||||
|
/// 부모 엔티티를 가리키는 컴포넌트
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Parent(pub Entity);
|
||||||
|
|
||||||
|
/// 자식 엔티티 목록 컴포넌트
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Children(pub Vec<Entity>);
|
||||||
|
|
||||||
|
/// entity를 parent의 자식으로 추가
|
||||||
|
pub fn add_child(world: &mut World, parent: Entity, child: Entity) {
|
||||||
|
// child에 Parent 컴포넌트 설정
|
||||||
|
world.add(child, Parent(parent));
|
||||||
|
|
||||||
|
// parent의 Children에 child 추가
|
||||||
|
if let Some(children) = world.get_mut::<Children>(parent) {
|
||||||
|
if !children.0.contains(&child) {
|
||||||
|
children.0.push(child);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
world.add(parent, Children(vec![child]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// entity를 부모에서 분리 (parent-child 관계 제거)
|
||||||
|
pub fn remove_child(world: &mut World, parent: Entity, child: Entity) {
|
||||||
|
// parent의 Children에서 child 제거
|
||||||
|
if let Some(children) = world.get_mut::<Children>(parent) {
|
||||||
|
children.0.retain(|&e| e != child);
|
||||||
|
}
|
||||||
|
|
||||||
|
// child의 Parent 제거
|
||||||
|
world.remove::<Parent>(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// entity와 모든 자손을 재귀적으로 삭제
|
||||||
|
pub fn despawn_recursive(world: &mut World, entity: Entity) {
|
||||||
|
// 자식들 먼저 수집 (borrow 문제 회피)
|
||||||
|
let children: Vec<Entity> = world.get::<Children>(entity)
|
||||||
|
.map(|c| c.0.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
for child in children {
|
||||||
|
despawn_recursive(world, child);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부모에서 자신 제거
|
||||||
|
if let Some(parent) = world.get::<Parent>(entity).map(|p| p.0) {
|
||||||
|
if let Some(children) = world.get_mut::<Children>(parent) {
|
||||||
|
children.0.retain(|&e| e != entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
world.despawn(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 루트 엔티티 목록 반환 (Parent가 없는 엔티티)
|
||||||
|
pub fn roots(world: &World) -> Vec<Entity> {
|
||||||
|
// Transform가 있지만 Parent가 없는 엔티티가 루트
|
||||||
|
world.query::<crate::Transform>()
|
||||||
|
.filter(|(entity, _)| world.get::<Parent>(*entity).is_none())
|
||||||
|
.map(|(entity, _)| entity)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::Transform;
|
||||||
|
use voltex_math::Vec3;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_child() {
|
||||||
|
let mut world = World::new();
|
||||||
|
let parent = world.spawn();
|
||||||
|
let child = world.spawn();
|
||||||
|
world.add(parent, Transform::new());
|
||||||
|
world.add(child, Transform::new());
|
||||||
|
|
||||||
|
add_child(&mut world, parent, child);
|
||||||
|
|
||||||
|
assert_eq!(world.get::<Parent>(child).unwrap().0, parent);
|
||||||
|
assert_eq!(world.get::<Children>(parent).unwrap().0.len(), 1);
|
||||||
|
assert_eq!(world.get::<Children>(parent).unwrap().0[0], child);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_multiple_children() {
|
||||||
|
let mut world = World::new();
|
||||||
|
let parent = world.spawn();
|
||||||
|
let c1 = world.spawn();
|
||||||
|
let c2 = world.spawn();
|
||||||
|
world.add(parent, Transform::new());
|
||||||
|
world.add(c1, Transform::new());
|
||||||
|
world.add(c2, Transform::new());
|
||||||
|
|
||||||
|
add_child(&mut world, parent, c1);
|
||||||
|
add_child(&mut world, parent, c2);
|
||||||
|
|
||||||
|
assert_eq!(world.get::<Children>(parent).unwrap().0.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remove_child() {
|
||||||
|
let mut world = World::new();
|
||||||
|
let parent = world.spawn();
|
||||||
|
let child = world.spawn();
|
||||||
|
world.add(parent, Transform::new());
|
||||||
|
world.add(child, Transform::new());
|
||||||
|
|
||||||
|
add_child(&mut world, parent, child);
|
||||||
|
remove_child(&mut world, parent, child);
|
||||||
|
|
||||||
|
assert!(world.get::<Parent>(child).is_none());
|
||||||
|
assert_eq!(world.get::<Children>(parent).unwrap().0.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_despawn_recursive() {
|
||||||
|
let mut world = World::new();
|
||||||
|
let root = world.spawn();
|
||||||
|
let child = world.spawn();
|
||||||
|
let grandchild = world.spawn();
|
||||||
|
world.add(root, Transform::new());
|
||||||
|
world.add(child, Transform::new());
|
||||||
|
world.add(grandchild, Transform::new());
|
||||||
|
|
||||||
|
add_child(&mut world, root, child);
|
||||||
|
add_child(&mut world, child, grandchild);
|
||||||
|
|
||||||
|
despawn_recursive(&mut world, root);
|
||||||
|
|
||||||
|
assert!(!world.is_alive(root));
|
||||||
|
assert!(!world.is_alive(child));
|
||||||
|
assert!(!world.is_alive(grandchild));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_roots() {
|
||||||
|
let mut world = World::new();
|
||||||
|
let r1 = world.spawn();
|
||||||
|
let r2 = world.spawn();
|
||||||
|
let child = world.spawn();
|
||||||
|
world.add(r1, Transform::new());
|
||||||
|
world.add(r2, Transform::new());
|
||||||
|
world.add(child, Transform::new());
|
||||||
|
|
||||||
|
add_child(&mut world, r1, child);
|
||||||
|
|
||||||
|
let root_list = roots(&world);
|
||||||
|
assert_eq!(root_list.len(), 2);
|
||||||
|
assert!(root_list.contains(&r1));
|
||||||
|
assert!(root_list.contains(&r2));
|
||||||
|
assert!(!root_list.contains(&child));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_duplicate_child() {
|
||||||
|
let mut world = World::new();
|
||||||
|
let parent = world.spawn();
|
||||||
|
let child = world.spawn();
|
||||||
|
world.add(parent, Transform::new());
|
||||||
|
world.add(child, Transform::new());
|
||||||
|
|
||||||
|
add_child(&mut world, parent, child);
|
||||||
|
add_child(&mut world, parent, child); // 중복 추가
|
||||||
|
|
||||||
|
assert_eq!(world.get::<Children>(parent).unwrap().0.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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 mod hierarchy;
|
||||||
|
|
||||||
|
pub use entity::{Entity, EntityAllocator};
|
||||||
|
pub use sparse_set::SparseSet;
|
||||||
|
pub use world::World;
|
||||||
|
pub use transform::Transform;
|
||||||
|
pub use hierarchy::{Parent, Children, add_child, remove_child, despawn_recursive, roots};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `cargo test -p voltex_ecs`
|
||||||
|
Expected: 기존 25 + hierarchy 6 = 31개 PASS
|
||||||
|
|
||||||
|
- [ ] **Step 4: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/voltex_ecs/
|
||||||
|
git commit -m "feat(ecs): add Parent/Children hierarchy with add_child, remove_child, despawn_recursive"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: WorldTransform 전파
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `crates/voltex_ecs/src/world_transform.rs`
|
||||||
|
- Modify: `crates/voltex_ecs/src/lib.rs`
|
||||||
|
|
||||||
|
로컬 Transform에서 월드 행렬을 계산하여 WorldTransform에 저장. 루트에서 리프까지 top-down 순회로 부모의 월드 행렬을 자식에 곱한다.
|
||||||
|
|
||||||
|
- [ ] **Step 1: world_transform.rs 작성**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// crates/voltex_ecs/src/world_transform.rs
|
||||||
|
use voltex_math::Mat4;
|
||||||
|
use crate::{Entity, World, Transform};
|
||||||
|
use crate::hierarchy::{Parent, Children};
|
||||||
|
|
||||||
|
/// 계산된 월드 트랜스폼 (로컬 * 부모 월드)
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct WorldTransform(pub Mat4);
|
||||||
|
|
||||||
|
impl WorldTransform {
|
||||||
|
pub fn identity() -> Self {
|
||||||
|
Self(Mat4::IDENTITY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 모든 엔티티의 WorldTransform을 갱신한다.
|
||||||
|
/// 루트(Parent 없는)부터 시작하여 자식으로 전파.
|
||||||
|
pub fn propagate_transforms(world: &mut World) {
|
||||||
|
// 루트 엔티티 수집
|
||||||
|
let root_entities: Vec<Entity> = world.query::<Transform>()
|
||||||
|
.filter(|(e, _)| world.get::<Parent>(*e).is_none())
|
||||||
|
.map(|(e, _)| e)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for root in root_entities {
|
||||||
|
propagate_entity(world, root, Mat4::IDENTITY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn propagate_entity(world: &mut World, entity: Entity, parent_world: Mat4) {
|
||||||
|
let local = match world.get::<Transform>(entity) {
|
||||||
|
Some(t) => t.matrix(),
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let world_matrix = parent_world * local;
|
||||||
|
|
||||||
|
world.add(entity, WorldTransform(world_matrix));
|
||||||
|
|
||||||
|
// 자식들 수집 (borrow 회피)
|
||||||
|
let children: Vec<Entity> = world.get::<Children>(entity)
|
||||||
|
.map(|c| c.0.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
for child in children {
|
||||||
|
propagate_entity(world, child, world_matrix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::hierarchy::add_child;
|
||||||
|
use voltex_math::{Vec3, Vec4};
|
||||||
|
|
||||||
|
fn approx_eq(a: f32, b: f32) -> bool {
|
||||||
|
(a - b).abs() < 1e-4
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_root_world_transform() {
|
||||||
|
let mut world = World::new();
|
||||||
|
let e = world.spawn();
|
||||||
|
world.add(e, Transform::from_position(Vec3::new(5.0, 0.0, 0.0)));
|
||||||
|
|
||||||
|
propagate_transforms(&mut world);
|
||||||
|
|
||||||
|
let wt = world.get::<WorldTransform>(e).unwrap();
|
||||||
|
let p = wt.0 * Vec4::new(0.0, 0.0, 0.0, 1.0);
|
||||||
|
assert!(approx_eq(p.x, 5.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_child_inherits_parent() {
|
||||||
|
let mut world = World::new();
|
||||||
|
let parent = world.spawn();
|
||||||
|
let child = world.spawn();
|
||||||
|
|
||||||
|
world.add(parent, Transform::from_position(Vec3::new(10.0, 0.0, 0.0)));
|
||||||
|
world.add(child, Transform::from_position(Vec3::new(0.0, 5.0, 0.0)));
|
||||||
|
add_child(&mut world, parent, child);
|
||||||
|
|
||||||
|
propagate_transforms(&mut world);
|
||||||
|
|
||||||
|
// child의 월드 위치: parent(10,0,0) + child(0,5,0) = (10,5,0)
|
||||||
|
let wt = world.get::<WorldTransform>(child).unwrap();
|
||||||
|
let p = wt.0 * Vec4::new(0.0, 0.0, 0.0, 1.0);
|
||||||
|
assert!(approx_eq(p.x, 10.0), "x: {}", p.x);
|
||||||
|
assert!(approx_eq(p.y, 5.0), "y: {}", p.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_three_level_hierarchy() {
|
||||||
|
let mut world = World::new();
|
||||||
|
let root = world.spawn();
|
||||||
|
let mid = world.spawn();
|
||||||
|
let leaf = world.spawn();
|
||||||
|
|
||||||
|
world.add(root, Transform::from_position(Vec3::new(1.0, 0.0, 0.0)));
|
||||||
|
world.add(mid, Transform::from_position(Vec3::new(0.0, 2.0, 0.0)));
|
||||||
|
world.add(leaf, Transform::from_position(Vec3::new(0.0, 0.0, 3.0)));
|
||||||
|
|
||||||
|
add_child(&mut world, root, mid);
|
||||||
|
add_child(&mut world, mid, leaf);
|
||||||
|
|
||||||
|
propagate_transforms(&mut world);
|
||||||
|
|
||||||
|
// leaf 월드 위치: (1, 2, 3)
|
||||||
|
let wt = world.get::<WorldTransform>(leaf).unwrap();
|
||||||
|
let p = wt.0 * Vec4::new(0.0, 0.0, 0.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_parent_scale_affects_child() {
|
||||||
|
let mut world = World::new();
|
||||||
|
let parent = world.spawn();
|
||||||
|
let child = world.spawn();
|
||||||
|
|
||||||
|
world.add(parent, Transform::from_position_scale(
|
||||||
|
Vec3::ZERO,
|
||||||
|
Vec3::new(2.0, 2.0, 2.0),
|
||||||
|
));
|
||||||
|
world.add(child, Transform::from_position(Vec3::new(1.0, 0.0, 0.0)));
|
||||||
|
add_child(&mut world, parent, child);
|
||||||
|
|
||||||
|
propagate_transforms(&mut world);
|
||||||
|
|
||||||
|
// parent 스케일 2x → child(1,0,0)이 (2,0,0)으로
|
||||||
|
let wt = world.get::<WorldTransform>(child).unwrap();
|
||||||
|
let p = wt.0 * Vec4::new(0.0, 0.0, 0.0, 1.0);
|
||||||
|
assert!(approx_eq(p.x, 2.0), "x: {}", p.x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: lib.rs 업데이트**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// lib.rs에 추가:
|
||||||
|
pub mod world_transform;
|
||||||
|
pub use world_transform::{WorldTransform, propagate_transforms};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `cargo test -p voltex_ecs`
|
||||||
|
Expected: 31 + 4 = 35개 PASS
|
||||||
|
|
||||||
|
- [ ] **Step 4: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/voltex_ecs/
|
||||||
|
git commit -m "feat(ecs): add WorldTransform propagation through parent-child hierarchy"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: 씬 직렬화 (.vscn 포맷)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `crates/voltex_ecs/src/scene.rs`
|
||||||
|
- Modify: `crates/voltex_ecs/src/lib.rs`
|
||||||
|
|
||||||
|
간단한 텍스트 포맷으로 씬을 저장/로드. 포맷:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Voltex Scene v1
|
||||||
|
entity 0
|
||||||
|
transform 1.0 2.0 3.0 | 0.0 0.5 0.0 | 1.0 1.0 1.0
|
||||||
|
tag solar_system
|
||||||
|
|
||||||
|
entity 1
|
||||||
|
parent 0
|
||||||
|
transform 5.0 0.0 0.0 | 0.0 0.0 0.0 | 0.5 0.5 0.5
|
||||||
|
tag planet
|
||||||
|
```
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
- `entity N` — 엔티티 시작 (N은 파일 내 로컬 인덱스)
|
||||||
|
- ` transform px py pz | rx ry rz | sx sy sz` — Transform 컴포넌트
|
||||||
|
- ` parent N` — 부모 엔티티의 로컬 인덱스
|
||||||
|
- ` tag name` — 문자열 태그 (선택적, 디버깅용)
|
||||||
|
|
||||||
|
- [ ] **Step 1: scene.rs 작성**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// crates/voltex_ecs/src/scene.rs
|
||||||
|
use crate::{Entity, World, Transform};
|
||||||
|
use crate::hierarchy::{Parent, Children, add_child};
|
||||||
|
use voltex_math::Vec3;
|
||||||
|
|
||||||
|
/// 디버깅/식별용 태그 컴포넌트
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Tag(pub String);
|
||||||
|
|
||||||
|
/// World의 씬 데이터를 .vscn 텍스트로 직렬화
|
||||||
|
pub fn serialize_scene(world: &World) -> String {
|
||||||
|
let mut output = String::from("# Voltex Scene v1\n");
|
||||||
|
|
||||||
|
// Transform를 가진 모든 엔티티 수집
|
||||||
|
let entities: Vec<(Entity, Transform)> = world.query::<Transform>()
|
||||||
|
.map(|(e, t)| (e, *t))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Entity → 로컬 인덱스 매핑
|
||||||
|
let entity_to_idx: std::collections::HashMap<Entity, usize> = entities.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, (e, _))| (*e, i))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (idx, (entity, transform)) in entities.iter().enumerate() {
|
||||||
|
output.push_str(&format!("\nentity {}\n", idx));
|
||||||
|
|
||||||
|
// Transform
|
||||||
|
output.push_str(&format!(
|
||||||
|
" transform {} {} {} | {} {} {} | {} {} {}\n",
|
||||||
|
transform.position.x, transform.position.y, transform.position.z,
|
||||||
|
transform.rotation.x, transform.rotation.y, transform.rotation.z,
|
||||||
|
transform.scale.x, transform.scale.y, transform.scale.z,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Parent
|
||||||
|
if let Some(parent) = world.get::<Parent>(*entity) {
|
||||||
|
if let Some(&parent_idx) = entity_to_idx.get(&parent.0) {
|
||||||
|
output.push_str(&format!(" parent {}\n", parent_idx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag
|
||||||
|
if let Some(tag) = world.get::<Tag>(*entity) {
|
||||||
|
output.push_str(&format!(" tag {}\n", tag.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
/// .vscn 텍스트를 파싱하여 World에 엔티티를 생성
|
||||||
|
pub fn deserialize_scene(world: &mut World, source: &str) -> Vec<Entity> {
|
||||||
|
let mut entities: Vec<Entity> = Vec::new();
|
||||||
|
let mut current_entity: Option<Entity> = None;
|
||||||
|
// 로컬 인덱스 → (parent_local_idx) 매핑 (나중에 해석)
|
||||||
|
let mut parent_map: Vec<Option<usize>> = Vec::new();
|
||||||
|
|
||||||
|
for line in source.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if line.starts_with("entity ") {
|
||||||
|
let entity = world.spawn();
|
||||||
|
entities.push(entity);
|
||||||
|
current_entity = Some(entity);
|
||||||
|
parent_map.push(None);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entity = match current_entity {
|
||||||
|
Some(e) => e,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if line.starts_with("transform ") {
|
||||||
|
if let Some(transform) = parse_transform(&line[10..]) {
|
||||||
|
world.add(entity, transform);
|
||||||
|
}
|
||||||
|
} else if line.starts_with("parent ") {
|
||||||
|
if let Ok(parent_idx) = line[7..].trim().parse::<usize>() {
|
||||||
|
let idx = entities.len() - 1;
|
||||||
|
parent_map[idx] = Some(parent_idx);
|
||||||
|
}
|
||||||
|
} else if line.starts_with("tag ") {
|
||||||
|
let tag_name = line[4..].trim().to_string();
|
||||||
|
world.add(entity, Tag(tag_name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parent 관계 설정
|
||||||
|
for (child_idx, parent_idx_opt) in parent_map.iter().enumerate() {
|
||||||
|
if let Some(parent_idx) = parent_idx_opt {
|
||||||
|
if *parent_idx < entities.len() && child_idx < entities.len() {
|
||||||
|
add_child(world, entities[*parent_idx], entities[child_idx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entities
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_transform(s: &str) -> Option<Transform> {
|
||||||
|
// "px py pz | rx ry rz | sx sy sz"
|
||||||
|
let parts: Vec<&str> = s.split('|').collect();
|
||||||
|
if parts.len() != 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pos = parse_vec3(parts[0].trim())?;
|
||||||
|
let rot = parse_vec3(parts[1].trim())?;
|
||||||
|
let scale = parse_vec3(parts[2].trim())?;
|
||||||
|
|
||||||
|
Some(Transform {
|
||||||
|
position: pos,
|
||||||
|
rotation: rot,
|
||||||
|
scale,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_vec3(s: &str) -> Option<Vec3> {
|
||||||
|
let nums: Vec<f32> = s.split_whitespace()
|
||||||
|
.filter_map(|n| n.parse().ok())
|
||||||
|
.collect();
|
||||||
|
if nums.len() == 3 {
|
||||||
|
Some(Vec3::new(nums[0], nums[1], nums[2]))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::hierarchy::roots;
|
||||||
|
|
||||||
|
fn approx_eq(a: f32, b: f32) -> bool {
|
||||||
|
(a - b).abs() < 1e-5
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialize_single_entity() {
|
||||||
|
let mut world = World::new();
|
||||||
|
let e = world.spawn();
|
||||||
|
world.add(e, Transform::from_position(Vec3::new(1.0, 2.0, 3.0)));
|
||||||
|
world.add(e, Tag("test".into()));
|
||||||
|
|
||||||
|
let text = serialize_scene(&world);
|
||||||
|
assert!(text.contains("entity 0"));
|
||||||
|
assert!(text.contains("transform 1 2 3"));
|
||||||
|
assert!(text.contains("tag test"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialize_with_parent() {
|
||||||
|
let mut world = World::new();
|
||||||
|
let parent = world.spawn();
|
||||||
|
let child = world.spawn();
|
||||||
|
world.add(parent, Transform::new());
|
||||||
|
world.add(child, Transform::new());
|
||||||
|
add_child(&mut world, parent, child);
|
||||||
|
|
||||||
|
let text = serialize_scene(&world);
|
||||||
|
assert!(text.contains("parent"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_roundtrip() {
|
||||||
|
let mut world = World::new();
|
||||||
|
let root = world.spawn();
|
||||||
|
let child = world.spawn();
|
||||||
|
world.add(root, Transform::from_position(Vec3::new(10.0, 0.0, 0.0)));
|
||||||
|
world.add(root, Tag("root_node".into()));
|
||||||
|
world.add(child, Transform::from_position(Vec3::new(0.0, 5.0, 0.0)));
|
||||||
|
world.add(child, Tag("child_node".into()));
|
||||||
|
add_child(&mut world, root, child);
|
||||||
|
|
||||||
|
let text = serialize_scene(&world);
|
||||||
|
|
||||||
|
// 새 World에 역직렬화
|
||||||
|
let mut world2 = World::new();
|
||||||
|
let entities = deserialize_scene(&mut world2, &text);
|
||||||
|
|
||||||
|
assert_eq!(entities.len(), 2);
|
||||||
|
|
||||||
|
// Transform 확인
|
||||||
|
let t0 = world2.get::<Transform>(entities[0]).unwrap();
|
||||||
|
assert!(approx_eq(t0.position.x, 10.0));
|
||||||
|
|
||||||
|
let t1 = world2.get::<Transform>(entities[1]).unwrap();
|
||||||
|
assert!(approx_eq(t1.position.y, 5.0));
|
||||||
|
|
||||||
|
// 부모-자식 관계 확인
|
||||||
|
let parent_comp = world2.get::<Parent>(entities[1]).unwrap();
|
||||||
|
assert_eq!(parent_comp.0, entities[0]);
|
||||||
|
|
||||||
|
// Tag 확인
|
||||||
|
let tag0 = world2.get::<Tag>(entities[0]).unwrap();
|
||||||
|
assert_eq!(tag0.0, "root_node");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_roots() {
|
||||||
|
let scene = "\
|
||||||
|
# Voltex Scene v1
|
||||||
|
|
||||||
|
entity 0
|
||||||
|
transform 0 0 0 | 0 0 0 | 1 1 1
|
||||||
|
|
||||||
|
entity 1
|
||||||
|
parent 0
|
||||||
|
transform 1 0 0 | 0 0 0 | 1 1 1
|
||||||
|
|
||||||
|
entity 2
|
||||||
|
transform 5 0 0 | 0 0 0 | 1 1 1
|
||||||
|
";
|
||||||
|
let mut world = World::new();
|
||||||
|
let entities = deserialize_scene(&mut world, scene);
|
||||||
|
assert_eq!(entities.len(), 3);
|
||||||
|
|
||||||
|
let root_list = roots(&world);
|
||||||
|
assert_eq!(root_list.len(), 2); // entity 0 and entity 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: lib.rs 업데이트**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// lib.rs에 추가:
|
||||||
|
pub mod scene;
|
||||||
|
pub use scene::{Tag, serialize_scene, deserialize_scene};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `cargo test -p voltex_ecs`
|
||||||
|
Expected: 35 + 4 = 39개 PASS
|
||||||
|
|
||||||
|
- [ ] **Step 4: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/voltex_ecs/
|
||||||
|
git commit -m "feat(ecs): add scene serialization/deserialization (.vscn format)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: hierarchy_demo 예제
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `examples/hierarchy_demo/Cargo.toml`
|
||||||
|
- Create: `examples/hierarchy_demo/src/main.rs`
|
||||||
|
- Modify: `Cargo.toml` (워크스페이스에 추가)
|
||||||
|
|
||||||
|
씬 그래프를 시각적으로 보여주는 데모. 태양계 모델: 태양(회전) → 행성(공전+자전) → 위성(공전). 씬을 .vscn 파일로 저장하고 다시 로드하는 기능 포함.
|
||||||
|
|
||||||
|
- [ ] **Step 1: 워크스페이스 + Cargo.toml**
|
||||||
|
|
||||||
|
workspace members에 `"examples/hierarchy_demo"` 추가.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# examples/hierarchy_demo/Cargo.toml
|
||||||
|
[package]
|
||||||
|
name = "hierarchy_demo"
|
||||||
|
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 2: main.rs 작성**
|
||||||
|
|
||||||
|
이 파일은 many_cubes를 기반으로 하되, ECS 계층을 사용한다. 핵심 변경:
|
||||||
|
|
||||||
|
1. 태양계 구축: 태양(중앙) → 행성 3개(공전) → 위성 1개(행성의 자식)
|
||||||
|
2. 매 프레임: Transform의 rotation.y를 dt만큼 증가시킨 뒤 `propagate_transforms()` 호출
|
||||||
|
3. 렌더링: `world.query::<WorldTransform>()`으로 월드 행렬을 직접 사용
|
||||||
|
4. S키: 씬을 `scene.vscn`로 저장, L키: `scene.vscn` 로드
|
||||||
|
|
||||||
|
구현은 many_cubes의 dynamic UBO 패턴을 따른다. 핵심 차이:
|
||||||
|
- Transform 대신 WorldTransform의 행렬을 uniform.model로 사용
|
||||||
|
- 엔티티별 회전은 Transform.rotation.y를 증가시킴
|
||||||
|
- propagate_transforms로 월드 행렬 계산
|
||||||
|
|
||||||
|
파일을 작성하기 전에 반드시 `examples/many_cubes/src/main.rs`를 읽고 dynamic UBO 패턴을 따를 것.
|
||||||
|
|
||||||
|
씬 구축:
|
||||||
|
```
|
||||||
|
Sun: position(0,0,0), scale(2,2,2), rotation.y += dt*0.2
|
||||||
|
├── Planet1: position(6,0,0), scale(0.5,0.5,0.5), rotation.y += dt*1.0
|
||||||
|
│ └── Moon: position(1.5,0,0), scale(0.3,0.3,0.3), rotation.y += dt*2.0
|
||||||
|
├── Planet2: position(10,0,0), scale(0.7,0.7,0.7), rotation.y += dt*0.6
|
||||||
|
└── Planet3: position(14,0,0), scale(0.4,0.4,0.4), rotation.y += dt*0.3
|
||||||
|
```
|
||||||
|
|
||||||
|
S키 저장: `voltex_ecs::serialize_scene(&world)`를 `scene.vscn`에 쓰기.
|
||||||
|
L키 로드: `scene.vscn`을 읽어 `voltex_ecs::deserialize_scene()`으로 World 재구축.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 빌드 + 테스트 확인**
|
||||||
|
|
||||||
|
Run: `cargo build --workspace`
|
||||||
|
Run: `cargo test --workspace`
|
||||||
|
|
||||||
|
- [ ] **Step 4: 실행 확인**
|
||||||
|
|
||||||
|
Run: `cargo run -p hierarchy_demo`
|
||||||
|
Expected: 태양 주위로 행성이 공전, 행성 주위로 위성 공전. S키로 씬 저장, L키로 로드.
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Cargo.toml examples/hierarchy_demo/
|
||||||
|
git commit -m "feat: add hierarchy_demo with solar system scene graph and save/load"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3b 완료 기준 체크리스트
|
||||||
|
|
||||||
|
- [ ] `cargo build --workspace` 성공
|
||||||
|
- [ ] `cargo test --workspace` — 모든 테스트 통과
|
||||||
|
- [ ] 부모-자식 트랜스폼 전파 정상 (3단계 계층)
|
||||||
|
- [ ] 씬 직렬화 roundtrip: 저장 → 로드 → 동일 결과
|
||||||
|
- [ ] `cargo run -p hierarchy_demo` — 태양계 렌더링, 계층적 회전, S/L 저장/로드
|
||||||
|
- [ ] 기존 예제 (triangle, model_viewer, many_cubes) 여전히 동작
|
||||||
Reference in New Issue
Block a user