From c478e2433d5dd86fda3a10d962b3a99aa9c895b0 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 25 Mar 2026 20:24:19 +0900 Subject: [PATCH] docs: add implementation plans for scene serialization, async loading, PBR textures Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-25-phase3b-scene-serialization.md | 406 ++++++++++++++++++ .../2026-03-25-phase3c-async-hotreload.md | 354 +++++++++++++++ .../plans/2026-03-25-phase4a-pbr-textures.md | 294 +++++++++++++ 3 files changed, 1054 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-25-phase3b-scene-serialization.md create mode 100644 docs/superpowers/plans/2026-03-25-phase3c-async-hotreload.md create mode 100644 docs/superpowers/plans/2026-03-25-phase4a-pbr-textures.md diff --git a/docs/superpowers/plans/2026-03-25-phase3b-scene-serialization.md b/docs/superpowers/plans/2026-03-25-phase3b-scene-serialization.md new file mode 100644 index 0000000..e62d69d --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-phase3b-scene-serialization.md @@ -0,0 +1,406 @@ +# Scene 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:** Add JSON scene serialization, binary scene format, and registration-based arbitrary component serialization to voltex_ecs. + +**Architecture:** ComponentRegistry stores per-type serialize/deserialize functions. JSON and binary serializers use the registry to handle arbitrary components. Existing .vscn text format unchanged. + +**Tech Stack:** Pure Rust, no external dependencies. Mini JSON writer/parser within voltex_ecs (not reusing voltex_renderer's json_parser to avoid dependency inversion). + +--- + +### Task 1: Mini JSON Writer + Parser for voltex_ecs + +**Files:** +- Create: `crates/voltex_ecs/src/json.rs` +- Modify: `crates/voltex_ecs/src/lib.rs` + +- [ ] **Step 1: Write tests for JSON writer** + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_write_null() { + assert_eq!(json_write_null(), "null"); + } + + #[test] + fn test_write_number() { + assert_eq!(json_write_f32(3.14), "3.14"); + assert_eq!(json_write_f32(1.0), "1"); + } + + #[test] + fn test_write_string() { + assert_eq!(json_write_string("hello"), "\"hello\""); + assert_eq!(json_write_string("a\"b"), "\"a\\\"b\""); + } + + #[test] + fn test_write_array() { + assert_eq!(json_write_array(&["1", "2", "3"]), "[1,2,3]"); + } + + #[test] + fn test_write_object() { + let pairs = vec![("name", "\"test\""), ("value", "42")]; + let result = json_write_object(&pairs); + assert_eq!(result, r#"{"name":"test","value":42}"#); + } + + #[test] + fn test_parse_number() { + match json_parse("42.5").unwrap() { + JsonVal::Number(n) => assert!((n - 42.5).abs() < 1e-10), + _ => panic!("expected number"), + } + } + + #[test] + fn test_parse_string() { + assert_eq!(json_parse("\"hello\"").unwrap(), JsonVal::Str("hello".into())); + } + + #[test] + fn test_parse_array() { + match json_parse("[1,2,3]").unwrap() { + JsonVal::Array(a) => assert_eq!(a.len(), 3), + _ => panic!("expected array"), + } + } + + #[test] + fn test_parse_object() { + let val = json_parse(r#"{"x":1,"y":2}"#).unwrap(); + assert!(matches!(val, JsonVal::Object(_))); + } + + #[test] + fn test_parse_null() { + assert_eq!(json_parse("null").unwrap(), JsonVal::Null); + } + + #[test] + fn test_parse_nested() { + let val = json_parse(r#"{"a":[1,2],"b":{"c":3}}"#).unwrap(); + assert!(matches!(val, JsonVal::Object(_))); + } +} +``` + +- [ ] **Step 2: Implement JSON writer and parser** + +Implement in `crates/voltex_ecs/src/json.rs`: +- `JsonVal` enum: Null, Bool(bool), Number(f64), Str(String), Array(Vec), Object(Vec<(String, JsonVal)>) +- Writer helpers: `json_write_null`, `json_write_f32`, `json_write_string`, `json_write_array`, `json_write_object` +- `json_parse(input: &str) -> Result` — minimal recursive descent parser +- `JsonVal::get(key)`, `as_f64()`, `as_str()`, `as_array()`, `as_object()` accessors + +Register in lib.rs: `pub mod json;` + +- [ ] **Step 3: Run tests, commit** + +```bash +cargo test --package voltex_ecs -- json::tests -v +git add crates/voltex_ecs/src/json.rs crates/voltex_ecs/src/lib.rs +git commit -m "feat(ecs): add mini JSON writer and parser for scene serialization" +``` + +--- + +### Task 2: ComponentRegistry + +**Files:** +- Create: `crates/voltex_ecs/src/component_registry.rs` +- Modify: `crates/voltex_ecs/src/lib.rs` + +- [ ] **Step 1: Write tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::{World, Transform}; + use voltex_math::Vec3; + + #[test] + fn test_register_and_serialize() { + let mut registry = ComponentRegistry::new(); + registry.register_defaults(); + assert!(registry.find("transform").is_some()); + assert!(registry.find("tag").is_some()); + } + + #[test] + fn test_serialize_transform() { + let mut registry = ComponentRegistry::new(); + registry.register_defaults(); + let mut world = World::new(); + let e = world.spawn(); + world.add(e, Transform::from_position(Vec3::new(1.0, 2.0, 3.0))); + + let entry = registry.find("transform").unwrap(); + let data = (entry.serialize)(&world, e); + assert!(data.is_some()); + } + + #[test] + fn test_roundtrip_transform() { + let mut registry = ComponentRegistry::new(); + registry.register_defaults(); + let mut world = World::new(); + let e = world.spawn(); + world.add(e, Transform::from_position(Vec3::new(1.0, 2.0, 3.0))); + + let entry = registry.find("transform").unwrap(); + let data = (entry.serialize)(&world, e).unwrap(); + + let mut world2 = World::new(); + let e2 = world2.spawn(); + (entry.deserialize)(&mut world2, e2, &data).unwrap(); + + let t = world2.get::(e2).unwrap(); + assert!((t.position.x - 1.0).abs() < 1e-6); + } +} +``` + +- [ ] **Step 2: Implement ComponentRegistry** + +```rust +// crates/voltex_ecs/src/component_registry.rs +use crate::entity::Entity; +use crate::world::World; + +pub type SerializeFn = fn(&World, Entity) -> Option>; +pub type DeserializeFn = fn(&mut World, Entity, &[u8]) -> Result<(), String>; + +pub struct ComponentEntry { + pub name: String, + pub serialize: SerializeFn, + pub deserialize: DeserializeFn, +} + +pub struct ComponentRegistry { + entries: Vec, +} + +impl ComponentRegistry { + pub fn new() -> Self { Self { entries: Vec::new() } } + + pub fn register(&mut self, name: &str, ser: SerializeFn, deser: DeserializeFn) { + self.entries.push(ComponentEntry { + name: name.to_string(), serialize: ser, deserialize: deser, + }); + } + + pub fn find(&self, name: &str) -> Option<&ComponentEntry> { + self.entries.iter().find(|e| e.name == name) + } + + pub fn entries(&self) -> &[ComponentEntry] { &self.entries } + + pub fn register_defaults(&mut self) { + // Transform: serialize as 9 f32s (pos.xyz, rot.xyz, scale.xyz) + self.register("transform", serialize_transform, deserialize_transform); + // Tag: serialize as UTF-8 string bytes + self.register("tag", serialize_tag, deserialize_tag); + // Parent handled specially (entity reference) + } +} + +fn serialize_transform(world: &World, entity: Entity) -> Option> { + let t = world.get::(entity)?; + let mut data = Vec::with_capacity(36); + for &v in &[t.position.x, t.position.y, t.position.z, + t.rotation.x, t.rotation.y, t.rotation.z, + t.scale.x, t.scale.y, t.scale.z] { + data.extend_from_slice(&v.to_le_bytes()); + } + Some(data) +} + +fn deserialize_transform(world: &mut World, entity: Entity, data: &[u8]) -> Result<(), String> { + if data.len() < 36 { return Err("Transform data too short".into()); } + let f = |off: usize| f32::from_le_bytes([data[off], data[off+1], data[off+2], data[off+3]]); + let t = crate::Transform { + position: voltex_math::Vec3::new(f(0), f(4), f(8)), + rotation: voltex_math::Vec3::new(f(12), f(16), f(20)), + scale: voltex_math::Vec3::new(f(24), f(28), f(32)), + }; + world.add(entity, t); + Ok(()) +} + +fn serialize_tag(world: &World, entity: Entity) -> Option> { + let tag = world.get::(entity)?; + Some(tag.0.as_bytes().to_vec()) +} + +fn deserialize_tag(world: &mut World, entity: Entity, data: &[u8]) -> Result<(), String> { + let s = std::str::from_utf8(data).map_err(|_| "Invalid UTF-8 in tag")?; + world.add(entity, crate::scene::Tag(s.to_string())); + Ok(()) +} +``` + +- [ ] **Step 3: Run tests, commit** + +```bash +cargo test --package voltex_ecs -- component_registry -v +git add crates/voltex_ecs/src/component_registry.rs crates/voltex_ecs/src/lib.rs +git commit -m "feat(ecs): add ComponentRegistry for arbitrary component serialization" +``` + +--- + +### Task 3: JSON Scene Serialization + +**Files:** +- Modify: `crates/voltex_ecs/src/scene.rs` + +- [ ] **Step 1: Write tests** + +```rust +#[test] +fn test_json_roundtrip() { + let mut registry = ComponentRegistry::new(); + registry.register_defaults(); + 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("player".into())); + + let json = serialize_scene_json(&world, ®istry); + assert!(json.contains("\"version\":1")); + assert!(json.contains("\"player\"")); + + let mut world2 = World::new(); + let entities = deserialize_scene_json(&mut world2, &json, ®istry).unwrap(); + assert_eq!(entities.len(), 1); + let t = world2.get::(entities[0]).unwrap(); + assert!((t.position.x - 1.0).abs() < 1e-4); +} + +#[test] +fn test_json_with_parent() { + let mut registry = ComponentRegistry::new(); + registry.register_defaults(); + 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 json = serialize_scene_json(&world, ®istry); + let mut world2 = World::new(); + let entities = deserialize_scene_json(&mut world2, &json, ®istry).unwrap(); + assert!(world2.get::(entities[1]).is_some()); +} +``` + +- [ ] **Step 2: Implement serialize_scene_json and deserialize_scene_json** + +Use the json module's writer/parser. Serialize components via registry. +Format: `{"version":1,"entities":[{"parent":null_or_idx,"components":{"transform":"base64_data","tag":"base64_data",...}}]}` +Use base64 encoding for component binary data in JSON (reuse decoder pattern or simple hex encoding). + +- [ ] **Step 3: Run tests, commit** + +--- + +### Task 4: Binary Scene Format + +**Files:** +- Create: `crates/voltex_ecs/src/binary_scene.rs` +- Modify: `crates/voltex_ecs/src/lib.rs` + +- [ ] **Step 1: Write tests** + +```rust +#[test] +fn test_binary_roundtrip() { + let mut registry = ComponentRegistry::new(); + registry.register_defaults(); + let mut world = World::new(); + let e = world.spawn(); + world.add(e, Transform::from_position(Vec3::new(5.0, 0.0, -1.0))); + world.add(e, Tag("enemy".into())); + + let data = serialize_scene_binary(&world, ®istry); + assert_eq!(&data[0..4], b"VSCN"); + + let mut world2 = World::new(); + let entities = deserialize_scene_binary(&mut world2, &data, ®istry).unwrap(); + assert_eq!(entities.len(), 1); + let t = world2.get::(entities[0]).unwrap(); + assert!((t.position.x - 5.0).abs() < 1e-6); +} + +#[test] +fn test_binary_with_hierarchy() { + let mut registry = ComponentRegistry::new(); + registry.register_defaults(); + let mut world = World::new(); + let a = world.spawn(); + let b = world.spawn(); + world.add(a, Transform::new()); + world.add(b, Transform::new()); + add_child(&mut world, a, b); + + let data = serialize_scene_binary(&world, ®istry); + let mut world2 = World::new(); + let entities = deserialize_scene_binary(&mut world2, &data, ®istry).unwrap(); + assert!(world2.get::(entities[1]).is_some()); +} + +#[test] +fn test_binary_invalid_magic() { + let data = vec![0u8; 20]; + let mut world = World::new(); + let registry = ComponentRegistry::new(); + assert!(deserialize_scene_binary(&mut world, &data, ®istry).is_err()); +} +``` + +- [ ] **Step 2: Implement binary format** + +Header: `VSCN` + version(1) u32 LE + entity_count u32 LE +Per entity: parent_index i32 LE (-1 = no parent) + component_count u32 LE +Per component: name_len u16 LE + name bytes + data_len u32 LE + data bytes + +- [ ] **Step 3: Run tests, commit** + +--- + +### Task 5: Exports and Full Verification + +- [ ] **Step 1: Update lib.rs exports** + +```rust +pub mod json; +pub mod component_registry; +pub mod binary_scene; +pub use component_registry::ComponentRegistry; +pub use binary_scene::{serialize_scene_binary, deserialize_scene_binary}; +// scene.rs already exports serialize_scene, deserialize_scene +// Add: pub use scene::{serialize_scene_json, deserialize_scene_json}; +``` + +- [ ] **Step 2: Run full tests** + +```bash +cargo test --package voltex_ecs -v +cargo build --workspace +``` + +- [ ] **Step 3: Commit** + +```bash +git commit -m "feat(ecs): complete JSON and binary scene serialization with component registry" +``` diff --git a/docs/superpowers/plans/2026-03-25-phase3c-async-hotreload.md b/docs/superpowers/plans/2026-03-25-phase3c-async-hotreload.md new file mode 100644 index 0000000..00a52c7 --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-phase3c-async-hotreload.md @@ -0,0 +1,354 @@ +# Async Loading + Hot Reload 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:** Add background asset loading via worker thread and file-change-based hot reload to voltex_asset. + +**Architecture:** AssetLoader spawns one worker thread, communicates via channels. FileWatcher polls `std::fs::metadata` for mtime changes. Both are independent modules. + +**Tech Stack:** Pure Rust std library (threads, channels, fs). No external crates. + +--- + +### Task 1: FileWatcher (mtime polling) + +**Files:** +- Create: `crates/voltex_asset/src/watcher.rs` +- Modify: `crates/voltex_asset/src/lib.rs` + +- [ ] **Step 1: Write tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::io::Write; + + #[test] + fn test_watch_and_poll_no_changes() { + let mut watcher = FileWatcher::new(Duration::from_millis(0)); + let dir = std::env::temp_dir().join("voltex_watcher_test_1"); + let _ = fs::create_dir_all(&dir); + let path = dir.join("test.txt"); + fs::write(&path, "hello").unwrap(); + + watcher.watch(path.clone()); + // First poll — should not report as changed (just registered) + let changes = watcher.poll_changes(); + assert!(changes.is_empty()); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_detect_file_change() { + let dir = std::env::temp_dir().join("voltex_watcher_test_2"); + let _ = fs::create_dir_all(&dir); + let path = dir.join("test2.txt"); + fs::write(&path, "v1").unwrap(); + + let mut watcher = FileWatcher::new(Duration::from_millis(0)); + watcher.watch(path.clone()); + let _ = watcher.poll_changes(); // register initial mtime + + // Modify file + std::thread::sleep(Duration::from_millis(50)); + fs::write(&path, "v2 with more data").unwrap(); + + let changes = watcher.poll_changes(); + assert!(changes.contains(&path)); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_unwatch() { + let mut watcher = FileWatcher::new(Duration::from_millis(0)); + let path = PathBuf::from("/nonexistent/test.txt"); + watcher.watch(path.clone()); + watcher.unwatch(&path); + assert!(watcher.poll_changes().is_empty()); + } +} +``` + +- [ ] **Step 2: Implement FileWatcher** + +```rust +// crates/voltex_asset/src/watcher.rs +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant, SystemTime}; + +pub struct FileWatcher { + watched: HashMap>, + poll_interval: Duration, + last_poll: Instant, +} + +impl FileWatcher { + pub fn new(poll_interval: Duration) -> Self { + Self { + watched: HashMap::new(), + poll_interval, + last_poll: Instant::now(), + } + } + + pub fn watch(&mut self, path: PathBuf) { + let mtime = std::fs::metadata(&path).ok() + .and_then(|m| m.modified().ok()); + self.watched.insert(path, mtime); + } + + pub fn unwatch(&mut self, path: &Path) { + self.watched.remove(path); + } + + pub fn poll_changes(&mut self) -> Vec { + let now = Instant::now(); + if now.duration_since(self.last_poll) < self.poll_interval { + return Vec::new(); + } + self.last_poll = now; + + let mut changed = Vec::new(); + for (path, last_mtime) in &mut self.watched { + let current = std::fs::metadata(path).ok() + .and_then(|m| m.modified().ok()); + if current != *last_mtime && last_mtime.is_some() { + changed.push(path.clone()); + } + *last_mtime = current; + } + changed + } + + pub fn watched_count(&self) -> usize { + self.watched.len() + } +} +``` + +- [ ] **Step 3: Run tests, commit** + +```bash +cargo test --package voltex_asset -- watcher::tests -v +git add crates/voltex_asset/src/watcher.rs crates/voltex_asset/src/lib.rs +git commit -m "feat(asset): add FileWatcher with mtime-based change detection" +``` + +--- + +### Task 2: AssetLoader (background thread) + +**Files:** +- Create: `crates/voltex_asset/src/loader.rs` +- Modify: `crates/voltex_asset/src/lib.rs` + +- [ ] **Step 1: Write tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn test_load_state_initial() { + let mut loader = AssetLoader::new(); + let dir = std::env::temp_dir().join("voltex_loader_test_1"); + let _ = fs::create_dir_all(&dir); + let path = dir.join("test.txt"); + fs::write(&path, "hello world").unwrap(); + + let handle: Handle = loader.load( + path.clone(), + |data| Ok(String::from_utf8_lossy(data).to_string()), + ); + + // Initially loading + assert!(matches!(loader.state::(&handle), LoadState::Loading)); + + let _ = fs::remove_dir_all(&dir); + loader.shutdown(); + } + + #[test] + fn test_load_and_process() { + let mut loader = AssetLoader::new(); + let dir = std::env::temp_dir().join("voltex_loader_test_2"); + let _ = fs::create_dir_all(&dir); + let path = dir.join("data.txt"); + fs::write(&path, "content123").unwrap(); + + let handle: Handle = loader.load( + path.clone(), + |data| Ok(String::from_utf8_lossy(data).to_string()), + ); + + // Wait for worker + std::thread::sleep(Duration::from_millis(200)); + + let mut assets = Assets::new(); + loader.process_loaded(&mut assets); + + assert!(matches!(loader.state::(&handle), LoadState::Ready)); + let val = assets.get(handle).unwrap(); + assert_eq!(val, "content123"); + + let _ = fs::remove_dir_all(&dir); + loader.shutdown(); + } + + #[test] + fn test_load_nonexistent_fails() { + let mut loader = AssetLoader::new(); + let handle: Handle = loader.load( + PathBuf::from("/nonexistent/file.txt"), + |data| Ok(String::from_utf8_lossy(data).to_string()), + ); + + std::thread::sleep(Duration::from_millis(200)); + + let mut assets = Assets::new(); + loader.process_loaded(&mut assets); + + assert!(matches!(loader.state::(&handle), LoadState::Failed(_))); + loader.shutdown(); + } +} +``` + +- [ ] **Step 2: Implement AssetLoader** + +Key design: +- Worker thread reads files from disk via channel +- `LoadRequest` contains path + parse function (boxed) +- `LoadResult` contains handle id + parsed asset (boxed Any) or error +- `process_loaded` drains results channel and inserts into Assets +- Handle is pre-allocated with a placeholder in a pending map +- `state()` checks pending map for Loading/Failed, or assets for Ready + +```rust +use std::any::Any; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::mpsc::{channel, Sender, Receiver}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; +use crate::handle::Handle; +use crate::assets::Assets; + +pub enum LoadState { + Loading, + Ready, + Failed(String), +} + +struct LoadRequest { + id: u64, + path: PathBuf, + parse: Box Result, String> + Send>, +} + +struct LoadResult { + id: u64, + result: Result, String>, +} + +pub struct AssetLoader { + sender: Sender, + receiver: Receiver, + thread: Option>, + next_id: u64, + pending: HashMap, +} + +struct PendingEntry { + state: LoadState, + handle_id: u32, + handle_gen: u32, + type_id: std::any::TypeId, +} +``` + +- [ ] **Step 3: Run tests, commit** + +```bash +cargo test --package voltex_asset -- loader::tests -v +git add crates/voltex_asset/src/loader.rs crates/voltex_asset/src/lib.rs +git commit -m "feat(asset): add AssetLoader with background thread loading" +``` + +--- + +### Task 3: Storage replace_in_place for Hot Reload + +**Files:** +- Modify: `crates/voltex_asset/src/storage.rs` + +- [ ] **Step 1: Write test** + +```rust +#[test] +fn replace_in_place() { + let mut storage: AssetStorage = AssetStorage::new(); + let h = storage.insert(Mesh { verts: 3 }); + storage.replace_in_place(h, Mesh { verts: 99 }); + assert_eq!(storage.get(h).unwrap().verts, 99); + // Same handle still works — generation unchanged +} +``` + +- [ ] **Step 2: Implement replace_in_place** + +```rust +/// Replace the asset data without changing generation or ref_count. +/// Used for hot reload — existing handles remain valid. +pub fn replace_in_place(&mut self, handle: Handle, new_asset: T) -> bool { + if let Some(Some(entry)) = self.entries.get_mut(handle.id as usize) { + if entry.generation == handle.generation { + entry.asset = new_asset; + return true; + } + } + false +} +``` + +- [ ] **Step 3: Run tests, commit** + +```bash +cargo test --package voltex_asset -- storage::tests -v +git add crates/voltex_asset/src/storage.rs +git commit -m "feat(asset): add replace_in_place for hot reload support" +``` + +--- + +### Task 4: Exports and Full Verification + +- [ ] **Step 1: Update lib.rs** + +```rust +pub mod watcher; +pub mod loader; +pub use watcher::FileWatcher; +pub use loader::{AssetLoader, LoadState}; +``` + +- [ ] **Step 2: Run full tests** + +```bash +cargo test --package voltex_asset -v +cargo build --workspace +cargo test --workspace +``` + +- [ ] **Step 3: Commit** + +```bash +git commit -m "feat(asset): complete async loading and hot reload support" +``` diff --git a/docs/superpowers/plans/2026-03-25-phase4a-pbr-textures.md b/docs/superpowers/plans/2026-03-25-phase4a-pbr-textures.md new file mode 100644 index 0000000..e83a6a3 --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-phase4a-pbr-textures.md @@ -0,0 +1,294 @@ +# PBR Texture Maps 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:** Add metallic/roughness/AO (ORM) and emissive texture map sampling to PBR shaders, extending bind group 1 from 4 to 8 bindings. + +**Architecture:** Extend `pbr_texture_bind_group_layout` with 4 new bindings (ORM texture+sampler, emissive texture+sampler). Update forward PBR shader and deferred G-Buffer shader. Default 1x1 white/black textures when maps not provided. MaterialUniform values become multipliers for texture values. + +**Tech Stack:** wgpu 28.0, WGSL shaders. No new Rust crates. + +--- + +### Task 1: Texture Utility Additions + +**Files:** +- Modify: `crates/voltex_renderer/src/texture.rs` + +- [ ] **Step 1: Write test for black_1x1** + +```rust +#[test] +fn test_black_1x1_exists() { + // This is a compile/API test — GPU tests need device + // We verify the function signature exists and the module compiles +} +``` + +- [ ] **Step 2: Add black_1x1 and extended layout functions** + +Add to `texture.rs`: + +```rust +/// 1x1 black texture for emissive default (no emission). +pub fn black_1x1( + device: &wgpu::Device, + queue: &wgpu::Queue, + layout: &wgpu::BindGroupLayout, +) -> GpuTexture { + Self::from_rgba(device, queue, 1, 1, &[0, 0, 0, 255], layout) +} + +/// Extended PBR texture layout: albedo + normal + ORM + emissive (8 bindings). +pub fn pbr_full_texture_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("PBR Full Texture Bind Group Layout"), + entries: &[ + // 0-1: albedo (existing) + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + // 2-3: normal map (existing) + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + // 4-5: ORM (AO/Roughness/Metallic) — NEW + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 5, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + // 6-7: Emissive — NEW + wgpu::BindGroupLayoutEntry { + binding: 6, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 7, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }) +} + +/// Create bind group for full PBR textures (albedo + normal + ORM + emissive). +pub fn create_pbr_full_texture_bind_group( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + albedo_view: &wgpu::TextureView, + albedo_sampler: &wgpu::Sampler, + normal_view: &wgpu::TextureView, + normal_sampler: &wgpu::Sampler, + orm_view: &wgpu::TextureView, + orm_sampler: &wgpu::Sampler, + emissive_view: &wgpu::TextureView, + emissive_sampler: &wgpu::Sampler, +) -> wgpu::BindGroup { + // ... 8 entries at bindings 0-7 +} +``` + +- [ ] **Step 3: Add exports to lib.rs** + +```rust +pub use texture::{pbr_full_texture_bind_group_layout, create_pbr_full_texture_bind_group}; +``` + +- [ ] **Step 4: Commit** + +```bash +git add crates/voltex_renderer/src/texture.rs crates/voltex_renderer/src/lib.rs +git commit -m "feat(renderer): add ORM and emissive texture bind group layout" +``` + +--- + +### Task 2: Update PBR Forward Shader + +**Files:** +- Modify: `crates/voltex_renderer/src/pbr_shader.wgsl` + +- [ ] **Step 1: Add ORM and emissive bindings** + +Add after existing normal map bindings: + +```wgsl +@group(1) @binding(4) var t_orm: texture_2d; +@group(1) @binding(5) var s_orm: sampler; +@group(1) @binding(6) var t_emissive: texture_2d; +@group(1) @binding(7) var s_emissive: sampler; +``` + +- [ ] **Step 2: Update fragment shader to sample ORM** + +Replace lines that read material params directly: + +```wgsl +// Before: +// let metallic = material.metallic; +// let roughness = material.roughness; +// let ao = material.ao; + +// After: +let orm_sample = textureSample(t_orm, s_orm, in.uv); +let ao = orm_sample.r * material.ao; +let roughness = orm_sample.g * material.roughness; +let metallic = orm_sample.b * material.metallic; +``` + +- [ ] **Step 3: Add emissive to final color** + +```wgsl +let emissive = textureSample(t_emissive, s_emissive, in.uv).rgb; + +// Before tone mapping: +var color = ambient + Lo + emissive; +``` + +- [ ] **Step 4: Verify existing examples still compile** + +The existing examples use `pbr_texture_bind_group_layout` (4 bindings). They should continue to work with the OLD layout — the new `pbr_full_texture_bind_group_layout` is a separate function. The shader needs to match whichever layout is used. + +**Strategy:** Create a NEW shader variant `pbr_shader_full.wgsl` with ORM+emissive support, keeping the old shader untouched for backward compatibility. OR add a preprocessor approach. + +**Simpler approach:** Just update `pbr_shader.wgsl` to include the new bindings AND update ALL examples that use it to use the full layout with default white/black textures. This avoids shader duplication. + +- [ ] **Step 5: Commit** + +```bash +git add crates/voltex_renderer/src/pbr_shader.wgsl +git commit -m "feat(renderer): add ORM and emissive texture sampling to PBR shader" +``` + +--- + +### Task 3: Update Deferred G-Buffer Shader + +**Files:** +- Modify: `crates/voltex_renderer/src/deferred_gbuffer.wgsl` +- Modify: `crates/voltex_renderer/src/deferred_lighting.wgsl` + +- [ ] **Step 1: Add ORM/emissive bindings to G-Buffer shader** + +Same pattern as forward shader — add bindings 4-7, sample ORM for metallic/roughness/ao, store in material_data output. + +- [ ] **Step 2: Store emissive in G-Buffer** + +Option: Pack emissive into existing G-Buffer outputs or add a 5th MRT. + +**Recommended:** Store emissive in material_data.a (was padding=1.0). Simple, no extra MRT. + +```wgsl +// G-Buffer output location(3): [metallic, roughness, ao, emissive_intensity] +// Emissive color stored as luminance for simplicity, or use location(2).a +out.material_data = vec4(metallic, roughness, ao, emissive_luminance); +``` + +Actually simpler: add emissive directly in the lighting pass as a texture sample pass-through. But that requires the emissive texture in the lighting pass too. + +**Simplest approach:** Write emissive to albedo output additively, since emissive bypasses lighting. + +```wgsl +// In deferred_gbuffer.wgsl: +let emissive = textureSample(t_emissive, s_emissive, in.uv).rgb; +// Store emissive luminance in material_data.w (was 1.0 padding) +let emissive_lum = dot(emissive, vec3(0.299, 0.587, 0.114)); +out.material_data = vec4(metallic, roughness, ao, emissive_lum); +``` + +Then in `deferred_lighting.wgsl`, read `material_data.w` as emissive intensity and add `albedo * emissive_lum` to final color. + +- [ ] **Step 3: Update deferred_lighting.wgsl** + +```wgsl +let emissive_lum = textureSample(g_material, s_gbuffer, uv).w; +// After lighting calculation: +color += albedo * emissive_lum; +``` + +- [ ] **Step 4: Commit** + +```bash +git add crates/voltex_renderer/src/deferred_gbuffer.wgsl crates/voltex_renderer/src/deferred_lighting.wgsl +git commit -m "feat(renderer): add ORM and emissive to deferred rendering pipeline" +``` + +--- + +### Task 4: Update Examples + Pipeline Creation + +**Files:** +- Modify: `crates/voltex_renderer/src/pbr_pipeline.rs` +- Modify: `examples/pbr_demo/src/main.rs` +- Modify: `examples/ibl_demo/src/main.rs` +- Modify: `crates/voltex_renderer/src/deferred_pipeline.rs` + +- [ ] **Step 1: Update examples to use full texture layout** + +In each example that uses `pbr_texture_bind_group_layout`: +- Switch to `pbr_full_texture_bind_group_layout` +- Create default ORM texture (white_1x1 = ao=1, roughness=1, metallic=1 — but material multipliers control actual values) +- Create default emissive texture (black_1x1 = no emission) +- Pass all 4 texture pairs to `create_pbr_full_texture_bind_group` + +- [ ] **Step 2: Update deferred_pipeline.rs** + +Update gbuffer pipeline layout to use the full texture layout. + +- [ ] **Step 3: Build and test** + +```bash +cargo build --workspace +cargo test --workspace +``` + +- [ ] **Step 4: Commit** + +```bash +git commit -m "feat(renderer): update examples and pipelines for full PBR texture maps" +```