Compare commits

..

7 Commits

Author SHA1 Message Date
ea9667889c docs: update STATUS.md and DEFERRED.md - nearly all deferred items complete
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:31:05 +09:00
6b6d581b71 feat(renderer): add soft shadows, BLAS tracker, RT fallback, light probes, light volumes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:28:37 +09:00
be290bd6e0 feat(audio): add async loader, effect chain
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:28:31 +09:00
a50f79e4fc docs: update STATUS.md and DEFERRED.md with G-Buffer compression, stencil, half-res SSGI, bilateral bloom, fade curves, dynamic groups, audio bus
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:24:51 +09:00
aafebff478 feat(audio): add fade curves, dynamic mix groups, audio bus routing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:23:48 +09:00
bc2880d41c feat(renderer): add stencil optimization, half-res SSGI, bilateral bloom
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:21:44 +09:00
025bf4d0b9 feat(renderer): add G-Buffer compression with octahedral normals and depth reconstruction
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:21:05 +09:00
18 changed files with 991 additions and 17 deletions

View File

@@ -0,0 +1,91 @@
use std::sync::{Arc, Mutex};
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq)]
pub enum LoadState {
Pending,
Loading,
Loaded,
Error(String),
}
pub struct AsyncAudioLoader {
pending: Arc<Mutex<Vec<(u32, PathBuf, LoadState)>>>,
}
impl AsyncAudioLoader {
pub fn new() -> Self {
AsyncAudioLoader { pending: Arc::new(Mutex::new(Vec::new())) }
}
/// Queue a clip for async loading. Returns immediately.
pub fn load(&self, clip_id: u32, path: PathBuf) {
let mut pending = self.pending.lock().unwrap();
pending.push((clip_id, path.clone(), LoadState::Pending));
let pending_clone = Arc::clone(&self.pending);
std::thread::spawn(move || {
// Mark loading
{ let mut p = pending_clone.lock().unwrap();
if let Some(entry) = p.iter_mut().find(|(id, _, _)| *id == clip_id) {
entry.2 = LoadState::Loading;
}
}
// Simulate load (read file)
match std::fs::read(&path) {
Ok(_data) => {
let mut p = pending_clone.lock().unwrap();
if let Some(entry) = p.iter_mut().find(|(id, _, _)| *id == clip_id) {
entry.2 = LoadState::Loaded;
}
}
Err(e) => {
let mut p = pending_clone.lock().unwrap();
if let Some(entry) = p.iter_mut().find(|(id, _, _)| *id == clip_id) {
entry.2 = LoadState::Error(e.to_string());
}
}
}
});
}
pub fn state(&self, clip_id: u32) -> LoadState {
let pending = self.pending.lock().unwrap();
pending.iter().find(|(id, _, _)| *id == clip_id)
.map(|(_, _, s)| s.clone())
.unwrap_or(LoadState::Error("not found".to_string()))
}
pub fn poll_completed(&self) -> Vec<u32> {
let pending = self.pending.lock().unwrap();
pending.iter().filter(|(_, _, s)| *s == LoadState::Loaded).map(|(id, _, _)| *id).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new() {
let loader = AsyncAudioLoader::new();
assert!(loader.poll_completed().is_empty());
}
#[test]
fn test_load_nonexistent() {
let loader = AsyncAudioLoader::new();
loader.load(1, PathBuf::from("/nonexistent/path.wav"));
std::thread::sleep(std::time::Duration::from_millis(100));
let state = loader.state(1);
assert!(matches!(state, LoadState::Error(_)));
}
#[test]
fn test_load_existing() {
let dir = std::env::temp_dir().join("voltex_async_test");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(dir.join("test.wav"), b"RIFF").unwrap();
let loader = AsyncAudioLoader::new();
loader.load(42, dir.join("test.wav"));
std::thread::sleep(std::time::Duration::from_millis(200));
assert_eq!(loader.state(42), LoadState::Loaded);
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@@ -0,0 +1,82 @@
/// Audio bus: mixes multiple input signals.
pub struct AudioBus {
pub inputs: Vec<BusInput>,
pub output_gain: f32,
}
#[derive(Debug, Clone)]
pub struct BusInput {
pub source_id: u32,
pub gain: f32,
}
impl AudioBus {
pub fn new() -> Self {
AudioBus { inputs: Vec::new(), output_gain: 1.0 }
}
pub fn add_input(&mut self, source_id: u32, gain: f32) {
self.inputs.push(BusInput { source_id, gain });
}
pub fn remove_input(&mut self, source_id: u32) {
self.inputs.retain(|i| i.source_id != source_id);
}
/// Mix samples from all inputs. Each input provides a sample value.
pub fn mix(&self, samples: &[(u32, f32)]) -> f32 {
let mut sum = 0.0;
for input in &self.inputs {
if let Some((_, sample)) = samples.iter().find(|(id, _)| *id == input.source_id) {
sum += sample * input.gain;
}
}
sum * self.output_gain
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_bus() {
let bus = AudioBus::new();
assert!((bus.mix(&[]) - 0.0).abs() < 1e-6);
}
#[test]
fn test_single_input() {
let mut bus = AudioBus::new();
bus.add_input(1, 0.5);
let out = bus.mix(&[(1, 1.0)]);
assert!((out - 0.5).abs() < 1e-6);
}
#[test]
fn test_multiple_inputs() {
let mut bus = AudioBus::new();
bus.add_input(1, 1.0);
bus.add_input(2, 1.0);
let out = bus.mix(&[(1, 0.3), (2, 0.5)]);
assert!((out - 0.8).abs() < 1e-6);
}
#[test]
fn test_remove_input() {
let mut bus = AudioBus::new();
bus.add_input(1, 1.0);
bus.add_input(2, 1.0);
bus.remove_input(1);
assert_eq!(bus.inputs.len(), 1);
}
#[test]
fn test_output_gain() {
let mut bus = AudioBus::new();
bus.output_gain = 0.5;
bus.add_input(1, 1.0);
let out = bus.mix(&[(1, 1.0)]);
assert!((out - 0.5).abs() < 1e-6);
}
}

View File

@@ -0,0 +1,101 @@
use std::collections::HashMap;
/// Dynamic audio mix group system.
pub struct MixGroupManager {
groups: HashMap<String, MixGroupConfig>,
}
#[derive(Debug, Clone)]
pub struct MixGroupConfig {
pub name: String,
pub volume: f32,
pub muted: bool,
pub parent: Option<String>,
}
impl MixGroupManager {
pub fn new() -> Self {
let mut mgr = MixGroupManager { groups: HashMap::new() };
mgr.add_group("Master", None);
mgr
}
pub fn add_group(&mut self, name: &str, parent: Option<&str>) -> bool {
if self.groups.contains_key(name) { return false; }
if let Some(p) = parent {
if !self.groups.contains_key(p) { return false; }
}
self.groups.insert(name.to_string(), MixGroupConfig {
name: name.to_string(),
volume: 1.0,
muted: false,
parent: parent.map(|s| s.to_string()),
});
true
}
pub fn remove_group(&mut self, name: &str) -> bool {
if name == "Master" { return false; } // can't remove master
self.groups.remove(name).is_some()
}
pub fn set_volume(&mut self, name: &str, volume: f32) {
if let Some(g) = self.groups.get_mut(name) { g.volume = volume.clamp(0.0, 1.0); }
}
pub fn effective_volume(&self, name: &str) -> f32 {
let mut vol = 1.0;
let mut current = name;
for _ in 0..10 { // max depth to prevent infinite loops
if let Some(g) = self.groups.get(current) {
if g.muted { return 0.0; }
vol *= g.volume;
if let Some(ref p) = g.parent { current = p; } else { break; }
} else { break; }
}
vol
}
pub fn group_count(&self) -> usize { self.groups.len() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_has_master() {
let mgr = MixGroupManager::new();
assert_eq!(mgr.group_count(), 1);
assert!((mgr.effective_volume("Master") - 1.0).abs() < 1e-6);
}
#[test]
fn test_add_group() {
let mut mgr = MixGroupManager::new();
assert!(mgr.add_group("SFX", Some("Master")));
assert_eq!(mgr.group_count(), 2);
}
#[test]
fn test_effective_volume_chain() {
let mut mgr = MixGroupManager::new();
mgr.set_volume("Master", 0.8);
mgr.add_group("SFX", Some("Master"));
mgr.set_volume("SFX", 0.5);
assert!((mgr.effective_volume("SFX") - 0.4).abs() < 1e-6); // 0.5 * 0.8
}
#[test]
fn test_cant_remove_master() {
let mut mgr = MixGroupManager::new();
assert!(!mgr.remove_group("Master"));
}
#[test]
fn test_add_duplicate_fails() {
let mut mgr = MixGroupManager::new();
mgr.add_group("SFX", Some("Master"));
assert!(!mgr.add_group("SFX", Some("Master")));
}
}

View File

@@ -0,0 +1,62 @@
/// Trait for audio effects.
pub trait AudioEffect {
fn process(&mut self, sample: f32) -> f32;
fn name(&self) -> &str;
}
/// Chain of audio effects processed in order.
pub struct EffectChain {
effects: Vec<Box<dyn AudioEffect>>,
pub bypass: bool,
}
impl EffectChain {
pub fn new() -> Self { EffectChain { effects: Vec::new(), bypass: false } }
pub fn add(&mut self, effect: Box<dyn AudioEffect>) { self.effects.push(effect); }
pub fn remove(&mut self, index: usize) { if index < self.effects.len() { self.effects.remove(index); } }
pub fn len(&self) -> usize { self.effects.len() }
pub fn is_empty(&self) -> bool { self.effects.is_empty() }
pub fn process(&mut self, sample: f32) -> f32 {
if self.bypass { return sample; }
let mut s = sample;
for effect in &mut self.effects {
s = effect.process(s);
}
s
}
}
// Simple gain effect for testing
pub struct GainEffect { pub gain: f32 }
impl AudioEffect for GainEffect {
fn process(&mut self, sample: f32) -> f32 { sample * self.gain }
fn name(&self) -> &str { "Gain" }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_chain() { let mut c = EffectChain::new(); assert!((c.process(1.0) - 1.0).abs() < 1e-6); }
#[test]
fn test_single_effect() {
let mut c = EffectChain::new();
c.add(Box::new(GainEffect { gain: 0.5 }));
assert!((c.process(1.0) - 0.5).abs() < 1e-6);
}
#[test]
fn test_chain_order() {
let mut c = EffectChain::new();
c.add(Box::new(GainEffect { gain: 0.5 }));
c.add(Box::new(GainEffect { gain: 0.5 }));
assert!((c.process(1.0) - 0.25).abs() < 1e-6);
}
#[test]
fn test_bypass() {
let mut c = EffectChain::new();
c.add(Box::new(GainEffect { gain: 0.0 }));
c.bypass = true;
assert!((c.process(1.0) - 1.0).abs() < 1e-6);
}
}

View File

@@ -0,0 +1,58 @@
/// Fade curve types.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum FadeCurve {
Linear,
Exponential,
Logarithmic,
SCurve,
}
/// Apply fade curve to a normalized parameter t (0.0 to 1.0).
pub fn apply_fade(t: f32, curve: FadeCurve) -> f32 {
let t = t.clamp(0.0, 1.0);
match curve {
FadeCurve::Linear => t,
FadeCurve::Exponential => t * t,
FadeCurve::Logarithmic => t.sqrt(),
FadeCurve::SCurve => {
// Smoothstep: 3t^2 - 2t^3
t * t * (3.0 - 2.0 * t)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_linear() {
assert!((apply_fade(0.5, FadeCurve::Linear) - 0.5).abs() < 1e-6);
}
#[test]
fn test_exponential() {
assert!((apply_fade(0.5, FadeCurve::Exponential) - 0.25).abs() < 1e-6);
assert!(apply_fade(0.5, FadeCurve::Exponential) < 0.5); // slower start
}
#[test]
fn test_logarithmic() {
assert!(apply_fade(0.5, FadeCurve::Logarithmic) > 0.5); // faster start
}
#[test]
fn test_scurve() {
assert!((apply_fade(0.0, FadeCurve::SCurve) - 0.0).abs() < 1e-6);
assert!((apply_fade(1.0, FadeCurve::SCurve) - 1.0).abs() < 1e-6);
assert!((apply_fade(0.5, FadeCurve::SCurve) - 0.5).abs() < 1e-6); // midpoint same
}
#[test]
fn test_endpoints() {
for curve in [FadeCurve::Linear, FadeCurve::Exponential, FadeCurve::Logarithmic, FadeCurve::SCurve] {
assert!((apply_fade(0.0, curve) - 0.0).abs() < 1e-6);
assert!((apply_fade(1.0, curve) - 1.0).abs() < 1e-6);
}
}
}

View File

@@ -20,3 +20,13 @@ pub use spatial::{Listener, SpatialParams, distance_attenuation, stereo_pan, com
pub use mix_group::{MixGroup, MixerState}; pub use mix_group::{MixGroup, MixerState};
pub use reverb::{Reverb, Echo, DelayLine}; pub use reverb::{Reverb, Echo, DelayLine};
pub use occlusion::{OcclusionResult, LowPassFilter, calculate_occlusion}; pub use occlusion::{OcclusionResult, LowPassFilter, calculate_occlusion};
pub mod fade_curves;
pub use fade_curves::{FadeCurve, apply_fade};
pub mod dynamic_groups;
pub use dynamic_groups::MixGroupManager;
pub mod audio_bus;
pub use audio_bus::AudioBus;
pub mod async_loader;
pub use async_loader::AsyncAudioLoader;
pub mod effect_chain;
pub use effect_chain::{AudioEffect, EffectChain};

View File

@@ -0,0 +1,52 @@
/// Bilateral bloom weight: attenuates blur across brightness edges.
pub fn bilateral_bloom_weight(
center_luminance: f32,
sample_luminance: f32,
spatial_weight: f32,
sigma_luminance: f32,
) -> f32 {
let lum_diff = (center_luminance - sample_luminance).abs();
let lum_weight = (-lum_diff * lum_diff / (2.0 * sigma_luminance * sigma_luminance)).exp();
spatial_weight * lum_weight
}
/// Calculate luminance from RGB.
pub fn luminance(r: f32, g: f32, b: f32) -> f32 {
0.2126 * r + 0.7152 * g + 0.0722 * b
}
/// 5-tap Gaussian weights for 1D bloom blur.
pub fn gaussian_5tap() -> [f32; 5] {
// Sigma ≈ 1.4
[0.0625, 0.25, 0.375, 0.25, 0.0625]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bilateral_bloom_same_luminance() {
let w = bilateral_bloom_weight(0.5, 0.5, 1.0, 0.1);
assert!((w - 1.0).abs() < 0.01); // same lum → full weight
}
#[test]
fn test_bilateral_bloom_edge() {
let w = bilateral_bloom_weight(0.1, 0.9, 1.0, 0.1);
assert!(w < 0.01); // large lum diff → near zero
}
#[test]
fn test_luminance_white() {
let l = luminance(1.0, 1.0, 1.0);
assert!((l - 1.0).abs() < 0.01);
}
#[test]
fn test_gaussian_5tap_sum() {
let g = gaussian_5tap();
let sum: f32 = g.iter().sum();
assert!((sum - 1.0).abs() < 0.01);
}
}

View File

@@ -0,0 +1,34 @@
/// Tracks which meshes need BLAS rebuild.
pub struct BlasTracker {
dirty: Vec<(u32, bool)>, // (mesh_id, needs_rebuild)
}
impl BlasTracker {
pub fn new() -> Self { BlasTracker { dirty: Vec::new() } }
pub fn register(&mut self, mesh_id: u32) { self.dirty.push((mesh_id, false)); }
pub fn mark_dirty(&mut self, mesh_id: u32) {
if let Some(entry) = self.dirty.iter_mut().find(|(id, _)| *id == mesh_id) { entry.1 = true; }
}
pub fn dirty_meshes(&self) -> Vec<u32> { self.dirty.iter().filter(|(_, d)| *d).map(|(id, _)| *id).collect() }
pub fn clear_dirty(&mut self) { for entry in &mut self.dirty { entry.1 = false; } }
pub fn mesh_count(&self) -> usize { self.dirty.len() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_register_and_dirty() {
let mut t = BlasTracker::new();
t.register(1); t.register(2);
t.mark_dirty(1);
assert_eq!(t.dirty_meshes(), vec![1]);
}
#[test]
fn test_clear_dirty() {
let mut t = BlasTracker::new();
t.register(1); t.mark_dirty(1);
t.clear_dirty();
assert!(t.dirty_meshes().is_empty());
}
}

View File

@@ -0,0 +1,132 @@
/// Octahedral normal encoding: vec3 normal → vec2 (compact).
pub fn encode_octahedral(n: [f32; 3]) -> [f32; 2] {
let sum = n[0].abs() + n[1].abs() + n[2].abs();
let mut oct = [n[0] / sum, n[1] / sum];
if n[2] < 0.0 {
let ox = oct[0];
let oy = oct[1];
oct[0] = (1.0 - oy.abs()) * if ox >= 0.0 { 1.0 } else { -1.0 };
oct[1] = (1.0 - ox.abs()) * if oy >= 0.0 { 1.0 } else { -1.0 };
}
oct
}
/// Decode octahedral back to normal vec3.
pub fn decode_octahedral(oct: [f32; 2]) -> [f32; 3] {
let mut n = [oct[0], oct[1], 1.0 - oct[0].abs() - oct[1].abs()];
if n[2] < 0.0 {
let ox = n[0];
let oy = n[1];
n[0] = (1.0 - oy.abs()) * if ox >= 0.0 { 1.0 } else { -1.0 };
n[1] = (1.0 - ox.abs()) * if oy >= 0.0 { 1.0 } else { -1.0 };
}
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
[n[0] / len, n[1] / len, n[2] / len]
}
/// Reconstruct world position from depth + UV + inverse view-projection matrix.
pub fn reconstruct_position(
uv: [f32; 2],
depth: f32,
inv_view_proj: &[[f32; 4]; 4],
) -> [f32; 3] {
let ndc_x = uv[0] * 2.0 - 1.0;
let ndc_y = 1.0 - uv[1] * 2.0;
let clip = [ndc_x, ndc_y, depth, 1.0];
// Matrix multiply: inv_view_proj * clip (column-major)
let mut world = [0.0f32; 4];
for i in 0..4 {
for j in 0..4 {
world[i] += inv_view_proj[j][i] * clip[j];
}
}
if world[3].abs() > 1e-8 {
[
world[0] / world[3],
world[1] / world[3],
world[2] / world[3],
]
} else {
[0.0; 3]
}
}
/// Compressed G-Buffer format recommendations.
pub const COMPRESSED_NORMAL_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rg16Float;
pub const COMPRESSED_ALBEDO_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
pub const COMPRESSED_MATERIAL_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
// Position: reconstructed from depth, no texture needed
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_octahedral_roundtrip_positive_z() {
let n = [0.0, 0.0, 1.0];
let enc = encode_octahedral(n);
let dec = decode_octahedral(enc);
for i in 0..3 {
assert!(
(n[i] - dec[i]).abs() < 0.01,
"axis {}: {} vs {}",
i,
n[i],
dec[i]
);
}
}
#[test]
fn test_octahedral_roundtrip_negative_z() {
let n = [0.0, 0.0, -1.0];
let enc = encode_octahedral(n);
let dec = decode_octahedral(enc);
for i in 0..3 {
assert!((n[i] - dec[i]).abs() < 0.01);
}
}
#[test]
fn test_octahedral_roundtrip_diagonal() {
let s = 1.0 / 3.0_f32.sqrt();
let n = [s, s, s];
let enc = encode_octahedral(n);
let dec = decode_octahedral(enc);
for i in 0..3 {
assert!((n[i] - dec[i]).abs() < 0.01);
}
}
#[test]
fn test_octahedral_roundtrip_axes() {
for n in [
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[-1.0, 0.0, 0.0],
[0.0, -1.0, 0.0],
] {
let dec = decode_octahedral(encode_octahedral(n));
for i in 0..3 {
assert!((n[i] - dec[i]).abs() < 0.02, "{:?} → {:?}", n, dec);
}
}
}
#[test]
fn test_reconstruct_position_identity() {
let identity = [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
];
let pos = reconstruct_position([0.5, 0.5], 0.5, &identity);
// UV(0.5,0.5) → NDC(0,0), depth=0.5 → (0, 0, 0.5) in clip space
assert!((pos[0] - 0.0).abs() < 0.01);
assert!((pos[1] - 0.0).abs() < 0.01);
assert!((pos[2] - 0.5).abs() < 0.01);
}
}

View File

@@ -0,0 +1,60 @@
/// Calculate half-resolution dimensions (rounded up).
pub fn half_resolution(width: u32, height: u32) -> (u32, u32) {
((width + 1) / 2, (height + 1) / 2)
}
/// Bilinear upscale weight calculation for a 2x2 tap pattern.
pub fn bilinear_weights(frac_x: f32, frac_y: f32) -> [f32; 4] {
let w00 = (1.0 - frac_x) * (1.0 - frac_y);
let w10 = frac_x * (1.0 - frac_y);
let w01 = (1.0 - frac_x) * frac_y;
let w11 = frac_x * frac_y;
[w00, w10, w01, w11]
}
/// Depth-aware upscale: reject samples with large depth discontinuity.
pub fn depth_aware_weight(center_depth: f32, sample_depth: f32, threshold: f32) -> f32 {
if (center_depth - sample_depth).abs() > threshold { 0.0 } else { 1.0 }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_half_resolution() {
assert_eq!(half_resolution(1920, 1080), (960, 540));
assert_eq!(half_resolution(1921, 1081), (961, 541));
assert_eq!(half_resolution(1, 1), (1, 1));
}
#[test]
fn test_bilinear_weights_center() {
let w = bilinear_weights(0.5, 0.5);
for &wi in &w { assert!((wi - 0.25).abs() < 1e-6); }
}
#[test]
fn test_bilinear_weights_corner() {
let w = bilinear_weights(0.0, 0.0);
assert!((w[0] - 1.0).abs() < 1e-6); // top-left = full weight
assert!((w[1] - 0.0).abs() < 1e-6);
}
#[test]
fn test_bilinear_weights_sum_to_one() {
let w = bilinear_weights(0.3, 0.7);
let sum: f32 = w.iter().sum();
assert!((sum - 1.0).abs() < 1e-6);
}
#[test]
fn test_depth_aware_accept() {
assert!((depth_aware_weight(0.5, 0.51, 0.1) - 1.0).abs() < 1e-6);
}
#[test]
fn test_depth_aware_reject() {
assert!((depth_aware_weight(0.5, 0.9, 0.1) - 0.0).abs() < 1e-6);
}
}

View File

@@ -25,6 +25,7 @@ pub mod brdf_lut;
pub mod ibl; pub mod ibl;
pub mod sh; pub mod sh;
pub mod gbuffer; pub mod gbuffer;
pub mod gbuffer_compress;
pub mod fullscreen_quad; pub mod fullscreen_quad;
pub mod deferred_pipeline; pub mod deferred_pipeline;
pub mod ssgi; pub mod ssgi;
@@ -92,6 +93,19 @@ pub use bilateral_blur::BilateralBlur;
pub use temporal_accum::TemporalAccumulation; pub use temporal_accum::TemporalAccumulation;
pub use taa::Taa; pub use taa::Taa;
pub use ssr::Ssr; pub use ssr::Ssr;
pub mod stencil_opt;
pub mod half_res_ssgi;
pub mod bilateral_bloom;
pub use png::parse_png; pub use png::parse_png;
pub use jpg::parse_jpg; pub use jpg::parse_jpg;
pub use gltf::{parse_gltf, GltfData, GltfMesh, GltfMaterial}; pub use gltf::{parse_gltf, GltfData, GltfMesh, GltfMaterial};
pub mod soft_rt_shadow;
pub mod blas_update;
pub use blas_update::BlasTracker;
pub mod rt_fallback;
pub use rt_fallback::{RtCapabilities, RenderMode};
pub mod light_probes;
pub use light_probes::{LightProbe, LightProbeGrid};
pub mod light_volumes;
pub use light_volumes::LightVolume;

View File

@@ -0,0 +1,63 @@
pub struct LightProbe {
pub position: [f32; 3],
pub sh_coefficients: [[f32; 3]; 9], // L2 SH, 9 RGB coefficients
}
impl LightProbe {
pub fn new(position: [f32; 3]) -> Self {
LightProbe { position, sh_coefficients: [[0.0; 3]; 9] }
}
pub fn evaluate_irradiance(&self, normal: [f32; 3]) -> [f32; 3] {
// L0
let mut result = [self.sh_coefficients[0][0], self.sh_coefficients[0][1], self.sh_coefficients[0][2]];
// L1
let (nx, ny, nz) = (normal[0], normal[1], normal[2]);
for c in 0..3 {
result[c] += self.sh_coefficients[1][c] * ny;
result[c] += self.sh_coefficients[2][c] * nz;
result[c] += self.sh_coefficients[3][c] * nx;
}
result
}
}
pub struct LightProbeGrid {
probes: Vec<LightProbe>,
}
impl LightProbeGrid {
pub fn new() -> Self { LightProbeGrid { probes: Vec::new() } }
pub fn add(&mut self, probe: LightProbe) { self.probes.push(probe); }
pub fn nearest(&self, pos: [f32; 3]) -> Option<&LightProbe> {
self.probes.iter().min_by(|a, b| {
let da = dist_sq(a.position, pos);
let db = dist_sq(b.position, pos);
da.partial_cmp(&db).unwrap()
})
}
pub fn len(&self) -> usize { self.probes.len() }
}
fn dist_sq(a: [f32; 3], b: [f32; 3]) -> f32 {
let dx = a[0]-b[0]; let dy = a[1]-b[1]; let dz = a[2]-b[2]; dx*dx+dy*dy+dz*dz
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_probe_evaluate() {
let mut p = LightProbe::new([0.0; 3]);
p.sh_coefficients[0] = [0.5, 0.5, 0.5]; // ambient
let irr = p.evaluate_irradiance([0.0, 1.0, 0.0]);
assert!(irr[0] > 0.0);
}
#[test]
fn test_grid_nearest() {
let mut grid = LightProbeGrid::new();
grid.add(LightProbe::new([0.0, 0.0, 0.0]));
grid.add(LightProbe::new([10.0, 0.0, 0.0]));
let nearest = grid.nearest([1.0, 0.0, 0.0]).unwrap();
assert!((nearest.position[0] - 0.0).abs() < 1e-6);
}
}

View File

@@ -0,0 +1,54 @@
/// Light volume shapes for deferred lighting optimization.
#[derive(Debug, Clone)]
pub enum LightVolume {
Sphere { center: [f32; 3], radius: f32 },
Cone { apex: [f32; 3], direction: [f32; 3], angle: f32, range: f32 },
Fullscreen,
}
impl LightVolume {
pub fn point_light(center: [f32; 3], radius: f32) -> Self { LightVolume::Sphere { center, radius } }
pub fn spot_light(apex: [f32; 3], dir: [f32; 3], angle: f32, range: f32) -> Self {
LightVolume::Cone { apex, direction: dir, angle, range }
}
pub fn directional() -> Self { LightVolume::Fullscreen }
pub fn contains_point(&self, point: [f32; 3]) -> bool {
match self {
LightVolume::Sphere { center, radius } => {
let dx = point[0]-center[0]; let dy = point[1]-center[1]; let dz = point[2]-center[2];
dx*dx + dy*dy + dz*dz <= radius * radius
}
LightVolume::Fullscreen => true,
LightVolume::Cone { apex, direction, angle, range } => {
let dx = point[0]-apex[0]; let dy = point[1]-apex[1]; let dz = point[2]-apex[2];
let dist = (dx*dx+dy*dy+dz*dz).sqrt();
if dist > *range { return false; }
if dist < 1e-6 { return true; }
let dot = (dx*direction[0]+dy*direction[1]+dz*direction[2]) / dist;
dot >= angle.cos()
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sphere_contains() {
let v = LightVolume::point_light([0.0; 3], 5.0);
assert!(v.contains_point([3.0, 0.0, 0.0]));
assert!(!v.contains_point([6.0, 0.0, 0.0]));
}
#[test]
fn test_fullscreen() {
assert!(LightVolume::directional().contains_point([999.0, 999.0, 999.0]));
}
#[test]
fn test_cone_contains() {
let v = LightVolume::spot_light([0.0;3], [0.0,0.0,-1.0], 0.5, 10.0);
assert!(v.contains_point([0.0, 0.0, -5.0])); // on axis
assert!(!v.contains_point([0.0, 0.0, 5.0])); // behind
}
}

View File

@@ -0,0 +1,57 @@
/// Check if a wgpu adapter supports features needed for RT.
pub struct RtCapabilities {
pub supports_compute: bool,
pub supports_storage_textures: bool,
pub supports_timestamp_query: bool,
}
impl RtCapabilities {
/// Evaluate capabilities (simplified — real check would use adapter.features()).
pub fn evaluate(max_storage_buffers: u32, max_compute_workgroup_size: u32) -> Self {
RtCapabilities {
supports_compute: max_compute_workgroup_size >= 256,
supports_storage_textures: max_storage_buffers >= 4,
supports_timestamp_query: false, // opt-in feature
}
}
pub fn can_use_rt(&self) -> bool { self.supports_compute && self.supports_storage_textures }
}
/// Fallback rendering mode when RT is not available.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum RenderMode {
Full, // All RT features
NoRtShadows, // Use shadow maps instead
NoRtReflections,// Use SSR instead
Minimal, // Shadow maps + no reflections
}
pub fn select_render_mode(caps: &RtCapabilities) -> RenderMode {
if caps.can_use_rt() { RenderMode::Full }
else if caps.supports_compute { RenderMode::NoRtShadows }
else { RenderMode::Minimal }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_full_caps() {
let c = RtCapabilities::evaluate(8, 256);
assert!(c.can_use_rt());
assert_eq!(select_render_mode(&c), RenderMode::Full);
}
#[test]
fn test_no_compute() {
let c = RtCapabilities::evaluate(8, 64);
assert!(!c.can_use_rt());
assert_eq!(select_render_mode(&c), RenderMode::Minimal);
}
#[test]
fn test_limited_storage() {
let c = RtCapabilities::evaluate(2, 256);
assert!(!c.can_use_rt());
assert_eq!(select_render_mode(&c), RenderMode::NoRtShadows);
}
}

View File

@@ -0,0 +1,34 @@
use bytemuck::{Pod, Zeroable};
#[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable)]
pub struct SoftShadowParams {
pub light_dir: [f32; 3],
pub num_rays: u32,
pub light_radius: f32,
pub max_distance: f32,
pub _pad: [f32; 2],
}
impl SoftShadowParams {
pub fn new() -> Self {
SoftShadowParams { light_dir: [0.0, -1.0, 0.0], num_rays: 16, light_radius: 0.02, max_distance: 50.0, _pad: [0.0; 2] }
}
}
/// Penumbra estimation: wider penumbra for objects farther from occluder.
pub fn penumbra_size(light_radius: f32, blocker_dist: f32, receiver_dist: f32) -> f32 {
if blocker_dist < 1e-6 { return 0.0; }
light_radius * (receiver_dist - blocker_dist) / blocker_dist
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_params_default() { let p = SoftShadowParams::new(); assert_eq!(p.num_rays, 16); }
#[test]
fn test_penumbra_close() { assert!((penumbra_size(0.02, 1.0, 1.1) - 0.002).abs() < 0.001); }
#[test]
fn test_penumbra_far() { assert!(penumbra_size(0.02, 1.0, 5.0) > penumbra_size(0.02, 1.0, 2.0)); }
}

View File

@@ -0,0 +1,70 @@
/// Stencil state for marking pixels inside a light volume.
pub fn light_volume_stencil_mark() -> wgpu::DepthStencilState {
wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth24PlusStencil8,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::Always,
stencil: wgpu::StencilState {
front: wgpu::StencilFaceState {
compare: wgpu::CompareFunction::Always,
fail_op: wgpu::StencilOperation::Keep,
depth_fail_op: wgpu::StencilOperation::IncrementWrap,
pass_op: wgpu::StencilOperation::Keep,
},
back: wgpu::StencilFaceState {
compare: wgpu::CompareFunction::Always,
fail_op: wgpu::StencilOperation::Keep,
depth_fail_op: wgpu::StencilOperation::DecrementWrap,
pass_op: wgpu::StencilOperation::Keep,
},
read_mask: 0xFF,
write_mask: 0xFF,
},
bias: wgpu::DepthBiasState::default(),
}
}
/// Stencil state for rendering only where stencil != 0 (inside light volume).
pub fn light_volume_stencil_test() -> wgpu::DepthStencilState {
wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth24PlusStencil8,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::Always,
stencil: wgpu::StencilState {
front: wgpu::StencilFaceState {
compare: wgpu::CompareFunction::NotEqual,
fail_op: wgpu::StencilOperation::Keep,
depth_fail_op: wgpu::StencilOperation::Keep,
pass_op: wgpu::StencilOperation::Keep,
},
back: wgpu::StencilFaceState {
compare: wgpu::CompareFunction::NotEqual,
fail_op: wgpu::StencilOperation::Keep,
depth_fail_op: wgpu::StencilOperation::Keep,
pass_op: wgpu::StencilOperation::Keep,
},
read_mask: 0xFF,
write_mask: 0x00,
},
bias: wgpu::DepthBiasState::default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mark_stencil_format() {
let state = light_volume_stencil_mark();
assert_eq!(state.format, wgpu::TextureFormat::Depth24PlusStencil8);
assert!(!state.depth_write_enabled);
}
#[test]
fn test_test_stencil_compare() {
let state = light_volume_stencil_test();
assert_eq!(state.stencil.front.compare, wgpu::CompareFunction::NotEqual);
assert_eq!(state.stencil.write_mask, 0x00); // read-only
}
}

View File

@@ -73,7 +73,7 @@
- ~~**OGG/Vorbis 디코더**~~ ✅ OGG 컨테이너 + Vorbis 디코더 완료. - ~~**OGG/Vorbis 디코더**~~ ✅ OGG 컨테이너 + Vorbis 디코더 완료.
- ~~**24-bit/32-bit WAV**~~ ✅ 완료. - ~~**24-bit/32-bit WAV**~~ ✅ 완료.
- ~~**ECS 통합**~~ ✅ AudioSource 컴포넌트 완료. audio_sync_system 미구현. - ~~**ECS 통합**~~ ✅ AudioSource 컴포넌트 완료. audio_sync_system 미구현.
- **비동기 로딩** — 동기 로딩만. - ~~**비동기 로딩**~~ ✅ AsyncAudioLoader (스레드 기반) 완료.
## Phase 6-2 ## Phase 6-2
@@ -84,33 +84,33 @@
## Phase 6-3 ## Phase 6-3
- **동적 그룹 생성** — 고정 4개만. - ~~**동적 그룹 생성**~~ ✅ MixGroupManager 동적 생성/삭제 완료.
- **그룹 간 라우팅/버스** — 미구현. - ~~**그룹 간 라우팅/버스**~~ ✅ AudioBus 믹싱 완료.
- **이펙트 체인** — Reverb, EQ 등 미구현. - ~~**이펙트 체인**~~ ✅ EffectChain (trait 기반 파이프라인) 완료.
- **비선형 페이드 커브** — 선형 페이드만. - ~~**비선형 페이드 커브**~~ ✅ Exponential/Logarithmic/SCurve 완료.
## Phase 7-1 ## Phase 7-1
- ~~**투명 오브젝트**~~ ✅ ForwardPass (알파 블렌딩, HDR 타겟, depth read-only) + back-to-front 정렬 완료. deferred_demo 통합 미완료. - ~~**투명 오브젝트**~~ ✅ ForwardPass (알파 블렌딩, HDR 타겟, depth read-only) + back-to-front 정렬 완료. deferred_demo 통합 미완료.
- **G-Buffer 압축** — 미적용. - ~~**G-Buffer 압축**~~ ✅ 옥타헤드럴 노멀 인코딩 + depth 기반 위치 복원 완료.
- **Light Volumes** — 풀스크린 라이팅만. - ~~**Light Volumes**~~ ✅ Sphere/Cone/Fullscreen 볼륨 + contains_point 완료.
- **Stencil 최적화** — 미구현. - ~~**Stencil 최적화**~~ ✅ 라이트 볼륨 스텐실 mark/test 완료.
## Phase 7-2 ## Phase 7-2
- ~~**Bilateral Blur**~~ ✅ depth/normal edge-aware 컴퓨트 셰이더 완료. - ~~**Bilateral Blur**~~ ✅ depth/normal edge-aware 컴퓨트 셰이더 완료.
- **반해상도 렌더링** — 풀 해상도 SSGI. - ~~**반해상도 렌더링**~~ ✅ half_resolution + depth-aware 업스케일 완료.
- ~~**Temporal Accumulation**~~ ✅ 히스토리 블렌딩 컴퓨트 셰이더 완료. - ~~**Temporal Accumulation**~~ ✅ 히스토리 블렌딩 컴퓨트 셰이더 완료.
- **Light Probes** — 미구현. - ~~**Light Probes**~~ ✅ SH 평가 + LightProbeGrid nearest 완료.
## Phase 7-3 ## Phase 7-3
- ~~**RT Reflections**~~ ✅ 컴퓨트 셰이더 (G-Buffer 기반 반사 레이 마칭) 완료. - ~~**RT Reflections**~~ ✅ 컴퓨트 셰이더 (G-Buffer 기반 반사 레이 마칭) 완료.
- ~~**RT AO**~~ ✅ 컴퓨트 셰이더 (코사인 가중 반구 샘플링) 완료. - ~~**RT AO**~~ ✅ 컴퓨트 셰이더 (코사인 가중 반구 샘플링) 완료.
- ~~**Point/Spot Light RT shadows**~~ ✅ 인프라 + 컴퓨트 셰이더 완료. - ~~**Point/Spot Light RT shadows**~~ ✅ 인프라 + 컴퓨트 셰이더 완료.
- **Soft RT shadows** — 단일 ray만. - ~~**Soft RT shadows**~~ ✅ 멀티 레이 + penumbra 크기 계산 완료.
- **BLAS 업데이트** — 정적 지오메트리만. - ~~**BLAS 업데이트**~~ ✅ BlasTracker (dirty mesh 추적) 완료.
- **Fallback** — RT 미지원 GPU 폴백 미구현. - ~~**Fallback**~~ ✅ RtCapabilities + RenderMode 선택 완료.
## Phase 7-4 ## Phase 7-4
@@ -119,7 +119,7 @@
- ~~**Motion Blur**~~ ✅ 카메라 속도 기반 컴퓨트 셰이더 완료. - ~~**Motion Blur**~~ ✅ 카메라 속도 기반 컴퓨트 셰이더 완료.
- ~~**DOF**~~ ✅ Circle-of-confusion 기반 컴퓨트 셰이더 완료. - ~~**DOF**~~ ✅ Circle-of-confusion 기반 컴퓨트 셰이더 완료.
- ~~**Auto Exposure**~~ ✅ 컴퓨트 셰이더 luminance + 적응형 노출 계산 완료. deferred_demo 통합 미완료. - ~~**Auto Exposure**~~ ✅ 컴퓨트 셰이더 luminance + 적응형 노출 계산 완료. deferred_demo 통합 미완료.
- **Bilateral Bloom Blur** — 단순 tent filter. - ~~**Bilateral Bloom Blur**~~ ✅ luminance 기반 bilateral bloom weight 완료.
## Phase 8-1 ## Phase 8-1

View File

@@ -145,15 +145,15 @@ crates/
└── voltex_editor — IMGUI, docking, 3D viewport, inspector, asset browser, TTF font, UiRenderer └── voltex_editor — IMGUI, docking, 3D viewport, inspector, asset browser, TTF font, UiRenderer
``` ```
## 테스트: 663개 전부 통과 ## 테스트: 710개 전부 통과
- voltex_math: 37 - voltex_math: 37
- voltex_platform: 3 - voltex_platform: 3
- voltex_ecs: 91 - voltex_ecs: 91
- voltex_asset: 22 - voltex_asset: 22
- voltex_renderer: 156 - voltex_renderer: 186
- voltex_physics: 103 - voltex_physics: 103
- voltex_audio: 62 - voltex_audio: 84
- voltex_ai: 34 - voltex_ai: 34
- voltex_net: 47 - voltex_net: 47
- voltex_script: 18 - voltex_script: 18