codecraft 0.1.1

A minimalist 3D game engine built on parts of Bevy (ECS, color) with wgpu and winit: OpenPBR materials, clustered lighting, an immediate-mode UI, audio and gamepad haptics
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
use bevy_color::Color;
use bevy_ecs::prelude::{Query, ResMut, Resource};
use glam::Vec3;

#[derive(crate::ecs::Component, Clone, Copy, Debug, PartialEq)]
pub struct Light {
    pub direction: Vec3,
    /// The colour of the light itself. Always applied; see [`Light::color`].
    pub color: Color,
    /// The temperature standing behind it, when one is switched on: 2000 is
    /// candlelight, 5500 daylight, 10000 a blue sky. See [`kelvin`].
    pub kelvin: Option<f32>,
    pub strength: f32,
    pub angle: f32,
    /// Whether it casts a shadow.
    ///
    /// The one real difference between the two directional lights a frame is
    /// shaded by, and the reason it is an option here rather than a kind of
    /// light somewhere else. A light that casts is shaded against the shadow
    /// map; one that does not is shaded against ambient occlusion instead,
    /// which is what lets it find an edge without a second shadow map to
    /// find it with.
    pub shadow: bool,
}

/// A plain white light from straight above, of ordinary strength, with a
/// sun's width to it, casting a shadow.
///
/// Somewhere to start building from and nothing more. What a *particular*
/// light is -- which way it comes from, how warm, how strong, how soft its
/// shadow, whether it casts one at all -- is the scene's to say, because it
/// is a fact about that scene and not about lighting in general. See
/// [`default_lights`] for a stock pair a scene can start from.
impl Default for Light {
    fn default() -> Self {
        Self {
            direction: Vec3::Y,
            color: Color::WHITE,
            kelvin: None,
            // Irradiance, and about pi: a diffuse BRDF integrates to
            // albedo/pi, so a light that used to read as "one" against a
            // shader that skipped the normalisation has to be pi to land in
            // the same place against one that does not.
            strength: 2.2,
            angle: 0.25,
            shadow: true,
        }
    }
}

impl Light {
    /// Whether it casts a shadow.
    pub fn shadow(mut self, shadow: bool) -> Self {
        self.shadow = shadow;
        self
    }

    /// Which way it comes from.
    pub fn from(mut self, direction: Vec3) -> Self {
        self.direction = direction;
        self
    }

    /// What colour it is, before any temperature.
    pub fn tint(mut self, color: Color) -> Self {
        self.color = color;
        self
    }

    /// Switches the temperature on, at `kelvin`.
    pub fn temperature(mut self, kelvin: f32) -> Self {
        self.kelvin = Some(kelvin);
        self
    }

    /// Switches it off again, leaving the colour to say everything.
    pub fn no_temperature(mut self) -> Self {
        self.kelvin = None;
        self
    }

    pub fn strength(mut self, strength: f32) -> Self {
        self.strength = strength;
        self
    }

    /// How wide the source is, which is how soft its shadow is.
    pub fn angle(mut self, degrees: f32) -> Self {
        self.angle = degrees;
        self
    }

    /// Its colour, in linear RGB.
    ///
    /// A light carries a colour and, *optionally*, a temperature -- the same
    /// pair Blender puts on a lamp, and the same rule: with the temperature
    /// switched on the two multiply, so the colour tints the light the black
    /// body makes rather than being thrown away by it. With it switched off
    /// the colour is the whole of it.
    pub fn color(&self) -> Vec3 {
        let tint = self.color.to_linear();
        let tint = Vec3::new(tint.red, tint.green, tint.blue);
        match self.kelvin {
            Some(temperature) => kelvin(temperature) * tint,
            None => tint,
        }
    }
}

/// The light with no direction: the sky, and the room.
#[derive(Resource, Clone, Copy, Debug, PartialEq)]
pub struct Ambient {
    pub kelvin: f32,
    pub strength: f32,
    /// How much of it is held back where the world closes in on itself. 0
    /// turns ambient occlusion off.
    pub occlusion: f32,
}

impl Default for Ambient {
    fn default() -> Self {
        Self {
            kelvin: 7000.0,
            // A room lit by its own lamps, not an overcast sky: enough fill
            // to keep a shadow from going to nothing, and no more.
            strength: 0.22,
            occlusion: 1.0,
        }
    }
}

impl Ambient {
    /// Its colour, in linear RGB, already scaled by how much of it there is.
    pub fn color(&self) -> Vec3 {
        kelvin(self.kelvin) * self.strength
    }
}

