Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-map3d-flight/src/atmos.rs

//! **Precomputed atmospheric scattering** (FLIGHT-SIM #2) — a Bruneton/Hillaire-
//! style physical sky that replaces the harsh distance-fog clip with real
//! **aerial perspective**, and makes the horizon curve, air density, and light
//! colour change with **altitude** and **sun angle**.
//!
//! ## What this is
//! A compact, *deterministic*, CPU model of a curved-planet atmosphere:
//!
//! - a precomputed **transmittance LUT** ([`TransmittanceLut`]) — a 2D table over
//!   `(altitude, view-zenith-cos)` of how much light survives the path from a point
//!   to the top of the atmosphere (the Rayleigh + Mie + ozone extinction). This is
//!   the same `T(r, μ)` texture Bruneton precomputes on the GPU; here it is a
//!   `Vec<[f32;3]>` so the model is pure data, headless-testable, and reusable as
//!   the GPU LUT's reference oracle;
//! - **single-scattering sky-view** ([`Atmosphere::sky_radiance`]) — ray-marches
//!   the curved atmosphere and integrates Rayleigh + Mie in-scatter, using the LUT
//!   for the sun-transmittance at each step, plus a cheap isotropic **multi-scatter**
//!   ambient so the zenith never crushes to black;
//! - **aerial perspective** ([`Atmosphere::aerial_perspective`]) — the in-scatter +
//!   transmittance over a *bounded* distance, so terrain/buildings fade into real
//!   atmosphere instead of a flat fog colour (the `surface·T + inscatter` cue);
//! - **derived sky colours** ([`Atmosphere::sky_colors`]) — zenith + horizon RGB at
//!   a given altitude & sun, ready to drive `facett_map3d`'s existing analytic
//!   `sky.wgsl` uniforms so the GPU sky becomes atmosphere-driven with zero new
//!   passes (the LUT-fed GPU precompute is the roadmap'd next step).
//!
//! Everything is in **kilometres** internally (the magnitudes stay tame) and uses
//! no RNG / no clock, so two evaluations are bit-identical (FC-7, snapshot-stable).

/// Ground (planet surface) radius, km. The Bruneton reference value.
pub const R_GROUND: f32 = 6360.0;
/// Top-of-atmosphere radius, km (60 km thick shell).
pub const R_TOP: f32 = 6420.0;

/// Rayleigh scale height, km (molecular density e-folds every ~8 km).
const H_RAYLEIGH: f32 = 8.0;
/// Mie scale height, km (aerosols hug the ground, ~1.2 km).
const H_MIE: f32 = 1.2;

/// Rayleigh scattering coefficient per km (R,G,B) — blue scatters ~6× red, which is
/// *why the sky is blue and sunsets are red*. Bruneton's `βR` scaled m→km.
const BETA_RAYLEIGH: [f32; 3] = [5.802e-3, 13.558e-3, 33.1e-3];
/// Mie scattering coefficient per km (grey haze, weakly wavelength-dependent).
const BETA_MIE: f32 = 3.996e-3;
/// Mie extinction = scattering / single-scatter albedo (~0.9) — Mie also absorbs.
const BETA_MIE_EXT: f32 = BETA_MIE / 0.9;
/// Ozone absorption per km (R,G,B) at the layer peak — deepens the daytime blue and
/// gives the twilight its violet. A tent layer centred at 25 km (see [`ozone_density`]).
const BETA_OZONE: [f32; 3] = [0.650e-3, 1.881e-3, 0.085e-3];
/// Mie anisotropy `g` (forward-scattering — the bright halo hugging the sun).
const MIE_G: f32 = 0.8;

#[inline]
fn rayleigh_density(height_km: f32) -> f32 {
    (-height_km / H_RAYLEIGH).exp()
}
#[inline]
fn mie_density(height_km: f32) -> f32 {
    (-height_km / H_MIE).exp()
}
/// Ozone tent profile: peaks at 25 km, linearly down to 0 by 10 km / 40 km.
#[inline]
fn ozone_density(height_km: f32) -> f32 {
    (1.0 - (height_km - 25.0).abs() / 15.0).clamp(0.0, 1.0)
}

