Files
game_engine/docs/superpowers/plans/2026-03-24-phase3c-asset-manager.md
2026-03-24 20:27:16 +09:00

18 KiB

Phase 3c: Asset Manager 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: 핸들 기반 에셋 관리 시스템으로 Mesh, Texture 등의 에셋을 타입별로 저장/조회/삭제하고, 참조 카운팅으로 메모리를 관리한다.

Architecture:voltex_asset crate를 만든다. Handle<T>는 경량 타입 안전 참조(u32 id + generation). AssetStorage<T>는 제네릭 에셋 저장소로 참조 카운팅 지원. Assets는 중앙 관리자로 타입별 스토리지를 type-erased로 관리한다. 로딩은 앱에서 기존 파서(parse_obj, parse_bmp)로 에셋을 만들고 insert하는 방식.

Tech Stack: Rust 1.94

Spec: docs/superpowers/specs/2026-03-24-voltex-engine-design.md Phase 3 (3-3. 에셋 매니저)

스코프 제한: 비동기 로딩, 핫 리로드는 별도 Phase로 분리. 이번에는 핸들 + 스토리지 + 참조 카운팅만 구현.


File Structure

crates/
└── voltex_asset/
    ├── Cargo.toml
    └── src/
        ├── lib.rs          # 모듈 re-export
        ├── handle.rs       # Handle<T> 제네릭 핸들
        ├── storage.rs      # AssetStorage<T> 참조 카운팅 스토리지
        └── assets.rs       # Assets 중앙 관리자 (type-erased)
examples/
└── asset_demo/             # 에셋 매니저 통합 데모 (NEW)
    ├── Cargo.toml
    └── src/
        └── main.rs

Task 1: Handle + AssetStorage

Files:

  • Create: crates/voltex_asset/Cargo.toml
  • Create: crates/voltex_asset/src/lib.rs
  • Create: crates/voltex_asset/src/handle.rs
  • Create: crates/voltex_asset/src/storage.rs
  • Modify: Cargo.toml (워크스페이스에 voltex_asset 추가)

Handle은 에셋을 가리키는 경량 식별자. AssetStorage는 에셋 + 참조 카운트를 관리.

  • Step 1: Cargo.toml 작성
# crates/voltex_asset/Cargo.toml
[package]
name = "voltex_asset"
version = "0.1.0"
edition = "2021"

[dependencies]
  • Step 2: 워크스페이스 업데이트

Cargo.toml root: members에 "crates/voltex_asset" 추가, workspace.dependencies에 voltex_asset = { path = "crates/voltex_asset" } 추가.

  • Step 3: handle.rs 작성
// crates/voltex_asset/src/handle.rs
use std::marker::PhantomData;

/// 타입 안전 에셋 핸들. 경량 식별자로 에셋을 참조.
#[derive(Debug)]
pub struct Handle<T> {
    pub id: u32,
    pub generation: u32,
    _marker: PhantomData<T>,
}

// PhantomData로 인해 자동 구현이 안되므로 수동 구현
impl<T> Clone for Handle<T> {
    fn clone(&self) -> Self {
        Self { id: self.id, generation: self.generation, _marker: PhantomData }
    }
}

impl<T> Copy for Handle<T> {}

impl<T> PartialEq for Handle<T> {
    fn eq(&self, other: &Self) -> bool {
        self.id == other.id && self.generation == other.generation
    }
}

impl<T> Eq for Handle<T> {}

impl<T> std::hash::Hash for Handle<T> {
    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
        self.id.hash(state);
        self.generation.hash(state);
    }
}