/// The directional lights a frame is shaded by, gathered from the world.
///
/// *Derived*, and the only reason it exists: a directional light is an
/// ordinary entity, so it can be listed, picked and drawn like anything else
/// in the scene -- but the shader has to see the whole set at once, and this
/// is where they are put for it.
///
/// There is room for two, and which is which is read off the lights
/// themselves rather than assigned: see [`Light::shadow`].
#[derive(Resource, Clone, Copy, Debug, Default, PartialEq)]
pub struct Suns {
    /// The one that casts, shaded against the shadow map.
    pub shadowed: Option<Light>,
    /// The one that does not, shaded against ambient occlusion instead.
    pub unshadowed: Option<Light>,
}

/// A stock pair to start a scene from: warm from over the left shoulder and
/// casting, cool from the right and casting nothing -- the way a lamp on a
/// desk and the window behind it fall on a thing being looked at.
///
/// A recipe, not a fallback: nothing is lit by these unless a scene spawns
/// them. A scene that says nothing about lighting has no lights, and its
/// outliner says so.
///
/// ```no_run
/// # use codecraft::{AppState, Light};
/// # use codecraft::sceneobjects::lights::default_lights;
/// # fn demo(app: &mut AppState) {
/// let [key, rim] = default_lights();
/// app.spawn_entity((key, Light::item("Key Light")));
/// app.spawn_entity((rim, Light::item("Rim Light")));
/// # }
/// ```
pub fn default_lights() -> [Light; 2] {
    [
        Light::default()
            .from(Vec3::new(-0.55, 1.0, -0.45))
            .temperature(4200.0)
            .strength(2.2)
            .angle(1.5),
        Light::default()
            .from(Vec3::new(1.0, 0.35, 0.1))
            .temperature(9500.0)
            .strength(1.5)
            // It casts nothing, so it has no penumbra to open.
            .angle(0.0)
            .shadow(false),
    ]
}

/// The colour of light at a temperature, in linear RGB.
///
/// Black-body radiation, by Tanner Helland's fit to the Planckian locus —
/// close enough over the range a lamp or the sky covers, and cheap. The
/// result is scaled so its brightest channel is 1: changing a light's
/// temperature should change its colour, not how bright the scene is.
///
/// ```
/// # use codecraft::sceneobjects::lights::kelvin;
/// let candle = kelvin(1900.0);
/// assert!(candle.x > candle.z, "candlelight is warm");
/// ```
pub fn kelvin(kelvin: f32) -> Vec3 {
    let t = kelvin.clamp(1000.0, 40000.0) / 100.0;

    let red = match t <= 66.0 {
        true => 255.0,
        false => 329.698_73 * (t - 60.0).powf(-0.133_204_76),
    };
    let green = match t <= 66.0 {
        true => 99.470_8 * t.ln() - 161.119_57,
        false => 288.122_16 * (t - 60.0).powf(-0.075_514_85),
    };
    let blue = if t >= 66.0 {
        255.0
    } else if t <= 19.0 {
        0.0
    } else {
        138.517_73 * (t - 10.0).ln() - 305.044_8
    };

    let srgb = Vec3::new(red, green, blue).clamp(Vec3::ZERO, Vec3::splat(255.0)) / 255.0;
    let srgb = srgb / srgb.max_element().max(1e-4);
    // The fit is in the space a monitor shows; the shader works in light.
    Vec3::new(to_linear(srgb.x), to_linear(srgb.y), to_linear(srgb.z))
}

