From ee22d3e62cacc6b6e147cbc1eb419d36f75ba330 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Tue, 24 Mar 2026 20:27:16 +0900 Subject: [PATCH] docs: add Phase 3c asset manager implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-24-phase3c-asset-manager.md | 636 ++++++++++++++++++ 1 file changed, 636 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-24-phase3c-asset-manager.md diff --git a/docs/superpowers/plans/2026-03-24-phase3c-asset-manager.md b/docs/superpowers/plans/2026-03-24-phase3c-asset-manager.md new file mode 100644 index 0000000..62dd130 --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-phase3c-asset-manager.md @@ -0,0 +1,636 @@ +# 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`는 경량 타입 안전 참조(u32 id + generation). `AssetStorage`는 제네릭 에셋 저장소로 참조 카운팅 지원. `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 제네릭 핸들 + ├── storage.rs # AssetStorage 참조 카운팅 스토리지 + └── 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 작성** + +```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 작성** + +```rust +// crates/voltex_asset/src/handle.rs +use std::marker::PhantomData; + +/// 타입 안전 에셋 핸들. 경량 식별자로 에셋을 참조. +#[derive(Debug)] +pub struct Handle { + pub id: u32, + pub generation: u32, + _marker: PhantomData, +} + +// PhantomData로 인해 자동 구현이 안되므로 수동 구현 +impl Clone for Handle { + fn clone(&self) -> Self { + Self { id: self.id, generation: self.generation, _marker: PhantomData } + } +} + +impl Copy for Handle {} + +impl PartialEq for Handle { + fn eq(&self, other: &Self) -> bool { + self.id == other.id && self.generation == other.generation + } +} + +impl Eq for Handle {} + +impl std::hash::Hash for Handle { + fn hash(&self, state: &mut H) { + self.id.hash(state); + self.generation.hash(state); + } +} + +impl Handle { + 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 = Handle::new(0, 0); + let h2 = h; + assert_eq!(h, h2); // Copy 확인 + } + + #[test] + fn test_handle_eq() { + let a: Handle = Handle::new(1, 0); + let b: Handle = Handle::new(1, 0); + let c: Handle = Handle::new(1, 1); + assert_eq!(a, b); + assert_ne!(a, c); + } +} +``` + +- [ ] **Step 4: storage.rs 작성** + +```rust +// crates/voltex_asset/src/storage.rs +use crate::handle::Handle; +use std::any::Any; + +struct AssetEntry { + asset: T, + generation: u32, + ref_count: u32, +} + +/// 타입별 에셋 스토리지. 참조 카운팅으로 메모리 관리. +pub struct AssetStorage { + entries: Vec>>, + free_ids: Vec, +} + +impl AssetStorage { + pub fn new() -> Self { + Self { + entries: Vec::new(), + free_ids: Vec::new(), + } + } + + /// 에셋 추가. 핸들 반환. 초기 ref_count = 1. + pub fn insert(&mut self, asset: T) -> Handle { + 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) -> 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) -> 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) { + 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) -> 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) -> 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, &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 AssetStorageDyn for AssetStorage { + 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 = 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 작성** + +```rust +// 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: 커밋** + +```bash +git add Cargo.toml crates/voltex_asset/ +git commit -m "feat(asset): add voltex_asset crate with Handle and AssetStorage" +``` + +--- + +## 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 작성** + +```rust +// 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>, +} + +impl Assets { + pub fn new() -> Self { + Self { + storages: HashMap::new(), + } + } + + /// 에셋 추가. 스토리지 자동 등록. + pub fn insert(&mut self, asset: T) -> Handle { + self.storage_mut::().insert(asset) + } + + /// 핸들로 에셋 참조 + pub fn get(&self, handle: Handle) -> Option<&T> { + self.storage::()?.get(handle) + } + + /// 핸들로 에셋 가변 참조 + pub fn get_mut(&mut self, handle: Handle) -> Option<&mut T> { + self.storage_mut::().get_mut(handle) + } + + /// 참조 카운트 증가 + pub fn add_ref(&mut self, handle: Handle) { + self.storage_mut::().add_ref(handle); + } + + /// 참조 카운트 감소 + pub fn release(&mut self, handle: Handle) -> bool { + self.storage_mut::().release(handle) + } + + /// 타입별 에셋 수 + pub fn count(&self) -> usize { + self.storage::().map(|s| s.len()).unwrap_or(0) + } + + /// 타입별 스토리지 읽기 참조 + pub fn storage(&self) -> Option<&AssetStorage> { + self.storages.get(&TypeId::of::()) + .and_then(|s| s.as_any().downcast_ref()) + } + + /// 타입별 스토리지 가변 참조 (없으면 생성) + pub fn storage_mut(&mut self) -> &mut AssetStorage { + self.storages + .entry(TypeId::of::()) + .or_insert_with(|| Box::new(AssetStorage::::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::(), 2); + assert_eq!(assets.count::(), 1); + } + + #[test] + fn test_release_through_assets() { + let mut assets = Assets::new(); + let h = assets.insert(42_i32); + assert_eq!(assets.count::(), 1); + assets.release(h); + assert_eq!(assets.count::(), 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::().unwrap(); + let values: Vec = storage.iter().map(|(_, v)| *v).collect(); + assert_eq!(values.len(), 2); + } +} +``` + +- [ ] **Step 2: lib.rs 업데이트** + +```rust +// 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: 커밋** + +```bash +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"` 추가. + +```toml +# 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` 컴포넌트로 에셋을 참조 +3. 렌더링 시 `assets.get(mesh_handle)` 로 Mesh를 가져와 사용 +4. 참조 카운팅 데모: 타이틀바에 에셋 수 표시 +5. R키: 랜덤 엔티티 10개 despawn (에셋은 다른 엔티티가 참조하므로 유지) +6. 단일 큐브 메시를 모든 엔티티가 공유 + +파일을 작성하기 전에 `examples/many_cubes/src/main.rs`의 dynamic UBO 패턴을 반드시 읽을 것. + +핵심 구조: +```rust +struct AppState { + // ... + assets: Assets, + world: World, + // mesh_handle은 world의 엔티티 컴포넌트로 저장 +} + +// MeshRef 컴포넌트: Handle를 감싸는 newtype +#[derive(Clone, Copy)] +struct MeshRef(Handle); +``` + +렌더링 루프: +```rust +// WorldTransform가 있는 엔티티 + MeshRef 쿼리 +let entities = state.world.query2::(); +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: 커밋** + +```bash +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키 엔티티 삭제 +- [ ] 기존 예제 모두 동작