facett-map3d/src/fog.rs
//! **The atmospheric fog ramp — ONE writer for both render lanes.**
//!
//! GFX_V2 §V2.9b named this under "still open": *"`FOG_MAX 0.55` exists in THREE
//! copies — `backend.rs:670`, `lib.rs:3145`, `lib.rs:3563` — and fog density …
//! hardcoded."* The count was right and the diagnosis was one level too shallow:
//! what was triplicated is not a constant, it is the whole **ramp contract** —
//!
//! ```text
//! near = distance · 0.6 (floored at 0.1)
//! far = distance · 2.2 + 0.5
//! max = 0.55
//! ```
//!
//! — written out longhand at three sites, two of which feed the CPU painter and one
//! of which feeds the GPU uniform. That is the shape this suite keeps getting burned
//! by: a numeric contract the two lanes agree on **by coincidence**, where a tune on
//! one site silently makes the 4090 and the software painter draw different haze and
//! no test anywhere can see it, because each lane is self-consistent.
//!
//! So this is LAW 5 by construction rather than by guard: the three sites now route
//! through [`FogRamp::from_camera_distance`], and the GPU's `[near, far, max, _]`
//! uniform is produced by [`FogRamp::as_uniform`] — the same struct the CPU painter
//! evaluates [`FogRamp::t`] from.
//!
//! ## ⚠ What this shares, and what it deliberately does NOT
//!
//! The two lanes now share the **band** — where haze starts, where it saturates, and
//! how strong it is allowed to get. They do **not** share the **ramp shape**, and
//! saying otherwise would be the false claim this file exists to prevent:
//!
//! * CPU painter ([`FogRamp::t`]) — **linear** across the band.
//! * GPU (`map3d.wgsl` `fs_main`) — **exponential extinction** through a
//! height-graded atmosphere, `1 − exp(−dist · density · ground_air)` with
//! `density = 2.5 / span`, from the 2026-08-04 aerial-perspective reform. The CPU
//! painter never got that reform.
//!
//! Both read `near`/`far`/`max` from here, so a tune moves both together; the curve
//! between those points is a KNOWN, open divergence, not something this module hides.
//! `tests/fog_ramp.rs` asserts the slot contract against the literal WGSL text, and
//! records the shape difference rather than pretending it away.
//!
//! Pure — no `wgpu`, no `egui`, no camera type. Testable on any box.
/// **Maximum haze**, as a fraction mixed toward the fog colour at `far` and beyond.
///
/// Capped below `1.0` on purpose: at `1.0` a distant building does not recede, it
/// **disappears** into the background, and the skyline silhouette — the depth cue the
/// fog exists to give — goes with it. `0.55` keeps the far city readable as shape.
pub const FOG_MAX: f32 = 0.55;
/// Fog band start, as a multiple of the camera's orbit distance. Below this depth
/// there is no haze at all, so the foreground stays crisp.
const NEAR_K: f32 = 0.6;
/// Fog band end, as a multiple of the camera's orbit distance (plus a constant so a
/// camera parked on the target still has a band with width).
const FAR_K: f32 = 2.2;
const FAR_BIAS: f32 = 0.5;
/// The smallest `near` the ramp will report. A camera at distance 0 would otherwise
/// put `near` at 0 and fog the pixel under the cursor.
const NEAR_FLOOR: f32 = 0.1;
/// **The distance-fog ramp for one frame**, in camera-space depth units.
///
/// Constructed once per frame from the camera and then read by whichever lane draws:
/// the CPU painter calls [`t`](Self::t) per triangle, the GPU lane ships
/// [`as_uniform`](Self::as_uniform) to `map3d.wgsl`, whose `fs_main` derives its own
/// (exponential) `fog_t` from the same three numbers. Same band, different curve —
/// see the module doc.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FogRamp {
/// Camera-space depth at which haze begins (haze = 0 at and before this).
pub near: f32,
/// Camera-space depth at which haze reaches [`max`](Self::max).
pub far: f32,
/// Haze fraction at `far` and beyond — [`FOG_MAX`], carried as a field so a
/// caller can dim it without reaching for the constant.
pub max: f32,
}
impl FogRamp {
/// The ramp for a camera orbiting at `distance` design units.
///
/// THE one writer of the near/far coefficients. Every other site in the crate
/// asks this rather than restating `distance * 0.6`.
#[must_use]
pub fn from_camera_distance(distance: f32) -> Self {
Self {
near: (distance * NEAR_K).max(NEAR_FLOOR),
far: distance * FAR_K + FAR_BIAS,
max: FOG_MAX,
}
}
/// Band width, floored so [`t`](Self::t) can never divide by zero.
///
/// The floor is not defensive decoration: `near` is clamped by `NEAR_FLOOR` and
/// `far` is not, so a camera closer than `NEAR_FLOOR / NEAR_K` genuinely inverts
/// the band. The floor makes that degenerate case a hard edge (no haze, then full
/// haze) instead of a NaN that paints the whole city the fog colour.
#[must_use]
pub fn span(&self) -> f32 {
(self.far - self.near).max(1e-3)
}
/// **Haze fraction at `depth`** — `0.0` in the foreground, [`max`](Self::max)
/// at `far` and beyond.
///
/// **The CPU painter's linear ramp**, `clamp((depth − near) / span, 0, 1) · max`.
/// The GPU lane derives an exponential `fog_t` from the same band — see the module
/// doc for why they differ and what is nonetheless guaranteed shared.
#[must_use]
pub fn t(&self, depth: f32) -> f32 {
((depth - self.near) / self.span()).clamp(0.0, 1.0) * self.max
}
/// The GPU uniform, in the layout `map3d.wgsl` declares: `fog_range.x = near`,
/// `.y = far`, and the max strength travelling in `fog.w`.
///
/// Returned as the 4-slot array the callback field already has, so the call site
/// is a substitution and not a signature change.
#[must_use]
pub fn as_uniform(&self) -> [f32; 4] {
[self.near, self.far, self.max, 0.0]
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The ramp's shape, at the three points that have meaning.
#[test]
fn the_ramp_is_zero_in_front_capped_behind_and_monotone_between() {
let r = FogRamp::from_camera_distance(10.0);
assert_eq!(r.near, 6.0, "near = distance · 0.6");
assert_eq!(r.far, 22.5, "far = distance · 2.2 + 0.5");
assert_eq!(r.max, FOG_MAX);
assert_eq!(r.t(0.0), 0.0, "the foreground is unfogged");
assert_eq!(r.t(r.near), 0.0, "…up to and including `near`");
assert!((r.t(r.far) - FOG_MAX).abs() < 1e-6, "`far` reaches the cap");
assert!((r.t(r.far * 100.0) - FOG_MAX).abs() < 1e-6, "and never exceeds it");
assert!(r.t(r.far) < 1.0, "the cap is BELOW 1.0 — distant shapes recede, they do not vanish");
// Monotone, which is what makes it a depth cue rather than a pattern.
let mut prev = -1.0;
for i in 0..=40 {
let d = i as f32 * (r.far / 20.0);
let t = r.t(d);
assert!(t >= prev, "haze never decreases with depth (at {d}: {t} < {prev})");
prev = t;
}
}
/// A camera pressed onto the target must not produce NaN haze.
#[test]
fn a_degenerate_camera_distance_yields_a_finite_ramp() {
for d in [0.0_f32, 1e-6, 0.01, 0.16] {
let r = FogRamp::from_camera_distance(d);
assert!(r.near >= NEAR_FLOOR, "near is floored at {d} ({})", r.near);
assert!(r.span() > 0.0, "span is positive at {d} ({})", r.span());
for probe in [0.0_f32, 0.05, 1.0, 1e6] {
assert!(r.t(probe).is_finite(), "t({probe}) is finite at distance {d}");
}
}
}
/// The uniform is the same three numbers, in the slots the shader reads.
#[test]
fn the_uniform_carries_the_ramp_the_cpu_lane_evaluates() {
let r = FogRamp::from_camera_distance(3.75);
let u = r.as_uniform();
assert_eq!(u[0], r.near);
assert_eq!(u[1], r.far);
assert_eq!(u[2], r.max);
}
}