/// One channel out of the space a monitor shows and into the one light adds
/// up in.
fn to_linear(value: f32) -> f32 {
    match value <= 0.04045 {
        true => value / 12.92,
        false => ((value + 0.055) / 1.055).powf(2.4),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_warm_light_is_red_and_a_cool_one_is_blue() {
        let candle = kelvin(1900.0);
        assert!(candle.x > candle.y && candle.y > candle.z, "{candle:?}");

        let sky = kelvin(12000.0);
        assert!(sky.z > sky.y && sky.y > sky.x, "{sky:?}");
    }

    #[test]
    fn daylight_is_very_nearly_white() {
        // 6500K is the white a monitor is calibrated to, so it should come
        // back as near enough no tint at all.
        let daylight = kelvin(6500.0);
        let spread = daylight.max_element() - daylight.min_element();
        assert!(spread < 0.12, "daylight should be neutral: {daylight:?}");
    }

    #[test]
    fn temperature_changes_the_colour_and_not_the_brightness() {
        // The brightest channel is always full, so warming a light does not
        // quietly dim the scene.
        for temperature in [1500.0, 3000.0, 5500.0, 9000.0, 20000.0] {
            let color = kelvin(temperature);
            assert!(
                (color.max_element() - 1.0).abs() < 1e-4,
                "{temperature}K came back at {color:?}",
            );
        }
    }

    #[test]
    fn the_range_is_bounded_rather_than_going_strange_at_the_ends() {
        for temperature in [-100.0, 0.0, 1.0, 1e9] {
            let color = kelvin(temperature);
            assert!(color.is_finite(), "{temperature}K gave {color:?}");
            assert!(color.min_element() >= 0.0 && color.max_element() <= 1.0);
        }
    }

    #[test]
    fn the_default_lighting_is_warm_from_the_left_and_cool_from_the_right() {
        let [key, rim] = default_lights();

        assert!(key.shadow, "the warm one is the one that casts");
        assert!(key.direction.x < 0.0, "the key comes from the left");
        assert!(key.direction.y > 0.0, "and from above");
        assert!(rim.direction.x > 0.0, "the rim comes from the right");
        assert!(key.kelvin < rim.kelvin, "warm key, cool rim");
        assert!(
            rim.strength < key.strength,
            "the rim is a hint, not a second sun",
        );
        assert!(!rim.shadow, "the rim casts nothing");
    }

    #[test]
    fn a_light_can_be_built_up_from_the_default_one() {
        let light = Light::default().temperature(3000.0).strength(0.7).angle(4.0);

        assert_eq!(light.kelvin, Some(3000.0));
        assert_eq!(light.strength, 0.7);
        assert_eq!(light.angle, 4.0);
        assert_eq!(light.direction, Light::default().direction, "unchanged");
    }
}

/// A light with a place in the world.
///
/// The key and the rim are directions and nothing more -- they are infinitely
/// far away, so they reach every surface facing them and there is nothing to
/// cull. A local light reaches only as far as [`range`](LocalLight::range),
/// and that is the whole basis of the clustered pass: a surface only has to
/// answer to the lights whose reach covers the cluster it sits in.
#[derive(crate::ecs::Component, Clone, Copy, Debug, PartialEq)]
pub struct LocalLight {
    pub position: Vec3,
    /// Colour temperature in kelvin, as [`Light`].
    pub kelvin: f32,
    /// How bright at the source, before the fall-off.
    pub intensity: f32,
    /// Past this it contributes nothing. Not a physical quantity -- an
    /// inverse square never reaches zero -- but the distance at which what is
    /// left is not worth lighting a cluster for.
    pub range: f32,
    pub shape: LightShape,
}

/// Whether a local light goes everywhere or down a cone.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum LightShape {
    /// Everywhere at once.
    Point,
    /// Down a cone: full strength inside `inner`, nothing outside `outer`,
    /// and falling off between the two. Both are half-angles in radians.
    Spot {
        /// Which way the cone points, from the light out into the world.
        direction: Vec3,
        inner: f32,
        outer: f32,
    },
}

impl LocalLight {
    /// A bare bulb at `position`.
    pub fn point(position: Vec3, range: f32) -> Self {
        Self {
            position,
            kelvin: 4000.0,
            intensity: 1.0,
            range,
            shape: LightShape::Point,
        }
    }

    /// A lamp pointed somewhere, with a soft edge to its pool of light.
    pub fn spot(position: Vec3, towards: Vec3, range: f32, cone: f32) -> Self {
        Self {
            position,
            kelvin: 4000.0,
            intensity: 1.0,
            range,
            shape: LightShape::Spot {
                direction: (towards - position).normalize_or(Vec3::NEG_Y),
                // A cone with no soft edge reads as a cardboard cut-out; a
                // fifth of the way in is enough to lose the rim without
                // losing the shape of the pool.
                inner: cone * 0.8,
                outer: cone,
            },
        }
    }

    pub fn temperature(mut self, kelvin: f32) -> Self {
        self.kelvin = kelvin;
        self
    }

    pub fn intensity(mut self, intensity: f32) -> Self {
        self.intensity = intensity;
        self
    }

    /// Its colour in linear RGB, already scaled by how bright it is.
    pub fn color(&self) -> Vec3 {
        kelvin(self.kelvin) * self.intensity
    }
}

