Skip to main content

mirage_engine/
skybox.rs

1//! The sky a frame draws where nothing else was drawn, and the light every
2//! surface takes from it.
3
4use core::f32::consts::{PI, TAU};
5use core::fmt;
6use core::fmt::Debug;
7use core::hash::Hash;
8
9use crate::assets::Unresolved;
10use crate::light::rgb;
11use crate::math::{UVec2, Vec3, Vec4};
12use crate::{Assets, Catalog, Color, TextureData};
13
14/// The color of the sky a frame that sets none is drawn and lit by: one
15/// neutral dark grey, the whole way around.
16const DEFAULT: Color = Color::rgb(0.1, 0.1, 0.1);
17
18/// The equirect a gradient is drawn into at startup. A gradient changes
19/// smoothly, so one this small holds it.
20const RASTERIZED: UVec2 = UVec2::new(64, 32);
21
22/// The widest a mip may be for the nine coefficients to be taken from it,
23/// in texels.
24const COEFFICIENT_WIDTH: u32 = 256;
25
26/// How far under the horizon a ground color reaches its whole share: the
27/// `y` of a direction three degrees under level, so the horizon reads as a
28/// line and not a step.
29const GROUND_BLEND: f32 = 0.052_336;
30
31/// What each coefficient's shape is scaled by, taken twice because a
32/// fragment applies none of it; in the order a fragment reads them back.
33const SHAPES: [f32; 9] = [
34    0.282_095, 0.488_603, 0.488_603, 0.488_603, 1.092_548, 1.092_548, 0.315_392, 1.092_548,
35    0.546_274,
36];
37
38/// The share of the light from every direction each coefficient takes,
39/// over `pi`, so a sky of one color lands exactly that color on a surface
40/// facing anywhere.
41const TAKEN: [f32; 9] = [
42    1.0,
43    2.0 / 3.0,
44    2.0 / 3.0,
45    2.0 / 3.0,
46    0.25,
47    0.25,
48    0.25,
49    0.25,
50    0.25,
51];
52
53/// Turns a game's value into a skybox.
54///
55/// Values are cache keys: equal values must build the same sky. [`Catalog`]
56/// proves every named asset loads before the first frame, and a startup
57/// error names a skybox by its [`Debug`].
58pub trait Skyboxes: Catalog + Clone + Debug + Eq + Hash {
59    /// Builds this value's sky, the first time a frame is drawn by it.
60    ///
61    /// Must not read a file or the network; loaded data is in `assets`.
62    fn build(&self, assets: &Assets) -> SkyboxData;
63}
64
65/// The vocabulary of a game with no sky of its own to name.
66///
67/// No value of it exists, so such a game draws and is lit by the default
68/// sky.
69#[derive(Clone, Debug, Eq, Hash, PartialEq)]
70pub enum NoSkyboxes {}
71
72impl Catalog for NoSkyboxes {
73    fn catalog() -> Vec<Self> {
74        Vec::new()
75    }
76}
77
78impl Skyboxes for NoSkyboxes {
79    fn build(&self, _assets: &Assets) -> SkyboxData {
80        match *self {}
81    }
82}
83
84/// The sky one value of a game's vocabulary is drawn and lit from: one of
85/// two kinds, an image of the whole sky or a gradient between three colors,
86/// the fraction of its own light it lands on a surface, and the ground
87/// color under its horizon where a game states one.
88#[derive(Clone, Debug)]
89pub struct SkyboxData {
90    kind: Kind,
91    light: f32,
92    ground: Option<Color>,
93    /// What the build that read this sky did not get, taken in from the
94    /// image it read.
95    unresolved: Unresolved,
96}
97
98/// The closed set of skies; [`SkyboxData`] has one constructor for each.
99#[derive(Clone, Debug)]
100enum Kind {
101    Equirect(TextureData),
102    Gradient(Gradient),
103}
104
105impl SkyboxData {
106    /// The sky `image` holds: the whole way around across it, and zenith to
107    /// nadir down it, so it is twice as wide as it is tall.
108    ///
109    /// Required if you want a sky an image holds. An image not twice as wide
110    /// as it is tall stops startup naming the skybox.
111    pub fn equirect(mut image: TextureData) -> Self {
112        let unresolved = image.take_unresolved();
113
114        Self {
115            unresolved,
116            ..Self::of(Kind::Equirect(image))
117        }
118    }
119
120    /// The sky `zenith` straight up, `horizon` level, and `nadir` straight
121    /// down, each texel between them the share its own elevation takes.
122    ///
123    /// Required if you want a sky with no image at all: one color in all
124    /// three lands that color on every surface, from every direction.
125    pub fn gradient(zenith: Color, horizon: Color, nadir: Color) -> Self {
126        Self::of(Kind::Gradient(Gradient::new(zenith, horizon, nadir)))
127    }
128
129    /// The same sky landing `fraction` of its own light on every surface,
130    /// and a surface that reflects it reflecting that share. The sky the
131    /// frame draws is unchanged.
132    ///
133    /// Required if you want the frame's own lights to show under a bright
134    /// sky. `1.0` where never called, held at zero and above; past `1.0`
135    /// lands more light than the sky holds.
136    pub fn lit_by(mut self, fraction: f32) -> Self {
137        self.light = fraction.max(0.0);
138        self
139    }
140
141    /// The same sky reading as `color` in every direction under the horizon,
142    /// blended over the three degrees below it: what a metal reflects there
143    /// and what the sky lands on a surface facing down take that color, and
144    /// so does the sky the frame draws where nothing covers it.
145    ///
146    /// Required if you want a metal to reflect the floor it stands on rather
147    /// than the sky's own underside. No ground where never called.
148    pub fn with_ground(mut self, color: Color) -> Self {
149        self.ground = Some(color);
150        self
151    }
152
153    fn of(kind: Kind) -> Self {
154        Self {
155            kind,
156            light: 1.0,
157            ground: None,
158            unresolved: Unresolved::default(),
159        }
160    }
161
162    /// A black sky under `unresolved`: what a name no source holds an image
163    /// under returns.
164    pub(crate) fn missing(unresolved: Unresolved) -> Self {
165        Self {
166            unresolved,
167            ..Self::default()
168        }
169    }
170
171    /// What the build of this sky did not get, taken out of it.
172    pub(crate) fn take_unresolved(&mut self) -> Unresolved {
173        self.unresolved.taken()
174    }
175
176    /// This sky as the engine keeps it for the run, or the error its image
177    /// is no sky for.
178    pub(crate) fn resident(&self) -> Result<Resident, SkyboxError> {
179        let largest = match &self.kind {
180            Kind::Equirect(image) => Mip::of(image)?,
181            Kind::Gradient(gradient) => Mip::between(*gradient),
182        };
183        let largest = match self.ground {
184            Some(ground) => largest.over(rgb(ground)),
185            None => largest,
186        };
187
188        Ok(Resident::of(largest).lit_by(self.light))
189    }
190}
191
192impl Default for SkyboxData {
193    /// The sky a name no source holds returns: black the whole way around.
194    fn default() -> Self {
195        Self::equirect(TextureData::rgba8(UVec2::new(2, 1), vec![0; 8]))
196    }
197}
198
199/// The three colors a gradient sky is drawn between.
200#[derive(Clone, Copy, Debug, PartialEq)]
201pub(crate) struct Gradient {
202    /// Straight up.
203    zenith: Color,
204    /// Level with the horizon.
205    horizon: Color,
206    /// Straight down.
207    nadir: Color,
208}
209
210impl Gradient {
211    /// The sky `zenith` straight up, `horizon` level, and `nadir` straight
212    /// down.
213    pub(crate) const fn new(zenith: Color, horizon: Color, nadir: Color) -> Self {
214        Self {
215            zenith,
216            horizon,
217            nadir,
218        }
219    }
220}
221
222impl Default for Gradient {
223    /// The sky a frame that sets none is drawn and lit by: one neutral dark
224    /// grey, the whole way around.
225    fn default() -> Self {
226        Self::new(DEFAULT, DEFAULT, DEFAULT)
227    }
228}
229
230/// Why an image is no sky at all, which startup reports under the name of
231/// the skybox value it was built for.
232#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
233pub(crate) enum SkyboxError {
234    /// It is not twice as wide as it is tall.
235    Sides { width: u32, height: u32 },
236    /// It holds another count of bytes than the four its every texel needs.
237    Pixels {
238        width: u32,
239        height: u32,
240        bytes: usize,
241    },
242}
243
244impl fmt::Display for SkyboxError {
245    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246        match self {
247            Self::Sides { width, height } => write!(
248                f,
249                "is {width}x{height}; a skybox image is twice as wide as it is tall"
250            ),
251            Self::Pixels {
252                width,
253                height,
254                bytes,
255            } => write!(
256                f,
257                "is {width}x{height} over {bytes} bytes, where every texel needs four"
258            ),
259        }
260    }
261}
262
263/// One sky as the engine keeps it: the mips it is drawn and reflected from,
264/// largest first, and the light it lands on a surface.
265pub(crate) struct Resident {
266    largest: Mip,
267    /// Every halving of `largest`, down to the one that is a single texel.
268    halved: Vec<Mip>,
269    irradiance: Irradiance,
270    /// What a surface's reflection of this sky is scaled by.
271    light: f32,
272}
273
274impl Resident {
275    /// The sky `gradient` holds, drawn into an equirect of its own.
276    pub(crate) fn gradient(gradient: Gradient) -> Self {
277        Self::of(Mip::between(gradient))
278    }
279
280    /// Halves `largest` down to a single texel, and takes the nine
281    /// coefficients from the first mip no wider than [`COEFFICIENT_WIDTH`].
282    fn of(largest: Mip) -> Self {
283        let mut halved: Vec<Mip> = Vec::new();
284        while let Some(mip) = halved.last().unwrap_or(&largest).halved() {
285            halved.push(mip);
286        }
287        // What a surface takes changes smoothly over the sky, so a small
288        // copy of it holds all nine.
289        let narrow = halved.iter().fold(&largest, |narrowest, mip| {
290            match narrowest.size.x <= COEFFICIENT_WIDTH {
291                true => narrowest,
292                false => mip,
293            }
294        });
295
296        Self {
297            irradiance: Irradiance::of(narrow),
298            largest,
299            halved,
300            light: 1.0,
301        }
302    }
303
304    /// The same sky landing `fraction` of its light: the coefficients are
305    /// scaled here, and the frame's own values hold the fraction for the
306    /// reflection.
307    fn lit_by(mut self, fraction: f32) -> Self {
308        self.irradiance = self.irradiance.scaled(fraction);
309        self.light = fraction;
310        self
311    }
312
313    /// What a surface's reflection of this sky is scaled by; the
314    /// coefficients already hold it.
315    pub(crate) fn share(&self) -> f32 {
316        self.light
317    }
318
319    /// The size of its largest mip, in texels.
320    pub(crate) fn size(&self) -> UVec2 {
321        self.largest.size
322    }
323
324    /// Every mip of this sky, largest first.
325    pub(crate) fn mips(&self) -> impl Iterator<Item = &Mip> {
326        core::iter::once(&self.largest).chain(&self.halved)
327    }
328
329    /// How many mips that is.
330    pub(crate) fn mip_count(&self) -> u32 {
331        1 + self.halved.len() as u32
332    }
333
334    /// The mip level a fully rough surface reflects this sky from: the
335    /// smallest mip's, counting `0.0` at the largest.
336    pub(crate) fn top_mip(&self) -> f32 {
337        self.halved.len() as f32
338    }
339
340    /// The light this sky lands on a surface, by the direction the surface
341    /// faces.
342    pub(crate) fn irradiance(&self) -> Irradiance {
343        self.irradiance
344    }
345}
346
347/// One mip of a sky: its size, and its linear texels row by row from the
348/// top left.
349pub(crate) struct Mip {
350    size: UVec2,
351    texels: Vec<Vec3>,
352}
353
354impl Mip {
355    /// The light `image` holds, or the error that image is no sky for.
356    fn of(image: &TextureData) -> Result<Self, SkyboxError> {
357        let size = image.size();
358        let (width, height) = (size.x, size.y);
359        if height == 0 || width != 2 * height {
360            return Err(SkyboxError::Sides { width, height });
361        }
362        // A texture built by hand holds whatever bytes it was given, and
363        // every mip under this one reads one texel per texel of its size.
364        let bytes = image.pixels().len();
365        if bytes as u64 != 4 * u64::from(width) * u64::from(height) {
366            return Err(SkyboxError::Pixels {
367                width,
368                height,
369                bytes,
370            });
371        }
372
373        Ok(Self {
374            size,
375            texels: image
376                .pixels()
377                .chunks_exact(4)
378                .map(|texel| rgb(Color::of_srgb([texel[0], texel[1], texel[2]])))
379                .collect(),
380        })
381    }
382
383    /// The sky `gradient` holds: each texel the share its own elevation
384    /// takes of the color above it and the color below it.
385    fn between(gradient: Gradient) -> Self {
386        let zenith = rgb(gradient.zenith);
387        let horizon = rgb(gradient.horizon);
388        let nadir = rgb(gradient.nadir);
389
390        Self {
391            size: RASTERIZED,
392            texels: Grid::of(RASTERIZED)
393                .directions()
394                .map(|direction| match direction.y >= 0.0 {
395                    true => horizon.lerp(zenith, direction.y),
396                    false => horizon.lerp(nadir, -direction.y),
397                })
398                .collect(),
399        }
400    }
401
402    /// This mip with `ground` under the horizon, blended across
403    /// [`GROUND_BLEND`] below it.
404    fn over(self, ground: Vec3) -> Self {
405        let texels = Grid::of(self.size)
406            .directions()
407            .zip(self.texels)
408            .map(|(direction, texel)| {
409                let share = (-direction.y / GROUND_BLEND).clamp(0.0, 1.0);
410                texel.lerp(ground, share)
411            })
412            .collect();
413
414        Self {
415            size: self.size,
416            texels,
417        }
418    }
419
420    /// This mip at half its size, each texel the average of the four it
421    /// covers; `None` for one that is a single texel.
422    fn halved(&self) -> Option<Self> {
423        if self.size.max_element() <= 1 {
424            return None;
425        }
426        let size = (self.size / 2).max(UVec2::ONE);
427
428        Some(Self {
429            size,
430            texels: (0..size.y)
431                .flat_map(|down| (0..size.x).map(move |across| UVec2::new(across, down)))
432                .map(|at| self.averaged(at))
433                .collect(),
434        })
435    }
436
437    /// This mip's size in texels.
438    pub(crate) fn size(&self) -> UVec2 {
439        self.size
440    }
441
442    /// Its texels, row by row from the top left.
443    pub(crate) fn texels(&self) -> &[Vec3] {
444        &self.texels
445    }
446
447    /// The average of the four texels this mip holds under texel `at` of
448    /// the mip half its size.
449    fn averaged(&self, at: UVec2) -> Vec3 {
450        let corner = at * 2;
451        let covered: Vec3 = [UVec2::ZERO, UVec2::X, UVec2::Y, UVec2::ONE]
452            .into_iter()
453            .map(|step| self.texel(corner + step))
454            .sum();
455
456        covered / 4.0
457    }
458
459    /// The texel `at`, held within this mip.
460    fn texel(&self, at: UVec2) -> Vec3 {
461        let held = at.min(self.size - UVec2::ONE);
462
463        self.texels[(held.y * self.size.x + held.x) as usize]
464    }
465}
466
467/// The texel grid of one equirect image: the direction each texel holds,
468/// and how much of the sky it covers.
469///
470/// `forward.wgsl` reads a direction back through the same mapping.
471#[derive(Clone, Copy)]
472struct Grid {
473    size: UVec2,
474}
475
476impl Grid {
477    fn of(size: UVec2) -> Self {
478        Self { size }
479    }
480
481    /// Each texel's direction, row by row from the top left.
482    fn directions(self) -> impl Iterator<Item = Vec3> {
483        self.rows().flat_map(Row::directions)
484    }
485
486    /// Each texel's direction and the fraction of the whole sky it covers,
487    /// row by row from the top left.
488    fn texels(self) -> impl Iterator<Item = (Vec3, f32)> {
489        self.rows().flat_map(|row| {
490            row.directions()
491                .map(move |direction| (direction, row.covered))
492        })
493    }
494
495    /// The rows from the top, each at its own angle from the zenith.
496    fn rows(self) -> impl Iterator<Item = Row> {
497        let size = self.size.as_vec2();
498        let texel = (PI / size.y) * (TAU / size.x);
499
500        (0..self.size.y).map(move |down| {
501            let latitude = (down as f32 + 0.5) / size.y * PI;
502            let (latitude_sin, latitude_cos) = latitude.sin_cos();
503            Row {
504                latitude_sin,
505                latitude_cos,
506                columns: self.size.x,
507                covered: latitude_sin * texel,
508            }
509        })
510    }
511}
512
513/// One row of a [`Grid`]: its angle from the zenith, and how much of the
514/// sky each of its texels covers.
515#[derive(Clone, Copy)]
516struct Row {
517    latitude_sin: f32,
518    latitude_cos: f32,
519    columns: u32,
520    covered: f32,
521}
522
523impl Row {
524    /// Each texel's direction, from the left.
525    fn directions(self) -> impl Iterator<Item = Vec3> {
526        let columns = self.columns as f32;
527
528        (0..self.columns).map(move |across| {
529            let longitude = ((across as f32 + 0.5) / columns - 0.5) * TAU;
530            let (longitude_sin, longitude_cos) = longitude.sin_cos();
531            Vec3::new(
532                self.latitude_sin * longitude_sin,
533                self.latitude_cos,
534                -self.latitude_sin * longitude_cos,
535            )
536        })
537    }
538}
539
540/// The light a sky lands on a surface, as nine coefficients a fragment
541/// reads through the direction the surface faces.
542///
543/// A coefficient's shape is its own factor at a direction `n`: `1`, `n.y`,
544/// `n.z`, `n.x`, `n.x * n.y`, `n.y * n.z`, `3 * n.z * n.z - 1`, `n.x * n.z`
545/// and `n.x * n.x - n.y * n.y`, in that order. The coefficients hold the
546/// sky's own light with the share a flat surface takes of each direction
547/// already scaled into them, so what a fragment reads is what the surface
548/// takes.
549#[derive(Clone, Copy, Debug)]
550pub(crate) struct Irradiance([Vec3; 9]);
551
552impl Irradiance {
553    /// The nine coefficients of `mip`.
554    fn of(mip: &Mip) -> Self {
555        let mut coefficients = [Vec3::ZERO; 9];
556        for (texel, (direction, covered)) in mip.texels().iter().zip(Grid::of(mip.size).texels()) {
557            let light = *texel * covered;
558            for (coefficient, shape) in coefficients.iter_mut().zip(Self::shapes(direction)) {
559                *coefficient += light * shape;
560            }
561        }
562
563        Self(core::array::from_fn(|at| {
564            coefficients[at] * TAKEN[at] * SHAPES[at] * SHAPES[at]
565        }))
566    }
567
568    /// The same light, `fraction` of it.
569    fn scaled(self, fraction: f32) -> Self {
570        Self(self.0.map(|coefficient| coefficient * fraction))
571    }
572
573    /// The nine coefficients as the frame's own values hold them, each in
574    /// the first three lanes of a `Vec4` of its own.
575    pub(crate) fn lanes(&self) -> [Vec4; 9] {
576        self.0.map(|coefficient| coefficient.extend(0.0))
577    }
578
579    /// The shape of each coefficient at `direction`, in the order a
580    /// fragment reads them back.
581    fn shapes(direction: Vec3) -> [f32; 9] {
582        let Vec3 { x, y, z } = direction;
583
584        [
585            1.0,
586            y,
587            z,
588            x,
589            x * y,
590            y * z,
591            3.0 * z * z - 1.0,
592            x * z,
593            x * x - y * y,
594        ]
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    /// A sky of one color, as a gradient and as an image of that color.
603    fn flat(color: Color) -> (Resident, Resident) {
604        let encoded = |linear: f32| {
605            let encoded = match linear <= 0.003_130_8 {
606                true => linear * 12.92,
607                false => 1.055 * linear.powf(1.0 / 2.4) - 0.055,
608            };
609            (encoded * 255.0).round() as u8
610        };
611        let texel = [
612            encoded(color.red),
613            encoded(color.green),
614            encoded(color.blue),
615            u8::MAX,
616        ];
617        let image = TextureData::rgba8(UVec2::new(16, 8), texel.repeat(16 * 8));
618
619        (
620            Resident::gradient(Gradient::new(color, color, color)),
621            SkyboxData::equirect(image)
622                .resident()
623                .expect("that image is a sky"),
624        )
625    }
626
627    #[test]
628    fn a_sky_of_one_color_lands_that_color_on_a_surface_facing_anywhere() {
629        let color = Color::rgb(0.25, 0.5, 0.75);
630        let (gradient, image) = flat(color);
631
632        for sky in [gradient, image] {
633            let coefficients = sky.irradiance().0;
634
635            for (read, held) in [coefficients[0].x, coefficients[0].y, coefficients[0].z]
636                .into_iter()
637                .zip([color.red, color.green, color.blue])
638            {
639                assert!(
640                    (read - held).abs() < 0.01,
641                    "the first coefficient holds the color itself, got {read} against {held}"
642                );
643            }
644            for coefficient in &coefficients[1..] {
645                assert!(
646                    coefficient.length() < 0.01,
647                    "and no other holds anything, got {coefficient}"
648                );
649            }
650        }
651    }
652
653    #[test]
654    fn a_sky_bright_above_lands_more_light_on_a_surface_facing_up() {
655        let coefficients =
656            Resident::gradient(Gradient::new(Color::WHITE, Color::BLACK, Color::BLACK))
657                .irradiance()
658                .0;
659
660        assert!(
661            coefficients[1].y > 0.0,
662            "the coefficient a normal reads through its own `+Y` rises with the \
663             light above, got {}",
664            coefficients[1].y
665        );
666        assert!(
667            coefficients[0].y > 0.0,
668            "and the sky lands light on a surface facing anywhere"
669        );
670    }
671
672    #[test]
673    fn an_image_not_twice_as_wide_as_it_is_tall_is_no_sky_at_all() {
674        let square = TextureData::rgba8(UVec2::splat(4), vec![u8::MAX; 4 * 4 * 4]);
675
676        assert_eq!(
677            SkyboxData::equirect(square).resident().err(),
678            Some(SkyboxError::Sides {
679                width: 4,
680                height: 4
681            })
682        );
683        assert_eq!(
684            SkyboxData::equirect(TextureData::default())
685                .resident()
686                .err(),
687            Some(SkyboxError::Sides {
688                width: 0,
689                height: 0
690            }),
691            "and an image with no texels at all is none either"
692        );
693    }
694
695    #[test]
696    fn a_sky_is_halved_down_to_one_texel() {
697        let sky = Resident::gradient(Gradient::default());
698        let sizes: Vec<UVec2> = sky.mips().map(Mip::size).collect();
699
700        assert_eq!(sizes.first(), Some(&RASTERIZED));
701        assert_eq!(sizes.last(), Some(&UVec2::ONE));
702        assert_eq!(sizes.len(), 7, "one mip per halving of the widest side");
703        assert_eq!(sky.mip_count(), 7);
704        assert_eq!(sky.top_mip(), 6.0, "the smallest of them is the last");
705        assert!(
706            sky.mips()
707                .all(|mip| mip.texels().len() == (mip.size().x * mip.size().y) as usize),
708            "each of them holds its own texels"
709        );
710    }
711
712    #[test]
713    fn the_smallest_mip_holds_the_average_of_the_sky() {
714        let sky = Resident::gradient(Gradient::new(Color::WHITE, Color::BLACK, Color::BLACK));
715        let smallest = sky.mips().last().expect("the mips end at one texel");
716
717        assert_eq!(smallest.size(), UVec2::ONE);
718        let average = smallest.texels()[0].x;
719        assert!(
720            (0.1..0.4).contains(&average),
721            "a sky white above and black below averages between them, got {average}"
722        );
723    }
724
725    #[test]
726    fn a_ground_colors_every_texel_under_the_horizon_and_none_above_it() {
727        let sky = SkyboxData::gradient(Color::WHITE, Color::WHITE, Color::WHITE)
728            .with_ground(Color::rgb(0.5, 0.0, 0.0))
729            .resident()
730            .expect("a gradient is a sky");
731        let largest = sky.mips().next().expect("a sky has a largest mip");
732        let size = largest.size();
733        let top = largest.texel(UVec2::new(size.x / 2, 0));
734        let bottom = largest.texel(UVec2::new(size.x / 2, size.y - 1));
735
736        assert!(
737            top.abs_diff_eq(Vec3::ONE, 0.01),
738            "the zenith keeps the sky's own color, got {top}"
739        );
740        assert!(
741            bottom.abs_diff_eq(Vec3::new(0.5, 0.0, 0.0), 0.01),
742            "and the nadir reads as the ground, got {bottom}"
743        );
744    }
745
746    #[test]
747    fn a_ground_darker_than_the_sky_lands_less_light_on_a_surface_facing_down() {
748        let coefficients = SkyboxData::gradient(Color::WHITE, Color::WHITE, Color::WHITE)
749            .with_ground(Color::BLACK)
750            .resident()
751            .expect("a gradient is a sky")
752            .irradiance()
753            .0;
754
755        assert!(
756            coefficients[1].x > 0.0,
757            "the coefficient a normal reads through its own `+Y` rises with the \
758             light above and not below, got {}",
759            coefficients[1].x
760        );
761    }
762}