From 0fdc8e1ad4f859cd60fb8806fab83b84252b3308 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 05:38:55 +0100 Subject: [PATCH] MONKEY (wind): sway classified tree foliage Classify static alpha-tested tree and bush leaf batches, log accepted model paths, and apply gentle anchor-phased crown sway and flutter in both retained and fade-twin render paths. Keep animated doodads and tree shadows unchanged, and add wind capture instruments. Co-Authored-By: GPT-5.6 Sol (Codex) --- LIGHTING.md | 6 ++ crates/benilla-app/src/capture/fixtures.rs | 59 ++++++++++++ crates/benilla-app/src/capture/mod.rs | 3 +- crates/benilla-app/src/capture/scenarios.rs | 30 +++++++ crates/benilla-app/src/lib.rs | 13 +-- .../benilla-assets/src/shaders/wind_hook.wgsl | 33 +++++++ .../benilla-assets/src/shaders/wow_model.wgsl | 9 ++ crates/benilla-world/src/mesh_tag.rs | 25 ++++++ .../benilla-world/src/shaders/static_gx.wgsl | 15 +++- crates/benilla-world/src/static_gx/bake.rs | 7 +- crates/benilla-world/src/static_gx/cull.rs | 6 +- crates/benilla-world/src/static_gx/mod.rs | 89 +++++++++++++++++++ 12 files changed, 283 insertions(+), 12 deletions(-) diff --git a/LIGHTING.md b/LIGHTING.md index bb879614..02b9b775 100644 --- a/LIGHTING.md +++ b/LIGHTING.md @@ -25,6 +25,7 @@ behind each constant. | Ground-effect spells | Flamestrike, Rain of Fire, Consecration, Flare and fire traps light the ground for their duration; frost and nature areas stay dark | `spellLightGain` | | Volumetric fog | near-field haze that converges on the zone fog colour (clear within 10 yd, full by 150 yd; mistier at dawn and in bad weather, faint indoors) and sun/moon light shafts through gaps, sampled from the shadow map; own fullscreen pass after the main pass (`benilla-app/src/volumetric_fog.rs`) | Advanced Graphics → Volumetric Fog (Off/Low/High), cvar `volumetricFog`, env `WOW_VOLFOG=0\|1\|2` | | 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` | +| 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 / @@ -52,6 +53,9 @@ is its own module, documented in `WATER.md`. `character_shadow.rs`, `world_shadow.rs`, `blob_shadow.rs`, `entities/carried_light.rs`, `entities/spell_fx/lifecycle.rs`, `dynamic_interior.rs`, `debug_panel/lighting_controls.rs`, and the cvars in `cvars.rs` / `video.rs`. +- **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 wmolights [--verts ]`, `wmolamps`, `m2firescan` print the inputs the system works from (groups, batch classes, portals, claims, flame emitters). @@ -80,6 +84,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 diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index 595a64f2..756b8968 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -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, + progress: Res, + mut seeded: Local, +) { + 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 diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index 51e87b91..226cc85d 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -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 diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index d970a9fd..c0290f5a 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -1379,6 +1379,36 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ minute: 0, 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, + }, ]; /// The owner's reported vantage for the lava-glow report: `(-7048.8, -1000.6, 242.0)` facing diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index cece103d..8b9ea057 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -311,12 +311,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 diff --git a/crates/benilla-assets/src/shaders/wind_hook.wgsl b/crates/benilla-assets/src/shaders/wind_hook.wgsl index 762cceab..6347f55a 100644 --- a/crates/benilla-assets/src/shaders/wind_hook.wgsl +++ b/crates/benilla-assets/src/shaders/wind_hook.wgsl @@ -59,3 +59,36 @@ fn grass_offset( } return vec3(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, + anchor: vec3, + camera: vec3, + frame: MonkeyFrame, +) -> vec3 { + if (frame.wind1.w <= 0.0) { + return vec3(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.wind0.xy + vec2(1.0e-6, 0.0)); + let cross = vec2(-dir.y, dir.x); + let phase = fract(sin(dot(anchor.xz, vec2(0.173, 0.317))) * 43758.5453) * WIND_TAU; + let travel = frame.wind0.z * 0.08 * frame.wind1.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.wind1.x * 2.3 + dot(world.xz, vec2(1.7, 2.1)) + phase * 2.0); + let gust = 0.75 + frame.wind0.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.wind1.y * frame.wind1.w; + return vec3(xz.x, 0.0, xz.y); +} diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl index 5a0e7dea..7743445e 100644 --- a/crates/benilla-assets/src/shaders/wow_model.wgsl +++ b/crates/benilla-assets/src/shaders/wow_model.wgsl @@ -1091,6 +1091,15 @@ fn vertex(vertex: WowVertex) -> WowVsOut { ); } #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(p_cam + view.world_position, 1.0); let view_rot = mat3x3( view.view_from_world[0].xyz, diff --git a/crates/benilla-world/src/mesh_tag.rs b/crates/benilla-world/src/mesh_tag.rs index d6ddc327..85339128 100644 --- a/crates/benilla-world/src/mesh_tag.rs +++ b/crates/benilla-world/src/mesh_tag.rs @@ -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); diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl index a626e709..7914e132 100644 --- a/crates/benilla-world/src/shaders/static_gx.wgsl +++ b/crates/benilla-world/src/shaders/static_gx.wgsl @@ -14,6 +14,8 @@ #import benilla::monkey_frame // MONKEY (p0 fog hook): the one distance-fog law every receiver calls. #import benilla::fog_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). @@ -103,6 +105,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, @@ -1282,8 +1286,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(world, 1.0); let view_rot = mat3x3( view.view_from_world[0].xyz, diff --git a/crates/benilla-world/src/static_gx/bake.rs b/crates/benilla-world/src/static_gx/bake.rs index d4fe063a..e7df0540 100644 --- a/crates/benilla-world/src/static_gx/bake.rs +++ b/crates/benilla-world/src/static_gx/bake.rs @@ -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) -> 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| { diff --git a/crates/benilla-world/src/static_gx/cull.rs b/crates/benilla-world/src/static_gx/cull.rs index f1adab2c..8db109b1 100644 --- a/crates/benilla-world/src/static_gx/cull.rs +++ b/crates/benilla-world/src/static_gx/cull.rs @@ -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, diff --git a/crates/benilla-world/src/static_gx/mod.rs b/crates/benilla-world/src/static_gx/mod.rs index 22858cd9..bde16956 100644 --- a/crates/benilla-world/src/static_gx/mod.rs +++ b/crates/benilla-world/src/static_gx/mod.rs @@ -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, /// `Some(uid)` on a fader item: the placement it exiles with. fader: Option, + /// 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, blend_mode: ModelBlend, geometry: Arc, + /// 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, @@ -478,6 +484,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, @@ -556,6 +566,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. @@ -591,6 +602,7 @@ impl StaticGx { wmo, prop, fader: fader_uid, + foliage_wind, }); if !entry.dirty { entry.dirty_since = self.frame; @@ -688,6 +700,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>> = 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 { @@ -831,6 +881,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();