impl<T> Handle<T> {
    pub(crate) fn new(id: u32, generation: u32) -> Self {
        Self { id, generation, _marker: PhantomData }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_handle_copy() {
        let h: Handle<String> = Handle::new(0, 0);
        let h2 = h;
        assert_eq!(h, h2); // Copy 확인
    }

    #[test]
    fn test_handle_eq() {
        let a: Handle<i32> = Handle::new(1, 0);
        let b: Handle<i32> = Handle::new(1, 0);
        let c: Handle<i32> = Handle::new(1, 1);
        assert_eq!(a, b);
        assert_ne!(a, c);
    }
}
  • Step 4: storage.rs 작성
// crates/voltex_asset/src/storage.rs
use crate::handle::Handle;
use std::any::Any;

struct AssetEntry<T> {
    asset: T,
    generation: u32,
    ref_count: u32,
}

/// 타입별 에셋 스토리지. 참조 카운팅으로 메모리 관리.
pub struct AssetStorage<T> {
    entries: Vec<Option<AssetEntry<T>>>,
    free_ids: Vec<u32>,
}

impl<T> AssetStorage<T> {
    pub fn new() -> Self {
        Self {
            entries: Vec::new(),
            free_ids: Vec::new(),
        }
    }

    /// 에셋 추가. 핸들 반환. 초기 ref_count = 1.
    pub fn insert(&mut self, asset: T) -> Handle<T> {
        if let Some(id) = self.free_ids.pop() {
            let idx = id as usize;
            let generation = match &self.entries[idx] {
                Some(e) => e.generation + 1,
                None => 0,
            };
            self.entries[idx] = Some(AssetEntry {
                asset,
                generation,
                ref_count: 1,
            });
            Handle::new(id, generation)
        } else {
            let id = self.entries.len() as u32;
            self.entries.push(Some(AssetEntry {
                asset,
                generation: 0,
                ref_count: 1,
            }));
            Handle::new(id, 0)
        }
    }

    /// 핸들로 에셋 참조
    pub fn get(&self, handle: Handle<T>) -> Option<&T> {
        let entry = self.entries.get(handle.id as usize)?.as_ref()?;
        if entry.generation == handle.generation {
            Some(&entry.asset)
        } else {
            None
        }
    }

    /// 핸들로 에셋 가변 참조
    pub fn get_mut(&mut self, handle: Handle<T>) -> Option<&mut T> {
        let entry = self.entries.get_mut(handle.id as usize)?.as_mut()?;
        if entry.generation == handle.generation {
            Some(&mut entry.asset)
        } else {
            None
        }
    }

    /// 참조 카운트 증가
    pub fn add_ref(&mut self, handle: Handle<T>) {
        if let Some(Some(entry)) = self.entries.get_mut(handle.id as usize) {
            if entry.generation == handle.generation {
                entry.ref_count += 1;
            }
        }
    }

    /// 참조 카운트 감소. 0이 되면 에셋 삭제.
    /// 삭제되었으면 true 반환.
    pub fn release(&mut self, handle: Handle<T>) -> bool {
        let idx = handle.id as usize;
        if let Some(Some(entry)) = self.entries.get_mut(idx) {
            if entry.generation == handle.generation {
                entry.ref_count = entry.ref_count.saturating_sub(1);
                if entry.ref_count == 0 {
                    self.entries[idx] = None;
                    self.free_ids.push(handle.id);
                    return true;
                }
            }
        }
        false
    }

    /// 현재 저장된 에셋 수
    pub fn len(&self) -> usize {
        self.entries.iter().filter(|e| e.is_some()).count()
    }

    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }

    /// 참조 카운트 조회
    pub fn ref_count(&self, handle: Handle<T>) -> u32 {
        self.entries.get(handle.id as usize)
            .and_then(|e| e.as_ref())
            .filter(|e| e.generation == handle.generation)
            .map(|e| e.ref_count)
            .unwrap_or(0)
    }

    /// 모든 에셋 순회 (handle, &asset)
    pub fn iter(&self) -> impl Iterator<Item = (Handle<T>, &T)> {
        self.entries.iter().enumerate().filter_map(|(i, entry)| {
            entry.as_ref().map(|e| (Handle::new(i as u32, e.generation), &e.asset))
        })
    }
}

