Skip to main content

concinnity_render/
csm.rs

1//! Cascaded shadow map cascade computation. Produces a `ShadowUniforms` carrying
2//! one orthographic light-view-projection matrix per cascade plus the view-space
3//! far depth for each cascade (the fragment shader uses these to select which
4//! cascade slice to sample).
5//!
6//! Algorithm (per-frame, called from each backend's draw loop):
7//!
8//!   1. Split the camera's [near, shadow_distance] depth range into N cascade
9//!      sub-ranges using the practical PSSM blend
10//!      (lambda * logarithmic + (1 - lambda) * linear).
11//!   2. For each cascade, compute the 8 frustum corners at its near/far depths,
12//!      then bound the corners with a sphere. The sphere bound makes the
13//!      orthographic light frustum rotation-invariant, eliminating shimmer
14//!      when the camera rotates.
15//!   3. Snap the sphere centre, in world space along the light's right/up axes,
16//!      to a per-cascade texel grid so the grid stays anchored in the world and
17//!      individual texels don't crawl as the camera translates.
18//!   4. Build a RH look_at from outside the sphere along the light direction
19//!      and an ortho projection that exactly encloses the sphere.
20//!
21//! The math is shared across all three backends: Metal, Vulkan, and DirectX
22//! all use RH view matrices with [0, 1] depth in their orthographic
23//! projections, so the same VPs are valid for every backend's shadow sampling.
24
25use crate::mat::{IDENTITY4, look_at, normalize3, ortho_rh};
26use crate::render_types::{NUM_SHADOW_CASCADES, ShadowUniforms};
27use concinnity_core::gfx::transform::mat4_mul;
28use concinnity_core::math::vec3::{add, cross, dot, scale, sub};
29use concinnity_core::math::{powf, round, sqrt, tan};
30
31const SPLIT_LAMBDA: f32 = 0.5;
32
33/// Fallback uniforms used when no shadow pass is active: identity VPs and a
34/// single split at +inf so the fragment shader always picks cascade 0 with a
35/// 1x1 fallback texture (returns "fully lit").
36pub fn empty_shadow_uniforms() -> ShadowUniforms {
37    ShadowUniforms {
38        light_vps: [IDENTITY4; NUM_SHADOW_CASCADES],
39        cascade_splits: [f32::INFINITY; NUM_SHADOW_CASCADES],
40        active_cascades: NUM_SHADOW_CASCADES as u32,
41        _pad: [0; 3],
42    }
43}
44
45/// Camera, light, and shadow-configuration inputs to
46/// [`compute_shadow_uniforms`].
47#[derive(Clone, Copy)]
48pub struct ShadowUniformInputs {
49    /// Camera view matrix (column-major, RH, same convention as look_at).
50    pub view: [[f32; 4]; 4],
51    /// World-space camera position.
52    pub cam_pos: [f32; 3],
53    /// Vertical FOV in radians.
54    pub fov_y_rad: f32,
55    /// Viewport aspect ratio (width / height).
56    pub aspect: f32,
57    /// Camera near plane.
58    pub near: f32,
59    /// Far end of the last cascade. Cascades cover [near, shadow_distance].
60    pub shadow_distance: f32,
61    /// Unit vector pointing TOWARD the light. Same convention as
62    /// `DirectionalLight.direction`; renormalised internally.
63    pub light_dir_to_source: [f32; 3],
64    /// Per-cascade texture resolution; used for texel snapping.
65    pub shadow_map_size: u32,
66    /// How many of the `NUM_SHADOW_CASCADES` slots are live (1..=4); only the
67    /// first `active` are split + projected, the rest hold a negative split
68    /// sentinel and an identity VP so the shader never selects them.
69    pub active_cascades: u32,
70}
71
72/// Compute cascade VPs + split depths from camera + light parameters.
73pub fn compute_shadow_uniforms(inputs: ShadowUniformInputs) -> ShadowUniforms {
74    let ShadowUniformInputs {
75        view,
76        cam_pos,
77        fov_y_rad,
78        aspect,
79        near,
80        shadow_distance,
81        light_dir_to_source,
82        shadow_map_size,
83        active_cascades,
84    } = inputs;
85    let shadow_far = shadow_distance.max(near + 1.0);
86    let active = active_cascades.clamp(1, NUM_SHADOW_CASCADES as u32) as usize;
87
88    // Practical PSSM splits over the `active` cascades. The unused tail keeps a
89    // negative sentinel so the fragment shader's `view_depth < split` test never
90    // selects a cascade the CPU did not render.
91    let cascade_count = active as f32;
92    let mut splits = [-1.0_f32; NUM_SHADOW_CASCADES];
93    for (i, split) in splits.iter_mut().take(active).enumerate() {
94        let p = (i + 1) as f32 / cascade_count;
95        let log = near * powf(shadow_far / near, p);
96        let lin = near + (shadow_far - near) * p;
97        *split = SPLIT_LAMBDA * log + (1.0 - SPLIT_LAMBDA) * lin;
98    }
99
100    let l_to = normalize3(light_dir_to_source);
101
102    // Camera basis from view matrix (column-major; look_at fills row 0 = right,
103    // row 1 = up, row 2 = -forward into view[*][0], view[*][1], view[*][2]).
104    let right = [view[0][0], view[1][0], view[2][0]];
105    let up = [view[0][1], view[1][1], view[2][1]];
106    let forward = [-view[0][2], -view[1][2], -view[2][2]];
107
108    let tan_half_v = tan(fov_y_rad * 0.5);
109    let tan_half_h = tan_half_v * aspect;
110
111    let mut light_vps = [IDENTITY4; NUM_SHADOW_CASCADES];
112    let mut prev_split = near;
113    for i in 0..active {
114        let near_d = prev_split;
115        let far_d = splits[i];
116        prev_split = far_d;
117
118        // 8 frustum corners at near_d and far_d in world space.
119        let h_near = near_d * tan_half_v;
120        let w_near = near_d * tan_half_h;
121        let h_far = far_d * tan_half_v;
122        let w_far = far_d * tan_half_h;
123        let cn = add(cam_pos, scale(forward, near_d));
124        let cf = add(cam_pos, scale(forward, far_d));
125        let corners: [[f32; 3]; 8] = [
126            add(add(cn, scale(right, -w_near)), scale(up, -h_near)),
127            add(add(cn, scale(right, w_near)), scale(up, -h_near)),
128            add(add(cn, scale(right, w_near)), scale(up, h_near)),
129            add(add(cn, scale(right, -w_near)), scale(up, h_near)),
130            add(add(cf, scale(right, -w_far)), scale(up, -h_far)),
131            add(add(cf, scale(right, w_far)), scale(up, -h_far)),
132            add(add(cf, scale(right, w_far)), scale(up, h_far)),
133            add(add(cf, scale(right, -w_far)), scale(up, h_far)),
134        ];
135
136        // Bounding sphere of the corners.
137        let mut centre = [0.0_f32; 3];
138        for c in &corners {
139            centre[0] += c[0];
140            centre[1] += c[1];
141            centre[2] += c[2];
142        }
143        centre = scale(centre, 1.0 / 8.0);
144        let mut r2 = 0.0_f32;
145        for c in &corners {
146            let d = sub(*c, centre);
147            let dd = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
148            if dd > r2 {
149                r2 = dd;
150            }
151        }
152        let radius = sqrt(r2).max(1e-3);
153
154        // Stable up axis: avoid parallel to light direction.
155        let up_l = if l_to[1].abs() > 0.95 {
156            [1.0_f32, 0.0, 0.0]
157        } else {
158            [0.0_f32, 1.0, 0.0]
159        };
160
161        // Light-space basis matching `look_at` below: f points from the light
162        // toward the scene, r and u span the shadow texel grid.
163        let f = scale(l_to, -1.0);
164        let r = normalize3(cross(f, up_l));
165        let u = cross(r, f);
166
167        // Texel-grid snap. Quantise the cascade centre along the light's right
168        // and up axes to whole shadow texels so the texel grid stays anchored in
169        // world space; the texels then stop crawling under camera translation
170        // (the shadow stops chasing the camera). The snap must happen in world
171        // space, before look_at: snapping the centre's light-space xy afterwards
172        // is a no-op, because look_at always maps the centre onto the optical
173        // axis (its light-space xy is identically zero).
174        let texel_size = 2.0 * radius / shadow_map_size.max(1) as f32;
175        let cx = dot(r, centre);
176        let cy = dot(u, centre);
177        let snap_dx = round(cx / texel_size) * texel_size - cx;
178        let snap_dy = round(cy / texel_size) * texel_size - cy;
179        let centre = add(add(centre, scale(r, snap_dx)), scale(u, snap_dy));
180
181        // Build the light view from the snapped centre and an ortho projection
182        // enclosing the sphere. The eye sits at +radius along the light
183        // direction so the sphere centre maps to the middle of the depth range.
184        //
185        // The near plane is pushed back toward the light by `caster_extent` so
186        // casters ABOVE this cascade's volume (tree canopies, tall building
187        // tops, anything between the light and the sphere) still render into the
188        // shadow map. Without it the near cascades, whose ortho boxes are only
189        // `radius` deep along the light, clip any caster taller than the cascade;
190        // as the camera moves, which casters fall inside each cascade changes,
191        // so elevated shadows pop in and out and their edges slide with the
192        // camera. The extension grows only the depth range, not the XY
193        // footprint, so shadow-map resolution is unchanged.
194        let caster_extent = shadow_far;
195        let light_eye = add(centre, scale(l_to, radius));
196        let light_view = look_at(light_eye, centre, up_l);
197        let proj = ortho_rh(
198            -radius,
199            radius,
200            -radius,
201            radius,
202            -caster_extent,
203            2.0 * radius,
204        );
205        light_vps[i] = mat4_mul(proj, light_view);
206    }
207
208    ShadowUniforms {
209        light_vps,
210        cascade_splits: splits,
211        active_cascades: active as u32,
212        _pad: [0; 3],
213    }
214}
215
216// Right-handed look-at producing a column-major view matrix matching the
217// per-backend look_at helpers (Metal, Vulkan, DX all use the same convention).
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    fn ident_view() -> [[f32; 4]; 4] {
223        // Camera at origin looking down -Z, up = +Y.
224        look_at([0.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0])
225    }
226
227    #[test]
228    fn empty_uniforms_have_infinite_splits() {
229        let u = empty_shadow_uniforms();
230        for s in &u.cascade_splits {
231            assert!(s.is_infinite());
232        }
233    }
234
235    #[test]
236    fn splits_are_strictly_increasing_within_range() {
237        let u = compute_shadow_uniforms(ShadowUniformInputs {
238            view: ident_view(),
239            cam_pos: [0.0, 0.0, 0.0],
240            fov_y_rad: core::f32::consts::FRAC_PI_2,
241            aspect: 1.0,
242            near: 0.1,
243            shadow_distance: 80.0,
244            light_dir_to_source: [0.0, 1.0, 0.0],
245            shadow_map_size: 2048,
246            active_cascades: 4,
247        });
248        for i in 1..NUM_SHADOW_CASCADES {
249            assert!(
250                u.cascade_splits[i] > u.cascade_splits[i - 1],
251                "splits must increase: {:?}",
252                u.cascade_splits
253            );
254        }
255        assert!(u.cascade_splits[0] > 0.1);
256        assert!((u.cascade_splits[NUM_SHADOW_CASCADES - 1] - 80.0).abs() < 1e-3);
257    }
258
259    #[test]
260    fn fewer_active_cascades_fill_only_the_live_slots() {
261        // With active_cascades = 2 the first two slots cover [near, shadow_far]
262        // (last split == shadow distance) and the unused tail holds the negative
263        // sentinel + identity VPs, so the shader never selects an unrendered slot.
264        let u = compute_shadow_uniforms(ShadowUniformInputs {
265            view: ident_view(),
266            cam_pos: [0.0, 0.0, 0.0],
267            fov_y_rad: core::f32::consts::FRAC_PI_2,
268            aspect: 1.0,
269            near: 0.1,
270            shadow_distance: 80.0,
271            light_dir_to_source: [0.0, 1.0, 0.0],
272            shadow_map_size: 2048,
273            active_cascades: 2,
274        });
275        assert_eq!(u.active_cascades, 2);
276        assert!(u.cascade_splits[0] > 0.1);
277        assert!(u.cascade_splits[1] > u.cascade_splits[0]);
278        assert!((u.cascade_splits[1] - 80.0).abs() < 1e-3);
279        // Unused tail: negative split sentinel + untouched identity VP.
280        assert!(u.cascade_splits[2] < 0.0);
281        assert!(u.cascade_splits[3] < 0.0);
282        assert_eq!(u.light_vps[2], IDENTITY4);
283        assert_eq!(u.light_vps[3], IDENTITY4);
284        // Out-of-range counts clamp into 1..=4.
285        let one = compute_shadow_uniforms(ShadowUniformInputs {
286            view: ident_view(),
287            cam_pos: [0.0, 0.0, 0.0],
288            fov_y_rad: core::f32::consts::FRAC_PI_2,
289            aspect: 1.0,
290            near: 0.1,
291            shadow_distance: 80.0,
292            light_dir_to_source: [0.0, 1.0, 0.0],
293            shadow_map_size: 2048,
294            active_cascades: 0,
295        });
296        assert_eq!(one.active_cascades, 1);
297        assert!((one.cascade_splits[0] - 80.0).abs() < 1e-3);
298    }
299
300    #[test]
301    fn near_clamped_to_avoid_degenerate_log() {
302        // shadow_distance smaller than near is clamped to near + 1.0 so the
303        // logarithmic split term stays finite.
304        let u = compute_shadow_uniforms(ShadowUniformInputs {
305            view: ident_view(),
306            cam_pos: [0.0, 0.0, 0.0],
307            fov_y_rad: core::f32::consts::FRAC_PI_2,
308            aspect: 1.0,
309            near: 5.0,
310            shadow_distance: 1.0,
311            light_dir_to_source: [0.0, 1.0, 0.0],
312            shadow_map_size: 2048,
313            active_cascades: 4,
314        });
315        for s in &u.cascade_splits {
316            assert!(s.is_finite() && *s > 0.0);
317        }
318    }
319
320    #[test]
321    fn cascade_vps_finite_for_typical_inputs() {
322        let u = compute_shadow_uniforms(ShadowUniformInputs {
323            view: ident_view(),
324            cam_pos: [10.0, 5.0, -3.0],
325            fov_y_rad: core::f32::consts::FRAC_PI_4,
326            aspect: 16.0 / 9.0,
327            near: 0.1,
328            shadow_distance: 80.0,
329            light_dir_to_source: [-0.4, 0.7, 0.3],
330            shadow_map_size: 2048,
331            active_cascades: 4,
332        });
333        for vp in &u.light_vps {
334            for col in vp {
335                for v in col {
336                    assert!(v.is_finite(), "non-finite element in light_vp");
337                }
338            }
339        }
340    }
341
342    #[test]
343    fn point_inside_first_cascade_projects_into_unit_box() {
344        // A point a few metres in front of the camera should project into the
345        // first cascade's light NDC, inside the [-1, 1] xy box (depth [0, 1]).
346        let u = compute_shadow_uniforms(ShadowUniformInputs {
347            view: ident_view(),
348            cam_pos: [0.0, 0.0, 0.0],
349            fov_y_rad: core::f32::consts::FRAC_PI_4,
350            aspect: 16.0 / 9.0,
351            near: 0.1,
352            shadow_distance: 80.0,
353            light_dir_to_source: [0.0, 1.0, 0.0],
354            shadow_map_size: 2048,
355            active_cascades: 4,
356        });
357        // World point 2m in front of camera (looking down -Z).
358        let p = [0.0_f32, 0.0, -2.0, 1.0];
359        let vp = u.light_vps[0];
360        let mut clip = [0.0_f32; 4];
361        for row in 0..4 {
362            clip[row] =
363                vp[0][row] * p[0] + vp[1][row] * p[1] + vp[2][row] * p[2] + vp[3][row] * p[3];
364        }
365        let ndc = [clip[0] / clip[3], clip[1] / clip[3], clip[2] / clip[3]];
366        assert!(ndc[0].abs() <= 1.0, "x out of range: {}", ndc[0]);
367        assert!(ndc[1].abs() <= 1.0, "y out of range: {}", ndc[1]);
368        assert!(
369            ndc[2] >= -0.05 && ndc[2] <= 1.05,
370            "depth out of range: {}",
371            ndc[2]
372        );
373    }
374
375    // Project a fixed world point into cascade 0's shadow map and return its
376    // texel coordinates for a camera at `cam` (looking down -Z).
377    fn cascade0_texel(cam: [f32; 3], world_p: [f32; 3], light: [f32; 3], size: u32) -> (f32, f32) {
378        let view = look_at(cam, [cam[0], cam[1], cam[2] - 1.0], [0.0, 1.0, 0.0]);
379        let u = compute_shadow_uniforms(ShadowUniformInputs {
380            view,
381            cam_pos: cam,
382            fov_y_rad: core::f32::consts::FRAC_PI_4,
383            aspect: 16.0 / 9.0,
384            near: 0.1,
385            shadow_distance: 80.0,
386            light_dir_to_source: light,
387            shadow_map_size: size,
388            active_cascades: 4,
389        });
390        let vp = u.light_vps[0];
391        let p = [world_p[0], world_p[1], world_p[2], 1.0];
392        let mut clip = [0.0_f32; 4];
393        for row in 0..4 {
394            clip[row] =
395                vp[0][row] * p[0] + vp[1][row] * p[1] + vp[2][row] * p[2] + vp[3][row] * p[3];
396        }
397        let uvx = (clip[0] / clip[3] * 0.5 + 0.5) * size as f32;
398        let uvy = (-clip[1] / clip[3] * 0.5 + 0.5) * size as f32;
399        (uvx, uvy)
400    }
401
402    #[test]
403    fn texels_do_not_crawl_under_camera_translation() {
404        // The chasing-shadow regression. A fixed world point must land on the
405        // SAME sub-texel of the shadow map regardless of camera position: with
406        // the texel grid anchored in world space, translating the camera shifts
407        // the projected point by a whole number of texels only, so its
408        // fractional texel position is invariant. (Before the world-space snap,
409        // the centre was snapped after look_at, which is a no-op, and the grid
410        // slid continuously with the camera -- the shadow "chased" it.)
411        let light = [-0.4, 0.78, 0.5];
412        let size = 2048u32;
413        let world_p = [3.0_f32, 0.0, -5.0];
414
415        // Two camera positions a fraction of a texel apart in world space.
416        let a = cascade0_texel([0.0, 2.0, 0.0], world_p, light, size);
417        let b = cascade0_texel([0.137, 2.0, 0.091], world_p, light, size);
418
419        // The texel delta must be (within float error) a whole number of
420        // texels: the fractional residual is the per-texel crawl.
421        let dx = a.0 - b.0;
422        let dy = a.1 - b.1;
423        let rx = dx - dx.round();
424        let ry = dy - dy.round();
425        assert!(
426            rx.abs() < 0.05,
427            "shadow x crawls within a texel: residual {rx}"
428        );
429        assert!(
430            ry.abs() < 0.05,
431            "shadow y crawls within a texel: residual {ry}"
432        );
433    }
434
435    // Project a world point through cascade 0's VP and return its light NDC.
436    fn cascade0_ndc(cam: [f32; 3], world_p: [f32; 3], light: [f32; 3]) -> [f32; 3] {
437        let view = look_at(cam, [cam[0], cam[1], cam[2] - 1.0], [0.0, 1.0, 0.0]);
438        let u = compute_shadow_uniforms(ShadowUniformInputs {
439            view,
440            cam_pos: cam,
441            fov_y_rad: core::f32::consts::FRAC_PI_4,
442            aspect: 16.0 / 9.0,
443            near: 0.1,
444            shadow_distance: 80.0,
445            light_dir_to_source: light,
446            shadow_map_size: 2048,
447            active_cascades: 4,
448        });
449        let vp = u.light_vps[0];
450        let p = [world_p[0], world_p[1], world_p[2], 1.0];
451        let mut clip = [0.0_f32; 4];
452        for row in 0..4 {
453            clip[row] =
454                vp[0][row] * p[0] + vp[1][row] * p[1] + vp[2][row] * p[2] + vp[3][row] * p[3];
455        }
456        [clip[0] / clip[3], clip[1] / clip[3], clip[2] / clip[3]]
457    }
458
459    #[test]
460    fn tall_casters_above_cascade_are_not_clipped() {
461        // The disappearing-shadow regression. A caster high above the near
462        // cascade (a tree canopy, a building top) must still fall inside the
463        // cascade's light frustum so it renders into the shadow map; otherwise
464        // its shadow vanishes the moment the receiver drops into a small near
465        // cascade, and the clip boundary slides across the world as the camera
466        // moves. The ortho near plane is extended toward the light for exactly
467        // this. A caster 30m up sits well beyond the few-metre cascade-0 sphere
468        // radius, so without the extension it projects to ndc.z < 0 (clipped).
469        let cam = [0.0_f32, 0.0, 0.0];
470        let light = [0.0_f32, 0.85, 0.3]; // mostly overhead
471        let ndc = cascade0_ndc(cam, [0.0, 30.0, -3.0], light);
472        assert!(
473            ndc[0].abs() <= 1.0,
474            "caster x outside footprint: {}",
475            ndc[0]
476        );
477        assert!(
478            ndc[1].abs() <= 1.0,
479            "caster y outside footprint: {}",
480            ndc[1]
481        );
482        assert!(
483            (0.0..=1.0).contains(&ndc[2]),
484            "tall caster clipped from shadow map: ndc.z = {}",
485            ndc[2]
486        );
487    }
488}