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

nordisk/facett

facett-map3d-flight/src/daycycle.rs

//! **Time-of-day sun driver + weather** (FLIGHT-SIM realism, animate the sky) — the
//! day/night cycle that *moves the sun* and recolours the whole scene, the single
//! biggest realism win over a fixed midday `SunSky`.
//!
//! ## What this is
//! A compact, **deterministic** (no clock, no RNG — FC-7), pure-CPU model that turns
//! a `(latitude, day-of-year, hour, weather)` query into a [`SunSky`]:
//!
//! - [`solar_angles`] — real **solar position** (declination + hour angle) so the
//!   sun **rises in the east, climbs to a season-dependent noon height, and sets in
//!   the west**. Higher at midsummer than midwinter, lower toward the poles.
//! - the **direct-sun colour + intensity** is *derived from physics, not hand-tuned*:
//!   it reuses the landed [`Atmosphere`] transmittance LUT, so as the sun drops to the
//!   horizon its light is reddened by the *same* Rayleigh/Mie/ozone extinction that
//!   reddens the sky — **golden hour emerges from the atmosphere** (L5 reuse of
//!   [`crate::atmos`], not a second sunset model).
//! - the **sky tint + ambient** come from [`Atmosphere::sky_colors`]; at night the
//!   sun is below the horizon → the sky radiance crushes toward dark on its own.
//! - **weather** ([`Weather`]) layers an overcast/cloud variant on top: it hides the
//!   sun (diffuse), greys + desaturates the sky, lifts the ambient fill and flattens
//!   the contrast — the honest "flat grey overcast day" look.
//!
//! The output is a `facett_map3d::SunSky`, so it drives the **existing** GPU PBR
//! shader + CPU painter with **zero new passes** (additive, L2): a caller that today
//! hard-codes `SunSky::noon()` swaps in `DayCycle::sun_sky(hour, &atmos, alt)` and the
//! scene gains a full animatable day/night + weather cycle.

use crate::atmos::Atmosphere;
use facett_map3d::SunSky;

/// Sun position as **elevation** (angle above the horizon; negative below) and
/// **azimuth** (clockwise from north: east = +90°, south = 180°, west = 270°).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SolarAngles {
    /// Angle above the local horizon, radians (negative when the sun is down).
    pub elevation_rad: f32,
    /// Compass bearing to the sun, radians clockwise from north.
    pub azimuth_rad: f32,
}

/// **Solar position** for a geographic `latitude_deg`, `day_of_year` (0–365, the
/// season via the declination) and local **solar `hour`** (0–24, noon = the sun's
/// culmination). Standard declination (Cooper) + hour-angle astronomy; pure +
/// deterministic. Elevation is the headline: it peaks at `hour = 12`, is higher at
/// midsummer than midwinter, and lower the closer to the poles.
#[must_use]
pub fn solar_angles(latitude_deg: f32, day_of_year: u32, hour: f32) -> SolarAngles {
    let lat = latitude_deg.to_radians();
    let doy = day_of_year as f32;
    // Declination δ = 23.44° · sin(2π (284 + doy)/365) — the axial-tilt season term.
    let decl = 23.44f32.to_radians() * (std::f32::consts::TAU * (284.0 + doy) / 365.0).sin();
    // Hour angle: 0 at solar noon, +15°/hour in the afternoon (west), −15°/h morning.
    let ha = (15.0f32 * (hour - 12.0)).to_radians();
    let (sin_lat, cos_lat) = lat.sin_cos();
    let (sin_decl, cos_decl) = decl.sin_cos();
    let sin_elev = (sin_lat * sin_decl + cos_lat * cos_decl * ha.cos()).clamp(-1.0, 1.0);
    let elevation = sin_elev.asin();
    let cos_elev = elevation.cos().max(1e-4);
    // Azimuth from north: cos(Az) = (sinδ − sin(elev)·sin(lat)) / (cos(elev)·cos(lat)).
    let cos_az = ((sin_decl - sin_elev * sin_lat) / (cos_elev * cos_lat.abs().max(1e-4))).clamp(-1.0, 1.0);
    let mut az = cos_az.acos(); // 0..π → the east half (morning)
    if ha > 0.0 {
        az = std::f32::consts::TAU - az; // afternoon → mirror onto the west half
    }
    SolarAngles { elevation_rad: elevation, azimuth_rad: az }
}