/// Rayleigh phase (the angular shape of molecular scattering).
#[inline]
fn phase_rayleigh(cos_theta: f32) -> f32 {
    3.0 / (16.0 * std::f32::consts::PI) * (1.0 + cos_theta * cos_theta)
}
/// Henyey–Greenstein Mie phase (forward-peaked by `MIE_G`).
#[inline]
fn phase_mie(cos_theta: f32) -> f32 {
    let g = MIE_G;
    let g2 = g * g;
    let denom = (1.0 + g2 - 2.0 * g * cos_theta).max(1e-4).powf(1.5);
    (1.0 - g2) / (4.0 * std::f32::consts::PI * denom)
}

/// Distance from a point at radius `r` looking at zenith-cosine `mu` to the
/// **top of the atmosphere** (the far root of the ray/shell intersection). `r` is
/// the distance from the planet centre; `mu = cos(view, local-up)`.
#[inline]
fn dist_to_top(r: f32, mu: f32) -> f32 {
    let disc = r * r * (mu * mu - 1.0) + R_TOP * R_TOP;
    (-r * mu + disc.max(0.0).sqrt()).max(0.0)
}

/// Distance to the **planet surface** along the ray, or `None` if it misses the
/// ground (so the path runs to the top of the atmosphere instead).
#[inline]
fn dist_to_ground(r: f32, mu: f32) -> Option<f32> {
    let disc = r * r * (mu * mu - 1.0) + R_GROUND * R_GROUND;
    if disc < 0.0 {
        return None;
    }
    let t = -r * mu - disc.sqrt();
    if t > 0.0 { Some(t) } else { None }
}

/// **Optical depth** (the integral of density·extinction) from a point at radius
/// `r`, zenith-cosine `mu`, to the top of the atmosphere. Ray-marched, the input
/// to `transmittance = exp(-optical_depth)`. Returns per-channel `(R,G,B)`.
fn optical_depth_to_top(r: f32, mu: f32, steps: usize) -> [f32; 3] {
    // March only as far as the ground if the ray dives into the planet (a sun
    // below the local horizon is occluded → huge optical depth → ~0 transmittance).
    let t_max = match dist_to_ground(r, mu) {
        Some(tg) => tg,
        None => dist_to_top(r, mu),
    };
    let dt = t_max / steps as f32;
    let mut od = [0.0f32; 3];
    // March points along P(t) = P0 + dir·t, with P0 = (0, r, 0), dir = (sin, mu, 0)
    // so |dir| = 1 and dir·up = mu. Height at t is |P(t)| - R_GROUND.
    let sin = (1.0 - mu * mu).max(0.0).sqrt();
    for i in 0..steps {
        let t = (i as f32 + 0.5) * dt;
        let px = sin * t;
        let py = r + mu * t;
        let radius = (px * px + py * py).sqrt();
        let h = (radius - R_GROUND).max(0.0);
        let dr = rayleigh_density(h);
        let dm = mie_density(h);
        let doz = ozone_density(h);
        for c in 0..3 {
            od[c] += (BETA_RAYLEIGH[c] * dr + BETA_MIE_EXT * dm + BETA_OZONE[c] * doz) * dt;
        }
    }
    od
}

/// The **transmittance LUT**: `T(altitude, μ)` = light surviving the path to the
/// top of the atmosphere, per RGB channel. A small precomputed 2D table with
/// bilinear lookup — the analogue of Bruneton's transmittance texture, kept on the
/// CPU so it is deterministic + testable and can seed / validate the GPU texture.
#[derive(Clone, Debug)]
pub struct TransmittanceLut {
    /// Altitude axis size (rows).
    pub h_size: usize,
    /// μ axis size (columns).
    pub mu_size: usize,
    /// Row-major `h_size × mu_size` of RGB transmittance.
    data: Vec<[f32; 3]>,
}

