Skip to main content

primase_core/shader/
shadow_choice.rs

1//! Which local lights a consumer shadows: the choice as machinery with options the consumer states
2//! ([`LocalShadowChoice`]), so that every consumer that draws a source's local shadow atlas chooses by the same rule and
3//! reports what it chose. The rule is a stated choice, not the source's: no game's own selection of shadowed lights is
4//! established.
5use serde::{Deserialize, Serialize};
6
7use super::{LightTableKind, ShadowedLight};
8use crate::{Entity, Light, Transform, World};
9
10/// The options of the choice: off (no light shadowed, every light lit unshadowed), or the lights of the shadowed table's
11/// kind whose `core.light` says they cast shadows and that have a range, whose range sphere meets the view's frustum
12/// and, where `max_distance` is set, comes within that many metres of the eye, taken in `order` up to the atlas's
13/// capacity and, where it is set, `max_lights`. Each option changes what is drawn, so each is the consumer's to state:
14/// there is no default, and deserialising requires every field, `null` for no limit included.
15#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct LocalShadowChoice {
18    pub enabled: bool,
19    #[serde(deserialize_with = "Option::deserialize")]
20    pub max_distance: Option<f32>,
21    #[serde(deserialize_with = "Option::deserialize")]
22    pub max_lights: Option<usize>,
23    pub order: ShadowOrder,
24}
25
26/// Which of the lights in view a choice takes first when there are more than it shadows. No game's order is
27/// established: a recorded frame shows the lights the game shadowed, not the ones it passed over.
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum ShadowOrder {
31    /// Nearest the eye first: by the distance from the eye to the light's range sphere (0 with the eye inside it), then
32    /// by the distance from the eye to its centre, then by entity.
33    NearestEye,
34}
35
36/// The view a choice is made for, in world space: its view-projection (rows: `clip = m * [p, 1]`, depth 0 to 1 in
37/// either direction) and its eye.
38#[derive(Clone, Copy, Debug, PartialEq)]
39pub struct ShadowView {
40    pub view_projection: [[f32; 4]; 4],
41    pub eye: [f32; 3],
42}
43
44/// What a choice chose for a view: the lights, in the choice's order, with their world centres, and how many it
45/// considered.
46#[derive(Clone, Debug, Default, PartialEq)]
47pub struct ChosenShadows {
48    pub lights: Vec<ShadowedLight>,
49    /// The scene's lights of the kind that cast shadows and have a range.
50    pub casters: usize,
51    /// Of those, the ones in view (and within `max_distance`).
52    pub in_view: usize,
53}
54
55/// The world's lights of `kind` that cast shadows and have a finite range above 0, with their world centre and range.
56fn casters(world: &World, kind: LightTableKind) -> impl Iterator<Item = (Entity, [f32; 3], f32)> + '_ {
57    world.iter::<Light>().filter_map(move |(e, l)| {
58        let range = l.range.filter(|r| *r > 0.0 && r.is_finite())?;
59        let t = world.get::<Transform>(e)?;
60        (kind.holds(l) && l.cast_shadows == Some(true)).then_some((e, t.cols[3], range))
61    })
62}
63
64impl LocalShadowChoice {
65    /// The most lights this choice can shadow in `world` with an atlas of `capacity`: 0 when off, else the capacity,
66    /// no more than the world's shadow-casting lights of `kind` with a range, nor `max_lights`.
67    pub fn most(&self, world: &World, kind: LightTableKind, capacity: usize) -> usize {
68        if !self.enabled {
69            return 0;
70        }
71        capacity
72            .min(casters(world, kind).count())
73            .min(self.max_lights.unwrap_or(usize::MAX))
74    }
75
76    /// The choice in words, for a consumer's report.
77    pub fn describe(&self, capacity: usize) -> String {
78        if !self.enabled {
79            return "local shadows: off; every shadow-casting light is lit without one".into();
80        }
81        format!(
82            "local shadows: lights with cast_shadows and a range, their range sphere in the view's frustum{}, {}, up to {}",
83            self.max_distance
84                .map(|d| format!(" and within {d} m of the eye"))
85                .unwrap_or_default(),
86            match self.order {
87                ShadowOrder::NearestEye => "nearest the eye first",
88            },
89            match self.max_lights {
90                Some(n) if n < capacity => format!("{n} (the atlas holds {capacity})"),
91                _ => format!("the atlas's capacity, {capacity}"),
92            }
93        )
94    }
95
96    /// The lights of `kind` in `world` this choice shadows for `view`, at most `capacity`, in the choice's `order`.
97    pub fn choose(
98        &self,
99        world: &World,
100        kind: LightTableKind,
101        view: &ShadowView,
102        capacity: usize,
103    ) -> ChosenShadows {
104        let mut chosen = ChosenShadows::default();
105        if !self.enabled {
106            return chosen;
107        }
108        let planes = frustum(&view.view_projection);
109        let mut found = Vec::new();
110        for (light, centre, range) in casters(world, kind) {
111            chosen.casters += 1;
112            let to_centre = distance(centre, view.eye);
113            let gap = (to_centre - range).max(0.0);
114            if !meets(&planes, centre, range) || self.max_distance.is_some_and(|d| gap > d) {
115                continue;
116            }
117            chosen.in_view += 1;
118            found.push((gap, to_centre, light, centre, range));
119        }
120        match self.order {
121            ShadowOrder::NearestEye => {
122                found.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2)));
123            }
124        }
125        let limit = self.max_lights.map_or(capacity, |n| n.min(capacity));
126        chosen.lights = found
127            .into_iter()
128            .take(limit)
129            .map(|(_, _, light, centre, range)| ShadowedLight { light, centre, range })
130            .collect();
131        chosen
132    }
133}
134
135fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
136    ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
137}
138
139/// The six planes of a view-projection's frustum (Gribb and Hartmann, from its rows, depth 0 to 1), each normalised:
140/// a point `p` is inside a plane where `n . p + d >= 0`.
141fn frustum(m: &[[f32; 4]; 4]) -> [[f32; 4]; 6] {
142    let row = |r: usize| m[r];
143    let add = |a: [f32; 4], b: [f32; 4], s: f32| std::array::from_fn::<f32, 4, _>(|k| a[k] + s * b[k]);
144    let (x, y, z, w) = (row(0), row(1), row(2), row(3));
145    [
146        add(w, x, 1.0),
147        add(w, x, -1.0),
148        add(w, y, 1.0),
149        add(w, y, -1.0),
150        z,
151        add(w, z, -1.0),
152    ]
153    .map(|p| {
154        let length = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
155        if length > 0.0 { p.map(|v| v / length) } else { p }
156    })
157}
158
159/// Whether the sphere of `centre` and `radius` may meet the frustum: false only when it lies wholly outside one plane.
160fn meets(planes: &[[f32; 4]; 6], centre: [f32; 3], radius: f32) -> bool {
161    planes
162        .iter()
163        .all(|p| p[0] * centre[0] + p[1] * centre[1] + p[2] * centre[2] + p[3] >= -radius)
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::LightKind;
170
171    /// Shadows on, with no distance or count limit, nearest the eye first.
172    const ALL: LocalShadowChoice = LocalShadowChoice {
173        enabled: true,
174        max_distance: None,
175        max_lights: None,
176        order: ShadowOrder::NearestEye,
177    };
178
179    /// Every option is stated: a choice that leaves one out, `null` limits included, does not deserialise.
180    #[test]
181    fn every_option_is_required() -> Result<(), serde_json::Error> {
182        let all: LocalShadowChoice = serde_json::from_str(
183            r#"{"enabled":true,"max_distance":null,"max_lights":null,"order":"nearest_eye"}"#,
184        )?;
185        assert_eq!(all, ALL);
186        let limited: LocalShadowChoice = serde_json::from_str(
187            r#"{"enabled":true,"max_distance":20.0,"max_lights":4,"order":"nearest_eye"}"#,
188        )?;
189        assert_eq!((limited.max_distance, limited.max_lights), (Some(20.0), Some(4)));
190        for partial in [
191            r#"{"max_distance":null,"max_lights":null,"order":"nearest_eye"}"#,
192            r#"{"enabled":true,"max_lights":null,"order":"nearest_eye"}"#,
193            r#"{"enabled":true,"max_distance":null,"order":"nearest_eye"}"#,
194            r#"{"enabled":true,"max_distance":null,"max_lights":null}"#,
195            r#"{"enabled":true,"max_distance":null,"max_lights":null,"order":"brightest"}"#,
196            r#"{}"#,
197        ] {
198            assert!(
199                serde_json::from_str::<LocalShadowChoice>(partial).is_err(),
200                "{partial}"
201            );
202        }
203        Ok(())
204    }
205
206    /// A world of point lights at `z` metres down -z, each of range 2 and `cast_shadows` as given.
207    fn world(lights: &[(f32, Option<bool>)]) -> (World, Vec<Entity>) {
208        let mut w = World::new();
209        let mut out = Vec::new();
210        for &(z, casts) in lights {
211            let e = w.spawn();
212            let mut t = Transform::IDENTITY;
213            t.cols[3] = [0.0, 0.0, -z];
214            w.insert(e, t);
215            w.insert(
216                e,
217                Light {
218                    kind: LightKind::Point,
219                    color: [1.0; 3],
220                    intensity: 1.0,
221                    range: Some(2.0),
222                    cast_shadows: casts,
223                    radius: None,
224                    specular: None,
225                },
226            );
227            out.push(e);
228        }
229        (w, out)
230    }
231
232    /// A camera at the origin looking down -z, 90 degrees square, 0.1 to 100 m, depth 0 to 1, rows.
233    fn view() -> ShadowView {
234        let (n, f) = (0.1f32, 100.0f32);
235        ShadowView {
236            view_projection: [
237                [1.0, 0.0, 0.0, 0.0],
238                [0.0, 1.0, 0.0, 0.0],
239                [0.0, 0.0, f / (n - f), n * f / (n - f)],
240                [0.0, 0.0, -1.0, 0.0],
241            ],
242            eye: [0.0; 3],
243        }
244    }
245
246    /// Only lights that cast shadows and are in view are chosen, nearest the eye first, and no more than asked.
247    #[test]
248    fn the_choice_takes_the_nearest_shadow_casting_lights_in_view() {
249        // a caster at 30 m, one behind the eye, one that casts none, one of unknown casting, casters at 10 and 20 m
250        let (w, e) = world(&[
251            (30.0, Some(true)),
252            (-10.0, Some(true)),
253            (5.0, Some(false)),
254            (6.0, None),
255            (10.0, Some(true)),
256            (20.0, Some(true)),
257        ]);
258        let choice = ALL;
259        let chosen = choice.choose(&w, LightTableKind::Point, &view(), 8);
260        assert_eq!((chosen.casters, chosen.in_view), (4, 3));
261        assert_eq!(
262            chosen.lights.iter().map(|l| l.light).collect::<Vec<_>>(),
263            [e[4], e[5], e[0]]
264        );
265        assert_eq!(chosen.lights[0].range, 2.0);
266        assert_eq!(chosen.lights[0].centre, [0.0, 0.0, -10.0]);
267        assert_eq!(
268            choice.choose(&w, LightTableKind::Point, &view(), 2).lights.len(),
269            2
270        );
271        let near = LocalShadowChoice {
272            max_distance: Some(20.0),
273            ..choice
274        };
275        assert_eq!(near.choose(&w, LightTableKind::Point, &view(), 8).lights.len(), 2);
276        let one = LocalShadowChoice {
277            max_lights: Some(1),
278            ..choice
279        };
280        assert_eq!(one.choose(&w, LightTableKind::Point, &view(), 8).lights.len(), 1);
281        let off = LocalShadowChoice {
282            enabled: false,
283            ..choice
284        };
285        assert!(
286            off.choose(&w, LightTableKind::Point, &view(), 8)
287                .lights
288                .is_empty()
289        );
290        assert!(
291            choice
292                .choose(&w, LightTableKind::Spot, &view(), 8)
293                .lights
294                .is_empty()
295        );
296    }
297
298    /// A light whose range sphere reaches into the frustum is in view though its centre is not.
299    #[test]
300    fn a_light_whose_range_reaches_the_view_is_in_view() {
301        let (mut w, e) = world(&[(1.0, Some(true))]);
302        // beside the 90-degree view at 1 m deep: its centre at x = 2.5 lies 1.06 m outside the edge plane, within its
303        // range of 2; at x = 5, 2.83 m outside, beyond it
304        let mut t = Transform::IDENTITY;
305        t.cols[3] = [2.5, 0.0, -1.0];
306        w.insert(e[0], t);
307        assert_eq!(ALL.choose(&w, LightTableKind::Point, &view(), 8).in_view, 1);
308        t.cols[3] = [5.0, 0.0, -1.0];
309        w.insert(e[0], t);
310        assert_eq!(ALL.choose(&w, LightTableKind::Point, &view(), 8).in_view, 0);
311    }
312
313    /// The most a choice can shadow: the atlas's capacity, no more than the scene's casters or the stated limit; none
314    /// when off.
315    #[test]
316    fn the_most_a_choice_shadows_is_bounded_by_the_atlas_the_casters_and_the_limit() {
317        let (w, _) = world(&[(10.0, Some(true)), (20.0, Some(true)), (30.0, Some(false))]);
318        let choice = ALL;
319        assert_eq!(choice.most(&w, LightTableKind::Point, 170), 2);
320        assert_eq!(choice.most(&w, LightTableKind::Point, 1), 1);
321        assert_eq!(
322            LocalShadowChoice {
323                max_lights: Some(1),
324                ..choice
325            }
326            .most(&w, LightTableKind::Point, 170),
327            1
328        );
329        assert_eq!(
330            LocalShadowChoice {
331                enabled: false,
332                ..choice
333            }
334            .most(&w, LightTableKind::Point, 170),
335            0
336        );
337    }
338}