facett-map3d/src/water.wgsl
// **Reflective water** (Phase 2) — the headline. A tessellated water grid,
// Gerstner-wave vertex displacement by a `time` uniform, and a fragment that
// blends a planar **reflection** sample against a **depth-tinted refraction**
// colour via Schlick **Fresnel** (glancing → mirror, top-down → see-through),
// with shoreline foam from the scene depth. Native-first; the wasm path degrades
// to flat Fresnel-tinted water (no reflection sample) — see `reflect_on`.
struct WUniforms {
view_proj: mat4x4<f32>, // main camera view·projection
refl_vp: mat4x4<f32>, // mirror camera view·projection (for the reflection UV)
eye: vec4<f32>, // world-space eye (xyz) + reflect_on flag (w: 1=planar, 0=sky-only)
sun: vec4<f32>, // sun dir (xyz) + intensity (w)
sun_color: vec4<f32>, // sun colour (rgb) + time-seconds (w)
sky_color: vec4<f32>, // sky tint (rgb) + foam strength (w)
shallow: vec4<f32>, // shallow water tint (rgb) + wave amplitude (w)
deep: vec4<f32>, // deep water tint (rgb) + wave steepness (w)
params: vec4<f32>, // near (x), far (y) camera depth; max_depth (z); base wavelen (w)
};
@group(0) @binding(0) var<uniform> W: WUniforms;
@group(0) @binding(1) var refl_tex: texture_2d<f32>; // the planar reflection colour
@group(0) @binding(2) var refl_smp: sampler;
@group(0) @binding(3) var scene_depth: texture_depth_2d; // offscreen scene depth (NDC z) for the refraction tint
struct WVsIn { @location(0) pos: vec3<f32> };
struct WVsOut {
@builtin(position) clip: vec4<f32>,
@location(0) world: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) screen_uv: vec2<f32>,
@location(3) crest: f32, // wave-crest height (0..1) for foam
@location(4) ndc_depth: f32, // NDC z [0,1] of the water surface (for the depth tint)
};
const PI: f32 = 3.14159265359;
// One Gerstner wave: direction `d` (unit, xz), wavelength `wl`, amplitude `amp`,
// steepness `q`, speed `spd`. Adds to position offset + accumulates the partial
// derivatives for the analytic normal. Returns the y displacement contribution.
fn gerstner(
p: vec2<f32>, t: f32, d: vec2<f32>, wl: f32, amp: f32, q: f32, spd: f32,
dx: ptr<function, vec3<f32>>, dz: ptr<function, vec3<f32>>, off: ptr<function, vec3<f32>>
) -> f32 {
let k = 2.0 * PI / max(wl, 1e-4);
let c = spd;
let f = k * (dot(d, p) - c * t);
let a = amp;
let cosf = cos(f);
let sinf = sin(f);
// Horizontal pinch + vertical displacement.
let qa = q * a;
*off = *off + vec3<f32>(d.x * qa * cosf, a * sinf, d.y * qa * cosf);
// Partials for the normal (Tessendorf/GPU-Gems form).
*dx = *dx + vec3<f32>(-d.x * d.x * (qa * k) * sinf, d.x * (a * k) * cosf, -d.x * d.y * (qa * k) * sinf);
*dz = *dz + vec3<f32>(-d.x * d.y * (qa * k) * sinf, d.y * (a * k) * cosf, -d.y * d.y * (qa * k) * sinf);
return a * sinf;
}
@vertex
fn water_vs(in: WVsIn) -> WVsOut {
let t = W.sun_color.w;
let amp = W.shallow.w;
let q = W.deep.w;
let base_wl = W.params.w;
let p = in.pos.xz;
var off = vec3<f32>(0.0);
var dpx = vec3<f32>(1.0, 0.0, 0.0);
var dpz = vec3<f32>(0.0, 0.0, 1.0);
// 3 summed Gerstner waves (different dirs / wavelengths) → rolling ripples.
var crest = 0.0;
crest = crest + gerstner(p, t, normalize(vec2<f32>( 1.0, 0.3)), base_wl, amp, q, 0.9, &dpx, &dpz, &off);
crest = crest + gerstner(p, t, normalize(vec2<f32>(-0.6, 1.0)), base_wl * 0.55, amp * 0.6, q * 0.8, 1.3, &dpx, &dpz, &off);
crest = crest + gerstner(p, t, normalize(vec2<f32>( 0.2, -1.0)), base_wl * 0.32, amp * 0.35, q * 0.6, 1.7, &dpx, &dpz, &off);
let world = in.pos + off;
let n = normalize(cross(dpz, dpx));
var out: WVsOut;
out.clip = W.view_proj * vec4<f32>(world, 1.0);
out.world = world;
out.normal = n;
out.ndc_depth = out.clip.z / out.clip.w;
// Screen UV for the depth sample (NDC → [0,1], flip y).
let ndc = out.clip.xy / out.clip.w;
out.screen_uv = vec2<f32>(ndc.x * 0.5 + 0.5, 1.0 - (ndc.y * 0.5 + 0.5));
out.crest = clamp(crest / max(amp, 1e-4) * 0.5 + 0.5, 0.0, 1.0);
return out;
}
fn fresnel_schlick(cos_t: f32, f0: f32) -> f32 {
return f0 + (1.0 - f0) * pow(clamp(1.0 - cos_t, 0.0, 1.0), 5.0);
}
// **Analytic sky radiance** along a world direction (T3.2) — a compact twin of
// `sky.wgsl::sky_radiance` so the water's env-reflection FALLBACK (no planar pass)
// mirrors the same gradient + sun glow as the real skybox, instead of a flat tint.
// (Water carries no inverse-VP, so the reflected RAY is built from the wave normal;
// the horizon/zenith tints + sun come from the shared `SunSky` uniforms.)
fn water_sky_radiance(dir: vec3<f32>) -> vec3<f32> {
let l = normalize(W.sun.xyz);
let up = clamp(dir.y, -1.0, 1.0);
let t = pow(clamp(up, 0.0, 1.0), 0.42);
// Horizon = a warm sun-tinted haze; zenith = the cool sky tint.
let horizon = mix(W.sky_color.rgb, W.sun_color.rgb, 0.35);
var col = mix(horizon, W.sky_color.rgb, t);
let cosang = dot(dir, l);
let glow = pow(clamp(cosang, 0.0, 1.0), 350.0);
let haze_glow = pow(clamp(cosang * 0.5 + 0.5, 0.0, 1.0), 4.0) * 0.18;
col = col + W.sun_color.rgb * (glow * 2.0 + haze_glow);
return col;
}
@fragment
fn water_fs(in: WVsOut) -> @location(0) vec4<f32> {
let n = normalize(in.normal);
let v = normalize(W.eye.xyz - in.world);
let cos_v = max(dot(n, v), 0.0);
// ── Reflection sample ──
// Planar reflection: project the world point by the MIRROR camera, distort the
// UV by the wave normal, and sample the reflection texture. When planar is off
// (wasm / far tier), fall back to the ANALYTIC SKY evaluated along the reflected
// view ray (T3.2) — the same sky model the skybox paints, so the lake mirrors a
// real gradient + sun glow, not a flat colour.
//
// The sample is UNCONDITIONAL and the choice is a `select`, not an `if`. WGSL
// requires `textureSample` (implicit-LOD, needs derivatives) to sit in UNIFORM
// control flow, and both `reflect_on` (a uniform, but the analysis does not know
// that) and the per-fragment bounds test are non-uniform. naga accepts the `if`
// form; Chrome's validator (Tint) rejects the whole module with
// `'textureSample' must only be called from uniform control flow`, so the water
// pipeline was INVALID in every browser (measured 2026-09-18 on the built
// bundle) while every native test stayed green. Sampling a clamped UV when the
// result is then discarded costs one texture fetch and nothing else. Guarded by
// `xtask/tests/wgsl_compiles_in_the_browser.rs`, which runs Tint.
let reflect_on = W.eye.w;
let refl_ray = reflect(-v, n); // mirror the eye→surface ray about the wave normal
let sky_col = water_sky_radiance(refl_ray); // analytic sky fallback
let rc = W.refl_vp * vec4<f32>(in.world, 1.0);
// With planar off, `refl_vp` may be anything (identity, zero); never divide by 0.
let rw = select(rc.w, 1.0, abs(rc.w) < 1e-6);
var ruv = vec2<f32>(rc.x / rw * 0.5 + 0.5, 1.0 - (rc.y / rw * 0.5 + 0.5));
ruv = ruv + n.xz * 0.04; // wave distortion
let in_bounds = reflect_on > 0.5
&& all(ruv >= vec2<f32>(0.0)) && all(ruv <= vec2<f32>(1.0));
let sampled = textureSample(refl_tex, refl_smp, clamp(ruv, vec2<f32>(0.0), vec2<f32>(1.0))).rgb;
let refl_col = select(sky_col, sampled, in_bounds);
// ── Depth-based refraction colour (shallow → deep) ──
// Compare NDC depths: the offscreen depth buffer holds the scene bottom's NDC
// z; the water surface's own NDC z is in_ndc_depth. A larger gap = deeper water.
// (Both share the SAME camera view-proj, so the NDC-space difference is a valid
// monotone depth cue — exact metres aren't needed for the shallow→deep tint.)
let dims = vec2<f32>(textureDimensions(scene_depth));
let px = vec2<i32>(clamp(in.screen_uv, vec2<f32>(0.0), vec2<f32>(1.0)) * dims);
let scene_d = textureLoad(scene_depth, px, 0); // NDC z of the opaque scene
// Manual occlusion: discard the water fragment only when the opaque scene is
// CLEARLY nearer (a building stands between camera and lake). The epsilon is
// generous so Gerstner wave TROUGHS (which dip toward / just past the terrain
// plane the water floats on) are NOT punched out as holes — only real occluders
// (buildings, well in front) win.
if (in.ndc_depth > scene_d + 2e-3) {
discard;
}
let depth_diff = max(0.0, scene_d - in.ndc_depth); // NDC-space water column
let max_d = max(W.params.z, 1e-5);
let tdepth = clamp(depth_diff / max_d, 0.0, 1.0);
let water_col = mix(W.shallow.rgb, W.deep.rgb, tdepth);
// ── Fresnel blend reflection ↔ refraction ──
let f = fresnel_schlick(cos_v, 0.02);
var col = mix(water_col, refl_col, f);
// Sun specular glint on the wave crests.
let l = normalize(W.sun.xyz);
let h = normalize(l + v);
let spec = pow(max(dot(n, h), 0.0), 220.0) * W.sun.w;
col = col + W.sun_color.rgb * spec;
// ── Crest foam (+ a faint shoreline at genuinely shallow water) ──
// Without bathymetry the depth column is a thin film everywhere, so the shore
// term is kept faint + tight (a real basin would widen it). Crest foam is the
// dominant, reliable cue: white caps on the wave peaks.
let shore = (1.0 - smoothstep(0.0, 0.015 * max_d, depth_diff)) * 0.25;
let crest_foam = smoothstep(0.86, 1.0, in.crest);
let foam = clamp((shore + crest_foam) * W.sky_color.w, 0.0, 1.0);
col = mix(col, vec3<f32>(0.92, 0.95, 0.98), foam);
// HDR out: write **linear radiance** (no tonemap here) to match the opaque
// pass — the shared post stack (bloom + ACES) exposes + tonemaps the offscreen.
// Sun glints (col can exceed 1.0) survive to bloom.
// Slight transparency at the very shallow edge for a soft shoreline.
let alpha = mix(0.78, 1.0, tdepth);
return vec4<f32>(col, alpha);
}