LAB_08 // BEACH_SUNSET

BEACH
SUNSET

Three.js // GLSL Waves // Pick Your Weather

A stylized beach with a real wave shader: toon water, a tide that comes and goes, palms in the wind. Pick from four weathers, drag the sun across the sky, or sync time and weather to your real sky. Built in Blender, rendered in three.js.

01

THE_BEACH

PICK A WEATHER // WAIT FOR THE TIDE

SCENE_CONTROLS

VIEW BEACH
WEATHER SUNNY
TIME 19:18

DECK MODEL // "HQ_HOTEL OUTDOOR PLANTS SET" BY PANDORA-LAND // SIMPLIFIED AND RECOLORED FOR THIS SCENE

HOW IT'S MADE [+]
<template>
  <div ref="wrapRef" class="beach-wrapper">
    <canvas ref="canvasRef" class="beach-canvas"></canvas>
    <div v-if="loading" class="beach-loading font-mono" aria-hidden="true">
      LOADING_SCENE_<span class="beach-caret">▌</span>
    </div>
  </div>
</template>

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue"
import * as THREE from "three"
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"

// LAB_08: a stylized beach. Geometry comes from public/beach/beach.glb
// (authored by scripts/blender/beach_scene.py); sky, water, weather, tide and
// light are all shader-side here. The page owns every control and the SFX —
// this component only takes weather + time and reports lightning strikes.
type Weather = "sunny" | "cloudy" | "rain" | "storm"

const props = withDefaults(defineProps<{
  weather?: Weather
  timeOfDay?: number // 0..24, fractional hours
  view?: "beach" | "deck"
  paused?: boolean // sleep timer: freeze the scene, audio keeps running
}>(), {
  weather: "sunny",
  timeOfDay: 19.3,
  view: "beach",
  paused: false,
})

// camera poses: standing at the deck's edge vs seated on the HQ_HOTEL bench.
// the deck rides on the beach crest — its lift (DECK_DZ, printed by
// scripts/blender/beach_scene.py) is baked into the deck pose here
const CAM_VIEWS = {
  beach: { pos: new THREE.Vector3(0, 2.6, 8.5), look: new THREE.Vector3(0, 0.9, -6) },
  deck: { pos: new THREE.Vector3(2.44, 1.83, 11.4), look: new THREE.Vector3(1.4, 0.5, -2) },
}

const emit = defineEmits<{
  (e: "lightning", strength: number): void
  (e: "mix", m: { waves: number; rain: number; gulls: number; storm: number; night: number }): void
}>()

const wrapRef = ref<HTMLDivElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)
const loading = ref(true)

// mirrored from scripts/blender/beach_scene.py — the water shader re-derives
// the sand height under the waves from these for the shoreline foam
const SHORE_Z0 = 0.0
const SHORE_SLOPE = 0.09

/* ── weather presets, crossfaded via one exponential lerp ── */
const PRESETS: Record<Weather, {
  overcast: number; clouds: number; cloudTint: THREE.Color
  waveAmp: number; waveSpeed: number; chop: number; sway: number
  lightDim: number; fogFar: number; rain: number; lightningRate: number
}> = {
  sunny: { overcast: 0.05, clouds: 2, cloudTint: new THREE.Color("#fff6ee"), waveAmp: 0.05, waveSpeed: 1.0, chop: 0.1, sway: 0.3, lightDim: 1.0, fogFar: 40, rain: 0, lightningRate: 0 },
  cloudy: { overcast: 0.65, clouds: 5, cloudTint: new THREE.Color("#cfd4da"), waveAmp: 0.10, waveSpeed: 1.3, chop: 0.3, sway: 0.7, lightDim: 0.55, fogFar: 34, rain: 0, lightningRate: 0 },
  rain: { overcast: 0.85, clouds: 6, cloudTint: new THREE.Color("#9aa3ad"), waveAmp: 0.16, waveSpeed: 1.7, chop: 0.55, sway: 1.6, lightDim: 0.4, fogFar: 28, rain: 0.6, lightningRate: 0 },
  storm: { overcast: 1.0, clouds: 6, cloudTint: new THREE.Color("#5c6470"), waveAmp: 0.28, waveSpeed: 2.3, chop: 0.9, sway: 3.0, lightDim: 0.28, fogFar: 22, rain: 1.0, lightningRate: 5 },
}

const cur = {
  overcast: 0.05, clouds: 2, cloudTint: new THREE.Color("#fff6ee"),
  waveAmp: 0.05, waveSpeed: 1.0, chop: 0.1, sway: 0.3,
  lightDim: 1.0, fogFar: 55, rain: 0, lightningRate: 0,
}

/* ── time-of-day palette keyframes (hour → colors), lerped in paletteAt ── */
type Row = { h: number; horizon: THREE.Color; mid: THREE.Color; zenith: THREE.Color; sun: THREE.Color; sunI: number; amb: THREE.Color; stars: number }
const row = (h: number, horizon: string, mid: string, zenith: string, sun: string, sunI: number, amb: string, stars: number): Row => ({
  h, horizon: new THREE.Color(horizon), mid: new THREE.Color(mid), zenith: new THREE.Color(zenith), sun: new THREE.Color(sun), sunI, amb: new THREE.Color(amb), stars,
})
const PALETTE: Row[] = [
  row(0, "#141b38", "#0b1226", "#030510", "#93a7ff", 0.0, "#242a40", 1),
  row(4.5, "#182040", "#0d142c", "#040714", "#93a7ff", 0.0, "#272d44", 1),
  row(6, "#ffb08a", "#8090c2", "#2b3a66", "#ffd9b0", 0.45, "#757d9a", 0.15),
  row(8, "#d3ecff", "#92c6f2", "#59a2e8", "#fff2d2", 1.0, "#a8bccf", 0),
  row(12, "#dcf3ff", "#a2d6f7", "#58acf0", "#fff8e8", 1.12, "#b6c6d6", 0),
  row(17.5, "#ffd9a3", "#a9c4e8", "#5f8fd0", "#ffe8b8", 1.0, "#a9b4c4", 0),
  row(19.3, "#ff9d5c", "#d97ba0", "#4a5d9e", "#ffb36b", 0.85, "#a58c96", 0.02),
  row(20.5, "#e06a63", "#7a5a95", "#232f5c", "#ff8a5c", 0.25, "#5c5a78", 0.4),
  row(21.8, "#182040", "#0d142c", "#040714", "#93a7ff", 0.0, "#272d44", 1),
  row(24, "#141b38", "#0b1226", "#030510", "#93a7ff", 0.0, "#242a40", 1),
]
const pal = { horizon: new THREE.Color(), mid: new THREE.Color(), zenith: new THREE.Color(), sun: new THREE.Color(), sunI: 0, amb: new THREE.Color(), stars: 0 }

