diff --git a/crates/voltex_math/src/mat4.rs b/crates/voltex_math/src/mat4.rs index 813c52c..15c5c6a 100644 --- a/crates/voltex_math/src/mat4.rs +++ b/crates/voltex_math/src/mat4.rs @@ -174,6 +174,64 @@ impl Mat4 { ) } + /// Compute the inverse of this matrix. Returns `None` if the matrix is singular. + pub fn inverse(&self) -> Option { + let m = &self.cols; + // Flatten to row-major for cofactor expansion + // m[col][row] — so element (row, col) = m[col][row] + let e = |r: usize, c: usize| -> f32 { m[c][r] }; + + // Compute cofactors using 2x2 determinants + let s0 = e(0,0) * e(1,1) - e(1,0) * e(0,1); + let s1 = e(0,0) * e(1,2) - e(1,0) * e(0,2); + let s2 = e(0,0) * e(1,3) - e(1,0) * e(0,3); + let s3 = e(0,1) * e(1,2) - e(1,1) * e(0,2); + let s4 = e(0,1) * e(1,3) - e(1,1) * e(0,3); + let s5 = e(0,2) * e(1,3) - e(1,2) * e(0,3); + + let c5 = e(2,2) * e(3,3) - e(3,2) * e(2,3); + let c4 = e(2,1) * e(3,3) - e(3,1) * e(2,3); + let c3 = e(2,1) * e(3,2) - e(3,1) * e(2,2); + let c2 = e(2,0) * e(3,3) - e(3,0) * e(2,3); + let c1 = e(2,0) * e(3,2) - e(3,0) * e(2,2); + let c0 = e(2,0) * e(3,1) - e(3,0) * e(2,1); + + let det = s0 * c5 - s1 * c4 + s2 * c3 + s3 * c2 - s4 * c1 + s5 * c0; + if det.abs() < 1e-12 { + return None; + } + let inv_det = 1.0 / det; + + // Adjugate matrix (transposed cofactor matrix), stored column-major + let inv = Self::from_cols( + [ + ( e(1,1) * c5 - e(1,2) * c4 + e(1,3) * c3) * inv_det, + (-e(0,1) * c5 + e(0,2) * c4 - e(0,3) * c3) * inv_det, + ( e(3,1) * s5 - e(3,2) * s4 + e(3,3) * s3) * inv_det, + (-e(2,1) * s5 + e(2,2) * s4 - e(2,3) * s3) * inv_det, + ], + [ + (-e(1,0) * c5 + e(1,2) * c2 - e(1,3) * c1) * inv_det, + ( e(0,0) * c5 - e(0,2) * c2 + e(0,3) * c1) * inv_det, + (-e(3,0) * s5 + e(3,2) * s2 - e(3,3) * s1) * inv_det, + ( e(2,0) * s5 - e(2,2) * s2 + e(2,3) * s1) * inv_det, + ], + [ + ( e(1,0) * c4 - e(1,1) * c2 + e(1,3) * c0) * inv_det, + (-e(0,0) * c4 + e(0,1) * c2 - e(0,3) * c0) * inv_det, + ( e(3,0) * s4 - e(3,1) * s2 + e(3,3) * s0) * inv_det, + (-e(2,0) * s4 + e(2,1) * s2 - e(2,3) * s0) * inv_det, + ], + [ + (-e(1,0) * c3 + e(1,1) * c1 - e(1,2) * c0) * inv_det, + ( e(0,0) * c3 - e(0,1) * c1 + e(0,2) * c0) * inv_det, + (-e(3,0) * s3 + e(3,1) * s1 - e(3,2) * s0) * inv_det, + ( e(2,0) * s3 - e(2,1) * s1 + e(2,2) * s0) * inv_det, + ], + ); + Some(inv) + } + /// Return the transpose of this matrix. pub fn transpose(&self) -> Self { let c = &self.cols; diff --git a/crates/voltex_renderer/src/csm.rs b/crates/voltex_renderer/src/csm.rs new file mode 100644 index 0000000..1ad8b82 --- /dev/null +++ b/crates/voltex_renderer/src/csm.rs @@ -0,0 +1,246 @@ +use bytemuck::{Pod, Zeroable}; +use voltex_math::{Mat4, Vec3, Vec4}; + +pub const CSM_CASCADE_COUNT: usize = 2; +pub const CSM_MAP_SIZE: u32 = 2048; +pub const CSM_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; + +/// Cascaded Shadow Map with 2 cascades. +pub struct CascadedShadowMap { + pub textures: [wgpu::Texture; CSM_CASCADE_COUNT], + pub views: [wgpu::TextureView; CSM_CASCADE_COUNT], + pub sampler: wgpu::Sampler, +} + +impl CascadedShadowMap { + pub fn new(device: &wgpu::Device) -> Self { + let create_cascade = |label: &str| { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { + width: CSM_MAP_SIZE, + height: CSM_MAP_SIZE, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: CSM_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + (texture, view) + }; + + let (t0, v0) = create_cascade("CSM Cascade 0"); + let (t1, v1) = create_cascade("CSM Cascade 1"); + + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("CSM 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 { + textures: [t0, t1], + views: [v0, v1], + sampler, + } + } +} + +/// CSM uniform data: 2 light-view-proj matrices, cascade split distance, shadow params. +#[repr(C)] +#[derive(Copy, Clone, Debug, Pod, Zeroable)] +pub struct CsmUniform { + pub light_view_proj: [[[f32; 4]; 4]; CSM_CASCADE_COUNT], // 128 bytes + pub cascade_split: f32, // view-space depth where cascade 0 ends and cascade 1 begins + pub shadow_map_size: f32, + pub shadow_bias: f32, + pub _padding: f32, // 16 bytes total for last row +} + +/// Compute the 8 corners of a sub-frustum in world space, given the camera's +/// inverse view-projection and sub-frustum near/far in NDC z [0..1] range. +fn frustum_corners_world(inv_vp: &Mat4, z_near_ndc: f32, z_far_ndc: f32) -> [Vec3; 8] { + let ndc_corners = [ + // Near plane corners + Vec4::new(-1.0, -1.0, z_near_ndc, 1.0), + Vec4::new( 1.0, -1.0, z_near_ndc, 1.0), + Vec4::new( 1.0, 1.0, z_near_ndc, 1.0), + Vec4::new(-1.0, 1.0, z_near_ndc, 1.0), + // Far plane corners + Vec4::new(-1.0, -1.0, z_far_ndc, 1.0), + Vec4::new( 1.0, -1.0, z_far_ndc, 1.0), + Vec4::new( 1.0, 1.0, z_far_ndc, 1.0), + Vec4::new(-1.0, 1.0, z_far_ndc, 1.0), + ]; + + let mut world_corners = [Vec3::ZERO; 8]; + for (i, ndc) in ndc_corners.iter().enumerate() { + let w = inv_vp.mul_vec4(*ndc); + world_corners[i] = Vec3::new(w.x / w.w, w.y / w.w, w.z / w.w); + } + world_corners +} + +/// Compute a tight orthographic light-view-projection matrix for a set of frustum corners. +fn light_matrix_for_corners(light_dir: Vec3, corners: &[Vec3; 8]) -> Mat4 { + // Build a light-space view matrix looking in the light direction. + let center = { + let mut c = Vec3::ZERO; + for corner in corners { + c = c + *corner; + } + c * (1.0 / 8.0) + }; + + // Pick a stable up vector that isn't parallel to light_dir. + let up = if light_dir.cross(Vec3::Y).length_squared() < 1e-6 { + Vec3::Z + } else { + Vec3::Y + }; + + let light_view = Mat4::look_at( + center - light_dir * 0.5, // eye slightly behind center along light direction + center, + up, + ); + + // Transform all corners into light view space and find AABB. + let mut min_x = f32::MAX; + let mut max_x = f32::MIN; + let mut min_y = f32::MAX; + let mut max_y = f32::MIN; + let mut min_z = f32::MAX; + let mut max_z = f32::MIN; + + for corner in corners { + let v = light_view.mul_vec4(Vec4::from_vec3(*corner, 1.0)); + let p = Vec3::new(v.x, v.y, v.z); + min_x = min_x.min(p.x); + max_x = max_x.max(p.x); + min_y = min_y.min(p.y); + max_y = max_y.max(p.y); + min_z = min_z.min(p.z); + max_z = max_z.max(p.z); + } + + // Extend the z range to catch shadow casters behind the frustum. + let z_margin = (max_z - min_z) * 2.0; + min_z -= z_margin; + + let light_proj = Mat4::orthographic(min_x, max_x, min_y, max_y, -max_z, -min_z); + + light_proj.mul_mat4(&light_view) +} + +/// Compute cascade light-view-projection matrices for 2 cascades. +/// +/// - `light_dir`: normalized direction **toward** the light source (opposite of light travel). +/// Internally we negate it to get the light travel direction. +/// - `camera_view`, `camera_proj`: the camera's view and projection matrices. +/// - `near`, `far`: camera near/far planes. +/// - `split`: the view-space depth where cascade 0 ends and cascade 1 begins. +/// +/// Returns two light-view-projection matrices, one for each cascade. +pub fn compute_cascade_matrices( + light_dir: Vec3, + camera_view: &Mat4, + camera_proj: &Mat4, + near: f32, + far: f32, + split: f32, +) -> [Mat4; CSM_CASCADE_COUNT] { + let vp = camera_proj.mul_mat4(camera_view); + let inv_vp = vp.inverse().expect("Camera VP matrix must be invertible"); + + // Map view-space depth to NDC z. For wgpu perspective: + // ndc_z = (far * (z_view + near)) / (z_view * (far - near)) + // But since z_view is negative in RH, and we want the NDC value, we use + // the projection matrix directly by projecting (0, 0, -depth, 1). + let depth_to_ndc = |depth: f32| -> f32 { + let clip = camera_proj.mul_vec4(Vec4::new(0.0, 0.0, -depth, 1.0)); + clip.z / clip.w + }; + + let ndc_near = depth_to_ndc(near); + let ndc_split = depth_to_ndc(split); + let ndc_far = depth_to_ndc(far); + + // Light direction is the direction light travels (away from the source). + let dir = (-light_dir).normalize(); + + let corners0 = frustum_corners_world(&inv_vp, ndc_near, ndc_split); + let corners1 = frustum_corners_world(&inv_vp, ndc_split, ndc_far); + + [ + light_matrix_for_corners(dir, &corners0), + light_matrix_for_corners(dir, &corners1), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use std::mem; + + #[test] + fn test_csm_uniform_size() { + // Must be multiple of 16 for WGSL uniform alignment + assert_eq!(mem::size_of::() % 16, 0, + "CsmUniform must be 16-byte aligned, got {} bytes", mem::size_of::()); + } + + #[test] + fn test_compute_cascade_matrices_produces_valid_matrices() { + let light_dir = Vec3::new(0.0, -1.0, -1.0).normalize(); + let view = Mat4::look_at(Vec3::new(0.0, 5.0, 10.0), Vec3::ZERO, Vec3::Y); + let proj = Mat4::perspective(std::f32::consts::FRAC_PI_4, 16.0 / 9.0, 0.1, 100.0); + + let matrices = compute_cascade_matrices(light_dir, &view, &proj, 0.1, 100.0, 20.0); + + // Both matrices should not be identity (they should be actual projections) + assert_ne!(matrices[0].cols, Mat4::IDENTITY.cols, "Cascade 0 should not be identity"); + assert_ne!(matrices[1].cols, Mat4::IDENTITY.cols, "Cascade 1 should not be identity"); + } + + #[test] + fn test_cascade_split_distance() { + // The split distance should partition the frustum + let light_dir = Vec3::new(0.0, -1.0, 0.0).normalize(); + let view = Mat4::look_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y); + let proj = Mat4::perspective(std::f32::consts::FRAC_PI_2, 1.0, 1.0, 50.0); + + let split = 15.0; + let matrices = compute_cascade_matrices(light_dir, &view, &proj, 1.0, 50.0, split); + + // Both matrices should be different (covering different frustum regions) + let differ = matrices[0].cols.iter() + .zip(matrices[1].cols.iter()) + .any(|(a, b)| { + a.iter().zip(b.iter()).any(|(x, y)| (x - y).abs() > 1e-3) + }); + assert!(differ, "Cascade matrices should differ for different frustum regions"); + } + + #[test] + fn test_frustum_corners_world_identity() { + // With identity inverse VP, corners should be at NDC positions. + let inv_vp = Mat4::IDENTITY; + let corners = frustum_corners_world(&inv_vp, 0.0, 1.0); + + // Near plane at z=0 + assert!((corners[0].z - 0.0).abs() < 1e-5); + // Far plane at z=1 + assert!((corners[4].z - 1.0).abs() < 1e-5); + } +} diff --git a/crates/voltex_renderer/src/frustum.rs b/crates/voltex_renderer/src/frustum.rs new file mode 100644 index 0000000..63c131b --- /dev/null +++ b/crates/voltex_renderer/src/frustum.rs @@ -0,0 +1,262 @@ +use voltex_math::Vec3; +use crate::light::{LightsUniform, LIGHT_DIRECTIONAL, LIGHT_POINT}; + +/// A plane in 3D space: normal.dot(point) + d = 0 +#[derive(Debug, Clone, Copy)] +pub struct Plane { + pub normal: Vec3, + pub d: f32, +} + +impl Plane { + /// Normalize the plane equation so that |normal| == 1. + pub fn normalize(&self) -> Self { + let len = self.normal.length(); + if len < 1e-10 { + return *self; + } + Self { + normal: Vec3::new(self.normal.x / len, self.normal.y / len, self.normal.z / len), + d: self.d / len, + } + } + + /// Signed distance from a point to the plane (positive = inside / front). + pub fn distance(&self, point: Vec3) -> f32 { + self.normal.dot(point) + self.d + } +} + +/// Six-plane frustum (left, right, bottom, top, near, far). +#[derive(Debug, Clone, Copy)] +pub struct Frustum { + pub planes: [Plane; 6], +} + +/// Extract 6 frustum planes from a view-projection matrix using the +/// Gribb-Hartmann method. +/// +/// The planes point inward so that a point is inside if distance >= 0 for all planes. +/// Matrix is column-major `[[f32;4];4]` (same as `Mat4::cols`). +pub fn extract_frustum(view_proj: &voltex_math::Mat4) -> Frustum { + // We work with rows of the VP matrix. + // For column-major storage cols[c][r]: + // row[r] = (cols[0][r], cols[1][r], cols[2][r], cols[3][r]) + let m = &view_proj.cols; + + let row = |r: usize| -> [f32; 4] { + [m[0][r], m[1][r], m[2][r], m[3][r]] + }; + + let r0 = row(0); + let r1 = row(1); + let r2 = row(2); + let r3 = row(3); + + // Left: row3 + row0 + let left = Plane { + normal: Vec3::new(r3[0] + r0[0], r3[1] + r0[1], r3[2] + r0[2]), + d: r3[3] + r0[3], + }.normalize(); + + // Right: row3 - row0 + let right = Plane { + normal: Vec3::new(r3[0] - r0[0], r3[1] - r0[1], r3[2] - r0[2]), + d: r3[3] - r0[3], + }.normalize(); + + // Bottom: row3 + row1 + let bottom = Plane { + normal: Vec3::new(r3[0] + r1[0], r3[1] + r1[1], r3[2] + r1[2]), + d: r3[3] + r1[3], + }.normalize(); + + // Top: row3 - row1 + let top = Plane { + normal: Vec3::new(r3[0] - r1[0], r3[1] - r1[1], r3[2] - r1[2]), + d: r3[3] - r1[3], + }.normalize(); + + // Near: row2 (wgpu NDC z in [0,1], so near = row2 directly) + let near = Plane { + normal: Vec3::new(r2[0], r2[1], r2[2]), + d: r2[3], + }.normalize(); + + // Far: row3 - row2 + let far = Plane { + normal: Vec3::new(r3[0] - r2[0], r3[1] - r2[1], r3[2] - r2[2]), + d: r3[3] - r2[3], + }.normalize(); + + Frustum { + planes: [left, right, bottom, top, near, far], + } +} + +/// Test whether a sphere (center, radius) is at least partially inside the frustum. +pub fn sphere_vs_frustum(center: Vec3, radius: f32, frustum: &Frustum) -> bool { + for plane in &frustum.planes { + if plane.distance(center) < -radius { + return false; + } + } + true +} + +/// Return indices of lights from `lights` that are visible in the given frustum. +/// +/// - Directional lights are always included. +/// - Point lights use a bounding sphere (position, range). +/// - Spot lights use a conservative bounding sphere centered at the light position +/// with radius equal to the light range. +pub fn cull_lights(frustum: &Frustum, lights: &LightsUniform) -> Vec { + let count = lights.count as usize; + let mut visible = Vec::with_capacity(count); + + for i in 0..count { + let light = &lights.lights[i]; + if light.light_type == LIGHT_DIRECTIONAL { + // Directional lights affect everything + visible.push(i); + } else if light.light_type == LIGHT_POINT { + let center = Vec3::new(light.position[0], light.position[1], light.position[2]); + if sphere_vs_frustum(center, light.range, frustum) { + visible.push(i); + } + } else { + // Spot light — use bounding sphere at position with radius = range + let center = Vec3::new(light.position[0], light.position[1], light.position[2]); + if sphere_vs_frustum(center, light.range, frustum) { + visible.push(i); + } + } + } + + visible +} + +#[cfg(test)] +mod tests { + use super::*; + use voltex_math::Mat4; + use crate::light::{LightData, LightsUniform}; + + fn approx_eq(a: f32, b: f32, eps: f32) -> bool { + (a - b).abs() < eps + } + + #[test] + fn test_frustum_extraction_identity() { + // Identity VP means clip space = NDC directly. + // For wgpu: x,y in [-1,1], z in [0,1]. + let frustum = extract_frustum(&Mat4::IDENTITY); + + // All 6 planes should be normalized (length ~1) + for (i, plane) in frustum.planes.iter().enumerate() { + let len = plane.normal.length(); + assert!(approx_eq(len, 1.0, 1e-4), "Plane {} normal length = {}", i, len); + } + } + + #[test] + fn test_frustum_extraction_perspective() { + let proj = Mat4::perspective( + std::f32::consts::FRAC_PI_2, // 90 deg + 1.0, + 0.1, + 100.0, + ); + let view = Mat4::look_at( + Vec3::new(0.0, 0.0, 5.0), + Vec3::ZERO, + Vec3::Y, + ); + let vp = proj.mul_mat4(&view); + let frustum = extract_frustum(&vp); + + // Origin (0,0,0) should be inside the frustum (it's 5 units in front of camera) + assert!(sphere_vs_frustum(Vec3::ZERO, 0.0, &frustum), + "Origin should be inside frustum"); + } + + #[test] + fn test_sphere_inside_frustum() { + let proj = Mat4::perspective(std::f32::consts::FRAC_PI_2, 1.0, 0.1, 100.0); + let view = Mat4::look_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y); + let vp = proj.mul_mat4(&view); + let frustum = extract_frustum(&vp); + + // Sphere at origin with radius 1 — well inside + assert!(sphere_vs_frustum(Vec3::ZERO, 1.0, &frustum)); + } + + #[test] + fn test_sphere_outside_frustum() { + let proj = Mat4::perspective(std::f32::consts::FRAC_PI_2, 1.0, 0.1, 100.0); + let view = Mat4::look_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y); + let vp = proj.mul_mat4(&view); + let frustum = extract_frustum(&vp); + + // Sphere far behind the camera + assert!(!sphere_vs_frustum(Vec3::new(0.0, 0.0, 200.0), 1.0, &frustum), + "Sphere far behind camera should be outside"); + + // Sphere far to the side + assert!(!sphere_vs_frustum(Vec3::new(500.0, 0.0, 0.0), 1.0, &frustum), + "Sphere far to the side should be outside"); + } + + #[test] + fn test_sphere_partially_inside() { + let proj = Mat4::perspective(std::f32::consts::FRAC_PI_2, 1.0, 0.1, 100.0); + let view = Mat4::look_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y); + let vp = proj.mul_mat4(&view); + let frustum = extract_frustum(&vp); + + // Sphere at far plane boundary but with large radius should be inside + assert!(sphere_vs_frustum(Vec3::new(0.0, 0.0, -96.0), 5.0, &frustum)); + } + + #[test] + fn test_cull_lights_directional_always_included() { + let proj = Mat4::perspective(std::f32::consts::FRAC_PI_2, 1.0, 0.1, 50.0); + let view = Mat4::look_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y); + let vp = proj.mul_mat4(&view); + let frustum = extract_frustum(&vp); + + let mut lights = LightsUniform::new(); + lights.add_light(LightData::directional([0.0, -1.0, 0.0], [1.0, 1.0, 1.0], 1.0)); + lights.add_light(LightData::point([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], 2.0, 5.0)); + // Point light far away — should be culled + lights.add_light(LightData::point([500.0, 500.0, 500.0], [0.0, 1.0, 0.0], 2.0, 1.0)); + + let visible = cull_lights(&frustum, &lights); + // Directional (0) always included, point at origin (1) inside, far point (2) culled + assert!(visible.contains(&0), "Directional light must always be included"); + assert!(visible.contains(&1), "Point light at origin should be visible"); + assert!(!visible.contains(&2), "Far point light should be culled"); + } + + #[test] + fn test_cull_lights_spot() { + let proj = Mat4::perspective(std::f32::consts::FRAC_PI_2, 1.0, 0.1, 50.0); + let view = Mat4::look_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y); + let vp = proj.mul_mat4(&view); + let frustum = extract_frustum(&vp); + + let mut lights = LightsUniform::new(); + // Spot light inside frustum + lights.add_light(LightData::spot( + [0.0, 2.0, 0.0], [0.0, -1.0, 0.0], [1.0, 1.0, 1.0], 3.0, 10.0, 15.0, 30.0, + )); + // Spot light far away + lights.add_light(LightData::spot( + [300.0, 300.0, 300.0], [0.0, -1.0, 0.0], [1.0, 1.0, 1.0], 3.0, 5.0, 15.0, 30.0, + )); + + let visible = cull_lights(&frustum, &lights); + assert!(visible.contains(&0), "Near spot should be visible"); + assert!(!visible.contains(&1), "Far spot should be culled"); + } +} diff --git a/crates/voltex_renderer/src/lib.rs b/crates/voltex_renderer/src/lib.rs index 50c081b..123ec4c 100644 --- a/crates/voltex_renderer/src/lib.rs +++ b/crates/voltex_renderer/src/lib.rs @@ -16,6 +16,10 @@ pub mod sphere; pub mod pbr_pipeline; pub mod shadow; pub mod shadow_pipeline; +pub mod csm; +pub mod point_shadow; +pub mod spot_shadow; +pub mod frustum; pub mod brdf_lut; pub mod ibl; pub mod gbuffer; @@ -38,6 +42,10 @@ pub use sphere::generate_sphere; pub use pbr_pipeline::create_pbr_pipeline; pub use shadow::{ShadowMap, ShadowUniform, ShadowPassUniform, SHADOW_MAP_SIZE, SHADOW_FORMAT}; pub use shadow_pipeline::{create_shadow_pipeline, shadow_pass_bind_group_layout}; +pub use csm::{CascadedShadowMap, CsmUniform, compute_cascade_matrices, CSM_CASCADE_COUNT, CSM_MAP_SIZE, CSM_FORMAT}; +pub use point_shadow::{PointShadowMap, point_shadow_view_matrices, point_shadow_projection, POINT_SHADOW_SIZE, POINT_SHADOW_FORMAT}; +pub use spot_shadow::{SpotShadowMap, spot_shadow_matrix}; +pub use frustum::{Plane, Frustum, extract_frustum, sphere_vs_frustum, cull_lights}; pub use ibl::IblResources; pub use gbuffer::GBuffer; pub use fullscreen_quad::{create_fullscreen_vertex_buffer, FullscreenVertex}; diff --git a/crates/voltex_renderer/src/point_shadow.rs b/crates/voltex_renderer/src/point_shadow.rs new file mode 100644 index 0000000..08f1df8 --- /dev/null +++ b/crates/voltex_renderer/src/point_shadow.rs @@ -0,0 +1,178 @@ +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); + } +} diff --git a/crates/voltex_renderer/src/spot_shadow.rs b/crates/voltex_renderer/src/spot_shadow.rs new file mode 100644 index 0000000..226dfbe --- /dev/null +++ b/crates/voltex_renderer/src/spot_shadow.rs @@ -0,0 +1,148 @@ +use voltex_math::{Mat4, Vec3}; +use crate::shadow::{SHADOW_FORMAT, SHADOW_MAP_SIZE}; + +/// Shadow map for a single spot light. Reuses the same texture format and +/// resolution as the directional `ShadowMap`. +pub struct SpotShadowMap { + pub texture: wgpu::Texture, + pub view: wgpu::TextureView, + pub sampler: wgpu::Sampler, +} + +impl SpotShadowMap { + pub fn new(device: &wgpu::Device) -> Self { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Spot Shadow Map Texture"), + size: wgpu::Extent3d { + width: SHADOW_MAP_SIZE, + height: SHADOW_MAP_SIZE, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: SHADOW_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Spot 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, sampler } + } +} + +/// Compute the view-projection matrix for a spot light shadow pass. +/// +/// - `position`: world-space position of the spot light +/// - `direction`: normalized direction the spot light points toward +/// - `outer_angle`: outer cone half-angle in **radians** +/// - `range`: maximum distance the spot light reaches +/// +/// The projection uses a perspective matrix with FOV = 2 * outer_angle, +/// 1:1 aspect ratio, near = 0.1, far = range. +pub fn spot_shadow_matrix(position: Vec3, direction: Vec3, outer_angle: f32, range: f32) -> Mat4 { + let dir = direction.normalize(); + + // Pick a stable up vector that isn't parallel to the light direction. + let up = if dir.cross(Vec3::Y).length_squared() < 1e-6 { + Vec3::Z + } else { + Vec3::Y + }; + + let target = position + dir; + let view = Mat4::look_at(position, target, up); + + // FOV = 2 * outer_angle; clamped to avoid degenerate projections. + let fov = (2.0 * outer_angle).min(std::f32::consts::PI - 0.01); + let near = 0.1_f32; + let far = range.max(near + 0.1); + let proj = Mat4::perspective(fov, 1.0, near, far); + + proj.mul_mat4(&view) +} + +#[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_spot_shadow_matrix_center() { + let pos = Vec3::new(0.0, 10.0, 0.0); + let dir = Vec3::new(0.0, -1.0, 0.0); + let outer_angle = 30.0_f32.to_radians(); + let range = 20.0; + + let vp = spot_shadow_matrix(pos, dir, outer_angle, range); + + // A point directly below the light (along the direction) should map near center of NDC. + let test_point = Vec4::new(0.0, 5.0, 0.0, 1.0); // 5 units below the light + let clip = vp.mul_vec4(test_point); + let ndc_x = clip.x / clip.w; + let ndc_y = clip.y / clip.w; + + assert!(approx_eq(ndc_x, 0.0), "Center point should have NDC x~0, got {}", ndc_x); + // y may not be exactly 0 due to the look_at up vector choice, but should be close + assert!(ndc_y.abs() < 0.5, "Center point should be near NDC center, got y={}", ndc_y); + } + + #[test] + fn test_spot_shadow_matrix_depth_range() { + let pos = Vec3::new(0.0, 0.0, 0.0); + let dir = Vec3::new(0.0, 0.0, -1.0); + let outer_angle = 45.0_f32.to_radians(); + let range = 50.0; + + let vp = spot_shadow_matrix(pos, dir, outer_angle, range); + + // A point at near distance should have NDC z ~ 0 + let near_point = Vec4::new(0.0, 0.0, -0.1, 1.0); + let clip_near = vp.mul_vec4(near_point); + let ndc_z_near = clip_near.z / clip_near.w; + assert!(ndc_z_near >= -0.1 && ndc_z_near <= 0.2, + "Near point NDC z should be ~0, got {}", ndc_z_near); + + // A point at far distance should have NDC z ~ 1 + let far_point = Vec4::new(0.0, 0.0, -50.0, 1.0); + let clip_far = vp.mul_vec4(far_point); + let ndc_z_far = clip_far.z / clip_far.w; + assert!(ndc_z_far > 0.9 && ndc_z_far <= 1.01, + "Far point NDC z should be ~1, got {}", ndc_z_far); + } + + #[test] + fn test_spot_shadow_matrix_not_identity() { + let pos = Vec3::new(5.0, 5.0, 5.0); + let dir = Vec3::new(-1.0, -1.0, -1.0).normalize(); + let vp = spot_shadow_matrix(pos, dir, 25.0_f32.to_radians(), 30.0); + assert_ne!(vp.cols, Mat4::IDENTITY.cols); + } + + #[test] + fn test_spot_shadow_matrix_direction_down() { + // Light pointing straight down should work (uses Z as up instead of Y) + let pos = Vec3::new(0.0, 10.0, 0.0); + let dir = Vec3::new(0.0, -1.0, 0.0); + let vp = spot_shadow_matrix(pos, dir, 30.0_f32.to_radians(), 15.0); + // Should not panic; the matrix should be valid + assert_ne!(vp.cols, Mat4::IDENTITY.cols); + } +}