MONKEY (water): restore WMO canal and pool rendering

Co-Authored-By: GPT-5.6 Sol (Codex)
This commit is contained in:
pkuzic
2026-09-25 03:11:31 +01:00
co-authored by GPT-5.6 Sol (Codex)
parent 129ea35f5a
commit 0489b5c168
5 changed files with 109 additions and 52 deletions
+7 -3
View File
@@ -2,7 +2,7 @@
An optional enhanced water renderer for [benilla](https://github.com/samwhosung/benilla), the Rust +
Bevy reimplementation of the 1.12.1 client. It needs no data changes: everything is derived from
what a 1.12 install already carries (MCLQ / MLIQ liquid grids and their authored depth bytes, the
what a 1.12 install already carries (MCLQ liquid depth and MLIQ opacity bytes, their liquid grids, the
`Light.dbc` water and sky rows, the opaque scene depth).
It is a **module on top of the reference water, not a replacement for it**. The reference path
@@ -12,7 +12,7 @@ player picks it, and every part of it can be switched off.
## Credits
- **Original project: [WarcraftXL](https://github.com/WarcraftXL)** — the `wxl-experimental-water`
module for the 1.12 client.
3.3.5a client extension.
- **Author: iThorgrim.**
The WarcraftXL water module is the design reference for this one, and its author has given
@@ -41,6 +41,7 @@ the technique is listed in the table below. Do not remove either.
| Quality tiers | `Classic` = the reference water, `Enhanced`, `High` (= Enhanced + scenery reflections) | Video options → Water Quality, cvar `waterQuality`, env `WOW_WATER=0\|1\|2` |
| Procedural waves | multi-band analytic waves with exact normals; the long swell moves ocean vertices | tier |
| Ocean / inland profiles | the sea and lakes/rivers have their own colour, energy and reflectivity | tier |
| WMO pools | exterior canals use a calm drifting profile with outdoor glints; interiors use their authored MOMT colour and room-fog reflection | tier |
| Depth look | light fades per channel over the real path through the water; the bed is seen through it, bent by the waves; sunlit shallows carry a caustic web | tier |
| Reflection and glints | Fresnel sky reflection from the zone's sky rows, sun path, moon path, point-light glints | tier |
| Beach surf | a travelling wave front, a lace of foam that dissolves behind it, a swash sheet on the sand | tier |
@@ -64,7 +65,8 @@ request. If it ever returns it should read the authored MCLQ flow records, not d
`materials.rs` (`LiquidExt` fields 103-106, shader registration), `lib.rs` (`WaterQuality`).
- **World** (`crates/benilla-world/src`): `liquid/scene_depth.rs` (copies the opaque depth AND colour after
the main opaque pass), `liquid/waves.rs` (the CPU mirror of the vertex swell), `liquid/surface.rs`
(per-kind material parameters), `water_fx/bob.rs` (swimmer bob), `lighting/lava_light.rs`.
(per-kind material parameters and the opt-in `WOW_WATER_PROBE` WMO classification/depth log),
`water_fx/bob.rs` (swimmer bob), `lighting/lava_light.rs`.
- **App** (`crates/benilla-app/src`): the setting in `cvars.rs` / `video.rs`, the options rows in
`assets/ui/OptionsFrame.xml`, the capture scenes `water-*` and `lava-*` in `capture/scenarios.rs`.
@@ -73,6 +75,8 @@ request. If it ever returns it should read the authored MCLQ flow records, not d
- `Classic` must stay byte-identical: capture `water-noon` with `WOW_WATER=0` and compare with the
baseline before and after any edit.
- The CPU swell (`liquid/waves.rs`) and the shader's `WATER_WAVES` table are one contract.
- MLIQ's per-vertex byte is an opacity-ramp coordinate, not water depth; enhanced WMO water uses
reconstructed scene depth for its column measurement.
- Every screen derivative in `enhanced_water()` is taken at the top of the function (WGSL
uniformity); WGSL only fails at pipeline creation, so verify by running a `water-*` scene.
- Never rotate a noise domain by a per-pixel angle at world coordinates (~1e4 yd): it shears.
@@ -26,7 +26,7 @@
struct WaterParams {
// x = quality (0 Classic, 1 Enhanced, 2 High); y = wave energy (ocean 1.0, ADT inland 0.18,
// WMO pools 0.12); z = the pinned capture time (WOW_CAPTURE_WATER_T); w = clock enable.
// WMO exterior 0.26, WMO interior 0.08); z = pinned capture time; w = clock enable.
mode: vec4<f32>,
// x = which renderer (0 ADT MCLQ, 1 WMO exterior, 2 WMO interior); y = ocean; z = fullbright;
// w = this surface may take the WMO INTERIOR fog block (upstream's kind.z).
@@ -37,26 +37,10 @@ struct WaterParams {
};
@group(#{MATERIAL_BIND_GROUP}) @binding(104) var<uniform> water: WaterParams;
// The shared global light (`lighting::global_light`), rows 0-20 + the point-light table. Mirrors
// the buffer layout the terrain/model shaders read; keep it in step with theirs.
struct WaterLight {
light_ambient: vec4<f32>, // 0
light_diffuse: vec4<f32>, // 1
light_sun: vec4<f32>, // 2 xyz = sun TRAVEL dir (to-light = -xyz)
light_spec: vec4<f32>, // 3
fog_color: vec4<f32>, // 4
fog_params: vec4<f32>, // 5
_sh: array<vec4<f32>, 6>, // 6-11
_sh_c16: vec4<f32>, // 12
water_river: array<vec4<f32>, 2>, // 13-14
water_ocean: array<vec4<f32>, 2>, // 15-16
_grade: vec4<f32>, // 17
wmo_fog_color: vec4<f32>, // 18
wmo_fog_params: vec4<f32>, // 19
point_count: vec4<f32>, // 20 live count + point-light controls
points: array<vec4<f32>, 512>, // 21+ position/range, colour/lane pairs
};
@group(#{MATERIAL_BIND_GROUP}) @binding(105) var<storage, read> water_light: WaterLight;
// MONKEY (water): the shared global light (`lighting::global_light`), rows 0-20 + the point-light
// table, is deliberately a row view of the exact terrain/model ABI. Bevy's composable-module
// writer rejects a second partial struct view because its unused members require substitution.
@group(#{MATERIAL_BIND_GROUP}) @binding(105) var<storage, read> water_light: array<vec4<f32>>;
// What the fragment stage hands over: the three varyings this module reads.
struct WaterFragment {
@@ -64,6 +48,8 @@ struct WaterFragment {
world_position: vec4<f32>,
// The authored per-vertex swatch depth (UV1.x): ocean byte/255, about 148 yd at 1.0.
depth: f32,
// MONKEY (water): an interior WMO pool's authored MOMT diffuse colour; white on other lanes.
colour: vec4<f32>,
// Upstream's per-surface room-fog lane (MeshTag bit 30), to pick the same fog block it would.
room_fog: u32,
};
@@ -74,11 +60,11 @@ struct WaterFragment {
// scenery come out of the scene copy, which the terrain and model shaders have already fogged
// (fogging them again made shallow water foggier than the dry sand beside it).
fn water_fog(world_pos: vec3<f32>, room_fog: u32) -> vec4<f32> {
var colour = water_light.fog_color;
var span = water_light.fog_params.xy;
var colour = water_light[4];
var span = water_light[5].xy;
if water.lane.w > 0.5 && room_fog != 0u {
colour = water_light.wmo_fog_color;
span = water_light.wmo_fog_params.xy;
colour = water_light[18];
span = water_light[19].xy;
}
if colour.w <= 0.5 { return vec4<f32>(colour.rgb, 1.0); }
let eye_z = -(view.view_from_world * vec4<f32>(world_pos, 1.0)).z;
@@ -171,10 +157,10 @@ fn water_waves(p: vec2<f32>, t: f32, distance: f32, footprint: f32,
let energy = clamp(water.mode.y, 0.0, 1.0);
let tempo = mix(0.4, 1.0, sqrt(energy));
let inland = water.lane.y < 0.5 || water.lane.x > 0.5;
// Inland ADT water drifts gently (a constant vector: a rigid translation, never a shear).
// WMO pools have no drift at all.
// MONKEY (water): inland ADT and exterior WMO water drift gently (a constant vector: a rigid
// translation, never a shear). True interior pools stay still.
var ripple_p = p;
if inland && water.lane.x < 0.5 {
if inland && water.lane.x < 1.5 {
ripple_p -= t * vec2<f32>(0.06, 0.025);
}
var result = vec3<f32>(0.0);
@@ -196,6 +182,12 @@ fn water_waves(p: vec2<f32>, t: f32, distance: f32, footprint: f32,
return result;
}
// MONKEY (water): seam for the later wet-weather lane. Its result is a height-gradient
// perturbation in world XZ; zero preserves today's image until that lane supplies rain data.
fn rain_ripple_normal(world_xz: vec2<f32>, time: f32) -> vec2<f32> {
return vec2<f32>(0.0);
}
fn foam_hash(p_in: vec2<f32>) -> f32 {
// MONKEY (foam noise): world coordinates here are ~1e4 yd and are scaled further before they
// arrive, so `p * 0.1031` sat near 2e3 where an f32 keeps only ~13 fraction bits - the hash
@@ -464,7 +456,11 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
// 148 yd): where the bed lies well below what the pixel sees, the pixel is an OBJECT, and an
// object gets the thin wet-edge line only. The byte is a FLOOR (1.72 per yd), so on a real beach the
// authored bed is never deeper than the scene by more than interpolation error: allow 0.5 yd.
let bed_yd = clamp(in.depth, 0.0, 1.0) * 148.0;
// MONKEY (water): MLIQ carries an opacity-ramp byte, not ADT's authored bed-depth byte. For a
// WMO pool the only actual column measurement is the reconstructed scene depth, so never turn
// its opacity into 148 yards of fictitious water.
let bed_yd = select(vertical_depth, clamp(in.depth, 0.0, 1.0) * 148.0,
water.lane.x < 0.5);
let on_bed = 1.0 - smoothstep(0.5, 1.0, bed_yd - vertical_depth);
let shore_gain = select(0.0, shoal * shore_collapse * shore_offshore_fade * on_bed, ocean_mesh)
* energy;
@@ -477,7 +473,7 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
let shore = select(1.0, swell_shore_fade(in.depth), ocean_mesh);
let wave = water_waves(p, t, length(eye_pos), footprint, shore, false);
// One surface gradient: the procedural bands and the shore break.
let surf_grad = wave.yz + shore_grad;
let surf_grad = wave.yz + shore_grad + rain_ripple_normal(in.world_position.xz, t);
var n = normalize(vec3<f32>(-surf_grad.x, 1.0, -surf_grad.y));
if dot(n, to_view) < 0.0 { n = -n; }
@@ -507,14 +503,22 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
clamp(inland_row.b, inland_row.g * 0.82, inland_row.g * 1.15));
body = mix(clear_tint, inland_deep, smoothstep(0.0, 5.0, vertical_depth));
}
let to_light = -normalize(water_light.light_sun.xyz);
// MONKEY (water): the reference interior renderer carries MOMT.diffColor on the pool vertex.
// Under a roof that authored colour replaces the outdoor Light.dbc/sky-derived body palette.
if water.lane.x > 1.5 {
body = in.colour.rgb;
}
let to_light = -normalize(water_light[2].xyz);
// MONKEY (water body): water is lit by the light it SCATTERS, not only by N.L on its skin, so
// the lit body keeps a high floor (ambient x 1.35, N.L floor 0.5). With the old 0.25 floor a
// noon lake rendered near-black navy where the owner reference is a bright teal-blue; night
// still darkens because both rows do.
let lighting = clamp(water_light.light_ambient.rgb * 1.35 + water_light.light_diffuse.rgb
let lighting = clamp(water_light[0].rgb * 1.35 + water_light[1].rgb
* max(dot(n, to_light), 0.5), vec3<f32>(0.0), vec3<f32>(1.0));
var rgb = body * lighting;
if water.lane.x > 1.5 {
rgb = body;
}
// MONKEY (shore waves): the shore train is shown mainly as a CONTINUOUS crest brightening, not as
// a normal tilt. Its direction comes from the screen-space gradient of the bed depth, and the
// terrain is flat triangles, so that direction jumps at every triangle edge: at SHORE_TILT 0.30
@@ -537,9 +541,14 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
let reflected = reflect(-to_view, reflection_n);
let sky_linear = mix(water.sky_horizon.rgb, water.sky_zenith.rgb, clamp(reflected.y * 1.4, 0.0, 1.0));
// Linear reflection interpolation, then conversion to the world's gamma blend/fog lane.
let sky = select(12.92 * sky_linear,
var sky = select(12.92 * sky_linear,
1.055 * pow(max(sky_linear, vec3<f32>(0.0)), vec3<f32>(1.0 / 2.4)) - 0.055,
sky_linear > vec3<f32>(0.0031308));
// MONKEY (water): an interior pool has no sky probe. High may still replace this fallback with
// a real SSR hit, while Enhanced reflects the room's own fog colour and keeps sun glints off.
if water.lane.x > 1.5 {
sky = fog.rgb;
}
var reflectivity = mix(0.06, 0.88, pow(1.0 - max(dot(n, to_view), 0.0), 4.0));
if !ocean_mesh { reflectivity = min(reflectivity, 0.55); }
@@ -572,7 +581,7 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
let web = caustic_web(bed_world.xz + n.xz * 0.35, t)
* (1.0 - smoothstep(0.12, 0.40, footprint * CAUSTIC_SCALE * 4.0));
behind *= 1.0 + web * CAUSTIC_GAIN * sun_focus * caustic_fade
* dot(water_light.light_diffuse.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
* dot(water_light[1].rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
}
// Light that reaches the eye through water has been scattered on the way, so it carries the
// water's own tint as well as the colour extinction leaves: without this, yellow sand under a
@@ -616,7 +625,7 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
+ 0.25 * pow(ndoth, 60.0 / sqrt(spread)) / sqrt(spread))
* smoothstep(0.0, 0.1391731, celestial_dir.y);
} else {
rgb += water_light.light_diffuse.rgb
rgb += water_light[1].rgb
* (pow(ndoth, 400.0 / spread) / sqrt(spread)
+ 0.25 * pow(ndoth, 60.0 / sqrt(spread)) / sqrt(spread))
* smoothstep(-0.02, 0.08, celestial_dir.y);
@@ -626,10 +635,13 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
// Rank candidates by actual fragment distance, not table order. Only four lights are shaded.
var nearest = array<u32, 4>(256u, 256u, 256u, 256u);
var distances = array<f32, 4>(900.0, 900.0, 900.0, 900.0);
for (var i = 0u; i < min(u32(water_light.point_count.x), 256u); i += 1u) {
let colour = water_light.points[2u * i + 1u];
if colour.w >= 0.5 && water.lane.x < 1.5 { continue; }
let delta = water_light.points[2u * i].xyz - in.world_position.xyz;
for (var i = 0u; i < min(u32(water_light[20].x), 256u); i += 1u) {
let colour = water_light[21u + 2u * i + 1u];
// MONKEY (water): outside takes only exterior lights; a true interior pool takes only its
// room fixtures. This keeps street torches on canals and out of pools behind closed walls.
let interior_fixture = colour.w >= 0.5;
if interior_fixture != (water.lane.x > 1.5) { continue; }
let delta = water_light[21u + 2u * i].xyz - in.world_position.xyz;
let d2 = dot(delta, delta);
if d2 >= distances[3] { continue; }
var slot = 3u;
@@ -645,8 +657,8 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
}
for (var j = 0u; j < 4u; j += 1u) {
if nearest[j] == 256u { continue; }
let pos = water_light.points[nearest[j] * 2u];
let colour = water_light.points[nearest[j] * 2u + 1u];
let pos = water_light[21u + nearest[j] * 2u];
let colour = water_light[21u + nearest[j] * 2u + 1u];
let delta = pos.xyz - in.world_position.xyz;
let distance = sqrt(max(distances[j], 0.0001));
let light_dir = delta / distance;
@@ -710,7 +722,7 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
* wall_suppression * energy;
let foam = min(FOAM_MAX, contact_alpha + arcs_alpha + crest_alpha);
let illumination = water_light.light_ambient.rgb + water_light.light_diffuse.rgb;
let illumination = water_light[0].rgb + water_light[1].rgb;
let foam_luma = clamp(dot(illumination, vec3<f32>(0.2126, 0.7152, 0.0722)), 0.22, 1.0);
// Matte foam composites on top of reflection with its own coverage.
// The body now carries its own transmission (above), so the surface is opaque bar the very
@@ -209,7 +209,9 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
// Returned as-is: the module fogs its own surface terms (the scene it shows through is
// already fogged).
return enhanced_water(
WaterFragment(in.clip_position, in.world_position, in.depth, in.room_fog),
// MONKEY (water): the enhanced interior arm consumes the MOMT colour already carried
// by the reference fragment interface; exterior/ADT vertices supply white.
WaterFragment(in.clip_position, in.world_position, in.depth, in.vcolor, in.room_fog),
shallow_enhanced, deep_enhanced);
}
+43 -4
View File
@@ -259,6 +259,36 @@ pub(crate) fn spawn_wmo_liquids<'a>(
};
let path = LiquidPath::wmo(interior);
for lq in liquids {
// MONKEY (water): opt-in evidence for WMO-liquid classification and the raw MLIQ opacity
// contract. The byte is not a depth; logging it beside the group and world centre makes a
// deterministic capture sufficient to identify the exact canal/pool surface in question.
if std::env::var_os("WOW_WATER_PROBE").is_some() {
let (depth_min, depth_max, depth_sum) = lq.depths.iter().fold(
(f32::INFINITY, f32::NEG_INFINITY, 0.0),
|(lo, hi, sum), &v| (lo.min(v), hi.max(v), sum + v),
);
let local_center = lq
.positions
.iter()
.map(|&position| wow_to_bevy(position))
.sum::<Vec3>()
/ lq.positions.len().max(1) as f32;
let world_center = transform.transform_point(local_center);
let depth_mean = depth_sum / lq.depths.len().max(1) as f32;
info!(
"water-probe: WMO group={:?} path={path:?} kind={:?} centre_bevy=({:.2},{:.2},{:.2}) opacity_byte=min:{:.0} mean:{:.1} max:{:.0} verts={} wet_cells={}",
pool.owner.map(|room| room.group),
lq.kind,
world_center.x,
world_center.y,
world_center.z,
depth_min.clamp(0.0, 1.0) * 255.0,
depth_mean.clamp(0.0, 1.0) * 255.0,
depth_max.clamp(0.0, 1.0) * 255.0,
lq.positions.len(),
lq.wet.iter().filter(|&&wet| wet).count(),
);
}
// The one path that can scroll, decided by the nibble, not the kind (`scrolls`).
let scroll = scrolls(lq.sound_nibble);
// The interior arm's body colour via the pool's MLIQ `materialId`; none when fullbright.
@@ -421,10 +451,19 @@ pub(super) fn setup_liquid(
water: benilla_assets::WaterUniform {
mode: Vec4::new(
water_quality.0 as f32,
if kind.is_fullbright() { 0.0 }
else if path != LiquidPath::Adt { 0.12 }
else if kind == LiquidKind::Ocean { 1.0 }
else { 0.18 },
if kind.is_fullbright() {
0.0
// MONKEY (water): exterior WMO canals need a calm but legible ripple
// profile; true interior pools stay quieter under a roof.
} else if path == LiquidPath::WmoExterior {
0.26
} else if path == LiquidPath::WmoInterior {
0.08
} else if kind == LiquidKind::Ocean {
1.0
} else {
0.18
},
capture_time,
if deterministic { 0.0 } else { 1.0 },
),
+2 -2
View File
@@ -44,8 +44,8 @@ const GRAVITY_YD: f32 = 10.72;
const TWO_PI: f32 = 6.2831853;
/// The ADT **ocean**'s wave energy — `liquid/surface.rs` packs `water.mode.y = 1.0` for
/// `LiquidPath::Adt` + [`benilla_formats::LiquidKind::Ocean`] (river/lake 0.18, WMO pools 0.12,
/// fullbright 0.0). The vertex swell arm only ever runs on that one combination
/// `LiquidPath::Adt` + [`benilla_formats::LiquidKind::Ocean`] (river/lake 0.18, WMO exterior 0.26,
/// WMO interior 0.08, fullbright 0.0). The vertex swell arm only ever runs on that combination
/// (`enhanced_water.wgsl`), so this is the only energy a bob can ever be driven at — the parameter
/// stays open because the function is the shader's, not the bob's.
pub const OCEAN_WAVE_ENERGY: f32 = 1.0;