function paletteAt(h: number) {
  let a = PALETTE[0], b = PALETTE[PALETTE.length - 1]
  for (let i = 0; i < PALETTE.length - 1; i++) {
    if (h >= PALETTE[i].h && h <= PALETTE[i + 1].h) { a = PALETTE[i]; b = PALETTE[i + 1]; break }
  }
  const t = a.h === b.h ? 0 : (h - a.h) / (b.h - a.h)
  pal.horizon.lerpColors(a.horizon, b.horizon, t)
  pal.mid.lerpColors(a.mid, b.mid, t)
  pal.zenith.lerpColors(a.zenith, b.zenith, t)
  pal.sun.lerpColors(a.sun, b.sun, t)
  pal.amb.lerpColors(a.amb, b.amb, t)
  pal.sunI = a.sunI + (b.sunI - a.sunI) * t
  pal.stars = a.stars + (b.stars - a.stars) * t
}

/* ── shaders ── */
const SKY_VERT = /* glsl */ `
varying vec3 vDir;
void main() {
  vDir = normalize(position);
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`

const SKY_FRAG = /* glsl */ `
uniform vec3 uHorizon; uniform vec3 uMid; uniform vec3 uZenith;
uniform vec3 uSunDir; uniform vec3 uSunColor;
uniform float uStars; uniform float uOvercast; uniform float uFlash;
varying vec3 vDir;
float hash21(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
void main() {
  vec3 dir = normalize(vDir);
  float y = clamp(dir.y, -0.1, 1.0);
  vec3 col = mix(uHorizon, uMid, smoothstep(0.0, 0.28, y));
  col = mix(col, uZenith, smoothstep(0.22, 0.75, y));
  float d = dot(dir, normalize(uSunDir));
  col += uSunColor * (smoothstep(0.9989, 0.9995, d) + pow(max(d, 0.0), 28.0) * 0.35) * (1.0 - uOvercast * 0.85);
  // the moon rises where the sun set: anti-solar, only once it is dark
  float m = dot(dir, normalize(-uSunDir));
  vec3 moonCol = vec3(0.92, 0.94, 1.0);
  col += moonCol * (smoothstep(0.9985, 0.9994, m) * 0.85 + pow(max(m, 0.0), 40.0) * 0.16)
       * uStars * (1.0 - uOvercast * 0.9);
  vec2 sp = dir.xz / (0.28 + dir.y) * 14.0;
  float star = smoothstep(0.93, 0.99, hash21(floor(sp)));
  star *= smoothstep(0.28, 0.06, length(fract(sp) - 0.5)); // a dot, not the whole cell
  col += vec3(star) * uStars * (1.0 - uOvercast) * smoothstep(0.05, 0.3, y) * 0.9;
  float g = dot(col, vec3(0.333));
  col = mix(col, vec3(g) * 0.72 + uHorizon * 0.1, uOvercast * 0.8);
  col *= mix(1.0, 0.42, uOvercast * uOvercast); // heavy skies get dark, not just gray
  col += uFlash;
  gl_FragColor = vec4(col, 1.0);
  #include <colorspace_fragment>
}`

// one toon material for every GLB mesh: vertex colors, banded sun light,
// height-weighted wind sway (palms) and a wet darkening band (sand)
const TOON_VERT = /* glsl */ `
uniform float uTime; uniform float uSwayAmp; uniform float uPalmHeight;
varying vec3 vN; varying vec3 vWp; varying vec3 vCol;
#include <fog_pars_vertex>
void main() {
  vec3 p = position;
  float w = pow(smoothstep(0.0, uPalmHeight, p.y), 2.0) * uSwayAmp;
  float ph = modelMatrix[3][0] * 1.7; // per-object phase from world x
  // storm wind: a steady downwind lean plus layered whip and flutter
  float osc = sin(uTime * 1.7 + ph) + 0.5 * sin(uTime * 3.1 + ph) + 0.35 * sin(uTime * 6.3 + ph * 1.7);
  p.x += w * (2.2 + osc);
  p.z += w * 0.6 * sin(uTime * 2.3 + ph);
  #if defined( USE_COLOR_ALPHA )
    vCol = color.rgb;
  #elif defined( USE_COLOR )
    vCol = color;
  #else
    vCol = vec3(1.0);
  #endif
  vN = normalize(mat3(modelMatrix) * normal);
  vec4 wp = modelMatrix * vec4(p, 1.0);
  vWp = wp.xyz;
  vec4 mvPosition = viewMatrix * wp;
  gl_Position = projectionMatrix * mvPosition;
  #include <fog_vertex>
}`

const TOON_FRAG = /* glsl */ `
uniform vec3 uSunDir; uniform vec3 uSunColor; uniform vec3 uAmbient;
uniform float uWetLevel; uniform float uWetDarken;
uniform vec3 uLanternA; uniform vec3 uLanternB; uniform float uLanternGlow;
varying vec3 vN; varying vec3 vWp; varying vec3 vCol;
#include <fog_pars_fragment>
void main() {
  vec3 n = normalize(vN) * (gl_FrontFacing ? 1.0 : -1.0);
  float d = dot(n, normalize(uSunDir));
  float band = 0.5 + 0.22 * smoothstep(-0.05, 0.05, d) + 0.28 * smoothstep(0.4, 0.5, d);
  vec3 col = vCol * (uAmbient + uSunColor * band);
  col *= mix(1.0, uWetDarken, smoothstep(uWetLevel + 0.04, uWetLevel - 0.06, vWp.y));
  // cozy pools of candlelight around the two deck lanterns
  float dl = distance(vWp, uLanternA);
  float dr = distance(vWp, uLanternB);
  col += vec3(1.0, 0.55, 0.22) * uLanternGlow * (1.0 / (1.0 + 5.0 * dl * dl) + 1.0 / (1.0 + 5.0 * dr * dr));
  gl_FragColor = vec4(col, 1.0);
  #include <fog_fragment>
  #include <colorspace_fragment>
}`

// fairy lights: warm twinkling bulbs spiralling up the palm trunks. They ride
// the exact same wind math as the palm sway shader so the string never
// detaches from its tree.
const FAIRY_VERT = /* glsl */ `
uniform float uTime; uniform float uSway;
attribute float aSeed; attribute vec3 aBase; attribute float aH; attribute float aAmp;
varying float vTw; varying float vSeed;
void main() {
  vec3 p = position;
  // identical wind math to the palm shader, per-bulb base/height/amp baked in
  float w = pow(smoothstep(0.0, aH, p.y - aBase.y), 2.0) * uSway * aAmp;
  float ph = aBase.x * 1.7;
  float osc = sin(uTime * 1.7 + ph) + 0.5 * sin(uTime * 3.1 + ph) + 0.35 * sin(uTime * 6.3 + ph * 1.7);
  p.x += w * (2.2 + osc);
  p.z += w * 0.6 * sin(uTime * 2.3 + ph);
  vTw = 0.65 + 0.35 * sin(uTime * (1.2 + fract(aSeed) * 1.6) + aSeed * 21.0);
  vSeed = aSeed;
  vec4 mv = modelViewMatrix * vec4(p, 1.0);
  gl_PointSize = 46.0 / max(1.0, -mv.z);
  gl_Position = projectionMatrix * mv;
}`

const FAIRY_FRAG = /* glsl */ `
uniform float uNight;
varying float vTw; varying float vSeed;
void main() {
  float r = length(gl_PointCoord - 0.5);
  float a = smoothstep(0.5, 0.12, r) * vTw * uNight;
  vec3 warm = mix(vec3(1.0, 0.72, 0.32), vec3(1.0, 0.55, 0.45), fract(vSeed * 7.31));
  gl_FragColor = vec4(warm, a);
  #include <colorspace_fragment>
}`