/// Type erasure trait for Assets manager
pub trait AssetStorageDyn: Any {
    fn as_any(&self) -> &dyn Any;
    fn as_any_mut(&mut self) -> &mut dyn Any;
    fn count(&self) -> usize;
}

impl<T: 'static> AssetStorageDyn for AssetStorage<T> {
    fn as_any(&self) -> &dyn Any { self }
    fn as_any_mut(&mut self) -> &mut dyn Any { self }
    fn count(&self) -> usize { self.len() }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_insert_and_get() {
        let mut storage = AssetStorage::new();
        let h = storage.insert("hello".to_string());
        assert_eq!(storage.get(h), Some(&"hello".to_string()));
    }

    #[test]
    fn test_get_mut() {
        let mut storage = AssetStorage::new();
        let h = storage.insert(vec![1, 2, 3]);
        storage.get_mut(h).unwrap().push(4);
        assert_eq!(storage.get(h).unwrap().len(), 4);
    }

    #[test]
    fn test_release_removes_at_zero() {
        let mut storage = AssetStorage::new();
        let h = storage.insert(42);
        assert_eq!(storage.len(), 1);
        let removed = storage.release(h);
        assert!(removed);
        assert_eq!(storage.len(), 0);
        assert_eq!(storage.get(h), None);
    }

    #[test]
    fn test_ref_counting() {
        let mut storage = AssetStorage::new();
        let h = storage.insert(42);
        assert_eq!(storage.ref_count(h), 1);
        storage.add_ref(h);
        assert_eq!(storage.ref_count(h), 2);
        assert!(!storage.release(h)); // ref_count 2 → 1, not removed
        assert_eq!(storage.ref_count(h), 1);
        assert!(storage.release(h)); // ref_count 1 → 0, removed
        assert_eq!(storage.ref_count(h), 0);
    }

    #[test]
    fn test_stale_handle() {
        let mut storage = AssetStorage::new();
        let h1 = storage.insert(10);
        storage.release(h1);
        let h2 = storage.insert(20); // reuse slot, generation+1
        assert_eq!(storage.get(h1), None); // old handle invalid
        assert_eq!(storage.get(h2), Some(&20));
    }

    #[test]
    fn test_id_reuse() {
        let mut storage = AssetStorage::new();
        let h1 = storage.insert("first");
        let id1 = h1.id;
        storage.release(h1);
        let h2 = storage.insert("second");
        assert_eq!(h2.id, id1); // ID 재사용
        assert_eq!(h2.generation, 1); // generation 증가
    }

    #[test]
    fn test_iter() {
        let mut storage = AssetStorage::new();
        storage.insert(10);
        storage.insert(20);
        storage.insert(30);
        let values: Vec<i32> = storage.iter().map(|(_, v)| *v).collect();
        assert_eq!(values.len(), 3);
        assert!(values.contains(&10));
        assert!(values.contains(&20));
        assert!(values.contains(&30));
    }
}
  • Step 5: lib.rs 작성
// crates/voltex_asset/src/lib.rs
pub mod handle;
pub mod storage;

pub use handle::Handle;
pub use storage::AssetStorage;
  • Step 6: 테스트 통과 확인

Run: cargo test -p voltex_asset Expected: handle 2개 + storage 7개 = 9개 PASS

  • Step 7: 커밋
git add Cargo.toml crates/voltex_asset/
git commit -m "feat(asset): add voltex_asset crate with Handle<T> and AssetStorage<T>"

Task 2: Assets 중앙 관리자

Files:

  • Create: crates/voltex_asset/src/assets.rs
  • Modify: crates/voltex_asset/src/lib.rs

Assets는 여러 타입의 AssetStorage를 type-erased로 보관. World처럼 TypeId 기반.

  • Step 1: assets.rs 작성
// crates/voltex_asset/src/assets.rs
use std::any::TypeId;
use std::collections::HashMap;
use crate::handle::Handle;
use crate::storage::{AssetStorage, AssetStorageDyn};

