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

nordisk/facett

facett-map3d/src/sky.rs

//! **Sun + sky lighting model** (Phase 1) — the single source of truth for the
//! directional sun, its colour/intensity, and the sky/ambient tint, shared by the
//! GPU PBR shader (`map3d.wgsl`) and the CPU painter so both render paths agree.
//!
//! The "flight-sim look" comes from LIGHT, not geometry: a warm directional sun
//! from the upper-front-left, a cool sky-blue ambient hemisphere, and a horizon
//! haze the geometry fogs toward. This module is pure data (no GPU, no egui), so
//! the lighting parameters are inject-assert-testable.

/// A directional sun + sky environment. All colours are linear-ish `[r,g,b]` in
/// `0..1`; `dir` points FROM the surface TO the sun (the convention the PBR
/// shader's `dot(n, l)` wants).
#[derive(Clone, Copy, Debug)]
pub struct SunSky {
    /// World-space direction surface→sun (normalised).
    pub dir: [f32; 3],
    /// Sun colour (rgb) — warm white.
    pub sun_color: [f32; 3],
    /// Sun intensity multiplier (direct light).
    pub sun_intensity: f32,
    /// Sky / ambient zenith tint (rgb) — cool blue.
    pub sky_color: [f32; 3],
    /// Ambient strength (the hemisphere fill that keeps shadowed faces readable).
    pub ambient: f32,
}

impl Default for SunSky {
    fn default() -> Self {
        Self::noon()
    }
}

impl SunSky {
    /// The default midday sun: high, upper-front-left, warm; cool blue sky fill.
    #[must_use]
    pub fn noon() -> Self {
        Self {
            dir: normalize([-0.4, 0.78, 0.5]),
            sun_color: [1.0, 0.96, 0.88],
            sun_intensity: 2.6,
            sky_color: [0.42, 0.56, 0.78],
            ambient: 0.42,
        }
    }

    /// A low, warm **golden-hour** sun (longer shadows, orange light) — used by the
    /// blob oracles to prove the sun ANGLE actually changes the shading.
    #[must_use]
    pub fn golden_hour() -> Self {
        Self {
            dir: normalize([-0.85, 0.22, 0.32]),
            sun_color: [1.0, 0.72, 0.45],
            sun_intensity: 2.2,
            sky_color: [0.36, 0.42, 0.62],
            ambient: 0.38,
        }
    }

    /// A **night** preset for car-nav night driving: a very low, dim, cool "moon"
    /// key with a deep-blue low-ambient sky, so facades fall into shadow and the lit
    /// route ribbon / HUD read as the bright chrome. The 3D twin of the 2D
    /// `CartographicNight` palette — darker + cooler than [`Self::golden_hour`], with a
    /// much lower `ambient` so the scene is unmistakably a night scene (the
    /// red-when-broken property the night proof asserts: `night().ambient <
    /// noon().ambient` and the sky is darker).
    #[must_use]
    pub fn night() -> Self {
        Self {
            dir: normalize([-0.3, 0.35, 0.4]),
            sun_color: [0.55, 0.62, 0.85],
            sun_intensity: 0.9,
            sky_color: [0.06, 0.09, 0.18],
            ambient: 0.16,
        }
    }

    /// Build a sun from an explicit azimuth + elevation (radians) — the matrix
    /// sweeps these to prove lit vs unlit faces differ with the sun angle.
    #[must_use]
    pub fn from_angles(azimuth: f32, elevation: f32) -> Self {
        let (se, ce) = elevation.sin_cos();
        let (sa, ca) = azimuth.sin_cos();
        let mut s = Self::noon();
        s.dir = normalize([ce * sa, se.max(0.02), ce * ca]);
        s
    }

    /// The sun direction packed as the shader's `light` uniform: `[dx, dy, dz, ambient]`.
    #[must_use]
    pub fn light_uniform(&self) -> [f32; 4] {
        [self.dir[0], self.dir[1], self.dir[2], self.ambient]
    }
    /// The `sun_color` uniform: `[r, g, b, intensity]`.
    #[must_use]
    pub fn sun_uniform(&self) -> [f32; 4] {
        [self.sun_color[0], self.sun_color[1], self.sun_color[2], self.sun_intensity]
    }
    /// The `sky_color` uniform: `[r, g, b, horizon_bias]`.
    #[must_use]
    pub fn sky_uniform(&self) -> [f32; 4] {
        [self.sky_color[0], self.sky_color[1], self.sky_color[2], 0.5]
    }

