// kaitlian — the harbor. // A single-scattering sky over a real sea surface, keyed to the visitor's clock. struct P { res: vec2f, minute: f32, time: f32, horizon: f32, camH: f32, fov: f32, detail: f32, _pad: f32, /* Disturbances on the water, in metres on the sea plane. xy is where, z is when it started, w is how hard. A ring travelling out from a point is the whole of it: no simulation, no ping-pong texture, and because it moves the surface NORMAL rather than a colour, it shows up in the reflection and the glitter path the way a real one would. The first 8 are presses. The other 24 are the wake: a pointer moving over the water leaves a dense trail of faint rings, and faint rings from a moving point interfere into the V that anything dragged through water draws behind it. Wake entries carry a NEGATIVE w so the shader can let them die fast; a press keeps its long settle. */ drops: array, }; @group(0) @binding(0) var p: P; const PI = 3.14159265359; const betaR = vec3f(5.5e-6, 13.0e-6, 22.4e-6); const betaM = vec3f(21e-6); const gM = 0.76; const Hr = 8000.0; const Hm = 1200.0; fn hash21(q: vec2f) -> f32 { var v = fract(q * vec2f(123.34, 456.21)); v += dot(v, v + 45.32); return fract(v.x * v.y); } // Preetham's optical-depth approximation. Near the horizon the path length // blows up, which is exactly what reddens a setting sun. fn depth(cosZen: f32, H: f32) -> f32 { let z = degrees(acos(clamp(cosZen, -1.0, 1.0))); return H / (max(cosZen, 0.0) + 0.15 * pow(max(93.885 - z, 0.001), -1.253)); } fn skyCol(dir: vec3f, sun: vec3f) -> vec3f { let mu = dot(dir, sun); let phaseR = 3.0 / (16.0 * PI) * (1.0 + mu * mu); let g2 = gM * gM; let phaseM = 3.0 / (8.0 * PI) * ((1.0 - g2) * (1.0 + mu * mu)) / ((2.0 + g2) * pow(1.0 + g2 - 2.0 * gM * mu, 1.5)); let sR = depth(dir.y, Hr); let sM = depth(dir.y, Hm); let ext = exp(-(betaR * sR + betaM * sM)); let inS = (betaR * phaseR + betaM * phaseM) / (betaR + betaM) * (1.0 - ext); // The sun's own path tints the light that arrives: low sun, long path, // blue scattered away, what is left is orange and red. let sunR = depth(max(sun.y, 0.0), Hr); let sunM = depth(max(sun.y, 0.0), Hm); var tint = exp(-(betaR * sunR + betaM * sunM) * 0.30); tint = mix(tint, vec3f(1.0), smoothstep(0.02, 0.46, dir.y) * 0.58); let sunUp = smoothstep(-0.185, 0.02, sun.y); let expo = mix(5.0, 7.4, smoothstep(0.02, 0.38, sun.y)); return inS * tint * expo * sunUp; } // Night is not black. Harbor Night is laid under the scattering and fades out // as the sun clears the horizon. fn nightGround(dirY: f32, night: f32) -> vec3f { // #16242F near the horizon to #0B141B overhead, in LINEAR light. These are // added under the scattering and then tone-mapped and gamma-encoded, so they // have to arrive as linear or the night lands two stops too bright. let g = mix(vec3f(0.0075, 0.0159, 0.0273), vec3f(0.0033, 0.0064, 0.0102), smoothstep(0.0, 0.62, dirY)); return g * night; } fn stars(dir: vec3f, fragXY: vec2f, night: f32) -> vec3f { if (night < 0.001 || dir.y <= 0.0) { return vec3f(0.0); } let sc = fragXY / max(p.res.y, 1.0) * 160.0; let cell = floor(sc); let f = fract(sc); let h = hash21(cell); if (h <= 0.9770) { return vec3f(0.0); } let at = vec2f(hash21(cell + 3.17), hash21(cell + 7.71)); let d = length(f - at); let mag = 0.35 + 0.65 * hash21(cell + 11.3); let star = smoothstep(0.16 * mag, 0.0, d) * mag; return star * night * smoothstep(0.0, 0.30, dir.y) * vec3f(0.95, 0.97, 1.0) * 0.62; } // ---- the sea ----------------------------------------------------------- // Height is a sum of directional waves. Each octave carries its own analytic // derivative, so the normal is exact rather than sampled, and each fades out // once its wavelength drops under the pixel footprint on the water. struct Surf { h: f32, dx: f32, dz: f32, lost: f32, total: f32 }; // One wave octave, with its analytic derivative. // // A pure directional sine stacks into a visible lattice no matter how many you // sum, so each octave here is a swell crossed by a weaker perpendicular swell: // the product is cellular, and the derivative is still exact. `sharp` pinches // the crests and flattens the troughs, the difference between a sine hill and // a swell. // // Each octave fades out before its wavelength drops under the pixel footprint, // and the amplitude it gives up is carried in `lost` so the shading can spend // it as roughness instead of losing the light altogether. fn waveOctave(pos: vec2f, dir: vec2f, len: f32, amp: f32, speed: f32, sharp: f32, cross: f32, t: f32, foot: f32, acc: ptr) { (*acc).total += amp; let vis = smoothstep(0.0, 1.0, clamp(len / max(foot * 4.2, 1e-5) - 0.28, 0.0, 1.0)); if (vis <= 0.0) { (*acc).lost += amp; return; } let k = 6.28318530718 / len; let perp = vec2f(-dir.y, dir.x); let kc = k * 0.71; let x = dot(dir, pos) * k + t * speed; let y = dot(perp, pos) * kc + t * speed * 0.43; let ex = exp(sharp * (sin(x) - 1.0)); let ey = exp(sharp * 0.8 * (sin(y) - 1.0)); let g = (1.0 - cross) + cross * ey; let dex = ex * sharp * cos(x) * k; let dey = ey * sharp * 0.8 * cos(y) * kc; let a = amp * vis; (*acc).h += a * (ex * g - 0.30); let grad = dir * (dex * g) + perp * (ex * cross * dey); (*acc).dx += a * grad.x; (*acc).dz += a * grad.y; (*acc).lost += amp * (1.0 - vis); } fn rot(v: vec2f, a: f32) -> vec2f { let c = cos(a); let s = sin(a); return vec2f(v.x * c - v.y * s, v.x * s + v.y * c); } // A spectrum rather than a handful of stripes: each octave is shorter, smaller, // faster, and turned by an angle that never comes back around over the stack, // and each one drags the sample point for the next so the water never settles // into a tile you can see. fn seaSurface(pos: vec2f, t: f32, foot: f32) -> Surf { var s = Surf(0.0, 0.0, 0.0, 0.0, 0.0); var d = vec2f(0.9563, 0.2923); var len = 46.0; var amp = 0.255; // The clock of the whole sea: every shorter octave runs 1.245x faster than // the one before, near the deep-water ratio for these wavelengths, so this // one number sets how quickly the water moves at rest. 0.50 read as hurried. var spd = 0.34; var sharp = 0.66; var cross = 0.58; var warp = vec2f(0.0); let n = select(6, 9, p.detail > 0.5); for (var i = 0; i < n; i = i + 1) { let before = vec2f(s.dx, s.dz); waveOctave(pos + warp, d, len, amp, spd, sharp, cross, t, foot, &s); warp += (vec2f(s.dx, s.dz) - before) * len * 0.13; d = rot(d, 2.0994); len *= 0.605; amp *= 0.632; spd *= 1.245; // The swells peak, the ripples round. Sharpness climbs through the three // gravity octaves and then DECAYS: a capillary with a pinched crest is a // small hard ridge, and a field of them reads as bumps rather than water. sharp = select(sharp * 0.90, min(sharp * 1.14, 1.95), i < 3); cross = min(cross * 1.06, 0.66); } return s; } /* Rings from whatever has touched the water. Returned as a slope, in the same units the wave octaves work in, so it simply adds to the surface derivative and everything downstream (Fresnel, the reflection, the glitter, the glints) picks it up without knowing it is there. The ring is a wave packet: a crest travelling outward at a fixed speed inside a Gaussian envelope, losing height with age and with the distance it has spread over. Capillary rings really do outrun their own envelope, which is why the leading edge reads as sharp and the middle as calm. */ fn ripples(pos: vec2f, t: f32, foot: f32) -> vec2f { var g = vec2f(0.0); // A ring finer than the pixel footprint is noise, not detail. let vis = smoothstep(0.0, 1.0, clamp(2.2 / max(foot * 5.0, 1e-5) - 0.30, 0.0, 1.0)); if (vis <= 0.0) { return g; } for (var i = 0; i < 32; i = i + 1) { let d = p.drops[i]; if (d.w == 0.0) { continue; } // A wake ring is one of many and lives briefly; a press ring settles alone. let trail = d.w < 0.0; let age = t - d.z; if (age < 0.0 || age > select(5.5, 2.2, trail)) { continue; } let r = pos - d.xy; let dist = length(r); if (dist > 60.0) { continue; } /* A wake ring spreads faster and longer than a splash ring. A pointer crosses this water at twenty metres a second and more; against a 3.1 m/s crest the V it draws would close to a sliver along its path, while a crest at 7.5 m/s opens it to the angle a real wake has. */ let x = dist - age * select(3.1, 7.5, trail); // where the crest has reached if (x > 3.6 || x < -12.0) { continue; } // outside the packet let k = 6.28318530718 / select(2.2, 3.2, trail); // ring wavelength /* The falloff has to suit the water this composition actually shows, which is fifteen to thirty metres out, not the five a wider lens would give. */ let env = exp(-x * x * select(0.16, 0.09, trail)) * exp(-age * select(0.85, 1.6, trail)) * (1.0 / (1.0 + dist * 0.11)); let slope = -sin(x * k) * env * k * abs(d.w); g += r / max(dist, 1e-4) * slope; } return g * vis; } // ---- the air ------------------------------------------------------------ fn vnoise(q: vec2f) -> f32 { let i = floor(q); let f = fract(q); let u = f * f * (3.0 - 2.0 * f); let a = hash21(i); let b = hash21(i + vec2f(1.0, 0.0)); let c = hash21(i + vec2f(0.0, 1.0)); let e = hash21(i + vec2f(1.0, 1.0)); return mix(mix(a, b, u.x), mix(c, e, u.x), u.y); } fn fbm(q0: vec2f, oct: i32) -> f32 { var q = q0; var v = 0.0; var a = 0.5; for (var i = 0; i < oct; i = i + 1) { v += a * vnoise(q); q = rot(q, 0.73) * 2.02; a *= 0.5; } return v; } fn fresnel(cosT: f32) -> f32 { let f = pow(clamp(1.0 - cosT, 0.0, 1.0), 5.0); return 0.02 + 0.98 * f; } // GGX, isotropic. Roughness grows with how much wave detail the pixel lost, so // the sun's glitter path stays continuous all the way to the horizon instead of // dying where the octaves fade. fn ggx(N: vec3f, V: vec3f, L: vec3f, rough: f32) -> f32 { let H = normalize(V + L); let a = max(rough * rough, 1e-4); let a2 = a * a; let NdH = max(dot(N, H), 0.0); let NdV = max(dot(N, V), 1e-4); let NdL = max(dot(N, L), 0.0); let d = NdH * NdH * (a2 - 1.0) + 1.0; let D = a2 / (PI * d * d); let k = a * 0.5; let gv = NdV / (NdV * (1.0 - k) + k); let gl = NdL / (NdL * (1.0 - k) + k); return D * gv * gl / (4.0 * NdV * NdL + 1e-4) * NdL; } // ---- clouds ------------------------------------------------------------- // A single high sheet, intersected by the view ray. Thin on purpose: enough to // catch the sunrise and give the sky a subject, never enough to become weather. struct Cloud { cover: f32, lit: f32 }; fn clouds(dir: vec3f, sun: vec3f, t: f32) -> Cloud { if (dir.y < 0.002) { return Cloud(0.0, 0.0); } // Projection onto a sheet overhead. The distance is capped rather than left // to run away at the horizon, and the cap sits BELOW where the deck has // already faded out: clamping inside the visible band freezes the pattern in // y and extrudes it into vertical stripes along the horizon. let reach = min(1.0 / max(dir.y, 0.0065), 155.0); // Two clocks move this deck. Wall-clock time drifts it, slowly, because that // is what high cloud actually does and anything faster reads as time-lapse. // The DAY moves it too: a scroll carries twenty-four hours past, and a day // that returned the same sky with different light would be a lie the reader // can feel. So the field advances about one period over the whole day, and // the weather arrives with the hours rather than on a timer of its own. let drift = vec2f(t * 0.0030, t * 0.0011) + vec2f(p.minute * 0.00062, p.minute * -0.00021); let q = dir.xz * reach * 0.34 + drift; let n = fbm(q * 1.15, 5); let wisp = fbm(q * 3.4 + vec2f(n * 0.9), 3); let shape = n * 0.78 + wisp * 0.30; // Coverage opens up overhead and thickens toward the horizon, the way a real // deck reads from a beach. `weather` gives the day clear hours and thick // ones, held inside a range that can neither empty the sky nor close it. let weather = 0.86 + 0.32 * vnoise(vec2f(p.minute * 0.0013, 11.3)); let band = mix(1.04, 0.86, smoothstep(0.03, 0.62, dir.y)) * weather; var cover = smoothstep(0.52, 0.84, shape * band + 0.06); cover *= smoothstep(0.010, 0.085, dir.y); // Cheap self-shadowing: the thick side goes dark and the side facing the sun // keeps a lit edge, so the deck has form instead of being a decal. let toward = clamp(dot(normalize(dir.xz), normalize(sun.xz + vec2f(1e-4))), -1.0, 1.0); let shade = smoothstep(0.86, 0.34, shape); return Cloud(cover, clamp(shade * 0.72 + toward * 0.28 + 0.22, 0.0, 1.0)); } // ---- one sky, sampled twice --------------------------------------------- // The water is a mirror, so whatever the sky is worth in a direction is what // the water is worth reflecting it. Both sides of the horizon call THIS, which // is the only reason the horizon is a meeting and not a seam: give the sea its // own specular model and the two disagree by a visible step exactly at y = 0. // // `blur` is the surface roughness the reflection carries. It widens the sun and // the moon into the lobe that draws a glitter path, and it is zero for the sky // itself, where the sun is a disc. fn skyFull(d: vec3f, sun: vec3f, moon: vec3f, night: f32, moonUp: f32, fragXY: vec2f, blur: f32) -> vec3f { var c = skyCol(d, sun); let ca = clamp(dot(d, sun), -1.0, 1.0); let ang = acos(ca); let w = 0.0125 + blur * 0.92; // A Gaussian lobe rather than a microfacet distribution: its peak is bounded // no matter how grazing the view gets, which is what keeps the horizon from // detonating into white. let lobe = exp(-(ang * ang) / (2.0 * w * w)) * (0.190 / w); let bloom = pow(max(ca, 0.0), 620.0) * 0.55 + pow(max(ca, 0.0), 44.0) * 0.16; c += (lobe + bloom * 2.1) * vec3f(1.0, 0.85, 0.63) * smoothstep(-0.14, 0.06, sun.y); c += stars(d, fragXY, night); c += nightGround(d.y, night); let cm = clamp(dot(d, moon), -1.0, 1.0); let am = acos(cm); let wm = 0.0098 + blur * 0.86; let mlobe = exp(-(am * am) / (2.0 * wm * wm)) * (0.040 / wm); let mglow = pow(max(cm, 0.0), 1400.0) * 0.30 + pow(max(cm, 0.0), 70.0) * 0.045; c += (mlobe + mglow) * vec3f(0.88, 0.91, 0.99) * moonUp; // clouds, lit by the same light that makes the sky, so they redden with it // and never argue with the palette let cl = clouds(d, sun, p.time); if (cl.cover > 0.001) { let sunUp = smoothstep(-0.185, 0.05, sun.y); let tint = exp(-(betaR * depth(max(sun.y, 0.0), Hr) + betaM * depth(max(sun.y, 0.0), Hm)) * 0.34); let ambient = skyCol(vec3f(0.0, 1.0, 0.0), sun) * 0.55 + vec3f(0.010, 0.014, 0.021) * night; let body = ambient + tint * mix(0.10, 0.95, cl.lit) * sunUp * 0.85; let fade = smoothstep(0.0, 0.11, d.y); c = mix(c, body, cl.cover * 0.90 * mix(0.35, 1.0, fade)); } return c; } @fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f { let aspect = p.res.x / p.res.y; // uv is top-origin; the scene is built bottom-up. let sv = vec2f(uv.x, 1.0 - uv.y); let fragXY = vec2f(uv.x * p.res.x, sv.y * p.res.y); // ---- the clock ------------------------------------------------------- // One turn of the whole day, not a half-day folded back on itself. // // This used to run on a half-day parameter wrapped by a modulo into // [-1.71, 1.71]. Elevation survived that, because cos is even and the wrap // lands on a matched pair. Azimuth did not: sin is odd, so at 01:15 it // changed sign between one minute and the next and whatever was lighting the // sky teleported across it. Invisible for the sun, which is far below the // horizon by then, and glaring for the moon, which is the only light up. // // A full-turn angle is periodic by construction, so there is no wrap and // nothing to be discontinuous at. The constants are chosen to keep what the // old curve gave: peak elevation 0.46, and zeroes at 06:15 and 20:15, which // is solar noon 13:15 with a 420-minute half-day. let a = 6.28318530718 * (p.minute - 795.0) / 1440.0; let el = max(0.36542 * cos(a) + 0.09458, -0.34); let az = -0.46 * sin(a); let sun = normalize(vec3f(az, el, 0.62)); let night = 1.0 - smoothstep(-0.175, -0.035, sun.y); // A full moon rises where the sun set, so the night keeps a light source. It // is held low on purpose: a moon at its true midnight altitude throws its // path onto water that is behind the reader, and the path is the point. let moonEl = 0.072 + 0.150 * clamp(-el / 0.271, 0.0, 1.0); let moon = normalize(vec3f(-az, moonEl, 0.62)); let moonUp = smoothstep(-0.02, 0.14, moon.y) * night; // ---- the ray --------------------------------------------------------- let fy = (sv.y - p.horizon) * p.fov; let fx = (sv.x - 0.5) * aspect * p.fov; let dir = normalize(vec3f(fx, fy, 0.62)); var col: vec3f; if (dir.y > 0.0) { col = skyFull(dir, sun, moon, night, moonUp, fragXY, 0.0); } else { // ---- the sea -------------------------------------------------------- let t = p.camH / max(-dir.y, 1e-5); let hit = vec2f(dir.x, dir.z) * t; // How much water one pixel covers, stretched by the grazing angle. This is // what decides which octaves survive and how rough the rest becomes. let foot = t * (p.fov / p.res.y) / max(-dir.y, 1e-5) * 0.62; var s = seaSurface(hit, p.time, foot); let ring = ripples(hit, p.time, foot); s.dx += ring.x; s.dz += ring.y; var N = normalize(vec3f(-s.dx, 1.0, -s.dz)); // Distance flattens the surface: the footprint that eats the octaves means // the normal cannot be trusted either. Once it is flat the reflected ray IS // the sky just above the horizon, and Fresnel at that angle is almost // total, so the far water becomes the sky by construction. That is why // there is no separate horizon blend; a blend is what puts a seam there. let flat = clamp(foot / 22.0, 0.0, 1.0); N = normalize(mix(N, vec3f(0.0, 1.0, 0.0), flat)); let V = -dir; var R = reflect(dir, N); R.y = max(R.y, 1.0e-5); R = normalize(R); // Slope the pixel could not resolve becomes roughness. Cox and Munk put a // calm sea's RMS slope near 0.15, which is the floor here; everything the // LOD threw away is added on top, so the glitter path widens with distance // instead of dying where the octaves stop. let lostFrac = clamp(s.lost / max(s.total, 1e-5), 0.0, 1.0); var rough = clamp(0.055 + lostFrac * 0.20 + flat * 0.055, 0.045, 0.26); // At the last degree before the horizon the reflection sharpens again: only // the forward-tilted facets are still visible from that angle, which is why // distant water mirrors a coastline cleanly and why a glitter path narrows // to a point instead of fanning out forever. rough = mix(rough, 0.030, smoothstep(0.020, 0.0022, -dir.y)); let refl = skyFull(R, sun, moon, night, moonUp, fragXY, rough); // The body of the water: Deep Harbor, opened toward Marina Blue by however // much light the sky is actually delivering. let daylight = clamp(skyCol(vec3f(0.0, 1.0, 0.0), sun).g * 1.35, 0.0, 1.0); let body = mix(vec3f(0.0125, 0.0300, 0.0455), vec3f(0.0250, 0.0990, 0.1210), daylight); // Light that gets through the shoulder of a wave carries the sea's green. let sss = clamp(s.h * 2.4 + 0.30, 0.0, 1.0) * pow(clamp(dot(V, sun), 0.0, 1.0), 2.0) * smoothstep(-0.02, 0.24, sun.y) * (1.0 - flat); let water = body + vec3f(0.050, 0.145, 0.124) * sss * 1.15; let F = fresnel(max(dot(N, V), 0.0)); col = mix(water, refl, F); // ---- glints ---------------------------------------------------------- // The roughness model spreads the sun's reflection into a smooth path, and // a smooth path is the one thing real water never has: what the eye reads // as sparkle is individual facets each catching the sun for a moment. The // LOD threw those facets away, so a few come back here as points. // // The cell size tracks the PIXEL FOOTPRINT rather than the water, which is // the whole trick: a fixed world-space grid draws 20-pixel squares in the // foreground and nothing at all at distance. Scaled this way a glint is // about three pixels wherever it lands. let near = 1.0 - flat; if (near > 0.01) { let cs = max(foot * 3.2, 1.0e-4); let c = hit / cs + vec2f(p.time * 0.17, p.time * 0.08); let cell = floor(c); let r = hash21(cell); if (r > 0.855) { let at = vec2f(hash21(cell + 5.13), hash21(cell + 9.71)); let d = length(fract(c) - at); let ph = fract(r * 31.7 + p.time * (0.45 + r * 0.85)); let flash = smoothstep(0.34, 0.0, d) * pow(max(sin(ph * PI), 0.0), 16.0); let toSun = acos(clamp(dot(R, sun), -1.0, 1.0)); let inSun = exp(-(toSun * toSun) / 0.26) * smoothstep(-0.05, 0.10, sun.y); let toMoon = acos(clamp(dot(R, moon), -1.0, 1.0)); let inMoon = exp(-(toMoon * toMoon) / 0.20) * moonUp; col += flash * near * F * (inSun * vec3f(1.0, 0.90, 0.72) * 3.2 + inMoon * vec3f(0.74, 0.82, 0.96) * 0.6); } } } // ---- the air between -------------------------------------------------- // Four kilometres of sea air sit between the eye and the horizon, and they // are what makes a horizon dissolve instead of cut. One term, applied to sky // and water alike, so the two meet in the same light. let hazeSky = skyCol(normalize(vec3f(sun.x * 0.5, 0.055, 0.62)), sun) + nightGround(0.055, night) * 1.6; let haze = exp(-abs(dir.y) * 21.0) * mix(0.44, 0.21, smoothstep(-0.06, 0.30, sun.y)); col = mix(col, hazeSky, clamp(haze, 0.0, 0.58)); col = vec3f(1.0) - exp(-col * 1.15); col = pow(col, vec3f(1.0 / 2.2)); col = mix(vec3f(dot(col, vec3f(0.2126, 0.7152, 0.0722))), col, 1.16); col += (hash21(fragXY) - 0.5) / 255.0; return vec4f(col, 1.0); }