/// One local light as the GPU sees it. Four rows of four, because a storage
/// buffer's stride wants the alignment anyway and it leaves room to say more
/// about a light later without moving anything.
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct GpuLight {
    /// xyz where it is, w how far it reaches.
    pub position_range: [f32; 4],
    /// rgb its colour times its strength, w unused.
    pub color: [f32; 4],
    /// xyz the way a spot points, w the cosine of its outer half-angle.
    /// A point light puts -2 there, which no cosine can be, so the shader
    /// tells the two apart without a second field to keep in step.
    pub direction_outer: [f32; 4],
    /// x the cosine of the inner half-angle. The rest is spare.
    pub cone: [f32; 4],
}

/// A point light's marker in [`GpuLight::direction_outer`]`.w`: outside the
/// range of any cosine, so it cannot collide with a real cone.
pub const NOT_A_CONE: f32 = -2.0;

impl From<&LocalLight> for GpuLight {
    fn from(light: &LocalLight) -> Self {
        let color = light.color();
        let (direction, outer, inner) = match light.shape {
            LightShape::Point => (Vec3::ZERO, NOT_A_CONE, 0.0),
            LightShape::Spot {
                direction,
                inner,
                outer,
            } => (
                direction.normalize_or(Vec3::NEG_Y),
                outer.cos(),
                inner.cos(),
            ),
        };
        Self {
            position_range: [
                light.position.x,
                light.position.y,
                light.position.z,
                light.range.max(0.0),
            ],
            color: [color.x, color.y, color.z, 0.0],
            direction_outer: [direction.x, direction.y, direction.z, outer],
            cone: [inner, 0.0, 0.0, 0.0],
        }
    }
}

/// How many local lights one frame can carry. The buffer is made once at this
/// size rather than grown: a scene that wants more than this wants a different
/// conversation about what it is doing, and a fixed size is one less thing to
/// go wrong between the culling pass and the shading one.
pub const MAX_LIGHTS: usize = 256;

/// The local lights to shade with this frame, gathered by
/// [`collect_lights_system`].
#[derive(crate::ecs::Resource, Default)]
pub struct LightDrawList(pub Vec<GpuLight>);

pub fn collect_lights_system(
    mut list: bevy_ecs::system::ResMut<LightDrawList>,
    lights: bevy_ecs::system::Query<&LocalLight>,
) {
    list.0.clear();
    for light in &lights {
        if list.0.len() == MAX_LIGHTS {
            log::warn!("more than {MAX_LIGHTS} local lights; the rest are dark this frame");
            break;
        }
        // A light with no reach lights nothing, and would cull into every
        // cluster it touches for no result.
        if light.range > 0.0 && light.intensity > 0.0 {
            list.0.push(GpuLight::from(light));
        }
    }
}

#[cfg(test)]
mod local_light_tests {
    use super::*;

    #[test]
    fn a_point_light_is_not_a_cone() {
        let gpu = GpuLight::from(&LocalLight::point(Vec3::Y, 5.0));
        assert_eq!(gpu.direction_outer[3], NOT_A_CONE);
        assert_eq!(gpu.position_range, [0.0, 1.0, 0.0, 5.0]);
    }

    #[test]
    fn a_spot_points_where_it_was_aimed() {
        let light = LocalLight::spot(Vec3::new(0.0, 3.0, 0.0), Vec3::ZERO, 6.0, 0.5);
        let gpu = GpuLight::from(&light);
        assert_eq!(
            [gpu.direction_outer[0], gpu.direction_outer[1]],
            [0.0, -1.0]
        );
        // Straight down, and the outer cosine is the wider of the two.
        assert!(gpu.direction_outer[3] < gpu.cone[0]);
        assert!(gpu.direction_outer[3] > NOT_A_CONE);
    }

    #[test]
    fn brightness_is_carried_in_the_colour() {
        let dim = GpuLight::from(&LocalLight::point(Vec3::ZERO, 1.0).intensity(0.5));
        let bright = GpuLight::from(&LocalLight::point(Vec3::ZERO, 1.0).intensity(2.0));
        assert!(bright.color[0] > dim.color[0]);
    }
}

/// What a light is, for the outliner and anything else that lists a scene.
mod kinds {
    use glam::Vec3;

    use super::{Light, LocalLight};
    use crate::AppState;
    use crate::ecs::Entity;
    use crate::sceneobjects::{Category, SceneObject};
    use crate::ui::icons::path;

    /// How far off the origin a light added from a menu stands, and how far
    /// it reaches. A light spawned *at* the origin is inside whatever is
    /// standing there and lights nothing.
    const HEIGHT: f32 = 2.0;
    const REACH: f32 = 10.0;

