From 0489b5c168d9e8dededc7b47dcf58376ff13dc68 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 03:11:31 +0100 Subject: [PATCH] MONKEY (water): restore WMO canal and pool rendering Co-Authored-By: GPT-5.6 Sol (Codex) --- WATER.md | 10 +- .../src/shaders/enhanced_water.wgsl | 96 +++++++++++-------- crates/benilla-assets/src/shaders/liquid.wgsl | 4 +- crates/benilla-world/src/liquid/surface.rs | 47 ++++++++- crates/benilla-world/src/liquid/waves.rs | 4 +- 5 files changed, 109 insertions(+), 52 deletions(-) diff --git a/WATER.md b/WATER.md index 9ee06550..f41a518d 100644 --- a/WATER.md +++ b/WATER.md @@ -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. diff --git a/crates/benilla-assets/src/shaders/enhanced_water.wgsl b/crates/benilla-assets/src/shaders/enhanced_water.wgsl index 667e7459..7662d00d 100644 --- a/crates/benilla-assets/src/shaders/enhanced_water.wgsl +++ b/crates/benilla-assets/src/shaders/enhanced_water.wgsl @@ -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, // 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 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, // 0 - light_diffuse: vec4, // 1 - light_sun: vec4, // 2 xyz = sun TRAVEL dir (to-light = -xyz) - light_spec: vec4, // 3 - fog_color: vec4, // 4 - fog_params: vec4, // 5 - _sh: array, 6>, // 6-11 - _sh_c16: vec4, // 12 - water_river: array, 2>, // 13-14 - water_ocean: array, 2>, // 15-16 - _grade: vec4, // 17 - wmo_fog_color: vec4, // 18 - wmo_fog_params: vec4, // 19 - point_count: vec4, // 20 live count + point-light controls - points: array, 512>, // 21+ position/range, colour/lane pairs -}; -@group(#{MATERIAL_BIND_GROUP}) @binding(105) var 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 water_light: array>; // What the fragment stage hands over: the three varyings this module reads. struct WaterFragment { @@ -64,6 +48,8 @@ struct WaterFragment { world_position: vec4, // 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, // 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, room_fog: u32) -> vec4 { - 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(colour.rgb, 1.0); } let eye_z = -(view.view_from_world * vec4(world_pos, 1.0)).z; @@ -171,10 +157,10 @@ fn water_waves(p: vec2, 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(0.06, 0.025); } var result = vec3(0.0); @@ -196,6 +182,12 @@ fn water_waves(p: vec2, 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, time: f32) -> vec2 { + return vec2(0.0); +} + fn foam_hash(p_in: vec2) -> 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, deep: vec4) -> 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, deep: vec4) -> 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(-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, deep: vec4) -> 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(0.0), vec3(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, deep: vec4) -> 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(0.0)), vec3(1.0 / 2.4)) - 0.055, sky_linear > vec3(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, deep: vec4) -> 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(0.2126, 0.7152, 0.0722)); + * dot(water_light[1].rgb, vec3(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, deep: vec4) -> 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, deep: vec4) -> vec // Rank candidates by actual fragment distance, not table order. Only four lights are shaded. var nearest = array(256u, 256u, 256u, 256u); var distances = array(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, deep: vec4) -> 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, deep: vec4) -> 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(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 diff --git a/crates/benilla-assets/src/shaders/liquid.wgsl b/crates/benilla-assets/src/shaders/liquid.wgsl index 1a9d72e7..248497bf 100644 --- a/crates/benilla-assets/src/shaders/liquid.wgsl +++ b/crates/benilla-assets/src/shaders/liquid.wgsl @@ -209,7 +209,9 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4 { // 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); } diff --git a/crates/benilla-world/src/liquid/surface.rs b/crates/benilla-world/src/liquid/surface.rs index 31a4f939..df6edc5b 100644 --- a/crates/benilla-world/src/liquid/surface.rs +++ b/crates/benilla-world/src/liquid/surface.rs @@ -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::() + / 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 }, ), diff --git a/crates/benilla-world/src/liquid/waves.rs b/crates/benilla-world/src/liquid/waves.rs index aad4d247..363a87fe 100644 --- a/crates/benilla-world/src/liquid/waves.rs +++ b/crates/benilla-world/src/liquid/waves.rs @@ -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;