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)
This commit is contained in:
co-authored by
GPT-5.6 Sol (Codex)
parent
d85c0718c1
commit
0fdc8e1ad4
@@ -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 <Data> wmolights <wmo> [--verts <group>]`, `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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -59,3 +59,36 @@ fn grass_offset(
|
||||
}
|
||||
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.wind1.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.wind0.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.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<f32>(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<f32>(xz.x, 0.0, xz.y);
|
||||
}
|
||||
|
||||
@@ -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<f32>(p_cam + view.world_position, 1.0);
|
||||
let view_rot = mat3x3<f32>(
|
||||
view.view_from_world[0].xyz,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<f32>(world, 1.0);
|
||||
let view_rot = mat3x3<f32>(
|
||||
view.view_from_world[0].xyz,
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -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<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 {
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user