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