impl TransmittanceLut {
    /// Precompute the LUT. `h_size × mu_size` cells, `steps` ray-march samples per
    /// cell. Altitude spans `[0, R_TOP-R_GROUND]`; μ spans `[-1, 1]`.
    #[must_use]
    pub fn precompute(h_size: usize, mu_size: usize, steps: usize) -> Self {
        let h_size = h_size.max(2);
        let mu_size = mu_size.max(2);
        let mut data = vec![[0.0f32; 3]; h_size * mu_size];
        let max_h = R_TOP - R_GROUND;
        for hi in 0..h_size {
            let h = max_h * hi as f32 / (h_size - 1) as f32;
            let r = R_GROUND + h;
            for mi in 0..mu_size {
                let mu = -1.0 + 2.0 * mi as f32 / (mu_size - 1) as f32;
                let od = optical_depth_to_top(r, mu, steps);
                data[hi * mu_size + mi] = [(-od[0]).exp(), (-od[1]).exp(), (-od[2]).exp()];
            }
        }
        Self { h_size, mu_size, data }
    }

    /// A default-resolution LUT (cheap enough for a unit test, dense enough to read
    /// smoothly): 32 altitudes × 128 μ × 24 march steps.
    #[must_use]
    pub fn standard() -> Self {
        Self::precompute(32, 128, 24)
    }

    /// The maximum altitude the LUT spans, km (`R_TOP − R_GROUND`). The altitude
    /// axis maps `[0, max_altitude_km]` → rows; the GPU precompute uses the same span.
    #[must_use]
    pub fn max_altitude_km() -> f32 {
        R_TOP - R_GROUND
    }

    /// The raw **row-major RGB texels** (`h_size × mu_size`). This is the GPU LUT's
    /// reference **oracle**: the GPU precompute pass ([`crate::scatter_gpu`], behind
    /// the `wgpu` feature) is validated texel-for-texel against exactly this data.
    #[must_use]
    pub fn texels(&self) -> &[[f32; 3]] {
        &self.data
    }

    /// The raw RGB texel at grid cell `(hi, mi)` — row `hi < h_size` (altitude), column
    /// `mi < mu_size` (μ). The un-interpolated table entry a GPU readback maps 1:1 to.
    #[must_use]
    pub fn texel(&self, hi: usize, mi: usize) -> [f32; 3] {
        self.data[hi * self.mu_size + mi]
    }

    /// A stable **FNV-1a content fingerprint** over the LUT texels (little-endian
    /// float bytes, row-major). The value the `functional_status` emitter records so a
    /// matrix row proves *which* LUT shipped, and a determinism guard the GPU parity
    /// check can lean on — a math regression flips the hash (RED-when-broken).
    #[must_use]
    pub fn content_hash(&self) -> u64 {
        let mut h: u64 = 0xcbf2_9ce4_8422_2325;
        for t in &self.data {
            for v in t {
                for b in v.to_le_bytes() {
                    h ^= u64::from(b);
                    h = h.wrapping_mul(0x0000_0100_0000_01b3);
                }
            }
        }
        h
    }

    /// Bilinear lookup of `T(altitude_km, μ)`.
    #[must_use]
    pub fn sample(&self, altitude_km: f32, mu: f32) -> [f32; 3] {
        let max_h = R_TOP - R_GROUND;
        let fh = (altitude_km.clamp(0.0, max_h) / max_h) * (self.h_size - 1) as f32;
        let fm = ((mu.clamp(-1.0, 1.0) + 1.0) * 0.5) * (self.mu_size - 1) as f32;
        let h0 = fh.floor() as usize;
        let m0 = fm.floor() as usize;
        let h1 = (h0 + 1).min(self.h_size - 1);
        let m1 = (m0 + 1).min(self.mu_size - 1);
        let th = fh - h0 as f32;
        let tm = fm - m0 as f32;
        let at = |h: usize, m: usize| self.data[h * self.mu_size + m];
        let (a, b, c, d) = (at(h0, m0), at(h0, m1), at(h1, m0), at(h1, m1));
        let mut out = [0.0f32; 3];
        for k in 0..3 {
            let top = a[k] * (1.0 - tm) + b[k] * tm;
            let bot = c[k] * (1.0 - tm) + d[k] * tm;
            out[k] = top * (1.0 - th) + bot * th;
        }
        out
    }
}

