diff --git a/LIGHTING.md b/LIGHTING.md index 6fbddf3e..fa4812c5 100644 --- a/LIGHTING.md +++ b/LIGHTING.md @@ -31,7 +31,7 @@ behind each constant. | Sky quality | Enhanced: the five Light.dbc sky stops through a smooth monotone curve in linear light (no bands at the rings), a soft sun glow tinted by sun and fog colour (fades at night and under cloud), a procedural star field with twinkle and a faint Milky Way over the stock `Stars.m2`. High adds domain-warped cloud detail and sun-lit clouds (self-shadow, silver lining; technique from WarcraftXL, see `THIRD-PARTY.md`). Classic is the reference sky unchanged | Advanced Graphics → Sky Quality, cvar `skyQuality` 0 Classic / 1 Enhanced / 2 High, env `WOW_SKY_QUALITY` | | Modern fog | MONKEY (fog): radial distance fog with a gradual exponential curve, a daylight sun lobe, and an end colour shared by the world, WDL hull, sky horizon and volumetric haze. Each zone keeps its authored fog end up to the reference view-distance limit; optional `LightFogBand.dbc` rows provide height, sun and end-fog controls. Interior WMO fog stays classic | Advanced Graphics → Modern Fog, cvar `fogModel` 0 Classic (default) / 1 Modern (High = 1), env `WOW_FOGMODEL=0\|1`; `fog_hook.wgsl`, `lighting/fog_model.rs`, `light/fog_band.rs` | | Rain on surfaces | MONKEY (wet): rain gradually darkens and saturates exposed terrain and models, adds puddles and a sky/sun sheen, and produces rings on exterior Enhanced water. Wetness rises over roughly 90 seconds and dries over roughly four minutes; phase 1 has no rain-occlusion map, so shelter is classified only by interior/unlit state and surface facing | Advanced Graphics → Wet Surfaces in Rain, cvar `rainSurfaces` 0/1 (default and High 1), env `WOW_RAIN_SURFACES`, `WOW_WETNESS`, `WOW_WET_T`; `weather/wetness.rs`, `wet_hook.wgsl` | -| Foliage wind | one weather-fed gust/veer field drives grass and classified tree/bush foliage; grass also parts around the player and nearby units | Advanced Graphics → Foliage Wind, cvar `foliageWind` 0 Off / 1 Grass / 2 Grass + Trees (High = 2), capture override `WOW_FOLIAGE_WIND`; field and benders in `benilla-world/src/wind/` | +| Foliage wind | one weather-fed gust/veer field drives grass and classified tree/bush foliage; grass also parts around the player and nearby units | Advanced Graphics → Foliage Wind, cvar `foliageWind` 0 Off / 1 Grass / 2 Grass + Trees (High = 2), capture override `WOW_FOLIAGE_WIND`; field and benders in `benilla-world/src/wind/`. The wave phase is the CPU-integrated travel (speed integrated over time, wrapped seamlessly), and the waves' spatial term uses the fixed profile heading; the veer turns only the bend. Tree exile copies sway through material marker bit 14 (`FOLIAGE_WIND_MARKER`). Capture offset `WOW_CAPTURE_WIND_T` (s) | | Night and interior level | global dimming of the night sky term and of interior ambient | `nightGain`, `interiorGain`, `interiorBakeFloor` | Players reach all of it from **Options -> Advanced Graphics** (a Lighting Quality preset Off / Low / Medium / High plus the individual rows; Off is the original client look). The dev build has a panel for all of it: **Ctrl+Shift+D → Lighting & shadows**, with Dim / Default / @@ -84,8 +84,8 @@ is its own module, documented in `WATER.md`. | `fog_b` | sun_fog r | g | b | sun_fog_strength | | `fog_c` | end_fog r | g | b | end_fog_distance | | `fog_d` | fog_model (0 classic; Modern = scene fog end in yd) | sun_fog_angle | sun direction octahedral x | y | - | `wind_a` | dir_x | dir_y | speed | gust | - | `wind_b` | time_s | sway_strength | grass_strength | tree_strength | + | `wind_a` | dir_x | dir_y | base_heading (rad) | gust | + | `wind_b` | travel (yd, wrapped at 4096) | sway_strength | grass_strength | tree_strength | | `wet_a` | rain_rate | wetness | ripple_time_s | snow | | `misc` | bender_count | time_of_day 0..1 | night 0..1 | 0 | | `benders[8]` | world x | world y | world z | radius | diff --git a/crates/benilla-assets/src/shaders/monkey_frame.wgsl b/crates/benilla-assets/src/shaders/monkey_frame.wgsl index 234f442e..dbf08d29 100644 --- a/crates/benilla-assets/src/shaders/monkey_frame.wgsl +++ b/crates/benilla-assets/src/shaders/monkey_frame.wgsl @@ -15,8 +15,8 @@ struct MonkeyFrame { fog_b: vec4, // sun_fog_rgb (gamma 0..1), sun_fog_strength fog_c: vec4, // end_fog_rgb (gamma 0..1), end_fog_distance (yd) fog_d: vec4, // 0 classic or scene fog end for Modern, sun_fog_angle, sun direction octahedral xy - wind_a: vec4, // dir_x, dir_y (unit, world XZ: .x = world x, .y = world z), speed (yd/s), gust 0..1 - wind_b: vec4, // time_s, sway_strength, grass_strength, tree_strength + wind_a: vec4, // dir_x, dir_y (unit, world XZ: .x = world x, .y = world z), base_heading (rad, fixed profile heading), gust 0..1 + wind_b: vec4, // travel (yd, integrated speed, wrapped at 4096), sway_strength, grass_strength, tree_strength wet_a: vec4, // rain_rate 0..1, wetness 0..1, ripple_time_s, snow 0..1 misc: vec4, // bender_count, time_of_day 0..1 (packer), night 0..1 (packer), 0 benders: array, 8>, // world xyz (Bevy space) + radius (yd); the first bender_count are live diff --git a/crates/benilla-assets/src/shaders/wind_hook.wgsl b/crates/benilla-assets/src/shaders/wind_hook.wgsl index c398dbf7..28e60f68 100644 --- a/crates/benilla-assets/src/shaders/wind_hook.wgsl +++ b/crates/benilla-assets/src/shaders/wind_hook.wgsl @@ -5,6 +5,21 @@ #import benilla::monkey_frame::MonkeyFrame const WIND_TAU: f32 = 6.28318530718; +// MONKEY (fix-wind): `wind_b.x` is the integrated wind travel in yards, wrapped at 4096 on the CPU +// (`wind::TRAVEL_WRAP`). Each wave rate is a whole number of cycles per wrap, so the wrap is +// seamless; the phase advances at k * speed, never k * speed * age. +const WIND_WRAP: f32 = 4096.0; + +// The phase (radians) of a wave doing `cycles` whole cycles per wrap of travel. +fn wind_travel_phase(travel: f32, cycles: f32) -> f32 { + return fract(cycles * (travel / WIND_WRAP)) * WIND_TAU; +} + +// The fixed profile heading: the waves' spatial term uses it, so the veer (which only turns the +// bend direction) never rotates the phase field about the world origin. +fn wind_base_dir(frame: MonkeyFrame) -> vec2 { + return vec2(cos(frame.wind_a.z), sin(frame.wind_a.z)); +} fn wind_cross_dir(dir: vec2) -> vec2 { // WarcraftXL's secondary wave is 35 degrees off the primary. @@ -32,13 +47,16 @@ fn grass_offset( let dir_primary = normalize(frame.wind_a.xy + vec2(1.0e-6, 0.0)); let dir_secondary = wind_cross_dir(dir_primary); + let base_primary = wind_base_dir(frame); + let base_secondary = wind_cross_dir(base_primary); let wave_primary = WIND_TAU / 18.0; let wave_secondary = WIND_TAU / 6.5; - let travel = frame.wind_a.z * 0.33; - let phase_primary = wave_primary * dot(dir_primary, world.xz) - - wave_primary * travel * frame.wind_b.x + tuft_phase; - let phase_secondary = wave_secondary * dot(dir_secondary, world.xz) - - wave_secondary * travel * 1.37 * frame.wind_b.x + tuft_phase * 1.7; + // WarcraftXL's k * 0.33 * speed (primary) and k * 0.33 * 1.37 * speed (secondary), as whole + // cycles per wrap: 75 and 285. + let phase_primary = wave_primary * dot(base_primary, world.xz) + - wind_travel_phase(frame.wind_b.x, 75.0) + tuft_phase; + let phase_secondary = wave_secondary * dot(base_secondary, world.xz) + - wind_travel_phase(frame.wind_b.x, 285.0) + tuft_phase * 1.7; let gust = 1.0 + 0.5 * (frame.wind_a.w - 0.5) * 2.0; let variance = mix(0.7, 1.3, fract(sin(tuft_phase * 12.9898) * 43758.5453)); let distance_fade = 1.0 / (1.0 + distance(camera, world) * 0.015); @@ -80,11 +98,17 @@ fn tree_offset( let weight = crown * crown; let dir = normalize(frame.wind_a.xy + vec2(1.0e-6, 0.0)); let cross = vec2(-dir.y, dir.x); + let base = wind_base_dir(frame); + let base_cross = vec2(-base.y, base.x); let phase = fract(sin(dot(anchor.xz, vec2(0.173, 0.317))) * 43758.5453) * WIND_TAU; - let travel = frame.wind_a.z * 0.08 * frame.wind_b.x; - let slow = sin(dot(dir, world.xz) * (WIND_TAU / 34.0) - travel + phase); - let side = sin(dot(cross, world.xz) * (WIND_TAU / 21.0) - travel * 0.61 + phase * 1.7); - let flutter = sin(frame.wind_b.x * 2.3 + dot(world.xz, vec2(1.7, 2.1)) + phase * 2.0); + // 0.08 rad/yd sway, 0.61x side wave and a 2.3 rad/s-at-base-speed flutter, as whole cycles + // per wrap of travel: 52, 32 and 167. + let slow = sin(dot(base, world.xz) * (WIND_TAU / 34.0) + - wind_travel_phase(frame.wind_b.x, 52.0) + phase); + let side = sin(dot(base_cross, world.xz) * (WIND_TAU / 21.0) + - wind_travel_phase(frame.wind_b.x, 32.0) + phase * 1.7); + let flutter = sin(wind_travel_phase(frame.wind_b.x, 167.0) + + dot(world.xz, vec2(1.7, 2.1)) + phase * 2.0); let gust = 0.75 + frame.wind_a.w * 0.5; let fade = 1.0 / (1.0 + distance(camera, world) * 0.006); let xz = ( diff --git a/crates/benilla-world/src/lighting/monkey_frame.rs b/crates/benilla-world/src/lighting/monkey_frame.rs index 363058ef..ec2cc059 100644 --- a/crates/benilla-world/src/lighting/monkey_frame.rs +++ b/crates/benilla-world/src/lighting/monkey_frame.rs @@ -18,8 +18,8 @@ //! | 1 `fog_b` | sun_fog_rgb | sun_fog_strength | //! | 2 `fog_c` | end_fog_rgb | end_fog_distance | //! | 3 `fog_d` | fog_model (0 classic; Modern = the scene fog end, yd, ≥ 1), sun_fog_angle, sun dir oct.x | sun dir oct.y | -//! | 4 `wind_a` | dir_x, dir_y, speed | gust | -//! | 5 `wind_b` | time_s, sway_strength, grass_strength | tree_strength | +//! | 4 `wind_a` | dir_x, dir_y, base_heading (rad) | gust | +//! | 5 `wind_b` | travel (yd, wrapped), sway_strength, grass_strength | tree_strength | //! | 6 `wet_a` | rain_rate, wetness, ripple_time_s | snow | //! | 7 `misc` | bender_count, time_of_day 0..1, night 0..1 | 0 | //! | 8-15 `benders` | world x, y, z | radius | @@ -75,11 +75,13 @@ pub struct MonkeyFrame { // ── WIND lane (rows 4-5) ── /// Unit vector in the world XZ plane (`[x, z]`). pub wind_dir: [f32; 2], - /// Yards per second. - pub wind_speed: f32, + /// MONKEY (fix-wind): the profile's fixed heading in radians. The waves' spatial term uses + /// it, so the veer never rotates the phase field about the world origin. + pub wind_base_heading: f32, pub wind_gust: f32, - /// The wind clock in seconds (the lane owns it, so a capture can pin it). - pub wind_time_s: f32, + /// MONKEY (fix-wind): the integrated wind travel (speed integrated over time) in yards, wrapped + /// at `wind::TRAVEL_WRAP`; the shader's wave rates are whole cycles per wrap, so it is seamless. + pub wind_travel: f32, pub sway_strength: f32, pub grass_strength: f32, pub tree_strength: f32, @@ -115,8 +117,8 @@ impl MonkeyFrame { let e = self.end_fog_rgb; rows[2] = [e[0], e[1], e[2], self.end_fog_distance]; rows[3] = [model, self.sun_fog_angle, self.sun_fog_dir[0], self.sun_fog_dir[1]]; - rows[4] = [self.wind_dir[0], self.wind_dir[1], self.wind_speed, self.wind_gust]; - rows[5] = [self.wind_time_s, self.sway_strength, self.grass_strength, self.tree_strength]; + rows[4] = [self.wind_dir[0], self.wind_dir[1], self.wind_base_heading, self.wind_gust]; + rows[5] = [self.wind_travel, self.sway_strength, self.grass_strength, self.tree_strength]; rows[6] = [self.rain_rate, self.wetness, self.ripple_time_s, self.snow]; let n = (self.bender_count as usize).min(MAX_BENDERS); rows[7] = [n as f32, time_of_day, night, 0.0]; @@ -153,9 +155,9 @@ mod tests { sun_fog_dir: [0.5, -0.5], fog_scene_end: 444.0, wind_dir: [14.0, 15.0], - wind_speed: 16.0, + wind_base_heading: 16.0, wind_gust: 17.0, - wind_time_s: 18.0, + wind_travel: 18.0, sway_strength: 19.0, grass_strength: 20.0, tree_strength: 21.0, diff --git a/crates/benilla-world/src/wind/mod.rs b/crates/benilla-world/src/wind/mod.rs index 6b848969..f1e811cf 100644 --- a/crates/benilla-world/src/wind/mod.rs +++ b/crates/benilla-world/src/wind/mod.rs @@ -131,6 +131,43 @@ pub fn sample(profile: WindProfile, seconds: f64, storm: f32) -> WindSample { } } +/// MONKEY (fix-wind): the wrap of the published wind travel, in yards. Every wave rate in +/// `wind_hook.wgsl` is a whole number of cycles per wrap, so the wrap is invisible. +pub const TRAVEL_WRAP: f64 = 4096.0; + +/// MONKEY (fix-wind): the integrated travel. The waves' phase must be the integral of the speed, +/// not `speed(t) * t` (whose rate grows with session age and flickered after a few minutes). +#[derive(Default)] +struct WindClock { + travel: f64, + last: Option, +} + +/// Capture-only clock offset (`$WOW_CAPTURE_WIND_T`, seconds): photographs the field as it is +/// that long into a session. Ignored outside `WOW_CAPTURE`. +fn capture_wind_offset() -> f64 { + if std::env::var_os("WOW_CAPTURE").is_none() { + return 0.0; + } + std::env::var("WOW_CAPTURE_WIND_T") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(0.0) + .max(0.0) +} + +/// Integrates the travel from `from` to `to` seconds at a fixed 1/60 s step. +fn integrate_travel(profile: WindProfile, storm: f32, from: f64, to: f64) -> f64 { + let mut travel = 0.0; + let mut t = from; + while t < to { + let dt = (to - t).min(1.0 / 60.0); + travel += f64::from(sample(profile, t, storm).speed) * dt; + t += dt; + } + travel +} + /// MONKEY (wind): resolve once at the frame boundary, then publish the same sample and clock to /// Rust consumers and the appended MonkeyFrame block. A zero tier clears every visual strength. fn update_wind( @@ -139,14 +176,27 @@ fn update_wind( quality: Res, mut wind: ResMut, mut frame: ResMut, + mut clock: Local, + mut offset: Local>, ) { - let seconds = time.elapsed_secs_f64(); - wind.sample = sample(wind.profile, seconds, storm_blend(weather.sky_density)); + let offset = *offset.get_or_insert_with(capture_wind_offset); + let seconds = time.elapsed_secs_f64() + offset; + let storm = storm_blend(weather.sky_density); + wind.sample = sample(wind.profile, seconds, storm); + + // MONKEY (fix-wind): integrate the travel on the CPU in f64 and publish it wrapped. + let step = match clock.last { + // First frame: catch up from session start (only non-zero under a capture offset). + None => integrate_travel(wind.profile, storm, 0.0, seconds), + Some(last) => f64::from(wind.sample.speed) * (seconds - last).max(0.0), + }; + clock.last = Some(seconds); + clock.travel = (clock.travel + step).rem_euclid(TRAVEL_WRAP); frame.wind_dir = wind.sample.dir.to_array(); - frame.wind_speed = wind.sample.speed; + frame.wind_base_heading = wind.profile.heading_deg.to_radians(); frame.wind_gust = wind.sample.gust; - frame.wind_time_s = seconds as f32; + frame.wind_travel = clock.travel as f32; frame.sway_strength = if quality.0 > 0 { 1.0 } else { 0.0 }; frame.grass_strength = if quality.0 >= 1 { 1.0 } else { 0.0 }; frame.tree_strength = if quality.0 >= 2 { 1.0 } else { 0.0 }; @@ -250,4 +300,14 @@ mod tests { assert!(s.speed >= 0.0); } } + + #[test] + fn travel_rate_stays_bounded_late_in_a_session() { + // MONKEY (fix-wind): the published travel advances at the wind speed, never speed x age. + let p = WindProfile::default(); + for t0 in [10.0, 600.0, 36_000.0] { + let d = integrate_travel(p, 0.0, t0, t0 + 1.0); + assert!(d > 0.0 && d < 2.2 * f64::from(p.base_speed), "t0={t0} d={d}"); + } + } }