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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user