    /// **Light-space view-projection** for the shadow pass: an **orthographic**
    /// camera placed up along the sun direction, looking back at the scene centre,
    /// fitted to a box of half-extent `radius` (design units). Column-major `[f32;
    /// 16]` mapping world → light clip with z in wgpu's `[0,1]`. The mesh is
    /// depth-rendered through this; the main pass projects each fragment by the same
    /// matrix to look up whether it sits behind the nearest sun-facing surface (= in
    /// shadow). `centre` is the scene centre the shadow frustum is fitted around.
    ///
    /// **glam (GFX_V2 item 3).** This used to be 13 hand-written `set(row, col, …)`
    /// stores plus a hand-rolled `look_at`; it is now `orthographic_rh · look_at_rh`.
    /// Both glam calls match the hand-rolled algebra term for term, which is what
    /// makes the swap provable rather than hopeful:
    /// * `look_at_rh` builds `s = normalize(cross(fwd, up))`, `u = cross(s, fwd)` —
    ///   the same right/up basis, and its view space looks down `-z`, so a point's
    ///   `-view_z` is exactly the old `f·p + tz`.
    /// * `orthographic_rh` is the wgpu `[0,1]` depth convention (NOT the GL `[-1,1]`
    ///   one — `orthographic_rh_gl` is the wrong function here) and maps that same
    ///   distance through `(d − near)/(far − near)`, i.e. the old `za`/`zb`.
    /// * symmetric `±radius` bounds give `2/(right−left) = 1/radius` = the old
    ///   `sx`/`sy`, with zero xy translation.
    ///
    /// `sky_light_view_proj_matches_the_hand_rolled_reference` holds it to the
    /// pre-glam implementation, kept verbatim as a test-only reference.
    #[must_use]
    pub fn light_view_proj(&self, centre: [f32; 3], radius: f32) -> [f32; 16] {
        // `Mat4` is no longer named here — both matrices now come from the
        // `glam::camera::rh` free functions below, which return one.
        use glam::Vec3;
        let sun = Vec3::from_array(self.dir);
        // Forward runs sun→scene; pick a stable up when the sun is near-vertical.
        let f = -sun;
        let world_up = if f.y.abs() > 0.95 { Vec3::Z } else { Vec3::Y };
        // Eye sits up-sun from the centre, far enough to see the whole box.
        let dist = radius * 2.0;
        let centre = Vec3::from_array(centre);
        let eye = centre + sun * dist;
        // Orthographic: half-width = half-height = radius; depth range [near, far].
        let near = 0.01_f32;
        let far = dist * 2.0 + radius;
        // `glam::camera::rh::proj::directx` — NOT `::vulkan` — is the successor to the
        // deprecated `Mat4::orthographic_rh`: both give Z in [0,1] with Y **up**, and
        // glam's own deprecation note names `directx`. `::vulkan` is the same depth
        // range with Y **down**, so taking the module whose name matches the backend
        // would silently flip the shadow map vertically. `look_at_mat4` is the exact
        // successor to `Mat4::look_at_rh`. Both swaps are held to the pre-glam
        // implementation by `sky_light_view_proj_matches_the_hand_rolled_reference`.
        let proj = glam::camera::rh::proj::directx::orthographic(-radius, radius, -radius, radius, near, far);
        (proj * glam::camera::rh::view::look_at_mat4(eye, centre, world_up)).to_cols_array()
    }

    /// **CPU twin of the shader's diffuse term** (Lambert + ambient), so the CPU
    /// painter shades with the same sun. Returns a 0..~1 shade scalar for a normal.
    #[must_use]
    pub fn lambert_shade(&self, n: [f32; 3]) -> f32 {
        let d = (n[0] * self.dir[0] + n[1] * self.dir[1] + n[2] * self.dir[2]).max(0.0);
        (self.ambient + (1.0 - self.ambient) * d).clamp(0.0, 1.0)
    }
}

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

/// Apply a column-major `[f32;16]` to a homogeneous point → `[x,y,z,w]` clip.
///
/// `glam::Mat4` IS this layout — `#[repr(C)]`, 64 bytes, column-major — so
/// `from_cols_array` is a reinterpretation, not a transpose, and the SIMD `*` below
/// replaces the scalar row×col loop this used to run.
#[must_use]
pub fn apply_mat(m: &[f32; 16], p: [f32; 3]) -> [f32; 4] {
    let v = glam::Vec3::from_array(p).extend(1.0);
    (glam::Mat4::from_cols_array(m) * v).to_array()
}

/// **Inverse of a column-major `[f32;16]` 4×4 matrix** (the same layout
/// [`SunSky::light_view_proj`] / `OrbitCamera::view_proj` emit). The **skybox**
/// fragment shader needs `inverse(view_proj)` to turn each screen NDC back into a
/// world-space view ray (`ndc → world` at the near + far plane → ray direction),
/// so the analytic sky/sun-disc is evaluated per pixel along the true camera ray.
///
/// Returns `None` for a singular (non-invertible) matrix. Cofactor expansion
/// (Cramer / the MESA `gluInvertMatrix` form), deterministic — no GPU, no clock.
#[must_use]
pub fn invert(m: &[f32; 16]) -> Option<[f32; 16]> {
    let mat = glam::Mat4::from_cols_array(m);
    // glam's `inverse()` does NOT check for singularity — it divides by the
    // determinant and hands back inf/NaN. The `None` contract is this crate's, so
    // the guard stays explicit, on glam's own determinant, at the historical
    // threshold.
    if mat.determinant().abs() < 1e-20 {
        return None;
    }
    Some(mat.inverse().to_cols_array())
}

/// Multiply a column-major `[f32;16]` by a homogeneous `[x,y,z,w]` → `[x,y,z,w]`.
/// (The skybox CPU twin uses this to map an NDC corner back to world space.)
#[must_use]
pub fn apply_mat4(m: &[f32; 16], v: [f32; 4]) -> [f32; 4] {
    (glam::Mat4::from_cols_array(m) * glam::Vec4::from_array(v)).to_array()
}

