Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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);
}