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:
co-authored by
GPT-5.6 Sol (Codex)
parent
7116e402b9
commit
d85c0718c1
+5
-2
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user