/// The analytic sky **radiance** along a world direction, the CPU twin of the
/// shader's `env_gradient` + a broad sun glow — integrated into SH for the diffuse
/// IBL so the CPU SH and the GPU specular reflections read the same sky. `horizon`
/// is the background/haze colour (the shader's `fog.rgb`), `zenith` the sky tint.
#[inline]
fn sky_radiance_env(
    dir: [f32; 3],
    sun_dir: [f32; 3],
    sun_color: [f32; 3],
    sun_intensity: f32,
    zenith: [f32; 3],
    horizon: [f32; 3],
) -> [f32; 3] {
    let up = dir[1].clamp(0.0, 1.0);
    // SKY_HORIZON_BIAS (0.5) — the same horizon→zenith blend the sky pass uses.
    let t = up.powf(0.5);
    let below = (-dir[1] * 3.0).clamp(0.0, 1.0);
    // Broad forward-scatter sun glow (no tight disc — irradiance is low-frequency),
    // so the diffuse fill warms toward the sun.
    let cosang = (dir[0] * sun_dir[0] + dir[1] * sun_dir[1] + dir[2] * sun_dir[2]).max(0.0);
    let glow = cosang.powf(8.0) * 0.5 * sun_intensity;
    let mut col = [0.0f32; 3];
    for c in 0..3 {
        let grad = horizon[c] * (1.0 - t) + zenith[c] * t;
        let ground = horizon[c] * 0.6;
        let base = grad * (1.0 - below) + ground * below;
        col[c] = base + sun_color[c] * glow;
    }
    col
}

/// Integrate the analytic sky into **9 SH-L2 coefficients** (raw radiance projection
/// `L_lm = ∫ L(s)·Y_lm(s) dω`), packed as `[[r,g,b,0]; 9]` for the GPU uniform. The
/// shader (`map3d.wgsl::sh_irradiance`) reconstructs cosine-convolved irradiance from
/// these via the Ramamoorthi constants. Deterministic (fixed Fibonacci sphere), no
/// GPU/clock — a ~free per-frame integral of a few hundred samples.
///
/// Coefficient order matches the shader: `[Y00, Y1-1(y), Y10(z), Y11(x), Y2-2(xy),
/// Y2-1(yz), Y20(3z²-1), Y21(xz), Y22(x²-y²)]`.
#[must_use]
pub fn sky_sh_l2(
    sun_dir: [f32; 3],
    sun_color: [f32; 3],
    sun_intensity: f32,
    zenith: [f32; 3],
    horizon: [f32; 3],
) -> [[f32; 4]; 9] {
    const N: usize = 256;
    let sd = normalize(sun_dir);
    let mut acc = [[0.0f32; 3]; 9];
    // Fibonacci sphere: uniform directions over the full sphere.
    let ga = std::f32::consts::PI * (3.0 - 5.0_f32.sqrt()); // golden angle
    for i in 0..N {
        let y = 1.0 - 2.0 * (i as f32 + 0.5) / N as f32; // [1,-1]
        let r = (1.0 - y * y).max(0.0).sqrt();
        let phi = ga * i as f32;
        let s = [r * phi.cos(), y, r * phi.sin()];
        let l = sky_radiance_env(s, sd, sun_color, sun_intensity, zenith, horizon);
        let (x, yy, z) = (s[0], s[1], s[2]);
        let basis = [
            0.282095,
            0.488603 * yy,
            0.488603 * z,
            0.488603 * x,
            1.092548 * x * yy,
            1.092548 * yy * z,
            0.315392 * (3.0 * z * z - 1.0),
            1.092548 * x * z,
            0.546274 * (x * x - yy * yy),
        ];
        for (b, coeff) in basis.iter().zip(acc.iter_mut()) {
            for c in 0..3 {
                coeff[c] += l[c] * b;
            }
        }
    }
    // Solid angle per sample over the whole sphere: 4π / N.
    let w = 4.0 * std::f32::consts::PI / N as f32;
    let mut out = [[0.0f32; 4]; 9];
    for (o, a) in out.iter_mut().zip(acc.iter()) {
        *o = [a[0] * w, a[1] * w, a[2] * w, 0.0];
    }
    out
}

#[cfg(test)]
mod tests {
    // ── The PRE-GLAM implementations, kept verbatim ──────────────────────────
    //
    // GFX_V2 item 3 replaced `light_view_proj` and `invert` with glam. A swap like
    // that is only worth trusting if the OLD code is still here to disagree, so both
    // originals live on as test-only references and the two `*_matches_the_hand_rolled_
    // reference` tests below hold the new implementations to them across a pose grid.
    // Do not delete these: without them the conversion has no witness, and the
    // remaining tests (round-trip to identity, depth ordering) all pass just as
    // happily on a transposed or GL-depth-range matrix.
    mod reference {
        #[inline]
        fn normalize(v: [f32; 3]) -> [f32; 3] {
            let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-9);
            [v[0] / l, v[1] / l, v[2] / l]
        }

        #[inline]
        fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
            [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
        }

