diff --git a/STATUS.md b/STATUS.md index a2a45666..b4dc092d 100644 --- a/STATUS.md +++ b/STATUS.md @@ -2314,13 +2314,19 @@ Every simplification is marked with a `ponytail:` comment at the code site, with draws the same sky over the same module** — its Time-of-day panel sets date, clock, time zone and cloud cover, and a slider runs a whole day past; latitude and longitude are the module's anchor, exactly as in a run, so the sun comes over - the same hillside in both programs. Two ceilings the sky carries: the camera's - exposure is fixed at Bevy's default, so the sun's illuminance and the stars' - luminance are lifted or lowered into it by constants rather than by an EV curve - (`world_render::sky` names them and says what the correct version is), and a sun - below the horizon still lights vertical faces from underneath, because Bevy hands - the atmosphere the directional light's own colour and anything taken off the - light would take the twilight sky with it. **The seasonal appearance hangs off + the same hillside in both programs. **Every light carries its real figure** — the + sun its 130 klx, the moon its quarter lux, the headlights 200 kcd, the cab lamp + 800 lm, the stars and the moon's disk their luminance — and the camera's exposure + does the rest: `world_render::sky::exposure` is a reflected-light meter over a + level view with the eye's limits on top, about EV 14 under a high sun, the tables' + sunset and twilight, a floor of −3 under the moon, and it closes again by four + stops when the headlights or the cab lamp are lit (`Sky::artificial`), adapting + over seconds. Emissive surfaces stay display-referred on purpose — a driver has + to read a screen and a signal at any exposure. One ceiling the sky still carries: + Bevy's atmosphere has no earth shadow, so a set sun still lights the air and the + faces under it; the light is dimmed a decade every seven degrees below the + horizon (`EARTH_SHADOW_SLOPE`), which brings the twilight down to the tables' but + is a factor on the light where the real thing is a shadow on the air. **The seasonal appearance hangs off the same date** (`world_render::Season`, ch. 14 "seasons v2"): the generated ground textures and the placeholder trees are built in the colours of the start day — meadows turn through October, ground, gravel and foliage go under snow from November to March, diff --git a/crates/app/src/loading.rs b/crates/app/src/loading.rs index 9bc64f30..e7551729 100644 --- a/crates/app/src/loading.rs +++ b/crates/app/src/loading.rs @@ -801,10 +801,11 @@ pub(crate) fn load_sky( // itself is bloom, which the settings can switch off. bevy::camera::Hdr, // The sky lights the scene itself; what stays here is the floor a - // moonless night needs to keep the ground off pure black (`feed_sky`). + // moonless night needs to keep the ground off pure black. `feed_sky` + // writes the brightness every frame. AmbientLight { color: Color::srgb(0.7, 0.8, 1.0), - brightness: 20.0, + brightness: 0.0, ..default() }, world_render::sky::camera_settings(), diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 3a8254d2..c5b0e708 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -110,6 +110,30 @@ const PRECIP_PERIOD: f32 = 24.0; /// the haze of a clear day, and a second one on top of it would be a grey veil. pub(crate) const CLEAR_VISIBILITY: f32 = 8_000.0; +/// Luminous intensity of a headlight on full beam \[cd\]: a locomotive's +/// Fernlicht, 200 kcd, which puts 20 lx on the track 100 m out. Bevy takes a +/// spot light in lumens over the whole sphere, hence the 4π at the light. +const HEADLIGHT_CANDELA: f32 = 200_000.0; + +/// Luminous flux of the cab lamp \[lm\]: one ceiling light over the desk. +const CAB_LAMP_LUMENS: f32 = 800.0; + +/// What the headlights put on the track where the eye rests, about 80 m out +/// \[lx\], and what the cab lamp puts on the desk \[lx\]: what the camera's +/// exposure is metered on while they are lit (`sky::Sky::artificial`). +const HEADLIGHT_METER_LX: f32 = 30.0; +const CAB_LAMP_METER_LX: f32 = 30.0; + +/// What a moonless night sky puts on the ground \[cd/m²\]: airglow, starlight +/// and the glow of the next town, a few hundredths of a candela — dark, not +/// blind. By day the sky's own image-based light does this and the floor is +/// nothing against it. +const NIGHT_SKY_GLOW: f32 = 0.02; + +/// The sky lit by a lightning channel \[cd/m²\], put on the ground as ambient +/// light for the third of a second the strike lasts. +const FLASH_SKY: f32 = 4_000.0; + /// Which train is driven by the player. #[derive(Resource)] pub struct PlayerTrain(pub usize); @@ -408,7 +432,13 @@ fn main() { )) .insert_resource(OffscreenSize(size)) .add_systems(PreStartup, open_offscreen) - .add_systems(PreUpdate, retarget_offscreen); + // After every spawn of the frame and before Bevy sizes the cameras + // against their targets: a camera the loading stages spawn in `Update` + // would otherwise reach the renderer still pointing at the window. + .add_systems( + PostUpdate, + retarget_offscreen.before(bevy::camera::CameraUpdateSystems), + ); } app.add_plugins(app_icon::plugin) // FSR 3 upscaling (`fsr.rs`): its render-side systems and pipelines, next to @@ -1391,10 +1421,13 @@ fn sync_vehicles( /// /// What stays here is the two things the sky does not own: the ambient floor a /// moonless night needs, and the distance fog the weather pulls in (M6). +// A Bevy system takes its resources as parameters — the argument count says nothing here. +#[allow(clippy::too_many_arguments)] fn feed_sky( sim: Res, origin: Res, daylight: Res, + player: Res, mut sky: ResMut, mut ambient: Query<&mut AmbientLight, With>, mut fog: Query<&mut DistanceFog, With>, @@ -1403,6 +1436,8 @@ fn feed_sky( let _scope = profiler.scope("sky"); let (latitude, longitude, _) = world_coords::geo::from_ecef(origin.0.position()); let start = sim.0.start; + let night = 1.0 - daylight.0; + let cab = &sim.0.controls[player.0]; *sky = sky::Sky { year: start.year, month: start.month, @@ -1422,6 +1457,10 @@ fn feed_sky( .weather .lightning(sim.0.time) .map_or(0.0, |strike| strike.brightness(sim.0.time)), + // The lamps the exposure is metered on. The headlights follow the + // darkness the way `update_headlights` dims them; the cab lamp is a switch. + artificial: f32::from(u8::from(cab.headlights)) * night * HEADLIGHT_METER_LX + + f32::from(u8::from(cab.cab_light)) * CAB_LAMP_METER_LX, }; // Fog and heavy snow: the scattering medium reddens and brightens them @@ -1440,13 +1479,12 @@ fn feed_sky( fog.color = Color::srgb(0.66 * lit, 0.69 * lit, 0.74 * lit); } - let night = 1.0 - daylight.0; for mut ambient in &mut ambient { // The sky's own image-based light carries the day; this is the floor // underneath it, so a night without a moon is dark and not blind. A // lightning flash comes on top of it, and at night it is the only light // there is. - ambient.brightness = 8.0 + 24.0 * night + 4_000.0 * sky.flash; + ambient.brightness = NIGHT_SKY_GLOW * night + FLASH_SKY * sky.flash; } } @@ -1467,9 +1505,13 @@ fn update_headlights( let cab = &sim.0.controls[head.train]; let backwards = cab.reverser < 0; let on = cab.headlights && head.reverse == backwards; - // ponytail: like the moon above, lit artistically bright — the night - // scene has no auto-exposure to lift a physical beam out of the black. - light.intensity = if on { 2_000_000_000.0 * night } else { 0.0 }; + // The real figure: the exposure (`sky::exposure`) closes for the lit + // track, so a real beam lights a real distance. + light.intensity = if on { + HEADLIGHT_CANDELA * 4.0 * std::f32::consts::PI * night + } else { + 0.0 + }; } for (tail, mut vis) in &mut tails { let cab = &sim.0.controls[tail.train]; @@ -1482,7 +1524,7 @@ fn update_headlights( } let on = sim.0.controls[player.0].cab_light; for mut lamp in &mut cab_lamp { - lamp.intensity = if on { 60_000.0 } else { 0.0 }; + lamp.intensity = if on { CAB_LAMP_LUMENS } else { 0.0 }; } } @@ -1575,6 +1617,7 @@ fn update_precipitation( player: Res, origin: Res, daylight: Res, + sky: Res, view: Res, camera: Query<&Transform, With>, sun: Query<&Transform, (With, Without)>, @@ -1626,8 +1669,13 @@ fn update_precipitation( // around the camera and stays where it belongs, outside the glass. let inside = view.mode.inside(); for (field, material, mut tf, mut visibility) in &mut fields { - let mut params = - world_render::precipitation::params(weather, daylight.0, field.snow, field.near); + let mut params = world_render::precipitation::params( + weather, + daylight.0, + sky.artificial, + field.snow, + field.near, + ); params.state.x *= 1.0 - sheltered; if inside && field.near { params.state.x = 0.0; diff --git a/crates/route-editor/src/main.rs b/crates/route-editor/src/main.rs index afc69209..4f64d445 100644 --- a/crates/route-editor/src/main.rs +++ b/crates/route-editor/src/main.rs @@ -713,11 +713,13 @@ fn setup( ..default() }), Transform::default(), - // The sky lights the module itself; this is the floor that keeps the - // relief readable when the time panel is set to the middle of the night. + // The sky lights the module itself; this is a moonless night sky's glow + // on the ground, the floor that keeps the relief readable when the time + // panel is set to the middle of the night and the exposure has opened + // for it (`world_render::sky::exposure`). AmbientLight { color: Color::srgb(0.75, 0.82, 1.0), - brightness: 60.0, + brightness: 0.05, ..default() }, sky::camera_settings(), diff --git a/crates/world-render/src/clouds.rs b/crates/world-render/src/clouds.rs index dd271176..471f7a37 100644 --- a/crates/world-render/src/clouds.rs +++ b/crates/world-render/src/clouds.rs @@ -152,12 +152,13 @@ const WIND_ALOFT: f32 = 2.5; /// for two frames. const SKY_TINT: Vec3 = Vec3::new(0.50, 0.62, 0.85); -/// What the sky's own light on the ground is against the sun's illuminance, and -/// the least a cloud is ever lit to against the same, as fractions of -/// [`SUN_ILLUMINANCE`](crate::sky::SUN_ILLUMINANCE). The floor is what keeps a -/// night cloud a shape against the stars instead of a hole in them. -const SKY_SHARE: f32 = 0.10; -const NIGHT_FLOOR: f32 = 0.005; +/// What the sky's own light on the ground is against the sun's illuminance, as a +/// fraction of [`SUN_ILLUMINANCE`](crate::sky::SUN_ILLUMINANCE). +pub(crate) const SKY_SHARE: f32 = 0.10; +/// The least a cloud is ever lit to \[cd/m²\]: an overcast night sky over open +/// country, a hundredth of a candela — what keeps a night cloud a shape against +/// the stars instead of a hole in them once the exposure has opened for the dark. +const NIGHT_FLOOR: f32 = 0.01; /// Albedo of what lies under the deck: fields and woods, the same when wet but /// darker, and snow. A cloud base over a snowfield is lit nearly as brightly @@ -738,7 +739,7 @@ fn update( .extend(weather.cover), // Never quite black, or a night cloud would be a hole in the stars. The // lightning is *not* in here — it is on the dome, where every frame runs. - floor: (SKY_TINT * crate::sky::SUN_ILLUMINANCE * NIGHT_FLOOR).extend(0.0), + floor: (SKY_TINT * NIGHT_FLOOR).extend(0.0), layer: Vec4::new( weather.base, // A closed deck is a thick one; a fair-weather cumulus is not. @@ -848,7 +849,7 @@ fn sequence(turn: u32) -> Vec3 { /// The colour of the sunlight that reaches the cloud layer, from the sun's /// elevation alone — Kasten & Young's air mass through the same zenith optical /// depth the star shader uses, so the two agree on what a low sun looks like. -fn sunlight(sin_elevation: f32) -> Vec3 { +pub(crate) fn sunlight(sin_elevation: f32) -> Vec3 { const ZENITH_OPTICAL_DEPTH: Vec3 = Vec3::new(0.081, 0.150, 0.315); let degrees = sin_elevation.clamp(-1.0, 1.0).asin().to_degrees(); let air_mass = @@ -877,7 +878,7 @@ fn ground_light(sin_elevation: f32, daylight: f32, cover: f32, wetness: f32, sno albedo * (sun + sky) * under_deck } -fn smoothstep(edge0: f32, edge1: f32, x: f32) -> f32 { +pub(crate) 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) } diff --git a/crates/world-render/src/precipitation.rs b/crates/world-render/src/precipitation.rs index 9951eac9..f6f9bf04 100644 --- a/crates/world-render/src/precipitation.rs +++ b/crates/world-render/src/precipitation.rs @@ -70,8 +70,13 @@ impl Material for PrecipitationMaterial { } } +/// Luminance of the night sky a drop carries when nothing else lights it +/// \[cd/m²\] — the same overcast night as the clouds' floor. +const NIGHT_SKY: f32 = 0.01; + /// What the weather makes of a field of drops: how many of them are alive, how -/// long they are drawn and how brightly. +/// long they are drawn and how brightly. `artificial` is the vehicle's own +/// light on the ground \[lx\] (`Sky::artificial`). /// /// `near` is the layer of a few big out-of-focus drops close to the lens — the /// one that actually sells rain, which is why it is thinned to a tenth and drawn @@ -79,6 +84,7 @@ impl Material for PrecipitationMaterial { pub fn params( weather: Weather, daylight: f32, + artificial: f32, snow_field: bool, near: bool, ) -> PrecipitationParams { @@ -116,11 +122,13 @@ pub fn params( // shimmer and the same streak against a dark cutting is a line. // A streak is a lens that averages the whole sky behind it — its mean // luminance sits *below* the bright horizon, which is why real rain is a - // grey shimmer and never a white line. + // grey shimmer and never a white line. At night the sky is a hundredth + // of a candela and the streaks are what the headlights catch + // (`Sky::artificial`, lux on the track, over π as a white thing in it). light: (Vec3::new(0.66, 0.69, 0.75) - * crate::sky::SUN_ILLUMINANCE - * 0.16 - * (0.10 + 0.90 * daylight)) + * (crate::sky::SUN_ILLUMINANCE * 0.16 * daylight + + NIGHT_SKY + + artificial / std::f32::consts::PI)) .extend(1.6), sun: Vec4::new(0.0, 1.0, 0.0, daylight), } @@ -134,31 +142,51 @@ mod tests { #[test] fn each_field_draws_only_what_matches_it() { let rain = Preset::Rain.weather(); - assert!(params(rain, 1.0, false, false).state.x > 0.0, "rain falls"); - assert_eq!(params(rain, 1.0, true, false).state.x, 0.0, "not as snow"); + assert!( + params(rain, 1.0, 0.0, false, false).state.x > 0.0, + "rain falls" + ); + assert_eq!( + params(rain, 1.0, 0.0, true, false).state.x, + 0.0, + "not as snow" + ); let snow = Preset::Snow.weather(); - assert!(params(snow, 1.0, true, false).state.x > 0.0, "snow falls"); - assert_eq!(params(snow, 1.0, false, false).state.x, 0.0, "not as rain"); + assert!( + params(snow, 1.0, 0.0, true, false).state.x > 0.0, + "snow falls" + ); + assert_eq!( + params(snow, 1.0, 0.0, false, false).state.x, + 0.0, + "not as rain" + ); let clear = Preset::Clear.weather(); - assert_eq!(params(clear, 1.0, false, false).state.x, 0.0); - assert_eq!(params(clear, 1.0, true, false).state.x, 0.0); + assert_eq!(params(clear, 1.0, 0.0, false, false).state.x, 0.0); + assert_eq!(params(clear, 1.0, 0.0, true, false).state.x, 0.0); } #[test] fn a_drizzle_is_thinner_than_a_downpour() { - let drizzle = params(Preset::Drizzle.weather(), 1.0, false, false).state.x; - let rain = params(Preset::Rain.weather(), 1.0, false, false).state.x; - let storm = params(Preset::Storm.weather(), 1.0, false, false).state.x; + let drizzle = params(Preset::Drizzle.weather(), 1.0, 0.0, false, false) + .state + .x; + let rain = params(Preset::Rain.weather(), 1.0, 0.0, false, false) + .state + .x; + let storm = params(Preset::Storm.weather(), 1.0, 0.0, false, false) + .state + .x; assert!(drizzle < rain && rain <= storm, "{drizzle} {rain} {storm}"); assert_eq!(storm, 1.0, "a downpour fills the field"); } #[test] fn the_near_layer_is_a_few_big_drops() { - let far = params(Preset::Rain.weather(), 1.0, false, false); - let near = params(Preset::Rain.weather(), 1.0, false, true); + let far = params(Preset::Rain.weather(), 1.0, 0.0, false, false); + let near = params(Preset::Rain.weather(), 1.0, 0.0, false, true); assert!(near.state.x < far.state.x * 0.2, "far fewer of them"); assert!(near.state.z < far.state.z, "and dimmer"); } diff --git a/crates/world-render/src/sky.rs b/crates/world-render/src/sky.rs index 2cc74787..8392adfb 100644 --- a/crates/world-render/src/sky.rs +++ b/crates/world-render/src/sky.rs @@ -39,7 +39,7 @@ use crate::Daylight; use bevy::asset::{RenderAssetUsages, embedded_asset}; -use bevy::camera::Camera3d; +use bevy::camera::{Camera3d, Exposure}; use bevy::light::atmosphere::{Falloff, PhaseFunction, ScatteringMedium, ScatteringTerm}; use bevy::light::{Atmosphere, AtmosphereEnvironmentMapLight, SunDisk, light_consts::lux}; use bevy::mesh::{Indices, PrimitiveTopology}; @@ -48,7 +48,7 @@ use bevy::prelude::*; use bevy::render::render_resource::{AsBindGroup, ShaderType}; use bevy::shader::ShaderRef; use sim_core::weather::Weather; -use std::f32::consts::TAU; +use std::f32::consts::{PI, TAU}; use world_coords::sun; /// Registers the sky's materials and its update. Part of @@ -97,6 +97,12 @@ pub struct Sky { /// A lightning channel lighting the sky, 1 … 0 /// (`sim_core::weather::Strike::brightness`). pub flash: f32, + /// Illuminance of the vehicle's own lights on what the view rests on \[lx\]: + /// the headlights on the track ahead, the cab lamp on the desk. The camera's + /// exposure follows it the way an eye follows a lit road, so the beam is + /// neither a white wall on a moonless night nor a candle under a full moon. + /// The simulator writes it; the editor has no lamps. + pub artificial: f32, /// The weather (plan 14.1). The sky reads three things out of it: the cover, /// which dims the sun and puts out the stars; the visibility, which becomes a /// scattering term of the atmosphere itself; and the depth of a ground fog @@ -121,6 +127,7 @@ impl Default for Sky { snow: 0.0, cloud_shadow: 0.0, flash: 0.0, + artificial: 0.0, weather: Weather::default(), } } @@ -182,41 +189,143 @@ struct MoonDisk; /// does not depend on it. pub(crate) const SKY_RADIUS: f32 = 9_000.0; -/// Illuminance the sun is given \[lx\]. +/// Illuminance the sun is given \[lx\]: the real figure, 130 klx above the +/// atmosphere. /// -/// Physically this is [`lux::RAW_SUNLIGHT`] (130 klx above the atmosphere), and -/// that is what the scattering model wants: Bevy hands the atmosphere the light's +/// That is what the scattering model wants: Bevy hands the atmosphere the light's /// own colour and applies the transmittance itself, so anything taken off the light -/// is taken off the sky with it. The value below is the raw figure scaled by 0.15 — -/// an exposure choice written into the light, because the camera's exposure is -/// fixed at Bevy's default and the rest of the night lighting is tuned to it. -/// -/// ponytail: one constant instead of an EV curve over the whole day. The correct -/// version is `lux::RAW_SUNLIGHT` here plus an `Exposure` that rides from EV 13 at -/// noon down to the night — worth doing once the artificial lights (headlights, cab -/// lamps, the `_NIGHT` emissives in the mods) are in physical units too. -pub(crate) const SUN_ILLUMINANCE: f32 = lux::RAW_SUNLIGHT * 0.15; +/// is taken off the sky with it. What makes the picture the right brightness is +/// not this number but the camera's [`Exposure`], which [`exposure`] rides from +/// about EV 14 under a high sun to EV −3 under the moon. Every light in the world +/// carries its real figure and the exposure does the rest — the one place a +/// number is chosen for the look is the exposure itself. +pub(crate) const SUN_ILLUMINANCE: f32 = lux::RAW_SUNLIGHT; + +/// Exposure the view is never opened past \[EV100\]. A meter keeps opening as +/// long as there is something to measure, and would lift a moonless night to +/// daylight; an eye does not, and a night has to be dark. +pub(crate) const MIN_EV100: f32 = -3.5; + +/// Where the eye stops keeping up \[EV100 of the metered scene\] — about 4 cd/m², +/// the mesopic knee. Above it the exposure follows the light one for one; below +/// it only by [`ADAPTATION`], so a moonlit field is dim and a headlit track +/// bright rather than both being middle grey. +const KNEE_EV100: f32 = 5.0; +const ADAPTATION: f32 = 0.7; + +/// How far the picture is exposed off the meter's reading \[EV\]. The meter puts +/// the metered scene at middle grey, which is a photograph; a picture looked at +/// in a room rather than held up to the sun wants nearly two stops more, or a +/// June meadow reads as late afternoon. +const EXPOSURE_BIAS: f32 = -1.8; + +/// Albedo the meter assumes for the ground in view: fields and woods. +const METER_ALBEDO: f32 = 0.15; + +/// How much brighter the band of sky in a level view is than the hemisphere's +/// mean — the horizon against the zenith. +const SKY_VIEW: f32 = 2.0; + +/// Luminance of the sky at sunset \[cd/m²\] — the exposure tables' EV 12 — and +/// how fast it goes out once the sun is under \[decades per degree\]: 0.38 takes +/// it through civil twilight to the hundredth of a lux the nautical end has. +const TWILIGHT_SKY: f32 = 1_000.0; +const TWILIGHT_SLOPE: f32 = 0.38; + +/// Starlight and airglow on the ground \[lx\], so a moonless night meters as +/// something rather than as the logarithm of nothing. +const STARLIGHT: f32 = 0.002; + +/// How fast the exposure closes when light comes \[EV/s\], and opens when it goes. +/// An eye is quick to protect itself and slow to dark-adapt — though the minutes +/// real dark adaptation takes would leave the player blind for a whole tunnel, +/// so the slow side is seconds too. +const DARKEN_RATE: f32 = 4.0; +const BRIGHTEN_RATE: f32 = 1.5; + +/// How fast the sun is taken down once it is under the horizon \[decades per +/// degree\]. Bevy's atmosphere has no earth shadow: a sun that geometrically +/// cannot reach the air it is scattering in still lights it, and through the sky +/// the ground, so its twilight fades at a fifth of a decade per degree where the +/// real one fades at two fifths. This makes up the difference — the end of civil +/// twilight is a decade under sunset, the nautical end three — and it takes the +/// light off the faces a set sun was still lighting from underneath. +const EARTH_SHADOW_SLOPE: f32 = 0.15; + +/// A clock that moves by more than this between two frames has jumped \[s\] — +/// the console's `time`, a new run — and the exposure snaps rather than adapts, +/// or a run started at night would open up over its first seconds like a camera +/// switched on. +const TIME_JUMP: f64 = 10.0; + +/// What is left of the sun's light once it is this far under the horizon \[°\], +/// 1 while it is up ([`EARTH_SHADOW_SLOPE`]). +fn earth_shadow(elevation: f32) -> f32 { + 10f32.powf(EARTH_SHADOW_SLOPE * elevation.min(0.0)) +} -/// Illuminance of a full moon at the zenith \[lx\]. +/// Exposure of the view \[EV100\] for a sun at this elevation, this much moonlight +/// and artificial light on the ground \[lx\], under this much cloud. +/// +/// A reflected-light meter over a level view — half ground, half sky — with the +/// eye's limits put on top: +/// +/// * The ground is lit by the sun through the air mass of its elevation, averaged +/// between a level meadow and a face turned to the sun (a meter on the meadow +/// alone would open three stops for a sun at 12° and burn every sunlit face and +/// the whole sky with it — the world in view is not flat), by the sky's tenth +/// of it, by the twilight glow, the moon, the lamps and starlight. The deck +/// takes its share off the sun, and the meter opens up for part of that the +/// way a camera does: an overcast day still looks darker than a clear one. +/// * The sky is the clear sky's share plus the twilight glow, going out at a +/// third of a decade per degree once the sun is under — so a sunset meters at +/// the tables' EV 12, the end of civil twilight near 6 and a moonlit landscape +/// at −3, where the tables put them. +/// * Below the mesopic knee the exposure follows only in part, and never past +/// [`MIN_EV100`]: the night stays dark, and the headlights light the track +/// rather than the exposure lighting it. /// -/// ponytail: a real full moon is 0.25 lx and would be pure black without eye -/// adaptation — the night is lit artistically bright instead, as it already was. -const MOON_ILLUMINANCE: f32 = 40.0; +/// Continuous everywhere, so a day run through at speed shows no step. +pub(crate) fn exposure(sin_elevation: f32, moon: f32, artificial: f32, cover: f32) -> f32 { + let up = sin_elevation.max(0.0); + let elevation = sin_elevation.clamp(-1.0, 1.0).asin().to_degrees(); + // Kasten & Young's air mass is for a sun above the horizon; below it the disk + // is behind the earth, and the fade is the last light on a face after sunset. + let sun = crate::clouds::sunlight(up) * crate::clouds::smoothstep(-0.035, 0.0, sin_elevation); + let transmittance = 0.2126 * sun.x + 0.7152 * sun.y + 0.0722 * sun.z; + let direct = SUN_ILLUMINANCE * transmittance * 0.5 * (1.0 + up) * (1.0 - 0.85 * cover); + let twilight = TWILIGHT_SKY * 10f32.powf(TWILIGHT_SLOPE * elevation.min(0.0)); + let sky_illuminance = + SUN_ILLUMINANCE * crate::clouds::SKY_SHARE * up + PI * twilight / SKY_VIEW; + let ground = METER_ALBEDO * (direct + sky_illuminance + moon + artificial + STARLIGHT) / PI; + let sky = SKY_VIEW * sky_illuminance / PI; + // ISO 2720's reflected-light constant K = 12.5: EV = log₂(L · 100 / K). + let meter = (0.5 * (ground + sky) * 8.0).log2(); + let adapted = if meter > KNEE_EV100 { + meter + } else { + KNEE_EV100 + ADAPTATION * (meter - KNEE_EV100) + }; + (adapted + EXPOSURE_BIAS).max(MIN_EV100) +} + +/// Illuminance of a full moon at the zenith \[lx\] — the real quarter of a lux. +/// What makes it a lit field rather than black is the exposure, which opens to +/// [`MIN_EV100`] for it. +const MOON_ILLUMINANCE: f32 = 0.25; -/// Luminance of the fully lit moon's disk \[cd/m²\] — the real figure, which at -/// the camera's fixed exposure lands almost exactly where it should: a disk that -/// clips to white where the sun is on it, an earthshine grey where it is not. +/// Luminance of the fully lit moon's disk \[cd/m²\] — the real figure: a disk +/// that clips to white where the sun is on it, an earthshine grey where it is +/// not, and by day a pale coin that is dimmer than the sky around it. const MOON_LUMINANCE: f32 = 2_500.0; -/// Rendered peak luminance of a magnitude-0 star's sprite \[cd/m²\]. Every other -/// star is this times `10^(-0.4 · magnitude)`, so the catalogue's own brightness -/// ratios survive and a constellation reads by its shape. -/// -/// ponytail: physically the sprite would be about 1.7 cd/m², and at the camera's -/// fixed exposure that is black. The stars are the one thing here that has to be -/// lifted wholesale — the moon's own figure needs no such help — and the lift is -/// a constant rather than a curve because the exposure never moves either. -const STAR_LUMINANCE: f32 = 12_000.0; +/// Peak luminance of a magnitude-0 star's sprite \[cd/m²\]: the star's light +/// spread over the two pixels of blur a lens gives it. Every other star is this +/// times `10^(-0.4 · magnitude)`, so the catalogue's own brightness ratios +/// survive and a constellation reads by its shape — the bright ones under the +/// headlights, down to the fifth magnitude once the exposure has opened for a +/// moonlit field. +const STAR_LUMINANCE: f32 = 1.7; /// Angular diameter a star's point sprite is drawn at \[rad\]. A star is a point /// source; this is the width of the blur a lens gives it, chosen at about two @@ -283,12 +392,14 @@ pub fn spawn( )); } -/// What a camera needs to see the sky: the atmosphere for this view, and the -/// image-based light the sky itself casts back into the scene. -pub fn camera_settings() -> (AtmosphereSettings, AtmosphereEnvironmentMapLight) { +/// What a camera needs to see the sky: the atmosphere for this view, the +/// image-based light the sky itself casts back into the scene, and the exposure +/// [`update`] rides over the day and the night. +pub fn camera_settings() -> (AtmosphereSettings, AtmosphereEnvironmentMapLight, Exposure) { ( AtmosphereSettings::default(), AtmosphereEnvironmentMapLight::default(), + Exposure::default(), ) } @@ -329,6 +440,10 @@ fn update( mut star_materials: ResMut>, mut moon_materials: ResMut>, camera: Query<&GlobalTransform, (With, With)>, + mut exposures: Query<&mut Exposure, With>, + time: Res