Files
game_engine/docs/superpowers/plans/2026-03-24-phase3b-scene-graph.md
2026-03-24 20:18:10 +09:00

23 KiB

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) 컴포넌트. 계층을 조작하는 free function들.

  • Step 1: hierarchy.rs 작성
// 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 업데이트
// 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: 커밋
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 작성
// 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 업데이트
// 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: 커밋
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 작성

// 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 업데이트
// 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: 커밋
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" 추가.

# 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: 커밋
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) 여전히 동작