Merge gfx/wind into integration

# Conflicts:
#	LIGHTING.md
#	THIRD-PARTY.md
#	crates/benilla-app/src/cvars.rs
#	crates/benilla-assets/src/materials.rs
#	crates/benilla-assets/src/shaders/wow_model.wgsl
#	crates/benilla-world/src/shaders/static_gx.wgsl
This commit is contained in:
pkuzic
2026-09-25 11:49:31 +01:00
22 changed files with 741 additions and 31 deletions
+6
View File
@@ -29,6 +29,7 @@ behind each constant.
| Sky dithering | a faint screen-space dither in the FFXGlow combine so smooth sky and fog gradients do not band (MONKEY p0; was env-only) | Advanced Graphics → Sky Dithering, cvar `skyDither` 0/1 (default 0, Graphics preset High = 1), env `WOW_DITHER=1` still forces it on; bridge in `benilla-app/src/monkey_gfx.rs` |
| HDR emission and bloom | lit WMO windows, additive models/particles and magma can exceed display white; a soft-capped fullscreen halo is added before the faithful FFX clamp | Advanced Graphics → Bloom (Off/Low/High), cvar `bloom`; Off is the unchanged reference image, suggested High preset value 2 |
| 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` |
| 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/` |
| 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 /
@@ -61,6 +62,9 @@ is its own module, documented in `WATER.md`.
- **Census**: `cargo test -p benilla-world --lib lighting::daylight::census -- --ignored --nocapture`
(`WOW_CENSUS_WMO`, `WOW_CENSUS_PLACE=map,tx,ty,uid`) prints, per interior room of a city WMO,
whether a daylight fixture, a bleed fixture or neither reaches it, and the sky-room count.
- **Wind**: `wind/mod.rs` resolves the WXL-derived shared field and nearest-unit benders;
`clutter.rs` authors blade weights/phases; `static_gx` classifies and marks alpha-tested leaf
batches. `wind_hook.wgsl` owns the displacement shared by clutter, retained trees and fade twins.
- **Tools**: `benilla-extract <Data> wmolights <wmo> [--verts <group>]`, `wmolamps`, `m2firescan`
print the inputs the system works from (groups, batch classes, portals, claims, flame emitters).
@@ -89,6 +93,8 @@ is its own module, documented in `WATER.md`.
(`benilla-world/src/lighting/monkey_frame.rs`); `global_light::pack_monkey_frame` packs it after
the point table and fills `time_of_day` / `night` itself. All zero = no visual change.
- World lights use upstream's `WorldPointLight`, never Bevy's `PointLight`.
- Foliage wind is vertex-only. Tree and leaf shadow meshes stay static; the small mismatch is the
accepted first-stage cost/complexity tradeoff.
- WGSL only fails at pipeline creation, so a shader edit is verified by running a world scene, not
by `cargo check`.
- Exterior point lights are selected per draw unit (12 slots, ranked against the chunk's box); the
+6 -2
View File
@@ -37,6 +37,10 @@ where they stand. Do not remove either.
| `crates/benilla-world/src/shaders/cloud.wgsl` | `wxl-retail-clouds` | `Clouds.cpp` | The 3-tap march toward the sun (self-shadow, ambient floor) and the one-tap silver lining |
| `crates/benilla-world/src/clouds/layer.rs` (`update_cloud_fx`) | `wxl-retail-clouds` | `Clouds.cpp` | March direction = the sun projected on the sheet, strength folded by its flatness (noon clouds evenly lit) |
Planned (not yet in the tree): breaker index from `sea/Shore.hpp`, `shaders/Wave.hlsli`; grass wind from `wxl-experimental-wind`
(`field/Wind.*`, `grass/GrassWind.*`).
| `crates/benilla-world/src/wind/mod.rs` | `wxl-experimental-wind` | `field/Wind.hpp`, `field/Wind.cpp` | Stateless three-sine gust and veer field, default profile, and weather gain (ported) |
| `crates/benilla-assets/src/shaders/wind_hook.wgsl` | `wxl-experimental-wind` | `grass/GrassWind.hpp`, `grass/GrassWind.cpp` | Two-wave grass sway, gust response, lean, blade phase/variance, distance fade and radial parting (ported; extended to eight benders) |
| `crates/benilla-assets/src/shaders/wow_model.wgsl` (MONKEY wind hook) | `wxl-experimental-wind` | `grass/GrassWind.cpp` | Grass vertex displacement call seam (technique) |
| `crates/benilla-world/src/clutter.rs` (MONKEY wind attributes) | `wxl-experimental-wind` | `grass/GrassWind.cpp` | Per-blade bend weight and per-tuft phase inputs (technique; height replaces WXL's unverified texture-V weight) |
Planned (not yet in the tree): breaker index from `sea/Shore.hpp`, `shaders/Wave.hlsli`.
The lane that ports one adds its row here.
+38 -1
View File
@@ -528,12 +528,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 "
@@ -644,6 +648,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 = {
@@ -685,6 +694,10 @@
interiorDarkness = "Темнота помещений",
-- MONKEY (p0 skyDither)
skyDither = "Дизеринг неба",
-- MONKEY (wind)
foliageWind = "Ветер листвы",
windGrass = "Трава",
windTrees = "Трава + деревья",
yards = "%d ярд.",
tips = {
LIGHTING_QUALITY =
@@ -811,6 +824,11 @@
"Добавляет едва заметный шум в итоговое изображение, чтобы плавные переходы цвета "
.. "неба и тумана не распадались на полосы. Выключено — вид оригинального клиента. "
.. "Почти ничего не стоит.",
-- MONKEY (wind)
FOLIAGE_WIND =
"Трава гнётся двумя бегущими волнами ветра и расступается вокруг персонажей. "
.. "Трава + деревья также добавляет мягкое качание листвы деревьев и кустов.\n\n"
.. "Выключено не смещает вершины и восстанавливает исходный вид. Тени деревьев остаются неподвижными.",
},
},
}
@@ -877,6 +895,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 = {
@@ -2985,6 +3005,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
@@ -29,6 +29,65 @@ const CHEST_GUID: u64 = (0xF110u64 << 48) | 0x744;
/// face, `rear` the tail — the same body, its lit and unlit sides.
const SUBJECT_YAW: f32 = 2.36;
/// MONKEY (wind): stand one real player in the grass-parting world capture without loading the
/// player UI. `UiFixture::Subject` intentionally opts the whole HUD in; parting needs the live
/// entity path but a pristine world framebuffer. `look` is the scenario's documented feet point.
pub(super) fn seed_wind_player(
mut commands: Commands,
ctx: Res<CaptureCtx>,
progress: Res<WorldLoadProgress>,
mut seeded: Local<bool>,
) {
let Some(scenario) = ctx.scenario else {
return;
};
if *seeded
|| scenario.name != "wind-player-parting"
|| !(progress.total > 0 && progress.ready == progress.total)
{
return;
}
*seeded = true;
use benilla_protocol::messages::ObjectFields;
commands.spawn((
crate::net::Guid(0x51),
crate::net::NetEntity {
kind: benilla_protocol::EntityKind::Player,
// HumanMale.m2; the normal entity visual path supplies the body.
display_id: Some(49),
scale: 1.0,
},
crate::net::ObjectStore(ObjectFields::from_pairs(&[
(22, 100), // UNIT_FIELD_HEALTH
(28, 100), // UNIT_FIELD_MAXHEALTH
(34, 12), // UNIT_FIELD_LEVEL
(35, 1), // UNIT_FIELD_FACTIONTEMPLATE
(36, 1 | 1 << 8), // UNIT_FIELD_BYTES_0: human warrior
])),
// A player visual waits for its equipment resolution handshake before attaching. This
// fixture is intentionally naked, so its final resolved set is known immediately.
crate::entities::Equipment {
settled: true,
..default()
},
// Mark this visible remote body as the capture's viewer. The real client publishes the
// local body through `Viewer::at`; this server-less instrument has no active Player.
benilla_world::world_unit::ViewerUnit,
benilla_world::world_unit::WorldUnit {
wades: true,
scale: 1.0,
height: crate::entities::CollisionHeight::default().0,
bound: None,
},
Transform {
translation: wow_to_bevy(scenario.look),
rotation: Quat::from_rotation_y(SUBJECT_YAW),
..default()
},
Visibility::default(),
));
}
/// Seed the fixture window's state resources once, right after the scene goes resident — the real
/// feeds then push it into the VM during the settle window exactly as live wire data would. Item
/// icons resolve through the offline `ItemDisplayCatalog` (display ids chosen from entries that
+2 -1
View File
@@ -119,7 +119,7 @@ mod probes;
mod scenarios;
use crate::run_mode::CaptureMode;
pub(crate) use depth_probe::DepthProbePlugin;
use fixtures::seed_ui_fixture;
use fixtures::{seed_ui_fixture, seed_wind_player};
pub(crate) use live_shot::LiveShotPlugin;
pub(crate) use phase_probe::PhaseProbePlugin;
pub(crate) use pick_probe::PickProbePlugin;
@@ -904,6 +904,7 @@ impl Plugin for CapturePlugin {
bailed: false,
})
.add_systems(Update, pin_scene.in_set(WorldStage::Present))
.add_systems(Update, seed_wind_player)
// Before the UnitFeed pass: the seed stands in for wire data that in live play
// filled the app caches on EARLIER frames, so the same frame's feeds (item-template
// / player-req pushes, then the merchant paint) must all see it. Unordered, the
@@ -1562,6 +1562,36 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 720,
ui: None,
},
// MONKEY wind: keep these at the END of the table. They are the three visual instruments for
// W1: authored grass weight/lean, static-tree classification/sway, and the viewer bender.
Scenario {
name: "wind-grass-elwynn",
map: Some(MAP_AZEROTH),
// Inside the grassy hill that the earlier long shot put beyond clutter's 70 yd horizon.
eye: [-9460.0, 45.0, 60.5],
look: [-9442.0, 27.0, 57.5],
minute: 720,
ui: None,
},
Scenario {
name: "wind-forest-elwynn",
map: Some(MAP_AZEROTH),
// East of Goldshire looking south-west across the lake and Elwynn forest.
eye: [-9380.0, -30.0, 80.0],
look: [-9600.0, -200.0, 62.0],
minute: 720,
ui: None,
},
Scenario {
name: "wind-player-parting",
map: Some(MAP_AZEROTH),
eye: [-9472.0, 57.0, 60.5],
look: [-9460.0, 45.0, 57.5],
minute: 720,
// The capture plugin stands a real player at `look` without opting into the HUD: this is
// a world-image instrument, and UI script errors must not cover the grass it measures.
ui: None,
},
];
/// MONKEY (p0 baseline): the Elwynn programme view, above the canopy east of Goldshire looking
+21 -6
View File
@@ -938,6 +938,12 @@ pub(crate) const REGISTERED: &[Registered] = &[
"0",
"benilla's own: sky quality, 0 Classic / 1 Enhanced (smooth gradient, sun glow, stars) / \n 2 High (+ detailed sun-lit clouds)",
),
// 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",
@@ -2041,6 +2047,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
@@ -2068,6 +2076,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
@@ -2098,6 +2108,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:
@@ -2129,6 +2141,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"),
],
),
];
@@ -3010,8 +3024,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 — 36 rows, 36 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 — 37
// VideoConfig rows, 37 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(), 37, "the lighting lane welds 37 distinct rows");
@@ -3031,10 +3046,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);
+7 -6
View File
@@ -315,12 +315,13 @@ pub fn run(build: BuildId) -> AppExit {
// never fights the director's screen (decision 0703; `WOW_BG` overrides). See `bgwin`.
let background = benilla_world::bgwin::background_run();
if capturing {
// Ground clutter scatters with per-run randomness, so disable it for byte-stable baselines
// — clutter isn't what the lighting rework validates, and the regression diff must not be
// masked by grass wobble. Set before plugins build so `ClutterConfig::from_env` reads it.
// It is not the only source of per-run drift, though it was long documented as such: the
// other is the frame clock itself, frozen in `capture` (decision 0723).
std::env::set_var("WOW_CLUTTER_DENSITY", "0");
// Ordinary baselines omit clutter, but a feature-specific capture may explicitly ask for
// it (MONKEY wind does). Scatter is now chunk-seeded and deterministic; the default stays
// zero so unrelated lighting diffs are not masked by grass. Set before plugins build so
// `ClutterConfig::default` reads it.
if std::env::var_os("WOW_CLUTTER_DENSITY").is_none() {
std::env::set_var("WOW_CLUTTER_DENSITY", "0");
}
// Third source of per-run drift, and the one the lighting matrix (decision 0746) hit: the
// anim-LOD park/wake gate (`creature_anim::lod::gate_rig_animation`). Whether a rig is
// parked, and which pose it wakes into, depends on when its model finished loading relative
+31 -7
View File
@@ -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);
}
}
+2
View File
@@ -36,6 +36,8 @@ pub fn register_shaders(app: &mut App) {
load_shader_library!(app, "shaders/fog_hook.wgsl");
// MONKEY (post): shared opt-in HDR multipliers; tier 0 is an exact identity.
load_shader_library!(app, "shaders/emissive_hook.wgsl");
// MONKEY (wind): `benilla::wind_hook`, shared vertex displacement for grass and foliage.
load_shader_library!(app, "shaders/wind_hook.wgsl");
// MONKEY (enhanced water): the optional water module, `benilla::enhanced_water` (WATER.md).
load_shader_library!(app, "shaders/enhanced_water.wgsl");
}
@@ -8,8 +8,8 @@
//
// Coordinates are Bevy world space (Y up, 1 unit = 1 yd), the space of the receivers'
// `world_position`: WoW `(x, y, z)` is Bevy `(-y, z, -x)` (`benilla_assets::coords::wow_to_bevy`).
// Member names carry no trailing digit: naga_oil refuses a composable-module identifier that
// naga's namer would rewrite (`fog0` -> `fog0_`), so the rows are `fog_a..fog_d`, `wind_a/b`, `wet_a`.
// Member names carry no trailing digit: naga_oil refuses composable-module identifiers that its
// namer would rewrite, so the rows are `fog_a..fog_d`, `wind_a/b`, `wet_a`.
struct MonkeyFrame {
fog_a: vec4<f32>, // height_fog_density, height_fog_height (world Y, yd), height_fog_falloff, curve_blend
fog_b: vec4<f32>, // sun_fog_rgb (gamma 0..1), sun_fog_strength
@@ -0,0 +1,96 @@
// Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module
// wxl-experimental-wind, grass/GrassWind.hpp and grass/GrassWind.cpp.
#define_import_path benilla::wind_hook
#import benilla::monkey_frame::MonkeyFrame
const WIND_TAU: f32 = 6.28318530718;
fn wind_cross_dir(dir: vec2<f32>) -> vec2<f32> {
// WarcraftXL's secondary wave is 35 degrees off the primary.
return vec2<f32>(
dir.x * 0.819152 - dir.y * 0.573576,
dir.x * 0.573576 + dir.y * 0.819152,
);
}
// MONKEY (wind): WarcraftXL's two travelling waves, gust response, downwind lean, per-tuft
// phase/variance and distance fade, plus the same radial parting extended from the player to the
// eight MonkeyFrame benders. `bend_height` is authored from local mesh height, never texture V.
fn grass_offset(
world: vec3<f32>,
bend_height: f32,
tuft_phase: f32,
camera: vec3<f32>,
frame: MonkeyFrame,
) -> vec3<f32> {
let anchored = clamp((bend_height - 0.30) / 0.70, 0.0, 1.0);
let weight = anchored * anchored;
if (weight <= 0.0 || frame.wind_b.z <= 0.0) {
return vec3<f32>(0.0);
}
let dir_primary = normalize(frame.wind_a.xy + vec2<f32>(1.0e-6, 0.0));
let dir_secondary = wind_cross_dir(dir_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;
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);
var xz = (
dir_primary * 0.060 * (sin(phase_primary) + 0.35)
+ dir_secondary * 0.020 * sin(phase_secondary)
) * gust * variance * weight * distance_fade * frame.wind_b.y * frame.wind_b.z;
let count = min(u32(frame.misc.x), 8u);
for (var i = 0u; i < 8u; i += 1u) {
if (i < count) {
let bender = frame.benders[i];
let away = world.xz - bender.xz;
let r = length(away);
let radial = away / max(r, 1.0e-3);
let edge = 1.0 - smoothstep(0.0, max(bender.w, 0.01), r);
let cone = smoothstep(0.30, 0.80, world.y - bender.y);
xz += radial * 0.50 * edge * cone * weight * frame.wind_b.z;
}
}
return vec3<f32>(xz.x, 0.0, xz.y);
}
// MONKEY (wind): our tree/bush design (WarcraftXL has no tree wind). Only leaf-card batches call
// this: a low-frequency crown sway, small leaf flutter, stable anchor phase and distance fade.
fn tree_offset(
world: vec3<f32>,
anchor: vec3<f32>,
camera: vec3<f32>,
frame: MonkeyFrame,
) -> vec3<f32> {
if (frame.wind_b.w <= 0.0) {
return vec3<f32>(0.0);
}
let h = max(world.y - anchor.y, 0.0);
// A one-yard bush still needs a crown; tree leaf cards generally begin above this range and
// therefore reach full weight, while the first 0.15 yd remains a planted base.
let crown = smoothstep(0.15, 1.5, h);
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 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);
let gust = 0.75 + frame.wind_a.w * 0.5;
let fade = 1.0 / (1.0 + distance(camera, world) * 0.006);
let xz = (
dir * (0.045 + slow * 0.055)
+ cross * side * 0.025
+ dir * flutter * 0.012
) * weight * gust * fade * frame.wind_b.y * frame.wind_b.w;
return vec3<f32>(xz.x, 0.0, xz.y);
}
@@ -8,6 +8,8 @@
// the tuft, which the reference writes onto the clutter vertex.
// Not built: a specular term (the M2 per-material shininess is only inferred) and the WMO
// per-group authored colour.
// Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module
// wxl-experimental-wind, grass/GrassWind.cpp (grass vertex displacement seam).
#import bevy_pbr::{
pbr_fragment::pbr_input_from_standard_material,
@@ -26,6 +28,8 @@
#import benilla::fog_hook
// MONKEY (post): shared tier-gated HDR emission; Off is an exact identity.
#import benilla::emissive_hook
// MONKEY (wind): WarcraftXL-derived grass motion and the matching tree fade-twin motion.
#import benilla::wind_hook
// bevy_pbr 0.18.1's `forward_io::FragmentOutput`. No depth output: a fragment depth write costs
// the pipeline early-Z, so the sky lane pins its depth in the vertex stage.
@@ -1077,8 +1081,27 @@ fn vertex(vertex: WowVertex) -> WowVsOut {
// Precision: camera-relative to clip space; `clip_from_world × p_world` cancels
// catastrophically with camera and geometry near 9 k yd. `world_position` is absolute again:
// lighting and fog need no such precision.
let p_cam = (frame_from_local * vec4<f32>(vertex.position, 1.0)).xyz
var p_cam = (frame_from_local * vec4<f32>(vertex.position, 1.0)).xyz
+ (frame_origin - view.world_position);
// MONKEY (wind): clutter UV_B is `(height-from-base, per-tuft phase)` authored by clutter.rs;
// UV_0 and vertex colour alpha retain their texture/cutout meanings.
#ifdef VERTEX_UVS_B
if (m.clutter_fade.w > 0.5) {
let world = p_cam + view.world_position;
p_cam += wind_hook::grass_offset(
world, vertex.uv_b.x, vertex.uv_b.y, view.world_position, wow_light.monkey
);
}
#endif
// MONKEY (wind): a retained tree inside its fade band renders here. StaticGx marks only its
// alpha-tested leaf batches in MeshTag bit 18; use the same anchor/field maths to avoid a pop.
let wind_tag = mesh_functions::get_tag(vertex.instance_index);
if ((wind_tag & 262144u) != 0u && m.clutter_fade.w <= 0.5) {
let world = p_cam + view.world_position;
p_cam += wind_hook::tree_offset(
world, frame_origin, view.world_position, wow_light.monkey
);
}
out.world_position = vec4<f32>(p_cam + view.world_position, 1.0);
let view_rot = mat3x3<f32>(
view.view_from_world[0].xyz,
+24
View File
@@ -1,6 +1,8 @@
//! Ground clutter (`GroundEffect*.dbc`): the terrain streamer scatters each chunk's tufts at tile
//! load, and a chunk's meshes exist only inside the 70 yd detail-doodad horizon (`[0x867958]`),
//! the reference's per-chunk `CDetailDoodadInst` build and unlink.
//! Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module
//! `wxl-experimental-wind`, `grass/GrassWind.cpp` (bend-weight/phase technique).
use std::collections::HashMap;
@@ -321,9 +323,20 @@ fn build_chunk_clutter(
continue;
}
for sub in subs.iter() {
// MONKEY (wind): source M2 Z is up. Normalize the actual tuft geometry rather than
// trusting texture V (WXL's 3.3.5 detail doodads use inverse V, but vanilla assets are
// not guaranteed to). UV_1 stays free here and carries height + per-tuft phase.
let (min_z, max_z) = sub
.positions
.iter()
.fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| {
(lo.min(p[2]), hi.max(p[2]))
});
let height_span = (max_z - min_z).max(1.0e-4);
let vcount = sub.positions.len() * placements.len();
let mut positions = Vec::with_capacity(vcount);
let mut uvs = Vec::with_capacity(vcount);
let mut wind = Vec::with_capacity(vcount);
// The ground normal under each tuft, never the blade's own M2 normal.
let mut normals = Vec::with_capacity(vcount);
let mut colors = Vec::with_capacity(vcount);
@@ -335,6 +348,7 @@ fn build_chunk_clutter(
let rot = Quat::from_rotation_y(d.yaw);
// The reference's random per-tuft scale, in [0.9, 1.1].
let inst_scale = d.scale;
let phase = tuft_phase(origin);
// One tint per tuft. The reference makes the grey the lit material (colour material
// on ambient and diffuse, `0x59cfec`), `clamp(grey × light) × texture`; the shader
// multiplies it outside the clamp, `texture × grey × clamp(light)`, so under light
@@ -343,6 +357,7 @@ fn build_chunk_clutter(
for (i, p) in sub.positions.iter().enumerate() {
positions.push((rot * (wow_to_bevy(*p) * inst_scale) + origin).to_array());
uvs.push(sub.uvs[i]);
wind.push([((p[2] - min_z) / height_span).clamp(0.0, 1.0), phase]);
normals.push(sp.ground_normal);
colors.push(tint);
}
@@ -354,6 +369,8 @@ fn build_chunk_clutter(
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
// MONKEY (wind): second UV set enables VERTEX_UVS_B only for clutter meshes.
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_1, wind);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
// The MCSH tint, under the `VERTEX_COLORS` def the shader's clutter path reads.
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors);
@@ -403,6 +420,13 @@ fn build_chunk_clutter(
out
}
/// MONKEY (wind): deterministic per-tuft phase from its world-space origin. This is an identity
/// hash, not state; rebuild order and frame timing cannot change the blade's motion.
fn tuft_phase(origin: Vec3) -> f32 {
let h = (origin.x * 0.737 + origin.z * 1.311).sin() * 43_758.547;
h.fract().abs() * std::f32::consts::TAU
}
/// Squared distance from a point to an axis-aligned box, zero inside.
fn box_distance_squared(p: Vec3, (lo, hi): (Vec3, Vec3)) -> f32 {
(lo - p).max(p - hi).max(Vec3::ZERO).length_squared()
+1
View File
@@ -160,6 +160,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;
+25
View File
@@ -90,6 +90,10 @@ const SHADE_SHIFT: u32 = 6;
/// Sits in the exterior payload's reserved bits, so [`with_shade`]/[`with_alpha`]/[`with_rig`]
/// carry it through and the shader's 8-bit shade decode never sees it.
pub(crate) const MATTE_INDOOR_BIT: u32 = 0x0000_4000;
/// MONKEY (wind): bit 18 on an exterior, non-Matte static doodad exile marks a classified
/// tree/bush leaf batch. It aliases the top lane-weight bit safely: lane weight is read only with
/// [`MATTE_INDOOR_BIT`], which these exterior fade twins never carry.
const FOLIAGE_WIND_BIT: u32 = 0x0004_0000;
/// Bits 15..=18 of the exterior payload (MONKEY, portal lane fade): the **lane weight**, a 4-bit
/// crossfade `0` (fully exterior-lit) … [`LANE_MAX`] (fully room-lit) that the shader mixes the
/// two lanes by. It rides beside [`MATTE_INDOOR_BIT`] — the bit says "this part is on the room
@@ -204,6 +208,16 @@ pub fn alpha_bits(alpha: f32) -> u32 {
}
}
/// MONKEY (wind): marks or clears the fade-twin foliage receiver without disturbing its live
/// alpha. Only exterior static doodad exiles use this payload convention.
pub(crate) fn with_foliage_wind(tag: u32, on: bool) -> u32 {
if on {
tag | FOLIAGE_WIND_BIT
} else {
tag & !FOLIAGE_WIND_BIT
}
}
/// Sets or clears [`INTERIOR_FOG_BIT`] alone: the probe slot says how a prop is lit, the
/// reference's `[0xca7f00]` only which fog triple its batch gets.
pub(crate) fn with_interior_fog(tag: u32, on: bool) -> u32 {
@@ -352,6 +366,17 @@ mod tests {
assert_eq!(shade_of(t, ext()), 10);
}
// MONKEY (wind): the fade-twin marker is orthogonal to the live fade field.
#[test]
fn foliage_marker_survives_alpha_writes_and_clears_cleanly() {
let alpha = alpha_bits(0.5);
let marked = with_foliage_wind(alpha, true);
assert_eq!(marked & FOLIAGE_WIND_BIT, FOLIAGE_WIND_BIT);
assert_eq!(marked & ALPHA_MASK, alpha);
assert_eq!(with_alpha(marked, 0.25) & FOLIAGE_WIND_BIT, FOLIAGE_WIND_BIT);
assert_eq!(with_foliage_wind(marked, false), alpha);
}
#[test]
fn probe_bits_compose_with_the_alpha_field() {
let t = probe_bits(6660);
@@ -16,6 +16,8 @@
#import benilla::fog_hook
// MONKEY (post): shared tier-gated HDR emission; Off is an exact identity.
#import benilla::emissive_hook
// MONKEY (wind): shared foliage vertex displacement; tree shadows intentionally stay static.
#import benilla::wind_hook
// Group 0 is Bevy's standard mesh-view bind group (view matrices, directional-light records and
// the shadow textures the retained pass reads).
@@ -105,6 +107,8 @@ const WORD_HAS_VC: u32 = 134217728u; // 1 << 27: the batch authors vertex colo
// INTERIOR without WMO is an interior M2 prop, the entity shader's `interior_prop =
// flags.z && !flags.x`: probe lighting, interior fog, no point lights.
const WORD_MATTE: u32 = 268435456u; // 1 << 28: ShadeSel::Matte, fixed intensity 1.0
// MONKEY (wind): alpha-tested leaf batch of a classified static tree/bush model.
const WORD_FOLIAGE_WIND: u32 = 536870912u; // 1 << 29
// MONKEY (room gate): record column `w` bits 15..=26 — this item's ROOM KEY, packed as
// `group + 1`, so **0 means the item names no room** and takes every fixture (a terrain-cell item,
@@ -1284,8 +1288,15 @@ fn vertex(v: GxVertex) -> GxVsOut {
return out;
}
// Camera-relative for f32 precision: the recentred vertex plus (cell origin - camera).
let p_cam = v.position + (cell.origin.xyz - view.world_position);
let world = v.position + cell.origin.xyz;
var p_cam = v.position + (cell.origin.xyz - view.world_position);
var world = v.position + cell.origin.xyz;
// MONKEY (wind): placement anchor is already baked per vertex. Only classified leaf-card
// batches carry the bit; animated doodads were rejected before this retained path.
if ((v.word & WORD_FOLIAGE_WIND) != 0u) {
let offset = wind_hook::tree_offset(world, v.anchor, view.world_position, wow_light.monkey);
world += offset;
p_cam += offset;
}
out.world_position = vec4<f32>(world, 1.0);
let view_rot = mat3x3<f32>(
view.view_from_world[0].xyz,
+5 -2
View File
@@ -12,8 +12,9 @@ use super::{
MAX_DIRTY_FRAMES, REBAKE_FRAMES,
};
use super::{
WORD_CLASS_INT, WORD_CLASS_TRANS, WORD_FOG_OFF, WORD_HAS_VC, WORD_INTERIOR, WORD_MATTE,
WORD_SHADE_LIT, WORD_TEXTURED, WORD_UNLIT, WORD_WINDOW, WORD_WMO, WORD_WRAP_X, WORD_WRAP_Y,
WORD_CLASS_INT, WORD_CLASS_TRANS, WORD_FOG_OFF, WORD_FOLIAGE_WIND, WORD_HAS_VC, WORD_INTERIOR,
WORD_MATTE, WORD_SHADE_LIT, WORD_TEXTURED, WORD_UNLIT, WORD_WINDOW, WORD_WMO, WORD_WRAP_X,
WORD_WRAP_Y,
};
/// Print the declined-batch census, beside the accepted count, once the counts have sat still for
@@ -208,6 +209,8 @@ fn bake_cell(items: &[GxItem], meshes: &mut Assets<Mesh>) -> render::GxCellDraw
| (u32::from(item.matte) * WORD_MATTE)
| (u32::from(item.texture.is_some()) * WORD_TEXTURED)
| (u32::from(has_vc) * WORD_HAS_VC)
// MONKEY (wind): only classified alpha-tested tree/bush leaf cards carry this bit.
| (u32::from(item.foliage_wind) * WORD_FOLIAGE_WIND)
// An interior prop is WORD_INTERIOR without WORD_WMO; a slot-less prop keeps the
// exterior law, as on the entity path.
| item.prop.as_ref().map_or(0, |p| {
+5 -1
View File
@@ -100,7 +100,11 @@ fn spawn_exile(commands: &mut Commands, f: &GxFader, alpha: f32, admitted: bool)
},
crate::model_render::EntityPathWhy("exile"),
crate::interact::PickMesh(b.geometry.clone()),
bevy::mesh::MeshTag(crate::mesh_tag::alpha_bits(alpha)),
// MONKEY (wind): bit 18 is the fade-twin leaf marker in exterior payload mode.
bevy::mesh::MeshTag(crate::mesh_tag::with_foliage_wind(
crate::mesh_tag::alpha_bits(alpha),
b.foliage_wind,
)),
crate::model_fade::DoodadFade {
radius: f.radius,
local_center: f.local_center,
+89
View File
@@ -76,6 +76,8 @@ const WORD_HAS_VC: u32 = 1 << 27;
// `ShadeSel::Matte`, fixed 1.0: a map doodad never reaches the 2.5 site (`0x69e4ad`). Not
// `WORD_SHADE_LIT`, so lifting the shader's `min(I, 1)` cap leaves this at 1.0.
const WORD_MATTE: u32 = 1 << 28;
// MONKEY (wind): classified alpha-tested tree/bush foliage; bits 30-31 remain free.
const WORD_FOLIAGE_WIND: u32 = 1 << 29;
// An interior M2 prop is WORD_INTERIOR with WORD_WMO clear, the entity shader's `interior_prop =
// flags.z && !flags.x`: SH-probe light from its record slot, interior fog, no live point lights.
@@ -173,6 +175,8 @@ struct GxItem {
prop: Option<GxItemProp>,
/// `Some(uid)` on a fader item: the placement it exiles with.
fader: Option<u32>,
/// MONKEY (wind): an alpha-tested leaf-card batch of a static tree/bush model.
foliage_wind: bool,
}
/// A WMO-prop item's referrer set (an index into [`GxCell::sets`]) and folded SH-probe slot;
@@ -211,6 +215,8 @@ struct GxFaderBatch {
blend: Handle<benilla_assets::materials::WowModelMaterial>,
blend_mode: ModelBlend,
geometry: Arc<RenderSubmesh>,
/// MONKEY (wind): mirror the retained batch's sway while this placement fades as an entity.
foliage_wind: bool,
}
/// A fader's exile state. Steady: drawn retained at fade 1. Exiled: drawn as ordinary entities,
@@ -481,6 +487,10 @@ impl StaticGx {
return false;
}
};
let foliage_wind = foliage_wind_batch(&b.object.label, b.blend, b.wmo.is_some());
if foliage_wind {
log_foliage_classification(&b.object.label);
}
let wmo_key = b.wmo.as_ref().map(|w| w.instance);
let wmo = b.wmo.map(|w| GxItemWmo {
group: w.group,
@@ -559,6 +569,7 @@ impl StaticGx {
blend: seed.blend,
blend_mode: b.blend,
geometry: b.geometry.clone(),
foliage_wind,
});
if is_new {
// A placement's later batches share its sphere: the caches move only on a new one.
@@ -594,6 +605,7 @@ impl StaticGx {
wmo,
prop,
fader: fader_uid,
foliage_wind,
});
if !entry.dirty {
entry.dirty_since = self.frame;
@@ -691,6 +703,44 @@ impl StaticGx {
}
}
/// MONKEY (wind): conservative leaf classification. Alpha test excludes trunks/rocks even when a
/// whole model path says tree; the name terms cover vanilla's tree/bush/plant families. Animated
/// doodads never reach this function because `assemble.rs` excludes them before `StaticGx::divert`.
fn foliage_wind_batch(path: &str, blend: ModelBlend, wmo_geometry: bool) -> bool {
if wmo_geometry || blend != ModelBlend::AlphaTest {
return false;
}
let p = path.to_ascii_lowercase();
// Substrings alone otherwise catch StreetLamp, tree huts, dead stumps/logs and painted tree
// facades. Those can carry alpha-tested cards, but moving the whole card is not foliage sway.
if ["street", "treehut", "stump", "log", "facade", "fallen"]
.iter()
.any(|term| p.contains(term))
{
return false;
}
[
"tree", "bush", "shrub", "foliage", "fern", "palm", "plant", "willow", "canopy", "hedge",
"thorn", "cactus", "vine", "reed", "kelp",
]
.iter()
.any(|term| p.contains(term))
}
/// Log each classified model once so false positives can be audited from an ordinary scene load.
fn log_foliage_classification(path: &str) {
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
let mut seen = SEEN
.get_or_init(|| Mutex::new(HashSet::new()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if seen.insert(path.to_string()) {
info!("MONKEY wind: classified foliage leaf batches for {path}");
}
}
pub struct StaticGxPlugin;
impl Plugin for StaticGxPlugin {
@@ -834,6 +884,45 @@ mod tests {
assert_eq!(gx.cells.len(), 1);
}
#[test]
fn foliage_classifier_requires_a_leaf_batch_and_a_plant_name() {
assert!(foliage_wind_batch(
"World\\Azeroth\\Elwynn\\PassiveDoodads\\Trees\\ElwynnTree01.m2",
ModelBlend::AlphaTest,
false,
));
assert!(foliage_wind_batch(
"World\\Generic\\PassiveDoodads\\Bush\\Bush01.mdx",
ModelBlend::AlphaTest,
false,
));
assert!(!foliage_wind_batch(
"World\\Azeroth\\Elwynn\\PassiveDoodads\\Trees\\ElwynnTree01.m2",
ModelBlend::Opaque,
false,
));
assert!(!foliage_wind_batch(
"World\\Stormwind\\StreetLamp01.m2",
ModelBlend::AlphaTest,
false,
));
assert!(!foliage_wind_batch(
"World\\Azeroth\\SwampOfSorrow\\PassiveDoodads\\TreeHuts\\LostTreeHuts03.m2",
ModelBlend::AlphaTest,
false,
));
assert!(!foliage_wind_batch(
"World\\Azeroth\\Elwynn\\PassiveDoodads\\Tree\\ElwynnLog02.m2",
ModelBlend::AlphaTest,
false,
));
assert!(!foliage_wind_batch(
"World\\Wmo\\TreeHouse.wmo",
ModelBlend::AlphaTest,
true,
));
}
#[test]
fn a_fader_divert_registers_its_placement_seed() {
let mut gx = StaticGx::default();
+253
View File
@@ -0,0 +1,253 @@
//! 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};
use crate::world_unit::{ViewerUnit, WorldUnit};
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 };
}
/// MONKEY (wind): player first, then the seven nearest streamed units in the 40-yard bubble.
/// MonkeyFrame stores Bevy world coordinates and a per-body 1.5–2 yard radius.
fn update_benders(
quality: Res<FoliageWind>,
viewer: Res<crate::view::Viewer>,
viewer_unit: Query<&Transform, (With<ViewerUnit>, With<WorldUnit>)>,
units: Query<(&Transform, &WorldUnit), Without<ViewerUnit>>,
mut frame: ResMut<MonkeyFrame>,
) {
frame.bender_count = 0;
if quality.0 == 0 {
return;
}
// Capture/world-viewer fixtures have no live Player resource, but can still publish a real
// ViewerUnit. In play `Viewer::at` remains authoritative; the entity transform is only the
// no-avatar fallback that lets the parting instrument exercise the same receiver.
let Some(player) = viewer
.at
.or_else(|| viewer_unit.single().ok().map(|t| t.translation))
else {
return;
};
frame.benders[0] = [player.x, player.y, player.z, 1.75];
frame.bender_count = 1;
// Fixed insertion list: this is a per-frame scan, so do not allocate and sort a Vec of every
// unit just to retain seven entries.
let mut nearby = [(f32::INFINITY, Vec3::ZERO, 1.5); 7];
for (transform, unit) in &units {
let at = transform.translation;
let d2 = (at.xz() - player.xz()).length_squared();
if d2 > 40.0 * 40.0 || d2 >= nearby[6].0 {
continue;
}
let mut slot = 6;
while slot > 0 && d2 < nearby[slot - 1].0 {
nearby[slot] = nearby[slot - 1];
slot -= 1;
}
nearby[slot] = (d2, at, (1.5 * unit.scale).clamp(1.5, 2.0));
}
for (slot, (_, at, radius)) in nearby
.into_iter()
.take_while(|(d2, _, _)| d2.is_finite())
.enumerate()
{
frame.benders[slot + 1] = [at.x, at.y, at.z, radius];
frame.bender_count += 1;
}
}
/// 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, update_benders).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);
}
}
}
@@ -89,6 +89,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.