        /// `SunSky::light_view_proj` as it stood before glam.
        pub fn light_view_proj(dir: [f32; 3], centre: [f32; 3], radius: f32) -> [f32; 16] {
            let f = [-dir[0], -dir[1], -dir[2]];
            let world_up = if f[1].abs() > 0.95 { [0.0, 0.0, 1.0] } else { [0.0, 1.0, 0.0] };
            let r = normalize(cross(f, world_up));
            let u = normalize(cross(r, f));
            let dist = radius * 2.0;
            let eye = [centre[0] + dir[0] * dist, centre[1] + dir[1] * dist, centre[2] + dir[2] * dist];
            let dot = |a: [f32; 3], b: [f32; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
            let (tx, ty, tz) = (-dot(r, eye), -dot(u, eye), -dot(f, eye));
            let near = 0.01_f32;
            let far = dist * 2.0 + radius;
            let (sx, sy) = (1.0 / radius, 1.0 / radius);
            let za = 1.0 / (far - near);
            let zb = -near / (far - near);
            let mut m = [0.0f32; 16];
            let set = |m: &mut [f32; 16], row: usize, col: usize, v: f32| m[col * 4 + row] = v;
            set(&mut m, 0, 0, sx * r[0]);
            set(&mut m, 0, 1, sx * r[1]);
            set(&mut m, 0, 2, sx * r[2]);
            set(&mut m, 0, 3, sx * tx);
            set(&mut m, 1, 0, sy * u[0]);
            set(&mut m, 1, 1, sy * u[1]);
            set(&mut m, 1, 2, sy * u[2]);
            set(&mut m, 1, 3, sy * ty);
            set(&mut m, 2, 0, za * f[0]);
            set(&mut m, 2, 1, za * f[1]);
            set(&mut m, 2, 2, za * f[2]);
            set(&mut m, 2, 3, za * tz + zb);
            set(&mut m, 3, 3, 1.0);
            m
        }

        /// `invert` as it stood before glam — the MESA `gluInvertMatrix` cofactor form.
        #[allow(clippy::too_many_lines, reason = "the historical 4x4 cofactor inverse, kept as a witness")]
        pub fn invert(m: &[f32; 16]) -> Option<[f32; 16]> {
            let mut inv = [0.0f32; 16];
            inv[0] = m[5] * m[10] * m[15] - m[5] * m[11] * m[14] - m[9] * m[6] * m[15]
                + m[9] * m[7] * m[14] + m[13] * m[6] * m[11] - m[13] * m[7] * m[10];
            inv[4] = -m[4] * m[10] * m[15] + m[4] * m[11] * m[14] + m[8] * m[6] * m[15]
                - m[8] * m[7] * m[14] - m[12] * m[6] * m[11] + m[12] * m[7] * m[10];
            inv[8] = m[4] * m[9] * m[15] - m[4] * m[11] * m[13] - m[8] * m[5] * m[15]
                + m[8] * m[7] * m[13] + m[12] * m[5] * m[11] - m[12] * m[7] * m[9];
            inv[12] = -m[4] * m[9] * m[14] + m[4] * m[10] * m[13] + m[8] * m[5] * m[14]
                - m[8] * m[6] * m[13] - m[12] * m[5] * m[10] + m[12] * m[6] * m[9];
            inv[1] = -m[1] * m[10] * m[15] + m[1] * m[11] * m[14] + m[9] * m[2] * m[15]
                - m[9] * m[3] * m[14] - m[13] * m[2] * m[11] + m[13] * m[3] * m[10];
            inv[5] = m[0] * m[10] * m[15] - m[0] * m[11] * m[14] - m[8] * m[2] * m[15]
                + m[8] * m[3] * m[14] + m[12] * m[2] * m[11] - m[12] * m[3] * m[10];
            inv[9] = -m[0] * m[9] * m[15] + m[0] * m[11] * m[13] + m[8] * m[1] * m[15]
                - m[8] * m[3] * m[13] - m[12] * m[1] * m[11] + m[12] * m[3] * m[9];
            inv[13] = m[0] * m[9] * m[14] - m[0] * m[10] * m[13] - m[8] * m[1] * m[14]
                + m[8] * m[2] * m[13] + m[12] * m[1] * m[10] - m[12] * m[2] * m[9];
            inv[2] = m[1] * m[6] * m[15] - m[1] * m[7] * m[14] - m[5] * m[2] * m[15]
                + m[5] * m[3] * m[14] + m[13] * m[2] * m[7] - m[13] * m[3] * m[6];
            inv[6] = -m[0] * m[6] * m[15] + m[0] * m[7] * m[14] + m[4] * m[2] * m[15]
                - m[4] * m[3] * m[14] - m[12] * m[2] * m[7] + m[12] * m[3] * m[6];
            inv[10] = m[0] * m[5] * m[15] - m[0] * m[7] * m[13] - m[4] * m[1] * m[15]
                + m[4] * m[3] * m[13] + m[12] * m[1] * m[7] - m[12] * m[3] * m[5];
            inv[14] = -m[0] * m[5] * m[14] + m[0] * m[6] * m[13] + m[4] * m[1] * m[14]
                - m[4] * m[2] * m[13] - m[12] * m[1] * m[6] + m[12] * m[2] * m[5];
            inv[3] = -m[1] * m[6] * m[11] + m[1] * m[7] * m[10] + m[5] * m[2] * m[11]
                - m[5] * m[3] * m[10] - m[9] * m[2] * m[7] + m[9] * m[3] * m[6];
            inv[7] = m[0] * m[6] * m[11] - m[0] * m[7] * m[10] - m[4] * m[2] * m[11]
                + m[4] * m[3] * m[10] + m[8] * m[2] * m[7] - m[8] * m[3] * m[6];
            inv[11] = -m[0] * m[5] * m[11] + m[0] * m[7] * m[9] + m[4] * m[1] * m[11]
                - m[4] * m[3] * m[9] - m[8] * m[1] * m[7] + m[8] * m[3] * m[5];
            inv[15] = m[0] * m[5] * m[10] - m[0] * m[6] * m[9] - m[4] * m[1] * m[10]
                + m[4] * m[2] * m[9] + m[8] * m[1] * m[6] - m[8] * m[2] * m[5];
            let det = m[0] * inv[0] + m[1] * inv[4] + m[2] * inv[8] + m[3] * inv[12];
            if det.abs() < 1e-20 {
                return None;
            }
            let inv_det = 1.0 / det;
            for v in &mut inv {
                *v *= inv_det;
            }
            Some(inv)
        }
    }