    pub struct Area;
    pub struct Point;
    pub struct Spot;

    impl SceneObject for Area {
        fn label(&self) -> &'static str {
            "Area Light"
        }
        fn icon(&self) -> &'static str {
            // A sun: the one light that is infinitely far away.
            path::SUN
        }
        fn category(&self) -> Category {
            Category::Light
        }

        /// A directional light, shining down the way the key does.
        ///
        /// It is listed and it can be moved, but it does not *shade* until it
        /// is given a [`super::LightRole`]: the renderer has exactly two
        /// directional slots -- see [`super::collect_lighting_system`] -- and
        /// which of them a new one takes is the scene's call, not the kind's.
        fn spawn(&self, app: &mut AppState) -> Entity {
            let light = Light::default();
            app.spawn_entity((light, Light::item(self.label())))
        }
    }

    impl SceneObject for Point {
        fn label(&self) -> &'static str {
            "Point Light"
        }
        fn icon(&self) -> &'static str {
            path::LIGHTBULB
        }
        fn category(&self) -> Category {
            Category::Light
        }

        /// A bare bulb, hanging above the origin.
        fn spawn(&self, app: &mut AppState) -> Entity {
            let light = LocalLight::point(Vec3::new(0.0, HEIGHT, 0.0), REACH);
            app.spawn_entity((light, light.item(self.label())))
        }
    }

    impl SceneObject for Spot {
        fn label(&self) -> &'static str {
            "Spot Light"
        }
        fn icon(&self) -> &'static str {
            // A lamp rather than a bulb: a spot points somewhere.
            path::LAMP
        }
        fn category(&self) -> Category {
            Category::Light
        }

        /// The same lamp as a point light, but aimed: hanging above the
        /// origin and pointed down at it.
        fn spawn(&self, app: &mut AppState) -> Entity {
            let light = LocalLight::spot(
                Vec3::new(0.0, HEIGHT, 0.0),
                Vec3::ZERO,
                REACH,
                std::f32::consts::FRAC_PI_6,
            );
            app.spawn_entity((light, light.item(self.label())))
        }
    }
}

impl LocalLight {
    /// What kind of thing this is, for [`crate::sceneobjects::SceneItem`].
    pub fn kind(&self) -> &'static dyn crate::sceneobjects::SceneObject {
        match self.shape {
            LightShape::Point => &kinds::Point,
            LightShape::Spot { .. } => &kinds::Spot,
        }
    }

    /// This light as something the outliner can list.
    pub fn item(&self, name: impl Into<String>) -> crate::sceneobjects::SceneItem {
        crate::sceneobjects::SceneItem::new(self.kind(), name)
    }
}

impl Light {
    /// What kind of thing this is, for [`crate::sceneobjects::SceneItem`].
    pub fn kind() -> &'static dyn crate::sceneobjects::SceneObject {
        &kinds::Area
    }

    /// This light as something the outliner can list.
    pub fn item(name: impl Into<String>) -> crate::sceneobjects::SceneItem {
        crate::sceneobjects::SceneItem::new(Self::kind(), name)
    }
}

/// Gathers the scene's directional lights into [`Suns`] for the renderer.
///
/// The entities are the truth and [`Suns`] is the copy the shader reads: a
/// light somebody can find in the outliner, select and eventually drag has to
/// *be* something, and a field on a resource is not.
///
/// No lights means no suns: a scene that has not lit itself is lit by the
/// ambient alone, and the copy is emptied rather than left holding the last
/// scene's -- a scene change takes the old lights with it, and the shading
/// has to follow.
pub fn collect_suns_system(mut suns: ResMut<Suns>, lights: Query<&Light>) {
    let mut next = Suns::default();
    for light in &lights {
        // Which slot a light takes is a fact about the light. Two that agree
        // about casting are two that want the same slot, and the shader has
        // only the one: the first is kept so the frame is stable rather than
        // flickering between them as the query order moves.
        let slot = match light.shadow {
            true => &mut next.shadowed,
            false => &mut next.unshadowed,
        };
        match slot {
            Some(_) => log::warn!(
                "more than one directional light that {} a shadow; the extra one is dark",
                match light.shadow {
                    true => "casts",
                    false => "does not cast",
                },
            ),
            None => *slot = Some(*light),
        }
    }
    *suns = next;
}

#[cfg(test)]
mod lighting_tests {
    use super::*;
    use crate::ecs::Application;
    use crate::sceneobjects::SceneItem;

