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