diff --git a/docs/superpowers/plans/2026-03-24-phase3b-scene-graph.md b/docs/superpowers/plans/2026-03-24-phase3b-scene-graph.md new file mode 100644 index 0000000..c77811e --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-phase3b-scene-graph.md @@ -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)` 컴포넌트를 추가하고, 계층 관리 함수를 `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) 컴포넌트. 계층을 조작하는 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를 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::(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::(parent) { + children.0.retain(|&e| e != child); + } + + // child의 Parent 제거 + world.remove::(child); +} + +/// entity와 모든 자손을 재귀적으로 삭제 +pub fn despawn_recursive(world: &mut World, entity: Entity) { + // 자식들 먼저 수집 (borrow 문제 회피) + let children: Vec = world.get::(entity) + .map(|c| c.0.clone()) + .unwrap_or_default(); + + for child in children { + despawn_recursive(world, child); + } + + // 부모에서 자신 제거 + if let Some(parent) = world.get::(entity).map(|p| p.0) { + if let Some(children) = world.get_mut::(parent) { + children.0.retain(|&e| e != entity); + } + } + + world.despawn(entity); +} + +/// 루트 엔티티 목록 반환 (Parent가 없는 엔티티) +pub fn roots(world: &World) -> Vec { + // Transform가 있지만 Parent가 없는 엔티티가 루트 + world.query::() + .filter(|(entity, _)| world.get::(*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::(child).unwrap().0, parent); + assert_eq!(world.get::(parent).unwrap().0.len(), 1); + assert_eq!(world.get::(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::(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::(child).is_none()); + assert_eq!(world.get::(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::(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 = world.query::() + .filter(|(e, _)| world.get::(*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::(entity) { + Some(t) => t.matrix(), + None => return, + }; + let world_matrix = parent_world * local; + + world.add(entity, WorldTransform(world_matrix)); + + // 자식들 수집 (borrow 회피) + let children: Vec = world.get::(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::(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::(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::(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::(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::() + .map(|(e, t)| (e, *t)) + .collect(); + + // Entity → 로컬 인덱스 매핑 + let entity_to_idx: std::collections::HashMap = 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::(*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::(*entity) { + output.push_str(&format!(" tag {}\n", tag.0)); + } + } + + output +} + +/// .vscn 텍스트를 파싱하여 World에 엔티티를 생성 +pub fn deserialize_scene(world: &mut World, source: &str) -> Vec { + let mut entities: Vec = Vec::new(); + let mut current_entity: Option = None; + // 로컬 인덱스 → (parent_local_idx) 매핑 (나중에 해석) + let mut parent_map: Vec> = 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::() { + 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 { + // "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 { + let nums: Vec = 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::(entities[0]).unwrap(); + assert!(approx_eq(t0.position.x, 10.0)); + + let t1 = world2.get::(entities[1]).unwrap(); + assert!(approx_eq(t1.position.y, 5.0)); + + // 부모-자식 관계 확인 + let parent_comp = world2.get::(entities[1]).unwrap(); + assert_eq!(parent_comp.0, entities[0]); + + // Tag 확인 + let tag0 = world2.get::(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::()`으로 월드 행렬을 직접 사용 +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) 여전히 동작