/// 중앙 에셋 관리자. 타입별 AssetStorage를 관리.
pub struct Assets {
    storages: HashMap<TypeId, Box<dyn AssetStorageDyn>>,
}

impl Assets {
    pub fn new() -> Self {
        Self {
            storages: HashMap::new(),
        }
    }

    /// 에셋 추가. 스토리지 자동 등록.
    pub fn insert<T: 'static>(&mut self, asset: T) -> Handle<T> {
        self.storage_mut::<T>().insert(asset)
    }

    /// 핸들로 에셋 참조
    pub fn get<T: 'static>(&self, handle: Handle<T>) -> Option<&T> {
        self.storage::<T>()?.get(handle)
    }

    /// 핸들로 에셋 가변 참조
    pub fn get_mut<T: 'static>(&mut self, handle: Handle<T>) -> Option<&mut T> {
        self.storage_mut::<T>().get_mut(handle)
    }

    /// 참조 카운트 증가
    pub fn add_ref<T: 'static>(&mut self, handle: Handle<T>) {
        self.storage_mut::<T>().add_ref(handle);
    }

    /// 참조 카운트 감소
    pub fn release<T: 'static>(&mut self, handle: Handle<T>) -> bool {
        self.storage_mut::<T>().release(handle)
    }

    /// 타입별 에셋 수
    pub fn count<T: 'static>(&self) -> usize {
        self.storage::<T>().map(|s| s.len()).unwrap_or(0)
    }

    /// 타입별 스토리지 읽기 참조
    pub fn storage<T: 'static>(&self) -> Option<&AssetStorage<T>> {
        self.storages.get(&TypeId::of::<T>())
            .and_then(|s| s.as_any().downcast_ref())
    }

    /// 타입별 스토리지 가변 참조 (없으면 생성)
    pub fn storage_mut<T: 'static>(&mut self) -> &mut AssetStorage<T> {
        self.storages
            .entry(TypeId::of::<T>())
            .or_insert_with(|| Box::new(AssetStorage::<T>::new()))
            .as_any_mut()
            .downcast_mut()
            .unwrap()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[derive(Debug, PartialEq)]
    struct MeshData { vertex_count: u32 }

    #[derive(Debug, PartialEq)]
    struct TextureData { width: u32, height: u32 }

    #[test]
    fn test_insert_and_get_different_types() {
        let mut assets = Assets::new();
        let mesh_h = assets.insert(MeshData { vertex_count: 100 });
        let tex_h = assets.insert(TextureData { width: 256, height: 256 });

        assert_eq!(assets.get(mesh_h).unwrap().vertex_count, 100);
        assert_eq!(assets.get(tex_h).unwrap().width, 256);
    }

    #[test]
    fn test_count_per_type() {
        let mut assets = Assets::new();
        assets.insert(MeshData { vertex_count: 10 });
        assets.insert(MeshData { vertex_count: 20 });
        assets.insert(TextureData { width: 64, height: 64 });

        assert_eq!(assets.count::<MeshData>(), 2);
        assert_eq!(assets.count::<TextureData>(), 1);
    }

    #[test]
    fn test_release_through_assets() {
        let mut assets = Assets::new();
        let h = assets.insert(42_i32);
        assert_eq!(assets.count::<i32>(), 1);
        assets.release(h);
        assert_eq!(assets.count::<i32>(), 0);
    }

    #[test]
    fn test_ref_counting_through_assets() {
        let mut assets = Assets::new();
        let h = assets.insert("shared".to_string());
        assets.add_ref(h);
        assert!(!assets.release(h)); // 2 → 1
        assert!(assets.get(h).is_some()); // still alive
        assert!(assets.release(h)); // 1 → 0
        assert!(assets.get(h).is_none());
    }

    #[test]
    fn test_storage_access() {
        let mut assets = Assets::new();
        assets.insert(10_i32);
        assets.insert(20_i32);

        let storage = assets.storage::<i32>().unwrap();
        let values: Vec<i32> = storage.iter().map(|(_, v)| *v).collect();
        assert_eq!(values.len(), 2);
    }
}
  • Step 2: lib.rs 업데이트
