• 01 - CSS: Insects
  • 02 - Critter Models 02
  • 03 - VantaJS Shaders
  • 04 - App Bundle 02
  • 05 - Cathedrals
  • 06 - Board Games
  • 07 - App Bundle 01
  • 08 - Retroscopic
  • 09 - Games 26
  • 10 - Voronoi
  • 11 - Terrain
  • 12 - Critter Models 01
  • 13 - Chromatophores
  • 14 - Conformal Maps
  • 15 - Echo Logix
  • 16 - Music 26
  • 17 - Colour Theory
ARCHIVE
Notes
Number:
Project:
01CSS: Insects
└ CSS Beetle└ CSS Ladybird└ CSS Bluebottle└ CSS Dragonfly
02Critter Models 02
└ Locust└ Butterfly└ Cockroach└ Spider└ Scorpion└ Dragon└ Brazilian Treehopper└ Tardigrade└ Diatoms└ Bumblebee└ Wasp└ Hammerhead Worm└ Giraffe Weevil└ Trilobite Beetle
03VantaJS Shaders
└ Clouds└ Waves└ Cells└ Rings└ Halo└ Globe└ Net└ Dots└ Birds└ Trunk└ Topology└ About VantaJS
04App Bundle 02
└ OwnZones└ Gutindex└ OpenLayers└ SH-101└ Pip└ Great White
05Cathedrals
└ Lattice└ Lost└ Lucent└ Lumen└ Glass└ Disco└ Hyperion└ Latent
06Board Games
└ Chess└ Carrom└ Connect 4
07App Bundle 01
└ Robohash└ PrismJS└ Horoscope└ Binary Jazz└ Morse Code└ URL Extractor└ Entity Escaper└ Phonetic Alphabet└ Toki Pona└ Semaphore└ Cut Up Machine└ Text Mixer└ Currency Freaks└ Host IO└ CrUX API└ Logo Dev└ Web Speech API└ GEO DB
01 / 03

GREAT WHITE - INTERACTIVE APP SOURCE CODE

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Great white</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600&family=Big+Shoulders+Display:wght@800&display=swap">
<style>
  :root {
    box-sizing: border-box;
    padding-top: env(safe-area-inset-top, 0px);
    padding-bottom: env(safe-area-inset-bottom, 0px);
    color-scheme: light;
    --mode: 0;
    --water-top: #9edcef;
    --water-mid: #1f7aa3;
    --water-deep: #052640;
    --ink: #04263b;
    --ink-soft: rgba(4, 38, 59, 0.78);
    --chip: rgba(236, 249, 255, 0.84);
    --chip-ink: #04263b;
    --chip-edge: rgba(255, 255, 255, 0.9);
    --accent: #c0101f;
    --accent-ink: #ffffff;
    --flash: rgba(150, 0, 12, 0.6);
  }
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
      color-scheme: dark;
      --mode: 1;
      --water-top: #1d4166;
      --water-mid: #08203a;
      --water-deep: #010810;
      --ink: #e4f5ff;
      --ink-soft: rgba(228, 245, 255, 0.76);
      --chip: rgba(6, 22, 40, 0.8);
      --chip-ink: #e4f5ff;
      --chip-edge: rgba(120, 200, 255, 0.26);
      --accent: #e0303c;
      --accent-ink: #ffffff;
      --flash: rgba(120, 0, 10, 0.65);
    }
  }
  :root[data-theme="dark"] {
    color-scheme: dark;
    --mode: 1;
    --water-top: #1d4166;
    --water-mid: #08203a;
    --water-deep: #010810;
    --ink: #e4f5ff;
    --ink-soft: rgba(228, 245, 255, 0.76);
    --chip: rgba(6, 22, 40, 0.8);
    --chip-ink: #e4f5ff;
    --chip-edge: rgba(120, 200, 255, 0.26);
    --accent: #e0303c;
    --accent-ink: #ffffff;
    --flash: rgba(120, 0, 10, 0.65);
  }
  html { height: 100%; scroll-padding-top: env(safe-area-inset-top, 0px); }
  body {
    height: 100%;
    margin: 0;
    overflow: hidden;
    background: var(--water-deep);
    color: var(--ink);
    font-family: "Archivo", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
    -webkit-font-smoothing: antialiased;
    -webkit-tap-highlight-color: transparent;
  }
  #stage {
    position: relative;
    width: 100%;
    height: 100%;
    overflow: hidden;
    background: linear-gradient(180deg, var(--water-top) 0%, var(--water-mid) 40%, var(--water-deep) 100%);
  }
  #stage canvas {
    position: absolute;
    top: 0; left: 0;
    width: 100%; height: 100%;
    display: block;
    touch-action: none;
    outline: none;
  }
  .vignette, .flash {
    position: absolute;
    top: 0; left: 0; right: 0; bottom: 0;
    pointer-events: none;
  }
  .vignette { background: radial-gradient(130% 100% at 50% 35%, transparent 52%, rgba(0, 10, 22, 0.5) 100%); }
  .flash { opacity: 0; background: radial-gradient(120% 95% at 50% 50%, transparent 35%, var(--flash) 100%); }
  .bar {
    position: absolute;
    left: 0; right: 0; bottom: 0;
    padding: 0 clamp(12px, 3vw, 28px) clamp(12px, 3vw, 24px);
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
    justify-content: space-between;
    gap: 10px;
    pointer-events: none;
  }
  .hint {
    margin: 0;
    padding: 9px 15px;
    border-radius: 14px;
    font-size: 15px;
    line-height: 1.35;
    max-width: 32em;
    color: var(--chip-ink);
    background: var(--chip);
    border: 1px solid var(--chip-edge);
    -webkit-backdrop-filter: blur(10px);
    backdrop-filter: blur(10px);
    transition: opacity 0.7s ease, transform 0.7s ease;
  }
  .hint.gone { opacity: 0; transform: translateY(8px); }
  .actions { display: flex; gap: 8px; margin-left: auto; pointer-events: auto; }
  .btn {
    appearance: none;
    -webkit-appearance: none;
    font: inherit;
    font-size: 16px;
    font-weight: 600;
    color: var(--chip-ink);
    background: var(--chip);
    border: 1px solid var(--chip-edge);
    padding: 10px 18px;
    border-radius: 10px;
    cursor: pointer;
    -webkit-backdrop-filter: blur(10px);
    backdrop-filter: blur(10px);
    transition: transform 0.15s ease;
  }
  .btn:active { transform: translateY(1px) scale(0.97); }
  .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
  .btn.primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
  .sr-only {
    position: absolute;
    width: 1px; height: 1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
  }
  .fallback {
    position: absolute;
    top: 0; left: 0; right: 0; bottom: 0;
    display: grid;
    place-items: center;
    padding: 32px;
    text-align: center;
    font-size: 17px;
  }
  @media (max-width: 560px) {
    .hint { font-size: 14px; }
    .btn { padding: 9px 13px; font-size: 15px; }
  }
  @media (prefers-reduced-motion: reduce) {
    .hint, .btn { transition: none; }
  }