/// The unit world-space **surface→sun** direction for the given angles, in the
/// `SunSky` frame (+X east, +Y up, +Z north). Unlike [`SunSky::from_angles`] this
/// does **not** clamp the elevation to the horizon, so a night sun stays below it
/// (its light is then zeroed by [`daylight_curve`], not by faking a sunrise).
#[must_use]
pub fn sun_dir(angles: SolarAngles) -> [f32; 3] {
    let (se, ce) = angles.elevation_rad.sin_cos();
    let (sa, ca) = angles.azimuth_rad.sin_cos();
    normalize([ce * sa, se, ce * ca])
}

/// **Golden-hour weight** for a sun `elevation_rad`: a smooth triangular window that
/// peaks (=1) with the sun ~2° up and falls to 0 outside roughly `[-4°, +8°]` — the
/// low-sun band of long shadows + warm light. 0 at high noon and deep night.
#[must_use]
pub fn golden_hour_factor(elevation_rad: f32) -> f32 {
    let e = elevation_rad.to_degrees();
    if !(-4.0..=8.0).contains(&e) {
        return 0.0;
    }
    (1.0 - ((e - 2.0) / 6.0).abs()).max(0.0)
}

/// The **daylight intensity curve** `0..1` over the sun elevation: 0 at/below −6°
/// (astronomical/civil night), climbing through twilight and rising toward 1 as the
/// sun approaches the zenith (an air-mass-ish `sin(elev)` peak so noon is clearly the
/// brightest). Monotonically non-decreasing in elevation.
#[must_use]
pub fn daylight_curve(elevation_rad: f32) -> f32 {
    let e_deg = elevation_rad.to_degrees();
    // Twilight gate: 0 at/below −6°, 1 at/above +6°, smooth between (covers golden hour).
    let gate = smoothstep(-6.0, 6.0, e_deg);
    // Noon peak: sin(elev) lifts the high-sun end so midday dwarfs a low sun.
    let high = elevation_rad.sin().max(0.0);
    (0.14 * gate + 0.86 * gate * high).clamp(0.0, 1.0)
}

/// Weather / cloud state layered over the clear-sky physical model. `cloudiness`
/// runs `0` (clear) → `1` (fully overcast); it is a continuous lever so the overcast
/// blend is monotone + testable, not a hard two-state switch.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Weather {
    /// 0 = clear sky, 1 = full overcast deck (diffuse grey).
    pub cloudiness: f32,
}

impl Default for Weather {
    fn default() -> Self {
        Self::clear()
    }
}

impl Weather {
    /// A clear sky (no cloud).
    #[must_use]
    pub fn clear() -> Self {
        Self { cloudiness: 0.0 }
    }
    /// A fully overcast sky (flat grey diffuse light).
    #[must_use]
    pub fn overcast() -> Self {
        Self { cloudiness: 1.0 }
    }
    /// An arbitrary cloud fraction, clamped to `0..1`.
    #[must_use]
    pub fn cloudy(cloudiness: f32) -> Self {
        Self { cloudiness: cloudiness.clamp(0.0, 1.0) }
    }
}

/// The animated **day cycle**: a geographic latitude + season + weather that turns
/// any `hour` into a physically-derived [`SunSky`]. Pure + `Copy`; construct once and
/// evaluate per frame with the current time-of-day.
#[derive(Clone, Copy, Debug)]
pub struct DayCycle {
    /// Observer latitude, degrees (governs the sun's arc height).
    pub latitude_deg: f32,
    /// Day of year 0–365 (governs the seasonal solar declination).
    pub day_of_year: u32,
    /// Cloud / weather state layered over the clear-sky model.
    pub weather: Weather,
    /// Tonemap exposure fed to [`Atmosphere::sky_colors`] for the sky tint.
    pub exposure: f32,
}