// aura orbs: big, very soft pastel glows drifting slowly over the deck
const AURA_VERT = /* glsl */ `
uniform float uTime;
attribute float aSeed;
varying float vSeed;
void main() {
  vec3 p = position;
  p.x += sin(uTime * 0.22 + aSeed * 9.0) * 0.55;
  p.y += sin(uTime * 0.17 + aSeed * 5.0) * 0.3;
  p.z += cos(uTime * 0.19 + aSeed * 7.0) * 0.55;
  vSeed = aSeed;
  vec4 mv = modelViewMatrix * vec4(p, 1.0);
  gl_PointSize = 260.0 / max(1.0, -mv.z);
  gl_Position = projectionMatrix * mv;
}`

const AURA_FRAG = /* glsl */ `
uniform float uNight;
varying float vSeed;
void main() {
  float r = length(gl_PointCoord - 0.5);
  float a = smoothstep(0.5, 0.0, r);
  a = a * a * 0.26 * uNight;
  float pick = fract(vSeed * 3.7);
  vec3 hue = pick < 0.34 ? vec3(1.0, 0.72, 0.35)
           : pick < 0.67 ? vec3(0.65, 0.55, 1.0)
           : vec3(0.45, 0.9, 0.8);
  gl_FragColor = vec4(hue, a);
  #include <colorspace_fragment>
}`

// fireflies: soft blinking dots wandering around the beds at night
const FIREFLY_VERT = /* glsl */ `
uniform float uTime;
attribute float aSeed;
varying float vBlink;
void main() {
  vec3 p = position;
  p.x += sin(uTime * 0.31 + aSeed * 7.0) * 0.8;
  p.y += sin(uTime * 0.43 + aSeed * 13.0) * 0.35;
  p.z += cos(uTime * 0.27 + aSeed * 5.0) * 0.8;
  vBlink = smoothstep(0.35, 0.9, sin(uTime * 0.8 + aSeed * 17.0));
  vec4 mvPosition = modelViewMatrix * vec4(p, 1.0);
  gl_PointSize = 90.0 / max(1.0, -mvPosition.z);
  gl_Position = projectionMatrix * mvPosition;
}`

const FIREFLY_FRAG = /* glsl */ `
uniform float uNight;
varying float vBlink;
void main() {
  float r = length(gl_PointCoord - 0.5);
  float a = smoothstep(0.5, 0.1, r) * vBlink * uNight;
  gl_FragColor = vec4(1.0, 0.85, 0.35, a);
  #include <colorspace_fragment>
}`

const WATER_VERT = /* glsl */ `
uniform float uTime; uniform float uTide; uniform float uSurge;
uniform float uWaveAmp; uniform float uWaveSpeed; uniform float uChop;
uniform float uShoreZ0; uniform float uShoreSlope;
varying vec3 vN; varying vec3 vWp;
varying float vRelH; varying float vDepth0;
#include <fog_pars_vertex>
void main() {
  vec3 p = position;
  vec4 wp0 = modelMatrix * vec4(p, 1.0);
  // waves keep most of their height into the shallows and collapse right at
  // the waterline — the fragment shader paints the break there
  float depth0 = -(uShoreSlope * (wp0.z - uShoreZ0));
  float att = smoothstep(-0.02, 0.35, depth0);
  // 3 directional sines, amplitudes relative to uWaveAmp
  vec2 d1 = normalize(vec2(0.2, 1.0)); vec2 d2 = normalize(vec2(-0.6, 1.0)); vec2 d3 = normalize(vec2(0.9, 0.4));
  float k1 = 0.9, k2 = 1.7, k3 = 3.1;
  float s = uTime * uWaveSpeed;
  // negative time term: crests travel along +d, i.e. toward the beach
  float p1 = dot(d1, wp0.xz) * k1 - s * 1.1;
  float p2 = dot(d2, wp0.xz) * k2 - s * 1.7;
  float p3 = dot(d3, wp0.xz) * k3 - s * 2.6;
  float a1 = uWaveAmp * att, a2 = uWaveAmp * 0.55 * att, a3 = uWaveAmp * 0.3 * att;
  float h = a1 * sin(p1) + a2 * sin(p2) + a3 * sin(p3);
  float dhx = a1 * k1 * d1.x * cos(p1) + a2 * k2 * d2.x * cos(p2) + a3 * k3 * d3.x * cos(p3);
  float dhz = a1 * k1 * d1.y * cos(p1) + a2 * k2 * d2.y * cos(p2) + a3 * k3 * d3.y * cos(p3);
  // waves steepen where the water gets shallow
  float chopEff = uChop * (1.0 + 1.4 * (1.0 - smoothstep(0.1, 0.8, depth0)));
  p.xz += (d1 * cos(p1) * a1 + d2 * cos(p2) * a2) * chopEff;
  // swash: the whole near-shore sheet surges up the sand and drains back,
  // which walks the waterline meters up the beach in a storm
  float shoreBand = 1.0 - smoothstep(0.1, 0.55, depth0);
  p.y += h + uTide + uSurge * shoreBand;
  vRelH = h / max(uWaveAmp * 1.85, 0.001);
  vDepth0 = depth0;
  vN = normalize(vec3(-dhx, 1.0, -dhz));
  vec4 wp = modelMatrix * vec4(p, 1.0);
  vWp = wp.xyz;
  vec4 mvPosition = viewMatrix * wp;
  gl_Position = projectionMatrix * mvPosition;
  #include <fog_vertex>
}`

const WATER_FRAG = /* glsl */ `
uniform vec3 uShallow; uniform vec3 uDeep; uniform vec3 uFoamColor; uniform vec3 uSkyTint;
uniform vec3 uSunDir; uniform vec3 uSunColor; uniform vec3 uAmbient;
uniform float uTime; uniform float uOvercast; uniform float uShoreZ0; uniform float uShoreSlope;
uniform float uSurge;
varying vec3 vN; varying vec3 vWp;
varying float vRelH; varying float vDepth0;
#include <fog_pars_fragment>
float hash21(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
void main() {
  // analytic sand height below this fragment → depth proxy + foam band
  float sandY = uShoreSlope * (vWp.z - uShoreZ0);
  float depth = vWp.y - sandY;
  float t = smoothstep(0.05, 0.12, depth) * 0.34 + smoothstep(0.22, 0.3, depth) * 0.33 + smoothstep(0.42, 0.52, depth) * 0.33;
  vec3 col = mix(uShallow, uDeep, t);
  float wob = (hash21(floor(vWp.xz * 6.0 + vec2(uTime * 0.8, 0.0))) - 0.5) * 0.05;
  float swash = 0.035 * sin(uTime * 0.7); // the waterline breathes
  // the running-up sheet turns white — the crash reads as a broad wash
  float foam = smoothstep(0.14 + wob + swash + uSurge * 1.1, 0.03 + wob + swash, depth);
  // breakers: crests whiten as they roll into the shallow band
  foam += smoothstep(0.3, 0.75, vRelH) * smoothstep(0.8, 0.12, vDepth0);
  foam = clamp(foam, 0.0, 1.0);
  col = mix(col, uFoamColor, foam * 0.92);
  vec3 n = normalize(vN);
  vec3 view = normalize(cameraPosition - vWp);
  float spec = pow(max(dot(reflect(-normalize(uSunDir), n), view), 0.0), 120.0);
  col += uSunColor * smoothstep(0.2, 0.75, spec) * 0.5 * (1.0 - uOvercast);
  vec3 light = clamp(uAmbient * 1.15 + uSunColor * 0.5 + vec3(0.12), 0.0, 1.35);
  col *= light;
  // grazing angles reflect the sky, so the far water melts into the horizon
  float fresnel = pow(1.0 - max(dot(view, n), 0.0), 3.0);
  col = mix(col, uSkyTint, fresnel * (1.0 - foam) * 0.6);
  float g = dot(col, vec3(0.333));
  col = mix(col, vec3(g) * 0.9, uOvercast * 0.45);
  gl_FragColor = vec4(col, 1.0);
  #include <fog_fragment>
  #include <colorspace_fragment>
}`

