MONKEY (water): add Gerstner crest whitecaps
Co-Authored-By: GPT-5.6 Sol (Codex)
This commit is contained in:
co-authored by
GPT-5.6 Sol (Codex)
parent
46c60f6241
commit
b77393c98d
@@ -0,0 +1,7 @@
|
||||
# Third-party technique credits
|
||||
|
||||
## WarcraftXL
|
||||
|
||||
| Benilla component | Source | Author | Use |
|
||||
| --- | --- | --- | --- |
|
||||
| `enhanced_water.wgsl` Gerstner displacement and fold-driven High-quality whitecaps | [WarcraftXL `wxl-experimental-water`](https://github.com/WarcraftXL), `shaders/Surface.ps.hlsl`, `sea/Spectrum.*`, `sea/Ocean.*` | iThorgrim | Technique ported with the author's permission; attribution is required. |
|
||||
@@ -40,6 +40,7 @@ the technique is listed in the table below. Do not remove either.
|
||||
|---|---|---|
|
||||
| Quality tiers | `Classic` = the reference water, `Enhanced`, `High` (= Enhanced + scenery reflections) | Video options → Water Quality, cvar `waterQuality`, env `WOW_WATER=0\|1\|2` |
|
||||
| Procedural waves | multi-band analytic waves with exact normals; the long swell moves ocean vertices | tier |
|
||||
| Open-sea whitecaps | High adds Gerstner crest gathering and fold-thresholded foam; calm canals remain clear | tier |
|
||||
| Ocean / inland profiles | the sea and lakes/rivers have their own colour, energy and reflectivity | tier |
|
||||
| WMO pools | exterior canals use a calm drifting profile with outdoor glints; interiors use their authored MOMT colour and room-fog reflection | tier |
|
||||
| Depth look | light fades per channel over the real path through the water; the bed is seen through it, bent by the waves; sunlit shallows carry a caustic web | tier |
|
||||
@@ -80,4 +81,6 @@ request. If it ever returns it should read the authored MCLQ flow records, not d
|
||||
- Every screen derivative in `enhanced_water()` is taken at the top of the function (WGSL
|
||||
uniformity); WGSL only fails at pipeline creation, so verify by running a `water-*` scene.
|
||||
- Never rotate a noise domain by a per-pixel angle at world coordinates (~1e4 yd): it shears.
|
||||
- The temporary water wind is `WATER_WIND_DIR`; all wind-aligned shader terms derive from it until
|
||||
the weather lane supplies shared wind.
|
||||
- Run one GPU capture at a time, and run `cargo test` in a shell WITHOUT `WOW_DATA` exported.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#define_import_path benilla::enhanced_water
|
||||
|
||||
// Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module wxl-experimental-water, shaders/Surface.ps.hlsl, sea/Spectrum.*, sea/Ocean.*.
|
||||
// ENHANCED WATER - the optional water module (Video options -> Water Quality; see WATER.md).
|
||||
//
|
||||
// Credits: original project WarcraftXL (`wxl-experimental-water`, https://github.com/WarcraftXL),
|
||||
@@ -84,21 +85,25 @@ fn water_time() -> f32 {
|
||||
return globals.time;
|
||||
}
|
||||
|
||||
// Vertex lift for the ocean's long swell (0 everywhere else). `liquid/waves.rs` mirrors it on
|
||||
// the CPU for the swimmer bob; the two are one contract.
|
||||
fn water_swell(xz: vec2<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),
|
||||
@@ -137,7 +142,7 @@ const SHORE_PERIOD: f32 = 3.6; // seconds between arrivals; the swash ru
|
||||
const SHORE_TILT: f32 = 0.09; // peak crest steepness (tan of the tilt), slope-independent
|
||||
const SHORE_WARP_A: f32 = 0.22; // yards of depth — coarse crest wander (never ruler-straight)
|
||||
const SHORE_WARP_B: f32 = 0.10; // yards of depth — finer segmentation of the same crests
|
||||
const SHORE_WIND_DIR: f32 = 0.35; // = WATER_WAVES[0].x, the primary swell bearing
|
||||
const SHORE_WIND_DIR: f32 = WATER_WIND_DIR;
|
||||
|
||||
// Foam alphas. The owner rejected BOTH a thick icing sheet and straight stripes before this, so
|
||||
// every one of these is gated behind a noise breakup and a depth window; the numbers are the
|
||||
@@ -145,6 +150,7 @@ const SHORE_WIND_DIR: f32 = 0.35; // = WATER_WAVES[0].x, the primary swell
|
||||
const FOAM_WET_EDGE: f32 = 0.35; // the faint wet line where water meets anything solid
|
||||
const FOAM_SWASH: f32 = 0.62; // the sheet running up the sand and fading
|
||||
const FOAM_CREST: f32 = 0.88; // the white front of the last wave or two, and its lace
|
||||
const FOAM_WHITECAP: f32 = 0.68; // High-only open-sea crest fold
|
||||
const FOAM_MAX: f32 = 0.90; // hard ceiling on the sum
|
||||
|
||||
fn swell_shore_fade(depth: f32) -> f32 {
|
||||
@@ -182,6 +188,37 @@ fn water_waves(p: vec2<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> {
|
||||
@@ -472,6 +509,10 @@ 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 + rain_ripple_normal(in.world_position.xz, t);
|
||||
var n = normalize(vec3<f32>(-surf_grad.x, 1.0, -surf_grad.y));
|
||||
@@ -672,6 +713,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);
|
||||
@@ -721,7 +768,7 @@ 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 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.
|
||||
|
||||
@@ -137,8 +137,8 @@ fn vertex(in: Vertex) -> LiquidVsOut {
|
||||
let world_from_local = mesh_functions::get_world_from_local(in.instance_index);
|
||||
out.world_position =
|
||||
mesh_functions::mesh_position_local_to_world(world_from_local, vec4<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.xyz += water_swell(out.world_position.xz, in.uv_b.x);
|
||||
out.clip_position = position_world_to_clip(out.world_position.xyz);
|
||||
out.world_normal = mesh_functions::mesh_normal_local_to_world(in.normal, in.instance_index);
|
||||
out.uv = in.uv;
|
||||
|
||||
@@ -31,10 +31,12 @@ use bevy::math::Vec2;
|
||||
///
|
||||
/// Components 2..8 are the shader's fragment-only ripple band and are NOT here — see the module
|
||||
/// doc's `long_only` note. Adding them would be a second design, not a closer mirror.
|
||||
pub const LONG_SWELL: [[f32; 4]; 2] = [
|
||||
[0.35, 18.0, 0.200, 0.0],
|
||||
[0.80, 12.8, 0.140, 1.7],
|
||||
];
|
||||
pub const WATER_WIND_DIR: f32 = 0.35;
|
||||
pub const LONG_SWELL: [[f32; 4]; 2] =
|
||||
[[WATER_WIND_DIR, 18.0, 0.200, 0.0], [0.80, 12.8, 0.140, 1.7]];
|
||||
|
||||
/// Horizontal Gerstner gather, mirrored from `enhanced_water.wgsl::WATER_GERSTNER_CHOP`.
|
||||
pub const GERSTNER_CHOP: f32 = 2.4;
|
||||
|
||||
/// Gravity in yd/s², `enhanced_water.wgsl` — deep-water dispersion, `c = sqrt(g/k)`.
|
||||
const GRAVITY_YD: f32 = 10.72;
|
||||
@@ -60,6 +62,8 @@ pub struct Swell {
|
||||
/// `(∂height/∂x, ∂height/∂z)` in BEVY world axes, the shader's `result.yz`. The shader builds
|
||||
/// its normal from exactly this as `normalize(vec3(-grad.x, 1, -grad.y))` (`enhanced_water.wgsl`).
|
||||
pub grad: Vec2,
|
||||
/// Horizontal Gerstner displacement in BEVY XZ at the parameter-space point under this sample.
|
||||
pub horizontal: Vec2,
|
||||
}
|
||||
|
||||
/// `enhanced_water.wgsl`'s `swell_shore_fade`: the authored ocean depth `V` (byte/255, ≈148 yd at
|
||||
@@ -84,25 +88,69 @@ pub fn swell_shore_fade(depth_v: f32) -> f32 {
|
||||
/// * `long_only = true` — components 0 and 1, which are also the two the shore fade multiplies.
|
||||
///
|
||||
/// `world_xz` is BEVY world XZ. `shallow_fade` is the shore term (1 = open sea, 0 = beach).
|
||||
pub fn swell(world_xz: Vec2, time: f32, wave_energy: f32, shallow_fade: f32) -> Swell {
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct RawSwell {
|
||||
height: f32,
|
||||
grad: Vec2,
|
||||
horizontal: Vec2,
|
||||
jacobian: [f32; 4],
|
||||
}
|
||||
|
||||
/// Evaluate the same parameter-space Gerstner surface the vertex shader receives. The public
|
||||
/// sampler below inverts its horizontal map because callers hold a displaced world position.
|
||||
fn raw_swell(parameter_xz: Vec2, time: f32, wave_energy: f32, shallow_fade: f32) -> RawSwell {
|
||||
let energy = wave_energy.clamp(0.0, 1.0);
|
||||
// `mix(0.4, 1.0, sqrt(energy))` — a calm inland sheet also swells SLOWER, not merely lower.
|
||||
let tempo = 0.4 + 0.6 * energy.sqrt();
|
||||
let shore = shallow_fade.clamp(0.0, 1.0);
|
||||
let mut out = Swell::default();
|
||||
let mut out = RawSwell {
|
||||
jacobian: [1.0, 0.0, 0.0, 1.0],
|
||||
..RawSwell::default()
|
||||
};
|
||||
for w in LONG_SWELL {
|
||||
let dir = Vec2::new(w[0].cos(), w[0].sin());
|
||||
let k = TWO_PI / w[1];
|
||||
let speed = (GRAVITY_YD / k).sqrt(); // phase speed, yd/s
|
||||
let phase = k * (dir.dot(world_xz) - speed * tempo * time) + w[3];
|
||||
let phase = k * (dir.dot(parameter_xz) - speed * tempo * time) + w[3];
|
||||
// `fade` is 1 at zero footprint; `i < 2` ⇒ both components take the shore term.
|
||||
let amplitude = w[2] * energy * shore;
|
||||
out.height += amplitude * phase.sin();
|
||||
out.grad += dir * (amplitude * k * phase.cos());
|
||||
let horizontal = GERSTNER_CHOP * amplitude;
|
||||
out.horizontal += dir * (horizontal * phase.cos());
|
||||
let compression = horizontal * k * phase.sin();
|
||||
out.jacobian[0] -= compression * dir.x * dir.x;
|
||||
out.jacobian[1] -= compression * dir.x * dir.y;
|
||||
out.jacobian[2] -= compression * dir.y * dir.x;
|
||||
out.jacobian[3] -= compression * dir.y * dir.y;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The displaced Gerstner surface at a WORLD-space point. Four fixed-point steps find the original
|
||||
/// mesh coordinate under that point; the long-band Jacobian is contractive (worst compression
|
||||
/// below 0.34), so this converges without a branch or an allocation. The gradient is transformed
|
||||
/// through the same map, keeping swimmer lean consistent with the visibly pinched crest.
|
||||
pub fn swell(world_xz: Vec2, time: f32, wave_energy: f32, shallow_fade: f32) -> Swell {
|
||||
let mut parameter_xz = world_xz;
|
||||
for _ in 0..4 {
|
||||
let raw = raw_swell(parameter_xz, time, wave_energy, shallow_fade);
|
||||
parameter_xz = world_xz - raw.horizontal;
|
||||
}
|
||||
let raw = raw_swell(parameter_xz, time, wave_energy, shallow_fade);
|
||||
let [j00, j01, j10, j11] = raw.jacobian;
|
||||
let det = (j00 * j11 - j01 * j10).max(1.0e-4);
|
||||
let grad = Vec2::new(
|
||||
(j11 * raw.grad.x - j01 * raw.grad.y) / det,
|
||||
(-j10 * raw.grad.x + j00 * raw.grad.y) / det,
|
||||
);
|
||||
Swell {
|
||||
height: raw.height,
|
||||
grad,
|
||||
horizontal: raw.horizontal,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`swell`]'s height alone — the shape the brief names, for callers that do not want the slope.
|
||||
pub fn swell_height(world_xz: Vec2, time: f32, wave_energy: f32, shallow_fade: f32) -> f32 {
|
||||
swell(world_xz, time, wave_energy, shallow_fade).height
|
||||
@@ -167,8 +215,19 @@ mod tests {
|
||||
"enhanced_water.wgsl WATER_WAVES[0..2]"
|
||||
);
|
||||
assert_eq!(GRAVITY_YD, 10.72, "enhanced_water.wgsl gravity");
|
||||
assert_eq!(TWO_PI, 6.2831853, "enhanced_water.wgsl k = 6.2831853/wavelength");
|
||||
assert_eq!(OCEAN_WAVE_ENERGY, 1.0, "surface.rs water.mode.y for ADT ocean");
|
||||
assert_eq!(
|
||||
TWO_PI, 6.2831853,
|
||||
"enhanced_water.wgsl k = 6.2831853/wavelength"
|
||||
);
|
||||
assert_eq!(WATER_WIND_DIR, 0.35, "enhanced_water.wgsl WATER_WIND_DIR");
|
||||
assert_eq!(
|
||||
GERSTNER_CHOP, 2.4,
|
||||
"enhanced_water.wgsl WATER_GERSTNER_CHOP"
|
||||
);
|
||||
assert_eq!(
|
||||
OCEAN_WAVE_ENERGY, 1.0,
|
||||
"surface.rs water.mode.y for ADT ocean"
|
||||
);
|
||||
// The band's ceiling, stated in the shader's own comment ("at most 0.34 yd").
|
||||
let peak: f32 = LONG_SWELL.iter().map(|w| w[2]).sum();
|
||||
assert!((peak - 0.34).abs() < 1e-6, "amplitude sum {peak}");
|
||||
@@ -183,11 +242,13 @@ mod tests {
|
||||
let s = swell(Vec2::new(-9016.0, 226.0), t, 0.0, 1.0);
|
||||
assert_eq!(s.height, 0.0, "height at t={t}");
|
||||
assert_eq!(s.grad, Vec2::ZERO, "grad at t={t}");
|
||||
assert_eq!(s.horizontal, Vec2::ZERO, "horizontal at t={t}");
|
||||
}
|
||||
// The shore term is the other kill switch: a beached vertex does not move either.
|
||||
let beached = swell(Vec2::new(-9016.0, 226.0), 3.0, OCEAN_WAVE_ENERGY, 0.0);
|
||||
assert_eq!(beached.height, 0.0);
|
||||
assert_eq!(beached.grad, Vec2::ZERO);
|
||||
assert_eq!(beached.horizontal, Vec2::ZERO);
|
||||
}
|
||||
|
||||
/// The swell stays inside the band the shader advertises, and the gradient it reports really
|
||||
@@ -220,12 +281,39 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The CPU query samples by displaced world position while the vertex shader starts in mesh
|
||||
/// parameter space. Pin the inversion: adding the returned horizontal displacement to the
|
||||
/// recovered parameter must land back under the swimmer, with the advertised hard bound.
|
||||
#[test]
|
||||
fn gerstner_inverse_lands_under_the_world_sample() {
|
||||
let world = Vec2::new(-9016.0, 226.0);
|
||||
for step in 0..40 {
|
||||
let t = step as f32 * 0.37;
|
||||
let s = swell(world, t, OCEAN_WAVE_ENERGY, 1.0);
|
||||
let parameter = world - s.horizontal;
|
||||
let raw = raw_swell(parameter, t, OCEAN_WAVE_ENERGY, 1.0);
|
||||
let reprojection = parameter + raw.horizontal;
|
||||
assert!(
|
||||
(reprojection - world).length() < 2.0e-3,
|
||||
"reprojection at t={t}"
|
||||
);
|
||||
assert!(
|
||||
s.horizontal.length() <= 0.817,
|
||||
"horizontal at t={t}: {:?}",
|
||||
s.horizontal
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The shore fade's two ends, as the shader's `smoothstep(0.001, 0.025, depth)` places them.
|
||||
#[test]
|
||||
fn shore_fade_matches_the_shader_band() {
|
||||
assert_eq!(swell_shore_fade(0.0), 0.0);
|
||||
assert_eq!(swell_shore_fade(1.0), 1.0, "open sea (V = 1) is unfaded");
|
||||
assert!((swell_shore_fade(0.013) - 0.5).abs() < 0.02, "band midpoint");
|
||||
assert!(
|
||||
(swell_shore_fade(0.013) - 0.5).abs() < 0.02,
|
||||
"band midpoint"
|
||||
);
|
||||
}
|
||||
|
||||
/// The clock: a live run is the wrapped elapsed seconds verbatim (the GPU's `globals.time`),
|
||||
|
||||
Reference in New Issue
Block a user