    use super::*;

    /// Reconstruct cosine-convolved irradiance E(n) from SH-L2 (the Rust twin of the
    /// shader's `sh_irradiance`, for the test only).
    fn sh_e(sh: &[[f32; 4]; 9], n: [f32; 3]) -> [f32; 3] {
        let (x, y, z) = (n[0], n[1], n[2]);
        let (c1, c2, c3, c4, c5) = (0.429043, 0.511664, 0.743125, 0.886227, 0.247708);
        let mut e = [0.0f32; 3];
        for k in 0..3 {
            let (l00, l1m1, l10) = (sh[0][k], sh[1][k], sh[2][k]);
            let (l11, l2m2, l2m1) = (sh[3][k], sh[4][k], sh[5][k]);
            let (l20, l21, l22) = (sh[6][k], sh[7][k], sh[8][k]);
            e[k] = c1 * l22 * (x * x - y * y) + c3 * l20 * (z * z) + c4 * l00 - c5 * l20
                + 2.0 * c1 * (l2m2 * x * y + l21 * x * z + l2m1 * y * z)
                + 2.0 * c2 * (l11 * x + l1m1 * y + l10 * z);
        }
        e
    }

    /// **INJECT-ASSERT (IBL diffuse SH):** a *constant* grey sky integrates to a flat
    /// irradiance E ≈ π·L in every direction (the analytic SH identity), and a real
    /// directional sky lifts the sun-facing hemisphere above the away side — the
    /// directional sky bounce the flat-hemisphere ambient could not represent.
    #[test]
    fn sky_sh_l2_reconstructs_irradiance() {
        use std::f32::consts::PI;
        // Constant grey sky, no sun: horizon == zenith, intensity 0.
        let g = 0.5;
        let flat = sky_sh_l2([0.0, 1.0, 0.0], [0.0; 3], 0.0, [g, g, g], [g, g, g]);
        let up = sh_e(&flat, [0.0, 1.0, 0.0])[0];
        let down = sh_e(&flat, [0.0, -1.0, 0.0])[0];
        let side = sh_e(&flat, [1.0, 0.0, 0.0])[0];
        // An up-normal integrates the full-brightness upper hemisphere → E ≈ π·L.
        assert!((up - PI * g).abs() < 0.06, "up-facing sky → E ≈ π·L, got {up} want {}", PI * g);
        // The sky twin darkens the lower hemisphere to a ground haze, so a down-facing
        // normal (an underside/overhang) sees LESS irradiance — real directional sky
        // occlusion the old flat-hemisphere ambient could not represent. Side sits between.
        assert!(down < up - 0.1, "down-facing (ground-haze) darker than up ({down} < {up})");
        assert!(side < up + 0.01 && side > down, "side irradiance sits between up and down ({side})");

        // Real directional sky: a warm sun toward +x lifts the +x-facing irradiance.
        let sun = normalize([0.8, 0.5, 0.0]);
        let sh = sky_sh_l2(sun, [1.0, 0.9, 0.8], 2.6, [0.42, 0.56, 0.78], [0.7, 0.75, 0.82]);
        let toward = sh_e(&sh, sun); // normal facing the sun
        let away = sh_e(&sh, [-sun[0], -sun[1], -sun[2]]);
        let lum = |c: [f32; 3]| 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
        assert!(lum(toward) > lum(away) + 0.05, "sun-facing irradiance brighter ({} > {})", lum(toward), lum(away));
    }

    // ── GFX_V2 item 3: glam vs the hand-rolled algebra ───────────────────────

    /// A spread of sun angles + scene framings, including the near-vertical sun that
    /// trips `light_view_proj`'s up-vector fallback (`|f.y| > 0.95`), so the grid
    /// exercises BOTH branches rather than sitting on one.
    ///
    /// Framings stay inside the operating envelope: `backend.rs:427` calls this with
    /// `centre = [0.0, 0.4, 0.0]` and the three cascade radii `0.75 / 1.35 / 2.40`,
    /// i.e. an origin-local design space where `|centre| / radius` is order 1. The
    /// grid widens that to ratios up to ~23, which is already generous. See
    /// `shadow_matrix_conditioning_limits_agreement_far_from_the_origin` for what
    /// happens beyond it — and why that is f32, not glam.
    fn pose_grid() -> Vec<(SunSky, [f32; 3], f32)> {
        let mut out = Vec::new();
        for (az, el) in [(0.0, 0.3), (1.0, 0.3), (2.4, 0.9), (-1.7, 0.15), (0.7, 1.52), (3.1, 1.56)] {
            for (centre, radius) in [
                ([0.0, 0.4, 0.0], 0.75),   // the real cascade 0
                ([0.0, 0.4, 0.0], 2.40),   // the real cascade 2
                ([12.0, -3.0, 7.5], 40.0), // a wide scene
                ([-6.0, 1.0, 9.0], 0.5),   // |centre| / radius ≈ 23
            ] {
                out.push((SunSky::from_angles(az, el), centre, radius));
            }
        }
        out.push((SunSky::noon(), [0.0, 0.0, 0.0], 8.0));
        out.push((SunSky::golden_hour(), [5.0, 1.0, -5.0], 60.0));
        out.push((SunSky::night(), [0.0, 2.0, 0.0], 3.0));
        out
    }

