MONKEY (fix-wind): integrate wind travel on the CPU; fixed-heading spatial phase

The wave phase was k*speed(t)*t (its rate grows with session age: ~33 Hz noise
after 10 min) and its spatial term rotated about the world origin with the veer
(hundreds of rad/s at 9k yd from the first second). Now update_wind integrates
travel += speed*dt in f64 and publishes it wrapped at 4096 yd (wind_b.x); every
wave rate is a whole number of cycles per wrap, so the wrap is seamless. The
spatial term uses the fixed profile heading (wind_a.z); the veer and gust turn
only the bend. Capture offset WOW_CAPTURE_WIND_T for late-session shots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-25 16:54:07 +01:00
co-authored by Claude Opus 5.5
parent 0c2e40fd3e
commit f0526d610b
5 changed files with 114 additions and 28 deletions
+3 -3
View File
@@ -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 |
@@ -15,8 +15,8 @@ struct MonkeyFrame {
fog_b: vec4<f32>, // sun_fog_rgb (gamma 0..1), sun_fog_strength
fog_c: vec4<f32>, // end_fog_rgb (gamma 0..1), end_fog_distance (yd)
fog_d: vec4<f32>, // 0 classic or scene fog end for Modern, sun_fog_angle, sun direction octahedral xy
wind_a: vec4<f32>, // dir_x, dir_y (unit, world XZ: .x = world x, .y = world z), speed (yd/s), gust 0..1
wind_b: vec4<f32>, // time_s, sway_strength, grass_strength, tree_strength
wind_a: vec4<f32>, // 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<f32>, // travel (yd, integrated speed, wrapped at 4096), sway_strength, grass_strength, tree_strength
wet_a: vec4<f32>, // rain_rate 0..1, wetness 0..1, ripple_time_s, snow 0..1
misc: vec4<f32>, // bender_count, time_of_day 0..1 (packer), night 0..1 (packer), 0
benders: array<vec4<f32>, 8>, // world xyz (Bevy space) + radius (yd); the first bender_count are live
@@ -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<f32> {
return vec2<f32>(cos(frame.wind_a.z), sin(frame.wind_a.z));
}
fn wind_cross_dir(dir: vec2<f32>) -> vec2<f32> {
// 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<f32>(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<f32>(1.0e-6, 0.0));
let cross = vec2<f32>(-dir.y, dir.x);
let base = wind_base_dir(frame);
let base_cross = vec2<f32>(-base.y, base.x);
let phase = fract(sin(dot(anchor.xz, vec2<f32>(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<f32>(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<f32>(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 = (
@@ -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,
+64 -4
View File
@@ -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<f64>,
}
/// 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::<f64>().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<FoliageWind>,
mut wind: ResMut<WindField>,
mut frame: ResMut<MonkeyFrame>,
mut clock: Local<WindClock>,
mut offset: Local<Option<f64>>,
) {
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}");
}
}
}