docs: add implementation plans for scene serialization, async loading, PBR textures
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
406
docs/superpowers/plans/2026-03-25-phase3b-scene-serialization.md
Normal file
406
docs/superpowers/plans/2026-03-25-phase3b-scene-serialization.md
Normal file
@@ -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<JsonVal, String>` — 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::<Transform>(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<Vec<u8>>;
|
||||
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<ComponentEntry>,
|
||||
}
|
||||
|
||||
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<Vec<u8>> {
|
||||
let t = world.get::<crate::Transform>(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<Vec<u8>> {
|
||||
let tag = world.get::<crate::scene::Tag>(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::<Transform>(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::<Parent>(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::<Transform>(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::<Parent>(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"
|
||||
```
|
||||
354
docs/superpowers/plans/2026-03-25-phase3c-async-hotreload.md
Normal file
354
docs/superpowers/plans/2026-03-25-phase3c-async-hotreload.md
Normal file
@@ -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<PathBuf, Option<SystemTime>>,
|
||||
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<PathBuf> {
|
||||
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<String> = loader.load(
|
||||
path.clone(),
|
||||
|data| Ok(String::from_utf8_lossy(data).to_string()),
|
||||
);
|
||||
|
||||
// Initially loading
|
||||
assert!(matches!(loader.state::<String>(&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<String> = 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::<String>(&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<String> = 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::<String>(&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<dyn FnOnce(&[u8]) -> Result<Box<dyn Any + Send>, String> + Send>,
|
||||
}
|
||||
|
||||
struct LoadResult {
|
||||
id: u64,
|
||||
result: Result<Box<dyn Any + Send>, String>,
|
||||
}
|
||||
|
||||
pub struct AssetLoader {
|
||||
sender: Sender<LoadRequest>,
|
||||
receiver: Receiver<LoadResult>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
next_id: u64,
|
||||
pending: HashMap<u64, PendingEntry>,
|
||||
}
|
||||
|
||||
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<Mesh> = 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<T>, 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"
|
||||
```
|
||||
294
docs/superpowers/plans/2026-03-25-phase4a-pbr-textures.md
Normal file
294
docs/superpowers/plans/2026-03-25-phase4a-pbr-textures.md
Normal file
@@ -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<f32>;
|
||||
@group(1) @binding(5) var s_orm: sampler;
|
||||
@group(1) @binding(6) var t_emissive: texture_2d<f32>;
|
||||
@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<f32>(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<f32>(0.299, 0.587, 0.114));
|
||||
out.material_data = vec4<f32>(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"
|
||||
```
|
||||
Reference in New Issue
Block a user