</style>
</head>
<body>
<div id="stage">
  <div class="vignette" aria-hidden="true"></div>
  <div class="flash" id="flash" aria-hidden="true"></div>
  <div class="bar">
    <p class="hint" id="hint">Tap the surface to put a seal there, or tap the shark to make it charge.</p>
    <div class="actions">
      <button class="btn" id="chargeBtn" type="button">Make it charge</button>
      <button class="btn primary" id="sealBtn" type="button">Add a seal</button>
    </div>
  </div>
  <p class="sr-only" id="live" aria-live="polite"></p>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(() => {
  'use strict';

  const stage = document.getElementById('stage');
  const hintEl = document.getElementById('hint');
  const sealBtn = document.getElementById('sealBtn');
  const chargeBtn = document.getElementById('chargeBtn');
  const liveEl = document.getElementById('live');
  const flashEl = document.getElementById('flash');

  function fail(msg) {
    const d = document.createElement('div');
    d.className = 'fallback';
    d.textContent = msg;
    stage.appendChild(d);
    const bar = document.querySelector('.bar');
    if (bar) bar.style.display = 'none';
  }
  if (!window.THREE) { fail('The 3D library didn’t load. Check your connection and refresh the page.'); return; }

  // ─── Helpers ──────────────────────────────────────────────────────────
  const V3 = THREE.Vector3;
  const TAU = Math.PI * 2;
  const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
  const lerp = (a, b, t) => a + (b - a) * t;
  const damp = (a, b, k, dt) => lerp(a, b, 1 - Math.exp(-k * dt));
  const smoothstep = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
  const wrapAngle = (a) => Math.atan2(Math.sin(a), Math.cos(a));
  const dampAngle = (a, b, k, dt) => a + wrapAngle(b - a) * (1 - Math.exp(-k * dt));
  let seed = 4021;
  const rand = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
  const rr = (a, b) => a + (b - a) * rand();
  const REDUCED = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
  const MOTION = REDUCED ? 0.5 : 1;

  // ─── Renderer, scene, camera, lights ──────────────────────────────────
  let renderer;
  try {
    renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'high-performance' });
  } catch (err) {
    fail('This little aquarium needs WebGL, which isn’t available in this browser.');
    return;
  }
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
  renderer.setClearColor(0x000000, 0);
  const canvas = renderer.domElement;
  canvas.setAttribute('role', 'img');
  canvas.setAttribute('aria-label', 'A great white shark hunting below the surface beside a kelp forest');
  stage.insertBefore(canvas, stage.firstChild);

  const scene = new THREE.Scene();
  scene.fog = new THREE.Fog(0x1f6a8c, 5, 42);
  const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 200);
  const tanHalf = Math.tan(THREE.MathUtils.degToRad(camera.fov / 2));
  const camLook = new V3(0, -2.4, -2);
  const camBase = new V3(0, -4.2, 11);
  let viewHalfW = 8;

  const hemi = new THREE.HemisphereLight(0xc8f5ff, 0xe6cc98, 0.72);
  const key = new THREE.DirectionalLight(0xfff0cf, 0.85);
  key.position.set(-2.5, 10, 2);
  const rim = new THREE.DirectionalLight(0xa9f2ff, 0.5);
  rim.position.set(5, 2.5, -6);
  scene.add(hemi, key, rim);

  // ─── Sand height (shared by JS and the sand shader) ───────────────────
  const SAND_Y = -11;
  const duneH = (x, z) =>
    0.16 * Math.sin(x * 0.35 + 1.3) * Math.cos(z * 0.3) +
    0.07 * Math.sin(x * 0.9 + z * 0.6) +
    0.05 * Math.cos(z * 1.1 - x * 0.4);
  const sandAt = (x, z) => SAND_Y + duneH(x, z);

  // ─── Textures painted on 2D canvases ──────────────────────────────────
  const maxAniso = renderer.capabilities.getMaxAnisotropy();
  const makeCanvas = (w, h) => { const c = document.createElement('canvas'); c.width = w; c.height = h; return c; };
  // Equirectangular mapping used by THREE.SphereGeometry (u = 0.25 is the +z "front").
  const uvToDir = (u, v) => {
    const th = v * Math.PI, ph = u * TAU;
    return new V3(-Math.cos(ph) * Math.sin(th), Math.cos(th), Math.sin(ph) * Math.sin(th));
  };
  const dirToUV = (d) => ({
    u: ((Math.atan2(d.z, -d.x) / TAU) % 1 + 1) % 1,
    v: Math.acos(clamp(d.y, -1, 1)) / Math.PI,
  });

  // Soft vertical beam for the sun rays.
  function paintRay() {
    const W = 64, H = 256;
    const c = makeCanvas(W, H), g = c.getContext('2d');
    const img = g.createImageData(W, H), d = img.data;
    for (let y = 0; y < H; y++) {
      for (let x = 0; x < W; x++) {
        const u = ((x + 0.5) / W) * 2 - 1, v = (y + 0.5) / H;
        const prof = Math.exp(-u * u * 4.5) * (1 - smoothstep(0.85, 1, Math.abs(u)));
        const fall = Math.pow(1 - v, 1.6) * smoothstep(0, 0.08, v);
        const i = (y * W + x) * 4;
        d[i] = d[i + 1] = d[i + 2] = 255;
        d[i + 3] = Math.round(255 * prof * fall);
      }
    }
    g.putImageData(img, 0, 0);
    return new THREE.CanvasTexture(c);
  }

  // ─── Sand with animated caustics ──────────────────────────────────────
  const DUNE_GLSL = `
    float duneH(vec2 p) {
      return 0.16 * sin(p.x * 0.35 + 1.3) * cos(p.y * 0.3)
           + 0.07 * sin(p.x * 0.9 + p.y * 0.6)
           + 0.05 * cos(p.y * 1.1 - p.x * 0.4);
    }`;
  const sandVS = `
    varying vec3 vWorld;
    varying vec3 vN;
    varying float vDepth;
    #include <fog_pars_vertex>
    ${DUNE_GLSL}
    void main() {
      vec3 p = position;
      p.y += duneH(p.xz);
      float e = 0.06;
      float hx = duneH(p.xz + vec2(e, 0.0)) - duneH(p.xz - vec2(e, 0.0));
      float hz = duneH(p.xz + vec2(0.0, e)) - duneH(p.xz - vec2(0.0, e));
      vN = normalize(vec3(-hx, 2.0 * e, -hz));
      vec4 world = modelMatrix * vec4(p, 1.0);
      vWorld = world.xyz;
      vec4 mvPosition = viewMatrix * world;
      vDepth = -mvPosition.z;
      gl_Position = projectionMatrix * mvPosition;
      #include <fog_vertex>
    }`;
  const sandFS = `
    uniform float uTime;
    uniform vec3 uSand;
    uniform vec3 uSandDark;
    uniform vec3 uCaustic;
    uniform float uCausticStrength;
    uniform vec4 uShadow;
    uniform vec3 uLightDir;
    uniform vec2 uFade;
    uniform vec2 uDetail;
    varying vec3 vWorld;
    varying vec3 vN;
    varying float vDepth;
    #include <fog_pars_fragment>
    float hash1(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
    vec2 hash2(vec2 p) {
      p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
      return fract(sin(p) * 43758.5453);
    }
    float vnoise(vec2 p) {
      vec2 i = floor(p);
      vec2 f = fract(p);
      vec2 u = f * f * (3.0 - 2.0 * f);
      return mix(mix(hash1(i), hash1(i + vec2(1.0, 0.0)), u.x),
                 mix(hash1(i + vec2(0.0, 1.0)), hash1(i + vec2(1.0, 1.0)), u.x), u.y);
    }
    // Bright web along Voronoi cell borders, with drifting cell centres.
    float caustic(vec2 p, float t) {
      vec2 ip = floor(p);
      vec2 fp = fract(p);
      float d1 = 8.0;
      float d2 = 8.0;
      for (int j = -1; j <= 1; j++) {
        for (int i = -1; i <= 1; i++) {
          vec2 g = vec2(float(i), float(j));
          vec2 o = hash2(ip + g);
          o = 0.5 + 0.42 * sin(t + 6.2831 * o);
          vec2 r = g + o - fp;
          float d = dot(r, r);
          if (d < d1) { d2 = d1; d1 = d; }
          else if (d < d2) { d2 = d; }
        }
      }
      float edge = sqrt(d2) - sqrt(d1);
      return pow(1.0 - smoothstep(0.0, 0.09, edge), 2.5);
    }
    void main() {
      vec2 p = vWorld.xz;
      float detail = 1.0 - smoothstep(uDetail.x, uDetail.y, vDepth);
      float n = vnoise(p * 0.55) * 0.65 + vnoise(p * 2.1 + 7.0) * 0.35;
      vec3 col = mix(uSandDark, uSand, n);
      col *= 1.0 + (hash1(floor(p * 70.0)) - 0.5) * 0.1 * detail;
      float diff = max(dot(normalize(vN), uLightDir), 0.0);
      col *= 0.7 + 0.4 * diff;
      float t = uTime;
      vec2 q = p + 0.45 * vec2(vnoise(p * 0.8 + t * 0.12), vnoise(p * 0.8 + 5.3 - t * 0.1));
      float c = caustic(q * 1.5 + vec2(t * 0.05, t * 0.03), t * 0.75);
      c = max(c * 0.75, caustic(q * 2.2 + vec2(3.7, 1.2) - vec2(t * 0.04, -t * 0.025), -t * 0.6) * 0.55);
      c *= detail;
      float sd = length(p - uShadow.xy);
      float sh = uShadow.z * (1.0 - smoothstep(0.0, uShadow.w, sd));
      col += uCaustic * c * uCausticStrength * (1.0 - sh);
      col *= 1.0 - sh * 0.6;
      gl_FragColor = vec4(col, 1.0);
      #include <fog_fragment>
      gl_FragColor.a = 1.0 - smoothstep(uFade.x, uFade.y, vDepth);
    }`;
  const sandGeo = new THREE.PlaneGeometry(90, 90, 180, 180);
  sandGeo.rotateX(-Math.PI / 2);
  const sandMat = new THREE.ShaderMaterial({
    uniforms: THREE.UniformsUtils.merge([THREE.UniformsLib.fog, {
      uTime: { value: 0 },
      uSand: { value: new THREE.Color('#edd9a8') },
      uSandDark: { value: new THREE.Color('#d0b47c') },
      uCaustic: { value: new THREE.Color('#fffbea') },
      uCausticStrength: { value: 0.6 },
      uShadow: { value: new THREE.Vector4(0, 0, 0, 1) },
      uLightDir: { value: new V3(-0.3, 1, 0.45).normalize() },
      uFade: { value: new THREE.Vector2(14, 40) },
      uDetail: { value: new THREE.Vector2(11, 26) },
    }]),
    vertexShader: sandVS,
    fragmentShader: sandFS,
    fog: true,
    transparent: true,
  });
  const sand = new THREE.Mesh(sandGeo, sandMat);
  sand.position.y = SAND_Y;
  sand.renderOrder = -1; // draw before other see-through things so fins never punch holes in it
  scene.add(sand);

  // ─── Rocks ────────────────────────────────────────────────────────────
  // Average normals of vertices that share a position (hides sphere seams).
  function weldNormals(geo) {
    const pos = geo.attributes.position, nrm = geo.attributes.normal, groups = new Map();
    for (let i = 0; i < pos.count; i++) {
      const k = `${pos.getX(i).toFixed(3)},${pos.getY(i).toFixed(3)},${pos.getZ(i).toFixed(3)}`;
      let e = groups.get(k);
      if (!e) { e = { n: new V3(), idx: [] }; groups.set(k, e); }
      e.n.x += nrm.getX(i); e.n.y += nrm.getY(i); e.n.z += nrm.getZ(i);
      e.idx.push(i);
    }
    groups.forEach((e) => { e.n.normalize(); e.idx.forEach((i) => nrm.setXYZ(i, e.n.x, e.n.y, e.n.z)); });
    nrm.needsUpdate = true;
  }
  const rockBase = new THREE.Color('#7b8a8e'), rockDark = new THREE.Color('#4c5a63'), rockMoss = new THREE.Color('#6f9150');
  function rockGeometry(r, s) {
    const geo = new THREE.SphereGeometry(r, 36, 26);
    const pos = geo.attributes.position, v = new V3();
    for (let i = 0; i < pos.count; i++) {
      v.fromBufferAttribute(pos, i).normalize();
      const n = Math.sin(v.x * 2.1 + s) * Math.cos(v.y * 2.7 - s * 0.7) * 0.5 +
        Math.sin(v.z * 3.3 + v.x * 1.7 + s * 1.3) * 0.3 +
        Math.sin((v.x + v.y + v.z) * 6 + s) * 0.06;
      v.multiplyScalar(r * (1 + 0.2 * n));
      pos.setXYZ(i, v.x, v.y, v.z);
    }
    geo.computeVertexNormals();
    weldNormals(geo);
    const nrm = geo.attributes.normal, cols = new Float32Array(pos.count * 3), c = new THREE.Color();
    for (let i = 0; i < pos.count; i++) {
      const ny = nrm.getY(i);
      c.copy(rockBase).lerp(rockDark, smoothstep(0.2, -0.6, ny)).lerp(rockMoss, smoothstep(0.45, 0.95, ny) * 0.6);
      cols[i * 3] = c.r; cols[i * 3 + 1] = c.g; cols[i * 3 + 2] = c.b;
    }
    geo.setAttribute('color', new THREE.BufferAttribute(cols, 3));
    return geo;
  }
  const rockMat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.92 });
  const rockList = [];
  [
    [-11, -9, 2.6, 1.3, 0.8, 1.1], [-6.5, -13, 3.2, 1.4, 0.75, 1], [9.5, -10, 2.8, 1.2, 0.85, 1.1],
    [3, -17, 3.6, 1.5, 0.7, 1.2], [-14, -18, 3.4, 1.3, 0.8, 1], [15, -19, 3.8, 1.2, 0.8, 1.1],
    [-2.5, -9.5, 1.3, 1.3, 0.7, 1], [5.5, -7.5, 1.1, 1.2, 0.8, 1], [-8.5, -4.5, 1.6, 1.3, 0.75, 1.1], [12, -3.5, 1.9, 1.2, 0.8, 1],
  ].forEach(([x, z, r, sx, sy, sz], i) => {
    const m = new THREE.Mesh(rockGeometry(r, i * 1.7 + 0.3), rockMat);
    m.scale.set(sx, sy, sz);
    m.rotation.y = rr(0, TAU);
    m.position.set(x, sandAt(x, z) + r * sy * 0.25, z);
    scene.add(m);
    rockList.push({ p: m.position.clone(), r: r * Math.max(sx, sy, sz) * 1.05 });
  });

  // ─── Kelp ribbons (vertices rewritten each frame) ─────────────────────
  const kelpMat = new THREE.MeshStandardMaterial({ vertexColors: true, side: THREE.DoubleSide, roughness: 0.7 });
  const kelps = [];
  function addKelp(x, z, h, w, c0, c1) {
    const segs = 28;
    const geo = new THREE.BufferGeometry();
    const pos = new Float32Array((segs + 1) * 6), col = new Float32Array((segs + 1) * 6), idx = [];
    const a = new THREE.Color(c0), b = new THREE.Color(c1), c = new THREE.Color();
    for (let i = 0; i <= segs; i++) {
      c.copy(a).lerp(b, Math.pow(i / segs, 0.8));
      col.set([c.r, c.g, c.b, c.r, c.g, c.b], i * 6);
      if (i < segs) { const q = i * 2; idx.push(q, q + 1, q + 2, q + 1, q + 3, q + 2); }
    }
    geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
    geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
    geo.setIndex(idx);
    const mesh = new THREE.Mesh(geo, kelpMat);
    mesh.frustumCulled = false;
    scene.add(mesh);
    kelps.push({ geo, x, z, y0: sandAt(x, z) - 0.05, h, w, segs, ph: rand() * TAU, tw: rr(0.6, 1.6) });
  }
  const GREEN = ['#2e6b45', '#8fd27a'], LIME = ['#3c7a35', '#b2db63'], KELP = ['#6b4a2b', '#c99d55'];
  [
    [-8.6, 1.5, 9.6, 0.62, KELP], [-12.5, -1, 10.2, 0.7, KELP], [-9.5, -3.5, 9.9, 0.6, LIME], [-13.5, -6, 10.3, 0.66, KELP],
    [-10.5, -9, 10.0, 0.6, GREEN], [-15, -13, 10.4, 0.7, KELP], [-11, -15.5, 10.2, 0.64, KELP],
    [8.8, 1, 9.7, 0.62, KELP], [12.8, -2.5, 10.1, 0.68, LIME], [9.8, -5, 9.9, 0.6, KELP], [14, -8, 10.4, 0.7, KELP],
    [11, -12, 10.2, 0.62, GREEN], [15.5, -15, 10.4, 0.7, KELP], [-4, -21, 10.3, 0.66, KELP], [5, -23, 10.4, 0.68, KELP],
  ].forEach(([x, z, h, w, [c0, c1]]) => addKelp(x, z, h, w, c0, c1));
  function updateKelp(t) {
    for (const k of kelps) {
      const p = k.geo.attributes.position;
      for (let i = 0; i <= k.segs; i++) {
        const s = i / k.segs, bend = Math.pow(s, 1.5);
        const sx = (Math.sin(t * 0.9 + k.ph + s * 2.4) * 0.42 + Math.sin(t * 0.37 + k.ph * 2) * 0.28) * bend * MOTION;
        const sz = Math.cos(t * 0.7 + k.ph + s * 2) * 0.25 * bend * MOTION;
        const w = k.w * Math.pow(Math.max(Math.sin(Math.PI * (0.1 + 0.9 * s)), 0), 0.6) * (1 + 0.12 * Math.sin(s * 15 + k.ph));
        const ang = k.ph + s * k.tw + Math.sin(t * 0.5 + k.ph) * 0.3;
        const dx = Math.cos(ang) * w * 0.5, dz = Math.sin(ang) * w * 0.5;
        const cx = k.x + sx, cy = k.y0 + s * k.h, cz = k.z + sz;
        p.setXYZ(i * 2, cx - dx, cy, cz - dz);
        p.setXYZ(i * 2 + 1, cx + dx, cy, cz + dz);
      }
      p.needsUpdate = true;
      k.geo.computeVertexNormals();
    }
  }

  // ─── Sun rays ─────────────────────────────────────────────────────────
  const rayTex = paintRay();
  const rays = [];
  for (let i = 0; i < 7; i++) {
    const mat = new THREE.MeshBasicMaterial({
      map: rayTex, color: 0xffffff, transparent: true, opacity: 0.1,
      blending: THREE.AdditiveBlending, depthWrite: false, fog: false,
    });
    const m = new THREE.Mesh(new THREE.PlaneGeometry(rr(1.2, 3.4), 17), mat);
    m.position.set(-13 + i * 4.3 + rr(-1, 1), -8.4, rr(-13, -3));
    m.rotation.z = 0.3 + rr(-0.05, 0.05);
    scene.add(m);
    rays.push({ m, base: rr(0.07, 0.16), ph: rand() * TAU, sp: rr(0.25, 0.6), rot: m.rotation.z });
  }

  // ─── Drifting specks (plankton that twinkles at night) ────────────────
  const SN = 520;
  const snGeo = new THREE.BufferGeometry();
  const snPos = new Float32Array(SN * 3), snSize = new Float32Array(SN), snPh = new Float32Array(SN);
  for (let i = 0; i < SN; i++) {
    snPos[i * 3] = rr(-18, 18); snPos[i * 3 + 1] = rr(SAND_Y, 0); snPos[i * 3 + 2] = rr(-24, 9);
    snSize[i] = rr(0.025, 0.07); snPh[i] = rand();
  }
  snGeo.setAttribute('position', new THREE.BufferAttribute(snPos, 3));
  snGeo.setAttribute('aSize', new THREE.BufferAttribute(snSize, 1));
  snGeo.setAttribute('aPh', new THREE.BufferAttribute(snPh, 1));
  const snMat = new THREE.ShaderMaterial({
    uniforms: {
      uTime: { value: 0 }, uScale: { value: 300 }, uColor: { value: new THREE.Color('#ffffff') },
      uOpacity: { value: 0.34 }, uTwinkle: { value: 0 },
    },
    vertexShader: `
      attribute float aSize;
      attribute float aPh;
      uniform float uTime;
      uniform float uScale;
      uniform float uTwinkle;
      varying float vA;
      void main() {
        vec4 mv = modelViewMatrix * vec4(position, 1.0);
        gl_Position = projectionMatrix * mv;
        float d = max(-mv.z, 0.1);
        gl_PointSize = max(aSize * uScale / d, 1.0);
        float tw = mix(1.0, 0.3 + 0.7 * (0.5 + 0.5 * sin(uTime * (0.7 + aPh) + aPh * 40.0)), uTwinkle);
        vA = tw * (1.0 - smoothstep(14.0, 30.0, d)) * smoothstep(0.5, 2.0, d);
      }`,
    fragmentShader: `
      uniform vec3 uColor;
      uniform float uOpacity;
      varying float vA;
      void main() {
        vec2 uv = gl_PointCoord * 2.0 - 1.0;
        float r2 = dot(uv, uv);
        if (r2 > 1.0) discard;
        gl_FragColor = vec4(uColor, exp(-r2 * 3.5) * vA * uOpacity);
      }`,
    transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
  });
  const snow = new THREE.Points(snGeo, snMat);
  snow.frustumCulled = false;
  scene.add(snow);
  function updateSnow(dt, t) {
    for (let i = 0; i < SN; i++) {
      const ph = snPh[i];
      let y = snPos[i * 3 + 1] - (0.04 + 0.05 * ph) * dt * MOTION;
      snPos[i * 3] += Math.sin(t * 0.25 + ph * 20) * 0.06 * dt * MOTION;
      if (y < SAND_Y) y -= SAND_Y;
      snPos[i * 3 + 1] = y;
    }
    snGeo.attributes.position.needsUpdate = true;
  }

  // ─── Bubbles (one Points cloud, drawn procedurally in the shader) ─────
  const BMAX = 700;
  const bGeo = new THREE.BufferGeometry();
  const bPos = new Float32Array(BMAX * 3), bSize = new Float32Array(BMAX), bAlpha = new Float32Array(BMAX);
  bGeo.setAttribute('position', new THREE.BufferAttribute(bPos, 3));
  bGeo.setAttribute('aSize', new THREE.BufferAttribute(bSize, 1));
  bGeo.setAttribute('aAlpha', new THREE.BufferAttribute(bAlpha, 1));
  const bMat = new THREE.ShaderMaterial({
    uniforms: { uScale: { value: 300 }, uColor: { value: new THREE.Color('#ffffff') } },
    vertexShader: `
      attribute float aSize;
      attribute float aAlpha;
      uniform float uScale;
      varying float vA;
      void main() {
        vec4 mv = modelViewMatrix * vec4(position, 1.0);
        gl_Position = projectionMatrix * mv;
        gl_PointSize = aSize * uScale / max(-mv.z, 0.1);
        vA = aAlpha;
      }`,
    fragmentShader: `
      uniform vec3 uColor;
      varying float vA;
      void main() {
        vec2 uv = gl_PointCoord * 2.0 - 1.0;
        float r = length(uv);
        if (r > 1.0 || vA <= 0.0) discard;
        float rim = smoothstep(0.62, 0.92, r) * (1.0 - smoothstep(0.92, 1.0, r));
        float hl = 1.0 - smoothstep(0.0, 0.3, length(uv - vec2(-0.34, -0.36)));
        float a = clamp(rim * 0.85 + hl * 0.95 + 0.12 * (1.0 - r), 0.0, 1.0);
        gl_FragColor = vec4(uColor, a * vA);
      }`,
    transparent: true, depthWrite: false,
  });
  const bubblePoints = new THREE.Points(bGeo, bMat);
  bubblePoints.frustumCulled = false;
  scene.add(bubblePoints);
  const bubbles = Array.from({ length: BMAX }, () => ({ on: false, p: new V3(), v: new V3(), s: 0, age: 0, life: 0, ph: 0 }));
  let bCursor = 0;
  function spawnBubble(p, size, vx, vy, vz) {
    for (let k = 0; k < BMAX; k++) {
      const i = (bCursor + k) % BMAX, b = bubbles[i];
      if (b.on) continue;
      b.on = true; b.p.copy(p); b.v.set(vx, vy, vz); b.s = size; b.age = 0; b.life = rr(5, 9); b.ph = rand() * TAU;
      bCursor = (i + 1) % BMAX;
      return;
    }
  }
  function updateBubbles(dt) {
    const top = -0.06;
    for (let i = 0; i < BMAX; i++) {
      const b = bubbles[i];
      if (!b.on) { bAlpha[i] = 0; bSize[i] = 0; continue; }
      b.age += dt;
      b.v.y = Math.min(b.v.y + 1.1 * dt, 0.75 + b.s * 3);
      b.v.x *= Math.exp(-1.5 * dt);
      b.v.z *= Math.exp(-1.5 * dt);
      b.p.x += (b.v.x + Math.sin(b.age * 7 + b.ph) * 0.12) * dt;
      b.p.y += b.v.y * dt;
      b.p.z += (b.v.z + Math.cos(b.age * 6 + b.ph) * 0.08) * dt;
      if (b.age > b.life || b.p.y > top) { b.on = false; bAlpha[i] = 0; bSize[i] = 0; continue; }
      bPos[i * 3] = b.p.x; bPos[i * 3 + 1] = b.p.y; bPos[i * 3 + 2] = b.p.z;
      bSize[i] = b.s * (1 + 0.15 * Math.min(b.age, 3) / 3) * (1 + 0.06 * Math.sin(b.age * 12 + b.ph));
      bAlpha[i] = smoothstep(0, 0.15, b.age) * (1 - smoothstep(top - 0.5, top, b.p.y)) * (1 - smoothstep(b.life - 1, b.life, b.age));
    }
    bGeo.attributes.position.needsUpdate = true;
    bGeo.attributes.aSize.needsUpdate = true;
    bGeo.attributes.aAlpha.needsUpdate = true;
  }
  const vents = [
    { x: -7.5, z: -10, t: 1.0, burst: 0 },
    { x: 6.5, z: -13, t: 3.0, burst: 0 },
  ].map((v) => Object.assign(v, { y: sandAt(v.x, v.z) + 0.05 }));
  const tVent = new V3();
  function updateVents(dt) {
    for (const v of vents) {
      v.t -= dt;
      if (v.t > 0) continue;
      if (v.burst > 0) {
        spawnBubble(tVent.set(v.x + rr(-0.05, 0.05), v.y, v.z + rr(-0.05, 0.05)), rr(0.035, 0.08), 0, 0.2, 0);
        v.burst--;
        v.t = rr(0.06, 0.18);
      } else {
        v.burst = 4 + Math.floor(rand() * 7);
        v.t = rr(2.5, 6);
      }
    }
  }


  // ─── The surface, seen from below ─────────────────────────────────────
  const ripples = Array.from({ length: 8 }, () => new THREE.Vector4(0, 0, 0, 0));
  function addRipple(x, z, strength) {
    let slot = ripples[0];
    for (const r of ripples) { if (r.w <= 0) { slot = r; break; } if (r.z > slot.z) slot = r; }
    slot.set(x, z, 0, strength);
  }
  const surfMat = new THREE.ShaderMaterial({
    uniforms: {
      uTime: { value: 0 }, uSky: { value: new THREE.Color() }, uRefl: { value: new THREE.Color() },
      uFog: { value: new THREE.Color() }, uSun: { value: new THREE.Color() }, uSunPos: { value: new THREE.Vector2(-2.5, 4) },
      uRip: { value: ripples }, uFade: { value: new THREE.Vector2(24, 60) },
    },
    vertexShader: `
      varying vec3 vWorld;
      void main() {
        vec4 w = modelMatrix * vec4(position, 1.0);
        vWorld = w.xyz;
        gl_Position = projectionMatrix * viewMatrix * w;
      }`,
    fragmentShader: `
      uniform float uTime;
      uniform vec3 uSky;
      uniform vec3 uRefl;
      uniform vec3 uFog;
      uniform vec3 uSun;
      uniform vec2 uSunPos;
      uniform vec4 uRip[8];
      uniform vec2 uFade;
      varying vec3 vWorld;
      void main() {
        vec3 toCam = cameraPosition - vWorld;
        float dist = length(toCam);
        vec3 V = toCam / dist;
        vec2 p = vWorld.xz;
        float t = uTime;
        vec2 g = vec2(0.0);
        g += vec2(0.6, 0.8) * cos(dot(p, vec2(0.6, 0.8)) * 1.1 + t * 1.3) * 0.08;
        g += vec2(-0.8, 0.5) * cos(dot(p, vec2(-0.8, 0.5)) * 1.9 + t * 1.7) * 0.06;
        g += vec2(0.3, -0.95) * cos(dot(p, vec2(0.3, -0.95)) * 3.1 + t * 2.3) * 0.04;
        g += vec2(0.9, 0.2) * cos(dot(p, vec2(0.9, 0.2)) * 5.3 + t * 3.1) * 0.025;
        float foam = 0.0;
        for (int i = 0; i < 8; i++) {
          vec4 r = uRip[i];
          if (r.w > 0.001) {
            vec2 d = p - r.xy;
            float len = length(d) + 0.0001;
            float x = len - r.z * 2.4;
            float env = exp(-x * x * 0.6) * r.w * exp(-r.z * 0.45);
            g += (d / len) * sin(x * 4.0) * env * 0.6;
            foam += r.w * exp(-len * len * 0.4) * exp(-r.z * 1.1);
          }
        }
        vec3 N = normalize(vec3(g.x, -1.0, g.y));
        float cosI = clamp(dot(V, N), 0.0, 1.0);
        float win = smoothstep(0.26, 0.5, cosI);
        float edge = exp(-pow((cosI - 0.3) * 14.0, 2.0));
        float sparkle = clamp(length(g) * 5.0, 0.0, 1.0);
        vec3 col = mix(uRefl * (0.7 + 0.6 * sparkle * sparkle), uSky * (0.8 + 0.4 * sparkle), win);
        vec2 sp = vWorld.xz + g * 5.0 - uSunPos;
        col += uSun * win * exp(-dot(sp, sp) * 0.06) * 0.8;
        col += uSky * edge * 0.18;
        float f = clamp(foam, 0.0, 1.0);
        col = mix(col, max(uSky, vec3(0.55)), f * 0.8);
        col = mix(col, uFog, smoothstep(uFade.x * 0.25, uFade.y, dist) * 0.9);
        float a = max(mix(0.92, 0.6, win), f);
        a *= 1.0 - smoothstep(uFade.x, uFade.y, dist);
        gl_FragColor = vec4(col, a);
      }`,
    transparent: true, depthWrite: false, side: THREE.DoubleSide,
  });
  const surfGeo = new THREE.PlaneGeometry(260, 260);
  surfGeo.rotateX(Math.PI / 2);
  const surface = new THREE.Mesh(surfGeo, surfMat);
  surface.position.set(0, 0, -70);
  surface.renderOrder = -0.5; // everything underwater sits between the camera and the surface, so draw it first
  surface.frustumCulled = false;
  scene.add(surface);

  // ─── Soft particle pools: blood, surface foam, night sparks ───────────
  const pools = [];
  function makePool(n, color, opacity, additive, order) {
    const geo = new THREE.BufferGeometry();
    const pos = new Float32Array(n * 3), size = new Float32Array(n), alpha = new Float32Array(n);
    geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
    geo.setAttribute('aSize', new THREE.BufferAttribute(size, 1));
    geo.setAttribute('aAlpha', new THREE.BufferAttribute(alpha, 1));
    const mat = new THREE.ShaderMaterial({
      uniforms: { uScale: { value: 300 }, uColor: { value: new THREE.Color(color) }, uOpacity: { value: opacity } },
      vertexShader: `
        attribute float aSize;
        attribute float aAlpha;
        uniform float uScale;
        varying float vA;
        void main() {
          vec4 mv = modelViewMatrix * vec4(position, 1.0);
          gl_Position = projectionMatrix * mv;
          gl_PointSize = clamp(aSize * uScale / max(-mv.z, 0.1), 0.0, 900.0);
          vA = aAlpha;
        }`,
      fragmentShader: `
        uniform vec3 uColor;
        uniform float uOpacity;
        varying float vA;
        void main() {
          vec2 uv = gl_PointCoord * 2.0 - 1.0;
          float r2 = dot(uv, uv);
          if (r2 > 1.0 || vA <= 0.0) discard;
          gl_FragColor = vec4(uColor, exp(-r2 * 2.6) * (1.0 - r2) * vA * uOpacity);
        }`,
      transparent: true, depthWrite: false, blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending,
    });
    const pts = new THREE.Points(geo, mat);
    pts.frustumCulled = false;
    pts.renderOrder = order;
    scene.add(pts);
    const P = Array.from({ length: n }, () => ({ on: false, p: new V3(), v: new V3(), age: 0, life: 1, s0: 0, s1: 1, a: 1, drag: 1, rise: 0 }));
    let cursor = 0;
    const pool = {
      mat,
      spawn(x, y, z, vx, vy, vz, life, s0, s1, a, drag, rise) {
        const q = P[cursor];
        cursor = (cursor + 1) % n;
        q.on = true; q.p.set(x, y, z); q.v.set(vx, vy, vz); q.age = 0; q.life = life;
        q.s0 = s0; q.s1 = s1; q.a = a; q.drag = drag; q.rise = rise;
      },
      update(dt) {
        for (let i = 0; i < n; i++) {
          const q = P[i];
          if (q.on) {
            q.age += dt;
            if (q.age >= q.life) q.on = false;
            else {
              q.v.multiplyScalar(Math.exp(-q.drag * dt));
              q.v.y += q.rise * dt;
              q.p.addScaledVector(q.v, dt);
              if (q.p.y > -0.04) { q.p.y = -0.04; if (q.v.y > 0) q.v.y = 0; }
            }
          }
          const k = q.age / q.life;
          pos[i * 3] = q.p.x; pos[i * 3 + 1] = q.p.y; pos[i * 3 + 2] = q.p.z;
          size[i] = q.on ? lerp(q.s0, q.s1, 1 - (1 - k) * (1 - k)) : 0;
          alpha[i] = q.on ? q.a * smoothstep(0, 0.06, k) * Math.pow(1 - k, 1.4) : 0;
        }
        geo.attributes.position.needsUpdate = true;
        geo.attributes.aSize.needsUpdate = true;
        geo.attributes.aAlpha.needsUpdate = true;
      },
    };
    pools.push(pool);
    return pool;
  }
  const blood = makePool(900, '#8e0f17', 0.6, false, 3);
  const foam = makePool(320, '#ffffff', 0.9, false, 6);
  const sparks = makePool(400, '#7ef9ff', 0, true, 4);

  const tSp = new V3();
  function surfaceSplash(x, z, power) {
    addRipple(x, z, 0.8 * power);
    const nf = Math.round(70 * power), nb = Math.round(120 * power);
    for (let i = 0; i < nf; i++) {
      const a = rand() * TAU, r = Math.sqrt(rand()) * 1.2 * power, sp = rr(0.3, 1.8) * power;
      foam.spawn(x + Math.cos(a) * r, -0.05, z + Math.sin(a) * r, Math.cos(a) * sp, 0, Math.sin(a) * sp,
        rr(2.5, 5.5), rr(0.3, 0.6), rr(1.2, 2.6) * power, rr(0.5, 0.9), 1.1, 0);
    }
    for (let i = 0; i < nb; i++) {
      const a = rand() * TAU, r = Math.sqrt(rand()) * 1.5 * power;
      spawnBubble(tSp.set(x + Math.cos(a) * r, rr(-2.4, -0.15) * power, z + Math.sin(a) * r), rr(0.04, 0.15),
        Math.cos(a) * rr(0, 1.4), rr(-2.2, 0.4), Math.sin(a) * rr(0, 1.4));
    }
  }

  // ─── A school of baitfish (boids) ─────────────────────────────────────
  const NF = 90;
  const fishGeo = (() => {
    const body = new THREE.SphereGeometry(1, 10, 7).toNonIndexed();
    body.scale(0.035, 0.055, 0.17);
    const bp = body.attributes.position.array, bn = body.attributes.normal.array;
    const pos = new Float32Array(bp.length + 9), nor = new Float32Array(bp.length + 9);
    pos.set(bp); nor.set(bn);
    pos.set([0, 0, -0.14, 0, 0.065, -0.26, 0, -0.065, -0.26], bp.length);
    for (let i = 0; i < 3; i++) nor.set([1, 0, 0], bp.length + i * 3);
    const col = new Float32Array(pos.length), c = new THREE.Color(), top = new THREE.Color('#2c5a74'), bot = new THREE.Color('#e8f1f4');
    for (let i = 0; i < pos.length / 3; i++) {
      c.copy(bot).lerp(top, smoothstep(-0.01, 0.035, pos[i * 3 + 1]));
      col.set([c.r, c.g, c.b], i * 3);
    }
    const g = new THREE.BufferGeometry();
    g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
    g.setAttribute('normal', new THREE.BufferAttribute(nor, 3));
    g.setAttribute('color', new THREE.BufferAttribute(col, 3));
    const ph = new Float32Array(NF);
    for (let i = 0; i < NF; i++) ph[i] = rand();
    g.setAttribute('aPhase', new THREE.InstancedBufferAttribute(ph, 1));
    return g;
  })();
  const fishTime = { value: 0 };
  const fishMat = new THREE.MeshStandardMaterial({ vertexColors: true, metalness: 0.55, roughness: 0.35, side: THREE.DoubleSide });
  fishMat.onBeforeCompile = (sh) => {
    sh.uniforms.uTime = fishTime;
    sh.vertexShader = 'attribute float aPhase;\nuniform float uTime;\n' + sh.vertexShader.replace('#include <begin_vertex>', `#include <begin_vertex>
      transformed.x += sin(uTime * 16.0 + aPhase * 6.2831 + position.z * 18.0) * smoothstep(0.06, -0.25, position.z) * 0.045;`);
  };
  const fishSchool = new THREE.InstancedMesh(fishGeo, fishMat, NF);
  fishSchool.frustumCulled = false;
  scene.add(fishSchool);
  const fishes = [];
  for (let i = 0; i < NF; i++) {
    fishes.push({ p: new V3(rr(-2, 2), rr(-4.2, -2.6), rr(-8, -5)), v: new V3(rr(-0.5, 0.5), 0, 1).setLength(1.4), alive: true, back: 0 });
  }
  const fCenter = new V3(), fAvg = new V3(), fHome = new V3(), fSteer = new V3(), fT = new V3(), segP = new V3(), segD = new V3(), segT = new V3();
  const fQ = new THREE.Quaternion(), fM = new THREE.Matrix4(), FZ = new V3(0, 0, 1), F_ONE = new V3(1, 1, 1), F_ZERO = new V3(0, 0, 0);
  function closestOnShark(p, out) {
    segD.subVectors(tailW, snoutW);
    const k = clamp(segT.subVectors(p, snoutW).dot(segD) / (segD.lengthSq() || 1), 0, 1);
    return out.copy(snoutW).addScaledVector(segD, k);
  }
  function updateSchool(dt, t) {
    fishTime.value = t;
    fHome.set(Math.min(patrolAmpX, 6) * 0.8 * Math.sin(t * 0.06 + 2), -3.3 + 1.1 * Math.sin(t * 0.1), -6.5 + 2.6 * Math.sin(t * 0.08 + 1));
    fCenter.set(0, 0, 0); fAvg.set(0, 0, 0);
    let n = 0;
    for (const f of fishes) if (f.alive) { fCenter.add(f.p); fAvg.add(f.v); n++; }
    if (n) { fCenter.divideScalar(n); fAvg.divideScalar(n); }
    for (let i = 0; i < NF; i++) {
      const f = fishes[i];
      if (!f.alive) {
        f.back -= dt;
        if (f.back <= 0) { f.alive = true; f.p.set(fHome.x + rr(-1.5, 1.5), fHome.y + rr(-0.6, 0.6), fHome.z - rr(5, 8)); f.v.set(0, 0, 1.4); }
        fishSchool.setMatrixAt(i, fM.compose(f.p, fQ.identity(), F_ZERO));
        continue;
      }
      fSteer.subVectors(fCenter, f.p).multiplyScalar(0.5);
      fSteer.addScaledVector(fT.subVectors(fAvg, f.v), 0.9);
      fSteer.addScaledVector(fT.subVectors(fHome, f.p), 0.3);
      for (let j = 0; j < NF; j++) {
        const g = fishes[j];
        if (j === i || !g.alive) continue;
        const dx = f.p.x - g.p.x, dy = f.p.y - g.p.y, dz = f.p.z - g.p.z, d2 = dx * dx + dy * dy + dz * dz;
        if (d2 < 0.12 && d2 > 1e-6) { const k = (0.12 - d2) * 30; fSteer.x += dx * k; fSteer.y += dy * k; fSteer.z += dz * k; }
      }
      closestOnShark(f.p, segP);
      const ds = f.p.distanceTo(segP);
      const panic = ds < 4.2;
      if (panic) fSteer.addScaledVector(fT.subVectors(f.p, segP).normalize(), (4.2 - ds) * 9);
      if (f.p.y > -0.7) fSteer.y -= 5;
      if (f.p.y < SAND_Y + 2) fSteer.y += 5;
      f.v.addScaledVector(fSteer, dt);
      const sp = f.v.length(), maxS = panic ? 5.5 : 2.2;
      if (sp > maxS) f.v.multiplyScalar(maxS / sp);
      else if (sp < 0.9) f.v.multiplyScalar(0.9 / Math.max(sp, 1e-4));
      f.p.addScaledVector(f.v, dt);
      fQ.setFromUnitVectors(FZ, fT.copy(f.v).normalize());
      fishSchool.setMatrixAt(i, fM.compose(f.p, fQ, F_ONE));
    }
    fishSchool.instanceMatrix.needsUpdate = true;
  }
  function eatFishNear(p, r) {
    let n = 0;
    for (const f of fishes) if (f.alive && f.p.distanceTo(p) < r) { f.alive = false; f.back = rr(6, 12); n++; }
    return n;
  }

  // ─── Seals resting at the surface ─────────────────────────────────────
  const seals = [];
  const sealBodyGeo = (() => {
    const g = new THREE.SphereGeometry(1, 24, 14), p = g.attributes.position;
    const col = new Float32Array(p.count * 3), c = new THREE.Color(), back = new THREE.Color('#3a312b'), belly = new THREE.Color('#7a6a5a');
    for (let i = 0; i < p.count; i++) {
      const x = p.getX(i), y = p.getY(i), z = p.getZ(i), taper = z > 0 ? 1 - 0.3 * z * z : 1 + 0.6 * z;
      p.setXYZ(i, x * 0.22 * taper, y * 0.19 * taper, z * 0.78);
      c.copy(belly).lerp(back, smoothstep(-0.3, 0.4, y));
      col.set([c.r, c.g, c.b], i * 3);
    }
    g.setAttribute('color', new THREE.BufferAttribute(col, 3));
    g.computeVertexNormals();
    return g;
  })();
  const sealSkin = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.65 });
  const sealHeadMat = new THREE.MeshStandardMaterial({ color: '#463c35', roughness: 0.6 });
  const sealFlipMat = new THREE.MeshStandardMaterial({ color: '#2f2823', roughness: 0.7 });
  const sealEyeMat = new THREE.MeshStandardMaterial({ color: '#050505', roughness: 0.1 });
  const sphere1 = new THREE.SphereGeometry(1, 14, 10);
  function makeSeal(x, z) {
    const g = new THREE.Group();
    g.rotation.order = 'YXZ';
    g.add(new THREE.Mesh(sealBodyGeo, sealSkin));
    const head = new THREE.Mesh(sphere1, sealHeadMat);
    head.scale.set(0.15, 0.14, 0.17); head.position.set(0, 0.04, 0.8);
    const snout = new THREE.Mesh(sphere1, sealHeadMat);
    snout.scale.set(0.075, 0.065, 0.1); snout.position.set(0, 0, 0.95);
    g.add(head, snout);
    for (const sx of [-1, 1]) {
      const e = new THREE.Mesh(sphere1, sealEyeMat);
      e.scale.setScalar(0.022); e.position.set(sx * 0.085, 0.08, 0.9);
      g.add(e);
    }
    const fore = [-1, 1].map((sx) => {
      const p = new THREE.Group(), m = new THREE.Mesh(sphere1, sealFlipMat);
      p.position.set(sx * 0.17, -0.08, 0.36);
      m.scale.set(0.2, 0.022, 0.075); m.position.x = sx * 0.17;
      p.add(m); g.add(p);
      return p;
    });
    const hind = [-1, 1].map((sx) => {
      const p = new THREE.Group(), m = new THREE.Mesh(sphere1, sealFlipMat);
      p.position.set(sx * 0.06, 0, -0.76);
      m.scale.set(0.06, 0.018, 0.2); m.position.z = -0.17;
      p.add(m); g.add(p);
      return p;
    });
    scene.add(g);
    const s = { g, fore, hind, pos: new V3(x, -0.14, z), heading: rand() * TAU, turn: 0, turnT: 0, ph: rand() * TAU, held: false, panic: 0 };
    seals.push(s);
    addRipple(x, z, 0.35);
    for (let i = 0; i < 14; i++) spawnBubble(tSp.set(x + rr(-0.4, 0.4), rr(-0.6, -0.1), z + rr(-0.4, 0.4)), rr(0.03, 0.07), 0, 0.2, 0);
    return s;
  }
  function updateSeals(dt, t) {
    for (const s of seals) {
      const k = s.held ? 3 : s.panic > 0 ? 2.2 : 1, paddle = Math.sin(t * 2.4 * k + s.ph), kick = Math.sin(t * 3 * k + s.ph);
      s.fore[0].rotation.set(0, 0.4 + paddle * 0.4, -0.35 - paddle * 0.35);
      s.fore[1].rotation.set(0, -0.4 - paddle * 0.4, 0.35 + paddle * 0.35);
      s.hind[0].rotation.y = kick * 0.45 + 0.15;
      s.hind[1].rotation.y = kick * 0.45 - 0.15;
      if (s.held) continue;
      s.panic = Math.max(0, s.panic - dt);
      s.turnT -= dt;
      if (s.turnT <= 0) { s.turnT = rr(2, 5); s.turn = rr(-0.5, 0.5); }
      const spd = s.panic > 0 ? 1.5 : 0.32;
      if (Math.abs(s.pos.x) > sealZoneX || s.pos.z < -5.5 || s.pos.z > 2) s.heading = dampAngle(s.heading, Math.atan2(-s.pos.x, -1.8 - s.pos.z), 2, dt);
      else s.heading += s.turn * dt * (s.panic > 0 ? 4 : 1);
      s.pos.x += Math.sin(s.heading) * spd * dt;
      s.pos.z += Math.cos(s.heading) * spd * dt;
      s.pos.y = -0.14 + Math.sin(t * 1.6 + s.ph) * 0.03;
      s.g.position.copy(s.pos);
      s.g.rotation.set(Math.sin(t * 1.1 + s.ph) * 0.05, s.heading, Math.sin(t * 1.3 + s.ph) * 0.08);
    }
  }

  // ─── Great white ──────────────────────────────────────────────────────
  // The body is a lofted tube: s runs from the snout (0) to the tail stock (1).
  const SL = 4.6, NS = 72, NA = 40, NR = NS + 2, K_WAVE = TAU * 0.8;
  const zOf = (s) => SL * (0.5 - s);
  const frontF = (s) => Math.pow(Math.sin(Math.min(s / 0.38, 1) * Math.PI / 2), 0.72);
  const backF = (s) => (s < 0.38 ? 1 : 1 - 0.8 * Math.pow((s - 0.38) / 0.62, 1.15));
  const hOf = (s) => 0.56 * frontF(s) * backF(s);
  const wOf = (s) => 0.47 * frontF(s) * backF(s) * (1 + 0.35 * smoothstep(0.85, 1, s));
  const cOf = (s) => 0.12 * (1 - smoothstep(0, 0.35, s)) + 0.05 * smoothstep(0.6, 1, s);
  const flatOf = (s, yn) => (yn < 0 && s < 0.25 ? lerp(0.72, 1, s / 0.25) : 1);
  const mouthLine = (s) => lerp(-0.6, -0.12, smoothstep(0.05, 0.19, s));
  const isJawAt = (s, yn) => s > 0.05 && s < 0.2 && yn < mouthLine(s);
  const liftW = (s) => 1 - smoothstep(0.08, 0.26, s);
  const protW = (s, yn) => (1 - smoothstep(0.16, 0.22, s)) * (1 - smoothstep(mouthLine(s) + 0.05, mouthLine(s) + 0.4, yn));
  const HY = cOf(0.205) - 0.12 * hOf(0.205), HZ = zOf(0.205);   // jaw hinge
  const H2Y = cOf(0.3) + 0.35 * hOf(0.3), H2Z = zOf(0.3);       // snout lift pivot
  function skinPoint(out, s, yn, side, inset) {
    const k = inset === undefined ? 1 : inset, a = Math.asin(clamp(yn, -1, 1));
    return out.set(side * wOf(s) * Math.cos(a) * k, cOf(s) + hOf(s) * yn * flatOf(s, yn) * k, zOf(s));
  }

  const ringS = new Float32Array(NR);
  for (let i = 1; i <= NS; i++) ringS[i] = Math.pow(i / NS, 1.25);
  ringS[NR - 1] = 1;
  const NV = 2 + NS * NA, TAILV = NV - 1;
  const rest = new Float32Array(NV * 3), vRing = new Uint8Array(NV), vJaw = new Uint8Array(NV), vProt = new Float32Array(NV), vYn = new Float32Array(NV);
  const vid = (i, j) => 1 + (i - 1) * NA + (j % NA);
  rest[1] = cOf(0); rest[2] = zOf(0);
  for (let i = 1; i <= NS; i++) {
    const s = ringS[i];
    for (let j = 0; j < NA; j++) {
      const th = (j / NA) * TAU, yn = Math.sin(th), v = vid(i, j);
      rest[v * 3] = wOf(s) * Math.cos(th);
      rest[v * 3 + 1] = cOf(s) + hOf(s) * yn * flatOf(s, yn);
      rest[v * 3 + 2] = zOf(s);
      vRing[v] = i; vYn[v] = yn;
      vJaw[v] = isJawAt(s, yn) ? 1 : 0;
      vProt[v] = vJaw[v] ? 0 : protW(s, yn);
    }
  }
  rest[TAILV * 3 + 1] = cOf(1); rest[TAILV * 3 + 2] = zOf(1) - 0.03; vRing[TAILV] = NR - 1;

  // Triangles that straddle the lip line are left out: that gap is the mouth.
  const idx = [], upperLip = new Set(), lowerLip = new Set();
  function tri(a, b, c) {
    const n = vJaw[a] + vJaw[b] + vJaw[c];
    if (n > 0 && n < 3 && Math.max(ringS[vRing[a]], ringS[vRing[b]], ringS[vRing[c]]) < 0.185) {
      for (const q of [a, b, c]) (vJaw[q] ? lowerLip : upperLip).add(q);
      return;
    }
    idx.push(a, b, c);
  }
  for (let j = 0; j < NA; j++) tri(0, vid(1, j), vid(1, j + 1));
  for (let i = 1; i < NS; i++) {
    for (let j = 0; j < NA; j++) {
      const a = vid(i, j), b = vid(i, j + 1), c = vid(i + 1, j), d = vid(i + 1, j + 1);
      tri(a, c, b); tri(b, c, d);
    }
  }
  for (let j = 0; j < NA; j++) tri(TAILV, vid(NS, j + 1), vid(NS, j));

  // Countershading with the ragged grey/white boundary great whites have.
  const C_TOP = new THREE.Color('#46535d'), C_FLANK = new THREE.Color('#7c8992'), C_BELLY = new THREE.Color('#eeeee8');
  const C_GUM = new THREE.Color('#c79a98'), C_DARK = new THREE.Color('#1c2227'), C_WHITE = new THREE.Color('#eeeee8');
  const bodyCol = new Float32Array(NV * 3), tc = new THREE.Color();
  for (let v = 0; v < NV; v++) {
    const s = ringS[vRing[v]], yn = vYn[v], xn = rest[v * 3] / Math.max(wOf(s), 1e-3);
    const jag = 0.07 * Math.sin(s * 47 + 1.3) + 0.045 * Math.sin(s * 113 + 0.4) + 0.03 * Math.sin(xn * 9 + s * 20);
    const bnd = -0.06 - 0.24 * smoothstep(0.12, 0.5, s) + 0.12 * smoothstep(0.78, 1, s) + jag;
    tc.copy(C_BELLY).lerp(C_FLANK, smoothstep(bnd - 0.035, bnd + 0.035, yn)).lerp(C_TOP, smoothstep(0.25, 0.95, yn));
    tc.multiplyScalar(0.95 + 0.05 * Math.sin(s * 90 + xn * 7) * Math.sin(yn * 11 + s * 30));
    if (s < 0.2) tc.lerp(C_GUM, 0.55 * Math.exp(-Math.pow((yn - mouthLine(s)) / 0.07, 2)) * (1 - smoothstep(0.17, 0.2, s)));
    bodyCol[v * 3] = tc.r; bodyCol[v * 3 + 1] = tc.g; bodyCol[v * 3 + 2] = tc.b;
  }
  const bodyGeo = new THREE.BufferGeometry();
  bodyGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(rest), 3));
  bodyGeo.setAttribute('color', new THREE.BufferAttribute(bodyCol, 3));
  bodyGeo.setIndex(idx);
  bodyGeo.computeVertexNormals();
  // Double-sided so the inside of the head reads as a dark red mouth through the gap.
  const bodyMat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.5, metalness: 0.05, side: THREE.DoubleSide });
  bodyMat.onBeforeCompile = (sh) => {
    sh.fragmentShader = sh.fragmentShader.replace('#include <color_fragment>',
      '#include <color_fragment>\n  if (!gl_FrontFacing) diffuseColor.rgb = vec3(0.32, 0.04, 0.06);');
  };
  const sharkRoot = new THREE.Group();
  sharkRoot.rotation.order = 'YXZ';
  const bodyMesh = new THREE.Mesh(bodyGeo, bodyMat);
  bodyMesh.frustumCulled = false;
  sharkRoot.add(bodyMesh);
  scene.add(sharkRoot);

  // ─── Pose: jaw drop, snout lift, upper jaw protrusion, swimming wave ─
  // Swimming wave for a lamnid shark: the front is stiff and nearly all the
  // side-to-side motion is in the rear third, growing toward the tail. When
  // turning, the whole body bows along the arc it is following.
  const cur = { jaw: 0, A: 0.5, ph: 0, curv: 0 };
  const BO = { x: 0, c: 1, s: 0, b: 0 };
  const SL2 = SL * SL, PIVOT = 0.38;
  function bend(s) {
    const sp = Math.pow(Math.max(s, 0), 1.4), env = 0.006 + 0.084 * sp * s, envd = 0.2016 * sp;
    const arg = cur.ph - K_WAVE * s, sa = Math.sin(arg), ca = Math.cos(arg), d = PIVOT - s;
    BO.x = cur.A * SL * env * sa + 0.5 * cur.curv * SL2 * d * d;
    const dxds = cur.A * SL * (envd * sa - env * K_WAVE * ca) - cur.curv * SL2 * d;
    BO.b = Math.atan(-dxds / SL);
    BO.c = Math.cos(BO.b); BO.s = Math.sin(BO.b);
    return BO;
  }
  function posePoint(out, x, y, z, s, isJaw, pr) {
    const j = cur.jaw;
    if (isJaw) {
      const phi = j * 0.95, c = Math.cos(phi), sn = Math.sin(phi), dy = y - HY, dz = z - HZ;
      y = HY + dy * c - dz * sn; z = HZ + dy * sn + dz * c;
    } else {
      const l = -j * 0.2 * liftW(s);
      if (l !== 0) { const c = Math.cos(l), sn = Math.sin(l), dy = y - H2Y, dz = z - H2Z; y = H2Y + dy * c - dz * sn; z = H2Z + dy * sn + dz * c; }
      if (pr > 0) { y -= 0.06 * j * pr; z += 0.06 * j * pr; }
    }
    bend(s);
    return out.set(BO.x + x * BO.c, y, z - x * BO.s);
  }
  const bodyPos = bodyGeo.attributes.position.array;
  const rX = new Float32Array(NR), rC = new Float32Array(NR), rSn = new Float32Array(NR), rLC = new Float32Array(NR), rLS = new Float32Array(NR);
  function poseBody() {
    const j = cur.jaw, phi = j * 0.95, cp = Math.cos(phi), sp = Math.sin(phi), prY = -0.06 * j, prZ = 0.06 * j;
    for (let r = 0; r < NR; r++) {
      bend(ringS[r]);
      rX[r] = BO.x; rC[r] = BO.c; rSn[r] = BO.s;
      const l = -j * 0.2 * liftW(ringS[r]);
      rLC[r] = Math.cos(l); rLS[r] = Math.sin(l);
    }
    for (let v = 0; v < NV; v++) {
      const x = rest[v * 3], r = vRing[v];
      let y = rest[v * 3 + 1], z = rest[v * 3 + 2];
      if (vJaw[v]) {
        const dy = y - HY, dz = z - HZ;
        y = HY + dy * cp - dz * sp; z = HZ + dy * sp + dz * cp;
      } else {
        if (rLS[r] !== 0) { const dy = y - H2Y, dz = z - H2Z; y = H2Y + dy * rLC[r] - dz * rLS[r]; z = H2Z + dy * rLS[r] + dz * rLC[r]; }
        const pw = vProt[v];
        if (pw > 0) { y += prY * pw; z += prZ * pw; }
      }
      bodyPos[v * 3] = rX[r] + x * rC[r];
      bodyPos[v * 3 + 1] = y;
      bodyPos[v * 3 + 2] = z - x * rSn[r];
    }
    bodyGeo.attributes.position.needsUpdate = true;
    bodyGeo.computeVertexNormals();
  }

  // ─── Teeth, placed along the lip gaps found while cutting the mouth ───
  function lipTeeth(set, n, lower) {
    const B = 14, bins = Array.from({ length: B }, () => ({ x: 0, y: 0, z: 0, s: 0, pr: 0, n: 0 }));
    for (const v of set) {
      const x = rest[v * 3], y = rest[v * 3 + 1], z = rest[v * 3 + 2], a = Math.atan2(x, z - HZ);
      if (Math.abs(a) > 1.3) continue;
      const b = bins[Math.min(B - 1, Math.floor(((a + 1.3) / 2.6) * B))];
      b.x += x; b.y += y; b.z += z; b.s += ringS[vRing[v]]; b.pr += vProt[v]; b.n++;
    }
    const poly = bins.filter((b) => b.n).map((b) => ({ x: b.x / b.n, y: b.y / b.n, z: b.z / b.n, s: b.s / b.n, pr: b.pr / b.n }));
    if (poly.length < 2) return [];
    const cum = [0];
    for (let i = 1; i < poly.length; i++) cum.push(cum[i - 1] + Math.hypot(poly[i].x - poly[i - 1].x, poly[i].y - poly[i - 1].y, poly[i].z - poly[i - 1].z));
    const total = cum[cum.length - 1], out = [];
    for (let k = 0; k < n; k++) {
      const d = (total * (k + 0.5)) / n;
      let i = 1;
      while (i < poly.length - 1 && cum[i] < d) i++;
      const f = clamp((d - cum[i - 1]) / Math.max(cum[i] - cum[i - 1], 1e-6), 0, 1), A = poly[i - 1], Bp = poly[i];
      const x = lerp(A.x, Bp.x, f), y = lerp(A.y, Bp.y, f), z = lerp(A.z, Bp.z, f);
      const mid = Math.abs((k + 0.5) / n - 0.5) * 2, size = lerp(1.05, 0.55, mid * mid), len = 0.075 * size;
      const bx = x * 0.9, by = y + (lower ? -0.012 : 0.012), bz = z - 0.025;
      out.push({
        jaw: lower ? 1 : 0, s: lerp(A.s, Bp.s, f), pr: lower ? 0 : lerp(A.pr, Bp.pr, f), size,
        bx, by, bz, tx: bx * 0.94, ty: by + (lower ? len : -len), tz: bz - 0.01, ox: x, oz: Math.max(z - HZ, 0.02),
      });
    }
    return out;
  }
  const allTeeth = lipTeeth(upperLip, 24, false).concat(lipTeeth(lowerLip, 22, true));
  const toothGeo = new THREE.ConeGeometry(1, 1, 4, 1);
  toothGeo.translate(0, 0.5, 0);
  const teethMesh = new THREE.InstancedMesh(toothGeo, new THREE.MeshStandardMaterial({ color: '#f3efe2', roughness: 0.3 }), Math.max(1, allTeeth.length));
  teethMesh.frustumCulled = false;
  sharkRoot.add(teethMesh);

  // ─── Eyes (black, rolling back to white when it bites) ────────────────
  const eyeGeo = new THREE.SphereGeometry(1, 18, 12);
  const eyeMat = new THREE.MeshStandardMaterial({ color: '#030405', roughness: 0.06, metalness: 0.4 });
  const eyeWhiteMat = new THREE.MeshStandardMaterial({ color: '#d6d9d4', roughness: 0.4 });
  const eyes = [1, -1].map((side) => {
    const r = skinPoint(new V3(), 0.115, 0.16, side, 0.93);
    const black = new THREE.Mesh(eyeGeo, eyeMat), white = new THREE.Mesh(eyeGeo, eyeWhiteMat);
    black.scale.setScalar(0.042); white.scale.setScalar(0.044); white.visible = false;
    sharkRoot.add(black, white);
    return { r, black, white };
  });

  // ─── Sensory pores and nostrils on the snout ──────────────────────────
  const pores = [];
  for (let i = 0; i < 90 && pores.length < 38; i++) {
    const s = rr(0.012, 0.11), th = rr(-1.2, 1.35), yn = Math.sin(th), side = rand() < 0.5 ? -1 : 1;
    if (isJawAt(s, yn) || Math.abs(yn - mouthLine(s)) < 0.12) continue;
    const p = skinPoint(new V3(), s, yn, side, 1);
    pores.push({ x: p.x, y: p.y, z: p.z, s, pr: protW(s, yn), sz: rr(0.006, 0.0105), fy: 1 });
  }
  for (const side of [1, -1]) {
    const p = skinPoint(new V3(), 0.038, -0.42, side, 0.99);
    pores.push({ x: p.x, y: p.y, z: p.z, s: 0.038, pr: protW(0.038, -0.42), sz: 0.024, fy: 0.45 });
  }
  const poreMesh = new THREE.InstancedMesh(new THREE.SphereGeometry(1, 6, 4), new THREE.MeshStandardMaterial({ color: '#1e2428', roughness: 0.6 }), pores.length);
  poreMesh.frustumCulled = false;
  sharkRoot.add(poreMesh);

  // ─── Five gill slits a side ───────────────────────────────────────────
  const gillMat = new THREE.MeshStandardMaterial({ color: '#2a3238', roughness: 0.7 });
  const gills = [];
  for (const side of [1, -1]) {
    for (let k = 0; k < 5; k++) {
      const s = 0.205 + k * 0.021, top = 0.4 - Math.abs(k - 2) * 0.04, bot = -0.5 + k * 0.035;
      const r = skinPoint(new V3(), s, 0, side, 1), pts = [];
      for (let m = 0; m <= 8; m++) {
        const f = m / 8;
        pts.push(skinPoint(new V3(), s + 0.008 * (1 - Math.pow(f * 2 - 1, 2)), lerp(bot, top, f), side, 1.015).sub(r));
      }
      const grp = new THREE.Group();
      grp.add(new THREE.Mesh(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 14, 0.009, 4, false), gillMat));
      sharkRoot.add(grp);
      gills.push({ grp, r, s });
    }
  }

  // ─── Fins (extruded outlines, painted per vertex) ─────────────────────
  const finMat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.55 });
  function makeFin(shape, depth, chord, span, paint) {
    const g = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: true, bevelThickness: depth * 0.5, bevelSize: depth * 0.45, bevelSegments: 2, curveSegments: 16 });
    g.translate(0, 0, -depth / 2);
    const c = chord.clone().normalize(), sp = span.clone().addScaledVector(c, -span.dot(c)).normalize(), n = new V3().crossVectors(c, sp);
    g.applyMatrix4(new THREE.Matrix4().makeBasis(c, sp, n));
    const p = g.attributes.position, nr = g.attributes.normal, col = new Float32Array(p.count * 3), cc = new THREE.Color(), P = new V3(), N = new V3();
    let maxS = 1e-3;
    for (let i = 0; i < p.count; i++) maxS = Math.max(maxS, P.fromBufferAttribute(p, i).dot(sp));
    for (let i = 0; i < p.count; i++) {
      P.fromBufferAttribute(p, i); N.fromBufferAttribute(nr, i);
      paint(cc, clamp(P.dot(sp) / maxS, 0, 1), N);
      col[i * 3] = cc.r; col[i * 3 + 1] = cc.g; col[i * 3 + 2] = cc.b;
    }
    g.setAttribute('color', new THREE.BufferAttribute(col, 3));
    const grp = new THREE.Group();
    grp.add(new THREE.Mesh(g, finMat));
    sharkRoot.add(grp);
    return grp;
  }
  const paintGrey = (cc, f) => cc.copy(C_FLANK).lerp(C_TOP, smoothstep(0.1, 0.85, f));
  const paintPec = (cc, f, N) => (N.y < -0.15 ? cc.copy(C_WHITE).lerp(C_DARK, smoothstep(0.72, 0.92, f)) : cc.copy(C_FLANK).lerp(C_TOP, smoothstep(0.2, 0.9, f)));
  const paintLow = (cc, f, N) => cc.copy(N.y < -0.15 ? C_WHITE : C_FLANK).lerp(C_TOP, f * 0.3);
  const dorsalShape = new THREE.Shape();
  dorsalShape.moveTo(0.34, -0.12); dorsalShape.lineTo(0.34, 0);
  dorsalShape.bezierCurveTo(0.28, 0.26, 0.1, 0.5, -0.12, 0.6);
  dorsalShape.quadraticCurveTo(-0.04, 0.24, -0.28, 0);
  dorsalShape.lineTo(-0.28, -0.12); dorsalShape.closePath();
  const pecShape = new THREE.Shape();
  pecShape.moveTo(0.26, -0.05); pecShape.lineTo(0.26, 0);
  pecShape.bezierCurveTo(0.22, 0.32, 0.04, 0.74, -0.27, 0.95);
  pecShape.bezierCurveTo(-0.13, 0.66, -0.12, 0.3, -0.2, 0);
  pecShape.lineTo(-0.2, -0.05); pecShape.closePath();
  const ck = 0.82, tailShape = new THREE.Shape();
  tailShape.moveTo(-0.1 * ck, 0.12 * ck);
  tailShape.bezierCurveTo(0.15 * ck, 0.3 * ck, 0.42 * ck, 0.74 * ck, 0.74 * ck, 1.02 * ck);
  tailShape.quadraticCurveTo(0.42 * ck, 0.45 * ck, 0.47 * ck, 0.02 * ck);
  tailShape.quadraticCurveTo(0.42 * ck, -0.4 * ck, 0.63 * ck, -0.84 * ck);
  tailShape.bezierCurveTo(0.36 * ck, -0.55 * ck, 0.1 * ck, -0.2 * ck, -0.1 * ck, -0.1 * ck);
  tailShape.closePath();
  const smallFin = (h, back) => {
    const s = new THREE.Shape();
    s.moveTo(0.07, -0.04); s.lineTo(0.07, 0);
    s.quadraticCurveTo(0.04, h * 0.7, -back, h);
    s.quadraticCurveTo(-0.03, h * 0.3, -0.08, 0);
    s.lineTo(-0.08, -0.04); s.closePath();
    return s;
  };
  const Z1 = new V3(0, 0, 1), Y1 = new V3(0, 1, 0);
  const fins = [];
  fins.push({ grp: makeFin(dorsalShape, 0.05, Z1, Y1, paintGrey), s: 0.43, r: new V3(0, cOf(0.43) + hOf(0.43) * 0.95, zOf(0.43)) });
  for (const side of [1, -1]) {
    fins.push({ grp: makeFin(pecShape, 0.045, new V3(0, -0.08, 1), new V3(side, -0.5, -0.1), paintPec), s: 0.3, r: skinPoint(new V3(), 0.3, -0.42, side, 0.9), pec: side });
    fins.push({ grp: makeFin(smallFin(0.2, 0.12), 0.035, Z1, new V3(side * 0.6, -0.8, 0), paintLow), s: 0.66, r: skinPoint(new V3(), 0.66, -0.7, side, 0.9) });
  }
  fins.push({ grp: makeFin(smallFin(0.12, 0.06), 0.03, Z1, Y1, paintGrey), s: 0.86, r: new V3(0, cOf(0.86) + hOf(0.86) * 0.92, zOf(0.86)) });
  fins.push({ grp: makeFin(smallFin(0.11, 0.06), 0.03, Z1, new V3(0, -1, 0), paintLow), s: 0.88, r: new V3(0, cOf(0.88) - hOf(0.88) * 0.92, zOf(0.88)) });
  fins.push({ grp: makeFin(tailShape, 0.05, new V3(0, 0, -1), Y1, (cc, f) => cc.copy(C_FLANK).lerp(C_TOP, 0.35 + 0.5 * f)), s: 1, r: new V3(0, cOf(1), zOf(1) + 0.02), tail: true });

  // ─── Pose everything for this frame ───────────────────────────────────
  const tb = new V3(), tt = new V3(), tu = new V3(), to = new V3(), tg = new V3(), tmat = new THREE.Matrix4(), tqI = new THREE.Quaternion(), tsc = new V3();
  const bitePt = new V3(), snoutPt = new V3(), tailPt = new V3(), lipA = new V3(), lipB = new V3();
  const upRest = new V3(0, cOf(0.045) - hOf(0.045) * flatOf(0.045, -1), zOf(0.045));
  const loRest = new V3(0, cOf(0.08) - hOf(0.08) * flatOf(0.08, -1), zOf(0.08));
  function poseShark(jaw, A, ph, curv, eyeRoll, pecAoA, pecLift) {
    cur.jaw = jaw; cur.A = A; cur.ph = ph; cur.curv = curv;
    poseBody();
    for (let i = 0; i < allTeeth.length; i++) {
      const q = allTeeth[i];
      posePoint(tb, q.bx, q.by, q.bz, q.s, q.jaw, q.pr);
      posePoint(tt, q.tx, q.ty, q.tz, q.s, q.jaw, q.pr);
      tu.subVectors(tt, tb);
      const len = tu.length() || 1e-3;
      tu.divideScalar(len);
      to.set(q.ox, 0, q.oz).normalize();
      to.addScaledVector(tu, -to.dot(tu)).normalize();
      tg.crossVectors(tu, to);
      const w = 0.028 * q.size;
      tmat.set(tg.x * w, tu.x * len, to.x * w * 0.35, tb.x,
               tg.y * w, tu.y * len, to.y * w * 0.35, tb.y,
               tg.z * w, tu.z * len, to.z * w * 0.35, tb.z,
               0, 0, 0, 1);
      teethMesh.setMatrixAt(i, tmat);
    }
    teethMesh.instanceMatrix.needsUpdate = true;
    for (const e of eyes) {
      posePoint(e.black.position, e.r.x, e.r.y, e.r.z, 0.115, 0, 0);
      e.white.position.copy(e.black.position);
      e.white.visible = eyeRoll > 0.5;
      e.black.visible = !e.white.visible;
    }
    for (let i = 0; i < pores.length; i++) {
      const q = pores[i];
      posePoint(tb, q.x, q.y, q.z, q.s, 0, q.pr);
      poreMesh.setMatrixAt(i, tmat.compose(tb, tqI, tsc.set(q.sz, q.sz * q.fy, q.sz)));
    }
    poreMesh.instanceMatrix.needsUpdate = true;
    for (const g of gills) {
      posePoint(g.grp.position, g.r.x, g.r.y, g.r.z, g.s, 0, 0);
      g.grp.rotation.y = BO.b;
    }
    for (const f of fins) {
      posePoint(f.grp.position, f.r.x, f.r.y, f.r.z, f.s, 0, 0);
      if (f.tail) bend(1.14);
      if (f.pec) f.grp.rotation.set(-pecAoA, BO.b, f.pec * (pecLift + 0.02 * Math.sin(ph * 0.5 + f.pec)));
      else f.grp.rotation.set(0, BO.b, 0);
    }
    posePoint(lipA, upRest.x, upRest.y, upRest.z, 0.045, 0, 1);
    posePoint(lipB, loRest.x, loRest.y, loRest.z, 0.08, 1, 0);
    bitePt.addVectors(lipA, lipB).multiplyScalar(0.5);
    bitePt.y += 0.06; bitePt.z -= 0.1;
    posePoint(snoutPt, 0, cOf(0.02), zOf(0.02), 0.02, 0, 0);
    posePoint(tailPt, 0, cOf(0.95), zOf(0.95), 0.95, 0, 0);
  }

  // ─── Day and night palettes (blended smoothly) ────────────────────────
  const PAL = {
    day: {
      fog: '#1f6a8c', hemiSky: '#c4ecff', hemiGround: '#24546b', hemiI: 0.95, key: '#fff4de', keyI: 0.95, rim: '#8fe3ff', rimI: 0.4,
      sand: '#c7b68c', sandDark: '#9c8b66', caustic: '#eafcff', causticI: 0.35, ray: '#e2f8ff', rayI: 0.9,
      snow: '#e6fbff', snowI: 0.3, twinkle: 0, sky: '#f4fdff', refl: '#62b8d8', surfFog: '#2a7da3', sun: '#fffbe8',
      blood: '#8e0f17', bloodI: 0.6, foam: '#ffffff', bubble: '#ffffff', spark: 0, shadow: 0.35,
    },
    night: {
      fog: '#071c30', hemiSky: '#5a7bb2', hemiGround: '#050f1b', hemiI: 0.75, key: '#aac6ff', keyI: 0.5, rim: '#55d0ff', rimI: 0.55,
      sand: '#3a465e', sandDark: '#232b3e', caustic: '#9ecbff', causticI: 0.1, ray: '#7fa2e0', rayI: 0.35,
      snow: '#7ef9ff', snowI: 0.9, twinkle: 1, sky: '#8fa7d6', refl: '#163454', surfFog: '#0b2440', sun: '#dce8ff',
      blood: '#5e0810', bloodI: 0.75, foam: '#cfe2ff', bubble: '#cfe6ff', spark: 1, shadow: 0.15,
    },
  };
  for (const k of Object.keys(PAL)) for (const [n, v] of Object.entries(PAL[k])) if (typeof v === 'string') PAL[k][n] = new THREE.Color(v);
  const mixC = (out, n, m) => out.copy(PAL.day[n]).lerp(PAL.night[n], m);
  const mixN = (n, m) => lerp(PAL.day[n], PAL.night[n], m);
  const theme = { m: 0, target: 0, ready: false };
  function readTheme() {
    const cs = getComputedStyle(document.documentElement);
    theme.target = (parseFloat(cs.getPropertyValue('--mode')) || 0) > 0.5 ? 1 : 0;
    if (!theme.ready) { theme.m = theme.target; theme.ready = true; }
  }
  function applyTheme(t) {
    const m = theme.m;
    mixC(hemi.color, 'hemiSky', m); mixC(hemi.groundColor, 'hemiGround', m); hemi.intensity = mixN('hemiI', m);
    mixC(key.color, 'key', m); key.intensity = mixN('keyI', m);
    mixC(rim.color, 'rim', m); rim.intensity = mixN('rimI', m);
    mixC(scene.fog.color, 'fog', m);
    const u = sandMat.uniforms;
    mixC(u.uSand.value, 'sand', m); mixC(u.uSandDark.value, 'sandDark', m);
    mixC(u.uCaustic.value, 'caustic', m); u.uCausticStrength.value = mixN('causticI', m);
    const rayI = mixN('rayI', m);
    for (const r of rays) {
      mixC(r.m.material.color, 'ray', m);
      r.m.material.opacity = r.base * rayI * (0.6 + 0.4 * Math.sin(t * r.sp + r.ph));
      r.m.rotation.z = r.rot + Math.sin(t * 0.15 + r.ph) * 0.03 * MOTION;
    }
    mixC(snMat.uniforms.uColor.value, 'snow', m);
    snMat.uniforms.uOpacity.value = mixN('snowI', m);
    snMat.uniforms.uTwinkle.value = mixN('twinkle', m);
    mixC(bMat.uniforms.uColor.value, 'bubble', m);
    const su = surfMat.uniforms;
    mixC(su.uSky.value, 'sky', m); mixC(su.uRefl.value, 'refl', m); mixC(su.uFog.value, 'surfFog', m); mixC(su.uSun.value, 'sun', m);
    mixC(blood.mat.uniforms.uColor.value, 'blood', m);
    blood.mat.uniforms.uOpacity.value = mixN('bloodI', m);
    mixC(foam.mat.uniforms.uColor.value, 'foam', m);
    sparks.mat.uniforms.uOpacity.value = mixN('spark', m);
  }
  const darkQuery = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null;
  if (darkQuery) {
    if (darkQuery.addEventListener) darkQuery.addEventListener('change', readTheme);
    else if (darkQuery.addListener) darkQuery.addListener(readTheme);
  }
  new MutationObserver(readTheme).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class', 'style'] });

  // ─── Layout: keep the hunting ground framed on any screen ─────────────
  let patrolAmpX = 7, sealZoneX = 5;
  function layout() {
    const w = Math.max(1, stage.clientWidth), h = Math.max(1, stage.clientHeight);
    renderer.setSize(w, h, false);
    camera.aspect = w / h;
    camera.updateProjectionMatrix();
    const a = camera.aspect, camZ = Math.max(11, 4.6 / (tanHalf * a) - 3);
    camBase.set(0, -4.2, camZ);
    const halfAt = (z) => (camZ - z) * tanHalf * a;
    viewHalfW = halfAt(-2);
    patrolAmpX = clamp(halfAt(-4) - 2.6, 1.2, 7.5);
    sealZoneX = clamp(halfAt(-2) - 1.6, 0.8, 7);
    scene.fog.near = camZ * 0.35;
    scene.fog.far = camZ + 34;
    sandMat.uniforms.uFade.value.set(camZ + 16, camZ + 42);
    sandMat.uniforms.uDetail.value.set(camZ + 8, camZ + 26);
    surfMat.uniforms.uFade.value.set(camZ + 14, camZ + 55);
    surfMat.uniforms.uSunPos.value.set(-2.5, camZ - 6);
    const sc = renderer.domElement.height / (2 * tanHalf);
    bMat.uniforms.uScale.value = sc;
    snMat.uniforms.uScale.value = sc;
    for (const p of pools) p.mat.uniforms.uScale.value = sc;
  }
  if (window.ResizeObserver) new ResizeObserver(layout).observe(stage);
  else window.addEventListener('resize', layout);
  layout();

  // ─── The hunt ─────────────────────────────────────────────────────────
  // How the shark moves: its heading turns with its own angular velocity, so
  // turns ease in and out instead of snapping; speed changes through smoothed
  // acceleration; and its velocity trails the heading a little, giving a hint
  // of sideslip in hard turns. Tail-beat rate and size follow the thrust it
  // needs (fast and wide when accelerating, nearly still when gliding), the
  // body bows along the arc it is turning on, it banks into turns, and the
  // pectoral fins tilt to pitch it up or down and drop when it gets agitated.
  const S = {
    pos: new V3(-5, -5, -3), head: new V3(1, 0, 0.3).normalize(), up: new V3(0, 1, 0),
    vel: new V3(), angVel: new V3(), speed: 1.6, acc: 0,
    roll: 0, extraYaw: 0, spin: 0, shake: 0, mode: 'patrol', t: 0, wait: 0,
    phase: 0, freq: 0.66, A: 0.5, curv: 0, glide: 0, glideIn: rr(7, 12),
    jaw: 0, jawT: 0, jawRate: 6, eye: 0, eyeT: 0, armed: false, pecAoA: 0, pecLift: 0.05, yawW: 0, pitchW: 0, wpT: 0,
    seal: null, held: null, side: 1, target: new V3(), wp: new V3(), wpSide: -1,
    above: false, snoutAbove: false, bleedAcc: 0, sparkAcc: 0,
  };
  S.up.addScaledVector(S.head, -S.head.y).normalize();
  S.vel.copy(S.head).multiplyScalar(S.speed);
  const biteW = new V3(), snoutW = new V3(), tailW = new V3();
  const dDes = new V3(), tq = new V3(), tR = new V3(), tF = new V3(), tV = new V3(), probe = new V3(), avoidV = new V3(), wDes = new V3(), errAx = new V3(), camFwd = new V3();
  const WORLD_UP = new V3(0, 1, 0);
  const qT = new THREE.Quaternion(), qB = new THREE.Quaternion(), qL = new THREE.Quaternion(), mB = new THREE.Matrix4(), eL = new THREE.Euler(0, 0, 0, 'YZX');
  let slow = 0, shakeAmt = 0, flashAmt = 0;

  // ─── Steering helpers ─────────────────────────────────────────────────
  function rotateBy(w, dt) {
    const m = w.length();
    if (m < 1e-7) return;
    qT.setFromAxisAngle(tq.copy(w).divideScalar(m), m * dt);
    S.head.applyQuaternion(qT).normalize();
    S.up.applyQuaternion(qT);
  }
  // Fish turn by yawing flat and pitching only a little, so steering is split
  // into a yaw rate (about world up) and a capped pitch rate. Each rate eases
  // toward what the heading error asks for: a damped spring on the heading.
  function steer(dir, maxRate, gain, resp, dt, pitchMax) {
    let yw = 0, pw = 0;
    const L = dir.length();
    if (L > 1e-6) {
      const hz = Math.hypot(S.head.x, S.head.z), dzl = Math.hypot(dir.x, dir.z);
      if (dzl > 1e-4 && hz > 1e-4) {
        yw = clamp(wrapAngle(Math.atan2(dir.x, dir.z) - Math.atan2(S.head.x, S.head.z)) * gain, -maxRate, maxRate) * smoothstep(0.08, 0.35, hz);
      }
      const pT = clamp(Math.asin(clamp(dir.y / L, -1, 1)), -pitchMax, pitchMax);
      pw = clamp((pT - Math.asin(clamp(S.head.y, -1, 1))) * gain, -maxRate * 0.8, maxRate * 0.8);
    }
    const k = 1 - Math.exp(-resp * dt);
    S.yawW += (yw - S.yawW) * k;
    S.pitchW += (pw - S.pitchW) * k;
    tR.crossVectors(WORLD_UP, S.head);
    if (tR.lengthSq() < 1e-8) tR.crossVectors(S.up, S.head);
    tR.normalize();
    S.angVel.copy(WORLD_UP).multiplyScalar(S.yawW).addScaledVector(tR, -S.pitchW);
    rotateBy(S.angVel, dt);
  }
  // While airborne the nose simply follows the flight path.
  function faceTowards(dir, k, dt) {
    tF.copy(S.head).lerp(dir, 1 - Math.exp(-k * dt));
    if (tF.lengthSq() < 1e-8) return;
    tF.normalize();
    qT.setFromUnitVectors(S.head, tF);
    S.head.copy(tF);
    S.up.applyQuaternion(qT);
    S.angVel.multiplyScalar(Math.exp(-4 * dt));
    S.yawW *= Math.exp(-4 * dt); S.pitchW *= Math.exp(-4 * dt);
  }
  // Keep the dorsal side up without ever flipping, even when pointing straight up.
  function levelUp(dt, k) {
    S.up.addScaledVector(S.head, -S.up.dot(S.head));
    if (S.up.lengthSq() < 1e-6) S.up.set(0, 1, 0).addScaledVector(S.head, -S.head.y);
    if (S.up.lengthSq() < 1e-6) S.up.set(1, 0, 0);
    S.up.normalize();
    tR.copy(WORLD_UP).addScaledVector(S.head, -S.head.y);
    const l = tR.length();
    if (l > 0.08) {
      S.up.lerp(tR.divideScalar(l), (1 - Math.exp(-k * dt)) * smoothstep(0.08, 0.4, l));
      S.up.addScaledVector(S.head, -S.up.dot(S.head)).normalize();
    }
  }
  // Gentle pushes away from rocks, the seabed, the surface and the camera,
  // applied to a point a little ahead so it turns early instead of sliding along walls.
  function avoidance(out, allowSurface, allowNear) {
    out.set(0, 0, 0);
    probe.copy(S.pos).addScaledVector(S.head, 2.6);
    for (const r of rockList) {
      for (let k = 0; k < 2; k++) {
        const pt = k ? probe : S.pos, d = pt.distanceTo(r.p), lim = r.r + 1.4;
        if (d < lim) out.addScaledVector(tq.subVectors(pt, r.p).normalize(), ((lim - d) / lim) * 2.2);
      }
    }
    const vis = Math.max(3, (camBase.z - probe.z) * tanHalf * camera.aspect); // half-width of the view at that depth
    const xl = allowNear ? 12.5 : Math.min(12.5, vis - 1.2);
    if (Math.abs(probe.x) > xl) out.x -= Math.sign(probe.x) * (Math.abs(probe.x) - xl) * 0.6;
    const floor = sandAt(probe.x, probe.z) + 1.9;
    if (probe.y < floor) out.y += (floor - probe.y) * 1.1;
    if (!allowSurface && probe.y > -1.4) out.y -= (probe.y + 1.4) * 1.1;
    if (probe.z < -19) out.z += (-19 - probe.z) * 0.5;
    const nearZ = camBase.z - 6.5;
    if (!allowNear && probe.z > nearZ) out.z -= (probe.z - nearZ) * 0.6;
    return out;
  }
  function nextWaypoint() {
    S.wpSide = -S.wpSide;
    S.wp.set(S.wpSide * rr(0.3, 0.75) * patrolAmpX, rr(-6.2, -3.4), rr(-8, 0.5));
    S.wpT = 0;
  }
  nextWaypoint();

  // ─── Events ───────────────────────────────────────────────────────────
  const say = (msg) => { liveEl.textContent = msg; };
  function bleed(p, n, power) {
    for (let i = 0; i < n; i++) {
      blood.spawn(p.x + rr(-0.25, 0.25) * power, p.y + rr(-0.2, 0.2) * power, p.z + rr(-0.25, 0.25) * power,
        rr(-1, 1) * power, rr(-0.5, 0.8) * power, rr(-1, 1) * power,
        rr(7, 13), rr(0.25, 0.5) * (0.6 + 0.4 * power), rr(1.6, 3.2) * (0.6 + 0.4 * power), rr(0.3, 0.55), 0.9, 0.03);
    }
  }
  function bubbleBurst(p, n, spread, up) {
    for (let i = 0; i < n; i++) {
      spawnBubble(tq.set(p.x + rr(-spread, spread), p.y + rr(-spread, spread), p.z + rr(-spread, spread)),
        rr(0.04, 0.12), rr(-0.8, 0.8), rr(0, up), rr(-0.8, 0.8));
    }
  }
  const freeSeal = () => seals.find((s) => !s.held) || null;
  function startStalk(s) { S.mode = 'stalk'; S.seal = s; S.t = 0; S.glide = 0; S.side = S.pos.x < s.pos.x ? -1 : 1; }
  function toPatrol() {
    S.mode = 'patrol'; S.t = 0; S.jawRate = 4; S.eyeT = 0; S.seal = null; S.wait = 1.5;
    S.wpSide = S.pos.x > 0 ? 1 : -1;
    nextWaypoint();
  }
  function charge() {
    if (S.mode === 'breach' || S.mode === 'thrash') return;
    S.mode = 'charge'; S.t = 0; S.glide = 0;
    say('The shark is charging the camera.');
  }
  function lunge(p) {
    if (S.mode === 'breach' || S.mode === 'thrash' || S.mode === 'strike') return;
    S.mode = 'lunge'; S.t = 0; S.glide = 0;
    S.target.set(clamp(p.x, -15, 15), clamp(p.y, SAND_Y + 1.5, -1.2), clamp(p.z, -18, camBase.z - 6));
  }
  function addSeal(x, z) {
    if (seals.filter((s) => !s.held).length >= 3) { say('There are already three seals at the surface.'); return; }
    makeSeal(clamp(x, -sealZoneX, sealZoneX), clamp(z, -5.5, 2));
    say('A seal is resting at the surface.');
    if (S.mode === 'patrol') S.wait = Math.max(S.wait, 1.2);
  }
  function grab(s) {
    S.held = s; s.held = true; s.panic = 0;
    scene.remove(s.g); sharkRoot.add(s.g);
    S.mode = 'breach'; S.t = 0; S.above = false; S.snoutAbove = false;
    S.vel.copy(S.head).multiplyScalar(Math.max(S.speed, 8.5));
    S.speed = S.vel.length();
    S.jawT = 0.5; S.jawRate = 22;
    slow = 1.5; shakeAmt = Math.max(shakeAmt, 0.18); flashAmt = 0.7;
    bleed(biteW, 70, 1.3);
    say('The shark hit the seal from below.');
  }
  function consume() {
    const s = S.held;
    if (!s) return;
    if (s.g.parent) s.g.parent.remove(s.g);
    const i = seals.indexOf(s);
    if (i >= 0) seals.splice(i, 1);
    S.held = null;
    bleed(biteW, 140, 2.0);
    bubbleBurst(biteW, 30, 0.5, 1.5);
    flashAmt = Math.max(flashAmt, 0.4);
    say('The shark took the seal.');
  }

  // ─── Per-frame update ─────────────────────────────────────────────────
  function updateShark(dt, t) {
    S.t += dt;
    let want = 1.6, maxRate = 0.42, gain = 0.9, resp = 2.6, accel = 0.8, decel = 0.9, velK = 4, pitchMax = 0.45;
    let allowSurface = false, allowNear = false, agitated = false;
    dDes.set(0, 0, 0);
    switch (S.mode) {
      case 'patrol': {
        S.wpT += dt;
        tq.subVectors(S.wp, S.pos);
        if (tq.length() < 3.2 || (tq.dot(S.head) < 0 && Math.hypot(tq.x, tq.z) < 6) || S.wpT > 16) nextWaypoint();
        dDes.subVectors(S.wp, S.pos);
        const h = Math.hypot(dDes.x, dDes.z);
        dDes.y = clamp(dDes.y, -0.3 * h, 0.3 * h);
        // swim, then coast: great whites glide on a slight descent between bouts of tail beats
        S.glideIn -= dt;
        if (S.glideIn <= 0 && S.glide <= 0) { S.glide = rr(2.5, 4.5); S.glideIn = rr(9, 16); }
        if (S.glide > 0) { S.glide -= dt; want = 1.15; dDes.y -= 0.12 * h; }
        S.jawT = 0.1 + 0.06 * Math.sin(S.t * 0.5);
        S.wait -= dt;
        const s = freeSeal();
        if (s && S.wait <= 0) startStalk(s);
        break;
      }
      case 'stalk': {
        const s = S.seal;
        if (!s || s.held || seals.indexOf(s) < 0) { toPatrol(); break; }
        S.target.set(clamp(s.pos.x + S.side * 3.4, -12, 12), SAND_Y + 3.4, s.pos.z - 3.2);
        dDes.subVectors(S.target, S.pos);
        want = 2.4; maxRate = 0.6; gain = 1.1; resp = 3.2; accel = 1.2; decel = 1; pitchMax = 0.7;
        S.jawT = 0.05;
        if (S.pos.distanceTo(S.target) < 2.4 || S.t > 7) { S.mode = 'strike'; S.t = 0; }
        break;
      }
      case 'strike': {
        const s = S.seal;
        if (!s || s.held || seals.indexOf(s) < 0) { toPatrol(); break; }
        S.target.copy(s.pos); S.target.y -= 0.1;
        dDes.subVectors(S.target, snoutW);
        want = 9.5; maxRate = 1.5; gain = 3.2; resp = 7; accel = 7.5; decel = 4; velK = 8; pitchMax = 1.45;
        allowSurface = true; agitated = true;
        const d = snoutW.distanceTo(S.target);
        S.jawT = d < 3.6 ? 1 : 0.1; S.jawRate = 8; S.eyeT = d < 4.5 ? 1 : 0;
        if (d < 7) s.panic = 1;
        if (biteW.distanceTo(s.pos) < 1.1 || d < 0.7) { grab(s); break; }
        if (S.t > 5) toPatrol();
        break;
      }
      case 'breach': agitated = true; break;
      case 'thrash': {
        dDes.set(S.head.x, -0.08, S.head.z);
        want = 0.8; maxRate = 0.5; gain = 1; resp = 3; accel = 1.5; decel = 5; velK = 3; pitchMax = 0.6;
        agitated = true;
        if (S.t > 1.8 && S.held) consume();
        if (S.t > 2.8) toPatrol();
        break;
      }
      case 'charge': {
        // aim at a point on the camera's line of sight that slides in as it closes,
        // so it swings onto the lens axis first and the last rush is head-on
        camera.getWorldDirection(camFwd);
        const d = snoutW.distanceTo(camera.position);
        S.target.copy(camera.position).addScaledVector(camFwd, clamp(d * 0.55, 1.6, 9));
        S.target.y -= 0.35;
        dDes.subVectors(S.target, snoutW);
        const align = S.head.dot(tq.copy(dDes).normalize());
        want = lerp(2.6, 7.5, smoothstep(0.55, 0.92, align)); maxRate = 1.4; gain = 2.4; resp = 5.5; accel = 5.5; decel = 3; velK = 6; pitchMax = 0.9;
        allowNear = true; agitated = true;
        S.jawT = d < 6.5 && align > 0.8 ? 1 : 0; S.jawRate = 7; S.eyeT = d < 7.5 ? 1 : 0;
        if (d < 2.4 + 0.1 * S.speed) {
          S.jawT = 0; S.jawRate = 30; shakeAmt = 0.5; flashAmt = 0.55;
          bubbleBurst(snoutW, 26, 0.4, 1);
          eatFishNear(biteW, 1.2);
          S.side = S.pos.x >= camera.position.x ? 1 : -1;
          S.mode = 'veer'; S.t = 0;
        } else if (S.t > 7) toPatrol();
        break;
      }
      case 'veer': {
        S.target.set(camera.position.x + S.side * 8, camera.position.y - 2.5, camera.position.z - 7);
        dDes.subVectors(S.target, S.pos);
        want = 4.5; maxRate = 1.9; gain = 3; resp = 7; accel = 3; decel = 7; velK = 6; pitchMax = 0.8;
        allowNear = true; agitated = S.t < 0.6;
        if (S.t > 0.35) S.eyeT = 0;
        if (S.t > 2) toPatrol();
        break;
      }
      case 'lunge': {
        dDes.subVectors(S.target, snoutW);
        want = 7; maxRate = 1.6; gain = 3; resp = 6.5; accel = 6.5; decel = 4; velK = 7; pitchMax = 1.0;
        agitated = true;
        const d = snoutW.distanceTo(S.target);
        S.jawT = d < 2.6 ? 1 : 0; S.jawRate = 9; S.eyeT = d < 3 ? 1 : 0;
        if (d < 0.8 || S.t > 2.6) { S.jawT = 0; S.jawRate = 26; S.mode = 'recover'; S.t = 0; }
        break;
      }
      case 'recover': {
        dDes.set(S.head.x, 0, S.head.z);
        want = 2.4; maxRate = 0.6; gain = 1; resp = 3; accel = 1; decel = 2.5; pitchMax = 0.5;
        if (S.t > 1.2) toPatrol();
        break;
      }
      default: break;
    }

    if (S.mode === 'breach') {
      // ballistic once enough of the body is out of the water
      if (S.pos.y > -1.4) S.vel.y -= 9.8 * dt;
      else S.vel.multiplyScalar(Math.exp((S.vel.y < 0 ? -2.4 : -0.5) * dt));
      S.pos.addScaledVector(S.vel, dt);
      S.speed = S.vel.length(); S.acc = 0;
      if (S.speed > 0.1) faceTowards(tV.copy(S.vel).divideScalar(S.speed), 6, dt);
      levelUp(dt, 1.5);
      if (S.pos.y > -0.5) S.spin = Math.min(S.spin + dt * 2.4, 1.6);
      if (snoutW.y > 0 && !S.snoutAbove) { S.snoutAbove = true; surfaceSplash(snoutW.x, snoutW.z, 1.1); }
      const above = S.pos.y > 0;
      if (S.above && !above && S.vel.y < 0) { surfaceSplash(S.pos.x, S.pos.z, 1.7); shakeAmt = Math.max(shakeAmt, 0.14); }
      S.above = above;
      if ((S.vel.y < 0 && S.pos.y < -2) || (S.t > 1.2 && S.pos.y < -1.4 && !S.snoutAbove) || S.t > 6) { S.mode = 'thrash'; S.t = 0; }
    } else {
      dDes.normalize().add(avoidance(avoidV, allowSurface, allowNear));
      steer(dDes, maxRate, gain, resp, dt, pitchMax);
      levelUp(dt, 1.2);
      const aT = clamp((want - S.speed) * 1.8, -decel, accel);
      S.acc = damp(S.acc, aT, 5, dt);
      S.speed = Math.max(0.2, S.speed + S.acc * dt);
      const surge = 1 + 0.03 * S.A * Math.sin(2 * S.phase); // each tail stroke gives a small push
      S.vel.lerp(tV.copy(S.head).multiplyScalar(S.speed * surge), 1 - Math.exp(-velK * dt));
      const zLim = camBase.z - 3.3; // soft stop in front of the lens instead of a hard wall
      if (S.pos.z > zLim) S.vel.z -= ((S.pos.z - zLim) * 25 + Math.max(S.vel.z, 0) * 5) * dt;
      S.pos.addScaledVector(S.vel, dt);
      // hard limits only as a last resort; the steering above keeps it well inside
      S.pos.x = clamp(S.pos.x, -16, 16);
      S.pos.y = clamp(S.pos.y, sandAt(S.pos.x, S.pos.z) + 1.1, -0.2);
      S.pos.z = clamp(S.pos.z, -22, camBase.z - 2.4);
      S.spin = damp(S.spin, 0, 2.5, dt);
    }

    // tail beat follows the thrust it needs
    let fT, aT;
    if (S.mode === 'thrash') { fT = 2.0; aT = 1.0; }
    else if (S.mode === 'breach') { fT = S.pos.y > 0 ? 1.6 : 2.6; aT = 1.05; }
    else {
      const push = Math.max(0, S.acc);
      fT = Math.min(0.2 + 0.3 * S.speed + 0.22 * push, 3.2);
      aT = clamp(0.4 + 0.05 * S.speed + 0.1 * push, 0.4, 1.05);
      if (S.glide > 0 || S.acc < -0.3) { fT *= 0.55; aT *= 0.35; }
    }
    S.freq = damp(S.freq, fT, 2.2, dt);
    S.A = damp(S.A, aT, 2, dt);
    S.phase += TAU * S.freq * dt;

    // body curve, banking and pectoral fins come from how it is turning
    const right = tR.crossVectors(S.up, S.head).normalize();
    const yawRate = S.angVel.dot(S.up), pitchUp = -S.angVel.dot(right);
    S.curv = damp(S.curv, S.mode === 'breach' ? 0 : clamp(0.75 * yawRate / Math.max(S.speed, 1.2), -0.17, 0.17), 4, dt);
    S.roll = damp(S.roll, clamp(-Math.atan(S.speed * yawRate / 7), -0.55, 0.55), 2.5, dt);
    S.pecAoA = damp(S.pecAoA, clamp(pitchUp * 0.9 + S.head.y * 0.25, -0.35, 0.35), 3, dt);
    S.pecLift = damp(S.pecLift, agitated ? -0.32 : S.glide > 0 ? 0.12 : 0.05, 3, dt);

    // head-shaking with the seal clamped in its jaws, in time with the body wave
    if (S.mode === 'thrash') {
      S.shake = smoothstep(0, 0.25, S.t) * (1 - smoothstep(2.0, 2.7, S.t));
      S.bleedAcc += dt * 120 * S.shake;
    } else S.shake = damp(S.shake, 0, 5, dt);
    S.extraYaw = 0.45 * S.shake * Math.sin(S.phase + 1.2);
    const thrashRoll = 0.28 * S.shake * Math.sin(S.phase + 2.4);
    while (S.bleedAcc >= 1) { S.bleedAcc -= 1; bleed(biteW, 1, 0.95); }

    if (S.jaw > 0.3 && S.mode !== 'thrash' && S.mode !== 'breach') {
      const n = eatFishNear(biteW, 0.7);
      if (n) bleed(biteW, 12 * n, 0.5);
    }
    S.jaw += (S.jawT - S.jaw) * (1 - Math.exp(-S.jawRate * dt));
    if (S.jaw > 0.6) S.armed = true;
    if (S.armed && S.jaw < 0.25) { S.armed = false; bubbleBurst(biteW, 10, 0.25, 0.6); }
    S.eye = damp(S.eye, S.eyeT, 10, dt);

    // orientation from the heading/up frame, plus small local yaw and roll
    S.up.crossVectors(S.head, right).normalize();
    mB.makeBasis(right, S.up, S.head);
    qB.setFromRotationMatrix(mB);
    const recoil = -0.035 * S.A * Math.sin(S.phase + 0.6); // head yaws slightly against each tail stroke
    eL.set(0, S.extraYaw + recoil, S.roll + thrashRoll + S.spin * S.side, 'YZX');
    qL.setFromEuler(eL);
    sharkRoot.quaternion.copy(qB).multiply(qL);
    sharkRoot.position.copy(S.pos);
    poseShark(S.jaw, S.A, S.phase, S.curv, S.eye, S.pecAoA, S.pecLift);
    sharkRoot.updateMatrixWorld(true);
    sharkRoot.localToWorld(biteW.copy(bitePt));
    sharkRoot.localToWorld(snoutW.copy(snoutPt));
    sharkRoot.localToWorld(tailW.copy(tailPt));
    if (S.held) {
      const g = S.held.g;
      g.position.copy(bitePt);
      g.rotation.set(0, Math.PI / 2 + Math.sin(t * 13) * 0.15, Math.sin(t * 9) * 0.2);
    }

    // bioluminescent wake at night
    if (theme.m > 0.05 && S.speed > 1.8) {
      S.sparkAcc += dt * S.speed * 9;
      while (S.sparkAcc >= 1) {
        S.sparkAcc -= 1;
        sparks.spawn(tailW.x + rr(-0.3, 0.3), tailW.y + rr(-0.3, 0.3), tailW.z + rr(-0.3, 0.3), rr(-0.3, 0.3), rr(-0.2, 0.2), rr(-0.3, 0.3),
          rr(1.2, 2.4), rr(0.05, 0.1), rr(0.01, 0.03), 1, 1.5, 0);
      }
    }
    sandMat.uniforms.uShadow.value.set(S.pos.x, S.pos.z, mixN('shadow', theme.m) * clamp(1 - (S.pos.y - SAND_Y) / 14, 0, 1), 2.4);
  }

  // ─── Input ────────────────────────────────────────────────────────────
  const raycaster = new THREE.Raycaster(), ndc = new THREE.Vector2(), tRay = new V3();
  const pointer = { x: 0, y: 0, inside: false, sx: 0, sy: 0 };
  function castFrom(e) {
    const r = canvas.getBoundingClientRect();
    ndc.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1);
    raycaster.setFromCamera(ndc, camera);
    return raycaster.ray;
  }
  const overShark = (ray) => ray.distanceSqToSegment(snoutW, tailW) < 0.85 * 0.85;
  const coarse = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
  hintEl.textContent = coarse
    ? 'Tap the surface to put a seal there, or tap the shark to make it charge.'
    : 'Click the surface to put a seal there, or click the shark to make it charge.';
  let hintGone = false;
  const dismissHint = () => { if (!hintGone) { hintGone = true; hintEl.classList.add('gone'); } };
  let down = null;
  canvas.addEventListener('pointermove', (e) => {
    const r = canvas.getBoundingClientRect();
    pointer.x = ((e.clientX - r.left) / r.width) * 2 - 1;
    pointer.y = -((e.clientY - r.top) / r.height) * 2 + 1;
    pointer.inside = true;
    if (e.pointerType === 'mouse') canvas.style.cursor = overShark(castFrom(e)) ? 'pointer' : 'default';
  });
  canvas.addEventListener('pointerleave', () => { pointer.inside = false; });
  canvas.addEventListener('pointerdown', (e) => { down = { x: e.clientX, y: e.clientY, t: performance.now() }; });
  canvas.addEventListener('pointercancel', () => { down = null; });
  canvas.addEventListener('pointerup', (e) => {
    if (!down) return;
    const moved = Math.hypot(e.clientX - down.x, e.clientY - down.y), held = performance.now() - down.t;
    down = null;
    if (moved > 12 || held > 700) return;
    dismissHint();
    const ray = castFrom(e);
    if (overShark(ray)) { charge(); return; }
    if (ray.direction.y > 0.02) {
      const k = -ray.origin.y / ray.direction.y;
      if (k < 80) { addSeal(ray.origin.x + ray.direction.x * k, ray.origin.z + ray.direction.z * k); return; }
    }
    tRay.copy(ray.direction).multiplyScalar(Math.max(4, camera.position.distanceTo(S.pos))).add(ray.origin);
    lunge(tRay);
  });
  const randomSeal = () => addSeal(rr(-sealZoneX, sealZoneX), rr(-4.5, 1.5));
  sealBtn.addEventListener('click', () => { dismissHint(); randomSeal(); });
  chargeBtn.addEventListener('click', () => { dismissHint(); charge(); });
  window.addEventListener('keydown', (e) => {
    const tag = e.target && e.target.tagName;
    if (tag === 'BUTTON' || tag === 'INPUT' || tag === 'TEXTAREA' || e.metaKey || e.ctrlKey || e.altKey) return;
    const k = e.key.toLowerCase();
    if (k === 's') { dismissHint(); randomSeal(); }
    else if (k === 'c') { dismissHint(); charge(); }
  });

  // ─── Frame update ─────────────────────────────────────────────────────
  let time = 0, simT = 0;
  function update(dt, real) {
    time += real;
    simT += dt;
    theme.m = damp(theme.m, theme.target, 4, real);
    applyTheme(simT);

    const par = REDUCED ? 0 : 1;
    pointer.sx = damp(pointer.sx, pointer.inside ? pointer.x : 0, 1.6, real);
    pointer.sy = damp(pointer.sy, pointer.inside ? pointer.y : 0, 1.6, real);
    shakeAmt *= Math.exp(-4.5 * real);
    flashAmt *= Math.exp(-3.2 * real);
    const sh = REDUCED ? 0 : shakeAmt;
    camera.position.set(
      camBase.x + pointer.sx * 0.8 * par + Math.sin(time * 61) * sh * 0.4,
      camBase.y + pointer.sy * 0.4 * par + Math.sin(time * 47 + 1) * sh * 0.3,
      camBase.z);
    camera.lookAt(camLook.x + pointer.sx * 0.4 * par + Math.sin(time * 53) * sh * 0.5, camLook.y + Math.cos(time * 41) * sh * 0.4, camLook.z);
    camera.updateMatrixWorld();
    flashEl.style.opacity = flashAmt.toFixed(3);

    updateShark(dt, simT);
    updateSeals(dt, simT);
    updateSchool(dt, simT);
    updateKelp(simT);
    updateSnow(dt, simT);
    updateVents(dt);
    updateBubbles(dt);
    for (const p of pools) p.update(dt);
    for (const r of ripples) if (r.w > 0) { r.z += dt; if (r.z > 8) r.w = 0; }
    surfMat.uniforms.uTime.value = simT * MOTION;
    sandMat.uniforms.uTime.value = simT * MOTION;
    snMat.uniforms.uTime.value = simT;
  }

  rockMat.color.setRGB(0.62, 0.66, 0.68);
  readTheme();
  applyTheme(0);
  const clock = new THREE.Clock();
  renderer.setAnimationLoop(() => {
    const real = Math.min(clock.getDelta(), 1 / 20);
    if (slow > 0) slow -= real;
    const ts = lerp(1, 0.28, smoothstep(0, 0.45, slow));
    update(real * ts, real);
    renderer.render(scene, camera);
  });
})();
</script>
</body>
</html>
MikeHiggins.me.uk LinkedIn GitHub Bluesky Contra Upwork
© 2026 Mike Higgins. All rights reserved.