pub mod astro;
mod clock;
pub mod light;
mod moon;
mod stars;
use bevy::camera::Exposure;
use bevy::ecs::system::SystemParam;
use bevy::light::light_consts::lux;
use bevy::light::{CascadeShadowConfig, CascadeShadowConfigBuilder, SunDisk, VolumetricLight};
use bevy::prelude::*;
pub(crate) use clock::plugin as clock_plugin;
pub use clock::{CivilDate, GeoLocation, WorldClock};
use crate::climate::Weather;
use crate::clouds::{CloudShadows, SkyProbe};
use crate::plugin::{
ElementsCamera, ElementsSystems, ElementsUnits, ElementsView, SkyBackground, ensure_gpu_core,
};
#[derive(Resource, Clone, Copy, Debug, Reflect)]
pub struct ElementsExposure {
pub ev100: f32,
pub exposure: f32,
}
impl Default for ElementsExposure {
fn default() -> Self {
let ev100 = bevy::camera::Exposure::EV100_BLENDER;
Self {
ev100,
exposure: bevy::camera::Exposure { ev100 }.exposure(),
}
}
}
#[derive(Component, Clone, Copy, Debug, Default, Reflect)]
pub struct Sun;
#[derive(Component, Clone, Copy, Debug, Default, Reflect)]
pub struct Moon;
#[derive(Resource, Clone, Copy, Debug, Reflect)]
pub struct SunState {
pub direction: Vec3,
pub altitude_deg: f32,
pub azimuth_deg: f32,
pub moon_direction: Vec3,
pub moon_altitude_deg: f32,
pub moon_phase: f32,
pub moon_illuminated_fraction: f32,
}
impl Default for SunState {
fn default() -> Self {
Self {
direction: Vec3::Y,
altitude_deg: 90.0,
azimuth_deg: 180.0,
moon_direction: Vec3::NEG_Y,
moon_altitude_deg: -90.0,
moon_phase: 0.0,
moon_illuminated_fraction: 0.0,
}
}
}
#[derive(Resource, Clone, Copy, Debug)]
pub(crate) struct CelestialFrame {
pub j2000_to_world: Mat3,
pub ecliptic_north: Vec3,
pub moon_angular_diameter_deg: f32,
pub moon_lux: f32,
pub sun_light_lux: f32,
pub moon_light_lux: f32,
}
impl Default for CelestialFrame {
fn default() -> Self {
Self {
j2000_to_world: Mat3::IDENTITY,
ecliptic_north: Vec3::Y,
moon_angular_diameter_deg: 0.5,
moon_lux: 0.0,
sun_light_lux: 0.0,
moon_light_lux: 0.0,
}
}
}
#[derive(Resource, Clone, Copy, Debug, Reflect)]
pub struct CelestialSettings {
pub lights: bool,
pub sky: bool,
}
impl Default for CelestialSettings {
fn default() -> Self {
Self {
lights: true,
sky: true,
}
}
}
pub struct CelestialPlugin;
impl Plugin for CelestialPlugin {
fn build(&self, app: &mut App) {
ensure_gpu_core(app);
app.init_resource::<CelestialSettings>();
stars::plugin(app);
moon::plugin(app);
app.init_resource::<CelestialFrame>()
.init_resource::<ElementsExposure>()
.add_systems(Startup, spawn_lights)
.add_systems(
Update,
(
update_sky,
drive_lights,
update_ambient,
auto_exposure,
publish_exposure,
sky_background,
fit_shadow_cascades,
)
.chain()
.in_set(ElementsSystems::Celestial),
);
}
}
const REFRACTION_PRESSURE_MBAR: f64 = 1010.0;
const REFRACTION_TEMP_C: f64 = 10.0;
const SUN_OFF_DEG: f32 = -18.0;
const SUN_SHADOWS_ABOVE_DEG: f32 = -0.5;
const TWILIGHT_FALL_PER_DEG: f32 = 0.44;
const TWILIGHT_KNEE_DEG: f32 = -6.0;
const TWILIGHT_TAIL_PER_DEG: f32 = 0.983;
const MOON_OFF_DEG: f32 = -0.5;
const MOON_SHADOWS_BELOW_SUN_DEG: f32 = -6.0;
pub(crate) fn twilight_factor(altitude_deg: f32) -> f32 {
if altitude_deg >= 0.0 {
1.0
} else if altitude_deg >= TWILIGHT_KNEE_DEG {
(TWILIGHT_FALL_PER_DEG * altitude_deg).exp()
} else if altitude_deg > SUN_OFF_DEG {
(TWILIGHT_FALL_PER_DEG * TWILIGHT_KNEE_DEG
+ TWILIGHT_TAIL_PER_DEG * (altitude_deg - TWILIGHT_KNEE_DEG))
.exp()
} else {
0.0
}
}
pub(crate) fn sun_disc_visible(altitude_deg: f32) -> f32 {
let radius = SunDisk::EARTH.angular_size.to_degrees() * 0.5;
((altitude_deg + radius) / (2.0 * radius)).clamp(0.0, 1.0)
}
pub(crate) const MOON_TINT: Vec3 = Vec3::new(0.88, 0.92, 1.0);
const SUN_SHADOW_DISTANCE_M: f32 = 600.0;
fn sun_cascades(u: f32) -> CascadeShadowConfig {
CascadeShadowConfigBuilder {
maximum_distance: SUN_SHADOW_DISTANCE_M * u,
first_cascade_far_bound: 15.0 * u,
..default()
}
.build()
}
fn orthographic_cascades(focus_depth_m: f32, u: f32) -> CascadeShadowConfig {
let near = (focus_depth_m - 0.5 * SUN_SHADOW_DISTANCE_M).max(0.0);
CascadeShadowConfigBuilder {
num_cascades: 1,
minimum_distance: near * u,
first_cascade_far_bound: (near + SUN_SHADOW_DISTANCE_M) * u,
maximum_distance: (near + SUN_SHADOW_DISTANCE_M) * u,
..default()
}
.build()
}
fn spawn_lights(
mut commands: Commands,
settings: Res<CelestialSettings>,
units: Res<ElementsUnits>,
) {
if !settings.lights {
return;
}
commands.spawn((
Name::new("Sun"),
Sun,
DirectionalLight {
illuminance: lux::RAW_SUNLIGHT,
shadow_maps_enabled: true,
..default()
},
sun_cascades(units.per_metre()),
VolumetricLight,
Transform::default(),
));
commands.spawn((
Name::new("Moon"),
Moon,
DirectionalLight {
illuminance: 0.0,
shadow_maps_enabled: false,
color: Color::srgb(MOON_TINT.x, MOON_TINT.y, MOON_TINT.z),
..default()
},
SunDisk::OFF,
Transform::default(),
));
}
type CelestialCascades<'w, 's> =
Query<'w, 's, (&'static mut CascadeShadowConfig, Has<Sun>), Or<(With<Sun>, With<Moon>)>>;
fn fit_shadow_cascades(
settings: Res<CelestialSettings>,
view: Res<ElementsView>,
units: Res<ElementsUnits>,
cameras: Query<(&Projection, &GlobalTransform)>,
mut lights: CelestialCascades,
) {
if !settings.lights {
return;
}
let orthographic =
view.entity
.and_then(|e| cameras.get(e).ok())
.and_then(|(projection, transform)| match projection {
Projection::Orthographic(_) => Some(orthographic_cascades(
(view.position - units.point_to_metres(transform.translation()))
.dot(*transform.forward()),
units.per_metre(),
)),
_ => None,
});
for (mut config, is_sun) in &mut lights {
let want = orthographic.clone().unwrap_or_else(|| {
if is_sun {
sun_cascades(units.per_metre())
} else {
CascadeShadowConfig::default()
}
});
if config.bounds != want.bounds || config.minimum_distance != want.minimum_distance {
*config = want;
}
}
}
fn enu_to_world(v: [f64; 3]) -> Vec3 {
Vec3::new(v[0] as f32, v[2] as f32, -v[1] as f32)
}
pub(crate) const OVERCAST_DIFFUSE: f32 = 0.25;
pub(crate) fn beam_transmittance(cover: f32) -> f32 {
1.0 - cover.clamp(0.0, 1.0).powf(3.4)
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct CloudLight {
pub direct: f32,
pub diffuse: f32,
}
impl CloudLight {
pub(crate) const CLEAR: Self = Self {
direct: 1.0,
diffuse: 0.0,
};
pub(crate) fn from_beam(beam: f32) -> Self {
let direct = beam.clamp(0.0, 1.0);
Self {
direct,
diffuse: OVERCAST_DIFFUSE * (1.0 - direct),
}
}
pub(crate) fn total(self) -> f32 {
self.direct + self.diffuse
}
}
pub(crate) fn cloud_light(cover: f32, shadows: Option<&CloudShadows>) -> CloudLight {
CloudLight::from_beam(shadows.map_or_else(
|| beam_transmittance(cover),
|shadows| shadows.mean_transmittance,
))
}
const MIN_SKY_BEAM: f32 = 0.001;
pub(crate) fn sun_cloud_factor(state: &SunState, cloud: CloudLight) -> f32 {
let up = sun_disc_visible(state.altitude_deg);
cloud.total() + (cloud.direct - cloud.total()) * up
}
pub(crate) fn sky_gain(state: &SunState, cloud: CloudLight) -> f32 {
if state.altitude_deg > SUN_OFF_DEG {
cloud.total() * sky_clear_gain(state, cloud)
} else {
1.0
}
}
pub(crate) fn sky_clear_gain(state: &SunState, cloud: CloudLight) -> f32 {
if state.altitude_deg > SUN_OFF_DEG {
1.0 / sun_cloud_factor(state, cloud).max(MIN_SKY_BEAM)
} else {
1.0
}
}
fn light_transform(toward: Vec3) -> Transform {
let up = if toward.y.abs() > 0.999 {
Vec3::Z
} else {
Vec3::Y
};
Transform::default().looking_to(-toward, up)
}
pub(crate) fn update_sky(
clock: Res<WorldClock>,
location: Res<GeoLocation>,
weather: Res<Weather>,
shadows: Option<Res<CloudShadows>>,
mut state: ResMut<SunState>,
mut frame: ResMut<CelestialFrame>,
) {
use astro::*;
let jd = clock.julian_day(location.utc_offset_hours);
let year = f64::from(clock.date.year) + (f64::from(clock.date.month) - 0.5) / 12.0;
let t = centuries(jd + delta_t_seconds(year) / 86_400.0);
let nut = nutation(t);
let lat = location.latitude_deg;
let lst = local_sidereal_deg(jd, &nut, location.longitude_deg);
let (sun, sun_lambda) = sun_apparent(t);
let (sun_alt, sun_az) = horizontal(sun.ra, sun.dec, lat, lst);
let sun_app = sun_alt + refraction_deg(sun_alt, REFRACTION_PRESSURE_MBAR, REFRACTION_TEMP_C);
let sun_dir = enu_to_world(enu(sun_app, sun_az)).normalize_or(Vec3::Y);
let (moon, moon_ecl) = moon_apparent(t, &nut);
let (geo_alt, geo_az) = horizontal(moon.ra, moon.dec, lat, lst);
let (moon_alt, moon_az) = topocentric(geo_alt, geo_az, moon.distance_km);
let moon_app = moon_alt + refraction_deg(moon_alt, REFRACTION_PRESSURE_MBAR, REFRACTION_TEMP_C);
let moon_dir = enu_to_world(enu(moon_app, moon_az)).normalize_or(Vec3::NEG_Y);
let i = phase_angle(&sun, &moon);
let lit = illuminated_fraction(i);
let synodic = normalize_deg(moon_ecl.longitude + nut.dpsi - sun_lambda) / 360.0;
let observer_distance = moon.distance_km - EARTH_RADIUS_KM * (moon_alt.to_radians()).sin();
*state = SunState {
direction: sun_dir,
altitude_deg: sun_alt as f32,
azimuth_deg: sun_az as f32,
moon_direction: moon_dir,
moon_altitude_deg: moon_alt as f32,
moon_phase: synodic as f32,
moon_illuminated_fraction: lit as f32,
};
let to_enu = mat_mul(equatorial_to_enu(lat, lst), precession_matrix(t));
let col = |j: usize| enu_to_world([to_enu[0][j], to_enu[1][j], to_enu[2][j]]);
let eps = nut.eps().to_radians();
let pole = [0.0, -eps.sin(), eps.cos()];
let pole_enu = equatorial_to_enu(lat, lst);
let pole_world = enu_to_world([
(0..3).map(|k| pole_enu[0][k] * pole[k]).sum(),
(0..3).map(|k| pole_enu[1][k] * pole[k]).sum(),
(0..3).map(|k| pole_enu[2][k] * pole[k]).sum(),
]);
let moon_lux = (FULL_MOON_LUX * moon_phase_factor(i)) as f32;
let cloud = cloud_light(weather.cloud_cover, shadows.as_deref());
let altitude = sun_alt as f32;
let sun_light_lux =
lux::RAW_SUNLIGHT * twilight_factor(altitude) * sun_cloud_factor(&state, cloud);
let moon_light_lux = if state.moon_altitude_deg > MOON_OFF_DEG {
moon_lux * cloud.direct
} else {
0.0
};
*frame = CelestialFrame {
j2000_to_world: Mat3::from_cols(col(0), col(1), col(2)),
ecliptic_north: pole_world.normalize_or(Vec3::Y),
moon_angular_diameter_deg: moon_angular_diameter(observer_distance) as f32,
moon_lux,
sun_light_lux,
moon_light_lux,
};
}
fn drive_lights(
settings: Res<CelestialSettings>,
state: Res<SunState>,
frame: Res<CelestialFrame>,
mut lights: Query<(&mut Transform, &mut DirectionalLight, Has<Sun>, Has<Moon>)>,
) {
if !settings.lights {
return;
}
for (mut transform, mut light, is_sun, is_moon) in &mut lights {
if is_sun {
*transform = light_transform(state.direction);
light.illuminance = frame.sun_light_lux;
set_shadows(&mut light, state.altitude_deg > SUN_SHADOWS_ABOVE_DEG);
} else if is_moon {
*transform = light_transform(state.moon_direction);
light.illuminance = frame.moon_light_lux;
set_shadows(
&mut light,
state.moon_altitude_deg > MOON_OFF_DEG
&& state.altitude_deg < MOON_SHADOWS_BELOW_SUN_DEG,
);
}
}
}
fn set_shadows(light: &mut Mut<DirectionalLight>, on: bool) {
if light.shadow_maps_enabled != on {
light.shadow_maps_enabled = on;
}
}
const EXPOSURE_TAU_S: f32 = 1.5;
const ILLUMINANCE_FLOOR_LUX: f32 = 0.0005;
fn clear_sky_illuminance(state: &SunState, moon_lux: f32) -> (f32, f32) {
let factor = |true_altitude: f32| {
let h = f64::from(true_altitude);
let apparent = h + astro::refraction_deg(h, REFRACTION_PRESSURE_MBAR, REFRACTION_TEMP_C);
astro::horizontal_illuminance_factor(apparent) as f32
};
(
lux::RAW_SUNLIGHT * factor(state.altitude_deg),
moon_lux * factor(state.moon_altitude_deg),
)
}
pub(crate) fn scene_illuminance(state: &SunState, moon_lux: f32, cloud: CloudLight) -> f32 {
let (sun, moon) = clear_sky_illuminance(state, moon_lux);
(sun + moon) * cloud.total() + ILLUMINANCE_FLOOR_LUX
}
const GLOW_DISPLAY_MAX: f32 = 3.2;
#[allow(clippy::approx_constant)]
const GLOW_TABLE: [(f32, f32); 17] = [
(-13.91, 4.648e-5),
(-12.04, 1.079e-3),
(-10.70, 1.057e-2),
(-9.29, 1.054e-1),
(-8.19, 5.721e-1),
(-7.05, 3.063),
(-6.28, 9.431),
(-5.49, 2.301e1),
(-3.87, 9.334e1),
(-2.20, 3.878e2),
(-0.49, 1.981e3),
(2.18, 4.476e3),
(6.81, 8.167e3),
(11.65, 1.069e4),
(27.02, 1.432e4),
(65.40, 1.524e4),
(90.0, 1.524e4),
];
pub(crate) fn horizon_glow(altitude_deg: f32) -> f32 {
let first = GLOW_TABLE[0];
if altitude_deg <= first.0 {
return first.1;
}
for pair in GLOW_TABLE.windows(2) {
let (a, b) = (pair[0], pair[1]);
if altitude_deg <= b.0 {
let t = (altitude_deg - a.0) / (b.0 - a.0);
return (a.1.ln() + (b.1.ln() - a.1.ln()) * t).exp();
}
}
GLOW_TABLE[GLOW_TABLE.len() - 1].1
}
pub(crate) fn ev100_for_glow(altitude_deg: f32, cover: f32) -> f32 {
let glow = horizon_glow(altitude_deg) * beam_transmittance(cover);
if glow <= 0.0 {
return f32::NEG_INFINITY;
}
(glow / (1.2 * GLOW_DISPLAY_MAX)).log2()
}
pub(crate) fn dusk_tint(sun_altitude_deg: f32) -> Vec3 {
let dusk = if sun_altitude_deg >= 0.0 {
(1.0 - sun_altitude_deg / DUSK_ONSET_DEG).clamp(0.0, 1.0)
} else {
(1.0 + sun_altitude_deg / CIVIL_TWILIGHT_DEG).clamp(0.0, 1.0)
};
Vec3::ONE.lerp(DUSK_TINT, dusk)
}
pub(crate) const DUSK_TINT: Vec3 = Vec3::new(1.0, 0.55, 0.3);
const DUSK_ONSET_DEG: f32 = 10.0;
const CIVIL_TWILIGHT_DEG: f32 = 6.0;
pub(crate) fn cloud_ambient(state: &SunState, moon_lux: f32, cloud: CloudLight) -> (Vec3, f32) {
let (sun, moon) = clear_sky_illuminance(state, moon_lux);
let sun = if state.altitude_deg > SUN_OFF_DEG {
sun * cloud.diffuse
} else {
0.0
};
let moon = if state.moon_altitude_deg > MOON_OFF_DEG {
moon * cloud.diffuse
} else {
0.0
};
let lux = sun + moon;
let night = NIGHT_SKY_LUMINANCE;
let total = lux + night * std::f32::consts::PI;
if total <= 0.0 {
return (Vec3::ONE, 0.0);
}
let body = if lux > 0.0 {
(dusk_tint(state.altitude_deg) * sun + MOON_TINT * moon) / lux
} else {
Vec3::ONE
};
let mixed = (body * lux + NIGHT_SKY_COLOUR * night * std::f32::consts::PI) / total;
let luminance = mixed.dot(LUMINANCE_WEIGHTS);
(mixed / luminance, total / std::f32::consts::PI)
}
pub(crate) const NIGHT_SKY_COLOUR: Vec3 = Vec3::new(0.72, 0.95, 1.28);
const LUMINANCE_WEIGHTS: Vec3 = Vec3::new(0.2126, 0.7152, 0.0722);
pub(crate) const BEVY_AMBIENT_DIFFUSE_SCALE: f32 = 0.452;
fn update_ambient(
weather: Res<Weather>,
state: Res<SunState>,
frame: Res<CelestialFrame>,
shadows: Option<Res<CloudShadows>>,
mut cameras: Query<(&ElementsCamera, &mut AmbientLight, Option<&SkyProbe>)>,
) {
let cloud = cloud_light(weather.cloud_cover, shadows.as_deref());
let (colour, radiance) = cloud_ambient(&state, frame.moon_lux, cloud);
let colour = Color::linear_rgb(colour.x, colour.y, colour.z);
for (dressing, mut ambient, probe) in &mut cameras {
if !dressing.ambient {
continue;
}
let brightness = if probe.is_some_and(|p| p.lit) {
0.0
} else {
radiance / BEVY_AMBIENT_DIFFUSE_SCALE
};
if ambient.brightness != brightness || ambient.color != colour {
ambient.brightness = brightness;
ambient.color = colour;
}
}
}
const DISPLAY_MAX_NITS: f32 = 100.0;
const ADAPTING_REFLECTANCE: f32 = 0.18;
pub(crate) fn log10_threshold(la: f32) -> f32 {
let x = la.max(1e-12).log10();
if x < -3.94 {
-2.86
} else if x < -1.44 {
(0.405 * x + 1.6).powf(2.18) - 2.86
} else if x < -0.0184 {
x - 0.395
} else if x < 1.9 {
(0.249 * x + 0.65).powf(2.7) - 0.72
} else {
x - 1.255
}
}
pub(crate) fn ev100_for(lux: f32) -> f32 {
-(1.2 * exposure_for_adaptation(ADAPTING_REFLECTANCE * lux / std::f32::consts::PI)).log2()
}
pub(crate) fn exposure_for_adaptation(la: f32) -> f32 {
let display = DISPLAY_MAX_NITS * 0.5;
10f32.powf(log10_threshold(display) - log10_threshold(la)) / DISPLAY_MAX_NITS
}
#[derive(SystemParam)]
pub(crate) struct ExposureSky<'w> {
state: Res<'w, SunState>,
frame: Res<'w, CelestialFrame>,
weather: Res<'w, Weather>,
shadows: Option<Res<'w, CloudShadows>>,
}
fn auto_exposure(
time: Res<Time<Real>>,
sky: ExposureSky,
mut published: ResMut<ElementsExposure>,
mut cameras: Query<(Entity, Option<&ElementsCamera>, &mut Exposure)>,
mut adapted: Local<Vec<Entity>>,
) {
let cloud = cloud_light(sky.weather.cloud_cover, sky.shadows.as_deref());
let target = ev100_for(scene_illuminance(&sky.state, sky.frame.moon_lux, cloud)).max(
ev100_for_glow(sky.state.altitude_deg, sky.weather.cloud_cover),
);
let k = 1.0 - (-time.delta_secs() / EXPOSURE_TAU_S).exp();
for (entity, camera, mut exposure) in &mut cameras {
let Some(camera) = camera else {
continue;
};
if !camera.auto_exposure {
continue;
}
if adapted.contains(&entity) {
exposure.ev100 += (target - exposure.ev100) * k;
} else {
exposure.ev100 = target;
adapted.push(entity);
}
}
published.ev100 = target;
published.exposure = bevy::camera::Exposure { ev100: target }.exposure();
}
fn publish_exposure(
view: Res<ElementsView>,
mut published: ResMut<ElementsExposure>,
cameras: Query<(Entity, &Exposure)>,
) {
let mut followed: Option<f32> = None;
let mut any: Option<f32> = None;
for (entity, exposure) in &cameras {
let ev = exposure.ev100;
if any.is_none() {
any = Some(ev);
}
if Some(entity) == view.entity {
followed = Some(ev);
}
}
let Some(ev100) = followed.or(any) else {
return;
};
published.ev100 = ev100;
published.exposure = bevy::camera::Exposure { ev100 }.exposure();
}
pub(crate) const NIGHT_SKY_LUMINANCE: f32 = ILLUMINANCE_FLOOR_LUX / std::f32::consts::PI;
pub(crate) const ZENITH_TRANSMITTANCE: Vec3 = Vec3::new(0.904, 0.640, 0.353);
fn sky_background(mut cameras: Query<(&mut Camera, Option<&Exposure>), With<SkyBackground>>) {
for (mut camera, exposure) in &mut cameras {
let v = NIGHT_SKY_LUMINANCE * exposure.copied().unwrap_or_default().exposure();
let colour = Color::linear_rgb(
v * NIGHT_SKY_COLOUR.x / ZENITH_TRANSMITTANCE.x,
v * NIGHT_SKY_COLOUR.y / ZENITH_TRANSMITTANCE.y,
v * NIGHT_SKY_COLOUR.z / ZENITH_TRANSMITTANCE.z,
);
if !matches!(camera.clear_color, ClearColorConfig::Custom(c) if c == colour) {
camera.clear_color = ClearColorConfig::Custom(colour);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::ecs::system::RunSystemOnce;
#[test]
fn the_shadow_cascades_reach_the_same_metres_in_a_world_of_feet() {
let feet = 3.2808_f32;
let (m, f) = (sun_cascades(1.0), sun_cascades(feet));
assert_eq!(m.bounds.len(), f.bounds.len());
for (a, b) in m.bounds.iter().zip(&f.bounds) {
assert!((a * feet - b).abs() <= 1e-3 * b, "{a} {b}");
}
assert!(
(m.bounds.last().copied().unwrap_or_default() - SUN_SHADOW_DISTANCE_M).abs() < 1e-2
);
let (m, f) = (
orthographic_cascades(1500.0, 1.0),
orthographic_cascades(1500.0, feet),
);
assert_eq!(m.bounds.len(), 1);
assert!((m.minimum_distance * feet - f.minimum_distance).abs() < 1e-2);
assert!((m.bounds[0] * feet - f.bounds[0]).abs() < 1e-2);
assert_eq!(m.minimum_distance, 1500.0 - 0.5 * SUN_SHADOW_DISTANCE_M);
}
#[test]
fn exposure_targets_match_the_research_note() {
let zenith_sun = SunState {
altitude_deg: 90.0,
moon_altitude_deg: -10.0,
..default()
};
assert!(
(ev100_for(scene_illuminance(&zenith_sun, 0.0, CloudLight::CLEAR)) - 13.3360).abs()
< 1e-3
);
let full_moon = SunState {
altitude_deg: -30.0,
moon_altitude_deg: 90.0,
..default()
};
let ev = ev100_for(scene_illuminance(
&full_moon,
astro::FULL_MOON_LUX as f32,
CloudLight::CLEAR,
));
assert!((ev - -2.2353).abs() < 1e-3, "{ev}");
let dark = SunState {
altitude_deg: -30.0,
moon_altitude_deg: -30.0,
..default()
};
assert!(
(ev100_for(scene_illuminance(&dark, 0.0, CloudLight::CLEAR)) - -4.7465).abs() < 1e-3
);
let sunrise = SunState {
altitude_deg: 0.0,
moon_altitude_deg: -30.0,
..default()
};
assert!(
(ev100_for(scene_illuminance(&sunrise, 0.0, CloudLight::CLEAR)) - 6.4780).abs() < 1e-3
);
let civil = SunState {
altitude_deg: -6.0,
moon_altitude_deg: -30.0,
..default()
};
assert!(
(ev100_for(scene_illuminance(&civil, 0.0, CloudLight::CLEAR)) - 0.8567).abs() < 2e-3
);
}
#[test]
fn overcast_passes_a_quarter_of_the_sun_all_of_it_diffuse() {
let clear = cloud_light(0.0, None);
assert_eq!(clear, CloudLight::CLEAR);
assert_eq!(clear.total(), 1.0);
let overcast = cloud_light(1.0, None);
assert_eq!(overcast.direct, 0.0);
assert!((overcast.diffuse - 0.25).abs() < 1e-6);
assert!((overcast.total() - 0.25).abs() < 1e-6);
for c in [0.05f32, 0.3, 0.5, 0.8, 0.95] {
let kc = 1.0 - 0.75 * c.powf(3.4);
assert!(
(cloud_light(c, None).total() - kc).abs() < 1e-6,
"cover {c}"
);
}
}
#[test]
fn the_total_follows_the_beam_linearly_and_the_diffuse_share_is_what_the_beam_lost() {
for t in [0.0f32, 0.1, 0.5, 0.9, 1.0] {
let light = CloudLight::from_beam(t);
assert_eq!(light.direct, t);
assert!((light.total() - (0.25 + 0.75 * t)).abs() < 1e-6);
assert!((light.diffuse - 0.25 * (1.0 - t)).abs() < 1e-6);
}
assert_eq!(CloudLight::from_beam(-1.0), CloudLight::from_beam(0.0));
assert_eq!(CloudLight::from_beam(2.0), CloudLight::CLEAR);
}
fn shadows(mean_transmittance: f32) -> CloudShadows {
CloudShadows {
map: Handle::default(),
origin: Vec2::ZERO,
extent: 8000.0,
light_direction: Vec3::Y,
mean_transmittance,
}
}
fn run_sky(
hour: f64,
cover: f32,
shadows: Option<CloudShadows>,
) -> (SunState, CelestialFrame, f32, f32) {
let mut world = World::new();
world.insert_resource(WorldClock {
date: CivilDate::new(2026, 6, 21),
hour,
..default()
});
world.insert_resource(GeoLocation::default());
world.insert_resource(Weather {
cloud_cover: cover,
..default()
});
world.insert_resource(SunState::default());
world.insert_resource(CelestialFrame::default());
world.insert_resource(CelestialSettings::default());
if let Some(shadows) = shadows {
world.insert_resource(shadows);
}
let sun = world
.spawn((Sun, DirectionalLight::default(), Transform::default()))
.id();
let moon = world
.spawn((Moon, DirectionalLight::default(), Transform::default()))
.id();
assert!(world.run_system_once(update_sky).is_ok());
assert!(world.run_system_once(drive_lights).is_ok());
let state = *world.resource::<SunState>();
let frame = *world.resource::<CelestialFrame>();
let lux_of = |entity| {
world
.get::<DirectionalLight>(entity)
.map_or(f32::NAN, |light| light.illuminance)
};
let (sun_lux, moon_lux) = (lux_of(sun), lux_of(moon));
(state, frame, sun_lux, moon_lux)
}
fn close(a: f32, b: f32) -> bool {
(a - b).abs() <= 1e-3 * b.abs().max(1e-9)
}
#[test]
fn cloud_shadow_transmittance_dims_the_sun_and_moon() {
let cover = 0.5;
let moon_hour = (0..24)
.map(f64::from)
.find(|&h| run_sky(h, cover, None).0.moon_altitude_deg > MOON_OFF_DEG);
assert!(
moon_hour.is_some(),
"the moon is up at some hour of the day"
);
let moon_hour = moon_hour.unwrap_or(0.0);
for (t, direct, total) in [(1.0, 1.0, 1.0), (0.5, 0.5, 0.625), (0.0, 0.0, 0.25)] {
let (state, _, sun_lux, _) = run_sky(12.0, cover, Some(shadows(t)));
assert!(state.altitude_deg > SUN_OFF_DEG);
assert!(
close(sun_lux, lux::RAW_SUNLIGHT * direct),
"sun T={t}: {sun_lux}"
);
let (state, frame, _, moon_lux) = run_sky(moon_hour, cover, Some(shadows(t)));
assert!(state.moon_altitude_deg > MOON_OFF_DEG);
assert!(frame.moon_lux > 0.0);
assert!(
close(moon_lux, frame.moon_lux * direct),
"moon T={t}: {moon_lux}"
);
let dimmed = scene_illuminance(
&state,
frame.moon_lux,
cloud_light(cover, Some(&shadows(t))),
);
let open = scene_illuminance(&state, frame.moon_lux, CloudLight::CLEAR)
- ILLUMINANCE_FLOOR_LUX;
assert!(close(dimmed, open * total + ILLUMINANCE_FLOOR_LUX));
}
let (_, _, sun_lux, _) = run_sky(12.0, cover, None);
assert!(close(
sun_lux,
lux::RAW_SUNLIGHT * beam_transmittance(cover)
));
let (_, frame, _, moon_lux) = run_sky(moon_hour, cover, None);
assert!(close(moon_lux, frame.moon_lux * beam_transmittance(cover)));
for c in [0.0f32, 0.3, 0.7, 1.0] {
let s = shadows(beam_transmittance(c));
assert_eq!(cloud_light(c, Some(&s)), cloud_light(c, None));
}
}
#[test]
fn the_ambient_carries_the_diffuse_share_and_the_night_sky_at_night() {
let noon = SunState {
altitude_deg: 90.0,
moon_altitude_deg: -30.0,
..default()
};
let clear_noon = scene_illuminance(&noon, 0.0, CloudLight::CLEAR) - ILLUMINANCE_FLOOR_LUX;
let (_, brightness) = cloud_ambient(&noon, 0.0, CloudLight::CLEAR);
assert!(
brightness < NIGHT_SKY_LUMINANCE * 1.001,
"the day carries the night sky's glow: {brightness}"
);
let storm = CloudLight::from_beam(0.0);
let (colour, brightness) = cloud_ambient(&noon, 0.0, storm);
assert!(close(brightness * std::f32::consts::PI, 0.25 * clear_noon));
assert!((colour.dot(LUMINANCE_WEIGHTS) - 1.0).abs() < 1e-5);
let direct = lux::RAW_SUNLIGHT * storm.direct;
assert_eq!(direct, 0.0);
let total = scene_illuminance(&noon, 0.0, storm);
assert!(close(total, 0.25 * clear_noon + ILLUMINANCE_FLOOR_LUX));
let half = CloudLight::from_beam(0.5);
let (_, brightness) = cloud_ambient(&noon, 0.0, half);
assert!(close(brightness * std::f32::consts::PI, 0.125 * clear_noon));
let dark = SunState {
altitude_deg: -30.0,
moon_altitude_deg: -30.0,
..default()
};
for t in [0.0, 0.5, 1.0] {
let (colour, brightness) = cloud_ambient(&dark, 0.0, CloudLight::from_beam(t));
assert!(
close(brightness, NIGHT_SKY_LUMINANCE),
"the night sky's radiance, T = {t}: {brightness}"
);
assert!(
colour.z > colour.y && colour.y > colour.x,
"the night is blue: {colour}"
);
}
let (colour, clear) = cloud_ambient(&dark, 0.0, CloudLight::from_beam(0.0));
let (_, overcast) = cloud_ambient(&dark, 0.0, CloudLight::from_beam(1.0));
assert!(close(clear, overcast), "the cloud dimmed the night sky");
assert!(colour.z > colour.x, "the night is blue: {colour}");
let moonlit = SunState {
altitude_deg: -30.0,
moon_altitude_deg: 60.0,
..default()
};
let (colour, brightness) = cloud_ambient(&moonlit, 0.3, storm);
assert!(brightness > 0.0);
assert!(colour.z > colour.x, "moonlight is blue: {colour}");
let sunrise = SunState {
altitude_deg: 0.0,
moon_altitude_deg: -30.0,
..default()
};
let (colour, _) = cloud_ambient(&sunrise, 0.0, storm);
assert!(colour.x > colour.y && colour.y > colour.z, "{colour}");
}
#[test]
fn the_night_sky_behind_the_atmosphere_is_blue() {
let clear = NIGHT_SKY_COLOUR / ZENITH_TRANSMITTANCE;
let through = clear * ZENITH_TRANSMITTANCE;
assert!(
through.z > through.x,
"the sky behind the atmosphere is not blue: {through:?}"
);
for c in 0..3 {
assert!(
(through[c] - NIGHT_SKY_COLOUR[c]).abs() < 1e-4,
"the round trip is not the scotopic colour: {through:?}"
);
}
assert!(
clear.z / clear.x > NIGHT_SKY_COLOUR.z / NIGHT_SKY_COLOUR.x,
"the clear colour is not blue enough: {clear:?}"
);
let warm = ZENITH_TRANSMITTANCE.x - ZENITH_TRANSMITTANCE.z;
assert!(
warm > 0.0,
"the transmittance is not warm: {ZENITH_TRANSMITTANCE:?}"
);
}
#[test]
fn the_ambient_system_drives_every_elements_camera() {
let mut world = World::new();
world.insert_resource(Weather {
cloud_cover: 1.0,
..default()
});
world.insert_resource(SunState {
altitude_deg: 90.0,
moon_altitude_deg: -30.0,
..default()
});
world.insert_resource(CelestialFrame::default());
world.insert_resource(shadows(0.0));
let camera = world
.spawn((
ElementsCamera::default(),
AmbientLight {
brightness: 0.0,
..default()
},
))
.id();
let other = world
.spawn(AmbientLight {
brightness: 0.0,
..default()
})
.id();
assert!(world.run_system_once(update_ambient).is_ok());
let brightness_of = |entity| {
world
.get::<AmbientLight>(entity)
.map_or(f32::NAN, |ambient| ambient.brightness)
};
assert!(brightness_of(camera) > 1000.0, "{}", brightness_of(camera));
assert_eq!(brightness_of(other), 0.0);
}
#[test]
fn the_sky_gain_restores_the_dome_to_the_total_factor_by_day_only() {
let noon = SunState {
altitude_deg: 60.0,
moon_altitude_deg: -30.0,
..default()
};
assert_eq!(sky_gain(&noon, CloudLight::CLEAR), 1.0);
for t in [0.01f32, 0.3, 0.5, 0.9] {
let light = CloudLight::from_beam(t);
assert!(
(t * sky_gain(&noon, light) - light.total()).abs() < 1e-6,
"{t}"
);
}
assert_eq!(
sky_gain(&noon, CloudLight::from_beam(0.0)),
0.25 / MIN_SKY_BEAM
);
let night = SunState {
altitude_deg: -30.0,
moon_altitude_deg: 60.0,
..default()
};
assert_eq!(sky_gain(&night, CloudLight::from_beam(0.2)), 1.0);
}
#[test]
fn the_sky_gain_never_amplifies_the_sky_once_the_sun_has_set() {
let at = |altitude_deg: f32| SunState {
altitude_deg,
..default()
};
for t in [0.0f32, 0.01, 0.5, 1.0] {
let light = CloudLight::from_beam(t);
assert!((sun_cloud_factor(&at(5.0), light) - t).abs() < 1e-6, "{t}");
for altitude in [-0.3, -3.0, -10.0, -17.0, -30.0] {
assert_eq!(sun_cloud_factor(&at(altitude), light), light.total());
assert!(
(sky_gain(&at(altitude), light) - 1.0).abs() < 1e-6,
"{altitude} {t}"
);
}
}
}
#[test]
fn the_sun_lights_the_sky_through_twilight_and_the_ground_only_while_its_disc_is_up() {
assert_eq!(twilight_factor(0.0), 1.0);
assert!((twilight_factor(-0.01) - 1.0).abs() < 0.01);
let mut last = 1.0;
for tenth in 1..180 {
let g = twilight_factor(-0.1 * tenth as f32);
assert!(g < last && g > 0.0, "{tenth}: {g}");
last = g;
}
assert_eq!(twilight_factor(-18.0), 0.0);
assert!(4.1 * twilight_factor(-17.99) < 0.02 * NIGHT_SKY_LUMINANCE);
assert_eq!(sun_disc_visible(0.3), 1.0);
assert_eq!(sun_disc_visible(-0.3), 0.0);
assert!((sun_disc_visible(0.0) - 0.5).abs() < 1e-6);
}
#[test]
fn the_glow_caps_the_exposure_only_for_a_low_sun() {
let target = |altitude_deg: f32, cover: f32| {
let state = SunState {
altitude_deg,
moon_altitude_deg: -30.0,
..default()
};
let cloud = CloudLight::from_beam(beam_transmittance(cover));
let illuminance = ev100_for(scene_illuminance(&state, 0.0, cloud));
(illuminance, ev100_for_glow(altitude_deg, cover))
};
for altitude in [27.0, 45.0, 68.0, 90.0] {
let (lux, glow) = target(altitude, 0.05);
assert!(glow <= lux + 0.1, "{altitude}°: {glow} over {lux}");
}
for altitude in [8.0, 2.0, -0.5, -2.0, -4.0] {
let (lux, glow) = target(altitude, 0.05);
assert!(glow > lux, "{altitude}°: {glow} under {lux}");
}
let (lux, glow) = target(-0.5, 0.05);
assert!(glow - lux > 1.0, "{glow} against {lux}");
let (lux, glow) = target(-0.5, 1.0);
assert!(glow < lux, "{glow} against {lux}");
}
}