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:
co-authored by
Claude Opus 5.5
parent
0c2e40fd3e
commit
f0526d610b
+3
-3
@@ -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,
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user