    /// The 8 corners of the shadow box, plus its centre — the points that actually
    /// get projected through this matrix.
    fn box_probes(centre: [f32; 3], radius: f32) -> Vec<[f32; 3]> {
        let mut v = vec![centre];
        for dx in [-1.0f32, 1.0] {
            for dy in [-1.0f32, 1.0] {
                for dz in [-1.0f32, 1.0] {
                    v.push([centre[0] + dx * radius, centre[1] + dy * radius, centre[2] + dz * radius]);
                }
            }
        }
        v
    }

    /// **The witness for the glam swap of `light_view_proj`, on APPLIED OUTPUT.**
    /// `orthographic_rh · look_at_rh` must land real world points on the same CLIP
    /// coordinates as the 13 hand-written `set(row, col, …)` stores it replaced.
    ///
    /// Clip space is the unit that matters — it is what the shadow lookup compares —
    /// and it is also the honest one: raw matrix cells of a far-from-origin frustum
    /// run to ~1700, where a "1e-6 relative" cell tolerance is meaninglessly loose in
    /// the clip units those cells feed. So this projects the shadow box's 8 corners
    /// and its centre and holds the two to 1e-4 CLIP units, on a box that spans
    /// [-1, 1] — a 20000th of the frustum.
    ///
    /// This is the guard that can actually go red. The other shadow tests cannot:
    /// `light_matrix_orders_depth_along_the_sun_ray` passes on a matrix scaled by any
    /// positive constant, and `invert_round_trips_to_identity` passes on ANY
    /// invertible matrix at all — including a transposed one, or one built with
    /// `orthographic_rh_gl`'s `[-1,1]` depth range, which would silently double every
    /// shadow-map depth.
    #[test]
    fn sky_light_view_proj_matches_the_hand_rolled_reference() {
        let mut worst = 0.0f32;
        let mut worst_at = String::new();
        let mut probes = 0usize;
        for (sun, centre, radius) in pose_grid() {
            let got = sun.light_view_proj(centre, radius);
            let want = reference::light_view_proj(sun.dir, centre, radius);
            for p in box_probes(centre, radius) {
                let a = apply_mat(&got, p);
                let b = apply_mat(&want, p);
                for k in 0..4 {
                    let err = (a[k] - b[k]).abs();
                    if err > worst {
                        worst = err;
                        worst_at = format!(
                            "clip.{} of {p:?} at dir={:?} centre={centre:?} radius={radius}: \
                             glam {} vs hand-rolled {}",
                            ["x", "y", "z", "w"][k],
                            sun.dir,
                            a[k],
                            b[k]
                        );
                    }
                }
                probes += 1;
            }
        }
        assert!(probes >= 200, "the grid must actually project something, got {probes} probes");
        assert!(
            worst < 1e-4,
            "glam's orthographic_rh · look_at_rh puts a point {worst:.3e} clip units away from \
             where the hand-rolled shadow matrix puts it — worst {worst_at}"
        );
        // Non-vacuous on two counts: the grid must produce DIFFERENT matrices (else
        // "they agree" is two constants agreeing), and the probes must actually
        // SPREAD across clip space (else they all landed on the frustum centre and
        // agreement is trivial).
        let a = SunSky::from_angles(0.0, 0.3).light_view_proj([0.0, 0.4, 0.0], 0.75);
        let b = SunSky::from_angles(2.4, 0.9).light_view_proj([12.0, -3.0, 7.5], 40.0);
        assert!(
            a.iter().zip(&b).any(|(x, y)| (x - y).abs() > 1e-3),
            "the pose grid must produce DIFFERENT matrices, else this proves nothing"
        );
        let m = SunSky::noon().light_view_proj([0.0, 0.4, 0.0], 0.75);
        let xs: Vec<f32> = box_probes([0.0, 0.4, 0.0], 0.75).iter().map(|p| apply_mat(&m, *p)[0]).collect();
        let spread = xs.iter().fold(f32::MIN, |a, b| a.max(*b)) - xs.iter().fold(f32::MAX, |a, b| a.min(*b));
        assert!(spread > 1.0, "the box corners must spread across clip x, got {spread}");
    }

    /// **What the pose grid deliberately excludes, and why it is f32 and not glam.**
    /// A shadow box of radius `R` centred `C` from the origin bakes a translation of
    /// order `C/R` into the matrix, and the clip coordinate of a point inside the box
    /// is then a difference of two numbers of that size. At `C/R ≈ 3700`
    /// (`centre = [-250, 18, 900]`, `radius = 0.25`) the two association orders —
    /// `sx · (−r·eye)` hand-rolled, versus the `proj × view` matrix product glam does
    /// — disagree by ~4e-5 RELATIVE on a cell of ~1694, which is ~0.06 in clip units:
    /// 3% of the whole frustum.
    ///
    /// Neither answer is the right one; f32 has ~7 digits and that pose spends them
    /// all on the translation. It is recorded here rather than hidden by a slack
    /// epsilon, and it is 3700× outside the operating envelope (`backend.rs` runs at
    /// `C/R ≈ 0.5`). The assertion is deliberately inverted — if a future change makes
    /// this pose AGREE, the conditioning analysis is wrong and someone should look.
    #[test]
    fn shadow_matrix_conditioning_limits_agreement_far_from_the_origin() {
        let sun = SunSky::from_angles(1.0, 0.9);
        let (centre, radius) = ([-250.0f32, 18.0, 900.0], 0.25f32);
        let got = sun.light_view_proj(centre, radius);
        let want = reference::light_view_proj(sun.dir, centre, radius);
        let drift = (0..16).fold(0.0f32, |m, i| m.max((got[i] - want[i]).abs()));
        assert!(
            drift > 1e-3,
            "expected f32 conditioning to bite at |centre|/radius ≈ 3700, but the two agree to \
             {drift:.3e} — if that is now true the analysis above is stale, not the code"
        );
        // And the operating envelope is genuinely fine, which is the point.
        let ok = sun.light_view_proj([0.0, 0.4, 0.0], 0.75);
        let ok_ref = reference::light_view_proj(sun.dir, [0.0, 0.4, 0.0], 0.75);
        let ok_drift = (0..16).fold(0.0f32, |m, i| m.max((ok[i] - ok_ref[i]).abs()));
        assert!(ok_drift < 1e-5, "the REAL cascade framing must agree, got {ok_drift:.3e}");
    }