// crates/voltex_asset/src/lib.rs
pub mod handle;
pub mod storage;
pub mod assets;

pub use handle::Handle;
pub use storage::AssetStorage;
pub use assets::Assets;
  • Step 3: 테스트 통과 확인

Run: cargo test -p voltex_asset Expected: 9 + 5 = 14개 PASS

  • Step 4: 커밋
git add crates/voltex_asset/
git commit -m "feat(asset): add Assets central manager with type-erased storage"

Task 3: asset_demo 예제

Files:

  • Create: examples/asset_demo/Cargo.toml
  • Create: examples/asset_demo/src/main.rs
  • Modify: Cargo.toml (워크스페이스에 추가)

에셋 매니저를 사용하여 메시와 텍스처를 관리하는 데모. many_cubes를 기반으로 하되, Mesh와 GpuTexture를 Assets에 등록하고 Handle로 참조.

  • Step 1: 워크스페이스 + Cargo.toml

workspace members에 "examples/asset_demo" 추가.

# examples/asset_demo/Cargo.toml
[package]
name = "asset_demo"
version = "0.1.0"
edition = "2021"

[dependencies]
voltex_math.workspace = true
voltex_platform.workspace = true
voltex_renderer.workspace = true
voltex_ecs.workspace = true
voltex_asset.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와 비슷하지만:

  1. Assets 매니저에 Mesh를 등록: let mesh_handle = assets.insert(mesh);
  2. ECS 엔티티가 Handle<Mesh> 컴포넌트로 에셋을 참조
  3. 렌더링 시 assets.get(mesh_handle) 로 Mesh를 가져와 사용
  4. 참조 카운팅 데모: 타이틀바에 에셋 수 표시
  5. R키: 랜덤 엔티티 10개 despawn (에셋은 다른 엔티티가 참조하므로 유지)
  6. 단일 큐브 메시를 모든 엔티티가 공유

파일을 작성하기 전에 examples/many_cubes/src/main.rs의 dynamic UBO 패턴을 반드시 읽을 것.

핵심 구조:

struct AppState {
    // ...
    assets: Assets,
    world: World,
    // mesh_handle은 world의 엔티티 컴포넌트로 저장
}

// MeshRef 컴포넌트: Handle<Mesh>를 감싸는 newtype
#[derive(Clone, Copy)]
struct MeshRef(Handle<Mesh>);

렌더링 루프:

// WorldTransform가 있는 엔티티 + MeshRef 쿼리
let entities = state.world.query2::<WorldTransform, MeshRef>();
for (_, wt, mesh_ref) in &entities {
    if let Some(mesh) = state.assets.get(mesh_ref.0) {
        // set vertex/index buffer, draw
    }
}
  • Step 3: 빌드 + 테스트

Run: cargo build --workspace Run: cargo test --workspace

  • Step 4: 실행 확인

Run: cargo run -p asset_demo Expected: 큐브 그리드가 렌더링됨. R키로 엔티티 삭제 시 메시 에셋은 유지.

  • Step 5: 커밋
git add Cargo.toml examples/asset_demo/
git commit -m "feat: add asset_demo with Handle-based mesh management"

Phase 3c 완료 기준 체크리스트

  • cargo build --workspace 성공
  • cargo test --workspace — 모든 테스트 통과 (기존 80 + asset 14 = 94개)
  • Handle: Copy, Eq, Hash, generation으로 stale 감지
  • AssetStorage: insert/get/release, 참조 카운팅, ID 재사용
  • Assets: 타입별 스토리지 관리, 자동 등록
  • cargo run -p asset_demo — 에셋 핸들로 메시 관리, R키 엔티티 삭제
  • 기존 예제 모두 동작