/// The atmosphere model + its precomputed LUT. Construct once ([`Atmosphere::earth`])
/// then evaluate the sky / aerial perspective per frame.
#[derive(Clone, Debug)]
pub struct Atmosphere {
    /// Precomputed transmittance LUT (sun-transmittance per scatter sample).
    pub transmittance: TransmittanceLut,
    /// Sun irradiance at the top of the atmosphere (RGB, ~white). Scales radiance.
    pub sun_irradiance: [f32; 3],
    /// Ground albedo for the cheap multi-scatter bounce (isotropic fill).
    pub ground_albedo: f32,
}

impl Atmosphere {
    /// Earth-like atmosphere with the standard LUT.
    #[must_use]
    pub fn earth() -> Self {
        Self { transmittance: TransmittanceLut::standard(), sun_irradiance: [1.0, 1.0, 1.0], ground_albedo: 0.3 }
    }

    /// **Single-scattering sky radiance** along `view_dir` (unit, world up = +Y)
    /// from a camera at `altitude_m`, with the sun at `sun_dir` (unit, surface→sun,
    /// up = +Y). Ray-marches the curved atmosphere; returns linear HDR RGB.
    ///
    /// `view_dir.y = cos(view-zenith)`; `sun_dir.y = sin(sun-elevation)`. Both must
    /// be normalised. `steps` is the in-scatter march resolution.
    #[must_use]
    pub fn sky_radiance(&self, view_dir: [f32; 3], sun_dir: [f32; 3], altitude_m: f32, steps: usize) -> [f32; 3] {
        let alt_km = (altitude_m / 1000.0).max(0.0);
        let r0 = R_GROUND + alt_km;
        let mu = view_dir[1].clamp(-1.0, 1.0); // cos(view, up); up is +Y at the camera
        // March from the camera to the first boundary (ground or top-of-atmosphere).
        let t_max = match dist_to_ground(r0, mu) {
            Some(tg) => tg,
            None => dist_to_top(r0, mu),
        };
        if t_max <= 0.0 {
            return [0.0; 3];
        }
        let dt = t_max / steps as f32;
        let cos_theta = dot(view_dir, sun_dir);
        let pr = phase_rayleigh(cos_theta);
        let pm = phase_mie(cos_theta);

        // Camera at P0 = (0, r0, 0); the ray advances along view_dir.
        let p0 = [0.0f32, r0, 0.0];
        let mut inscatter = [0.0f32; 3];
        let mut optical_depth_view = [0.0f32; 3]; // camera→sample extinction accumulator
        for i in 0..steps {
            let t = (i as f32 + 0.5) * dt;
            let p = [p0[0] + view_dir[0] * t, p0[1] + view_dir[1] * t, p0[2] + view_dir[2] * t];
            let radius = norm(p);
            let h = (radius - R_GROUND).max(0.0);
            let dr = rayleigh_density(h);
            let dm = mie_density(h);
            let doz = ozone_density(h);
            // Accumulate camera→sample optical depth (half-step trapezoid).
            for c in 0..3 {
                optical_depth_view[c] += (BETA_RAYLEIGH[c] * dr + BETA_MIE_EXT * dm + BETA_OZONE[c] * doz) * dt;
            }
            let t_view = [(-optical_depth_view[0]).exp(), (-optical_depth_view[1]).exp(), (-optical_depth_view[2]).exp()];
            // Sun transmittance to this sample: local up = p/|p|, μ_sun = up·sun.
            let up = [p[0] / radius, p[1] / radius, p[2] / radius];
            let mu_sun = dot(up, sun_dir);
            let t_sun = self.transmittance.sample(h, mu_sun);
            for c in 0..3 {
                let scatter = BETA_RAYLEIGH[c] * dr * pr + BETA_MIE * dm * pm;
                inscatter[c] += t_view[c] * t_sun[c] * scatter * dt * self.sun_irradiance[c];
            }
        }
        // Cheap multi-scatter / ambient fill so the zenith + shadowed sky never crush
        // to pure black at altitude (a constant isotropic bounce off the lit air).
        let ms = self.ground_albedo * 0.02;
        let zenith_sun = dot([0.0, 1.0, 0.0], sun_dir).max(0.0);
        for c in 0..3 {
            inscatter[c] += BETA_RAYLEIGH[c] * ms * (0.3 + 0.7 * zenith_sun);
        }
        inscatter
    }

