From 46c60f62410f0f270c191aa836801ae53fa12de7 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 03:11:31 +0100
Subject: [PATCH 1/3] 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;
From b77393c98d1dfef6505b4ef5fbaab53065caf7ab Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 03:17:48 +0100
Subject: [PATCH 2/3] MONKEY (water): add Gerstner crest whitecaps
Co-Authored-By: GPT-5.6 Sol (Codex)
---
THIRD-PARTY.md | 7 ++
WATER.md | 3 +
.../src/shaders/enhanced_water.wgsl | 63 ++++++++--
crates/benilla-assets/src/shaders/liquid.wgsl | 4 +-
crates/benilla-world/src/liquid/waves.rs | 108 ++++++++++++++++--
5 files changed, 165 insertions(+), 20 deletions(-)
create mode 100644 THIRD-PARTY.md
diff --git a/THIRD-PARTY.md b/THIRD-PARTY.md
new file mode 100644
index 00000000..ae7abaf7
--- /dev/null
+++ b/THIRD-PARTY.md
@@ -0,0 +1,7 @@
+# Third-party technique credits
+
+## WarcraftXL
+
+| Benilla component | Source | Author | Use |
+| --- | --- | --- | --- |
+| `enhanced_water.wgsl` Gerstner displacement and fold-driven High-quality whitecaps | [WarcraftXL `wxl-experimental-water`](https://github.com/WarcraftXL), `shaders/Surface.ps.hlsl`, `sea/Spectrum.*`, `sea/Ocean.*` | iThorgrim | Technique ported with the author's permission; attribution is required. |
diff --git a/WATER.md b/WATER.md
index f41a518d..0b077de0 100644
--- a/WATER.md
+++ b/WATER.md
@@ -40,6 +40,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 |
+| Open-sea whitecaps | High adds Gerstner crest gathering and fold-thresholded foam; calm canals remain clear | 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 |
@@ -80,4 +81,6 @@ request. If it ever returns it should read the authored MCLQ flow records, not d
- 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.
+- The temporary water wind is `WATER_WIND_DIR`; all wind-aligned shader terms derive from it until
+ the weather lane supplies shared wind.
- Run one GPU capture at a time, and run `cargo test` in a shell WITHOUT `WOW_DATA` exported.
diff --git a/crates/benilla-assets/src/shaders/enhanced_water.wgsl b/crates/benilla-assets/src/shaders/enhanced_water.wgsl
index 7662d00d..a89ca38f 100644
--- a/crates/benilla-assets/src/shaders/enhanced_water.wgsl
+++ b/crates/benilla-assets/src/shaders/enhanced_water.wgsl
@@ -1,5 +1,6 @@
#define_import_path benilla::enhanced_water
+// Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module wxl-experimental-water, shaders/Surface.ps.hlsl, sea/Spectrum.*, sea/Ocean.*.
// ENHANCED WATER - the optional water module (Video options -> Water Quality; see WATER.md).
//
// Credits: original project WarcraftXL (`wxl-experimental-water`, https://github.com/WarcraftXL),
@@ -84,21 +85,25 @@ fn water_time() -> f32 {
return globals.time;
}
-// Vertex lift for the ocean's long swell (0 everywhere else). `liquid/waves.rs` mirrors it on
-// the CPU for the swimmer bob; the two are one contract.
-fn water_swell(xz: vec2, authored_depth: f32) -> f32 {
+// Vertex Gerstner displacement for the ocean's long swell (zero everywhere else).
+// `liquid/waves.rs` mirrors it on the CPU for the swimmer bob; the two are one contract.
+fn water_swell(xz: vec2, authored_depth: f32) -> vec3 {
if water.lane.z < 0.5 && water.lane.y > 0.5 && water.lane.x < 0.5 && water.mode.x > 0.5 {
- return water_waves(xz, water_time(), 0.0, 0.0, swell_shore_fade(authored_depth), true).x;
+ return water_gerstner(xz, water_time(), swell_shore_fade(authored_depth)).xyz;
}
- return 0.0;
+ return vec3(0.0);
}
// Enhanced is entirely analytic: height and its exact x/z derivatives, in yards.
// Direction is radians from world +X toward +Z; phase speed follows deep-water dispersion.
// Two mesh-resolvable long waves sum to at most 0.34 yd before energy/shore attenuation.
+// MONKEY (water): one temporary wind bearing until the weather lane supplies shared wind.
+// Every wind-aligned water term derives from this constant rather than owning a second heading.
+const WATER_WIND_DIR: f32 = 0.35;
+const WATER_GERSTNER_CHOP: f32 = 2.4;
const WATER_WAVES: array, 8> = array, 8>(
// direction, wavelength, amplitude, phase offset
- vec4(0.35, 18.0, 0.200, 0.0),
+ vec4(WATER_WIND_DIR, 18.0, 0.200, 0.0),
vec4(0.80, 12.8, 0.140, 1.7),
vec4(-0.18, 9.5, 0.090, 3.1),
vec4(0.52, 4.8, 0.055, 0.8),
@@ -137,7 +142,7 @@ const SHORE_PERIOD: f32 = 3.6; // seconds between arrivals; the swash ru
const SHORE_TILT: f32 = 0.09; // peak crest steepness (tan of the tilt), slope-independent
const SHORE_WARP_A: f32 = 0.22; // yards of depth — coarse crest wander (never ruler-straight)
const SHORE_WARP_B: f32 = 0.10; // yards of depth — finer segmentation of the same crests
-const SHORE_WIND_DIR: f32 = 0.35; // = WATER_WAVES[0].x, the primary swell bearing
+const SHORE_WIND_DIR: f32 = WATER_WIND_DIR;
// Foam alphas. The owner rejected BOTH a thick icing sheet and straight stripes before this, so
// every one of these is gated behind a noise breakup and a depth window; the numbers are the
@@ -145,6 +150,7 @@ const SHORE_WIND_DIR: f32 = 0.35; // = WATER_WAVES[0].x, the primary swell
const FOAM_WET_EDGE: f32 = 0.35; // the faint wet line where water meets anything solid
const FOAM_SWASH: f32 = 0.62; // the sheet running up the sand and fading
const FOAM_CREST: f32 = 0.88; // the white front of the last wave or two, and its lace
+const FOAM_WHITECAP: f32 = 0.68; // High-only open-sea crest fold
const FOAM_MAX: f32 = 0.90; // hard ceiling on the sum
fn swell_shore_fade(depth: f32) -> f32 {
@@ -182,6 +188,37 @@ fn water_waves(p: vec2, t: f32, distance: f32, footprint: f32,
return result;
}
+// MONKEY (water): Ported from WarcraftXL's Gerstner displacement and fold-driven gFoam path.
+// xyz is horizontal/vertical/horizontal displacement; w is 1 - det(J), the amount the horizontal
+// map has compressed toward a fold. Only the two mesh-resolvable long bands move geometry.
+fn water_gerstner(p: vec2, t: f32, shore: f32) -> vec4 {
+ let energy = clamp(water.mode.y, 0.0, 1.0);
+ let tempo = mix(0.4, 1.0, sqrt(energy));
+ var displacement = vec3(0.0);
+ var j00 = 1.0;
+ var j01 = 0.0;
+ var j10 = 0.0;
+ var j11 = 1.0;
+ for (var i = 0u; i < 2u; i += 1u) {
+ let wave = WATER_WAVES[i];
+ let direction = vec2(cos(wave.x), sin(wave.x));
+ let k = 6.2831853 / wave.y;
+ let speed = sqrt(10.72 / k);
+ let phase = k * (dot(direction, p) - speed * tempo * t) + wave.w;
+ let amplitude = wave.z * energy * shore;
+ let horizontal = WATER_GERSTNER_CHOP * amplitude;
+ displacement += vec3(direction.x * horizontal * cos(phase),
+ amplitude * sin(phase), direction.y * horizontal * cos(phase));
+ let compression = horizontal * k * sin(phase);
+ j00 -= compression * direction.x * direction.x;
+ j01 -= compression * direction.x * direction.y;
+ j10 -= compression * direction.y * direction.x;
+ j11 -= compression * direction.y * direction.y;
+ }
+ let fold = max(1.0 - (j00 * j11 - j01 * j10), 0.0);
+ return vec4(displacement, fold);
+}
+
// 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 {
@@ -472,6 +509,10 @@ 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);
+ var open_sea_fold = 0.0;
+ if water.mode.x > 1.5 && ocean_mesh {
+ open_sea_fold = water_gerstner(p, t, shore).w;
+ }
// One surface gradient: the procedural bands and the shore break.
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));
@@ -672,6 +713,12 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec
let noise = 0.7 * foam_noise(p * 0.6 + t * vec2(0.025, -0.018))
+ 0.3 * foam_noise(p * 1.7 - t * vec2(0.014, 0.021));
let breakup = smoothstep(0.40, 0.72, noise);
+ // MONKEY (water): WarcraftXL's white belongs to a connected horizontal fold, not to every
+ // steep normal. Threshold jitter stops identical crests drawing identical white contours.
+ let fold_jitter = (noise - 0.5) * 0.04;
+ let breaking = smoothstep(0.13, 0.25, open_sea_fold + fold_jitter);
+ let whitecap_alpha = FOAM_WHITECAP * breaking * mix(0.55, 1.0, breakup)
+ * smoothstep(0.55, 1.0, energy);
// The cached derivatives from the top of the function — same expression as before, one tap.
let depth_gradient = length(vec2(ddx_depth, ddy_depth))
/ max(length(vec2(length(ddx_p), length(ddy_p))), 0.001);
@@ -721,7 +768,7 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec
FOAM_SWASH * swash_life * swash_band * max(swash_lace, 0.8 * lip), ocean_mesh)
* wall_suppression * energy;
- let foam = min(FOAM_MAX, contact_alpha + arcs_alpha + crest_alpha);
+ let foam = min(FOAM_MAX, contact_alpha + arcs_alpha + crest_alpha + whitecap_alpha);
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.
diff --git a/crates/benilla-assets/src/shaders/liquid.wgsl b/crates/benilla-assets/src/shaders/liquid.wgsl
index 248497bf..ba16672d 100644
--- a/crates/benilla-assets/src/shaders/liquid.wgsl
+++ b/crates/benilla-assets/src/shaders/liquid.wgsl
@@ -137,8 +137,8 @@ fn vertex(in: Vertex) -> LiquidVsOut {
let world_from_local = mesh_functions::get_world_from_local(in.instance_index);
out.world_position =
mesh_functions::mesh_position_local_to_world(world_from_local, vec4(in.position, 1.0));
- // MONKEY (enhanced water): the ocean's long swell; 0 on Classic and on every other surface.
- out.world_position.y += water_swell(out.world_position.xz, in.uv_b.x);
+ // MONKEY (enhanced water): ocean Gerstner displacement; zero on Classic/other surfaces.
+ out.world_position.xyz += water_swell(out.world_position.xz, in.uv_b.x);
out.clip_position = position_world_to_clip(out.world_position.xyz);
out.world_normal = mesh_functions::mesh_normal_local_to_world(in.normal, in.instance_index);
out.uv = in.uv;
diff --git a/crates/benilla-world/src/liquid/waves.rs b/crates/benilla-world/src/liquid/waves.rs
index 363a87fe..367b0c3f 100644
--- a/crates/benilla-world/src/liquid/waves.rs
+++ b/crates/benilla-world/src/liquid/waves.rs
@@ -31,10 +31,12 @@ use bevy::math::Vec2;
///
/// Components 2..8 are the shader's fragment-only ripple band and are NOT here — see the module
/// doc's `long_only` note. Adding them would be a second design, not a closer mirror.
-pub const LONG_SWELL: [[f32; 4]; 2] = [
- [0.35, 18.0, 0.200, 0.0],
- [0.80, 12.8, 0.140, 1.7],
-];
+pub const WATER_WIND_DIR: f32 = 0.35;
+pub const LONG_SWELL: [[f32; 4]; 2] =
+ [[WATER_WIND_DIR, 18.0, 0.200, 0.0], [0.80, 12.8, 0.140, 1.7]];
+
+/// Horizontal Gerstner gather, mirrored from `enhanced_water.wgsl::WATER_GERSTNER_CHOP`.
+pub const GERSTNER_CHOP: f32 = 2.4;
/// Gravity in yd/s², `enhanced_water.wgsl` — deep-water dispersion, `c = sqrt(g/k)`.
const GRAVITY_YD: f32 = 10.72;
@@ -60,6 +62,8 @@ pub struct Swell {
/// `(∂height/∂x, ∂height/∂z)` in BEVY world axes, the shader's `result.yz`. The shader builds
/// its normal from exactly this as `normalize(vec3(-grad.x, 1, -grad.y))` (`enhanced_water.wgsl`).
pub grad: Vec2,
+ /// Horizontal Gerstner displacement in BEVY XZ at the parameter-space point under this sample.
+ pub horizontal: Vec2,
}
/// `enhanced_water.wgsl`'s `swell_shore_fade`: the authored ocean depth `V` (byte/255, ≈148 yd at
@@ -84,25 +88,69 @@ pub fn swell_shore_fade(depth_v: f32) -> f32 {
/// * `long_only = true` — components 0 and 1, which are also the two the shore fade multiplies.
///
/// `world_xz` is BEVY world XZ. `shallow_fade` is the shore term (1 = open sea, 0 = beach).
-pub fn swell(world_xz: Vec2, time: f32, wave_energy: f32, shallow_fade: f32) -> Swell {
+#[derive(Clone, Copy, Debug, Default)]
+struct RawSwell {
+ height: f32,
+ grad: Vec2,
+ horizontal: Vec2,
+ jacobian: [f32; 4],
+}
+
+/// Evaluate the same parameter-space Gerstner surface the vertex shader receives. The public
+/// sampler below inverts its horizontal map because callers hold a displaced world position.
+fn raw_swell(parameter_xz: Vec2, time: f32, wave_energy: f32, shallow_fade: f32) -> RawSwell {
let energy = wave_energy.clamp(0.0, 1.0);
// `mix(0.4, 1.0, sqrt(energy))` — a calm inland sheet also swells SLOWER, not merely lower.
let tempo = 0.4 + 0.6 * energy.sqrt();
let shore = shallow_fade.clamp(0.0, 1.0);
- let mut out = Swell::default();
+ let mut out = RawSwell {
+ jacobian: [1.0, 0.0, 0.0, 1.0],
+ ..RawSwell::default()
+ };
for w in LONG_SWELL {
let dir = Vec2::new(w[0].cos(), w[0].sin());
let k = TWO_PI / w[1];
let speed = (GRAVITY_YD / k).sqrt(); // phase speed, yd/s
- let phase = k * (dir.dot(world_xz) - speed * tempo * time) + w[3];
+ let phase = k * (dir.dot(parameter_xz) - speed * tempo * time) + w[3];
// `fade` is 1 at zero footprint; `i < 2` ⇒ both components take the shore term.
let amplitude = w[2] * energy * shore;
out.height += amplitude * phase.sin();
out.grad += dir * (amplitude * k * phase.cos());
+ let horizontal = GERSTNER_CHOP * amplitude;
+ out.horizontal += dir * (horizontal * phase.cos());
+ let compression = horizontal * k * phase.sin();
+ out.jacobian[0] -= compression * dir.x * dir.x;
+ out.jacobian[1] -= compression * dir.x * dir.y;
+ out.jacobian[2] -= compression * dir.y * dir.x;
+ out.jacobian[3] -= compression * dir.y * dir.y;
}
out
}
+/// The displaced Gerstner surface at a WORLD-space point. Four fixed-point steps find the original
+/// mesh coordinate under that point; the long-band Jacobian is contractive (worst compression
+/// below 0.34), so this converges without a branch or an allocation. The gradient is transformed
+/// through the same map, keeping swimmer lean consistent with the visibly pinched crest.
+pub fn swell(world_xz: Vec2, time: f32, wave_energy: f32, shallow_fade: f32) -> Swell {
+ let mut parameter_xz = world_xz;
+ for _ in 0..4 {
+ let raw = raw_swell(parameter_xz, time, wave_energy, shallow_fade);
+ parameter_xz = world_xz - raw.horizontal;
+ }
+ let raw = raw_swell(parameter_xz, time, wave_energy, shallow_fade);
+ let [j00, j01, j10, j11] = raw.jacobian;
+ let det = (j00 * j11 - j01 * j10).max(1.0e-4);
+ let grad = Vec2::new(
+ (j11 * raw.grad.x - j01 * raw.grad.y) / det,
+ (-j10 * raw.grad.x + j00 * raw.grad.y) / det,
+ );
+ Swell {
+ height: raw.height,
+ grad,
+ horizontal: raw.horizontal,
+ }
+}
+
/// [`swell`]'s height alone — the shape the brief names, for callers that do not want the slope.
pub fn swell_height(world_xz: Vec2, time: f32, wave_energy: f32, shallow_fade: f32) -> f32 {
swell(world_xz, time, wave_energy, shallow_fade).height
@@ -167,8 +215,19 @@ mod tests {
"enhanced_water.wgsl WATER_WAVES[0..2]"
);
assert_eq!(GRAVITY_YD, 10.72, "enhanced_water.wgsl gravity");
- assert_eq!(TWO_PI, 6.2831853, "enhanced_water.wgsl k = 6.2831853/wavelength");
- assert_eq!(OCEAN_WAVE_ENERGY, 1.0, "surface.rs water.mode.y for ADT ocean");
+ assert_eq!(
+ TWO_PI, 6.2831853,
+ "enhanced_water.wgsl k = 6.2831853/wavelength"
+ );
+ assert_eq!(WATER_WIND_DIR, 0.35, "enhanced_water.wgsl WATER_WIND_DIR");
+ assert_eq!(
+ GERSTNER_CHOP, 2.4,
+ "enhanced_water.wgsl WATER_GERSTNER_CHOP"
+ );
+ assert_eq!(
+ OCEAN_WAVE_ENERGY, 1.0,
+ "surface.rs water.mode.y for ADT ocean"
+ );
// The band's ceiling, stated in the shader's own comment ("at most 0.34 yd").
let peak: f32 = LONG_SWELL.iter().map(|w| w[2]).sum();
assert!((peak - 0.34).abs() < 1e-6, "amplitude sum {peak}");
@@ -183,11 +242,13 @@ mod tests {
let s = swell(Vec2::new(-9016.0, 226.0), t, 0.0, 1.0);
assert_eq!(s.height, 0.0, "height at t={t}");
assert_eq!(s.grad, Vec2::ZERO, "grad at t={t}");
+ assert_eq!(s.horizontal, Vec2::ZERO, "horizontal at t={t}");
}
// The shore term is the other kill switch: a beached vertex does not move either.
let beached = swell(Vec2::new(-9016.0, 226.0), 3.0, OCEAN_WAVE_ENERGY, 0.0);
assert_eq!(beached.height, 0.0);
assert_eq!(beached.grad, Vec2::ZERO);
+ assert_eq!(beached.horizontal, Vec2::ZERO);
}
/// The swell stays inside the band the shader advertises, and the gradient it reports really
@@ -220,12 +281,39 @@ mod tests {
}
}
+ /// The CPU query samples by displaced world position while the vertex shader starts in mesh
+ /// parameter space. Pin the inversion: adding the returned horizontal displacement to the
+ /// recovered parameter must land back under the swimmer, with the advertised hard bound.
+ #[test]
+ fn gerstner_inverse_lands_under_the_world_sample() {
+ let world = Vec2::new(-9016.0, 226.0);
+ for step in 0..40 {
+ let t = step as f32 * 0.37;
+ let s = swell(world, t, OCEAN_WAVE_ENERGY, 1.0);
+ let parameter = world - s.horizontal;
+ let raw = raw_swell(parameter, t, OCEAN_WAVE_ENERGY, 1.0);
+ let reprojection = parameter + raw.horizontal;
+ assert!(
+ (reprojection - world).length() < 2.0e-3,
+ "reprojection at t={t}"
+ );
+ assert!(
+ s.horizontal.length() <= 0.817,
+ "horizontal at t={t}: {:?}",
+ s.horizontal
+ );
+ }
+ }
+
/// The shore fade's two ends, as the shader's `smoothstep(0.001, 0.025, depth)` places them.
#[test]
fn shore_fade_matches_the_shader_band() {
assert_eq!(swell_shore_fade(0.0), 0.0);
assert_eq!(swell_shore_fade(1.0), 1.0, "open sea (V = 1) is unfaded");
- assert!((swell_shore_fade(0.013) - 0.5).abs() < 0.02, "band midpoint");
+ assert!(
+ (swell_shore_fade(0.013) - 0.5).abs() < 0.02,
+ "band midpoint"
+ );
}
/// The clock: a live run is the wrapped elapsed seconds verbatim (the GPU's `globals.time`),
From 647cd87795d319508152359afa93b07652344d4c Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 05:42:59 +0100
Subject: [PATCH 3/3] MONKEY (water): refine nearby liquid meshes
Co-Authored-By: GPT-5.6 Sol (Codex)
---
WATER.md | 2 +
crates/benilla-assets/src/shaders/liquid.wgsl | 2 +-
crates/benilla-world/src/liquid/lod.rs | 301 ++++++++++++++++++
crates/benilla-world/src/liquid/mod.rs | 2 +
crates/benilla-world/src/liquid/surface.rs | 13 +-
5 files changed, 316 insertions(+), 4 deletions(-)
create mode 100644 crates/benilla-world/src/liquid/lod.rs
diff --git a/WATER.md b/WATER.md
index 0b077de0..53ada875 100644
--- a/WATER.md
+++ b/WATER.md
@@ -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 |
| Open-sea whitecaps | High adds Gerstner crest gathering and fold-thresholded foam; calm canals remain clear | tier |
+| Near mesh refinement | High builds a transient 4x liquid lattice within 64 yd, with an 80 yd release ring | 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 |
@@ -67,6 +68,7 @@ request. If it ever returns it should read the authored MCLQ flow records, not d
- **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 and the opt-in `WOW_WATER_PROBE` WMO classification/depth log),
+ `liquid/lod.rs` (High-only 4x near-grid swap),
`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`.
diff --git a/crates/benilla-assets/src/shaders/liquid.wgsl b/crates/benilla-assets/src/shaders/liquid.wgsl
index ba16672d..be5c9148 100644
--- a/crates/benilla-assets/src/shaders/liquid.wgsl
+++ b/crates/benilla-assets/src/shaders/liquid.wgsl
@@ -138,7 +138,7 @@ fn vertex(in: Vertex) -> LiquidVsOut {
out.world_position =
mesh_functions::mesh_position_local_to_world(world_from_local, vec4(in.position, 1.0));
// MONKEY (enhanced water): ocean Gerstner displacement; zero on Classic/other surfaces.
- out.world_position.xyz += water_swell(out.world_position.xz, in.uv_b.x);
+ out.world_position += vec4(water_swell(out.world_position.xz, in.uv_b.x), 0.0);
out.clip_position = position_world_to_clip(out.world_position.xyz);
out.world_normal = mesh_functions::mesh_normal_local_to_world(in.normal, in.instance_index);
out.uv = in.uv;
diff --git a/crates/benilla-world/src/liquid/lod.rs b/crates/benilla-world/src/liquid/lod.rs
new file mode 100644
index 00000000..6ac8ad6d
--- /dev/null
+++ b/crates/benilla-world/src/liquid/lod.rs
@@ -0,0 +1,301 @@
+//! MONKEY (water LOD): High-quality liquid uses a denser near-camera lattice.
+//!
+//! Wgpu has no tessellation stage. The coarse grid therefore remains the streamed source of
+//! truth, while a 4x bilinear copy is built only when the camera enters the surface's near ring.
+//! Every original edge vertex survives bit-for-bit, so a fine chunk and its coarse neighbour share
+//! the same boundary. Hysteresis avoids rebuilding while the camera hovers at the threshold.
+//! A full MCLQ copy is 1,089 vertices + 6,144 indices (about 68 KiB without allocator overhead);
+//! the 64 yd ring bounds ordinary terrain residency to roughly 3.5 MiB, and a per-surface ceiling
+//! refuses pathological MLIQ grids rather than allowing one pool to consume an unbounded copy.
+
+use bevy::asset::RenderAssetUsages;
+use bevy::mesh::{Indices, PrimitiveTopology, VertexAttributeValues};
+use bevy::prelude::*;
+
+use benilla_assets::{coords::wow_to_bevy, WaterQuality};
+use benilla_formats::LiquidMesh;
+
+use crate::view::WorldCamera;
+
+const SUBDIVISIONS: usize = 4;
+const ENTER_YARDS: f32 = 64.0;
+const EXIT_YARDS: f32 = 80.0;
+const MAX_FINE_VERTICES: usize = 65_536;
+
+/// The coarse asset is retained for the far swap. Only the tiny coverage mask/grid metadata is
+/// duplicated per surface; source vertex attributes remain in `Assets` and are copied only
+/// during an actual near-ring transition.
+#[derive(Component)]
+pub(super) struct LiquidLod {
+ coarse: Handle,
+ fine: Option>,
+ grid: [u32; 2],
+ wet: Vec,
+ local_center: Vec3,
+ local_radius_xz: f32,
+}
+
+impl LiquidLod {
+ pub(super) fn new(coarse: Handle, source: &LiquidMesh) -> Self {
+ let mut min = Vec3::splat(f32::INFINITY);
+ let mut max = Vec3::splat(f32::NEG_INFINITY);
+ for &position in &source.positions {
+ let p = wow_to_bevy(position);
+ min = min.min(p);
+ max = max.max(p);
+ }
+ let valid = min.is_finite() && max.is_finite();
+ let local_center = if valid { (min + max) * 0.5 } else { Vec3::ZERO };
+ let local_radius_xz = if valid {
+ Vec2::new(max.x - min.x, max.z - min.z).length() * 0.5
+ } else {
+ 0.0
+ };
+ Self {
+ coarse,
+ fine: None,
+ grid: source.grid,
+ wet: source.wet.clone(),
+ local_center,
+ local_radius_xz,
+ }
+ }
+}
+
+pub(super) fn register(app: &mut App) {
+ app.add_systems(Update, update_liquid_lod);
+}
+
+fn update_liquid_lod(
+ quality: Res,
+ camera: Query<&GlobalTransform, With>,
+ mut surfaces: Query<(&GlobalTransform, &mut Mesh3d, &mut LiquidLod)>,
+ mut meshes: ResMut>,
+) {
+ let Ok(camera) = camera.single() else { return };
+ let camera = camera.translation().xz();
+
+ for (transform, mut current, mut lod) in &mut surfaces {
+ if quality.0 < 2 {
+ if let Some(fine) = lod.fine.take() {
+ current.0 = lod.coarse.clone();
+ meshes.remove(fine.id());
+ }
+ continue;
+ }
+
+ let centre = transform.transform_point(lod.local_center).xz();
+ let (scale, _, _) = transform.to_scale_rotation_translation();
+ let world_radius = lod.local_radius_xz * scale.abs().max_element();
+ let edge_distance = (camera.distance(centre) - world_radius).max(0.0);
+ if lod.fine.is_some() {
+ if edge_distance > EXIT_YARDS {
+ let fine = lod.fine.take().expect("checked");
+ current.0 = lod.coarse.clone();
+ meshes.remove(fine.id());
+ }
+ continue;
+ }
+ if edge_distance > ENTER_YARDS {
+ continue;
+ }
+
+ let Some(fine_mesh) = meshes
+ .get(&lod.coarse)
+ .and_then(|coarse| subdivide(coarse, lod.grid, &lod.wet))
+ else {
+ continue;
+ };
+ let fine = meshes.add(fine_mesh);
+ current.0 = fine.clone();
+ lod.fine = Some(fine);
+ }
+}
+
+fn bilerp(
+ values: &[[f32; N]],
+ cols: usize,
+ x: usize,
+ y: usize,
+ tx: f32,
+ ty: f32,
+) -> [f32; N] {
+ let [tl, tr, bl, br] = [
+ values[y * cols + x],
+ values[y * cols + x + 1],
+ values[(y + 1) * cols + x],
+ values[(y + 1) * cols + x + 1],
+ ];
+ let mut out = [0.0; N];
+ for i in 0..N {
+ let top = tl[i] + (tr[i] - tl[i]) * tx;
+ let bottom = bl[i] + (br[i] - bl[i]) * tx;
+ out[i] = top + (bottom - top) * ty;
+ }
+ out
+}
+
+/// Four-way subdivision of every source cell. The output is still one indexed regular grid: there
+/// are no duplicated cell-edge vertices, and dry source cells emit no triangles.
+fn subdivide(coarse: &Mesh, grid: [u32; 2], wet: &[bool]) -> Option {
+ let [cols, rows] = grid.map(|v| v as usize);
+ if cols < 2 || rows < 2 || wet.len() != (cols - 1) * (rows - 1) {
+ return None;
+ }
+ let VertexAttributeValues::Float32x3(positions) = coarse.attribute(Mesh::ATTRIBUTE_POSITION)?
+ else {
+ return None;
+ };
+ let VertexAttributeValues::Float32x2(uv0) = coarse.attribute(Mesh::ATTRIBUTE_UV_0)? else {
+ return None;
+ };
+ let VertexAttributeValues::Float32x2(uv1) = coarse.attribute(Mesh::ATTRIBUTE_UV_1)? else {
+ return None;
+ };
+ if positions.len() != cols * rows
+ || uv0.len() != positions.len()
+ || uv1.len() != positions.len()
+ {
+ return None;
+ }
+ let colours = match coarse.attribute(Mesh::ATTRIBUTE_COLOR) {
+ Some(VertexAttributeValues::Float32x4(values)) if values.len() == positions.len() => {
+ Some(values)
+ }
+ None => None,
+ _ => return None,
+ };
+
+ let fine_cols = (cols - 1).checked_mul(SUBDIVISIONS)?.checked_add(1)?;
+ let fine_rows = (rows - 1).checked_mul(SUBDIVISIONS)?.checked_add(1)?;
+ let fine_count = fine_cols.checked_mul(fine_rows)?;
+ if fine_count > MAX_FINE_VERTICES {
+ return None;
+ }
+ let mut fine_positions = Vec::with_capacity(fine_count);
+ let mut fine_uv0 = Vec::with_capacity(fine_count);
+ let mut fine_uv1 = Vec::with_capacity(fine_count);
+ let mut fine_colours = colours.map(|_| Vec::with_capacity(fine_count));
+
+ for fy in 0..fine_rows {
+ let sy = (fy / SUBDIVISIONS).min(rows - 2);
+ let ty = (fy - sy * SUBDIVISIONS) as f32 / SUBDIVISIONS as f32;
+ for fx in 0..fine_cols {
+ let sx = (fx / SUBDIVISIONS).min(cols - 2);
+ let tx = (fx - sx * SUBDIVISIONS) as f32 / SUBDIVISIONS as f32;
+ fine_positions.push(bilerp(positions, cols, sx, sy, tx, ty));
+ fine_uv0.push(bilerp(uv0, cols, sx, sy, tx, ty));
+ fine_uv1.push(bilerp(uv1, cols, sx, sy, tx, ty));
+ if let (Some(source), Some(output)) = (colours, fine_colours.as_mut()) {
+ output.push(bilerp(source, cols, sx, sy, tx, ty));
+ }
+ }
+ }
+
+ let wet_fine_cells = wet.iter().filter(|&&cell| cell).count() * SUBDIVISIONS * SUBDIVISIONS;
+ let mut indices = Vec::with_capacity(wet_fine_cells * 6);
+ for y in 0..fine_rows - 1 {
+ for x in 0..fine_cols - 1 {
+ let source_x = x / SUBDIVISIONS;
+ let source_y = y / SUBDIVISIONS;
+ if !wet[source_y * (cols - 1) + source_x] {
+ continue;
+ }
+ let tl = (y * fine_cols + x) as u32;
+ let tr = tl + 1;
+ let bl = ((y + 1) * fine_cols + x) as u32;
+ let br = bl + 1;
+ indices.extend_from_slice(&[tl, bl, br, tl, br, tr]);
+ }
+ }
+
+ let mut fine = Mesh::new(
+ PrimitiveTopology::TriangleList,
+ RenderAssetUsages::default(),
+ );
+ fine.insert_attribute(Mesh::ATTRIBUTE_POSITION, fine_positions);
+ fine.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 1.0, 0.0]; fine_count]);
+ fine.insert_attribute(Mesh::ATTRIBUTE_UV_0, fine_uv0);
+ fine.insert_attribute(Mesh::ATTRIBUTE_UV_1, fine_uv1);
+ if let Some(colours) = fine_colours {
+ fine.insert_attribute(Mesh::ATTRIBUTE_COLOR, colours);
+ }
+ fine.insert_indices(Indices::U32(indices));
+ Some(fine)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn coarse() -> Mesh {
+ let mut mesh = Mesh::new(
+ PrimitiveTopology::TriangleList,
+ RenderAssetUsages::default(),
+ );
+ mesh.insert_attribute(
+ Mesh::ATTRIBUTE_POSITION,
+ vec![
+ [0.0, 0.0, 0.0],
+ [1.0, 0.0, 0.0],
+ [0.0, 0.0, 1.0],
+ [1.0, 1.0, 1.0],
+ ],
+ );
+ mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 1.0, 0.0]; 4]);
+ mesh.insert_attribute(
+ Mesh::ATTRIBUTE_UV_0,
+ vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]],
+ );
+ mesh.insert_attribute(Mesh::ATTRIBUTE_UV_1, vec![[0.0, 0.0]; 4]);
+ mesh
+ }
+
+ #[test]
+ fn subdivision_preserves_edges_and_splits_every_wet_cell_sixteen_ways() {
+ let fine = subdivide(&coarse(), [2, 2], &[true]).expect("fine mesh");
+ let Some(VertexAttributeValues::Float32x3(positions)) =
+ fine.attribute(Mesh::ATTRIBUTE_POSITION)
+ else {
+ panic!("positions");
+ };
+ assert_eq!(positions.len(), 25);
+ assert_eq!(positions[0], [0.0, 0.0, 0.0]);
+ assert_eq!(positions[4], [1.0, 0.0, 0.0]);
+ assert_eq!(positions[20], [0.0, 0.0, 1.0]);
+ assert_eq!(positions[24], [1.0, 1.0, 1.0]);
+ assert_eq!(positions[12], [0.5, 0.25, 0.5]);
+ assert_eq!(fine.indices().expect("indices").len(), 16 * 6);
+ }
+
+ #[test]
+ fn dry_parent_cells_stay_holes() {
+ let mut mesh = coarse();
+ mesh.insert_attribute(
+ Mesh::ATTRIBUTE_POSITION,
+ vec![
+ [0.0, 0.0, 0.0],
+ [1.0, 0.0, 0.0],
+ [2.0, 0.0, 0.0],
+ [0.0, 0.0, 1.0],
+ [1.0, 0.0, 1.0],
+ [2.0, 0.0, 1.0],
+ ],
+ );
+ mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 1.0, 0.0]; 6]);
+ mesh.insert_attribute(
+ Mesh::ATTRIBUTE_UV_0,
+ vec![
+ [0.0, 0.0],
+ [0.5, 0.0],
+ [1.0, 0.0],
+ [0.0, 1.0],
+ [0.5, 1.0],
+ [1.0, 1.0],
+ ],
+ );
+ mesh.insert_attribute(Mesh::ATTRIBUTE_UV_1, vec![[0.0, 0.0]; 6]);
+ let fine = subdivide(&mesh, [3, 2], &[true, false]).expect("fine mesh");
+ assert_eq!(fine.indices().expect("indices").len(), 16 * 6);
+ }
+}
diff --git a/crates/benilla-world/src/liquid/mod.rs b/crates/benilla-world/src/liquid/mod.rs
index fec0aec9..09861401 100644
--- a/crates/benilla-world/src/liquid/mod.rs
+++ b/crates/benilla-world/src/liquid/mod.rs
@@ -31,6 +31,7 @@ use benilla_assets::materials::LiquidMaterial;
use benilla_assets::AssetSet;
mod drift;
+mod lod;
mod scene_depth;
pub use scene_depth::WaterDepthPlugin;
mod query;
@@ -125,5 +126,6 @@ impl Plugin for LiquidPlugin {
);
}
drift::register(app);
+ lod::register(app);
}
}
diff --git a/crates/benilla-world/src/liquid/surface.rs b/crates/benilla-world/src/liquid/surface.rs
index df6edc5b..e1197ecf 100644
--- a/crates/benilla-world/src/liquid/surface.rs
+++ b/crates/benilla-world/src/liquid/surface.rs
@@ -11,6 +11,7 @@ use bevy::mesh::{Indices, PrimitiveTopology};
use bevy::pbr::ExtendedMaterial;
use bevy::prelude::*;
+use super::lod::LiquidLod;
use super::query::{wet_footprint, FoamPatch, LiquidSource, WmoPool};
use crate::collision::liquid_layers;
use crate::lighting::WATER_SHININESS;
@@ -181,7 +182,9 @@ pub(crate) fn spawn_liquids<'a>(
if foam {
commands
.entity(*entities.last().expect("just pushed"))
- .insert(FoamPatch);
+ // MONKEY (water LOD): High may swap this coarse water grid for a transient 4x
+ // near copy; magma/slime remain on their authored topology.
+ .insert((FoamPatch, LiquidLod::new(mesh_handle, lq)));
}
// The waterline for the camera sweep under `cameraWaterCollision`; nothing else queries it.
if let Some(collider) = liquid_collider(lq) {
@@ -308,9 +311,10 @@ pub(crate) fn spawn_wmo_liquids<'a>(
lq.kind, lq.sound_nibble
);
}
+ let mesh_handle = meshes.add(liquid_bevy_mesh(lq, body_color));
let surface = commands
.spawn((
- Mesh3d(meshes.add(liquid_bevy_mesh(lq, body_color))),
+ Mesh3d(mesh_handle.clone()),
MeshMaterial3d(material),
transform,
LiquidSurface,
@@ -330,7 +334,10 @@ pub(crate) fn spawn_wmo_liquids<'a>(
LiquidSource::WmoGroup(pool),
));
if !lq.kind.is_fullbright() {
- commands.entity(surface).insert(FoamPatch);
+ // MONKEY (water LOD): preserve the WMO grid boundary while refining its near interior.
+ commands
+ .entity(surface)
+ .insert((FoamPatch, LiquidLod::new(mesh_handle, lq)));
}
// The camera's waterline, model-local under the entity's placement `transform`.
if let Some(collider) = liquid_collider(lq) {