impl Default for DayCycle {
    fn default() -> Self {
        // Sweden, midsummer — the demo's home region.
        Self { latitude_deg: 59.5, day_of_year: 172, weather: Weather::clear(), exposure: 12.0 }
    }
}

/// Base solar spectrum at the top of the atmosphere (faintly warm white), before the
/// atmosphere reddens it. Normalised to a peak of 1 after extinction.
const BASE_SUN: [f32; 3] = [1.0, 0.985, 0.955];
/// Direct-sun intensity multiplier at the zenith (matches `SunSky::noon`'s ballpark).
const NOON_INTENSITY: f32 = 2.7;

impl DayCycle {
    /// The sun's [`SolarAngles`] at `hour`.
    #[must_use]
    pub fn solar_angles(&self, hour: f32) -> SolarAngles {
        solar_angles(self.latitude_deg, self.day_of_year, hour)
    }

    /// The unit surface→sun direction at `hour`.
    #[must_use]
    pub fn sun_dir(&self, hour: f32) -> [f32; 3] {
        sun_dir(self.solar_angles(hour))
    }

    /// The golden-hour weight at `hour` (see [`golden_hour_factor`]).
    #[must_use]
    pub fn golden_hour(&self, hour: f32) -> f32 {
        golden_hour_factor(self.solar_angles(hour).elevation_rad)
    }

    /// **The headline: a full [`SunSky`] for the current time-of-day + weather**,
    /// evaluated against the `atmos` scattering model at `altitude_m`. Returns the
    /// `SunSky` (sun direction/colour/intensity + sky tint + ambient) *and* the
    /// horizon tint (for the fog base / `sky.wgsl` horizon uniform). Drop-in for a
    /// hard-coded `SunSky::noon()`.
    #[must_use]
    pub fn sun_sky(&self, hour: f32, atmos: &Atmosphere, altitude_m: f32) -> (SunSky, [f32; 3]) {
        let angles = self.solar_angles(hour);
        let dir = sun_dir(angles);
        let mu_sun = dir[1].clamp(-1.0, 1.0); // = sin(elevation)

        // Direct sunlight, physically reddened by the SAME transmittance LUT that
        // reddens the sky (L5): sun colour = base ⊙ transmittance-to-sun, hue-
        // normalised so the warm cast survives and brightness lives in the intensity.
        let alt_km = (altitude_m / 1000.0).max(0.0);
        let trans = atmos.transmittance.sample(alt_km, mu_sun.max(0.0));
        let lit = [BASE_SUN[0] * trans[0], BASE_SUN[1] * trans[1], BASE_SUN[2] * trans[2]];
        let peak = lit[0].max(lit[1]).max(lit[2]).max(1e-4);
        let mut sun_color = [lit[0] / peak, lit[1] / peak, lit[2] / peak];
        let mut sun_intensity = NOON_INTENSITY * daylight_curve(angles.elevation_rad);

        // Sky tint from the atmosphere. Skylight is scattered *sunlight*, so as the sun
        // sinks below the horizon the visible sky fades: full above the horizon, through
        // a civil-twilight glow, down to a faint star/moonlight floor at astronomical
        // night. This `sky_scale` is also what keeps the model honest at deep night —
        // the landed `atmos` transmittance LUT has a below-horizon knife-edge (a near-
        // straight-down sun reads as high transmittance), which alone would make deep
        // night read *brighter* than day; gating on the sun elevation here fixes that
        // WITHOUT touching the landed atmosphere model + its oracle (L2, additive).
        let (mut zenith, mut horizon) = atmos.sky_colors(dir, altitude_m, self.exposure);
        let elev_deg = angles.elevation_rad.to_degrees();
        let sky_scale = 0.02f32.max(smoothstep(-8.0, 2.0, elev_deg));
        for k in 0..3 {
            zenith[k] *= sky_scale;
            horizon[k] *= sky_scale;
        }
        let sky_lum = luminance(zenith);
        let mut ambient = (0.10 + 0.55 * sky_lum).clamp(0.04, 0.7);

        // Weather: overcast hides the sun (diffuse), greys + desaturates the sky,
        // lifts the ambient fill, flattens the look.
        let c = self.weather.cloudiness.clamp(0.0, 1.0);
        if c > 0.0 {
            sun_intensity *= 1.0 - 0.85 * c;
            let sky_grey = luminance(zenith) * 1.05;
            for k in 0..3 {
                zenith[k] = lerp(zenith[k], sky_grey, 0.7 * c);
                horizon[k] = lerp(horizon[k], luminance(horizon), 0.7 * c);
            }
            let sun_grey = luminance(sun_color);
            for s in &mut sun_color {
                *s = lerp(*s, sun_grey, 0.6 * c);
            }
            ambient = lerp(ambient, (0.5 + 0.2 * sky_lum).min(0.75), c);
        }

        let sky = SunSky { dir, sun_color, sun_intensity, sky_color: zenith, ambient };

        // EMIT-DOCTRINE (L4): the sky state is derived data — record the sun angle,
        // golden-hour weight, intensity and sky chroma as a functional-status row so a
        // matrix run proves WHICH sky shipped. Gated behind `testmatrix` (release no-op).
        #[cfg(feature = "testmatrix")]
        {
            let gh = golden_hour_factor(angles.elevation_rad);
            let ok = sun_intensity.is_finite()
                && sky.sky_color.iter().all(|v| v.is_finite())
                && sun_color.iter().all(|v| v.is_finite());
            let detail = format!(
                "daycycle hour={hour:.2} elev_deg={:.2} az_deg={:.2} sun_intensity={sun_intensity:.3} golden={gh:.3} cloudiness={c:.2} sky_chroma={:.4}",
                angles.elevation_rad.to_degrees(),
                angles.azimuth_rad.to_degrees(),
                chroma(sky.sky_color),
            );
            facett_core::testmatrix::emit("facett-map3d-flight", "daycycle_sun_sky", ok, &detail);
        }

        (sky, horizon)
    }
}