    /// **The witness for the glam swap of `invert`.** `Mat4::inverse()` must
    /// reproduce the 48-line hand-transcribed MESA cofactor expansion it replaced,
    /// on the real matrices this crate inverts (shadow + camera view-projections),
    /// and must keep the `None`-on-singular contract.
    #[test]
    fn sky_invert_matches_the_hand_rolled_cofactor_reference() {
        let mut worst = 0.0f32;
        let mut worst_at = String::new();
        let mut checked = 0usize;
        for (sun, centre, radius) in pose_grid() {
            let m = reference::light_view_proj(sun.dir, centre, radius);
            let got = invert(&m).expect("a shadow view-projection is invertible");
            let want = reference::invert(&m).expect("reference agrees it is invertible");
            let mag = want.iter().fold(1.0f32, |a, v| a.max(v.abs()));
            for i in 0..16 {
                let err = (got[i] - want[i]).abs() / mag;
                if err > worst {
                    worst = err;
                    worst_at = format!("cell {i} at radius={radius}: glam {} vs MESA {}", got[i], want[i]);
                }
            }
            checked += 1;
        }
        assert!(checked >= 20, "the grid must actually cover something, got {checked} matrices");
        assert!(
            worst < 1e-5,
            "glam's Mat4::inverse drifted from the MESA cofactor inverse by {worst:.3e} \
             (relative) — worst {worst_at}"
        );
        // The `None` contract is this crate's, not glam's — glam would hand back
        // inf/NaN. A rank-deficient matrix (two identical columns) must still be
        // rejected by BOTH, and a NON-singular one must still be accepted, so this
        // cannot pass by rejecting everything.
        let mut singular = reference::light_view_proj(SunSky::noon().dir, [0.0, 0.0, 0.0], 4.0);
        singular[4] = singular[0];
        singular[5] = singular[1];
        singular[6] = singular[2];
        singular[7] = singular[3];
        assert!(invert(&singular).is_none(), "a rank-deficient matrix must be rejected, not returned as NaN");
        assert!(reference::invert(&singular).is_none(), "the reference agrees it is singular");
        assert!(invert(&reference::light_view_proj(SunSky::noon().dir, [0.0, 0.0, 0.0], 4.0)).is_some());
    }

    /// INJECT-ASSERT: the sun direction is unit-length and points UP (above the
    /// horizon) for every preset — a sub-horizon sun would light the city from
    /// below (the wrong look).
    #[test]
    fn sun_is_unit_and_above_horizon() {
        for s in [SunSky::noon(), SunSky::golden_hour(), SunSky::from_angles(1.0, 0.3)] {
            let len = (s.dir[0].powi(2) + s.dir[1].powi(2) + s.dir[2].powi(2)).sqrt();
            assert!((len - 1.0).abs() < 1e-4, "sun dir normalised, got {len}");
            assert!(s.dir[1] > 0.0, "sun above the horizon, got y={}", s.dir[1]);
        }
    }

    /// **INJECT-ASSERT (the sun ACTUALLY shades):** a face whose normal points at
    /// the sun is brighter than one facing away — and a different sun ANGLE changes
    /// which face is brightest. This is the data behind the "sun lit vs unlit" blob.
    #[test]
    fn lambert_shade_tracks_the_sun_angle() {
        let noon = SunSky::noon();
        let up = noon.lambert_shade([0.0, 1.0, 0.0]); // roof catches the high sun
        let down = noon.lambert_shade([0.0, -1.0, 0.0]); // underside only ambient
        assert!(up > down, "sun-facing roof brighter than the underside ({up} > {down})");
        assert!((down - noon.ambient).abs() < 1e-4, "the unlit face gets only ambient");

        // A low golden-hour sun lights a WALL (horizontal normal) more than noon does.
        let gold = SunSky::golden_hour();
        let wall = [-1.0_f32, 0.0, 0.0];
        assert!(
            gold.lambert_shade(wall) > noon.lambert_shade(wall),
            "the low sun lights the west wall more than the high sun"
        );
    }

