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:
2026-03-26 17:28:37 +09:00
parent be290bd6e0
commit 6b6d581b71
6 changed files with 251 additions and 0 deletions

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

@@ -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;

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)); }
}