#[inline]
fn smoothstep(edge0: f32, edge1: f32, x: f32) -> f32 {
    let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
    t * t * (3.0 - 2.0 * t)
}
#[inline]
fn lerp(a: f32, b: f32, t: f32) -> f32 {
    a + (b - a) * t
}
#[inline]
fn luminance(c: [f32; 3]) -> f32 {
    0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
}
/// Saturation proxy: max−min channel spread (0 = fully grey). Used by the
/// `testmatrix` emit + the tests (dead in a plain release build).
#[cfg_attr(not(any(test, feature = "testmatrix")), allow(dead_code))]
#[inline]
fn chroma(c: [f32; 3]) -> f32 {
    let mx = c[0].max(c[1]).max(c[2]);
    let mn = c[0].min(c[1]).min(c[2]);
    mx - mn
}
#[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]
}

#[cfg(test)]
mod tests {
    use super::*;

    const SWEDEN_LAT: f32 = 59.5;
    const MIDSUMMER: u32 = 172;
    const MIDWINTER: u32 = 355;

    /// **INJECT-ASSERT (the sun ARC — rises east, culminates at noon, sets west):**
    /// the single biggest realism lever, proven as data. RED if the hour-angle or the
    /// east/west azimuth mapping regresses.
    #[test]
    fn sun_rises_east_culminates_at_noon_sets_west() {
        let dc = DayCycle { latitude_deg: SWEDEN_LAT, day_of_year: MIDSUMMER, ..Default::default() };
        // Morning sun is in the EAST (+X), evening sun in the WEST (−X).
        let morning = dc.sun_dir(8.0);
        let evening = dc.sun_dir(16.0);
        assert!(morning[0] > 0.05, "8:00 sun is east (+X), got {morning:?}");
        assert!(evening[0] < -0.05, "16:00 sun is west (−X), got {evening:?}");
        // Elevation peaks at solar noon (hour angle 0) and is symmetric around it.
        let elev = |h: f32| dc.solar_angles(h).elevation_rad;
        assert!(elev(12.0) > elev(9.0), "noon higher than 9:00");
        assert!(elev(12.0) > elev(15.0), "noon higher than 15:00");
        assert!((elev(10.0) - elev(14.0)).abs() < 1e-4, "arc symmetric about noon");
        // The sun is actually up at midsummer noon this far north.
        assert!(elev(12.0).to_degrees() > 40.0, "midsummer noon sun is high, got {}", elev(12.0).to_degrees());
    }

