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