// gulls: one chevron per bird, wingtips flap in the vertex shader
const BIRD_VERT = /* glsl */ `
uniform float uTime;
#include <fog_pars_vertex>
void main() {
  vec3 p = position;
  float wing = smoothstep(0.08, 0.5, abs(p.x));
  p.y += sin(uTime * 9.0 + modelMatrix[3][0] * 3.1) * 0.24 * wing;
  vec4 mvPosition = modelViewMatrix * vec4(p, 1.0);
  gl_Position = projectionMatrix * mvPosition;
  #include <fog_vertex>
}`

const BIRD_FRAG = /* glsl */ `
uniform vec3 uColor; uniform float uOpacity;
#include <fog_pars_fragment>
void main() {
  gl_FragColor = vec4(uColor, uOpacity);
  #include <fog_fragment>
  #include <colorspace_fragment>
}`

const RAIN_VERT = /* glsl */ `
uniform float uTime; uniform float uWind;
attribute float aOffset; attribute float aEnd;
void main() {
  vec3 p = position;
  float h = 7.0;
  p.y = mod(p.y - uTime * 9.0 + aOffset, h);
  p.y -= aEnd * 0.35;
  // wind-driven slant with slow gusts; streaks align with the fall direction
  float slant = uWind * (0.8 + 0.4 * sin(uTime * 0.7));
  p.x += (h - p.y) * slant;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}`

const RAIN_FRAG = /* glsl */ `
uniform float uRain;
void main() {
  gl_FragColor = vec4(0.62, 0.72, 0.85, uRain * 0.45);
  #include <colorspace_fragment>
}`

/* ── scene state ── */
let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene
let camera: THREE.PerspectiveCamera
let fog: THREE.Fog
let skyMat: THREE.ShaderMaterial
let waterMat: THREE.ShaderMaterial
let rainMat: THREE.ShaderMaterial
let rainMesh: THREE.LineSegments
let toonMats: THREE.ShaderMaterial[] = []
let sandMat: THREE.ShaderMaterial | null = null
let palmMats: THREE.ShaderMaterial[] = []
let clouds: { mesh: THREE.Mesh; mat: THREE.MeshBasicMaterial; speed: number; baseOpacity: number; baseScale: number; baseY: number }[] = []
let birds: { mesh: THREE.Mesh; cx: number; cy: number; cz: number; r: number; speed: number; theta: number }[] = []
let birdMat: THREE.ShaderMaterial
let boltLine: THREE.Mesh
let boltMat: THREE.MeshBasicMaterial
let boltX = 0
let boltZ = -15
// the fairy-lit palms: trunk curve params mirrored from build_palm in
// beach_scene.py (base, height, lean per t^2), plus warm pools at their feet
const FAIRY_PALMS = [
  { base: [-3.2, 0.18, 2.8] as const, h: 2.6, lean: [0.275, -0.33] as const },
  { base: [4.0, 0.13, 3.6] as const, h: 2.132, lean: [-0.385, -0.22] as const },
]
let fairyLights: THREE.Points | null = null
let fairyLightMat: THREE.ShaderMaterial
let auraMat: THREE.ShaderMaterial
let fireflyMat: THREE.ShaderMaterial
let raf = 0
let running = false
let io: IntersectionObserver | undefined
let reduced = false
let elapsed = 0
let wetLevel = -10
let strikeT = -1
let strikeDur = 0
let nextStrike = 0
const camPos = CAM_VIEWS.beach.pos.clone()
const camLook = CAM_VIEWS.beach.look.clone()
let floraMats: THREE.ShaderMaterial[] = []
let mixT = 0
const sunDir = new THREE.Vector3(0, 1, 0)
const sunColor = new THREE.Color()
const ambient = new THREE.Color()
const cScratch = new THREE.Color()

const fogUniforms = () => ({
  fogColor: { value: new THREE.Color() },
  fogNear: { value: 14 },
  fogFar: { value: 55 },
})

function makeToon(overrides: Partial<{ swayAmp: number; palmHeight: number; wetDarken: number }> = {}) {
  const m = new THREE.ShaderMaterial({
    vertexShader: TOON_VERT,
    fragmentShader: TOON_FRAG,
    vertexColors: true,
    fog: true,
    side: THREE.DoubleSide, // palm fronds are single-sided strips
    uniforms: {
      ...fogUniforms(),
      uTime: { value: 0 },
      uSwayAmp: { value: overrides.swayAmp ?? 0 },
      uPalmHeight: { value: overrides.palmHeight ?? 2.6 },
      uSunDir: { value: sunDir },
      uSunColor: { value: new THREE.Color() },
      uAmbient: { value: new THREE.Color() },
      uWetLevel: { value: -10 },
      uWetDarken: { value: overrides.wetDarken ?? 0.62 },
      uLanternA: { value: new THREE.Vector3(FAIRY_PALMS[0].base[0], 0.5, FAIRY_PALMS[0].base[2]) },
      uLanternB: { value: new THREE.Vector3(FAIRY_PALMS[1].base[0], 0.45, FAIRY_PALMS[1].base[2]) },
      uLanternGlow: { value: 0 },
    },
  })
  toonMats.push(m)
  return m
}

