diff --git a/THIRD-PARTY.md b/THIRD-PARTY.md index e3d23475..2d816d8e 100644 --- a/THIRD-PARTY.md +++ b/THIRD-PARTY.md @@ -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. diff --git a/WATER.md b/WATER.md index 06061c30..e69de29b 100644 --- a/WATER.md +++ b/WATER.md @@ -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 , .`; 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. diff --git a/crates/benilla-assets/src/shaders/enhanced_water.wgsl b/crates/benilla-assets/src/shaders/enhanced_water.wgsl index 80d54670..924d5f88 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), @@ -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, // 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 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 - // MONKEY (p0 MonkeyFrame): the programme block after the point table (monkey_frame.wgsl). - monkey: monkey_frame::MonkeyFrame, -}; -@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>; + +// 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, // 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, }; @@ -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, 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; // 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, 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), @@ -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, 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); @@ -205,6 +212,43 @@ 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 { + 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 @@ -473,7 +517,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; @@ -485,8 +533,12 @@ 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; + 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; } @@ -516,14 +568,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 @@ -546,9 +606,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); } @@ -581,7 +646,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 @@ -625,7 +690,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); @@ -635,10 +700,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; @@ -654,8 +722,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; @@ -669,6 +737,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); @@ -718,8 +792,8 @@ 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 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(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 56bfd3e7..2ff2ab65 100644 --- a/crates/benilla-assets/src/shaders/liquid.wgsl +++ b/crates/benilla-assets/src/shaders/liquid.wgsl @@ -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(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(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 { // 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/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 31a4f939..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) { @@ -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::() + / 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 }, ), diff --git a/crates/benilla-world/src/liquid/waves.rs b/crates/benilla-world/src/liquid/waves.rs index aad4d247..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; @@ -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`),