    /// **Aerial perspective** over a bounded `distance_m` along `view_dir`: the
    /// `(transmittance, inscatter)` a surface that far away is seen through. Shade a
    /// distant building/terrain fragment as `surface_rgb · transmittance + inscatter`
    /// — real atmosphere, not a flat fog clamp. Up = +Y at the camera.
    #[must_use]
    pub fn aerial_perspective(
        &self,
        view_dir: [f32; 3],
        sun_dir: [f32; 3],
        distance_m: f32,
        altitude_m: f32,
        steps: usize,
    ) -> ([f32; 3], [f32; 3]) {
        let alt_km = (altitude_m / 1000.0).max(0.0);
        let r0 = R_GROUND + alt_km;
        let dist_km = (distance_m / 1000.0).max(0.0);
        // Don't march past the atmosphere boundary.
        let mu = view_dir[1].clamp(-1.0, 1.0);
        let bound = match dist_to_ground(r0, mu) {
            Some(tg) => tg,
            None => dist_to_top(r0, mu),
        };
        let t_max = dist_km.min(bound);
        if t_max <= 0.0 {
            return ([1.0, 1.0, 1.0], [0.0, 0.0, 0.0]);
        }
        let dt = t_max / steps as f32;
        let cos_theta = dot(view_dir, sun_dir);
        let pr = phase_rayleigh(cos_theta);
        let pm = phase_mie(cos_theta);
        let p0 = [0.0f32, r0, 0.0];
        let mut inscatter = [0.0f32; 3];
        let mut od = [0.0f32; 3];
        for i in 0..steps {
            let t = (i as f32 + 0.5) * dt;
            let p = [p0[0] + view_dir[0] * t, p0[1] + view_dir[1] * t, p0[2] + view_dir[2] * t];
            let radius = norm(p);
            let h = (radius - R_GROUND).max(0.0);
            let dr = rayleigh_density(h);
            let dm = mie_density(h);
            let doz = ozone_density(h);
            for c in 0..3 {
                od[c] += (BETA_RAYLEIGH[c] * dr + BETA_MIE_EXT * dm + BETA_OZONE[c] * doz) * dt;
            }
            let t_view = [(-od[0]).exp(), (-od[1]).exp(), (-od[2]).exp()];
            let up = [p[0] / radius, p[1] / radius, p[2] / radius];
            let mu_sun = dot(up, sun_dir);
            let t_sun = self.transmittance.sample(h, mu_sun);
            for c in 0..3 {
                let scatter = BETA_RAYLEIGH[c] * dr * pr + BETA_MIE * dm * pm;
                inscatter[c] += t_view[c] * t_sun[c] * scatter * dt * self.sun_irradiance[c];
            }
        }
        let transmittance = [(-od[0]).exp(), (-od[1]).exp(), (-od[2]).exp()];
        (transmittance, inscatter)
    }

    /// **Derived sky colours** for the analytic `sky.wgsl` uniforms: `(zenith,
    /// horizon)` RGB at this altitude + sun. `exposure` maps the (small) physical
    /// radiance into the renderer's `0..1`-ish tint range. Wiring these into
    /// `facett_map3d`'s `SunSky::sky_uniform` / horizon uniform makes the existing
    /// GPU sky pass altitude- and sun-driven with no new GPU pass.
    #[must_use]
    pub fn sky_colors(&self, sun_dir: [f32; 3], altitude_m: f32, exposure: f32) -> ([f32; 3], [f32; 3]) {
        let steps = 24;
        let zenith = self.sky_radiance([0.0, 1.0, 0.0], sun_dir, altitude_m, steps);
        // Horizon toward the sun's azimuth (a hair above level so the ray stays in air).
        let saz = (sun_dir[0] * sun_dir[0] + sun_dir[2] * sun_dir[2]).sqrt().max(1e-4);
        let horizon_dir = normalize([sun_dir[0] / saz, 0.06, sun_dir[2] / saz]);
        let horizon = self.sky_radiance(horizon_dir, sun_dir, altitude_m, steps);
        let tone = |c: [f32; 3]| {
            // Reinhard-ish exposure so very bright horizons don't clip to white.
            let e = |v: f32| {
                let x = (v * exposure).max(0.0);
                x / (1.0 + x)
            };
            [e(c[0]), e(c[1]), e(c[2])]
        };
        (tone(zenith), tone(horizon))
    }
}

