use gizmo_math::{Mat4, Vec3, Vec4};
pub const CASCADE_COUNT: usize = 4;
pub const SHADOW_MAP_RES: u32 = 3072;
pub const SHADOW_DISTANCE: f32 = 100.0;
pub const CASCADE_LAMBDA: f32 = 0.75;
pub struct ShadowCascades {
pub splits: [f32; CASCADE_COUNT],
pub view_projs: [Mat4; CASCADE_COUNT],
}
pub fn compute_directional_cascades(
cam_pos: Vec3,
cam_forward: Vec3,
aspect: f32,
fov_y: f32,
cam_near: f32,
cam_far: f32,
light_dir: Vec3,
) -> ShadowCascades {
let shadow_far = cam_far.min(SHADOW_DISTANCE);
let splits = cascade_split_distances(cam_near, shadow_far, CASCADE_LAMBDA);
let view_projs = directional_cascade_view_projs(
cam_pos,
cam_forward,
aspect,
fov_y,
cam_near,
&splits,
light_dir,
SHADOW_MAP_RES,
);
ShadowCascades { splits, view_projs }
}
pub fn cascade_split_distances(z_near: f32, z_far: f32, lambda: f32) -> [f32; CASCADE_COUNT] {
let mut s = [0.0f32; CASCADE_COUNT];
let z_near = z_near.max(0.001);
let z_far = z_far.max(z_near + 0.001);
let n = CASCADE_COUNT as f32;
for (i, s) in s.iter_mut().enumerate() {
let p = (i + 1) as f32 / n;
let log_d = z_near * (z_far / z_near).powf(p);
let uni_d = z_near + (z_far - z_near) * p;
*s = lambda * log_d + (1.0 - lambda) * uni_d;
}
s[CASCADE_COUNT - 1] = z_far;
s
}
fn camera_right_up(forward: Vec3) -> (Vec3, Vec3) {
let forward = forward.normalize();
let mut right = forward.cross(Vec3::Y);
if right.length_squared() < 1e-10 {
right = forward.cross(Vec3::X);
}
right = right.normalize();
let up = right.cross(forward).normalize();
(right, up)
}
fn frustum_slice_corners(
cam_pos: Vec3,
forward: Vec3,
right: Vec3,
up: Vec3,
aspect: f32,
fov_y: f32,
zn: f32,
zf: f32,
) -> [Vec3; 8] {
let th = (fov_y * 0.5).tan();
let corners_2d = [(-1f32, -1f32), (1.0, -1.0), (-1.0, 1.0), (1.0, 1.0)];
let mut out = [Vec3::ZERO; 8];
let mut k = 0;
for &(sx, sy) in &corners_2d {
for &d in &[zn, zf] {
let hh = d * th;
let hw = hh * aspect;
out[k] = cam_pos + forward * d + right * (sx * hw) + up * (sy * hh);
k += 1;
}
}
out
}
pub fn directional_cascade_view_projs(
cam_pos: Vec3,
cam_forward: Vec3,
aspect: f32,
fov_y: f32,
z_near: f32,
splits: &[f32; CASCADE_COUNT],
light_dir_world: Vec3,
shadow_map_size: u32,
) -> [Mat4; CASCADE_COUNT] {
let light_dir = light_dir_world.normalize();
let (right, up) = camera_right_up(cam_forward);
let mut prev_z = z_near;
let mut mats = [Mat4::IDENTITY; CASCADE_COUNT];
for i in 0..CASCADE_COUNT {
let zf = splits[i];
let corners =
frustum_slice_corners(cam_pos, cam_forward, right, up, aspect, fov_y, prev_z, zf);
let mid_dist = (prev_z + zf) * 0.5;
let slice_center = cam_pos + cam_forward * mid_dist;
let light_pos = slice_center - light_dir * 250.0;
let light_view = Mat4::look_at_rh(light_pos, slice_center, Vec3::Y);
let mut min_b = Vec3::splat(f32::MAX);
let mut max_b = Vec3::splat(f32::MIN);
for c in corners {
let v = light_view * Vec4::new(c.x, c.y, c.z, 1.0);
debug_assert!(v.w.abs() > 1e-6, "CSM corner projection: v.w ≈ 0 — degenerate light view matrix");
let p = Vec3::new(v.x, v.y, v.z) / v.w;
min_b = min_b.min(p);
max_b = max_b.max(p);
}
min_b.z -= 40.0;
max_b.z += 60.0;
let world_units_per_texel_x = (max_b.x - min_b.x) / shadow_map_size as f32;
let world_units_per_texel_y = (max_b.y - min_b.y) / shadow_map_size as f32;
if world_units_per_texel_x > 1e-8 && world_units_per_texel_y > 1e-8 {
min_b.x = (min_b.x / world_units_per_texel_x).floor() * world_units_per_texel_x;
min_b.y = (min_b.y / world_units_per_texel_y).floor() * world_units_per_texel_y;
max_b.x = min_b.x + world_units_per_texel_x * shadow_map_size as f32;
max_b.y = min_b.y + world_units_per_texel_y * shadow_map_size as f32;
}
let ortho = Mat4::orthographic_rh(min_b.x, max_b.x, min_b.y, max_b.y, -max_b.z, -min_b.z);
mats[i] = ortho * light_view;
prev_z = zf;
}
mats
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cascade_splits_are_monotonic_and_bounded() {
let splits = cascade_split_distances(0.1, 100.0, CASCADE_LAMBDA);
for i in 1..CASCADE_COUNT {
assert!(splits[i] > splits[i - 1], "splits must strictly increase: {splits:?}");
}
assert!(splits[0] > 0.1, "first split must be beyond the near plane: {splits:?}");
assert!(
(splits[CASCADE_COUNT - 1] - 100.0).abs() < 1e-3,
"last split must equal the shadow far distance: {splits:?}"
);
assert!(splits.iter().all(|s| s.is_finite()), "splits must be finite: {splits:?}");
}
#[test]
fn cascade_splits_handle_degenerate_range() {
let splits = cascade_split_distances(1.0, 0.5, CASCADE_LAMBDA);
assert!(
splits.iter().all(|s| s.is_finite()),
"degenerate range produced non-finite splits: {splits:?}"
);
for i in 1..CASCADE_COUNT {
assert!(splits[i] >= splits[i - 1], "splits must stay non-decreasing when clamped");
}
}
#[test]
fn directional_cascades_produce_finite_matrices() {
let c = compute_directional_cascades(
Vec3::ZERO,
Vec3::new(0.0, 0.0, -1.0),
16.0 / 9.0,
std::f32::consts::FRAC_PI_4,
0.1,
1500.0,
Vec3::new(0.3, -1.0, 0.2),
);
for (i, m) in c.view_projs.iter().enumerate() {
assert!(
m.to_cols_array().iter().all(|v| v.is_finite()),
"cascade {i} light-view-proj has non-finite entries"
);
}
assert!(c.splits.iter().all(|s| s.is_finite()));
assert!(
c.splits[CASCADE_COUNT - 1] <= SHADOW_DISTANCE + 1e-3,
"cascades must not stretch past SHADOW_DISTANCE: {:?}",
c.splits
);
}
}