← Mike Higgins Home
Custom Code Experiment •

Pip

Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Pip the Pufferfish</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=Fredoka:wght@400;500;600&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: #aef0ef;
    --water-mid: #53bdd3;
    --water-deep: #176fa0;
    --fog: #55b8cf;
    --sunlight: rgba(255, 252, 226, 0.9);
    --vignette: rgba(6, 52, 82, 0.26);
    --ink: #073a4f;
    --ink-soft: rgba(7, 58, 79, 0.74);
    --glass: rgba(255, 255, 255, 0.46);
    --glass-strong: rgba(255, 255, 255, 0.74);
    --glass-edge: rgba(255, 255, 255, 0.82);
    --accent: #ef8a25;
    --accent-ink: #ffffff;
  }
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
      color-scheme: dark;
      --mode: 1;
      --water-top: #143f69;
      --water-mid: #0b2748;
      --water-deep: #030916;
      --fog: #0c2848;
      --sunlight: rgba(150, 200, 255, 0.3);
      --vignette: rgba(0, 4, 12, 0.6);
      --ink: #e3f6ff;
      --ink-soft: rgba(227, 246, 255, 0.72);
      --glass: rgba(8, 30, 56, 0.5);
      --glass-strong: rgba(14, 44, 80, 0.82);
      --glass-edge: rgba(130, 200, 255, 0.26);
      --accent: #ffbe45;
      --accent-ink: #1c1203;
    }
  }
  :root[data-theme="dark"] {
    color-scheme: dark;
    --mode: 1;
    --water-top: #143f69;
    --water-mid: #0b2748;
    --water-deep: #030916;
    --fog: #0c2848;
    --sunlight: rgba(150, 200, 255, 0.3);
    --vignette: rgba(0, 4, 12, 0.6);
    --ink: #e3f6ff;
    --ink-soft: rgba(227, 246, 255, 0.72);
    --glass: rgba(8, 30, 56, 0.5);
    --glass-strong: rgba(14, 44, 80, 0.82);
    --glass-edge: rgba(130, 200, 255, 0.26);
    --accent: #ffbe45;
    --accent-ink: #1c1203;
  }
  html { height: 100%; scroll-padding-top: env(safe-area-inset-top, 0px); }
  body {
    height: 100%;
    margin: 0;
    overflow: hidden;
    background: linear-gradient(180deg, var(--water-top), var(--water-deep));
    color: var(--ink);
    font-family: "Fredoka", ui-rounded, "SF Pro Rounded", "Hiragino Maru Gothic ProN", "Arial Rounded MT Bold", system-ui, sans-serif;
    -webkit-font-smoothing: antialiased;
    -webkit-tap-highlight-color: transparent;
  }
  #stage {
    position: relative;
    width: 100%;
    height: 100%;
    overflow: hidden;
    background:
      radial-gradient(110% 60% at 28% -12%, var(--sunlight), transparent 62%),
      linear-gradient(180deg, var(--water-top) 0%, var(--water-mid) 44%, var(--water-deep) 100%);
  }
  #stage canvas {
    position: absolute;
    top: 0; left: 0;
    width: 100%; height: 100%;
    display: block;
    touch-action: none;
    outline: none;
  }
  .vignette {
    position: absolute;
    top: 0; left: 0; right: 0; bottom: 0;
    pointer-events: none;
    background: radial-gradient(130% 95% at 50% 38%, transparent 55%, var(--vignette) 100%);
  }
  .title {
    position: absolute;
    top: clamp(14px, 3vw, 28px);
    left: clamp(16px, 3.4vw, 34px);
    pointer-events: none;
    user-select: none;
    -webkit-user-select: none;
  }
  .title h1 {
    margin: 0;
    font-weight: 600;
    font-size: clamp(30px, 5vw, 52px);
    line-height: 1;
  }
  .title p {
    margin: 6px 0 0;
    font-size: clamp(14px, 1.6vw, 17px);
    color: var(--ink-soft);
  }
  .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: 18px;
    font-size: 15px;
    line-height: 1.3;
    max-width: 34em;
    background: var(--glass);
    border: 1px solid var(--glass-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: 500;
    color: var(--ink);
    background: var(--glass-strong);
    border: 1px solid var(--glass-edge);
    padding: 10px 18px;
    border-radius: 999px;
    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; }
  .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: 520px) {
    .hint { font-size: 14px; }
    .btn { padding: 9px 15px; 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>
  <header class="title">
    <h1>Pip</h1>
    <p>A very round little fish</p>
  </header>
  <div class="bar">
    <p class="hint" id="hint">Tap Pip to puff up, or tap the water to drop food.</p>
    <div class="actions">
      <button class="btn" id="feedBtn" type="button">Drop food</button>
      <button class="btn primary" id="puffBtn" type="button">Puff up</button>
    </div>
  </div>
</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 puffBtn = document.getElementById('puffBtn');
  const feedBtn = document.getElementById('feedBtn');

  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 MOTION = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 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', 'Pip, a cartoon pufferfish, swimming above a sandy seabed');
  stage.insertBefore(canvas, stage.firstChild);

  const scene = new THREE.Scene();
  scene.fog = new THREE.Fog(0x55b8cf, 9, 34);
  const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 120);
  const tanHalf = Math.tan(THREE.MathUtils.degToRad(camera.fov / 2));
  const camLook = new V3(0, -0.15, 0);
  let camDist = 7.3, viewHalfW = 4, viewTop = 2.5;

  const hemi = new THREE.HemisphereLight(0xc8f5ff, 0xe6cc98, 0.72);
  const key = new THREE.DirectionalLight(0xfff0cf, 0.85);
  key.position.set(-3, 8, 6); // sun from the upper left, matching the light rays
  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 = -2.3;
  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,
  });

  function paintSkin() {
    const W = 1024, H = 512;
    const cMap = makeCanvas(W, H), cGlow = makeCanvas(W, H);
    const g = cMap.getContext('2d'), gg = cGlow.getContext('2d');

    // golden back fading to a creamy belly
    const grad = g.createLinearGradient(0, 0, 0, H);
    [[0, '#c8892a'], [0.2, '#e0a53c'], [0.4, '#f4cb5a'], [0.52, '#f8dd90'], [0.62, '#fff1d4'], [1, '#fffaf0']]
      .forEach(([o, c]) => grad.addColorStop(o, c));
    g.fillStyle = grad;
    g.fillRect(0, 0, W, H);
    gg.fillStyle = '#000';
    gg.fillRect(0, 0, W, H);

    // fine freckles on the back
    for (let i = 0; i < 2400; i++) {
      const u = rand(), v = Math.pow(rand(), 1.25) * 0.6;
      const s = Math.max(Math.sin(v * Math.PI), 0.12), r = rr(0.8, 2.2);
      g.fillStyle = `rgba(150,95,32,${rr(0.05, 0.14).toFixed(3)})`;
      g.beginPath(); g.ellipse(u * W, v * H, r / s, r, 0, 0, TAU); g.fill();
    }

    // round spots (kept off the face); also painted into the glow map for night mode
    const spots = [];
    for (let tries = 0; spots.length < 80 && tries < 6000; tries++) {
      const u = rand(), v = rr(0.05, 0.5);
      const du = Math.min(Math.abs(u - 0.25), 1 - Math.abs(u - 0.25));
      if (du < 0.14 && v > 0.27) continue;
      const a = rr(0.032, 0.07), d = uvToDir(u, v);
      if (spots.some((s) => d.angleTo(s.d) < a + s.a + 0.05)) continue;
      spots.push({ u, v, a, d });
    }
    for (const s of spots) {
      const ry = (s.a * H) / Math.PI;
      const rx = ry / Math.max(Math.sin(s.v * Math.PI), 0.12); // undo equirect squash
      for (const off of [-W, 0, W]) {
        const x = s.u * W + off, y = s.v * H;
        if (x + rx < 0 || x - rx > W) continue;
        g.fillStyle = 'rgba(110,62,22,0.88)';
        g.beginPath(); g.ellipse(x, y, rx, ry, 0, 0, TAU); g.fill();
        gg.fillStyle = '#ffffff';
        gg.beginPath(); gg.ellipse(x, y, rx * 0.85, ry * 0.85, 0, 0, TAU); gg.fill();
      }
    }

    // rosy cheeks
    for (const sx of [-1, 1]) {
      const { u, v } = dirToUV(new V3(0.6 * sx, -0.12, 0.79).normalize());
      const ry = (0.2 * H) / Math.PI;
      g.save();
      g.translate(u * W, v * H);
      g.scale(1 / Math.sin(v * Math.PI), 1);
      const rg = g.createRadialGradient(0, 0, 0, 0, 0, ry);
      rg.addColorStop(0, 'rgba(255,118,140,0.62)');
      rg.addColorStop(0.6, 'rgba(255,128,150,0.26)');
      rg.addColorStop(1, 'rgba(255,140,160,0)');
      g.fillStyle = rg;
      g.beginPath(); g.arc(0, 0, ry, 0, TAU); g.fill();
      g.restore();
    }

    const map = new THREE.CanvasTexture(cMap);
    const glow = new THREE.CanvasTexture(cGlow);
    map.anisotropy = glow.anisotropy = maxAniso;
    return { map, glow };
  }

  // Fan-shaped fin membrane with ribs and a scalloped edge (alpha in the texture).
  function paintFin() {
    const N = 256;
    const c = makeCanvas(N, N), g = c.getContext('2d');
    const img = g.createImageData(N, N), d = img.data;
    for (let y = 0; y < N; y++) {
      for (let x = 0; x < N; x++) {
        const px = ((x + 0.5) / N) * 2 - 1, py = 1 - ((y + 0.5) / N) * 2;
        const r = Math.hypot(px, py), ang = Math.atan2(py, px);
        const ribC = Math.abs(Math.cos(ang * 9));
        const rib = Math.pow(ribC, 10);
        const edge = 0.84 + 0.12 * Math.pow(ribC, 3);
        let a = 1 - smoothstep(edge - 0.05, edge, r);
        a *= 0.5 + 0.35 * (1 - r) + 0.35 * rib;
        const shade = 1 - 0.32 * rib * smoothstep(0.1, 0.5, r);
        const i = (y * N + x) * 4;
        d[i] = d[i + 1] = d[i + 2] = Math.round(255 * shade);
        d[i + 3] = Math.round(255 * clamp(a, 0, 1));
      }
    }
    g.putImageData(img, 0, 0);
    const t = new THREE.CanvasTexture(c);
    t.anisotropy = maxAniso;
    return t;
  }

  // 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 });
  [
    [-4.6, -2.8, 1.1, 1.4, 0.75, 1.1], [-3.3, -3.7, 0.55, 1.2, 0.8, 1], [4.4, -3.0, 0.9, 1.3, 0.8, 1],
    [5.5, -2.1, 0.45, 1.2, 0.75, 1.1], [-1.4, -7.6, 1.4, 1.5, 0.7, 1.2], [2.8, -8.6, 1.0, 1.3, 0.8, 1],
    [-7.0, -6.2, 1.2, 1.3, 0.8, 1.1], [7.6, -6.6, 1.3, 1.2, 0.85, 1],
    [-2.6, 0.8, 0.22, 1.3, 0.7, 1], [2.3, 0.4, 0.28, 1.2, 0.7, 1.1], [3.1, 1.2, 0.16, 1.2, 0.8, 1],
    [-1.4, 3.4, 0.3, 1.3, 0.7, 1], [1.2, 4.6, 0.36, 1.2, 0.7, 1.2], [-3.2, 5.3, 0.42, 1.3, 0.75, 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);
  });

  // ─── 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'];
  [
    [-4.1, -2.1, 3.4, 0.32, GREEN], [-4.6, -2.0, 2.6, 0.26, LIME], [-3.7, -2.5, 4.2, 0.3, KELP],
    [3.9, -2.4, 3.0, 0.3, LIME], [4.5, -2.2, 3.9, 0.34, GREEN], [-1.9, -6.8, 4.6, 0.36, KELP],
    [-0.9, -6.9, 3.6, 0.3, GREEN], [3.4, -7.8, 4.2, 0.34, LIME], [-6.4, -5.4, 4.0, 0.34, GREEN],
    [7.1, -5.8, 4.4, 0.36, KELP], [-3.0, 1.6, 2.0, 0.22, LIME], [3.0, 2.6, 1.7, 0.2, GREEN],
  ].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(0.9, 2.4), 17), mat);
    m.position.set(-8 + i * 2.6 + rr(-0.8, 0.8), 3.6, rr(-9, -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 = 360;
  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(-16, 16); snPos[i * 3 + 1] = rr(SAND_Y, 8); snPos[i * 3 + 2] = rr(-16, 7);
    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 += 10.3;
      snPos[i * 3 + 1] = y;
    }
    snGeo.attributes.position.needsUpdate = true;
  }

  // ─── Bubbles (one Points cloud, drawn procedurally in the shader) ─────
  const BMAX = 220;
  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 = 7.5;
    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 - 2, 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: -3.9, z: -2.0, t: 1.0, burst: 0 },
    { x: 4.1, z: -2.6, 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);
      }
    }
  }

  // ─── A distant school of tiny fish ────────────────────────────────────
  const schoolMat = new THREE.MeshStandardMaterial({ color: '#3f86b3', roughness: 0.45, metalness: 0.15 });
  const schoolTailMat = new THREE.MeshStandardMaterial({ color: '#3f86b3', roughness: 0.5, side: THREE.DoubleSide });
  const smallBody = new THREE.SphereGeometry(1, 14, 10);
  const smallTail = new THREE.BufferGeometry();
  smallTail.setAttribute('position', new THREE.BufferAttribute(new Float32Array([0, 0, 0, 0, 0.13, -0.2, 0, -0.13, -0.2]), 3));
  smallTail.computeVertexNormals();
  const school = [];
  for (let i = 0; i < 9; i++) {
    const g = new THREE.Group();
    const b = new THREE.Mesh(smallBody, schoolMat);
    b.scale.set(0.085, 0.075, 0.26);
    g.add(b);
    const tail = new THREE.Group();
    tail.position.z = -0.22;
    tail.add(new THREE.Mesh(smallTail, schoolTailMat));
    g.add(tail);
    scene.add(g);
    school.push({ g, tail, off: new V3(rr(-0.7, 0.7), rr(-0.45, 0.45), rr(-0.6, 0.6)), lag: i * 0.03 + rr(0, 0.02), ph: rand() * TAU });
  }
  const tSchool = new V3();
  function updateSchool(t) {
    for (const f of school) {
      const a = t * 0.11 * MOTION - f.lag;
      const x = 12.5 * Math.cos(a) + f.off.x;
      const z = -12 + 3.5 * Math.sin(a) + f.off.z;
      const y = 1.7 + 0.5 * Math.sin(a * 1.7) + f.off.y + Math.sin(t * 2 + f.ph) * 0.05;
      f.g.position.set(x, y, z);
      f.g.lookAt(tSchool.set(x - 12.5 * Math.sin(a), y + 0.085 * Math.cos(a * 1.7), z + 3.5 * Math.cos(a)));
      f.tail.rotation.y = Math.sin(t * 11 + f.ph) * 0.45;
    }
  }

  // ─── Pip ──────────────────────────────────────────────────────────────
  // fish: world position + heading.  bob: idle wobble.  Features are pinned
  // to the body surface and re-placed every frame so they ride the puff.
  const fish = new THREE.Group();
  fish.rotation.order = 'YXZ';
  const bob = new THREE.Group();
  fish.add(bob);
  scene.add(fish);

  const BASE_S = new V3(1, 0.93, 1.08);
  const PUFF_S = new V3(1.5, 1.48, 1.5);
  const S = BASE_S.clone();
  const normalAt = (d, s) => new V3(d.x / s.x, d.y / s.y, d.z / s.z).normalize();
  const features = [];
  const pin = (obj, dir, inset = 1) => {
    features.push({ obj, dir: dir.clone().normalize(), inset });
    bob.add(obj);
  };

  const skin = paintSkin();
  const bodyMat = new THREE.MeshStandardMaterial({
    map: skin.map, emissiveMap: skin.glow, emissive: 0x46f0ff, emissiveIntensity: 0,
    roughness: 0.42, metalness: 0,
  });
  const body = new THREE.Mesh(new THREE.SphereGeometry(1, 96, 64), bodyMat);
  bob.add(body);

  // Eyes: white ball, iris + pupil + shines on a pivot that aims, and a
  // hemispherical lid that rotates down to blink.
  const unitSphere = new THREE.SphereGeometry(1, 32, 20);
  const scleraMat = new THREE.MeshStandardMaterial({ color: '#ffffff', roughness: 0.18 });
  const irisMat = new THREE.MeshStandardMaterial({ color: '#3a2415', roughness: 0.3 });
  const pupilMat = new THREE.MeshStandardMaterial({ color: '#0b0705', roughness: 0.15 });
  const shineMat = new THREE.MeshBasicMaterial({ color: '#ffffff' });
  const lidMat = new THREE.MeshStandardMaterial({ color: '#f0c250', roughness: 0.45, side: THREE.DoubleSide });
  const ER = 0.27, LID_OPEN = -0.78, LID_CLOSED = 1.66;
  const eyes = [];
  for (const sx of [-1, 1]) {
    const dir = new V3(0.5 * sx, 0.36, 0.8).normalize();
    const g = new THREE.Group();
    g.lookAt(normalAt(dir, BASE_S).add(new V3(0, 0.05, 0.55)).normalize());
    const sclera = new THREE.Mesh(new THREE.SphereGeometry(ER, 40, 28), scleraMat);
    sclera.position.z = -0.07;
    g.add(sclera);
    const pivot = new THREE.Group();
    pivot.position.z = -0.07;
    g.add(pivot);
    const iris = new THREE.Mesh(unitSphere, irisMat);
    iris.scale.set(0.15, 0.15, 0.07);
    iris.position.z = 0.212;
    const pupil = new THREE.Mesh(unitSphere, pupilMat);
    pupil.scale.set(0.112, 0.112, 0.06);
    pupil.position.z = 0.23;
    const s1 = new THREE.Mesh(unitSphere, shineMat);
    s1.scale.setScalar(0.042);
    s1.position.set(-0.048, 0.052, 0.282);
    const s2 = new THREE.Mesh(unitSphere, shineMat);
    s2.scale.setScalar(0.02);
    s2.position.set(0.05, -0.042, 0.276);
    pivot.add(iris, pupil, s1, s2);
    const lid = new THREE.Mesh(new THREE.SphereGeometry(ER * 1.05, 40, 16, 0, TAU, 0, Math.PI / 2), lidMat);
    lid.position.z = -0.07;
    lid.rotation.x = LID_OPEN;
    g.add(lid);
    pin(g, dir, 1);
    eyes.push({ g, pivot, lid, dir });
  }

  // Mouth: a little smile, plus an "open" shape used when chomping food.
  const mouthMat = new THREE.MeshStandardMaterial({ color: '#4a2412', roughness: 0.5 });
  const gapeMat = new THREE.MeshStandardMaterial({ color: '#7a2b2b', roughness: 0.6 });
  const mouthDir = new V3(0, -0.15, 1).normalize();
  const mouth = new THREE.Group();
  mouth.lookAt(normalAt(mouthDir, BASE_S));
  const smile = new THREE.Group();
  const arc = new THREE.Mesh(new THREE.TorusGeometry(0.11, 0.021, 10, 28, Math.PI), mouthMat);
  arc.rotation.z = Math.PI;
  smile.add(arc);
  for (const sx of [-1, 1]) {
    const cap = new THREE.Mesh(unitSphere, mouthMat);
    cap.scale.setScalar(0.021);
    cap.position.set(0.11 * sx, 0, 0);
    smile.add(cap);
  }
  smile.position.set(0, 0.04, 0.006);
  mouth.add(smile);
  const gape = new THREE.Mesh(new THREE.CircleGeometry(0.1, 28), gapeMat);
  gape.position.set(0, -0.02, 0.014);
  gape.scale.set(0.001, 0.001, 1);
  mouth.add(gape);
  pin(mouth, mouthDir, 1);

  // Fins: fan-shaped sectors. pivot = orientation on the body, flap = beat.
  const finMat = new THREE.MeshStandardMaterial({
    color: '#f6a43c', map: paintFin(), transparent: true, side: THREE.DoubleSide,
    roughness: 0.55, depthWrite: false,
  });
  function makeFin(radius, spread, pointDir, hingeDir, surfDir, inset) {
    const mesh = new THREE.Mesh(new THREE.CircleGeometry(radius, 24, -spread / 2, spread), finMat);
    const flap = new THREE.Group();
    flap.add(mesh);
    const pivot = new THREE.Group();
    pivot.add(flap);
    const x = pointDir.clone().normalize();
    const z = new V3().crossVectors(x, hingeDir).normalize();
    const y = new V3().crossVectors(z, x).normalize();
    pivot.quaternion.setFromRotationMatrix(new THREE.Matrix4().makeBasis(x, y, z));
    pin(pivot, surfDir, inset);
    return { pivot, flap, dir: surfDir.clone().normalize() };
  }
  const UP = new V3(0, 1, 0);
  const fins = {
    pecL: makeFin(0.4, 1.75, new V3(-1, 0.05, -0.8), UP, new V3(-0.97, -0.1, 0.12), 0.97),
    pecR: makeFin(0.4, 1.75, new V3(1, 0.05, -0.8), UP, new V3(0.97, -0.1, 0.12), 0.97),
    tail: makeFin(0.58, 1.9, new V3(0, 0.08, -1), UP, new V3(0, 0.06, -1), 0.93),
    dorsal: makeFin(0.34, 1.35, new V3(0, 0.8, -0.65), new V3(0, 0.65, 0.8), new V3(0, 0.62, -0.78), 0.95),
    anal: makeFin(0.3, 1.3, new V3(0, -0.75, -0.7), new V3(0, -0.7, 0.75), new V3(0, -0.55, -0.83), 0.95),
  };

  // Spikes: tiny nubs normally, full spines when puffed (one InstancedMesh).
  const avoid = [
    ...eyes.map((e) => ({ d: e.dir, r: 0.46 })),
    { d: mouthDir, r: 0.3 },
    { d: fins.pecL.dir, r: 0.3 }, { d: fins.pecR.dir, r: 0.3 },
    { d: fins.tail.dir, r: 0.42 }, { d: fins.dorsal.dir, r: 0.3 }, { d: fins.anal.dir, r: 0.28 },
    { d: new V3(0.6, -0.12, 0.79).normalize(), r: 0.22 }, { d: new V3(-0.6, -0.12, 0.79).normalize(), r: 0.22 },
  ];
  const spikeDirs = [];
  const NSP = 300;
  for (let i = 0; i < NSP; i++) {
    const y = 1 - ((i + 0.5) / NSP) * 2, r = Math.sqrt(1 - y * y), phi = i * Math.PI * (3 - Math.sqrt(5));
    const d = new V3(Math.cos(phi) * r, y, Math.sin(phi) * r);
    if (!avoid.some((a) => d.angleTo(a.d) < a.r)) spikeDirs.push(d);
  }
  const spikeGeo = new THREE.ConeGeometry(0.045, 0.25, 7, 1);
  spikeGeo.translate(0, 0.125, 0);
  const spikeMesh = new THREE.InstancedMesh(spikeGeo, new THREE.MeshStandardMaterial({ color: '#ffffff', roughness: 0.45 }), spikeDirs.length);
  spikeMesh.frustumCulled = false;
  const spikeLow = new THREE.Color('#fff4dc'), spikeHigh = new THREE.Color('#d99b35'), spikeCol = new THREE.Color();
  const spikes = spikeDirs.map((d, i) => {
    spikeMesh.setColorAt(i, spikeCol.copy(spikeLow).lerp(spikeHigh, smoothstep(-0.25, 0.6, d.y)));
    return { d, q: new THREE.Quaternion().setFromUnitVectors(UP, normalAt(d, BASE_S)), k: rr(0.85, 1.15) };
  });
  if (spikeMesh.instanceColor) spikeMesh.instanceColor.needsUpdate = true;
  bob.add(spikeMesh);

  // ─── Food flakes ──────────────────────────────────────────────────────
  const foods = [];
  const foodGeo = new THREE.DodecahedronGeometry(0.08, 0);
  foodGeo.scale(1.1, 0.45, 0.9);
  const foodMats = ['#ff7a3d', '#ffb23f', '#8fd14f', '#ff5e7a'].map(
    (c) => new THREE.MeshStandardMaterial({ color: c, roughness: 0.6, flatShading: true }));
  const tFood = new V3();
  function dropFood(p) {
    const x = clamp(p.x, -viewHalfW + 0.35, viewHalfW - 0.35);
    const y = clamp(p.y, sandAt(x, p.z) + 0.4, viewTop - 0.35);
    const n = 1 + (rand() < 0.55 ? 1 : 0) + (rand() < 0.3 ? 1 : 0);
    for (let i = 0; i < n; i++) {
      if (foods.length >= 10) scene.remove(foods.shift().mesh);
      const m = new THREE.Mesh(foodGeo, foodMats[Math.floor(rand() * foodMats.length)]);
      m.position.set(x + rr(-0.18, 0.18), y + rr(-0.1, 0.1), p.z + rr(-0.15, 0.15));
      m.rotation.set(rand() * TAU, rand() * TAU, rand() * TAU);
      m.scale.setScalar(0.001);
      scene.add(m);
      foods.push({ mesh: m, t: 0, ph: rand() * TAU, spin: rr(-2, 2), landed: false, landT: 0 });
    }
    for (let i = 0; i < 3; i++) spawnBubble(tFood.set(x + rr(-0.1, 0.1), y, p.z), rr(0.04, 0.07), 0, 0.3, 0);
  }

  // ─── Light / dark theme (reads tokens from CSS, blends smoothly) ──────
  const PAL = {
    light: {
      sky: '#c8f5ff', ground: '#e6cc98', hemiI: 0.72, key: '#fff0cf', keyI: 0.85, rim: '#a9f2ff', rimI: 0.45,
      sand: '#e9d3a0', sandDark: '#c9ab72', caustic: '#fffbea', causticI: 0.38, ray: '#ffffff', rayI: 1,
      snow: '#ffffff', snowI: 0.34, twinkle: 0, glow: 0, bubble: '#ffffff', small: '#3f86b3',
    },
    dark: {
      sky: '#5277b3', ground: '#0f1a30', hemiI: 0.85, key: '#a9c7ff', keyI: 0.62, rim: '#62f1ff', rimI: 0.9,
      sand: '#2f3f62', sandDark: '#1b2644', caustic: '#8ec6ff', causticI: 0.26, ray: '#7bafff', rayI: 0.5,
      snow: '#7ef9ff', snowI: 1, twinkle: 1, glow: 0.9, bubble: '#d4ecff', small: '#2a4f78',
    },
  };
  const PC = {};
  for (const k of Object.keys(PAL)) {
    PC[k] = {};
    for (const [n, v] of Object.entries(PAL[k])) PC[k][n] = typeof v === 'string' ? new THREE.Color(v) : v;
  }
  const mixC = (target, name, m) => target.copy(PC.light[name]).lerp(PC.dark[name], m);
  const mixN = (name, m) => lerp(PC.light[name], PC.dark[name], m);
  const theme = { m: 0, target: 0, fog: new THREE.Color('#55b8cf'), fogTarget: new THREE.Color('#55b8cf'), ready: false };

  function readTheme() {
    const cs = getComputedStyle(document.documentElement);
    theme.target = (parseFloat(cs.getPropertyValue('--mode')) || 0) > 0.5 ? 1 : 0;
    const f = cs.getPropertyValue('--fog').trim();
    if (f) theme.fogTarget.set(f);
    if (!theme.ready) { theme.m = theme.target; theme.fog.copy(theme.fogTarget); theme.ready = true; }
  }
  function applyTheme(t) {
    const m = theme.m;
    mixC(hemi.color, 'sky', m); mixC(hemi.groundColor, 'ground', 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);
    scene.fog.color.copy(theme.fog);
    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);
    bodyMat.emissiveIntensity = mixN('glow', m) * (0.7 + 0.3 * Math.sin(t * 1.8));
    mixC(schoolMat.color, 'small', m);
    schoolTailMat.color.copy(schoolMat.color);
  }
  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 Pip framed on any screen shape ──────────────────────
  const bounds = { x: 2.6, yMin: SAND_Y + 1.35, yMax: 2.0, zMin: -2.4, zMax: 1.1 };
  function layout() {
    const w = Math.max(1, stage.clientWidth), h = Math.max(1, stage.clientHeight);
    renderer.setSize(w, h, false);
    camera.aspect = w / h;
    camDist = Math.max(2.5, 2.1 / camera.aspect) / tanHalf;
    camera.updateProjectionMatrix();
    const halfH = camDist * tanHalf;
    viewHalfW = halfH * camera.aspect;
    viewTop = camLook.y + halfH;
    bounds.x = Math.max(0.3, viewHalfW - 1.55);
    bounds.yMax = Math.max(0.6, Math.min(2.2, viewTop - 1.5));
    scene.fog.near = camDist + 1.5;
    scene.fog.far = camDist + 27;
    sandMat.uniforms.uFade.value.set(camDist + 7, camDist + 32);
    sandMat.uniforms.uDetail.value.set(camDist + 4, camDist + 19);
    const sc = renderer.domElement.height / (2 * tanHalf);
    bMat.uniforms.uScale.value = sc;
    snMat.uniforms.uScale.value = sc;
  }
  if (window.ResizeObserver) new ResizeObserver(layout).observe(stage);
  else window.addEventListener('resize', layout);
  layout();

  // ─── Pip's state ──────────────────────────────────────────────────────
  const st = {
    pos: new V3(-(bounds.x + 3.5), 0.5, -0.6), vel: new V3(0.5, 0, 0), target: new V3(0, 0.2, 0.2),
    wanderT: 5, yaw: Math.PI / 2, pitch: 0, roll: 0, intro: true,
    puff: 0, puffV: 0, puffTarget: 0, puffHold: 0, deflate: 0,
    blinkIn: 2.2, blinkT: -1, happy: 0, chomp: 0, gape: 0,
    gaze: new V3(0, 0, 10), sac: new V3(), sacT: 2, bubbleIn: 3, finPh: 0, tailPh: 0,
  };
  function pickWander() {
    st.wanderT = rr(3.5, 7);
    st.target.set(rr(-bounds.x, bounds.x) * 0.9, rr(bounds.yMin + 0.2, Math.min(bounds.yMax, 1.3)), rr(bounds.zMin * 0.7, bounds.zMax * 0.8));
  }
  function puff() {
    if (st.puffTarget === 1) { st.puffV += 3.5; st.puffHold = Math.max(st.puffHold, 1.8); return; } // boing
    st.puffTarget = 1;
    st.puffHold = 2.8;
    st.puffV += 1.5;
    st.blinkT = -1;
  }

  // ─── Input ────────────────────────────────────────────────────────────
  const pointer = { ndc: new THREE.Vector2(), inside: false, active: 0, sx: 0, sy: 0 };
  const pointerWorld = new V3(0, 0, 5);
  const raycaster = new THREE.Raycaster();
  const gazePlane = new THREE.Plane(new V3(0, 0, 1), 0);
  const foodPlane = new THREE.Plane(new V3(0, 0, 1), 0);
  const hitSphere = new THREE.Sphere();
  const tHit = new V3();
  const coarse = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
  hintEl.textContent = coarse
    ? 'Tap Pip to puff up, or tap the water to drop food.'
    : 'Click Pip to puff up, or click the water to drop food.';
  let hintGone = false;
  const dismissHint = () => { if (!hintGone) { hintGone = true; hintEl.classList.add('gone'); } };

  function setNDC(e) {
    const r = canvas.getBoundingClientRect();
    pointer.ndc.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1);
  }
  function overFish() {
    raycaster.setFromCamera(pointer.ndc, camera);
    body.getWorldPosition(hitSphere.center);
    hitSphere.radius = Math.max(S.x, S.y, S.z) * 1.1 + 0.15;
    return raycaster.ray.intersectsSphere(hitSphere);
  }
  let down = null;
  canvas.addEventListener('pointermove', (e) => {
    setNDC(e);
    pointer.inside = true;
    pointer.active = 2.5;
    if (e.pointerType === 'mouse') canvas.style.cursor = overFish() ? 'pointer' : 'default';
  });
  canvas.addEventListener('pointerleave', () => { pointer.inside = false; });
  canvas.addEventListener('pointerdown', (e) => {
    setNDC(e);
    pointer.inside = true;
    pointer.active = 2.5;
    down = { x: e.clientX, y: e.clientY, t: performance.now() };
  });
  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;
    setNDC(e);
    dismissHint();
    if (overFish()) { puff(); return; }
    raycaster.setFromCamera(pointer.ndc, camera);
    if (raycaster.ray.intersectPlane(foodPlane, tHit)) dropFood(tHit);
  });
  canvas.addEventListener('pointercancel', () => { down = null; });
  puffBtn.addEventListener('click', () => { dismissHint(); puff(); });
  feedBtn.addEventListener('click', () => {
    dismissHint();
    dropFood(tHit.set(st.pos.x + rr(-1.4, 1.4), Math.min(viewTop - 0.7, st.pos.y + 1.8), clamp(st.pos.z + rr(-0.2, 0.4), -0.5, 0.6)));
  });
  window.addEventListener('keydown', (e) => {
    const tag = e.target && e.target.tagName;
    if (tag === 'BUTTON' || tag === 'INPUT' || tag === 'TEXTAREA') return;
    if (e.key === ' ' || e.key === 'p' || e.key === 'P') { e.preventDefault(); dismissHint(); puff(); }
    else if (e.key === 'f' || e.key === 'F') feedBtn.click();
  });

  // ─── Per-frame update ─────────────────────────────────────────────────
  const tA = new V3(), tB = new V3(), tC = new V3(), mouthW = new V3(), fwd = new V3();
  const m4 = new THREE.Matrix4(), sclTmp = new V3();
  let time = 0;

  function nearestFood() {
    let best = null, bd = Infinity;
    for (const f of foods) {
      const d = f.mesh.position.distanceToSquared(mouthW);
      if (d < bd) { bd = d; best = f; }
    }
    return best;
  }

  function update(dt) {
    time += dt;
    const t = time;

    theme.m = damp(theme.m, theme.target, 5, dt);
    theme.fog.lerp(theme.fogTarget, 1 - Math.exp(-5 * dt));
    applyTheme(t);

    // gentle parallax toward the pointer
    pointer.active = Math.max(0, pointer.active - dt);
    pointer.sx = damp(pointer.sx, pointer.inside ? pointer.ndc.x : 0, 1.8, dt);
    pointer.sy = damp(pointer.sy, pointer.inside ? pointer.ndc.y : 0, 1.8, dt);
    camera.position.set(pointer.sx * 0.5 * MOTION, 0.55 + pointer.sy * 0.28 * MOTION, camDist);
    camera.lookAt(camLook);
    camera.updateMatrixWorld();

    // ── where does Pip want to go?
    const puffed = st.puff > 0.35 || st.puffTarget > 0.5;
    const food = puffed ? null : nearestFood();
    let maxSpeed = 0.65 * MOTION, resp = 1.5;
    if (st.intro) {
      maxSpeed = 1.1;
      if (st.pos.x > -bounds.x) st.intro = false;
    }
    if (food) {
      st.target.copy(food.mesh.position).sub(tA.subVectors(mouthW, st.pos)); // bring the mouth, not the middle
      maxSpeed = 1.8;
      resp = 3.2;
    } else if (puffed) {
      st.target.set(clamp(st.pos.x, -bounds.x * 0.5, bounds.x * 0.5), clamp(st.pos.y + 0.1, bounds.yMin + 0.5, viewTop - 2.1), clamp(st.pos.z, -0.8, 0.2));
      maxSpeed = 0.35;
      resp = 1.2;
    } else {
      st.wanderT -= dt;
      if (!st.intro && (st.wanderT <= 0 || st.pos.distanceTo(st.target) < 0.3)) pickWander();
    }
    tA.subVectors(st.target, st.pos);
    const dist = tA.length();
    tA.multiplyScalar(dist > 1e-4 ? Math.min(maxSpeed, dist * 1.2) / dist : 0);
    st.vel.lerp(tA, 1 - Math.exp(-resp * dt));
    st.pos.addScaledVector(st.vel, dt);
    const floorY = sandAt(st.pos.x, st.pos.z) + (food ? 1.02 : 1.25) + Math.max(0, st.puff) * 0.55;
    const roomX = viewHalfW - Math.max(S.x, S.z) - 0.25; // keep the whole body on screen
    st.pos.x = clamp(st.pos.x, st.intro ? -bounds.x - 8 : -Math.max(roomX, 0), Math.max(roomX, 0));
    st.pos.y = clamp(st.pos.y, floorY, Math.max(floorY, Math.min(food ? viewTop - 0.7 : bounds.yMax, viewTop - S.y - 0.35)));
    st.pos.z = clamp(st.pos.z, bounds.zMin, bounds.zMax);

    // ── heading: travel direction, biased toward the viewer when idle
    const speed = st.vel.length();
    let hx, hz;
    if (food) {
      hx = food.mesh.position.x - st.pos.x;
      hz = food.mesh.position.z - st.pos.z;
      const l = Math.hypot(hx, hz);
      if (l > 1e-3) { hx /= l; hz /= l; } else { hx = 0; hz = 1; }
      hz += 0.12;
    } else {
      hx = st.vel.x * 1.6;
      hz = st.vel.z * 1.6 + 0.55;
      if (pointer.active > 0 && pointer.inside) hx += clamp(pointerWorld.x - st.pos.x, -2, 2) * 0.18;
    }
    const prevYaw = st.yaw;
    st.yaw = dampAngle(st.yaw, Math.atan2(hx, hz), food ? 5 : 2.4, dt);
    const yawRate = wrapAngle(st.yaw - prevYaw) / Math.max(dt, 1e-3);
    let pitchT = clamp(-st.vel.y * 0.55, -0.45, 0.45);
    if (food) {
      const dy = food.mesh.position.y - st.pos.y;
      const hd = Math.hypot(food.mesh.position.x - st.pos.x, food.mesh.position.z - st.pos.z);
      pitchT = clamp(-Math.atan2(dy, Math.max(hd, 0.35)) * 0.9, -0.8, 0.8);
    }
    st.pitch = damp(st.pitch, pitchT, 3.2, dt);
    st.roll = damp(st.roll, clamp(-yawRate * 0.2, -0.38, 0.38), 3, dt);
    fish.position.copy(st.pos);
    fish.rotation.set(st.pitch, st.yaw, st.roll);

    // ── puff spring (bouncy going up, softer coming down)
    if (st.puffHold > 0) {
      st.puffHold -= dt;
      if (st.puffHold <= 0) { st.puffTarget = 0; st.deflate = 1.4; }
    }
    const inflating = st.puffTarget > 0.5;
    const K = inflating ? 78 : 30, C = inflating ? 7.5 : 8.8;
    st.puffV += (K * (st.puffTarget - st.puff) - C * st.puffV) * dt;
    st.puff += st.puffV * dt;
    const P = clamp(st.puff, -0.12, 1.3), P01 = clamp(P, 0, 1);

    S.set(lerp(BASE_S.x, PUFF_S.x, P), lerp(BASE_S.y, PUFF_S.y, P), lerp(BASE_S.z, PUFF_S.z, P));
    S.multiplyScalar(1 + 0.016 * Math.sin(t * 2.3) * (1 - P01)); // breathing
    st.chomp = Math.max(0, st.chomp - dt * 3.5);
    const ch = Math.sin(st.chomp * Math.PI) * 0.1;
    S.x *= 1 + ch * 0.5; S.z *= 1 + ch * 0.4; S.y *= 1 - ch;
    body.scale.copy(S);
    for (const f of features) f.obj.position.copy(f.dir).multiply(S).multiplyScalar(f.inset);

    const spk = lerp(0.14, 1, smoothstep(0.08, 0.9, P)) + Math.max(0, P - 1) * 0.5;
    for (let i = 0; i < spikes.length; i++) {
      const s = spikes[i], kk = spk * s.k;
      tA.copy(s.d).multiply(S).multiplyScalar(0.985);
      sclTmp.set(0.45 + 0.55 * kk, kk, 0.45 + 0.55 * kk);
      spikeMesh.setMatrixAt(i, m4.compose(tA, s.q, sclTmp));
    }
    spikeMesh.instanceMatrix.needsUpdate = true;

    // ── fins
    st.finPh += dt * (puffed ? 19 : 9 + speed * 5);
    st.tailPh += dt * (3 + speed * 7 + (puffed ? 5 : 0));
    const pa = 0.42 + 0.18 * P01;
    fins.pecR.flap.rotation.y = Math.sin(st.finPh) * pa;
    fins.pecL.flap.rotation.y = -Math.sin(st.finPh + 0.8) * pa;
    fins.tail.flap.rotation.y = Math.sin(st.tailPh) * (0.2 + Math.min(speed, 1.5) * 0.22);
    fins.dorsal.flap.rotation.y = Math.sin(st.finPh * 0.6 + 1) * 0.32;
    fins.anal.flap.rotation.y = Math.sin(st.finPh * 0.6 + 2.4) * 0.32;

    bob.position.y = Math.sin(t * 1.7) * 0.055 * MOTION;
    bob.rotation.x = Math.sin(t * 1.3 + 1) * 0.03;
    bob.rotation.z = Math.sin(t * 1.1) * 0.035;
    fish.updateMatrixWorld(true);
    mouth.getWorldPosition(mouthW);
    fwd.set(0, 0, 1).applyQuaternion(fish.quaternion);

    // ── gaze: pointer > food > the viewer (with little glances around)
    if (pointer.active > 0 && pointer.inside) {
      raycaster.setFromCamera(pointer.ndc, camera);
      gazePlane.constant = -(st.pos.z + 1.4);
      if (raycaster.ray.intersectPlane(gazePlane, tB)) pointerWorld.copy(tB); else tB.copy(camera.position);
    } else if (food) {
      tB.copy(food.mesh.position);
    } else {
      st.sacT -= dt;
      if (st.sacT <= 0) {
        st.sacT = rr(1, 3.5);
        if (rand() < 0.6) st.sac.set(0, 0, 0); else st.sac.set(rr(-4, 4), rr(-2, 2.5), rr(-3, 0));
      }
      tB.copy(camera.position).add(st.sac);
    }
    st.gaze.lerp(tB, 1 - Math.exp(-14 * dt));
    for (const e of eyes) {
      tC.copy(st.gaze);
      e.g.worldToLocal(tC);
      tC.sub(e.pivot.position);
      const yaw = clamp(Math.atan2(tC.x, tC.z), -0.62, 0.62);
      const pitch = clamp(Math.atan2(tC.y, Math.hypot(tC.x, tC.z)), -0.5, 0.42);
      e.pivot.rotation.set(-pitch, yaw, 0);
    }

    // ── lids: blink, go wide when puffed, squint happily after a snack
    st.blinkIn -= dt;
    if (st.blinkIn <= 0 && st.blinkT < 0) { st.blinkT = 0; st.blinkIn = rand() < 0.2 ? 0.3 : rr(2.4, 5.6); }
    let close = 0;
    if (st.blinkT >= 0) {
      st.blinkT += dt;
      const x = st.blinkT / 0.18;
      close = x < 0.45 ? x / 0.45 : 1 - (x - 0.45) / 0.55;
      if (x >= 1) { st.blinkT = -1; close = 0; }
    }
    st.happy = Math.max(0, st.happy - dt);
    const happyK = smoothstep(0, 0.3, st.happy);
    const openA = lerp(lerp(LID_OPEN, -1.15, P01), -0.12, happyK);
    const lidA = lerp(openA, LID_CLOSED, clamp(close, 0, 1));
    for (const e of eyes) e.lid.rotation.x = lidA;

    // ── mouth
    st.gape = Math.max(0, st.gape - dt * 4.5);
    const gp = Math.sin(st.gape * Math.PI);
    gape.scale.set(0.001 + gp * 1.15, 0.001 + gp * 0.75, 1);
    const sk = Math.max((1 - 0.5 * P01) * (1 - gp) * (1 + 0.15 * happyK), 0.001);
    smile.scale.set(sk, sk * (1 + 0.3 * happyK), sk);

    // ── bubbles from Pip
    st.bubbleIn -= dt;
    if (st.bubbleIn <= 0 && !puffed) {
      st.bubbleIn = rr(2.5, 6.5);
      const n = 1 + Math.floor(rand() * 3);
      for (let i = 0; i < n; i++) {
        tA.copy(mouthW).addScaledVector(fwd, 0.05 + i * 0.05).add(tC.set(rr(-0.03, 0.03), i * 0.09, rr(-0.03, 0.03)));
        spawnBubble(tA, rr(0.04, 0.085), fwd.x * 0.3, 0.15, fwd.z * 0.3);
      }
    }
    if (st.deflate > 0) {
      st.deflate -= dt;
      if (rand() < 36 * smoothstep(0, 0.4, st.deflate) * dt) {
        tA.copy(mouthW).add(tC.set(rr(-0.04, 0.04), rr(-0.04, 0.04), rr(-0.04, 0.04)));
        spawnBubble(tA, rr(0.035, 0.1), fwd.x * 0.9, 0.2, fwd.z * 0.9);
      }
    }

    // ── food: sink, settle, fade, or get eaten
    for (let i = foods.length - 1; i >= 0; i--) {
      const f = foods[i], m = f.mesh;
      f.t += dt;
      let fade = 1;
      if (!f.landed) {
        m.position.y -= 0.24 * dt;
        m.position.x += Math.sin(f.t * 2.2 + f.ph) * 0.14 * dt;
        m.rotation.x += f.spin * dt;
        m.rotation.z += f.spin * 0.6 * dt;
        const fy = sandAt(m.position.x, m.position.z) + 0.06;
        if (m.position.y <= fy) { m.position.y = fy; f.landed = true; f.landT = f.t; }
      } else {
        fade = 1 - smoothstep(10, 13, f.t - f.landT);
        if (fade <= 0) { scene.remove(m); foods.splice(i, 1); continue; }
      }
      m.scale.setScalar(Math.max(0.001, smoothstep(0, 0.25, f.t) * fade));
      if (!puffed && mouthW.distanceTo(m.position) < 0.4) {
        scene.remove(m);
        foods.splice(i, 1);
        st.chomp = 1; st.gape = 1; st.happy = 1.1;
        for (let k = 0; k < 4; k++) {
          tA.copy(mouthW).add(tC.set(rr(-0.08, 0.08), rr(0, 0.1), rr(-0.05, 0.05)));
          spawnBubble(tA, rr(0.03, 0.06), rr(-0.1, 0.1), 0.25, rr(-0.1, 0.1));
        }
      }
    }

    // ── soft shadow under Pip (drawn by the sand shader)
    const hgt = Math.max(0, st.pos.y - sandAt(st.pos.x, st.pos.z));
    sandMat.uniforms.uShadow.value.set(
      st.pos.x + hgt * 0.1, st.pos.z - hgt * 0.14,
      clamp(0.6 - hgt * 0.1, 0.18, 0.55) * lerp(1, 0.55, theme.m),
      Math.max(S.x, S.z) * 1.1 + hgt * 0.2);
    sandMat.uniforms.uTime.value = t * MOTION;
    snMat.uniforms.uTime.value = t;

    updateKelp(t);
    updateSnow(dt, t);
    updateVents(dt);
    updateBubbles(dt);
    updateSchool(t);
  }

  readTheme();
  applyTheme(0);
  const clock = new THREE.Clock();
  renderer.setAnimationLoop(() => {
    update(Math.min(clock.getDelta(), 1 / 20));
    renderer.render(scene, camera);
  });
})();
</script>
</body>
</html>