#[inline]
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
#[inline]
fn norm(p: [f32; 3]) -> f32 {
    (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt()
}
#[inline]
fn normalize(v: [f32; 3]) -> [f32; 3] {
    let l = norm(v).max(1e-9);
    [v[0] / l, v[1] / l, v[2] / l]
}

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

    fn sun_at_elevation(elev_rad: f32) -> [f32; 3] {
        // Sun in the +x horizontal direction, lifted to `elev`.
        normalize([elev_rad.cos(), elev_rad.sin(), 0.0])
    }

    /// **INJECT-ASSERT (the LUT is physical):** transmittance is in `[0,1]`, and a
    /// longer / lower path lets LESS light through than a short overhead one — and
    /// blue is attenuated MORE than red over a grazing path (the reddening that makes
    /// sunsets warm). This is the precomputed-LUT render-proof, as data.
    #[test]
    fn transmittance_lut_is_physical_and_reddens_at_grazing_angles() {
        let lut = TransmittanceLut::standard();
        // Straight up from the ground: a short path, high transmittance.
        let up = lut.sample(0.0, 1.0);
        // Toward the horizon from the ground: a long grazing path, low transmittance.
        let graze = lut.sample(0.0, 0.02);
        for c in 0..3 {
            assert!((0.0..=1.0).contains(&up[c]), "T in [0,1] (up,{c})={}", up[c]);
            assert!((0.0..=1.0).contains(&graze[c]), "T in [0,1] (graze,{c})={}", graze[c]);
            assert!(up[c] > graze[c] + 1e-4, "overhead lets more through than grazing (c={c})");
        }
        // Grazing path: blue (c=2) is scattered out far more than red (c=0) → the
        // surviving light is RED-shifted (warm). T_red > T_blue.
        assert!(graze[0] > graze[2] + 0.05, "grazing path reddens: T_red {} > T_blue {}", graze[0], graze[2]);
        // High altitude → almost no air above → transmittance approaches 1.
        let high = lut.sample(R_TOP - R_GROUND - 1.0, 1.0);
        assert!(high[2] > up[2], "less air above at altitude → more blue survives");
    }

    /// **INJECT-ASSERT (the sky is blue, and the data says so):** at sea level with
    /// a high sun, the zenith radiance is BLUE-dominant (Rayleigh ∝ 1/λ⁴), proving
    /// the single-scatter integration produces the right colour, not a grey haze.
    #[test]
    fn daytime_zenith_is_blue() {
        let atmos = Atmosphere::earth();
        let sun = sun_at_elevation(80f32.to_radians());
        let z = atmos.sky_radiance([0.0, 1.0, 0.0], sun, 0.0, 32);
        assert!(z[2] > z[0] * 1.5, "zenith is blue: B {} ≫ R {}", z[2], z[0]);
        assert!(z[2] > z[1], "blue beats green at the zenith too");
        assert!(z.iter().all(|v| v.is_finite() && *v >= 0.0), "radiance finite + non-negative");
    }

    /// **INJECT-ASSERT (sunset reddening — the sky varies with the SUN as data):**
    /// as the sun drops to the horizon, the radiance toward it shifts WARM — the
    /// red:blue ratio at the horizon rises sharply between noon and sunset. This is
    /// the "light colour changes with sun angle" requirement, proven numerically.
    #[test]
    fn horizon_reddens_as_the_sun_sets() {
        let atmos = Atmosphere::earth();
        let horizon = normalize([1.0, 0.05, 0.0]); // look toward the sun's azimuth
        let ratio = |elev_deg: f32| {
            let sun = sun_at_elevation(elev_deg.to_radians());
            let c = atmos.sky_radiance(horizon, sun, 0.0, 48);
            c[0] / c[2].max(1e-6) // red / blue
        };
        let noon = ratio(75.0);
        let sunset = ratio(2.0);
        assert!(sunset > noon * 1.5, "horizon warms at sunset: R/B {sunset:.3} ≫ noon {noon:.3}");
    }

    /// **INJECT-ASSERT (altitude changes the sky — the headline #2 requirement):**
    /// climbing from sea level to the stratosphere DARKENS the overhead sky (less air
    /// overhead → less in-scatter → the sky deepens toward space-black). The total
    /// zenith radiance must drop monotonically-ish with altitude. Multi-value sweep.
    #[test]
    fn zenith_darkens_with_altitude() {
        let atmos = Atmosphere::earth();
        let sun = sun_at_elevation(70f32.to_radians());
        let lum = |alt_m: f32| {
            let c = atmos.sky_radiance([0.0, 1.0, 0.0], sun, alt_m, 32);
            c[0] + c[1] + c[2]
        };
        let ground = lum(0.0);
        let mid = lum(12_000.0);
        let high = lum(40_000.0);
        assert!(mid < ground, "sky dims climbing to 12 km ({mid:.4} < {ground:.4})");
        assert!(high < mid, "and dims further to 40 km ({high:.4} < {mid:.4})");
        // At 40 km most of the Rayleigh air is below the camera → a big drop.
        assert!(high < ground * 0.6, "high-altitude zenith is markedly darker");
    }

    /// **INJECT-ASSERT (aerial perspective replaces the fog clip):** a more distant
    /// surface is seen through LOWER transmittance and MORE in-scatter than a near
    /// one — the smooth haze build-up that replaces the harsh distance-fog clamp.
    /// Transmittance also reddens with distance (blue scatters out first).
    #[test]
    fn aerial_perspective_builds_haze_with_distance() {
        let atmos = Atmosphere::earth();
        let sun = sun_at_elevation(40f32.to_radians());
        // Camera 3 km up, looking out across distant terrain (slightly down): the
        // ray travels through real air to the surface, not straight into the ground.
        let view = normalize([1.0, -0.05, 0.0]);
        let (t_near, in_near) = atmos.aerial_perspective(view, sun, 500.0, 3000.0, 24);
        let (t_far, in_far) = atmos.aerial_perspective(view, sun, 30_000.0, 3000.0, 24);
        let lum = |c: [f32; 3]| c[0] + c[1] + c[2];
        // Farther → less light from the surface survives (transmittance falls).
        for ch in 0..3 {
            assert!(t_far[ch] <= t_near[ch] + 1e-6, "transmittance falls with distance (c={ch})");
        }
        assert!(lum(t_far) < lum(t_near) - 1e-3, "the far surface is dimmed by the air");
        // Farther → more in-scattered skylight piles up in front of it (the haze).
        assert!(lum(in_far) > lum(in_near), "in-scatter haze grows with distance");
        // The distant transmittance is red-shifted (blue removed first).
        assert!(t_far[0] > t_far[2], "distant haze reddens the surface light");
    }

    /// **INJECT-ASSERT (the derived uniforms drive the existing sky pass):**
    /// `sky_colors` returns finite, in-`[0,1]` zenith + horizon tints; the horizon is
    /// warmer (higher R:B) than the zenith at a low sun, and the colours CHANGE with
    /// altitude — exactly the data the GPU `sky.wgsl` uniforms consume.
    #[test]
    fn derived_sky_colors_feed_the_uniforms_and_track_altitude() {
        let atmos = Atmosphere::earth();
        let sun = sun_at_elevation(8f32.to_radians());
        let (zen, hor) = atmos.sky_colors(sun, 0.0, 12.0);
        for v in zen.iter().chain(hor.iter()) {
            assert!(v.is_finite() && (0.0..=1.0).contains(v), "tint in [0,1], got {v}");
        }
        // Horizon toward a low sun is warmer than the zenith.
        let rb = |c: [f32; 3]| c[0] / c[2].max(1e-6);
        assert!(rb(hor) > rb(zen), "horizon warmer than zenith at a low sun ({} > {})", rb(hor), rb(zen));
        // The zenith tint at altitude differs from sea level (altitude is wired in).
        let (zen_high, _) = atmos.sky_colors(sun, 30_000.0, 12.0);
        let dz: f32 = (0..3).map(|i| (zen_high[i] - zen[i]).abs()).sum();
        assert!(dz > 1e-3, "sky tint responds to altitude (Δ={dz})");
    }

    /// INJECT-ASSERT: the model is pure — two evaluations of the same query are
    /// bit-identical (FC-7 determinism, snapshot-stable).
    #[test]
    fn evaluation_is_deterministic() {
        let atmos = Atmosphere::earth();
        let sun = sun_at_elevation(30f32.to_radians());
        let a = atmos.sky_radiance(normalize([0.3, 0.5, 0.2]), sun, 1000.0, 24);
        let b = atmos.sky_radiance(normalize([0.3, 0.5, 0.2]), sun, 1000.0, 24);
        assert_eq!(a, b, "scattering is a pure function");
    }

    /// **INJECT-ASSERT (the GPU LUT's CPU ORACLE — the RED-when-broken check that
    /// runs anywhere, no GPU adapter needed).** The transmittance texels the GPU
    /// precompute pass ([`crate::scatter_gpu`]) is validated against must themselves
    /// be correct + deterministic: the table is row-major `h_size × mu_size`, its
    /// content hash is stable across two independent builds (a math regression would
    /// flip it), and the texel grid obeys the physics — overhead lets more light
    /// through than grazing, blue is scattered out harder over a grazing path. This
    /// is the reference the GPU-parity test compares to; if THIS fails, the GPU pass
    /// is validated against a wrong oracle, so it must fail loudly first.
    #[test]
    fn gpu_lut_oracle_is_stable_and_physical() {
        let (h, m, s) = (8usize, 16usize, 12usize);
        let a = TransmittanceLut::precompute(h, m, s);
        let b = TransmittanceLut::precompute(h, m, s);
        // Shape + determinism (the oracle the GPU readback maps 1:1 to).
        assert_eq!(a.texels().len(), h * m, "row-major h×m texels");
        assert_eq!(a.h_size, h);
        assert_eq!(a.mu_size, m);
        assert_eq!(a.content_hash(), b.content_hash(), "LUT precompute is deterministic");
        assert_ne!(a.content_hash(), 0, "hash is populated");
        // Physics at the ground row (hi=0): overhead μ≈+1 (last col) lets far more
        // through than a grazing μ≈-... (first col, pointing into the planet → ~0).
        let ground_overhead = a.texel(0, m - 1); // μ = +1
        let ground_graze = a.texel(0, m / 2); // μ = 0 (horizon-ish)
        for c in 0..3 {
            assert!((0.0..=1.0).contains(&ground_overhead[c]), "T in [0,1]");
            assert!(ground_overhead[c] > ground_graze[c] + 1e-4, "overhead > grazing (c={c})");
        }
        // Grazing path reddens: red survives more than blue.
        assert!(ground_graze[0] > ground_graze[2], "grazing path is red-shifted");

        // EMIT-DOCTRINE (L4): a LUT is a data artefact, so record its dims / format /
        // content-hash as a functional-status row. Emit + detail build are gated
        // behind `testmatrix` → a release build strips both.
        #[cfg(feature = "testmatrix")]
        {
            let std_lut = TransmittanceLut::standard();
            let ok = std_lut.texels().len() == std_lut.h_size * std_lut.mu_size && std_lut.content_hash() != 0;
            let detail = format!(
                "transmittance_lut dims={}x{} format=rgb_f32 texels={} content_hash={:#018x}",
                std_lut.h_size,
                std_lut.mu_size,
                std_lut.texels().len(),
                std_lut.content_hash()
            );
            facett_core::testmatrix::emit("facett-map3d-flight", "scatter_lut_oracle", ok, &detail);
            eprintln!("[atmos] emitted functional row: scatter_lut_oracle ok={ok} ({detail})");
        }
    }
}