function buildScene() {
  const canvas = canvasRef.value!
  renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
  scene = new THREE.Scene()
  fog = new THREE.Fog(0x000000, 14, 55)
  scene.fog = fog
  camera = new THREE.PerspectiveCamera(45, 1, 0.1, 130)
  camera.position.copy(camPos)
  camera.lookAt(camLook)

  skyMat = new THREE.ShaderMaterial({
    vertexShader: SKY_VERT,
    fragmentShader: SKY_FRAG,
    side: THREE.BackSide,
    depthWrite: false,
    uniforms: {
      uHorizon: { value: new THREE.Color() },
      uMid: { value: new THREE.Color() },
      uZenith: { value: new THREE.Color() },
      uSunDir: { value: sunDir },
      uSunColor: { value: new THREE.Color() },
      uStars: { value: 0 },
      uOvercast: { value: 0 },
      uFlash: { value: 0 },
    },
  })
  const sky = new THREE.Mesh(new THREE.SphereGeometry(60, 32, 16), skyMat)
  scene.add(sky)

  const small = (wrapRef.value?.clientWidth ?? 1000) < 640
  const waterGeo = new THREE.PlaneGeometry(90, 48, small ? 80 : 128, small ? 56 : 96)
  waterGeo.rotateX(-Math.PI / 2)
  waterMat = new THREE.ShaderMaterial({
    vertexShader: WATER_VERT,
    fragmentShader: WATER_FRAG,
    fog: true,
    uniforms: {
      ...fogUniforms(),
      uTime: { value: 0 },
      uTide: { value: 0 },
      uSurge: { value: 0 },
      uWaveAmp: { value: cur.waveAmp },
      uWaveSpeed: { value: cur.waveSpeed },
      uChop: { value: cur.chop },
      uShallow: { value: new THREE.Color("#8fe8d8") },
      uDeep: { value: new THREE.Color("#3596d6") },
      uFoamColor: { value: new THREE.Color("#fff8ec") },
      uSkyTint: { value: new THREE.Color() },
      uSunDir: { value: sunDir },
      uSunColor: { value: new THREE.Color() },
      uAmbient: { value: new THREE.Color() },
      uOvercast: { value: 0 },
      uShoreZ0: { value: SHORE_Z0 },
      uShoreSlope: { value: SHORE_SLOPE },
    },
  })
  const water = new THREE.Mesh(waterGeo, waterMat)
  water.position.z = -16 // every edge sits beyond the fog, no visible rim
  scene.add(water)

  // rain: one LineSegments; the box also covers the deck terrace behind
  const N = 700
  const pos = new Float32Array(N * 2 * 3)
  const off = new Float32Array(N * 2)
  const end = new Float32Array(N * 2)
  for (let i = 0; i < N; i++) {
    const x = (Math.random() - 0.5) * 24
    const y = Math.random() * 7
    const z = 16 - Math.random() * 26
    for (let v = 0; v < 2; v++) {
      pos.set([x, y, z], (i * 2 + v) * 3)
      off[i * 2 + v] = Math.random() * 7
      end[i * 2 + v] = v
    }
  }
  const rainGeo = new THREE.BufferGeometry()
  rainGeo.setAttribute("position", new THREE.BufferAttribute(pos, 3))
  rainGeo.setAttribute("aOffset", new THREE.BufferAttribute(off, 1))
  rainGeo.setAttribute("aEnd", new THREE.BufferAttribute(end, 1))
  rainMat = new THREE.ShaderMaterial({
    vertexShader: RAIN_VERT,
    fragmentShader: RAIN_FRAG,
    transparent: true,
    depthWrite: false,
    uniforms: { uTime: { value: 0 }, uRain: { value: 0 }, uWind: { value: 0.06 } },
  })
  rainMesh = new THREE.LineSegments(rainGeo, rainMat)
  rainMesh.visible = false
  rainMesh.frustumCulled = false
  scene.add(rainMesh)

  // lightning bolt: a jagged ribbon of independent quads, regenerated per
  // strike — thick enough to read at distance, additive so it burns bright
  boltMat = new THREE.MeshBasicMaterial({
    color: 0xdceaff, transparent: true, opacity: 0,
    blending: THREE.AdditiveBlending, depthWrite: false,
    side: THREE.DoubleSide, fog: false,
  })
  const MAXSEG = 18
  const boltGeo = new THREE.BufferGeometry()
  boltGeo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(MAXSEG * 4 * 3), 3))
  const bIdx = new Uint16Array(MAXSEG * 6)
  for (let s = 0; s < MAXSEG; s++) {
    const v = s * 4
    bIdx.set([v, v + 1, v + 2, v + 1, v + 3, v + 2], s * 6)
  }
  boltGeo.setIndex(new THREE.BufferAttribute(bIdx, 1))
  boltLine = new THREE.Mesh(boltGeo, boltMat)
  boltLine.name = "Bolt"
  boltLine.visible = false
  boltLine.frustumCulled = false
  scene.add(boltLine)

  // gulls: shared chevron geometry, one mesh per bird on a circular path
  birdMat = new THREE.ShaderMaterial({
    vertexShader: BIRD_VERT,
    fragmentShader: BIRD_FRAG,
    fog: true,
    side: THREE.DoubleSide,
    transparent: true,
    uniforms: {
      ...fogUniforms(),
      uTime: { value: 0 },
      uColor: { value: new THREE.Color("#4a4f58") },
      uOpacity: { value: 0 },
    },
  })
  const birdGeo = new THREE.BufferGeometry()
  birdGeo.setAttribute("position", new THREE.BufferAttribute(new Float32Array([
    -0.62, 0, 0.16, 0, 0, 0, 0, 0, 0.26,
    0.62, 0, 0.16, 0, 0, 0.26, 0, 0, 0,
  ]), 3))
  for (let i = 0; i < 5; i++) {
    const mesh = new THREE.Mesh(birdGeo, birdMat)
    birds.push({
      mesh,
      cx: -6 + Math.random() * 12,
      cy: 5 + Math.random() * 2.5,
      cz: -14 + Math.random() * 5,
      r: 2.5 + Math.random() * 3,
      speed: 0.25 + Math.random() * 0.2,
      theta: Math.random() * Math.PI * 2,
    })
    scene.add(mesh)
  }

  // aura orbs floating over the deck terrace
  const AN = 8
  const aPos = new Float32Array(AN * 3)
  const aSeed = new Float32Array(AN)
  for (let i = 0; i < AN; i++) {
    aPos.set([
      Math.random() * 5.4 - 0.2,
      1.5 + Math.random() * 1.2,
      9.6 + Math.random() * 6,
    ], i * 3)
    aSeed[i] = Math.random() * 10
  }
  const auraGeo = new THREE.BufferGeometry()
  auraGeo.setAttribute("position", new THREE.BufferAttribute(aPos, 3))
  auraGeo.setAttribute("aSeed", new THREE.BufferAttribute(aSeed, 1))
  auraMat = new THREE.ShaderMaterial({
    vertexShader: AURA_VERT,
    fragmentShader: AURA_FRAG,
    transparent: true,
    depthWrite: false,
    blending: THREE.AdditiveBlending,
    uniforms: { uTime: { value: 0 }, uNight: { value: 0 } },
  })
  const aura = new THREE.Points(auraGeo, auraMat)
  aura.frustumCulled = false
  scene.add(aura)

  // fireflies around the beds and the beach grass line
  const FN = 16
  const fpos = new Float32Array(FN * 3)
  const fseed = new Float32Array(FN)
  for (let i = 0; i < FN; i++) {
    fpos.set([
      -3 + Math.random() * 10,
      0.9 + Math.random() * 1.1 + 0.63,
      5.5 + Math.random() * 8,
    ], i * 3)
    fseed[i] = Math.random() * 10
  }
  const fgeo = new THREE.BufferGeometry()
  fgeo.setAttribute("position", new THREE.BufferAttribute(fpos, 3))
  fgeo.setAttribute("aSeed", new THREE.BufferAttribute(fseed, 1))
  fireflyMat = new THREE.ShaderMaterial({
    vertexShader: FIREFLY_VERT,
    fragmentShader: FIREFLY_FRAG,
    transparent: true,
    depthWrite: false,
    blending: THREE.AdditiveBlending,
    uniforms: { uTime: { value: 0 }, uNight: { value: 0 } },
  })
  const fireflies = new THREE.Points(fgeo, fireflyMat)
  fireflies.frustumCulled = false
  scene.add(fireflies)

  loadModel()
  if (false) (window as unknown as { __beachScene?: THREE.Scene }).__beachScene = scene
}

