From f0526d610b4e0fe686736b7bd3d393e9206565a7 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 16:54:07 +0100
Subject: [PATCH] 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
---
LIGHTING.md | 6 +-
.../src/shaders/monkey_frame.wgsl | 4 +-
.../benilla-assets/src/shaders/wind_hook.wgsl | 42 +++++++++---
.../src/lighting/monkey_frame.rs | 22 +++---
crates/benilla-world/src/wind/mod.rs | 68 +++++++++++++++++--
5 files changed, 114 insertions(+), 28 deletions(-)
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