    /// **INJECT-ASSERT (season — summer noon sun is HIGHER than winter):** the
    /// declination term works, so the same place has a low winter sun (long shadows,
    /// weak light) and a high summer sun.
    #[test]
    fn summer_noon_is_higher_than_winter_noon() {
        let summer = solar_angles(SWEDEN_LAT, MIDSUMMER, 12.0).elevation_rad;
        let winter = solar_angles(SWEDEN_LAT, MIDWINTER, 12.0).elevation_rad;
        assert!(summer > winter + 0.3, "summer {summer} ≫ winter {winter} noon elevation");
        // And a lower latitude sees a higher sun than a polar one at the same noon.
        let tropic = solar_angles(15.0, MIDSUMMER, 12.0).elevation_rad;
        assert!(tropic > summer, "the tropics see a higher noon sun than Sweden");
    }

    /// **INJECT-ASSERT (GOLDEN HOUR from physics — reddens AND dims):** as the sun
    /// drops from high to near the horizon, the *direct* sunlight reddens (R:B rises)
    /// and dims — the warm low-sun look, and it comes from the reused transmittance
    /// LUT, not a hand-painted gradient. Elevation sweep so it's a curve, not a point.
    #[test]
    fn golden_hour_sun_reddens_and_dims_toward_the_horizon() {
        let atmos = Atmosphere::earth();
        let dc = DayCycle { latitude_deg: SWEDEN_LAT, day_of_year: MIDSUMMER, ..Default::default() };
        // Compare a high sun (near noon) to a low sun (approaching sunset, still up).
        let (high, _) = dc.sun_sky(12.0, &atmos, 0.0);
        let (low, _) = dc.sun_sky(20.5, &atmos, 0.0);
        // The low sun must actually be low but above the horizon here.
        let low_elev = dc.solar_angles(20.5).elevation_rad.to_degrees();
        assert!((0.0..12.0).contains(&low_elev), "20:30 midsummer sun is low but up ({low_elev}°)");
        let rb = |c: [f32; 3]| c[0] / c[2].max(1e-4);
        assert!(rb(low.sun_color) > rb(high.sun_color) * 1.15, "low sun is redder: R/B {} > {}", rb(low.sun_color), rb(high.sun_color));
        assert!(low.sun_intensity < high.sun_intensity, "low sun is dimmer: {} < {}", low.sun_intensity, high.sun_intensity);
        // The golden-hour weight is high at the low sun and ~0 at noon.
        assert!(dc.golden_hour(20.5) > 0.4, "20:30 is golden hour ({})", dc.golden_hour(20.5));
        assert!(dc.golden_hour(12.0) < 1e-3, "noon is not golden hour");
    }

    /// **INJECT-ASSERT (NIGHT is dark):** at local midnight the sun is below the
    /// horizon → the direct light goes to ~0 and the sky is far darker than midday.
    /// RED if a night scene stays lit like noon.
    #[test]
    fn night_is_dark() {
        let atmos = Atmosphere::earth();
        let dc = DayCycle { latitude_deg: 40.0, day_of_year: MIDWINTER, ..Default::default() };
        let (night, _) = dc.sun_sky(0.0, &atmos, 0.0);
        let (noon, _) = dc.sun_sky(12.0, &atmos, 0.0);
        assert!(dc.solar_angles(0.0).elevation_rad < 0.0, "midnight sun is below the horizon");
        assert!(night.sun_intensity < 0.05, "no direct sun at night, got {}", night.sun_intensity);
        assert!(luminance(night.sky_color) < luminance(noon.sky_color) * 0.5, "night sky far darker than noon");
        assert!(noon.sun_intensity > 1.0, "midday still has a strong sun");
    }

