Files
game_engine/crates/voltex_renderer/src/point_shadow.rs
tolelom 1b0e12e824 feat(renderer): add CSM, point/spot shadows, and frustum light culling
- CascadedShadowMap: 2-cascade directional shadows with frustum-based splits
- PointShadowMap: cube depth texture with 6-face rendering
- SpotShadowMap: perspective shadow map from spot light cone
- Frustum light culling: Gribb-Hartmann plane extraction + sphere tests
- Mat4::inverse() for frustum corner computation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 20:55:43 +09:00

179 lines
6.7 KiB
Rust

use voltex_math::{Mat4, Vec3};
pub const POINT_SHADOW_SIZE: u32 = 512;
pub const POINT_SHADOW_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
/// Depth cube map for omnidirectional point light shadows.
///
/// Uses a single cube texture with 6 faces (512x512 each).
pub struct PointShadowMap {
pub texture: wgpu::Texture,
pub view: wgpu::TextureView,
/// Per-face views for rendering into each cube face.
pub face_views: [wgpu::TextureView; 6],
pub sampler: wgpu::Sampler,
}
impl PointShadowMap {
pub fn new(device: &wgpu::Device) -> Self {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Point Shadow Cube Texture"),
size: wgpu::Extent3d {
width: POINT_SHADOW_SIZE,
height: POINT_SHADOW_SIZE,
depth_or_array_layers: 6,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: POINT_SHADOW_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
// Full cube view for sampling in shader
let view = texture.create_view(&wgpu::TextureViewDescriptor {
label: Some("Point Shadow Cube View"),
dimension: Some(wgpu::TextureViewDimension::Cube),
..Default::default()
});
// Per-face views for rendering
let face_labels = ["+X", "-X", "+Y", "-Y", "+Z", "-Z"];
let face_views = std::array::from_fn(|i| {
texture.create_view(&wgpu::TextureViewDescriptor {
label: Some(&format!("Point Shadow Face {}", face_labels[i])),
dimension: Some(wgpu::TextureViewDimension::D2),
base_array_layer: i as u32,
array_layer_count: Some(1),
..Default::default()
})
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Point Shadow Sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
compare: Some(wgpu::CompareFunction::LessEqual),
..Default::default()
});
Self {
texture,
view,
face_views,
sampler,
}
}
}
/// Compute the 6 view matrices for rendering into a point light shadow cube map.
///
/// Order: +X, -X, +Y, -Y, +Z, -Z (matching wgpu cube face order).
///
/// Each matrix is a look-at view matrix from `light_pos` toward the
/// corresponding axis direction, with an appropriate up vector.
pub fn point_shadow_view_matrices(light_pos: Vec3) -> [Mat4; 6] {
[
// +X: look right
Mat4::look_at(light_pos, light_pos + Vec3::X, -Vec3::Y),
// -X: look left
Mat4::look_at(light_pos, light_pos - Vec3::X, -Vec3::Y),
// +Y: look up
Mat4::look_at(light_pos, light_pos + Vec3::Y, Vec3::Z),
// -Y: look down
Mat4::look_at(light_pos, light_pos - Vec3::Y, -Vec3::Z),
// +Z: look forward
Mat4::look_at(light_pos, light_pos + Vec3::Z, -Vec3::Y),
// -Z: look backward
Mat4::look_at(light_pos, light_pos - Vec3::Z, -Vec3::Y),
]
}
/// Compute the perspective projection for point light shadow rendering.
///
/// 90 degree FOV, 1:1 aspect ratio.
/// `near` and `far` control the shadow range (typically 0.1 and light.range).
pub fn point_shadow_projection(near: f32, far: f32) -> Mat4 {
Mat4::perspective(std::f32::consts::FRAC_PI_2, 1.0, near, far)
}
#[cfg(test)]
mod tests {
use super::*;
use voltex_math::Vec4;
fn approx_eq(a: f32, b: f32) -> bool {
(a - b).abs() < 1e-4
}
#[test]
fn test_point_shadow_view_matrices_count() {
let views = point_shadow_view_matrices(Vec3::ZERO);
assert_eq!(views.len(), 6);
}
#[test]
fn test_point_shadow_view_directions() {
let pos = Vec3::new(0.0, 0.0, 0.0);
let views = point_shadow_view_matrices(pos);
// For the +X face, a point at (1, 0, 0) should map to the center of the view
// (i.e., to (0, 0, -1) in view space, roughly).
let test_point = Vec4::new(1.0, 0.0, 0.0, 1.0);
let in_view = views[0].mul_vec4(test_point);
// z should be negative (in front of camera)
assert!(in_view.z < 0.0, "+X face: point at +X should be in front, got z={}", in_view.z);
// x and y should be near 0 (centered)
assert!(approx_eq(in_view.x, 0.0), "+X face: expected x~0, got {}", in_view.x);
assert!(approx_eq(in_view.y, 0.0), "+X face: expected y~0, got {}", in_view.y);
// For the -X face, a point at (-1, 0, 0) should be in front
let test_neg_x = Vec4::new(-1.0, 0.0, 0.0, 1.0);
let in_view_neg = views[1].mul_vec4(test_neg_x);
assert!(in_view_neg.z < 0.0, "-X face: point at -X should be in front, got z={}", in_view_neg.z);
}
#[test]
fn test_point_shadow_view_offset_position() {
let pos = Vec3::new(5.0, 10.0, -3.0);
let views = point_shadow_view_matrices(pos);
// Origin of the light should map to (0,0,0) in view space
let origin = Vec4::from_vec3(pos, 1.0);
for (i, view) in views.iter().enumerate() {
let v = view.mul_vec4(origin);
assert!(approx_eq(v.x, 0.0), "Face {}: origin x should be 0, got {}", i, v.x);
assert!(approx_eq(v.y, 0.0), "Face {}: origin y should be 0, got {}", i, v.y);
assert!(approx_eq(v.z, 0.0), "Face {}: origin z should be 0, got {}", i, v.z);
}
}
#[test]
fn test_point_shadow_projection_90fov() {
let proj = point_shadow_projection(0.1, 100.0);
// At 90 degree FOV with aspect 1:1, a point at (-near, 0, -near) in view space
// should project to the edge of the viewport.
let near = 0.1f32;
let edge = Vec4::new(near, 0.0, -near, 1.0);
let clip = proj.mul_vec4(edge);
let ndc_x = clip.x / clip.w;
// Should be at x=1.0 (right edge)
assert!(approx_eq(ndc_x, 1.0), "Expected NDC x=1.0, got {}", ndc_x);
}
#[test]
fn test_point_shadow_projection_near_plane() {
let proj = point_shadow_projection(0.1, 100.0);
let p = Vec4::new(0.0, 0.0, -0.1, 1.0);
let clip = proj.mul_vec4(p);
let ndc_z = clip.z / clip.w;
assert!(approx_eq(ndc_z, 0.0), "Near plane should map to NDC z=0, got {}", ndc_z);
}
}