Skip to main content

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