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)); } }