    /// **INJECT-ASSERT (OVERCAST flattens + greys):** the same noon under full
    /// overcast has a dimmer sun, a stronger ambient fill, and a greyer (less
    /// chromatic) sky than clear — the flat overcast look, and it's monotone in
    /// cloudiness. RED if the weather lever does nothing.
    #[test]
    fn overcast_flattens_desaturates_and_lifts_ambient() {
        let atmos = Atmosphere::earth();
        let clear = DayCycle { latitude_deg: SWEDEN_LAT, day_of_year: MIDSUMMER, weather: Weather::clear(), ..Default::default() };
        let cloudy = DayCycle { weather: Weather::overcast(), ..clear };
        let (c, _) = clear.sun_sky(12.0, &atmos, 0.0);
        let (o, _) = cloudy.sun_sky(12.0, &atmos, 0.0);
        assert!(o.sun_intensity < c.sun_intensity * 0.5, "overcast dims the sun: {} < {}", o.sun_intensity, c.sun_intensity);
        assert!(o.ambient > c.ambient, "overcast lifts the ambient fill: {} > {}", o.ambient, c.ambient);
        assert!(chroma(o.sky_color) < chroma(c.sky_color), "overcast greys the sky: chroma {} < {}", chroma(o.sky_color), chroma(c.sky_color));
        // Monotone: a half-overcast sky sits between clear and full overcast.
        let half = DayCycle { weather: Weather::cloudy(0.5), ..clear };
        let (h, _) = half.sun_sky(12.0, &atmos, 0.0);
        assert!(o.sun_intensity <= h.sun_intensity && h.sun_intensity <= c.sun_intensity, "cloudiness dims monotonically");
    }

    /// INJECT-ASSERT (the golden-hour window is a real window): peaks low, zero at
    /// high noon and deep night, and is a smooth ramp.
    #[test]
    fn golden_hour_factor_is_a_low_sun_window() {
        assert!(golden_hour_factor(2f32.to_radians()) > 0.9, "peaks ~2° up");
        assert_eq!(golden_hour_factor(45f32.to_radians()), 0.0, "0 at a high sun");
        assert_eq!(golden_hour_factor(-30f32.to_radians()), 0.0, "0 deep below the horizon");
        assert!(golden_hour_factor(6f32.to_radians()) < golden_hour_factor(2f32.to_radians()), "falls off above the peak");
    }

    /// **INJECT-ASSERT (drives the shared SunSky uniforms):** the produced `SunSky`
    /// yields finite, well-formed light/sun/sky uniforms — exactly the data the GPU
    /// PBR shader + CPU painter consume — so the day cycle plugs into the existing
    /// render path with no new plumbing.
    #[test]
    fn feeds_the_shared_sun_sky_uniforms() {
        let atmos = Atmosphere::earth();
        let dc = DayCycle::default();
        for hour in [0.0, 4.0, 8.0, 12.0, 16.0, 20.0, 23.0] {
            let (s, horizon) = dc.sun_sky(hour, &atmos, 1500.0);
            for v in s.light_uniform().iter().chain(s.sun_uniform().iter()).chain(s.sky_uniform().iter()).chain(horizon.iter()) {
                assert!(v.is_finite(), "uniform finite at hour {hour}");
            }
            let dlen = (s.dir[0] * s.dir[0] + s.dir[1] * s.dir[1] + s.dir[2] * s.dir[2]).sqrt();
            assert!((dlen - 1.0).abs() < 1e-3, "sun dir normalised at hour {hour}");
            assert!(s.sun_intensity >= 0.0 && s.ambient >= 0.0, "non-negative light at hour {hour}");
        }
    }

    /// INJECT-ASSERT: 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 dc = DayCycle::default();
        let a = dc.sun_sky(17.25, &atmos, 800.0);
        let b = dc.sun_sky(17.25, &atmos, 800.0);
        assert_eq!(a.0.dir, b.0.dir);
        assert_eq!(a.0.sun_color, b.0.sun_color);
        assert_eq!(a.0.sun_intensity, b.0.sun_intensity);
        assert_eq!(a.0.sky_color, b.0.sky_color);
        assert_eq!(a.1, b.1);
    }
}