use gizmo_math::{Mat4, Vec3};
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 const SHADOW_FADE_FRACTION: f32 = 0.15;
pub const CASTER_REACH: f32 = 500.0;
pub const RECEIVER_MARGIN: f32 = 40.0;
pub fn shadow_distance_fade(view_depth: f32, shadow_far: f32) -> f32 {
let far = shadow_far.max(1e-4);
let band = (far * SHADOW_FADE_FRACTION).max(1e-4);
let t = ((view_depth - (far - band)) / band).clamp(0.0, 1.0);
1.0 - t * t * (3.0 - 2.0 * t)
}
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 light_up = if light_dir.y.abs() > 0.99 { Vec3::Z } else { Vec3::Y };
let light_view = Mat4::look_at_rh(-light_dir, Vec3::ZERO, light_up);
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 center = corners.iter().copied().fold(Vec3::ZERO, |a, c| a + c) / corners.len() as f32;
let radius = corners
.iter()
.fold(0.0f32, |m, c| m.max((*c - center).length()));
let radius = (radius * 16.0).ceil() / 16.0;
let center_ls = light_view.transform_point3(center);
let texel = 2.0 * radius / shadow_map_size as f32;
let min_x = ((center_ls.x - radius) / texel).floor() * texel;
let min_y = ((center_ls.y - radius) / texel).floor() * texel;
let (max_x, max_y) = (min_x + 2.0 * radius, min_y + 2.0 * radius);
let (mut min_z, mut max_z) = (f32::MAX, f32::MIN);
for c in corners {
let p = light_view.transform_point3(c);
min_z = min_z.min(p.z);
max_z = max_z.max(p.z);
}
min_z -= RECEIVER_MARGIN;
max_z += CASTER_REACH;
let ortho = Mat4::orthographic_rh(min_x, max_x, min_y, max_y, -max_z, -min_z);
mats[i] = ortho * light_view;
prev_z = zf;
}
mats
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_tall_caster_still_casts_at_any_sun_angle() {
use gizmo_math::Vec4;
let splits = cascade_split_distances(0.1, SHADOW_DISTANCE, CASCADE_LAMBDA);
for (name, light) in [
("alçak güneş 20°", Vec3::new(-0.94, -0.34, 0.0).normalize()),
("orta 45°", Vec3::new(-0.7, -0.7, 0.0).normalize()),
("tepeye yakın 75°", Vec3::new(-0.26, -0.97, 0.0).normalize()),
] {
let (cam, fwd) = (Vec3::new(0.0, 2.0, 0.0), Vec3::X);
let m = directional_cascade_view_projs(
cam, fwd, 16.0 / 9.0, 0.785, 0.1, &splits, light, SHADOW_MAP_RES,
);
let mut limits = Vec::new();
for ci in 0..CASCADE_COUNT {
let d = if ci == 0 { splits[0] * 0.5 } else { (splits[ci - 1] + splits[ci]) * 0.5 };
let base = cam + fwd * d;
let mut last_ok = 0.0f32;
for h_i in 0..400 {
let h = h_i as f32 * 1.0;
let p = Vec3::new(base.x, base.y + h, base.z);
let c = m[ci] * Vec4::new(p.x, p.y, p.z, 1.0);
let z = c.z / c.w;
if (0.0..=1.0).contains(&z) { last_ok = h; } else { break; }
}
limits.push(last_ok);
}
for (ci, limit) in limits.iter().enumerate() {
assert!(
*limit >= 300.0,
"{name}: a caster above cascade {ci} stops casting at {limit} m — it is being \
clipped by the shadow projection's near plane, so tall geometry silently \
loses its shadow"
);
}
}
}
#[test]
fn every_visible_point_lands_in_its_own_cascade() {
use gizmo_math::Vec4;
let light = Vec3::new(-0.4, -1.0, -0.3).normalize();
let (aspect, fov) = (16.0f32 / 9.0, 0.785f32);
let splits = cascade_split_distances(0.1, SHADOW_DISTANCE, CASCADE_LAMBDA);
let mut outside = 0u32;
let mut total = 0u32;
for step in 0..40 {
let a = step as f32 * 0.05;
let (cam, fwd) = (Vec3::new(a, 2.0, 0.0), Vec3::new(a.cos(), 0.0, a.sin()));
let (right, up) = camera_right_up(fwd);
let m = directional_cascade_view_projs(
cam, fwd, aspect, fov, 0.1, &splits, light, SHADOW_MAP_RES,
);
for d_i in 1..40 {
let d = 0.1 + (SHADOW_DISTANCE - 0.1) * (d_i as f32 / 40.0);
let ci = splits.iter().position(|&s| d < s).unwrap_or(CASCADE_COUNT - 1);
let hh = d * (fov * 0.5).tan();
let hw = hh * aspect;
for &(sx, sy) in &[(0.0f32, 0.0f32), (0.98, 0.98), (-0.98, 0.98), (0.98, -0.98), (-0.98, -0.98)] {
let p = cam + fwd * d + right * (sx * hw) + up * (sy * hh);
let c = m[ci] * Vec4::new(p.x, p.y, p.z, 1.0);
let uv = ((c.x / c.w) * 0.5 + 0.5, (c.y / c.w) * -0.5 + 0.5);
total += 1;
if !(0.0..=1.0).contains(&uv.0) || !(0.0..=1.0).contains(&uv.1) {
outside += 1;
}
}
}
}
assert_eq!(
outside, 0,
"{outside} of {total} visible points fell outside the cascade selected for them — \
those fragments read as fully lit, so shadows go missing in a band of the view"
);
}
#[test]
fn an_overhead_sun_does_not_produce_nan() {
let splits = cascade_split_distances(0.1, SHADOW_DISTANCE, CASCADE_LAMBDA);
for light in [
Vec3::new(0.0, -1.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
Vec3::new(0.001, -1.0, 0.0).normalize(),
] {
let mats = directional_cascade_view_projs(
Vec3::new(0.0, 2.0, 0.0),
Vec3::X,
16.0 / 9.0,
0.785,
0.1,
&splits,
light,
SHADOW_MAP_RES,
);
for (i, m) in mats.iter().enumerate() {
assert!(
m.to_cols_array().iter().all(|v| v.is_finite()),
"cascade {i} is not finite for light {light:?}"
);
}
}
}
#[test]
fn a_static_point_keeps_its_sub_texel_phase() {
use gizmo_math::Vec4;
let point = Vec3::new(3.0, 0.0, 0.0);
let light = Vec3::new(-0.4, -1.0, -0.3).normalize();
let splits = cascade_split_distances(0.1, SHADOW_DISTANCE, CASCADE_LAMBDA);
let phase_at = |cam: Vec3, fwd: Vec3| -> (f32, f32) {
let m = directional_cascade_view_projs(
cam,
fwd,
16.0 / 9.0,
0.785,
0.1,
&splits,
light,
SHADOW_MAP_RES,
);
let c = m[0] * Vec4::new(point.x, point.y, point.z, 1.0);
let uv = (
(c.x / c.w) * 0.5 + 0.5,
(c.y / c.w) * -0.5 + 0.5,
);
let res = SHADOW_MAP_RES as f32;
((uv.0 * res).fract(), (uv.1 * res).fract())
};
let apart = |a: f32, b: f32| {
let d = (a - b).abs();
d.min(1.0 - d)
};
let base = phase_at(Vec3::new(-10.0, 2.0, 0.0), Vec3::X);
for k in 1..8 {
let slid = phase_at(Vec3::new(-10.0 + k as f32 * 0.01, 2.0, 0.0), Vec3::X);
assert!(
apart(slid.0, base.0) < 0.01 && apart(slid.1, base.1) < 0.01,
"sliding the camera moved the sub-texel phase from {base:?} to {slid:?} — the \
snap grid is not fixed in the world, so shadow edges crawl"
);
let a = k as f32 * 0.002;
let turned = phase_at(Vec3::new(-10.0, 2.0, 0.0), Vec3::new(a.cos(), 0.0, a.sin()));
assert!(
apart(turned.0, base.0) < 0.01 && apart(turned.1, base.1) < 0.01,
"turning the camera moved the sub-texel phase from {base:?} to {turned:?} — the \
texel size is changing with the camera's orientation"
);
}
}
#[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
);
}
#[test]
fn uniform_lambda_gives_evenly_spaced_splits() {
let s = cascade_split_distances(1.0, 5.0, 0.0);
assert!((s[0] - 2.0).abs() < 1e-4, "{s:?}");
assert!((s[1] - 3.0).abs() < 1e-4, "{s:?}");
assert!((s[2] - 4.0).abs() < 1e-4, "{s:?}");
assert!((s[3] - 5.0).abs() < 1e-4, "{s:?}");
}
#[test]
fn logarithmic_lambda_packs_splits_toward_the_near_plane() {
let log = cascade_split_distances(1.0, 100.0, 1.0);
let uni = cascade_split_distances(1.0, 100.0, 0.0);
assert!(log[0] < uni[0], "log near split should be tighter: {log:?} vs {uni:?}");
let r0 = log[1] / log[0];
let r1 = log[2] / log[1];
assert!((r0 - r1).abs() < 1e-3, "log splits not geometric: {log:?}");
}
fn baked_lit_shadow_term(view_depth: f32, shadow_far: f32, vis: f32) -> f32 {
const SUN_SHARE: f32 = 0.45; let sampled = if view_depth <= shadow_far { vis } else { 1.0 };
let fade = shadow_distance_fade(view_depth, shadow_far);
let vis_faded = 1.0 + (sampled - 1.0) * fade;
1.0 - SUN_SHARE + SUN_SHARE * vis_faded
}
#[test]
fn shadow_fade_is_inert_until_the_last_stretch_of_the_range() {
let far = SHADOW_DISTANCE;
for d in [0.0f32, 1.0, 25.0, 50.0, 84.9] {
assert_eq!(
shadow_distance_fade(d, far),
1.0,
"fade must not touch the shadow term at {d} m (band starts at \
{})",
far * (1.0 - SHADOW_FADE_FRACTION)
);
}
for d in [far, far + 1.0, far * 10.0] {
assert_eq!(shadow_distance_fade(d, far), 0.0, "fade must be spent by {d} m");
}
}
#[test]
fn shadow_fade_is_monotonic_and_bounded() {
let far = SHADOW_DISTANCE;
let mut prev = shadow_distance_fade(0.0, far);
let mut d = 0.0f32;
while d <= far * 1.2 {
let f = shadow_distance_fade(d, far);
assert!((0.0..=1.0).contains(&f), "fade out of range at {d} m: {f}");
assert!(f <= prev + 1e-6, "fade must never increase with distance ({d} m: {prev} → {f})");
prev = f;
d += 0.05;
}
}
#[test]
fn shadowed_brightness_has_no_step_at_the_shadow_distance() {
let far = SHADOW_DISTANCE;
let step_m = 0.05f32;
let mut worst_delta = 0.0f32;
let mut worst_at = 0.0f32;
let mut d = 0.0f32;
while d < far * 1.2 {
let a = baked_lit_shadow_term(d, far, 0.0);
let b = baked_lit_shadow_term(d + step_m, far, 0.0);
let delta = (b - a).abs();
if delta > worst_delta {
worst_delta = delta;
worst_at = d;
}
d += step_m;
}
assert!(
worst_delta < 0.005,
"shadow term still steps: {worst_delta} over {step_m} m at {worst_at} m \
(pre-fix this was 0.45 at {far} m)"
);
assert!(
(baked_lit_shadow_term(0.0, far, 0.0) - 0.55).abs() < 1e-6,
"a fully shadowed fragment in front of the camera must keep the 0.55 floor"
);
assert!(
(baked_lit_shadow_term(far + 5.0, far, 0.0) - 1.0).abs() < 1e-6,
"past the covered range the term must be fully lit"
);
}
#[test]
fn shadow_fade_leaves_a_lit_fragment_untouched() {
let far = SHADOW_DISTANCE;
for d in [0.0f32, 50.0, 90.0, 99.9, 100.0, 250.0] {
assert!(
(baked_lit_shadow_term(d, far, 1.0) - 1.0).abs() < 1e-6,
"unoccluded fragment darkened at {d} m"
);
}
}
#[test]
fn shadow_fade_survives_a_degenerate_range() {
for far in [0.0f32, -1.0, 1e-6, 0.5] {
for d in [0.0f32, 0.25, 10.0] {
let f = shadow_distance_fade(d, far);
assert!(f.is_finite(), "fade not finite for far={far}, d={d}: {f}");
assert!((0.0..=1.0).contains(&f), "fade out of range for far={far}, d={d}: {f}");
}
}
}
#[test]
fn shader_shadow_fade_matches_the_rust_mirror() {
let shaders = [
("baked_lit.wgsl", include_str!("shaders/baked_lit.wgsl")),
("deferred_lighting.wgsl", include_str!("shaders/deferred_lighting.wgsl")),
];
let expected = format!("const SHADOW_FADE_FRACTION: f32 = {SHADOW_FADE_FRACTION:?};");
for (name, src) in shaders {
assert!(
src.contains(&expected),
"{name} must declare `{expected}` — the shader fade band has drifted from \
csm::SHADOW_FADE_FRACTION"
);
assert!(
src.contains("fn shadow_distance_fade("),
"{name} lost its shadow_distance_fade mirror"
);
assert!(
src.contains("shadow_distance_fade(view_depth"),
"{name} declares the fade but never applies it to the sampled shadow term"
);
}
}
#[test]
fn cascade_computation_is_deterministic() {
let build = || {
compute_directional_cascades(
Vec3::new(1.0, 2.0, 3.0),
Vec3::new(0.0, 0.0, -1.0),
16.0 / 9.0,
std::f32::consts::FRAC_PI_4,
0.1,
200.0,
Vec3::new(0.3, -1.0, 0.2),
)
};
let a = build();
let b = build();
assert_eq!(a.splits, b.splits, "splits must be reproducible");
for i in 0..CASCADE_COUNT {
assert_eq!(
a.view_projs[i].to_cols_array(),
b.view_projs[i].to_cols_array(),
"cascade {i} matrix must be reproducible (texel snap is stable)"
);
}
}
}