const CLOUD_SLOTS: [number, number, number, number][] = [
  // x, y, z, drift speed — first two are the sunny-day clouds
  [-13, 7.5, -17, 0.22], [7, 8.5, -19, 0.16],
  [-4, 7, -15, 0.28], [13, 8, -16, 0.19], [-17, 9, -14, 0.25], [2, 9.5, -20, 0.13],
]

function loadModel() {
  new GLTFLoader().load("/beach/beach.glb", (gltf) => {
    let cloudIdx = 0
    const meshes: THREE.Mesh[] = []
    gltf.scene.traverse((n) => { if ((n as THREE.Mesh).isMesh) meshes.push(n as THREE.Mesh) })
    for (const mesh of meshes) {
      const name = mesh.name
      if (name === "Sand") {
        sandMat = makeToon()
        mesh.material = sandMat
      } else if (name.startsWith("Palm") || name.startsWith("Flora")) {
        mesh.geometry.computeBoundingBox()
        const h = Math.max(0.8, mesh.geometry.boundingBox!.max.y)
        const m = makeToon({ swayAmp: 0.02, palmHeight: h })
        ;(name.startsWith("Flora") ? floraMats : palmMats).push(m)
        mesh.material = m
      } else if (name === "Props" || name.startsWith("Deck")) {
        mesh.material = makeToon()
      } else if (name.startsWith("Cloud")) {
        const slot = CLOUD_SLOTS[cloudIdx % CLOUD_SLOTS.length]
        const mat = new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, fog: true })
        mesh.material = mat
        mesh.position.set(slot[0], slot[1], slot[2])
        const s = 2.2 + Math.random() * 0.8
        mesh.scale.set(s, s * 0.85, s)
        clouds.push({ mesh, mat, speed: slot[3], baseOpacity: 0.92, baseScale: s, baseY: slot[1] })
        cloudIdx++
      }
      scene.add(mesh)
    }
    buildFairyLights(meshes)
    loading.value = false
    if (reduced) renderOnce()
  }, undefined, () => { loading.value = false }) // scene still works without the GLB
}

// wrap every palm in fairy lights by tracing its actual trunk out of the
// mesh: walk up in slices, keep only verts near the previous slice centre
// (fronds and coconuts are far away and get ignored), then lay a helix
// around the traced spine
function buildFairyLights(meshes: THREE.Mesh[]) {
  const pts: number[] = []
  const seeds: number[] = []
  const bases: number[] = []
  const hs: number[] = []
  const amps: number[] = []

  for (const mesh of meshes) {
    const name = mesh.name
    const isPalm = name.startsWith("Palm")
    const isFlora = name.startsWith("Flora_Palm")
    if (!isPalm && !isFlora) continue
    const amp = isFlora ? 0.028 : 0.055
    const geo = mesh.geometry
    const pos = geo.attributes.position as THREE.BufferAttribute
    geo.computeBoundingBox()
    const maxY = geo.boundingBox!.max.y
    const off = mesh.position
    const SL = 26
    const spine: { y: number; x: number; z: number; r: number }[] = []
    let cx = 0, cz = 0
    let misses = 0
    let seeded = false
    for (let s = 0; s < SL; s++) {
      const y0 = (s / SL) * maxY
      const y1 = ((s + 1) / SL) * maxY
      let sx = 0, sz = 0, n = 0
      for (let i = 0; i < pos.count; i++) {
        const y = pos.getY(i)
        if (y < y0 || y >= y1) continue
        const x = pos.getX(i), z = pos.getZ(i)
        if (seeded && Math.hypot(x - cx, z - cz) > 0.3) continue
        sx += x; sz += z; n++
      }
      if (n < 4) {
        // trunk rings are sparser than the slices — skip gaps, and only
        // give up after several misses in a row (that is the crown)
        if (seeded && ++misses > 2) break
        continue
      }
      misses = 0
      seeded = true
      cx = sx / n
      cz = sz / n
      let r = 0
      for (let i = 0; i < pos.count; i++) {
        const y = pos.getY(i)
        if (y < y0 || y >= y1) continue
        const d = Math.hypot(pos.getX(i) - cx, pos.getZ(i) - cz)
        if (d < 0.3 && d > r) r = d
      }
      spine.push({ y: (y0 + y1) / 2, x: cx, z: cz, r })
    }
    if (spine.length < 6) continue

    const N = 42
    const top = spine.length - 2
    for (let k = 0; k < N; k++) {
      const f = 1 + (k / (N - 1)) * (top - 1) * 0.96
      const i0 = Math.min(Math.floor(f), top)
      const i1 = Math.min(i0 + 1, top)
      const ft = f - i0
      const s0 = spine[i0], s1 = spine[i1]
      const y = s0.y + (s1.y - s0.y) * ft
      const sxc = s0.x + (s1.x - s0.x) * ft
      const szc = s0.z + (s1.z - s0.z) * ft
      const rr = s0.r + (s1.r - s0.r) * ft + 0.035
      const ang = (y / maxY) * 26 + off.x * 3.1
      pts.push(off.x + sxc + Math.cos(ang) * rr, off.y + y, off.z + szc + Math.sin(ang) * rr)
      seeds.push(Math.random() * 10)
      bases.push(off.x, off.y, off.z)
      hs.push(maxY)
      amps.push(amp)
    }
  }
  if (!pts.length) return

  const fairyGeo = new THREE.BufferGeometry()
  fairyGeo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(pts), 3))
  fairyGeo.setAttribute("aSeed", new THREE.BufferAttribute(new Float32Array(seeds), 1))
  fairyGeo.setAttribute("aBase", new THREE.BufferAttribute(new Float32Array(bases), 3))
  fairyGeo.setAttribute("aH", new THREE.BufferAttribute(new Float32Array(hs), 1))
  fairyGeo.setAttribute("aAmp", new THREE.BufferAttribute(new Float32Array(amps), 1))
  fairyLightMat = new THREE.ShaderMaterial({
    vertexShader: FAIRY_VERT,
    fragmentShader: FAIRY_FRAG,
    transparent: true,
    depthWrite: false,
    blending: THREE.AdditiveBlending,
    uniforms: {
      uTime: { value: 0 },
      uSway: { value: 0 },
      uNight: { value: 0 },
    },
  })
  fairyLights = new THREE.Points(fairyGeo, fairyLightMat)
  fairyLights.frustumCulled = false
  scene.add(fairyLights)
}

