MONKEY (wind): add shared WarcraftXL wind field
Port the stateless gust and veer profile, scale it from live weather, and publish one sample through the WindField resource and MonkeyFrame each frame. Add the foliageWind tier CVar, High preset value, and Advanced Graphics row. Co-Authored-By: GPT-5.6 Sol (Codex)
This commit is contained in:
co-authored by
GPT-5.6 Sol (Codex)
parent
721a1f7d19
commit
347f675f91
@@ -521,12 +521,16 @@
|
||||
interiorDarkness = "Interior Darkness",
|
||||
-- MONKEY (p0 skyDither)
|
||||
skyDither = "Sky Dithering",
|
||||
-- MONKEY (wind)
|
||||
foliageWind = "Foliage Wind",
|
||||
windGrass = "Grass",
|
||||
windTrees = "Grass + Trees",
|
||||
yards = "%d yd",
|
||||
tips = {
|
||||
LIGHTING_QUALITY =
|
||||
"Sets character, world, indoor torch and outdoor torch shadows; dynamic "
|
||||
.. "interior lighting; moon shadows; volumetric fog; water quality; lava glow; fire and spell brightness; fire flicker; "
|
||||
.. "and night and interior darkness. Low, Medium and High also set shadow "
|
||||
.. "foliage wind; and night and interior darkness. Low, Medium and High also set shadow "
|
||||
.. "resolution; Medium and High also set resident and moving torch shadow counts.\n\n"
|
||||
.. "Off restores the original client's look: Classic water, no volumetric fog, lava glow or synthesised fire lights, "
|
||||
.. "night gain 1.0 and interior gain 1.0. All other settings, including "
|
||||
@@ -617,6 +621,11 @@
|
||||
SKY_DITHER =
|
||||
"Adds a faint noise to the final image so smooth sky and fog gradients do not "
|
||||
.. "break into visible bands. Off is the original client's look. Practically free.",
|
||||
-- MONKEY (wind)
|
||||
FOLIAGE_WIND =
|
||||
"Grass bends in two travelling wind waves and parts around nearby characters. "
|
||||
.. "Grass + Trees also gives classified tree and bush foliage a gentle sway.\n\n"
|
||||
.. "Off makes no vertex displacement and restores the unchanged image. Tree shadows stay static.",
|
||||
},
|
||||
},
|
||||
ruRU = {
|
||||
@@ -651,6 +660,10 @@
|
||||
interiorDarkness = "Темнота помещений",
|
||||
-- MONKEY (p0 skyDither)
|
||||
skyDither = "Дизеринг неба",
|
||||
-- MONKEY (wind)
|
||||
foliageWind = "Ветер листвы",
|
||||
windGrass = "Трава",
|
||||
windTrees = "Трава + деревья",
|
||||
yards = "%d ярд.",
|
||||
tips = {
|
||||
LIGHTING_QUALITY =
|
||||
@@ -756,6 +769,11 @@
|
||||
"Добавляет едва заметный шум в итоговое изображение, чтобы плавные переходы цвета "
|
||||
.. "неба и тумана не распадались на полосы. Выключено — вид оригинального клиента. "
|
||||
.. "Почти ничего не стоит.",
|
||||
-- MONKEY (wind)
|
||||
FOLIAGE_WIND =
|
||||
"Трава гнётся двумя бегущими волнами ветра и расступается вокруг персонажей. "
|
||||
.. "Трава + деревья также добавляет мягкое качание листвы деревьев и кустов.\n\n"
|
||||
.. "Выключено не смещает вершины и восстанавливает исходный вид. Тени деревьев остаются неподвижными.",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -816,6 +834,8 @@
|
||||
"RowNightDarkness", "RowInteriorDarkness",
|
||||
-- MONKEY (p0 skyDither)
|
||||
"RowSkyDither",
|
||||
-- MONKEY (wind): appended for conflict-free lane merges.
|
||||
"RowFoliageWind",
|
||||
},
|
||||
-- Player Guild Names is a child of Player Names, as in 1.12 (l.702-708).
|
||||
Nameplates = {
|
||||
@@ -2854,6 +2874,23 @@
|
||||
"BENILLA_TOOLTIP_SKY_DITHER")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<!-- MONKEY (wind): three foliage tiers; appended at the end of the page. -->
|
||||
<Frame name="$parentRowFoliageWind" inherits="OptionsDropdownRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowSkyDither" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowSkyDither" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "foliageWind",
|
||||
BENILLA_ADVGFX.foliageWind,
|
||||
"BENILLA_TOOLTIP_FOLIAGE_WIND")
|
||||
OptionsDropdownRow_Setup(self, {
|
||||
{ text = BENILLA_ADVGFX.qOff, value = "0" },
|
||||
{ text = BENILLA_ADVGFX.windGrass, value = "1" },
|
||||
{ text = BENILLA_ADVGFX.windTrees, value = "2" },
|
||||
})
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
</Frames>
|
||||
<!-- The poll (see AdvGfx_OnUpdate): this is the one page whose
|
||||
settings move from outside it — the Lighting Quality row
|
||||
|
||||
@@ -928,6 +928,12 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
"0",
|
||||
"benilla's own: faint screen dither against sky and fog banding, 0 Off / 1 On",
|
||||
),
|
||||
// MONKEY (wind): one tier controls the grass-only and grass-plus-tree receivers.
|
||||
ours(
|
||||
"foliageWind",
|
||||
"2",
|
||||
"benilla's own: foliage wind, 0 Off / 1 Grass / 2 Grass + trees",
|
||||
),
|
||||
ours(
|
||||
"waterQuality",
|
||||
"1",
|
||||
@@ -2021,6 +2027,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
|
||||
("interiorGain", "1.0"),
|
||||
("moonShadowStrength", "0"),
|
||||
("fireFlicker", "0"),
|
||||
// MONKEY (wind): Off is the exact zero-displacement path.
|
||||
("foliageWind", "0"),
|
||||
],
|
||||
),
|
||||
// Character silhouettes and lit rooms, and nothing that costs a second shadow pass: no world
|
||||
@@ -2044,6 +2052,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
|
||||
("interiorGain", "0.5"),
|
||||
("moonShadowStrength", "0"),
|
||||
("fireFlicker", "1"),
|
||||
// MONKEY (wind): Low animates the denser grass lane only.
|
||||
("foliageWind", "1"),
|
||||
],
|
||||
),
|
||||
// Both sun lanes, indoor torch shadows at half the residency, and the moon term — but no
|
||||
@@ -2070,6 +2080,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
|
||||
("interiorGain", "0.5"),
|
||||
("moonShadowStrength", "0.35"),
|
||||
("fireFlicker", "1"),
|
||||
// MONKEY (wind): Medium and High include classified trees.
|
||||
("foliageWind", "2"),
|
||||
],
|
||||
),
|
||||
// **High IS the shipped default**, member for member — which is a property, not a coincidence:
|
||||
@@ -2097,6 +2109,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
|
||||
("interiorGain", "0.5"),
|
||||
("moonShadowStrength", "0.35"),
|
||||
("fireFlicker", "1"),
|
||||
// MONKEY (wind): High preset = grass plus classified trees.
|
||||
("foliageWind", "2"),
|
||||
],
|
||||
),
|
||||
];
|
||||
@@ -2960,8 +2974,9 @@ mod tests {
|
||||
for (name, want) in lighting {
|
||||
assert_eq!(d[name], want, "{name}: registered default left the knob");
|
||||
}
|
||||
// …and the census IS the row set. A name here that nothing registers would weld against a
|
||||
// row the client does not have; the length is the other half — 33 rows, 33 welds.
|
||||
// …and the census is the VideoConfig-backed row set. A name here that nothing registers
|
||||
// would weld against a row the client does not have; the length is the other half — 33
|
||||
// VideoConfig rows, 33 welds. MONKEY (wind): `foliageWind` is a world resource bridge.
|
||||
let welded: std::collections::BTreeSet<&str> = lighting.iter().map(|(n, _)| *n).collect();
|
||||
// MONKEY (volumetric fog): the atmospheric tier joins the default-consumer weld.
|
||||
assert_eq!(welded.len(), 33, "the lighting lane welds 33 distinct rows");
|
||||
@@ -2981,10 +2996,10 @@ mod tests {
|
||||
assert_eq!(d["spellLightGain"], 1.0, "the spell lane ships neutral");
|
||||
assert_eq!(d["waterQuality"], 1.0, "mirror reflections stay opt-in");
|
||||
assert_eq!(d["lavaLightGain"], 1.0);
|
||||
// The 33rd lighting row, `lightingQuality`, is deliberately NOT in that census: it is the
|
||||
// only one with no `VideoConfig` knob to weld to, because it is a NAME for the rows above
|
||||
// rather than a knob of its own. Its own weld is `the_high_preset_is_the_registered_
|
||||
// defaults` — a fresh config must read "High", not "Custom".
|
||||
// `lightingQuality` and MONKEY (wind) `foliageWind` deliberately are not in that census:
|
||||
// neither is a `VideoConfig` knob. The former names the rows above; its own weld is
|
||||
// `the_high_preset_is_the_registered_defaults`. The latter bridges directly to the
|
||||
// world-owned `FoliageWind` resource in `monkey_gfx`.
|
||||
// ── end MONKEY (lighting) ─────────────────────────────────────────────────────────────
|
||||
// The pane half-rate (1444) welds to the portrait knob's shipped default.
|
||||
assert_eq!(d["boothHalfRate"] != 0.0, PaneRate::default().half);
|
||||
|
||||
@@ -5,17 +5,39 @@
|
||||
//! | cvar | values | default | Graphics preset High | resource |
|
||||
//! |---|---|---|---|---|
|
||||
//! | `skyDither` | 0 Off / 1 On | 0 | 1 | `benilla_world::ffx_glow::SkyDither` |
|
||||
//! | `foliageWind` | 0 Off / 1 Grass / 2 Grass + trees | 2 | 2 | `benilla_world::wind::FoliageWind` |
|
||||
|
||||
use bevy::prelude::*;
|
||||
use benilla_world::ffx_glow::SkyDither;
|
||||
use benilla_world::wind::FoliageWind;
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// The programme's cvar observer (registered by [`MonkeyGfxPlugin`]).
|
||||
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut dither: ResMut<SkyDither>) {
|
||||
if ev.key().as_str() == "skydither" {
|
||||
let want = ev.num() >= 0.5;
|
||||
if dither.0 != want {
|
||||
dither.0 = want;
|
||||
pub(crate) fn on_cvar(
|
||||
ev: On<crate::cvars::CvarChanged>,
|
||||
mut dither: ResMut<SkyDither>,
|
||||
mut foliage_wind: ResMut<FoliageWind>,
|
||||
) {
|
||||
match ev.key().as_str() {
|
||||
"skydither" => {
|
||||
let want = ev.num() >= 0.5;
|
||||
if dither.0 != want {
|
||||
dither.0 = want;
|
||||
}
|
||||
}
|
||||
// MONKEY (wind): numeric tier, live; 0 is the exact no-displacement path.
|
||||
"foliagewind" => {
|
||||
// Capture-only A/B override. Normal runs have no such environment variable and obey
|
||||
// the live CVar exactly; the instrument can force Off without rewriting Config.wtf.
|
||||
let requested = std::env::var("WOW_FOLIAGE_WIND")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<f32>().ok())
|
||||
.unwrap_or_else(|| ev.num());
|
||||
let want = requested.clamp(0.0, 2.0) as u8;
|
||||
if foliage_wind.0 != want {
|
||||
foliage_wind.0 = want;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +47,8 @@ pub(crate) struct MonkeyGfxPlugin;
|
||||
|
||||
impl Plugin for MonkeyGfxPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<SkyDither>().add_observer(on_cvar);
|
||||
app.init_resource::<SkyDither>()
|
||||
.init_resource::<FoliageWind>()
|
||||
.add_observer(on_cvar);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@ pub mod vis_chain;
|
||||
pub mod water_fx;
|
||||
pub mod wdl;
|
||||
pub mod weather;
|
||||
pub mod wind; // MONKEY (wind): shared stateless gust/veer field and foliage quality gate
|
||||
pub mod wmo_portal;
|
||||
pub mod world_census;
|
||||
pub mod world_map;
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module
|
||||
//! `wxl-experimental-wind`, `field/Wind.hpp` and `field/Wind.cpp`.
|
||||
//!
|
||||
//! MONKEY (wind): one stateless wind field shared by every visual system. The field is a pure
|
||||
//! function of its profile, weather and time; consumers read [`WindField`] rather than inventing
|
||||
//! their own heading or gust clock. [`FoliageWind`] only gates the foliage receivers.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::lighting::MonkeyFrame;
|
||||
use crate::weather::{storm_blend, WeatherState, WeatherTick};
|
||||
|
||||
const TAU: f64 = std::f64::consts::TAU;
|
||||
const RATIOS: [f64; 3] = [1.0, 0.437, 0.1913];
|
||||
const WEIGHTS: [f64; 3] = [0.60, 0.28, 0.12];
|
||||
|
||||
/// The WarcraftXL wind profile. Units are yards, seconds and degrees.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct WindProfile {
|
||||
pub base_speed: f32,
|
||||
pub heading_deg: f32,
|
||||
pub gust: f32,
|
||||
pub gust_period: f32,
|
||||
pub veer_deg: f32,
|
||||
pub veer_period: f32,
|
||||
pub weather_coupling: f32,
|
||||
pub seed: u32,
|
||||
}
|
||||
|
||||
impl Default for WindProfile {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base_speed: 9.0,
|
||||
heading_deg: 45.0,
|
||||
gust: 0.55,
|
||||
gust_period: 17.0,
|
||||
veer_deg: 22.0,
|
||||
veer_period: 53.0,
|
||||
weather_coupling: 1.0,
|
||||
seed: 0x5749_4E44,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One resolved instant of the shared field, in Bevy's horizontal XZ plane.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct WindSample {
|
||||
pub dir: Vec2,
|
||||
pub speed: f32,
|
||||
pub heading_deg: f32,
|
||||
/// The shaped gust envelope, 0 at a lull and 1 at a peak.
|
||||
pub gust: f32,
|
||||
}
|
||||
|
||||
/// The public shared wind resource. Future water/cloud consumers read [`Self::sample`].
|
||||
#[derive(Resource, Clone, Copy, Debug, PartialEq)]
|
||||
pub struct WindField {
|
||||
pub profile: WindProfile,
|
||||
pub sample: WindSample,
|
||||
}
|
||||
|
||||
impl Default for WindField {
|
||||
fn default() -> Self {
|
||||
let profile = WindProfile::default();
|
||||
Self {
|
||||
sample: sample(profile, 0.0, 0.0),
|
||||
profile,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Foliage wind quality: 0 Off, 1 grass, 2 grass + trees.
|
||||
#[derive(Resource, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct FoliageWind(pub u8);
|
||||
|
||||
impl Default for FoliageWind {
|
||||
fn default() -> Self {
|
||||
// The resource exists before the CVar host. Its ordinary default therefore has to equal
|
||||
// the registered default; a default-valued CVar emits no change event at boot. The env
|
||||
// arm is capture-only and lets the A/B harness force the exact zero-displacement path.
|
||||
let tier = std::env::var("WOW_FOLIAGE_WIND")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<f32>().ok())
|
||||
.unwrap_or(2.0)
|
||||
.clamp(0.0, 2.0) as u8;
|
||||
Self(tier)
|
||||
}
|
||||
}
|
||||
|
||||
fn seeded_unit(seed: u32, index: i32) -> f64 {
|
||||
let mut h = seed ^ (index as u32).wrapping_mul(0x9E37_79B9);
|
||||
h ^= h >> 16;
|
||||
h = h.wrapping_mul(0x7FEB_352D);
|
||||
h ^= h >> 15;
|
||||
h = h.wrapping_mul(0x846C_A68B);
|
||||
h ^= h >> 16;
|
||||
f64::from(h & 0x00FF_FFFF) / f64::from(0x0100_0000u32)
|
||||
}
|
||||
|
||||
/// WarcraftXL's three-sine band-limited wander in `[-1, 1]`.
|
||||
fn wander(seed: u32, lane: i32, period: f32, seconds: f64) -> f32 {
|
||||
let period = f64::from(period.max(0.05));
|
||||
RATIOS
|
||||
.into_iter()
|
||||
.zip(WEIGHTS)
|
||||
.enumerate()
|
||||
.map(|(i, (ratio, weight))| {
|
||||
let phase = seeded_unit(seed, lane * 97 + i as i32) * TAU;
|
||||
let turns = seconds / (period * ratio);
|
||||
weight * (turns.fract() * TAU + phase).sin()
|
||||
})
|
||||
.sum::<f64>() as f32
|
||||
}
|
||||
|
||||
/// Resolves the pure wind field. `storm` is 0 for clear weather and 1 for a full storm.
|
||||
pub fn sample(profile: WindProfile, seconds: f64, storm: f32) -> WindSample {
|
||||
let raw = wander(profile.seed, 0, profile.gust_period, seconds);
|
||||
let gust = ((raw + 1.0) * 0.5).powf(1.7);
|
||||
let gust_gain = 1.0 + profile.gust * (gust - 0.5) * 2.0;
|
||||
let weather_gain = 1.0 + storm.clamp(0.0, 1.0) * 1.1 * profile.weather_coupling;
|
||||
let speed = (profile.base_speed * gust_gain * weather_gain).max(0.0);
|
||||
let heading_deg = profile.heading_deg
|
||||
+ profile.veer_deg * wander(profile.seed, 1, profile.veer_period, seconds);
|
||||
let heading = heading_deg.to_radians();
|
||||
WindSample {
|
||||
dir: Vec2::new(heading.cos(), heading.sin()),
|
||||
speed,
|
||||
heading_deg,
|
||||
gust,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(
|
||||
time: Res<Time>,
|
||||
weather: Res<WeatherState>,
|
||||
quality: Res<FoliageWind>,
|
||||
mut wind: ResMut<WindField>,
|
||||
mut frame: ResMut<MonkeyFrame>,
|
||||
) {
|
||||
let seconds = time.elapsed_secs_f64();
|
||||
wind.sample = sample(wind.profile, seconds, storm_blend(weather.sky_density));
|
||||
|
||||
frame.wind_dir = wind.sample.dir.to_array();
|
||||
frame.wind_speed = wind.sample.speed;
|
||||
frame.wind_gust = wind.sample.gust;
|
||||
frame.wind_time_s = seconds 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 };
|
||||
}
|
||||
|
||||
/// Installs the shared field after this frame's weather ramp has resolved.
|
||||
pub struct WindPlugin;
|
||||
|
||||
impl Plugin for WindPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<WindField>()
|
||||
.init_resource::<FoliageWind>()
|
||||
.add_systems(Update, update_wind.after(WeatherTick));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_are_the_warcraft_xl_profile() {
|
||||
let p = WindProfile::default();
|
||||
assert_eq!(p.base_speed, 9.0);
|
||||
assert_eq!(p.heading_deg, 45.0);
|
||||
assert_eq!(p.gust, 0.55);
|
||||
assert_eq!(p.gust_period, 17.0);
|
||||
assert_eq!(p.veer_deg, 22.0);
|
||||
assert_eq!(p.veer_period, 53.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_is_stateless_and_weather_only_scales_speed() {
|
||||
let p = WindProfile::default();
|
||||
let clear = sample(p, 123.456, 0.0);
|
||||
assert_eq!(clear, sample(p, 123.456, 0.0));
|
||||
let storm = sample(p, 123.456, 1.0);
|
||||
assert_eq!(clear.dir, storm.dir);
|
||||
assert_eq!(clear.gust, storm.gust);
|
||||
assert!((storm.speed / clear.speed - 2.1).abs() < 1.0e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direction_is_unit_length_and_gust_is_bounded() {
|
||||
let p = WindProfile::default();
|
||||
for t in [0.0, 1.0, 17.0, 53.0, 86_400.0] {
|
||||
let s = sample(p, t, 0.0);
|
||||
assert!((s.dir.length() - 1.0).abs() < 1.0e-5);
|
||||
assert!((0.0..=1.0).contains(&s.gust));
|
||||
assert!(s.speed >= 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,8 @@ impl PluginGroup for WorldPlugins {
|
||||
.add(crate::clouds::CloudsPlugin)
|
||||
// Weather (`SMSG_WEATHER`): the storm light blend and precipitation.
|
||||
.add(crate::weather::WeatherPlugin)
|
||||
// MONKEY (wind): one weather-fed field shared by foliage and future water/cloud lanes.
|
||||
.add(crate::wind::WindPlugin)
|
||||
// The sun disc and glow halo (`CSky::Render`).
|
||||
.add(crate::sun::SunPlugin)
|
||||
// The interior classifier: M2 entities in a WMO room lit off its baked floor colour.
|
||||
|
||||
Reference in New Issue
Block a user