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})");
}
}