function regenBolt() {
  const pos = boltLine.geometry.getAttribute("position") as THREE.BufferAttribute
  let x = (Math.random() - 0.5) * 22
  // most strikes stay out at sea; every third one hits right off the beach
  let z = Math.random() < 0.3 ? -8 - Math.random() * 4 : -13 - Math.random() * 5
  let y = 13
  boltX = x
  boltZ = z
  let seg = 0
  // one camera-facing quad per segment, tapering toward the ground
  const put = (ax: number, ay: number, az: number, bx: number, by: number, bz: number, w: number) => {
    const v = seg * 4
    pos.setXYZ(v, ax - w, ay, az)
    pos.setXYZ(v + 1, ax + w, ay, az)
    pos.setXYZ(v + 2, bx - w * 0.85, by, bz)
    pos.setXYZ(v + 3, bx + w * 0.85, by, bz)
    seg++
  }
  let branchAt: [number, number, number] | null = null
  for (let s = 0; s < 13 && y > 0; s++) {
    const nx = x + (Math.random() - 0.5) * 1.5
    const nz = z + (Math.random() - 0.5) * 1.0
    const ny = y - (0.7 + Math.random() * 0.9)
    put(x, y, z, nx, Math.max(ny, 0), nz, 0.3 * (0.35 + 0.65 * (y / 13)))
    if (s === 5) branchAt = [nx, ny, nz]
    x = nx; y = ny; z = nz
  }
  if (branchAt) {
    let [bx, by, bz] = branchAt
    for (let s = 0; s < 4 && by > 1; s++) {
      const nx = bx + 0.5 + Math.random() * 0.9
      const ny = by - (0.6 + Math.random() * 0.7)
      const nz = bz + (Math.random() - 0.5)
      put(bx, by, bz, nx, ny, nz, 0.08)
      bx = nx; by = ny; bz = nz
    }
  }
  boltLine.geometry.setDrawRange(0, seg * 6)
  pos.needsUpdate = true
}

/* ── per-frame simulation ── */
function updateFrame(dt: number) {
  elapsed += dt
  const target = PRESETS[props.weather] ?? PRESETS.sunny
  const k = 1 - Math.exp(-dt / 0.7)
  cur.overcast += (target.overcast - cur.overcast) * k
  cur.clouds += (target.clouds - cur.clouds) * k
  cur.waveAmp += (target.waveAmp - cur.waveAmp) * k
  cur.waveSpeed += (target.waveSpeed - cur.waveSpeed) * k
  cur.chop += (target.chop - cur.chop) * k
  cur.sway += (target.sway - cur.sway) * k
  cur.lightDim += (target.lightDim - cur.lightDim) * k
  cur.fogFar += (target.fogFar - cur.fogFar) * k
  cur.rain += (target.rain - cur.rain) * k
  cur.lightningRate += (target.lightningRate - cur.lightningRate) * k
  cur.cloudTint.lerp(target.cloudTint, k)
  const stormF = THREE.MathUtils.smoothstep(cur.waveAmp, 0.12, 0.28)

  // sun path: rises ~6h, sets ~20h, arcs over the ocean (-z)
  const h = ((props.timeOfDay % 24) + 24) % 24
  paletteAt(h)
  const t = (h - 6) / 14
  const el = Math.sin(Math.PI * THREE.MathUtils.clamp(t, -0.12, 1.12)) * 1.05
  // azimuth stays inside the camera frustum so sunrise and sunset happen on screen
  const az = THREE.MathUtils.lerp(-0.9, 0.4, t)
  sunDir.set(Math.sin(az) * Math.cos(el), Math.sin(el), -Math.cos(az) * Math.cos(el)).normalize()

  // lightning: random flicker envelope, sky flash + ambient boost
  let flash = 0
  if (strikeT >= 0) {
    strikeT += dt
    const ph = strikeT / strikeDur
    if (ph >= 1) strikeT = -1
    else flash = Math.sin(Math.PI * ph) * (0.55 + 0.45 * Math.sin(strikeT * 87.0)) * 0.9
  } else if (cur.lightningRate > 0.1) {
    nextStrike -= dt
    if (nextStrike <= 0) {
      strikeT = 0
      strikeDur = 0.45 + Math.random() * 0.4
      nextStrike = (60 / cur.lightningRate) * (0.5 + Math.random())
      regenBolt()
      // closeness drives the thunder: near strikes crack, far ones rumble.
      // calibrated to the actual spawn range (roughly 17..30m from the camera)
      const bdist = Math.hypot(boltX - camPos.x, boltZ - camPos.z)
      emit("lightning", THREE.MathUtils.clamp(1 - (bdist - 18) / 12, 0.1, 1))
    }
  } else {
    nextStrike = 2 + Math.random() * 3
  }
  boltLine.visible = strikeT >= 0
  boltMat.opacity = strikeT >= 0 ? Math.min(1, 0.5 + flash * 1.2) : 0

  sunColor.copy(pal.sun).multiplyScalar(pal.sunI * cur.lightDim)
  ambient.copy(pal.amb).addScalar(flash * 0.45)

  // gulls exist where you would hear them: friendly weather, daylight
  const gullF = Math.max(0, 1 - cur.overcast * 1.15) * THREE.MathUtils.smoothstep(el, 0.02, 0.2)
  const nightF = 1 - THREE.MathUtils.smoothstep(el, -0.04, 0.14)

  // fairy lights come on at dusk; their soft pools warm the palm feet
  const fairyF = 1 - THREE.MathUtils.smoothstep(el, 0.02, 0.28)
  if (fairyLightMat) {
    fairyLightMat.uniforms.uTime.value = elapsed
    fairyLightMat.uniforms.uSway.value = cur.sway
    fairyLightMat.uniforms.uNight.value = fairyF
  }
  if (fairyLights) fairyLights.visible = fairyF > 0.02
  auraMat.uniforms.uTime.value = elapsed
  auraMat.uniforms.uNight.value = fairyF * (1 - cur.rain * 0.6)
  const lampGlow = fairyF * 0.3 * (0.92 + 0.08 * Math.sin(elapsed * 2.3))

  // fireflies come out on calm nights only
  fireflyMat.uniforms.uTime.value = elapsed
  fireflyMat.uniforms.uNight.value = nightF * (1 - cur.rain) * (1 - stormF)
  birdMat.uniforms.uTime.value = elapsed
  birdMat.uniforms.uOpacity.value = gullF * 0.9
  birdMat.uniforms.fogColor.value.copy(fog.color)
  birdMat.uniforms.fogFar.value = cur.fogFar
  for (const b of birds) {
    b.theta += b.speed * dt
    b.mesh.position.set(
      b.cx + Math.cos(b.theta) * b.r,
      b.cy + Math.sin(b.theta * 2.3) * 0.4,
      b.cz + Math.sin(b.theta) * b.r,
    )
    b.mesh.rotation.y = -b.theta
    b.mesh.visible = gullF > 0.2
  }

  // audio follows the visuals: report the crossfaded state a few times a second
  mixT += dt
  if (mixT > 0.15) {
    mixT = 0
    emit("mix", {
      waves: THREE.MathUtils.clamp(0.55 + (cur.waveAmp - 0.05) * 2.0, 0.55, 1),
      rain: cur.rain,
      gulls: gullF,
      storm: stormF,
      night: nightF,
    })
  }

  // tide: slow 3.5 min swell; wet sand trails the retreating waterline
  const tide = 0.15 * Math.sin((2 * Math.PI * elapsed) / 210)
  // swash train: two overlapped swells, shaped so each wave runs up and drains
  const s1 = Math.sin((2 * Math.PI * elapsed) / 5.2)
  const s2 = Math.sin((2 * Math.PI * elapsed) / 2.3 + 1.7)
  const surge = cur.waveAmp * 1.5 * Math.pow(Math.max(0, 0.62 * s1 + 0.38 * s2), 1.4)
  const waterEdgeY = tide + surge + cur.waveAmp * 0.3
  wetLevel = Math.max(waterEdgeY, wetLevel - 0.015 * dt)

  // fog color = the sky shader's horizon after its overcast mix, so the far
  // water edge melts into the sky without a seam
  const g = ((pal.horizon.r + pal.horizon.g + pal.horizon.b) / 3) * 0.72
  cScratch.setRGB(g + pal.horizon.r * 0.1, g + pal.horizon.g * 0.1, g + pal.horizon.b * 0.1)
  cScratch.lerpColors(pal.horizon, cScratch, cur.overcast * 0.8)
    .multiplyScalar(1 - 0.58 * cur.overcast * cur.overcast)
    .addScalar(flash)
  fog.color.copy(cScratch)
  fog.far = cur.fogFar
  renderer!.setClearColor(cScratch)

  const su = skyMat.uniforms
  su.uHorizon.value.copy(pal.horizon)
  su.uMid.value.copy(pal.mid)
  su.uZenith.value.copy(pal.zenith)
  su.uSunColor.value.copy(pal.sun).multiplyScalar(Math.min(pal.sunI + 0.15, 1.2))
  su.uStars.value = pal.stars
  su.uOvercast.value = cur.overcast
  su.uFlash.value = flash

  const wu = waterMat.uniforms
  wu.uTime.value = elapsed
  wu.uTide.value = tide
  wu.uSurge.value = surge
  wu.uWaveAmp.value = cur.waveAmp
  wu.uWaveSpeed.value = cur.waveSpeed
  wu.uChop.value = cur.chop
  wu.uSunColor.value.copy(sunColor)
  wu.uAmbient.value.copy(ambient)
  wu.uSkyTint.value.copy(cScratch)
  wu.uOvercast.value = cur.overcast
  wu.fogColor.value.copy(fog.color)
  wu.fogFar.value = cur.fogFar

  for (const m of toonMats) {
    m.uniforms.uTime.value = elapsed
    m.uniforms.uSunColor.value.copy(sunColor)
    m.uniforms.uAmbient.value.copy(ambient)
    m.uniforms.uLanternGlow.value = lampGlow
    m.uniforms.fogColor.value.copy(fog.color)
    m.uniforms.fogFar.value = cur.fogFar
  }
  if (sandMat) sandMat.uniforms.uWetLevel.value = wetLevel
  for (const m of palmMats) m.uniforms.uSwayAmp.value = 0.055 * cur.sway
  for (const m of floraMats) m.uniforms.uSwayAmp.value = 0.028 * cur.sway

  // camera drifts between the beach pose and the deck bench
  const vt = CAM_VIEWS[props.view] ?? CAM_VIEWS.beach
  const kc = 1 - Math.exp(-dt / 0.9)
  camPos.lerp(vt.pos, kc)
  camLook.lerp(vt.look, kc)
  camera.position.copy(camPos)
  camera.lookAt(camLook)

  // clouds drift and fade per weather; a storm deck hangs lower, swells
  // bigger and races — dim with the ambient light
  clouds.forEach((c, i) => {
    c.mesh.position.x += c.speed * (1 + stormF * 2.5) * dt
    if (c.mesh.position.x > 21) c.mesh.position.x = -21
    const want = i < Math.round(cur.clouds) ? c.baseOpacity : 0
    c.mat.opacity += (want - c.mat.opacity) * k
    c.mesh.visible = c.mat.opacity > 0.02
    const ts = c.baseScale * (1 + stormF * 0.7)
    c.mesh.scale.x += (ts - c.mesh.scale.x) * k
    c.mesh.scale.y += (ts * 0.85 - c.mesh.scale.y) * k
    c.mesh.scale.z += (ts - c.mesh.scale.z) * k
    c.mesh.position.y += (c.baseY - stormF * 1.1 - c.mesh.position.y) * k
    c.mat.color.copy(cur.cloudTint).multiply(cScratch.copy(ambient).multiplyScalar(1.2).addScalar(0.25))
  })

  rainMat.uniforms.uTime.value = elapsed
  rainMat.uniforms.uRain.value = cur.rain
  rainMat.uniforms.uWind.value = 0.04 + stormF * 0.22
  rainMesh.visible = cur.rain > 0.02
}

