Compare commits
7 Commits
7375b15fcf
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ea9667889c | |||
| 6b6d581b71 | |||
| be290bd6e0 | |||
| a50f79e4fc | |||
| aafebff478 | |||
| bc2880d41c | |||
| 025bf4d0b9 |
91
crates/voltex_audio/src/async_loader.rs
Normal file
91
crates/voltex_audio/src/async_loader.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
crates/voltex_audio/src/audio_bus.rs
Normal file
82
crates/voltex_audio/src/audio_bus.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
crates/voltex_audio/src/dynamic_groups.rs
Normal file
101
crates/voltex_audio/src/dynamic_groups.rs
Normal 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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
62
crates/voltex_audio/src/effect_chain.rs
Normal file
62
crates/voltex_audio/src/effect_chain.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
crates/voltex_audio/src/fade_curves.rs
Normal file
58
crates/voltex_audio/src/fade_curves.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
|||||||
52
crates/voltex_renderer/src/bilateral_bloom.rs
Normal file
52
crates/voltex_renderer/src/bilateral_bloom.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
crates/voltex_renderer/src/blas_update.rs
Normal file
34
crates/voltex_renderer/src/blas_update.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
132
crates/voltex_renderer/src/gbuffer_compress.rs
Normal file
132
crates/voltex_renderer/src/gbuffer_compress.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
crates/voltex_renderer/src/half_res_ssgi.rs
Normal file
60
crates/voltex_renderer/src/half_res_ssgi.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
63
crates/voltex_renderer/src/light_probes.rs
Normal file
63
crates/voltex_renderer/src/light_probes.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
crates/voltex_renderer/src/light_volumes.rs
Normal file
54
crates/voltex_renderer/src/light_volumes.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
57
crates/voltex_renderer/src/rt_fallback.rs
Normal file
57
crates/voltex_renderer/src/rt_fallback.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
crates/voltex_renderer/src/soft_rt_shadow.rs
Normal file
34
crates/voltex_renderer/src/soft_rt_shadow.rs
Normal 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)); }
|
||||||
|
}
|
||||||
70
crates/voltex_renderer/src/stencil_opt.rs
Normal file
70
crates/voltex_renderer/src/stencil_opt.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user