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)
This commit is contained in:
pkuzic
2026-09-25 08:06:56 +01:00
co-authored by GPT-5.6 Sol (Codex)
parent 7116e402b9
commit d85c0718c1
6 changed files with 160 additions and 4 deletions
+5 -2
View File
@@ -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.
+2
View File
@@ -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");
}
@@ -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<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.wind1.z <= 0.0) {
return vec3<f32>(0.0);
}
let dir1 = normalize(frame.wind0.xy + vec2<f32>(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<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,
@@ -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<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
out.world_position = vec4<f32>(p_cam + view.world_position, 1.0);
let view_rot = mat3x3<f32>(
view.view_from_world[0].xyz,
+24
View File
@@ -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()
+53 -1
View File
@@ -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<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;
@@ -158,7 +210,7 @@ impl Plugin for WindPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<WindField>()
.init_resource::<FoliageWind>()
.add_systems(Update, update_wind.after(WeatherTick));
.add_systems(Update, (update_wind, update_benders).after(WeatherTick));
}
}