From d85c0718c14bf297cb315c37677eee11bc118db3 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 05:37:52 +0100
Subject: [PATCH] MONKEY (wind): animate grass and unit parting
Author bend weight from source-mesh height in clutter UV1, preserve texture coordinates and cutout alpha, and apply WarcraftXL's two-wave gust, lean, phase, variance, and distance fade in the model vertex path. Fill the player plus seven nearest unit benders without per-frame allocation.
Co-Authored-By: GPT-5.6 Sol (Codex)
---
THIRD-PARTY.md | 7 ++-
crates/benilla-assets/src/materials.rs | 2 +
.../benilla-assets/src/shaders/wind_hook.wgsl | 61 +++++++++++++++++++
.../benilla-assets/src/shaders/wow_model.wgsl | 16 ++++-
crates/benilla-world/src/clutter.rs | 24 ++++++++
crates/benilla-world/src/wind/mod.rs | 54 +++++++++++++++-
6 files changed, 160 insertions(+), 4 deletions(-)
create mode 100644 crates/benilla-assets/src/shaders/wind_hook.wgsl
diff --git a/THIRD-PARTY.md b/THIRD-PARTY.md
index e3d23475..0505550b 100644
--- a/THIRD-PARTY.md
+++ b/THIRD-PARTY.md
@@ -30,9 +30,12 @@ where they stand. Do not remove either.
| `crates/benilla-assets/src/shaders/enhanced_water.wgsl` (HIGH reflections) | `wxl-experimental-water` | `shaders/Surface.ps.hlsl` | Screen-space reflection of the scene copy along an almost-planar normal, masked by the screen edge and by rays turning back toward the eye (principle; ours is a bisected march against the scene depth, not a single probe) |
| `crates/benilla-assets/src/shaders/enhanced_water.wgsl` (beach surf, `on_bed`) | `wxl-experimental-water` | `sea/Shore.hpp`, `shaders/Shore.hlsli` | Shore surf limited by the terrain column, so objects standing in the water never foam (principle) |
| `crates/benilla-world/src/water_fx/bob.rs` | `wxl-experimental-water` | `world/Ride.cpp` | Visual-only wave riding for swimmers; the authoritative position never moves (principle, implemented independently) |
+| `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): Gerstner trains, breaker index and crest-fold foam from
-`sea/Spectrum.*`, `sea/Shore.hpp`, `shaders/Wave.hlsli`; grass wind from `wxl-experimental-wind`
-(`field/Wind.*`, `grass/GrassWind.*`); colour grading from `wxl-retail-grading`
+`sea/Spectrum.*`, `sea/Shore.hpp`, `shaders/Wave.hlsli`; colour grading from `wxl-retail-grading`
(`Grading.*`, `shaders/Grading.ps.hlsl`); cloud sheets from `wxl-retail-clouds` (`Clouds.*`).
The lane that ports one adds its row here.
diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs
index a2254aa8..5050cb5e 100644
--- a/crates/benilla-assets/src/materials.rs
+++ b/crates/benilla-assets/src/materials.rs
@@ -34,6 +34,8 @@ pub fn register_shaders(app: &mut App) {
load_shader_library!(app, "shaders/monkey_frame.wgsl");
// MONKEY (p0 fog hook): `benilla::fog_hook`, the one distance-fog law all seven receivers call.
load_shader_library!(app, "shaders/fog_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");
}
diff --git a/crates/benilla-assets/src/shaders/wind_hook.wgsl b/crates/benilla-assets/src/shaders/wind_hook.wgsl
new file mode 100644
index 00000000..762cceab
--- /dev/null
+++ b/crates/benilla-assets/src/shaders/wind_hook.wgsl
@@ -0,0 +1,61 @@
+// 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) -> vec2 {
+ // WarcraftXL's secondary wave is 35 degrees off the primary.
+ return vec2(
+ 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,
+ bend_height: f32,
+ tuft_phase: f32,
+ camera: vec3,
+ frame: MonkeyFrame,
+) -> vec3 {
+ let anchored = clamp((bend_height - 0.30) / 0.70, 0.0, 1.0);
+ let weight = anchored * anchored;
+ if (weight <= 0.0 || frame.wind1.z <= 0.0) {
+ return vec3(0.0);
+ }
+
+ let dir1 = normalize(frame.wind0.xy + vec2(1.0e-6, 0.0));
+ let dir2 = wind_cross_dir(dir1);
+ let k1 = WIND_TAU / 18.0;
+ let k2 = WIND_TAU / 6.5;
+ let travel = frame.wind0.z * 0.33;
+ let phase1 = k1 * dot(dir1, world.xz) - k1 * travel * frame.wind1.x + tuft_phase;
+ let phase2 = k2 * dot(dir2, world.xz) - k2 * travel * 1.37 * frame.wind1.x + tuft_phase * 1.7;
+ let gust = 1.0 + 0.5 * (frame.wind0.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 = (
+ dir1 * 0.060 * (sin(phase1) + 0.35)
+ + dir2 * 0.020 * sin(phase2)
+ ) * gust * variance * weight * distance_fade * frame.wind1.y * frame.wind1.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.wind1.z;
+ }
+ }
+ 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 c7c9dcb4..5a0e7dea 100644
--- a/crates/benilla-assets/src/shaders/wow_model.wgsl
+++ b/crates/benilla-assets/src/shaders/wow_model.wgsl
@@ -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,
@@ -24,6 +26,8 @@
#import benilla::monkey_frame
// MONKEY (p0 fog hook): the one distance-fog law every receiver calls.
#import benilla::fog_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.
@@ -1075,8 +1079,18 @@ 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(vertex.position, 1.0)).xyz
+ var p_cam = (frame_from_local * vec4(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
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/clutter.rs b/crates/benilla-world/src/clutter.rs
index b9c584aa..da9cd94d 100644
--- a/crates/benilla-world/src/clutter.rs
+++ b/crates/benilla-world/src/clutter.rs
@@ -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()
diff --git a/crates/benilla-world/src/wind/mod.rs b/crates/benilla-world/src/wind/mod.rs
index b84a8b1f..6b848969 100644
--- a/crates/benilla-world/src/wind/mod.rs
+++ b/crates/benilla-world/src/wind/mod.rs
@@ -9,6 +9,7 @@ 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];
@@ -151,6 +152,57 @@ fn update_wind(
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,
+ viewer: Res,
+ viewer_unit: Query<&Transform, (With, With)>,
+ units: Query<(&Transform, &WorldUnit), Without>,
+ mut frame: ResMut,
+) {
+ 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;
@@ -158,7 +210,7 @@ impl Plugin for WindPlugin {
fn build(&self, app: &mut App) {
app.init_resource::()
.init_resource::()
- .add_systems(Update, update_wind.after(WeatherTick));
+ .add_systems(Update, (update_wind, update_benders).after(WeatherTick));
}
}