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>
This commit is contained in:
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,3 +100,12 @@ 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)); }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user