Skip to main content

concinnity_core/render/
lights.rs

1//! Converts drained DirectionalLight, PointLight, SpotLight, and RectAreaLight
2//! asset components into the GPU data the renderer consumes: the fixed
3//! LightUniforms uniform (directional lights, ambient, and the legacy point array
4//! the raymarch / fog / probe paths read), the GpuLight storage buffer the
5//! clustered forward pass iterates, the per-slice spot shadow projections, and
6//! the rect area-light extents.
7
8use crate::components::{
9    DirectionalLight, PointLight, RectAreaLight, SpotLight, SpotLightGeometry,
10};
11use crate::gfx::render_types::{
12    AreaLightData, DirectionalLightData, GpuLight, LIGHT_KIND_AREA, LIGHT_KIND_POINT,
13    LIGHT_KIND_SPOT, LightUniforms, MAX_DIRECTIONAL_LIGHTS, MAX_LOCAL_LIGHTS, MAX_POINT_LIGHTS,
14    PointLightData, SpotShadowData,
15};
16use crate::render::area_light;
17use crate::render::spot_shadow;
18use alloc::vec::Vec;
19
20/// The per-scene GPU light data: the storage buffer the clustered forward pass
21/// iterates, plus the side tables it indexes into. Kept together because
22/// `GpuLight.shadow_index` indexes `spot_shadows` and `GpuLight.data_index`
23/// indexes `area_lights` -- invariants that would be easy to break if the three
24/// were built independently.
25pub struct LightData {
26    /// Every local light for the clustered forward pass.
27    pub lights: Vec<GpuLight>,
28    /// One entry per shadowed spot light.
29    pub spot_shadows: Vec<SpotShadowData>,
30    /// One entry per rectangular area light.
31    pub area_lights: Vec<AreaLightData>,
32}
33
34/// Packs point, spot, and rect area lights into the GpuLight storage buffer and
35/// assigns their side-table slots. All three share the MAX_LOCAL_LIGHTS budget
36/// (not the 8-entry LightUniforms array); extras past the cap are dropped with a
37/// warning. Unused fields stay at their neutral GpuLight::ZERO values, so a point
38/// light carries no cone, shadow, or area data.
39pub fn build_light_data(
40    pt_lights: &[PointLight],
41    spot_lights: &[SpotLight],
42    rect_lights: &[RectAreaLight],
43) -> LightData {
44    let slices = spot_shadow::assign_spot_shadow_slices(spot_lights);
45    let spot_shadows = spot_shadow::build_spot_shadow_data(spot_lights, &slices);
46    let slots = area_light::assign_area_light_slots(rect_lights);
47    let area_lights = area_light::build_area_light_data(rect_lights, &slots);
48
49    let points = pt_lights.iter().map(|l| GpuLight {
50        position: l.position,
51        range: l.range,
52        color: l.color,
53        intensity: l.intensity,
54        kind: LIGHT_KIND_POINT,
55        ..GpuLight::ZERO
56    });
57    let spots = spot_lights
58        .iter()
59        .zip(&slices)
60        .map(|(l, &shadow_index)| GpuLight {
61            position: l.position,
62            range: l.range,
63            color: l.color,
64            intensity: l.intensity,
65            direction: l.unit_direction(),
66            kind: LIGHT_KIND_SPOT,
67            cos_inner: l.cos_inner(),
68            cos_outer: l.cos_outer(),
69            shadow_index,
70            ..GpuLight::ZERO
71        });
72    let areas = rect_lights
73        .iter()
74        .zip(&slots)
75        .map(|(l, &data_index)| GpuLight {
76            position: l.centre,
77            range: l.range,
78            color: l.color,
79            intensity: l.intensity,
80            direction: l.normal,
81            kind: LIGHT_KIND_AREA,
82            data_index,
83            ..GpuLight::ZERO
84        });
85    let lights: Vec<GpuLight> = points
86        .chain(spots)
87        .chain(areas)
88        .take(MAX_LOCAL_LIGHTS)
89        .collect();
90
91    // A spot dropped by the MAX_LOCAL_LIGHTS clamp must not leave a slice
92    // reserved for a light the forward pass will never see.
93    let kept = lights.iter().filter(|l| l.shadow_index >= 0).count();
94    let spot_shadows = if kept < spot_shadows.len() {
95        spot_shadows[..kept].to_vec()
96    } else {
97        spot_shadows
98    };
99
100    // An area light dropped by the MAX_LOCAL_LIGHTS clamp must not leave a table
101    // entry the forward pass will never reach.
102    let kept_areas = lights.iter().filter(|l| l.data_index >= 0).count();
103    let area_lights = if kept_areas < area_lights.len() {
104        area_lights[..kept_areas].to_vec()
105    } else {
106        area_lights
107    };
108
109    LightData {
110        lights,
111        spot_shadows,
112        area_lights,
113    }
114}
115
116const ZERO_DIR: DirectionalLightData = DirectionalLightData {
117    direction: [0.0; 3],
118    intensity: 0.0,
119    color: [0.0; 3],
120    _pad: 0.0,
121};
122const ZERO_PT: PointLightData = PointLightData {
123    position: [0.0; 3],
124    range: 0.0,
125    color: [0.0; 3],
126    intensity: 0.0,
127};
128
129/// The `LightUniforms` directional slots and their count for `lights`, padded
130/// with zeroed entries. Shared by the init-time build below and the live
131/// [`crate::render::backend::RenderBackend::update_directional_lights`] seam, so a sun
132/// edited at runtime packs exactly as a relaunch would pack it.
133pub fn directional_light_data(
134    lights: &[DirectionalLight],
135) -> ([DirectionalLightData; MAX_DIRECTIONAL_LIGHTS], i32) {
136    let mut directional = [ZERO_DIR; MAX_DIRECTIONAL_LIGHTS];
137    for (slot, l) in directional.iter_mut().zip(lights) {
138        *slot = DirectionalLightData {
139            direction: l.direction,
140            intensity: l.intensity,
141            color: l.color,
142            _pad: 0.0,
143        };
144    }
145    (directional, lights.len().min(MAX_DIRECTIONAL_LIGHTS) as i32)
146}
147
148/// The direction the cascade shadow projection and the fog ray-march follow:
149/// the first declared directional light, or the neutral sun a world with none
150/// falls back to. Backends cache this at init and re-derive it when the light
151/// set changes.
152pub fn sun_direction(uniforms: &LightUniforms) -> [f32; 3] {
153    if uniforms.num_directional > 0 {
154        uniforms.directional[0].direction
155    } else {
156        LightUniforms::DEFAULT.directional[0].direction
157    }
158}
159
160/// The intensity-weighted colour of the sun `sun_direction` names, white when
161/// the world declares no directional light.
162pub fn sun_color(uniforms: &LightUniforms) -> [f32; 3] {
163    if uniforms.num_directional > 0 {
164        let l = &uniforms.directional[0];
165        [
166            l.color[0] * l.intensity,
167            l.color[1] * l.intensity,
168            l.color[2] * l.intensity,
169        ]
170    } else {
171        [1.0, 1.0, 1.0]
172    }
173}
174
175/// `local_lights` is the buffer `build_light_data` produced; its length is the
176/// authoritative `num_local_lights` the forward pass iterates.
177pub fn build_light_uniforms(
178    dir_lights: Vec<DirectionalLight>,
179    pt_lights: Vec<PointLight>,
180    local_lights: &[GpuLight],
181    ambient_intensity: f32,
182) -> LightUniforms {
183    if dir_lights.is_empty() && pt_lights.is_empty() && local_lights.is_empty() {
184        return LightUniforms {
185            ambient_intensity,
186            ..LightUniforms::DEFAULT
187        };
188    }
189
190    let (directional, num_directional) = directional_light_data(&dir_lights);
191    // The `point` array is the legacy subset the raymarch / fog / probe paths
192    // read; the forward pass reads every light from the GpuLight buffer instead
193    // (see build_light_data), so exceeding MAX_POINT_LIGHTS is not an error.
194    let mut point = [ZERO_PT; MAX_POINT_LIGHTS];
195    let num_point = pt_lights.len().min(MAX_POINT_LIGHTS);
196    for (i, l) in pt_lights.into_iter().take(MAX_POINT_LIGHTS).enumerate() {
197        point[i] = PointLightData {
198            position: l.position,
199            range: l.range,
200            color: l.color,
201            intensity: l.intensity,
202        };
203    }
204
205    LightUniforms {
206        directional,
207        point,
208        num_directional,
209        num_point: num_point as i32,
210        ambient_intensity,
211        num_local_lights: local_lights.len() as i32,
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    use alloc::vec;
220    fn dir(direction: [f32; 3], color: [f32; 3], intensity: f32) -> DirectionalLight {
221        DirectionalLight {
222            direction,
223            color,
224            intensity,
225        }
226    }
227
228    fn pt(position: [f32; 3], color: [f32; 3], intensity: f32, range: f32) -> PointLight {
229        PointLight {
230            position,
231            color,
232            intensity,
233            range,
234        }
235    }
236
237    fn spot(position: [f32; 3], direction: [f32; 3], inner: f32, outer: f32) -> SpotLight {
238        SpotLight {
239            position,
240            direction,
241            inner_angle: inner,
242            outer_angle: outer,
243            ..SpotLight::default()
244        }
245    }
246
247    // The uniforms every test that does not exercise the local buffer wants.
248    fn uniforms(dir_lights: Vec<DirectionalLight>, pt_lights: Vec<PointLight>) -> LightUniforms {
249        let local = build_light_data(&pt_lights, &[], &[]).lights;
250        build_light_uniforms(dir_lights, pt_lights, &local, 1.0)
251    }
252
253    // The live seam packs a sun exactly as the init-time build does, so a
254    // runtime light edit and a relaunch produce the same uniform.
255    #[test]
256    fn directional_packing_matches_the_init_build() {
257        let lights = vec![
258            dir([-0.3, 0.85, 0.4], [1.0, 0.95, 0.8], 1.5),
259            dir([0.1, -1.0, 0.0], [0.2, 0.3, 0.4], 0.5),
260        ];
261        let (packed, count) = directional_light_data(&lights);
262        let built = uniforms(lights, vec![]);
263        assert_eq!(count, built.num_directional);
264        assert_eq!(packed, built.directional);
265    }
266
267    // Slots past the declared lights are zeroed, so a shorter set never leaves
268    // a stale sun behind it.
269    #[test]
270    fn directional_packing_zeroes_the_unused_slots() {
271        let (packed, count) = directional_light_data(&[dir([0.0, 1.0, 0.0], [1.0; 3], 2.0)]);
272        assert_eq!(count, 1);
273        assert!(packed[1..].iter().all(|d| *d == ZERO_DIR));
274        let (empty, none) = directional_light_data(&[]);
275        assert_eq!(none, 0);
276        assert!(empty.iter().all(|d| *d == ZERO_DIR));
277    }
278
279    // Extras past the fixed array are dropped rather than overflowing it.
280    #[test]
281    fn directional_packing_clamps_to_the_array_capacity() {
282        let many: Vec<DirectionalLight> = (0..MAX_DIRECTIONAL_LIGHTS + 2)
283            .map(|i| dir([0.0, 1.0, 0.0], [1.0; 3], i as f32))
284            .collect();
285        let (packed, count) = directional_light_data(&many);
286        assert_eq!(count, MAX_DIRECTIONAL_LIGHTS as i32);
287        assert_eq!(packed.len(), MAX_DIRECTIONAL_LIGHTS);
288    }
289
290    #[test]
291    fn empty_inputs_return_default() {
292        let u = uniforms(vec![], vec![]);
293        assert_eq!(u.num_directional, LightUniforms::DEFAULT.num_directional);
294        assert_eq!(u.num_point, LightUniforms::DEFAULT.num_point);
295    }
296
297    #[test]
298    fn ambient_intensity_carried_in_both_branches() {
299        // Empty (DEFAULT) branch and the populated branch both honour the
300        // authored multiplier.
301        let empty = build_light_uniforms(vec![], vec![], &[], 2.5);
302        assert!((empty.ambient_intensity - 2.5).abs() < 1e-6);
303        let populated = build_light_uniforms(
304            vec![dir([-0.3, 0.85, 0.4], [1.0; 3], 1.0)],
305            vec![],
306            &[],
307            3.0,
308        );
309        assert!((populated.ambient_intensity - 3.0).abs() < 1e-6);
310    }
311
312    #[test]
313    fn single_directional_light_fields_mapped() {
314        let u = uniforms(vec![dir([-0.3, 0.85, 0.4], [1.0, 0.95, 0.8], 1.5)], vec![]);
315        assert_eq!(u.num_directional, 1);
316        assert_eq!(u.num_point, 0);
317        assert_eq!(u.directional[0].direction, [-0.3, 0.85, 0.4]);
318        assert_eq!(u.directional[0].color, [1.0, 0.95, 0.8]);
319        assert!((u.directional[0].intensity - 1.5).abs() < 1e-6);
320    }
321
322    #[test]
323    fn single_point_light_fields_mapped() {
324        let u = uniforms(vec![], vec![pt([2.0, 3.0, 4.0], [1.0, 0.8, 0.5], 8.0, 6.0)]);
325        assert_eq!(u.num_directional, 0);
326        assert_eq!(u.num_point, 1);
327        assert_eq!(u.point[0].position, [2.0, 3.0, 4.0]);
328        assert_eq!(u.point[0].color, [1.0, 0.8, 0.5]);
329        assert!((u.point[0].intensity - 8.0).abs() < 1e-6);
330        assert!((u.point[0].range - 6.0).abs() < 1e-6);
331    }
332
333    #[test]
334    fn excess_directional_lights_clamped_to_max() {
335        let lights: Vec<DirectionalLight> = (0..MAX_DIRECTIONAL_LIGHTS + 2)
336            .map(|i| dir([i as f32, 0.0, 0.0], [1.0; 3], 1.0))
337            .collect();
338        let u = uniforms(lights, vec![]);
339        assert_eq!(u.num_directional, MAX_DIRECTIONAL_LIGHTS as i32);
340    }
341
342    #[test]
343    fn excess_point_lights_clamped_to_max() {
344        // The legacy `point` array (raymarch / fog / probe) still caps at 8, but
345        // num_local_lights carries the full count for the forward pass.
346        let lights: Vec<PointLight> = (0..MAX_POINT_LIGHTS + 2)
347            .map(|i| pt([i as f32, 0.0, 0.0], [1.0; 3], 1.0, 5.0))
348            .collect();
349        let u = uniforms(vec![], lights);
350        assert_eq!(u.num_point, MAX_POINT_LIGHTS as i32);
351        assert_eq!(u.num_local_lights, (MAX_POINT_LIGHTS + 2) as i32);
352    }
353
354    // Spot lights live only in the local buffer, so a spot-only scene still has
355    // to report them through num_local_lights.
356    #[test]
357    fn num_local_lights_counts_spot_lights() {
358        let local =
359            build_light_data(&[], &[spot([0.0; 3], [0.0, -1.0, 0.0], 10.0, 20.0)], &[]).lights;
360        let u = build_light_uniforms(vec![], vec![], &local, 1.0);
361        assert_eq!(u.num_point, 0);
362        assert_eq!(u.num_local_lights, 1);
363    }
364
365    #[test]
366    fn light_buffer_maps_point_light_fields() {
367        let buf =
368            build_light_data(&[pt([2.0, 3.0, 4.0], [1.0, 0.8, 0.5], 8.0, 6.0)], &[], &[]).lights;
369        assert_eq!(buf.len(), 1);
370        assert_eq!(buf[0].position, [2.0, 3.0, 4.0]);
371        assert_eq!(buf[0].color, [1.0, 0.8, 0.5]);
372        assert!((buf[0].intensity - 8.0).abs() < 1e-6);
373        assert!((buf[0].range - 6.0).abs() < 1e-6);
374        assert_eq!(buf[0].kind, LIGHT_KIND_POINT);
375        // Point lights carry no cone or shadow data.
376        assert_eq!(buf[0].shadow_index, -1);
377        assert_eq!(buf[0].direction, [0.0; 3]);
378        assert_eq!(buf[0].cos_inner, 0.0);
379        assert_eq!(buf[0].cos_outer, 0.0);
380    }
381
382    #[test]
383    fn light_buffer_maps_spot_light_fields() {
384        let buf = build_light_data(
385            &[],
386            &[spot([1.0, 5.0, 2.0], [0.0, -2.0, 0.0], 15.0, 30.0)],
387            &[],
388        )
389        .lights;
390        assert_eq!(buf.len(), 1);
391        assert_eq!(buf[0].position, [1.0, 5.0, 2.0]);
392        assert_eq!(buf[0].kind, LIGHT_KIND_SPOT);
393        // The authored direction is normalised into the record.
394        assert_eq!(buf[0].direction, [0.0, -1.0, 0.0]);
395        assert!((buf[0].cos_inner - 15.0f32.to_radians().cos()).abs() < 1e-6);
396        assert!((buf[0].cos_outer - 30.0f32.to_radians().cos()).abs() < 1e-6);
397        // A wider inner cone than outer would invert the falloff; it is clamped.
398        assert!(buf[0].cos_inner >= buf[0].cos_outer);
399    }
400
401    #[test]
402    fn spot_inner_cone_clamped_to_the_outer_cone() {
403        let buf =
404            build_light_data(&[], &[spot([0.0; 3], [0.0, -1.0, 0.0], 60.0, 20.0)], &[]).lights;
405        assert!((buf[0].cos_inner - buf[0].cos_outer).abs() < 1e-6);
406    }
407
408    #[test]
409    fn spot_lights_follow_the_point_lights_in_the_buffer() {
410        let buf = build_light_data(
411            &[
412                pt([0.0; 3], [1.0; 3], 1.0, 5.0),
413                pt([1.0; 3], [1.0; 3], 1.0, 5.0),
414            ],
415            &[spot([2.0; 3], [0.0, -1.0, 0.0], 10.0, 20.0)],
416            &[],
417        )
418        .lights;
419        assert_eq!(buf.len(), 3);
420        assert_eq!(buf[0].kind, LIGHT_KIND_POINT);
421        assert_eq!(buf[1].kind, LIGHT_KIND_POINT);
422        assert_eq!(buf[2].kind, LIGHT_KIND_SPOT);
423    }
424
425    // GpuLight.shadow_index indexes spot_shadows, so the two must agree.
426    #[test]
427    fn shadow_indices_point_at_real_spot_shadow_entries() {
428        let mut casting = spot([0.0, 5.0, 0.0], [0.0, -1.0, 0.0], 10.0, 20.0);
429        casting.cast_shadows = true;
430        let mut dark = spot([3.0, 5.0, 0.0], [0.0, -1.0, 0.0], 10.0, 20.0);
431        dark.cast_shadows = false;
432        let data = build_light_data(
433            &[pt([0.0; 3], [1.0; 3], 1.0, 5.0)],
434            &[
435                casting,
436                dark,
437                spot([6.0, 5.0, 0.0], [0.0, -1.0, 0.0], 10.0, 20.0),
438            ],
439            &[],
440        );
441        // Point lights never cast; the two casting spots take slices 0 and 1.
442        assert_eq!(data.lights[0].shadow_index, -1);
443        assert_eq!(data.lights[1].shadow_index, 0);
444        assert_eq!(data.lights[2].shadow_index, -1);
445        assert_eq!(data.lights[3].shadow_index, 1);
446        assert_eq!(data.spot_shadows.len(), 2);
447        for l in &data.lights {
448            assert!(
449                l.shadow_index < data.spot_shadows.len() as i32,
450                "shadow_index stays in bounds of spot_shadows"
451            );
452        }
453    }
454
455    // A spot dropped by the MAX_LOCAL_LIGHTS clamp must not leave a shadow slice
456    // reserved for a light the forward pass never sees.
457    #[test]
458    fn clamped_spots_do_not_strand_shadow_slices() {
459        let points: Vec<PointLight> = (0..MAX_LOCAL_LIGHTS - 1)
460            .map(|i| pt([i as f32, 0.0, 0.0], [1.0; 3], 1.0, 5.0))
461            .collect();
462        let spots: Vec<SpotLight> = (0..4)
463            .map(|i| {
464                let mut s = spot([i as f32, 5.0, 0.0], [0.0, -1.0, 0.0], 10.0, 20.0);
465                s.cast_shadows = true;
466                s
467            })
468            .collect();
469        let data = build_light_data(&points, &spots, &[]);
470        assert_eq!(data.lights.len(), MAX_LOCAL_LIGHTS);
471        // Only one spot survived the clamp, so only its slice is kept.
472        assert_eq!(data.spot_shadows.len(), 1);
473        let max_index = data.lights.iter().map(|l| l.shadow_index).max().unwrap();
474        assert_eq!(max_index, 0);
475    }
476
477    fn area(centre: [f32; 3], half_size: [f32; 2]) -> RectAreaLight {
478        RectAreaLight {
479            centre,
480            half_size,
481            ..RectAreaLight::default()
482        }
483    }
484
485    // GpuLight.data_index indexes area_lights, so the two must agree, and area
486    // lights must not disturb the spot shadow indices.
487    #[test]
488    fn area_lights_follow_the_other_kinds_and_index_their_table() {
489        let mut casting = spot([0.0, 5.0, 0.0], [0.0, -1.0, 0.0], 10.0, 20.0);
490        casting.cast_shadows = true;
491        let data = build_light_data(
492            &[pt([0.0; 3], [1.0; 3], 1.0, 5.0)],
493            &[casting],
494            &[
495                area([2.0, 3.0, 0.0], [1.0, 2.0]),
496                area([5.0; 3], [1.0, 1.0]),
497            ],
498        );
499        assert_eq!(data.lights.len(), 4);
500        assert_eq!(data.lights[2].kind, LIGHT_KIND_AREA);
501        assert_eq!(data.lights[3].kind, LIGHT_KIND_AREA);
502        assert_eq!(data.lights[2].data_index, 0);
503        assert_eq!(data.lights[3].data_index, 1);
504        assert_eq!(data.area_lights.len(), 2);
505        // Point and spot lights carry no area data; the spot keeps its slice.
506        assert_eq!(data.lights[0].data_index, -1);
507        assert_eq!(data.lights[1].data_index, -1);
508        assert_eq!(data.lights[1].shadow_index, 0);
509        for l in &data.lights {
510            assert!(l.data_index < data.area_lights.len() as i32);
511        }
512    }
513
514    // The area light's centre and emitting direction ride the GpuLight record.
515    #[test]
516    fn area_light_centre_and_normal_map_onto_the_gpu_light() {
517        let mut l = area([1.0, 2.0, 3.0], [1.0, 1.0]);
518        l.normal = [0.0, 0.0, 1.0];
519        let data = build_light_data(&[], &[], &[l]);
520        assert_eq!(data.lights[0].position, [1.0, 2.0, 3.0]);
521        assert_eq!(data.lights[0].direction, [0.0, 0.0, 1.0]);
522    }
523
524    #[test]
525    fn clamped_area_lights_do_not_strand_table_entries() {
526        let points: Vec<PointLight> = (0..MAX_LOCAL_LIGHTS - 1)
527            .map(|i| pt([i as f32, 0.0, 0.0], [1.0; 3], 1.0, 5.0))
528            .collect();
529        let areas: Vec<RectAreaLight> = (0..4).map(|_| area([0.0; 3], [1.0, 1.0])).collect();
530        let data = build_light_data(&points, &[], &areas);
531        assert_eq!(data.lights.len(), MAX_LOCAL_LIGHTS);
532        assert_eq!(data.area_lights.len(), 1);
533    }
534
535    #[test]
536    fn light_buffer_carries_more_than_the_legacy_cap() {
537        let lights: Vec<PointLight> = (0..MAX_POINT_LIGHTS + 50)
538            .map(|i| pt([i as f32, 0.0, 0.0], [1.0; 3], 1.0, 5.0))
539            .collect();
540        let buf = build_light_data(&lights, &[], &[]).lights;
541        assert_eq!(buf.len(), MAX_POINT_LIGHTS + 50);
542    }
543
544    #[test]
545    fn light_buffer_clamped_to_capacity() {
546        let lights: Vec<PointLight> = (0..MAX_LOCAL_LIGHTS + 10)
547            .map(|i| pt([i as f32, 0.0, 0.0], [1.0; 3], 1.0, 5.0))
548            .collect();
549        let buf = build_light_data(&lights, &[], &[]).lights;
550        assert_eq!(buf.len(), MAX_LOCAL_LIGHTS);
551    }
552
553    // Point and spot lights share one budget: the spots are what overflow.
554    #[test]
555    fn point_and_spot_lights_share_the_capacity() {
556        let points: Vec<PointLight> = (0..MAX_LOCAL_LIGHTS - 1)
557            .map(|i| pt([i as f32, 0.0, 0.0], [1.0; 3], 1.0, 5.0))
558            .collect();
559        let spots: Vec<SpotLight> = (0..4)
560            .map(|i| spot([i as f32, 0.0, 0.0], [0.0, -1.0, 0.0], 10.0, 20.0))
561            .collect();
562        let buf = build_light_data(&points, &spots, &[]).lights;
563        assert_eq!(buf.len(), MAX_LOCAL_LIGHTS);
564        assert_eq!(buf[MAX_LOCAL_LIGHTS - 1].kind, LIGHT_KIND_SPOT);
565    }
566
567    // The cascade projection and the fog ray-march follow the first declared
568    // directional light, and its colour is weighted by intensity so a dim sun
569    // does not light the march as brightly as a bright one.
570    #[test]
571    fn the_sun_is_the_first_declared_directional_light() {
572        let u = uniforms(
573            vec![
574                dir([-0.3, 0.85, 0.4], [1.0, 0.5, 0.25], 2.0),
575                dir([0.1, -1.0, 0.0], [0.0, 1.0, 0.0], 9.0),
576            ],
577            vec![],
578        );
579        assert_eq!(sun_direction(&u), u.directional[0].direction);
580        assert_eq!(sun_color(&u), [2.0, 1.0, 0.5]);
581    }
582
583    // A world lit only by point lights declares no directional one, but the
584    // cascade projection still needs an axis and the fog still needs a colour
585    // to integrate against, so both fall back to the neutral sun.
586    #[test]
587    fn a_world_with_no_directional_light_falls_back_to_a_neutral_sun() {
588        let u = uniforms(vec![], vec![pt([0.0, 2.0, 0.0], [1.0; 3], 1.0, 10.0)]);
589        assert_eq!(u.num_directional, 0);
590        assert_eq!(
591            sun_direction(&u),
592            LightUniforms::DEFAULT.directional[0].direction
593        );
594        assert_eq!(sun_color(&u), [1.0, 1.0, 1.0]);
595    }
596}