    /// **INJECT-ASSERT (the night preset is a NIGHT scene):** the `night` sun is
    /// dimmer, cooler and much lower-ambient than midday, so a shadowed face falls
    /// darker at night than at noon (headlight-lit night driving). Red-when-broken if
    /// `night()` ever drifts back toward a daylight ambient.
    #[test]
    fn night_preset_is_darker_than_day() {
        let noon = SunSky::noon();
        let night = SunSky::night();
        assert!(night.ambient < noon.ambient, "night ambient is lower ({} < {})", night.ambient, noon.ambient);
        assert!(night.sun_intensity < noon.sun_intensity, "the night key is dimmer");
        // The blue night sky is darker (lower luma) than the day sky.
        let luma = |c: [f32; 3]| 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
        assert!(luma(night.sky_color) < luma(noon.sky_color), "night sky is darker");
        // A shadowed (down-facing) face gets only ambient — darker at night.
        let down = [0.0_f32, -1.0, 0.0];
        assert!(night.lambert_shade(down) < noon.lambert_shade(down), "night shadows fall darker");
    }

    /// **INJECT-ASSERT (shadow light matrix):** the light view-projection orders
    /// depth along the sun ray — a point CLOSER to the sun (further up-sun from the
    /// centre) gets a SMALLER light-clip depth than one further from the sun, which
    /// is exactly what the shadow depth-compare needs (the occluder writes the
    /// smaller depth; the shadowed receiver is behind it). And the scene centre
    /// projects inside the `[-1,1]×[-1,1]`, `[0,1]` light clip box.
    #[test]
    fn light_matrix_orders_depth_along_the_sun_ray() {
        let s = SunSky::noon();
        let m = s.light_view_proj([0.0, 0.0, 0.0], 1.2);
        // The centre projects into the light clip box.
        let c = apply_mat(&m, [0.0, 0.0, 0.0]);
        assert!(c[0].abs() <= 1.0 && c[1].abs() <= 1.0, "centre in light XY clip");
        assert!((0.0..=1.0).contains(&c[2]), "centre depth in [0,1], got {}", c[2]);
        // A point pushed toward the sun vs away from it.
        let toward = [s.dir[0] * 0.5, s.dir[1] * 0.5, s.dir[2] * 0.5];
        let away = [-s.dir[0] * 0.5, -s.dir[1] * 0.5, -s.dir[2] * 0.5];
        let dt = apply_mat(&m, toward)[2];
        let da = apply_mat(&m, away)[2];
        assert!(dt < da, "closer-to-sun has smaller light depth ({dt} < {da}) → it occludes");
    }

    /// INJECT-ASSERT: the uniform packings carry the documented fields (so the GPU
    /// shader reads what the model holds).
    #[test]
    fn uniform_packings_carry_the_fields() {
        let s = SunSky::noon();
        let l = s.light_uniform();
        assert_eq!([l[0], l[1], l[2]], s.dir);
        assert_eq!(l[3], s.ambient);
        assert_eq!(s.sun_uniform()[3], s.sun_intensity);
        assert_eq!([s.sky_uniform()[0], s.sky_uniform()[1], s.sky_uniform()[2]], s.sky_color);
    }

    /// **INJECT-ASSERT (skybox ray reconstruction):** `invert(M) · M = I` for the
    /// shadow light matrix — the property the sky shader relies on to turn a screen
    /// NDC back into a world-space view ray. A bug in the cofactor inverse would
    /// fail the identity here (FAIL-ON-BUG), before it ever silently bent the sky
    /// rays on the GPU.
    #[test]
    fn invert_round_trips_to_identity() {
        let m = SunSky::noon().light_view_proj([0.1, 0.4, -0.2], 1.35);
        let inv = invert(&m).expect("non-singular view-projection inverts");
        // Compose M·inv (column-major): prod[col*4+row] = Σ_k m[k*4+row]·inv[col*4+k].
        let mut prod = [0.0f32; 16];
        for col in 0..4 {
            for row in 0..4 {
                let mut s = 0.0;
                for k in 0..4 {
                    s += m[k * 4 + row] * inv[col * 4 + k];
                }
                prod[col * 4 + row] = s;
            }
        }
        for col in 0..4 {
            for row in 0..4 {
                let want = if col == row { 1.0 } else { 0.0 };
                let got = prod[col * 4 + row];
                assert!((got - want).abs() < 1e-4, "M·inv[{row},{col}] = {got}, want {want}");
            }
        }
    }

    /// **INJECT-ASSERT (the sky ray points the right way):** reconstructing a view
    /// ray from the NDC centre of an inverted light view-projection yields a ray
    /// pointing roughly ALONG the light's forward axis (sun→scene), i.e. opposite
    /// the sun direction. This is the exact `ndc → near/far → dir` math the skybox
    /// fragment shader runs; a transposed/buggy inverse would point it elsewhere.
    #[test]
    fn reconstructed_centre_ray_follows_view_forward() {
        let s = SunSky::noon();
        let m = s.light_view_proj([0.0, 0.0, 0.0], 1.35);
        let inv = invert(&m).unwrap();
        let near = apply_mat4(&inv, [0.0, 0.0, 0.0, 1.0]);
        let far = apply_mat4(&inv, [0.0, 0.0, 1.0, 1.0]);
        let wn = [near[0] / near[3], near[1] / near[3], near[2] / near[3]];
        let wf = [far[0] / far[3], far[1] / far[3], far[2] / far[3]];
        let dir = normalize([wf[0] - wn[0], wf[1] - wn[1], wf[2] - wn[2]]);
        // The light camera looks DOWN the sun ray (forward = -sun_dir). The centre
        // ray must align with that forward, i.e. dir·(-sun) > 0.
        let dot = dir[0] * -s.dir[0] + dir[1] * -s.dir[1] + dir[2] * -s.dir[2];
        assert!(dot > 0.9, "centre ray follows the view forward (dir·-sun = {dot})");
    }
}