    /// A world with nothing in it but the system.
    fn unlit() -> Application {
        let mut app = Application::new();
        app.insert_resource(Suns::default());
        app.add_update_systems(collect_suns_system);
        app
    }

    /// The same, lit the way a scene that spawns the stock pair would be.
    fn app() -> Application {
        let mut app = unlit();
        let [key, rim] = default_lights();
        app.world.spawn((key, Light::item("Key Light")));
        app.world.spawn((rim, Light::item("Rim Light")));
        app
    }

    /// Which way round the lights in the world cast, sorted so the casting
    /// one comes first.
    fn casting(app: &mut Application) -> Vec<bool> {
        let mut casting: Vec<bool> = app
            .world
            .query::<&Light>()
            .iter(&app.world)
            .map(|light| light.shadow)
            .collect();
        casting.sort_by(|a, b| b.cmp(a));
        casting
    }

    /// A scene that says nothing about lighting has none: nothing is spawned
    /// on its behalf, so the outliner lists only what the scene put there.
    #[test]
    fn a_scene_with_no_lights_is_lit_by_nothing() {
        let mut app = unlit();
        app.update();
        assert!(casting(&mut app).is_empty(), "nothing was spawned for it");
        assert_eq!(*app.world.resource::<Suns>(), Suns::default());
    }

    /// A scene change takes the lights with it; the copy the shader reads
    /// has to go too, or the next scene is lit by the last one's suns.
    #[test]
    fn the_suns_go_when_the_lights_do() {
        let mut app = app();
        app.update();
        assert!(app.world.resource::<Suns>().shadowed.is_some());

        let lights: Vec<bevy_ecs::prelude::Entity> = app
            .world
            .query_filtered::<bevy_ecs::prelude::Entity, bevy_ecs::prelude::With<Light>>()
            .iter(&app.world)
            .collect();
        for light in lights {
            app.world.despawn(light);
        }
        app.update();
        assert_eq!(*app.world.resource::<Suns>(), Suns::default());
    }

    /// They are objects, so they are things the outliner can list.
    #[test]
    fn each_one_is_something_the_scene_can_list() {
        let mut app = app();
        app.update();

        let mut names: Vec<String> = app
            .world
            .query::<&SceneItem>()
            .iter(&app.world)
            .map(|item| item.name.clone())
            .collect();
        names.sort();
        assert_eq!(names, ["Key Light", "Rim Light"]);

        let kinds: Vec<&str> = app
            .world
            .query::<&SceneItem>()
            .iter(&app.world)
            .map(|item| item.kind.label())
            .collect();
        assert!(
            kinds.iter().all(|label| *label == "Area Light"),
            "a light with an angular size is an area light: {kinds:?}",
        );
    }

    #[test]
    fn the_resource_the_renderer_reads_comes_from_the_entities() {
        let mut app = app();
        app.update();

        // Turn the casting light warm in the world, not in the resource.
        let key = app
            .world
            .query::<(bevy_ecs::prelude::Entity, &Light)>()
            .iter(&app.world)
            .find(|(_, light)| light.shadow)
            .map(|(entity, _)| entity)
            .expect("a light that casts");
        app.world.get_mut::<Light>(key).unwrap().kelvin = Some(2000.0);
        app.update();

        assert_eq!(
            app.world.resource::<Suns>().shadowed.unwrap().kelvin,
            Some(2000.0),
            "the entity is the truth and the resource is the copy",
        );
    }

    /// Which slot a light takes is read off the light, not assigned to it.
    #[test]
    fn a_light_that_stops_casting_changes_which_slot_it_fills() {
        let mut app = app();
        app.update();

        let key = app
            .world
            .query::<(bevy_ecs::prelude::Entity, &Light)>()
            .iter(&app.world)
            .find(|(_, light)| light.shadow)
            .map(|(entity, _)| entity)
            .expect("a light that casts");
        // Take the rim away so turning the key off leaves the slot free.
        let rim = app
            .world
            .query::<(bevy_ecs::prelude::Entity, &Light)>()
            .iter(&app.world)
            .find(|(_, light)| !light.shadow)
            .map(|(entity, _)| entity)
            .expect("a light that does not");
        app.world.despawn(rim);
        app.world.get_mut::<Light>(key).unwrap().shadow = false;
        app.update();

        let suns = *app.world.resource::<Suns>();
        assert!(suns.shadowed.is_none(), "nothing casts any more");
        assert!(suns.unshadowed.is_some(), "and it fills the other slot");
    }
}