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