Merge gfx/water into integration

enhanced_water.wgsl keeps the water lane's row view of the shared light buffer (fixes the WGSL
composer failure); the p0 fog hook reads the MonkeyFrame block through water_monkey() (row 533).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-25 07:53:11 +01:00
co-authored by Claude Opus 5.5
8 changed files with 590 additions and 160 deletions
+2 -2
View File
@@ -29,10 +29,10 @@ where they stand. Do not remove either.
| `crates/benilla-assets/src/shaders/enhanced_water.wgsl` (caustics) | `wxl-experimental-water` | `shaders/Surface.ps.hlsl`, `render/Noise.cpp` | Two caustic layers at incommensurate scales and rates, multiplied (ported; our layers are built from animated cell edges, not value noise) |
| `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-assets/src/shaders/enhanced_water.wgsl` (Gerstner crests, High) + `crates/benilla-world/src/liquid/waves.rs` | `wxl-experimental-water` | `shaders/Surface.ps.hlsl`, `sea/Spectrum.*`, `sea/Ocean.*` | Gerstner displacement and fold-driven crest whitecaps; the CPU swimmer sample inverts the same displacement (ported) |
| `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) |
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`
Planned (not yet in the tree): breaker index from `sea/Shore.hpp`, `shaders/Wave.hlsli`; grass wind from `wxl-experimental-wind`
(`field/Wind.*`, `grass/GrassWind.*`); 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.
-83
View File
@@ -1,83 +0,0 @@
# benilla-water
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
`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
(`Classic`) is untouched and stays byte-identical to upstream; the enhanced path only runs when the
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, a client extension for the 3.3.5a (build 12340) client (C++ / D3D9 / HLSL), used here as
reference code and ported to WGSL/Rust for the 1.12 renderer.
- **Author: iThorgrim.**
The WarcraftXL water module is the design reference for this one, and its author has given
permission to reuse its code here on the condition that the author and the original project are
named. This section is that attribution; it must travel with the module (this file, and the
`enhanced_water.wgsl` header) wherever the module is copied or shared.
Rule for contributors: a file that ports code or a technique from WarcraftXL says so in its header
(`Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module <module>, <source
files>.`; blocks inside a file keep their `Portions derived from WarcraftXL ...` line), the
technique is listed in the table below, and the file is listed in
[`THIRD-PARTY.md`](THIRD-PARTY.md), the one table of every WarcraftXL-derived file across the
fork. Do not remove any of them.
| Technique | Status here | From WarcraftXL |
|---|---|---|
| Visual-only wave riding for swimmers (authoritative position never moves) | shipped (`water_fx/bob.rs`) | same principle as `world/Ride.cpp`, implemented independently |
| Shore surf limited by the TERRAIN column, so objects in the water never foam | shipped (authored depth gate, `on_bed`) | principle from `sea/Shore.hpp` |
| Per-channel extinction + scene-copy refraction | shipped (`enhanced_water.wgsl` DEPTH LOOK; bent samples landing on anything in front of the water are refused via the scene depth) | ported from `shaders/Surface.ps.hlsl`, `render/Refraction.cpp` |
| Caustics, two layers at incommensurate scales/rates | shipped (floor web; built from animated cell edges, not value noise) | ported from `shaders/Surface.ps.hlsl`, `render/Noise.cpp` |
| Screen-space reflection of the scenery | shipped on `High` (a bisected march against the scene depth, not WXL's single probe) | principle from `shaders/Surface.ps.hlsl` |
| Gerstner trains + breaker index, crest-fold foam | planned | `sea/Spectrum.*`, `sea/Shore.hpp`, `shaders/Wave.hlsli` |
| Specular lobe widened by the normal's screen derivative, distance glints | shipped / planned | `shaders/Surface.ps.hlsl` |
## What it does
| Lane | What you see | Main switch |
|---|---|---|
| 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 |
| 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 |
| Swimmers | wake foam, treading rings and a visual-only bob with the swell | tier |
| Lava glow | magma surfaces emit warm point lights through the lighting module | Advanced Graphics → Lava Glow, `lavaLightGain` |
Rivers deliberately have NO flow effect: a derived current was tried and removed at the owner's
request. If it ever returns it should read the authored MCLQ flow records, not derive one.
## Where the code lives
- **Shader**: `crates/benilla-assets/src/shaders/enhanced_water.wgsl` - the whole module on the GPU
side, an importable library (`benilla::enhanced_water`) with its own bindings: 103 scene depth,
104 `WaterParams` (`benilla_assets::WaterUniform`), 105 the shared light buffer (a second
read-only view of upstream's binding 90), 106 the scene colour (for refraction and reflection). Upstream's `liquid.wgsl` carries three hooks only: the
`#import`, `water_swell` in its vertex stage, and the `water_active()` branch at the top of its
fragment stage, which returns the module's result as-is (the module fogs its own surface terms
only: the scene it refracts and reflects is already fogged). Keep it that way: new
water code goes in the module, not in `liquid.wgsl`.
- **Assets** (`crates/benilla-assets/src`): `water_depth.rs` (`WaterUniform`, the scene-depth image),
`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`.
- **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`.
## Invariants worth knowing before editing
- `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.
- 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.
- Run one GPU capture at a time, and run `cargo test` in a shell WITHOUT `WOW_DATA` exported.
@@ -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),
@@ -32,7 +33,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).
@@ -43,28 +44,27 @@ 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
// MONKEY (p0 MonkeyFrame): the programme block after the point table (monkey_frame.wgsl).
monkey: monkey_frame::MonkeyFrame,
};
@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>>;
// MONKEY (integration): the p0 MonkeyFrame block as seen through the water lane's row view — it
// starts right after the 21 fixed rows + the 512-row point table (row 533; buffer 8528 -> 8784 B).
const MONKEY_ROW: u32 = 533u;
fn water_monkey() -> monkey_frame::MonkeyFrame {
var m: monkey_frame::MonkeyFrame;
m.fog0 = water_light[MONKEY_ROW + 0u];
m.fog1 = water_light[MONKEY_ROW + 1u];
m.fog2 = water_light[MONKEY_ROW + 2u];
m.fog3 = water_light[MONKEY_ROW + 3u];
m.wind0 = water_light[MONKEY_ROW + 4u];
m.wind1 = water_light[MONKEY_ROW + 5u];
m.wet0 = water_light[MONKEY_ROW + 6u];
m.misc = water_light[MONKEY_ROW + 7u];
for (var i = 0u; i < 8u; i++) { m.benders[i] = water_light[MONKEY_ROW + 8u + i]; }
return m;
}
// What the fragment stage hands over: the three varyings this module reads.
struct WaterFragment {
@@ -72,6 +72,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,
};
@@ -82,17 +84,17 @@ 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;
// MONKEY (p0 fog hook): the shared fog law (fog_hook.wgsl), as a factor; classic is bit-identical.
return fog_hook::fog_sample(colour.rgb, span, eye_z, world_pos, view.world_position, true,
water_light.monkey);
water_monkey());
}
// True where this surface takes the Enhanced path: water (not magma/slime), a tier above
@@ -107,21 +109,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<f32>, 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<f32>, authored_depth: f32) -> vec3<f32> {
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<f32>(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<vec4<f32>, 8> = array<vec4<f32>, 8>(
// direction, wavelength, amplitude, phase offset
vec4<f32>(0.35, 18.0, 0.200, 0.0),
vec4<f32>(WATER_WIND_DIR, 18.0, 0.200, 0.0),
vec4<f32>(0.80, 12.8, 0.140, 1.7),
vec4<f32>(-0.18, 9.5, 0.090, 3.1),
vec4<f32>(0.52, 4.8, 0.055, 0.8),
@@ -160,7 +166,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
@@ -168,6 +174,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 {
@@ -180,10 +187,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);
@@ -205,6 +212,43 @@ fn water_waves(p: vec2<f32>, 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<f32>, t: f32, shore: f32) -> vec4<f32> {
let energy = clamp(water.mode.y, 0.0, 1.0);
let tempo = mix(0.4, 1.0, sqrt(energy));
var displacement = vec3<f32>(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<f32>(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<f32>(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<f32>(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<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
@@ -473,7 +517,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;
@@ -485,8 +533,12 @@ 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);
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;
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; }
@@ -516,14 +568,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
@@ -546,9 +606,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); }
@@ -581,7 +646,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
@@ -625,7 +690,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);
@@ -635,10 +700,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;
@@ -654,8 +722,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;
@@ -669,6 +737,12 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
let noise = 0.7 * foam_noise(p * 0.6 + t * vec2<f32>(0.025, -0.018))
+ 0.3 * foam_noise(p * 1.7 - t * vec2<f32>(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<f32>(ddx_depth, ddy_depth))
/ max(length(vec2<f32>(length(ddx_p), length(ddy_p))), 0.001);
@@ -718,8 +792,8 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> 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 illumination = water_light.light_ambient.rgb + water_light.light_diffuse.rgb;
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<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
@@ -145,8 +145,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<f32>(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 += vec4<f32>(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;
@@ -217,7 +217,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);
}
+301
View File
@@ -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<Mesh>` and are copied only
/// during an actual near-ring transition.
#[derive(Component)]
pub(super) struct LiquidLod {
coarse: Handle<Mesh>,
fine: Option<Handle<Mesh>>,
grid: [u32; 2],
wet: Vec<bool>,
local_center: Vec3,
local_radius_xz: f32,
}
impl LiquidLod {
pub(super) fn new(coarse: Handle<Mesh>, 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<WaterQuality>,
camera: Query<&GlobalTransform, With<WorldCamera>>,
mut surfaces: Query<(&GlobalTransform, &mut Mesh3d, &mut LiquidLod)>,
mut meshes: ResMut<Assets<Mesh>>,
) {
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<const N: usize>(
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<Mesh> {
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);
}
}
+2
View File
@@ -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);
}
}
+53 -7
View File
@@ -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) {
@@ -259,6 +262,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.
@@ -278,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,
@@ -300,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) {
@@ -421,10 +458,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 },
),
+100 -12
View File
@@ -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;
@@ -44,8 +46,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;
@@ -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`),