Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions crates/app/src/loading.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
66 changes: 57 additions & 9 deletions crates/app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<SimResource>,
origin: Res<Origin>,
daylight: Res<Daylight>,
player: Res<PlayerTrain>,
mut sky: ResMut<sky::Sky>,
mut ambient: Query<&mut AmbientLight, With<ui::CabCamera>>,
mut fog: Query<&mut DistanceFog, With<ui::CabCamera>>,
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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;
}
}

Expand All @@ -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];
Expand All @@ -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 };
}
}

Expand Down Expand Up @@ -1575,6 +1617,7 @@ fn update_precipitation(
player: Res<PlayerTrain>,
origin: Res<Origin>,
daylight: Res<Daylight>,
sky: Res<sky::Sky>,
view: Res<ui::CameraState>,
camera: Query<&Transform, With<ui::CabCamera>>,
sun: Query<&Transform, (With<sky::Sun>, Without<Precipitation>)>,
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 5 additions & 3 deletions crates/route-editor/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
19 changes: 10 additions & 9 deletions crates/world-render/src/clouds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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)
}
Expand Down
60 changes: 44 additions & 16 deletions crates/world-render/src/precipitation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,21 @@ 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
/// dimmer and shorter than the field behind it.
pub fn params(
weather: Weather,
daylight: f32,
artificial: f32,
snow_field: bool,
near: bool,
) -> PrecipitationParams {
Expand Down Expand Up @@ -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),
}
Expand All @@ -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");
}
Expand Down
Loading