Skip to main content

concinnity_render/
spot_shadow.rs

1//! Slice assignment and light-space projections for the spot shadow map array.
2//!
3//! Local lights are static, so both the slice each shadowed spot owns and the
4//! matrix it renders with are decided once per scene and never recomputed. Only
5//! the depth contents need refreshing, and only when a caster moves -- that
6//! schedule is `SpotShadowScheduler` below, which mirrors the prime-then-
7//! round-robin policy the CSM cascades use.
8//!
9//! A spot's projection is a perspective frustum whose vertical FOV is the full
10//! cone angle (2x the outer half-angle), so the cone inscribes the shadow slice's
11//! square footprint. Right-handed with [0, 1] depth, matching `csm.rs`, so the
12//! same matrices are valid on all three backends.
13
14use crate::components::{SpotLight, SpotLightGeometry};
15use crate::mat::{look_at, up_for};
16use crate::render_types::{MAX_SHADOWED_SPOTS, SpotShadowData};
17use alloc::vec;
18use alloc::vec::Vec;
19use concinnity_core::gfx::projection::perspective_rh;
20use concinnity_core::gfx::transform::mat4_mul;
21use concinnity_core::math::vec3::{add, scale};
22
23// Near plane for a spot's shadow frustum. Fixed and small: the depth range is
24// [SHADOW_NEAR, range], and pulling the near plane in costs precision while
25// pushing it out clips casters close to the bulb.
26const SHADOW_NEAR: f32 = 0.05;
27
28// Depth compare offsets, in light-clip and world units respectively. Sized to
29// clear the acne a 512-ish slice produces at grazing angles without detaching
30// contact shadows.
31const DEPTH_BIAS: f32 = 0.0015;
32const NORMAL_BIAS: f32 = 0.035;
33
34// Shortest range a shadowed spot may declare. A zero or near-zero range would
35// collapse the frustum's depth span and make the projection degenerate.
36const MIN_SHADOW_RANGE: f32 = 0.1;
37
38// Per-spot slice assignment: `slices[i]` is the shadow map array slice spot `i`
39// owns, or -1 when it casts no shadow (either `cast_shadows` is false or the
40// slices ran out). The value is what `GpuLight.shadow_index` carries.
41pub(crate) fn assign_spot_shadow_slices(spot_lights: &[SpotLight]) -> Vec<i32> {
42    let mut next = 0_i32;
43    let mut wanted = 0_usize;
44    let slices: Vec<i32> = spot_lights
45        .iter()
46        .map(|l| {
47            if !l.cast_shadows {
48                return -1;
49            }
50            wanted += 1;
51            if (next as usize) < MAX_SHADOWED_SPOTS {
52                let slice = next;
53                next += 1;
54                slice
55            } else {
56                -1
57            }
58        })
59        .collect();
60    if wanted > MAX_SHADOWED_SPOTS {
61        tracing::warn!(
62            "GraphicsSystem: {} spot lights request shadows; only {} slices exist -- the rest light without casting",
63            wanted,
64            MAX_SHADOWED_SPOTS
65        );
66    }
67    slices
68}
69
70// The `SpotShadowData` for each assigned slice, ordered by slice index. Pair
71// with `assign_spot_shadow_slices` over the same slice: entry `slices[i]` of the
72// result describes spot `i`.
73pub(crate) fn build_spot_shadow_data(
74    spot_lights: &[SpotLight],
75    slices: &[i32],
76) -> Vec<SpotShadowData> {
77    let mut out = vec![SpotShadowData::ZERO; count_shadowed(slices)];
78    for (light, &slice) in spot_lights.iter().zip(slices) {
79        if slice >= 0 {
80            out[slice as usize] = spot_shadow_data(light);
81        }
82    }
83    out
84}
85
86// How many slices `assign_spot_shadow_slices` handed out.
87pub(crate) fn count_shadowed(slices: &[i32]) -> usize {
88    slices.iter().filter(|s| **s >= 0).count()
89}
90
91// One spot's light-space projection. The FOV is the full cone (2x the outer
92// half-angle) so the lit cone fits inside the slice's square footprint; the
93// validator caps the half-angle below 90 degrees, keeping the FOV under 180.
94fn spot_shadow_data(light: &SpotLight) -> SpotShadowData {
95    let dir = light.unit_direction();
96    let far = light.range.max(MIN_SHADOW_RANGE);
97    let view = look_at(
98        light.position,
99        add(light.position, scale(dir, far)),
100        up_for(dir),
101    );
102    let fov = (2.0 * light.outer_angle).to_radians();
103    let proj = perspective_rh(fov, 1.0, SHADOW_NEAR, far);
104    SpotShadowData {
105        light_vp: mat4_mul(proj, view),
106        depth_bias: DEPTH_BIAS,
107        normal_bias: NORMAL_BIAS,
108        _pad: [0.0; 2],
109    }
110}
111
112/// Prime-then-round-robin refresh schedule over the assigned slices, mirroring
113/// `ShadowCascadeScheduler`. Every slice renders once before it can be sampled;
114/// after that `Hybrid` refreshes one slice per frame so N shadowed spots cost one
115/// extra depth render per frame rather than N.
116#[derive(Debug, Default)]
117pub struct SpotShadowScheduler {
118    clock: u32,
119    primed: u32,
120}
121
122impl SpotShadowScheduler {
123    /// Bit `i` set means slice `i` re-renders this frame. Advances the clock.
124    pub fn next_mask(&mut self, every_frame: bool, shadowed: usize) -> u32 {
125        let (mask, primed) = select_slice_mask(every_frame, self.clock, self.primed, shadowed);
126        self.clock = self.clock.wrapping_add(1);
127        self.primed = primed;
128        mask
129    }
130}
131
132// Pure selection step, split out so the policy is testable without renderer
133// state. Returns `(render_mask, new_primed_mask)`. Any slice not yet primed is
134// force-rendered so it never gets sampled before it holds valid depth.
135fn select_slice_mask(every_frame: bool, clock: u32, primed: u32, shadowed: usize) -> (u32, u32) {
136    let shadowed = shadowed.min(MAX_SHADOWED_SPOTS);
137    if shadowed == 0 {
138        return (0, primed);
139    }
140    let all = if shadowed >= 32 {
141        u32::MAX
142    } else {
143        (1_u32 << shadowed) - 1
144    };
145    let scheduled = if every_frame {
146        all
147    } else {
148        1_u32 << (clock as usize % shadowed)
149    };
150    let unprimed = all & !primed;
151    let mask = (scheduled | unprimed) & all;
152    (mask, primed | mask)
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    fn spot(cast: bool) -> SpotLight {
160        SpotLight {
161            cast_shadows: cast,
162            ..SpotLight::default()
163        }
164    }
165
166    fn transform(m: [[f32; 4]; 4], p: [f32; 3]) -> [f32; 4] {
167        let mut out = [0.0_f32; 4];
168        for row in 0..4 {
169            out[row] = m[0][row] * p[0] + m[1][row] * p[1] + m[2][row] * p[2] + m[3][row];
170        }
171        out
172    }
173
174    #[test]
175    fn slices_are_handed_out_in_declaration_order() {
176        let lights = vec![spot(true), spot(true), spot(true)];
177        assert_eq!(assign_spot_shadow_slices(&lights), vec![0, 1, 2]);
178    }
179
180    // A non-casting spot takes no slice and does not shift the ones after it.
181    #[test]
182    fn non_casting_spots_are_skipped_without_consuming_a_slice() {
183        let lights = vec![spot(true), spot(false), spot(true)];
184        assert_eq!(assign_spot_shadow_slices(&lights), vec![0, -1, 1]);
185    }
186
187    #[test]
188    fn slices_past_the_cap_get_no_shadow() {
189        let lights: Vec<SpotLight> = (0..MAX_SHADOWED_SPOTS + 3).map(|_| spot(true)).collect();
190        let slices = assign_spot_shadow_slices(&lights);
191        assert_eq!(count_shadowed(&slices), MAX_SHADOWED_SPOTS);
192        assert_eq!(
193            slices[MAX_SHADOWED_SPOTS - 1],
194            MAX_SHADOWED_SPOTS as i32 - 1
195        );
196        assert!(slices[MAX_SHADOWED_SPOTS..].iter().all(|s| *s == -1));
197    }
198
199    #[test]
200    fn shadow_data_is_indexed_by_slice_not_by_light() {
201        let mut a = spot(false);
202        a.range = 5.0;
203        let mut b = spot(true);
204        b.range = 33.0;
205        let lights = vec![a, b];
206        let slices = assign_spot_shadow_slices(&lights);
207        assert_eq!(slices, vec![-1, 0]);
208        let data = build_spot_shadow_data(&lights, &slices);
209        // Only the casting light produced an entry, and it sits at slice 0.
210        assert_eq!(data.len(), 1);
211        // Its far plane is b's range: a point just inside it stays within depth 1.
212        let clip = transform(data[0].light_vp, [0.0, 4.0 - 32.0, 0.0]);
213        assert!(clip[3] > 0.0);
214        assert!((clip[2] / clip[3]) < 1.0);
215    }
216
217    // The cone axis maps to the centre of the slice, and the outer cone edge
218    // lands on the NDC boundary -- i.e. the frustum exactly contains the cone.
219    #[test]
220    fn the_cone_inscribes_the_shadow_frustum() {
221        let mut l = spot(true);
222        l.position = [0.0, 10.0, 0.0];
223        l.direction = [0.0, -1.0, 0.0];
224        l.outer_angle = 30.0;
225        l.range = 20.0;
226        let d = spot_shadow_data(&l);
227
228        // Straight down the axis: dead centre.
229        let centre = transform(d.light_vp, [0.0, 0.0, 0.0]);
230        assert!((centre[0] / centre[3]).abs() < 1e-4);
231        assert!((centre[1] / centre[3]).abs() < 1e-4);
232
233        // 10 units down, offset by tan(30 deg) * 10: exactly the cone edge.
234        let edge_x = 30.0_f32.to_radians().tan() * 10.0;
235        let edge = transform(d.light_vp, [edge_x, 0.0, 0.0]);
236        assert!(((edge[0] / edge[3]).abs() - 1.0).abs() < 1e-3);
237    }
238
239    // A straight-down cone is the common case and the one where a naive +Y up
240    // vector would collapse the basis into NaNs.
241    #[test]
242    fn a_straight_down_cone_produces_a_finite_matrix() {
243        let mut l = spot(true);
244        l.direction = [0.0, -1.0, 0.0];
245        let d = spot_shadow_data(&l);
246        assert!(d.light_vp.iter().flatten().all(|v| v.is_finite()));
247    }
248
249    // A zero range would collapse the depth span; it is floored instead.
250    #[test]
251    fn a_degenerate_range_still_produces_a_finite_matrix() {
252        let mut l = spot(true);
253        l.range = 0.0;
254        let d = spot_shadow_data(&l);
255        assert!(d.light_vp.iter().flatten().all(|v| v.is_finite()));
256    }
257
258    #[test]
259    fn no_shadowed_spots_renders_nothing() {
260        let mut s = SpotShadowScheduler::default();
261        assert_eq!(s.next_mask(false, 0), 0);
262    }
263
264    // Every slice is primed before the round-robin settles, so a slice is never
265    // sampled holding stale depth.
266    #[test]
267    fn all_slices_prime_on_the_first_frame() {
268        let mut s = SpotShadowScheduler::default();
269        assert_eq!(s.next_mask(false, 4), 0b1111);
270    }
271
272    #[test]
273    fn hybrid_settles_into_one_slice_per_frame() {
274        let mut s = SpotShadowScheduler::default();
275        s.next_mask(false, 4);
276        assert_eq!(s.next_mask(false, 4), 0b0010);
277        assert_eq!(s.next_mask(false, 4), 0b0100);
278        assert_eq!(s.next_mask(false, 4), 0b1000);
279        assert_eq!(s.next_mask(false, 4), 0b0001);
280    }
281
282    #[test]
283    fn every_frame_refreshes_all_slices() {
284        let mut s = SpotShadowScheduler::default();
285        s.next_mask(true, 3);
286        assert_eq!(s.next_mask(true, 3), 0b111);
287    }
288
289    // A slice that appears after priming (a larger shadowed count) is primed on
290    // the frame it appears rather than waiting for its round-robin turn.
291    #[test]
292    fn a_newly_appearing_slice_is_primed_immediately() {
293        let mut s = SpotShadowScheduler::default();
294        s.next_mask(false, 2);
295        let mask = s.next_mask(false, 4);
296        assert!(mask & 0b1100 == 0b1100, "the two new slices prime at once");
297    }
298
299    #[test]
300    fn the_mask_never_exceeds_the_shadowed_count() {
301        let mut s = SpotShadowScheduler::default();
302        for _ in 0..40 {
303            assert_eq!(s.next_mask(false, 3) & !0b111, 0);
304        }
305    }
306}