Skip to main content

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