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:
2026-03-25 20:24:19 +09:00
parent 389cbdb063
commit c478e2433d
3 changed files with 1054 additions and 0 deletions

View 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, &registry);
assert!(json.contains("\"version\":1"));
assert!(json.contains("\"player\""));
let mut world2 = World::new();
let entities = deserialize_scene_json(&mut world2, &json, &registry).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, &registry);
let mut world2 = World::new();
let entities = deserialize_scene_json(&mut world2, &json, &registry).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, &registry);
assert_eq!(&data[0..4], b"VSCN");
let mut world2 = World::new();
let entities = deserialize_scene_binary(&mut world2, &data, &registry).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, &registry);
let mut world2 = World::new();
let entities = deserialize_scene_binary(&mut world2, &data, &registry).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, &registry).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"
```

View 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"
```

View 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"
```