/* ── loop / lifecycle ── */
let last = 0
function loop(now: number) {
  const dt = Math.min((now - last) / 1000, 0.05)
  last = now
  updateFrame(dt)
  renderer!.render(scene, camera)
  raf = requestAnimationFrame(loop)
}

function start() {
  if (running || !renderer) return
  running = true
  last = performance.now()
  raf = requestAnimationFrame(loop)
}

function stop() {
  running = false
  cancelAnimationFrame(raf)
}

function renderOnce() {
  if (!renderer) return
  // snap the crossfade + camera so a static frame shows the target state
  Object.assign(cur, { ...PRESETS[props.weather], cloudTint: cur.cloudTint.copy(PRESETS[props.weather].cloudTint) })
  const vt = CAM_VIEWS[props.view] ?? CAM_VIEWS.beach
  camPos.copy(vt.pos)
  camLook.copy(vt.look)
  updateFrame(1 / 60)
  renderer.render(scene, camera)
}

function updateSize() {
  const wrap = wrapRef.value
  if (!wrap || !renderer) return
  const w = wrap.clientWidth, hgt = wrap.clientHeight
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
  renderer.setSize(w, hgt, false)
  camera.aspect = w / hgt
  camera.updateProjectionMatrix()
  if (reduced) renderOnce()
}

watch(() => [props.weather, props.timeOfDay, props.view], () => {
  if (reduced) renderOnce()
})

watch(() => props.paused, (p) => {
  if (reduced) return
  if (p) stop()
  else start()
})

onMounted(() => {
  buildScene()
  updateSize()
  window.addEventListener("resize", updateSize)
  reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
  if (reduced) {
    renderOnce()
    return
  }
  io = new IntersectionObserver(
    ([entry]) => (entry.isIntersecting ? start() : stop()),
    { threshold: 0.1 },
  )
  io.observe(wrapRef.value!)
})

onBeforeUnmount(() => {
  io?.disconnect()
  stop()
  window.removeEventListener("resize", updateSize)
  scene?.traverse((n) => {
    const mesh = n as THREE.Mesh
    if (mesh.isMesh) {
      mesh.geometry?.dispose()
      const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
      mats.forEach((m) => m?.dispose())
    }
  })
  renderer?.dispose()
  renderer?.forceContextLoss()
  renderer = null
})
</script>

<style scoped>
.beach-wrapper {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
}
.beach-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}
.beach-loading {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 0.8rem;
  letter-spacing: 0.12em;
  color: rgba(240, 236, 227, 0.8);
  pointer-events: none;
}
.beach-caret {
  animation: beach-blink 1s steps(2) infinite;
}
@keyframes beach-blink {
  50% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .beach-caret { animation: none; }
}
</style>