Models 44

MiniMax M3.1 Flash

by MiniMaxReasoningClean
Open in new window

Running live in a sandboxed frame. Click inside it before using the keyboard.

How it went

Total time
2 min 45 s
Cost
$0.022
Output tokens
36,386
Spent thinking
23,077 tokens
Speed
221.2 tok/s
Console errors
0
First token after
307 ms
First visible text after
2 min 5 s
Prompt tokens
613
Code size
38.2 KB
Finish reason
stop
Served by
Nebius
Model id
minimax/minimax-m3.1-flash
Reasoning sent
{"effort":"xhigh"}
Max output
64,000 tokens
Generated
Nov 11, 2026

What it was asked

The prompt
“a 3D aquarium with Three.js where the fish swim away when I move the mouse near them (Load Three.js only through this import map: <script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}</script>)”
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task.

Output rules:
- Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block.
- Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task.
- Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw.
- The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar.
- Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction.
- Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session.
- Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing.
- The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message.
- The code must run with zero errors in the console of the latest Chrome.

Code run-1.html · 1003 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>3D Aquarium — fish that flee your cursor</title>
<style>
  :root{
    --cyan:#7fe9ff;
    --ink:#03090f;
    --glass:rgba(8,26,38,.55);
    --stroke:rgba(127,233,255,.22);
  }
  *{box-sizing:border-box}
  html,body{height:100%;margin:0;overflow:hidden;background:#03090f;}
  body{
    font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    color:#dff4ff; -webkit-font-smoothing:antialiased;
  }
  canvas{display:block;width:100%;height:100%;}
  #vignette{
    position:fixed;inset:0;pointer-events:none;z-index:2;
    background:radial-gradient(120% 95% at 50% 45%, rgba(0,0,0,0) 40%, rgba(0,0,0,.55) 100%);
  }
  .hud{position:fixed;inset:0;z-index:3;pointer-events:none;}
  .panel{
    position:absolute;pointer-events:auto;
    background:var(--glass);
    border:1px solid var(--stroke);
    border-radius:14px;
    padding:14px 16px;
    backdrop-filter:blur(12px) saturate(1.2);
    -webkit-backdrop-filter:blur(12px) saturate(1.2);
    box-shadow:0 12px 40px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.05);
  }
  .panel--tl{top:18px;left:18px;max-width:min(330px,62vw)}
  .panel--tr{top:18px;right:18px;min-width:170px}
  .panel--bl{bottom:18px;left:18px;display:flex;flex-direction:column;gap:10px}
  h1{
    margin:0;font-size:19px;letter-spacing:.24em;text-transform:uppercase;font-weight:600;
    background:linear-gradient(92deg,#ffffff,#7fe9ff 70%);
    -webkit-background-clip:text;background-clip:text;color:transparent;
  }
  .sub{margin:4px 0 10px;font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:rgba(160,215,235,.65)}
  .hint{margin:0;font-size:12.5px;line-height:1.5;color:rgba(206,238,250,.82)}
  .hint b{color:var(--cyan);font-weight:600}
  .row{display:flex;justify-content:space-between;align-items:center;gap:14px;font-size:11.5px;letter-spacing:.1em;text-transform:uppercase;color:rgba(170,220,240,.7);padding:3px 0}
  .row b{color:#eafcff;font-size:13px;letter-spacing:.05em;font-variant-numeric:tabular-nums}
  .meter{margin-top:9px;height:6px;border-radius:99px;background:rgba(255,255,255,.08);overflow:hidden}
  .meter i{display:block;height:100%;width:100%;border-radius:99px;background:linear-gradient(90deg,#ff7a4d,#ffd166,#7fe9ff);transition:width .18s ease}
  .meter-label{margin-top:5px;font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:rgba(170,220,240,.5)}
  .btnrow{display:flex;gap:8px;align-items:center}
  button{
    font:inherit;font-size:12px;letter-spacing:.08em;text-transform:uppercase;
    color:#d9f6ff;background:rgba(127,233,255,.08);
    border:1px solid var(--stroke);border-radius:9px;padding:7px 12px;cursor:pointer;
    transition:background .15s ease, transform .1s ease, color .15s ease;
  }
  button:hover{background:rgba(127,233,255,.2);color:#fff}
  button:active{transform:translateY(1px)}
  button.toggle.on{background:rgba(127,233,255,.26);color:#fff;border-color:rgba(127,233,255,.55)}
  #popLabel{font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:rgba(190,232,248,.85);min-width:64px;text-align:center}
  #overlay{
    position:fixed;inset:0;z-index:6;display:flex;align-items:center;justify-content:center;
    background:radial-gradient(60% 50% at 50% 50%, rgba(2,12,20,.25), rgba(2,10,16,.62));
    cursor:pointer;transition:opacity .5s ease;
  }
  #overlay.hide{opacity:0;pointer-events:none}
  .card{
    text-align:center;padding:26px 34px;border-radius:20px;
    background:rgba(6,20,30,.62);border:1px solid var(--stroke);
    backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);
    box-shadow:0 30px 80px rgba(0,0,0,.55);
    transition:transform .6s cubic-bezier(.2,.8,.2,1), opacity .6s ease, padding .6s ease;
  }
  .card h2{margin:14px 0 6px;font-size:17px;letter-spacing:.16em;text-transform:uppercase;font-weight:600}
  .card p{margin:0;font-size:12.5px;color:rgba(190,228,244,.72);letter-spacing:.04em}
  .ring{
    width:54px;height:54px;margin:0 auto;border-radius:50%;
    border:2px solid rgba(127,233,255,.85);position:relative;
    box-shadow:0 0 24px rgba(127,233,255,.45) inset;
  }
  .ring::after{
    content:"";position:absolute;inset:-2px;border-radius:50%;
    border:2px solid rgba(127,233,255,.7);animation:ping 1.8s cubic-bezier(0,.2,.6,1) infinite;
  }
  @keyframes ping{0%{transform:scale(1);opacity:.9}100%{transform:scale(2.1);opacity:0}}
  #overlay.mini{background:none;align-items:flex-end;padding-bottom:92px;pointer-events:none}
  #overlay.mini .card{padding:10px 16px;border-radius:99px;pointer-events:auto}
  #overlay.mini .card h2{font-size:11.5px;margin:0}
  #overlay.mini .card p,#overlay.mini .ring{display:none}
  @media (max-width:760px){
    .panel--tl{top:12px;left:12px;right:12px;max-width:none;padding:11px 13px}
    .panel--tl .hint{font-size:11.5px}
    .panel--tr{top:auto;bottom:112px;right:12px;padding:10px 12px;min-width:0}
    .panel--bl{bottom:12px;left:12px;right:12px;flex-direction:row;flex-wrap:wrap;justify-content:space-between;padding:10px 12px}
    .btnrow{gap:6px}
    button{padding:6px 10px;font-size:11px}
    #overlay.mini{padding-bottom:110px}
  }
</style>
</head>
<body>
<canvas id="view"></canvas>
<div id="vignette"></div>

<div class="hud">
  <div class="panel panel--tl">
    <h1>Aquarium</h1>
    <div class="sub">3D reef tank · three.js</div>
    <p class="hint">Sweep your pointer through the water — every fish that feels it <b>scatters</b>. Move slowly for a shy nudge, fast for a stampede.</p>
  </div>

  <div class="panel panel--tr">
    <div class="row"><span>Population</span><b id="popCount">14</b></div>
    <div class="row"><span>Fleeing</span><b id="fleeCount">0</b></div>
    <div class="meter"><i id="calmBar"></i></div>
    <div class="meter-label">Calmness</div>
  </div>

  <div class="panel panel--bl">
    <div class="btnrow">
      <button id="less" title="Remove a fish">−</button>
      <span id="popLabel">14 fish</span>
      <button id="more" title="Add a fish">+</button>
    </div>
    <div class="btnrow">
      <button id="autoBtn" class="toggle on">Auto-pilot</button>
      <button id="startle">Startle</button>
      <button id="reset">Reset</button>
    </div>
  </div>
</div>

<div id="overlay">
  <div class="card">
    <div class="ring"></div>
    <h2>Click or press a key to start</h2>
    <p>Auto-pilot is swimming the pointer for you — watch the fish scatter.</p>
  </div>
</div>

<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}
</script>
<script type="module">
import * as THREE from 'three';

/* ------------------------------------------------------------------ */
/*  renderer / scene                                                  */
/* ------------------------------------------------------------------ */
const canvas = document.getElementById('view');
const renderer = new THREE.WebGLRenderer({ canvas, antialias:true, powerPreference:'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.02;
renderer.outputColorSpace = THREE.SRGBColorSpace;

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x040d15);
scene.fog = new THREE.FogExp2(0x061a26, 0.036);

const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 120);
const camBase = new THREE.Vector3(0, 2.75, 11.0);
const camLook = new THREE.Vector3(0, 2.55, 0);
camera.position.copy(camBase);
camera.lookAt(camLook);

const clock = new THREE.Clock();
const timeU = { value: 0 };

/* ---- environment (procedural, no external assets) ---- */
(function buildEnv(){
  const pmrem = new THREE.PMREMGenerator(renderer);
  const env = new THREE.Scene();
  const roomMat = new THREE.MeshBasicMaterial({ side: THREE.BackSide });
  roomMat.color.setRGB(0.035, 0.075, 0.10);
  env.add(new THREE.Mesh(new THREE.BoxGeometry(40, 26, 40), roomMat));

  function panel(pos, size, rgb){
    const m = new THREE.Mesh(new THREE.PlaneGeometry(size[0], size[1]), new THREE.MeshBasicMaterial({ side: THREE.DoubleSide }));
    m.material.color.setRGB(rgb[0], rgb[1], rgb[2]);
    m.position.set(pos[0], pos[1], pos[2]);
    m.lookAt(0, 0, 0);
    env.add(m);
  }
  panel([0, 11, 0], [26, 26], [4.2, 4.6, 5.0]);      // big overhead soft box
  panel([-11, 3, 3], [12, 12], [0.6, 2.2, 3.2]);     // cyan side
  panel([10, 2, -4], [10, 10], [2.4, 2.0, 1.2]);     // warm side
  panel([0, 1, -12], [20, 10], [0.25, 0.7, 1.1]);    // back glow

  scene.environment = pmrem.fromScene(env, 0.03).texture;
  if ('environmentIntensity' in scene) scene.environmentIntensity = 0.55;
  pmrem.dispose();
})();

/* ---- lights ---- */
scene.add(new THREE.HemisphereLight(0x8fd8ff, 0x1d1409, 0.85));

const sun = new THREE.DirectionalLight(0xffffff, 2.1);
sun.position.set(4.5, 9.5, 6.0);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -9; sun.shadow.camera.right = 9;
sun.shadow.camera.top = 9;   sun.shadow.camera.bottom = -6;
sun.shadow.camera.near = 1;  sun.shadow.camera.far = 30;
sun.shadow.bias = -0.0006;
sun.shadow.normalBias = 0.02;
scene.add(sun);

const back = new THREE.DirectionalLight(0x6fd0ff, 0.75);
back.position.set(-7, 5, -7);
scene.add(back);

const lamp = new THREE.PointLight(0x8febff, 26, 22, 2);
lamp.position.set(0, 5.3, 0.2);
scene.add(lamp);

/* ------------------------------------------------------------------ */
/*  tank geometry constants                                            */
/* ------------------------------------------------------------------ */
const TANK = { w: 12, h: 6, d: 6 };
const WATER_Y = 5.35;
const BOUND = { x: 5.15, yMin: 0.85, yMax: 4.95, z: 2.25 };

/* ---- sand ---- */
(function sand(){
  const g = new THREE.PlaneGeometry(TANK.w, TANK.d, 90, 48);
  g.rotateX(-Math.PI / 2);
  const p = g.attributes.position;
  for (let i = 0; i < p.count; i++){
    const x = p.getX(i), z = p.getZ(i);
    const n = Math.sin(x * 1.7 + 0.4) * 0.045 + Math.cos(z * 2.1) * 0.035
            + Math.sin((x + z) * 3.3) * 0.018 + Math.cos(x * 5.1 - z * 4.2) * 0.012;
    p.setY(i, n);
  }
  g.computeVertexNormals();
  const m = new THREE.MeshStandardMaterial({ color: 0xcdb894, roughness: 0.96, metalness: 0.0 });
  const mesh = new THREE.Mesh(g, m);
  mesh.receiveShadow = true;
  scene.add(mesh);
})();

/* ---- caustics ---- */
const causticsTex = (function(){
  const c = document.createElement('canvas'); c.width = c.height = 256;
  const x = c.getContext('2d');
  x.fillStyle = '#000'; x.fillRect(0,0,256,256);
  x.globalCompositeOperation = 'lighter';
  for (let i = 0; i < 70; i++){
    const px = Math.random()*256, py = Math.random()*256, r = 12 + Math.random()*34;
    const gr = x.createRadialGradient(px, py, 0, px, py, r);
    gr.addColorStop(0, 'rgba(255,255,255,0.85)');
    gr.addColorStop(0.5, 'rgba(255,255,255,0.22)');
    gr.addColorStop(1, 'rgba(255,255,255,0)');
    x.fillStyle = gr; x.beginPath(); x.arc(px, py, r, 0, 6.283); x.fill();
  }
  const t = new THREE.CanvasTexture(c);
  t.wrapS = t.wrapT = THREE.RepeatWrapping;
  return t;
})();

const caustics = new THREE.Mesh(
  new THREE.PlaneGeometry(TANK.w, TANK.d),
  new THREE.ShaderMaterial({
    uniforms: { uTex: { value: causticsTex }, uTime: timeU },
    transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
    vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
    fragmentShader: `
      uniform sampler2D uTex; uniform float uTime; varying vec2 vUv;
      void main(){
        vec2 p = vUv * vec2(2.2, 1.6);
        float a = texture2D(uTex, p + vec2(uTime*0.016, uTime*0.011)).r;
        float b = texture2D(uTex, p*1.35 - vec2(uTime*0.013, uTime*0.019)).r;
        float c = pow(min(a,b), 3.0) * 2.1;
        gl_FragColor = vec4(vec3(0.55,0.86,1.0) * c, c);
      }`
  })
);
caustics.rotation.x = -Math.PI/2;
caustics.position.y = 0.075;
scene.add(caustics);

/* ---- rocks ---- */
function makeRock(r, seed){
  const g = new THREE.IcosahedronGeometry(r, 1);
  const p = g.attributes.position;
  const v = new THREE.Vector3();
  for (let i = 0; i < p.count; i++){
    v.fromBufferAttribute(p, i);
    const n = 1 + 0.24*Math.sin(v.x*7+seed) + 0.2*Math.cos(v.y*9-seed*1.7) + 0.14*Math.sin(v.z*11+seed);
    v.multiplyScalar(n);
    v.y *= 0.78;
    p.setXYZ(i, v.x, v.y, v.z);
  }
  g.computeVertexNormals();
  return g;
}
const rockSpots = [[-4.2,-1.7,0.62],[4.6,-1.5,0.52],[-2.4,1.9,0.4],[3.2,2.0,0.45],[0.4,-2.3,0.34]];
rockSpots.forEach((s, i) => {
  const m = new THREE.Mesh(makeRock(s[2], i * 3.1),
    new THREE.MeshStandardMaterial({ color: new THREE.Color().setHSL(0.09, 0.06, 0.20 + i*0.02), roughness: 0.92 }));
  m.position.set(s[0], s[2]*0.42, s[1]);
  m.rotation.set(Math.random(), Math.random()*6, Math.random());
  m.castShadow = true; m.receiveShadow = true;
  scene.add(m);
});

/* ---- seaweed ---- */
function makeSeaweedGeometry(height, width, seg, bend){
  const g = new THREE.PlaneGeometry(width, height, 3, seg);
  g.translate(0, height/2, 0);
  const p = g.attributes.position;
  for (let i = 0; i < p.count; i++){
    const x = p.getX(i), y = p.getY(i);
    const t = y/height;
    p.setX(i, x + bend*t*t);
    p.setZ(i, Math.sin(t*2.4)*0.07);
  }
  g.computeVertexNormals();
  return g;
}
const weedSpots = [[-5.0,1.6,2.6],[-4.6,-0.6,2.1],[-3.2,2.2,1.7],[4.9,1.3,2.4],[4.4,-0.9,1.9],[2.2,2.4,1.5],[-1.5,-2.4,1.6],[5.3,0.2,1.4]];
weedSpots.forEach((s, i) => {
  const h = s[2];
  const mat = new THREE.MeshStandardMaterial({
    color: new THREE.Color().setHSL(0.28 + i*0.012, 0.55, 0.24 + (i%3)*0.05),
    roughness: 0.75, side: THREE.DoubleSide
  });
  const uni = { uTime: timeU, uPhase: { value: Math.random()*6.28 }, uHeight: { value: h } };
  mat.onBeforeCompile = (sh) => {
    sh.uniforms.uTime = uni.uTime; sh.uniforms.uPhase = uni.uPhase; sh.uniforms.uHeight = uni.uHeight;
    sh.vertexShader = 'uniform float uTime; uniform float uPhase; uniform float uHeight;\n' + sh.vertexShader.replace(
      '#include <begin_vertex>',
      `#include <begin_vertex>
       float hgt = clamp(transformed.y / uHeight, 0.0, 1.0);
       transformed.x += sin(uTime*1.15 + transformed.y*1.7 + uPhase) * 0.30 * hgt * hgt;
       transformed.z += cos(uTime*0.85 + transformed.y*1.25 + uPhase) * 0.17 * hgt * hgt;`
    );
  };
  mat.customProgramCacheKey = () => 'weed';
  const m = new THREE.Mesh(makeSeaweedGeometry(h, 0.26 + Math.random()*0.16, 10, (Math.random()-0.5)*0.5), mat);
  m.position.set(s[0], 0, s[1]);
  m.rotation.y = Math.random()*6.28;
  m.castShadow = true;
  scene.add(m);
});

/* ---- water surface ---- */
const surface = new THREE.Mesh(
  new THREE.PlaneGeometry(TANK.w, TANK.d, 60, 30),
  new THREE.ShaderMaterial({
    uniforms: { uTime: timeU, uColor: { value: new THREE.Color(0x63d8ff) } },
    transparent: true, depthWrite: false, side: THREE.DoubleSide,
    vertexShader: `
      uniform float uTime; varying vec2 vUv; varying vec3 vPos;
      void main(){
        vUv = uv; vec3 p = position;
        p.z += sin(p.x*2.1 + uTime*1.4)*0.035 + sin(p.y*3.3 - uTime*1.0)*0.028;
        vPos = p;
        gl_Position = projectionMatrix * modelViewMatrix * vec4(p,1.0);
      }`,
    fragmentShader: `
      uniform float uTime; uniform vec3 uColor; varying vec2 vUv; varying vec3 vPos;
      void main(){
        float w = sin(vUv.x*46.0 + uTime*2.1)*0.5+0.5;
        float w2 = sin(vUv.y*37.0 - uTime*1.5)*0.5+0.5;
        float w3 = sin((vUv.x+vUv.y)*64.0 + uTime*3.0)*0.5+0.5;
        float a = 0.07 + 0.13*w*w2 + 0.05*w3;
        vec3 c = mix(uColor, vec3(1.0), w*0.35);
        gl_FragColor = vec4(c, a);
      }`
  })
);
surface.rotation.x = -Math.PI/2;
surface.position.y = WATER_Y;
scene.add(surface);

/* ---- glass + frame ---- */
const glass = new THREE.Mesh(
  new THREE.BoxGeometry(TANK.w, TANK.h, TANK.d),
  new THREE.MeshPhysicalMaterial({
    color: 0x9fd8e8, transparent: true, opacity: 0.075, roughness: 0.04, metalness: 0,
    side: THREE.DoubleSide, depthWrite: false, envMapIntensity: 1.5
  })
);
glass.position.y = TANK.h/2;
scene.add(glass);

const frame = new THREE.LineSegments(
  new THREE.EdgesGeometry(new THREE.BoxGeometry(TANK.w, TANK.h, TANK.d)),
  new THREE.LineBasicMaterial({ color: 0x9fd6e8, transparent: true, opacity: 0.5 })
);
frame.position.y = TANK.h/2;
scene.add(frame);

/* ---- cabinet ---- */
const cabinet = new THREE.Mesh(
  new THREE.BoxGeometry(TANK.w + 0.5, 1.6, TANK.d + 0.5),
  new THREE.MeshStandardMaterial({ color: 0x2b2119, roughness: 0.85, metalness: 0.05 })
);
cabinet.position.set(0, -0.82, 0);
cabinet.castShadow = true; cabinet.receiveShadow = true;
scene.add(cabinet);
const trim = new THREE.Mesh(
  new THREE.BoxGeometry(TANK.w + 0.62, 0.12, TANK.d + 0.62),
  new THREE.MeshStandardMaterial({ color: 0x4a3a28, roughness: 0.6, metalness: 0.2 })
);
trim.position.set(0, -0.06, 0);
scene.add(trim);

/* ---- floating motes ---- */
(function motes(){
  const N = 420;
  const pos = new Float32Array(N*3), seed = new Float32Array(N);
  for (let i = 0; i < N; i++){
    pos[i*3]   = (Math.random()-0.5)*TANK.w*0.96;
    pos[i*3+1] = Math.random()*WATER_Y;
    pos[i*3+2] = (Math.random()-0.5)*TANK.d*0.92;
    seed[i] = Math.random();
  }
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  g.setAttribute('aSeed', new THREE.BufferAttribute(seed, 1));
  const m = new THREE.ShaderMaterial({
    uniforms: { uTime: timeU, uPR: { value: Math.min(window.devicePixelRatio||1, 2) } },
    transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
    vertexShader: `
      uniform float uTime; uniform float uPR; attribute float aSeed; varying float vS;
      void main(){
        vS = aSeed;
        vec3 p = position;
        p.y = mod(p.y + uTime*(0.06+0.10*aSeed), ${WATER_Y.toFixed(2)});
        p.x += sin(uTime*0.28 + aSeed*11.0)*0.32;
        p.z += cos(uTime*0.22 + aSeed*7.0)*0.22;
        vec4 mv = modelViewMatrix * vec4(p,1.0);
        gl_PointSize = (1.4+2.6*aSeed) * uPR * (9.0 / max(0.001,-mv.z));
        gl_Position = projectionMatrix * mv;
      }`,
    fragmentShader: `
      varying float vS;
      void main(){
        float d = length(gl_PointCoord-0.5);
        float a = smoothstep(0.5,0.05,d) * (0.18+0.3*vS);
        gl_FragColor = vec4(vec3(0.72,0.92,1.0), a);
      }`
  });
  scene.add(new THREE.Points(g, m));
})();

/* ------------------------------------------------------------------ */
/*  fish                                                               */
/* ------------------------------------------------------------------ */
function makeTexture(spec){
  const W = 512, H = 256;
  const c = document.createElement('canvas'); c.width = W; c.height = H;
  const x = c.getContext('2d');
  const g = x.createLinearGradient(0, 0, 0, H);
  g.addColorStop(0, spec.top); g.addColorStop(0.52, spec.base); g.addColorStop(1, spec.bottom);
  x.fillStyle = g; x.fillRect(0, 0, W, H);
  for (let i = 0; i < 1600; i++){
    x.fillStyle = `rgba(0,0,0,${0.02 + Math.random()*0.05})`;
    x.beginPath(); x.arc(Math.random()*W, Math.random()*H, 1 + Math.random()*3, 0, 6.283); x.fill();
  }
  if (spec.tailColor){
    x.fillStyle = spec.tailColor;
    x.fillRect(0, 0, W, (1 - spec.tailV) * H);
  }
  if (spec.bands){
    for (const v of spec.bands){
      const y = (1 - v) * H, th = spec.bandThick || 20;
      x.fillStyle = spec.bandEdge;
      x.fillRect(0, y - th/2 - 5, W, th + 10);
      x.fillStyle = spec.bandColor;
      x.fillRect(0, y - th/2, W, th);
    }
  }
  const t = new THREE.CanvasTexture(c);
  t.colorSpace = THREE.SRGBColorSpace;
  t.anisotropy = 4;
  return t;
}

function makeProfile(kind){
  return (s) => {
    let r;
    if (kind === 'round') r = Math.pow(Math.sin(Math.PI * Math.pow(s, 0.80)), 0.62);
    else                r = Math.pow(Math.sin(Math.PI * Math.pow(s, 0.60)), 0.80);
    if (s > 0.82){ const t = (s - 0.82) / 0.18; r = r * (1 - t) + 0.045 * t; }
    return Math.max(r, 0.02);
  };
}

function makeBodyGeometry(spec){
  const segs = 26, radial = 14, L = 1.2;
  const prof = spec.profileFn;
  const positions = [], uvs = [], indices = [];
  for (let i = 0; i <= segs; i++){
    const s = i / segs;
    const r = prof(s);
    const h = r * spec.h, w = r * spec.w;
    const z = L * (0.5 - s);
    for (let j = 0; j <= radial; j++){
      const th = (j / radial) * Math.PI * 2;
      positions.push(Math.cos(th) * w, Math.sin(th) * h, z);
      uvs.push(j / radial, s);
    }
  }
  for (let i = 0; i < segs; i++){
    for (let j = 0; j < radial; j++){
      const a = i * (radial + 1) + j, b = a + radial + 1;
      indices.push(a, b, a + 1, b, b + 1, a + 1);
    }
  }
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
  g.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
  g.setIndex(indices);
  g.computeVertexNormals();
  return g;
}

function makeFinGeometry(spec){
  const p = [];
  const tri = (a, b, c) => { p.push(a[0],a[1],a[2], b[0],b[1],b[2], c[0],c[1],c[2]); };
  const H = spec.h, W = spec.w;
  // caudal
  tri([0, H*0.85, -0.56], [0, -H*0.85, -0.56], [0, 0, -1.02]);
  tri([0, H*0.85, -0.56], [0, 0, -0.74], [0, -H*0.2, -0.60]);
  // dorsal
  tri([0, H*0.80, 0.16], [0, H*0.72, -0.26], [0, H*1.55, -0.10]);
  // anal
  tri([0, -H*0.78, 0.02], [0, -H*0.66, -0.30], [0, -H*1.28, -0.14]);
  // pectorals
  tri([W*0.9, -0.02, 0.10], [W*0.9, -0.02, -0.12], [W*2.6, -0.14, -0.06]);
  tri([-W*0.9, -0.02, 0.10], [-W*2.6, -0.14, -0.06], [-W*0.9, -0.02, -0.12]);
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.Float32BufferAttribute(p, 3));
  g.computeVertexNormals();
  return g;
}

function injectUndulate(mat, uni){
  mat.onBeforeCompile = (sh) => {
    sh.uniforms.uTime = uni.uTime;
    sh.uniforms.uPhase = uni.uPhase;
    sh.uniforms.uFreq = uni.uFreq;
    sh.uniforms.uWave = uni.uWave;
    sh.vertexShader =
      'uniform float uTime; uniform float uPhase; uniform float uFreq; uniform float uWave;\n' +
      sh.vertexShader.replace('#include <begin_vertex>',
        `#include <begin_vertex>
         float s = clamp((0.55 - transformed.z) / 1.5, 0.0, 1.0);
         float amp = uWave * pow(s, 1.7);
         transformed.x += sin(uTime * uFreq - s * 4.2 + uPhase) * amp;`);
  };
  mat.customProgramCacheKey = () => 'fish-undulate';
}

const SPECIES = [
  { name:'clown', base:'#ff7d24', top:'#ffa24a', bottom:'#d9520d', fin:'#ffb257',
    bands:[0.17,0.50,0.84], bandColor:'#ffffff', bandEdge:'#20140c', bandThick:22,
    profileFn: makeProfile('long'), h:0.30, w:0.155, min:0.55, max:0.86 },
  { name:'tang', base:'#ffc42e', top:'#ffdf74', bottom:'#ee9c12', fin:'#ffe28f',
    profileFn: makeProfile('round'), h:0.34, w:0.105, min:0.60, max:0.98 },
  { name:'cardinal', base:'#e8452f', top:'#ff6f52', bottom:'#a9271a', fin:'#ff9377',
    bands:[0.32,0.64], bandColor:'#ffdcc6', bandEdge:'rgba(0,0,0,0.35)', bandThick:12,
    profileFn: makeProfile('long'), h:0.26, w:0.110, min:0.48, max:0.78 },
  { name:'blue', base:'#2f6fd0', top:'#5a9bff', bottom:'#1b4489', fin:'#ffd23f',
    tailColor:'#ffd23f', tailV:0.76,
    profileFn: makeProfile('round'), h:0.30, w:0.100, min:0.55, max:0.92 },
];
SPECIES.forEach(s => { s.texture = makeTexture(s); s.bodyGeo = makeBodyGeometry(s); s.finGeo = makeFinGeometry(s); });

const fishes = [];
const dummy = new THREE.Object3D();
const tmpA = new THREE.Vector3(), tmpB = new THREE.Vector3(), tmpC = new THREE.Vector3(), tmpD = new THREE.Vector3();
const qRoll = new THREE.Quaternion(), zAxis = new THREE.Vector3(0,0,1);

function spawnFish(speciesIndex){
  const spec = SPECIES[speciesIndex];
  const uni = {
    uTime:  { value: Math.random()*10 },
    uPhase: { value: Math.random()*6.283 },
    uFreq:  { value: 5.6 + Math.random()*1.8 },
    uWave:  { value: 0.085 }
  };
  const bodyMat = new THREE.MeshStandardMaterial({
    map: spec.texture, roughness: 0.42, metalness: 0.06, side: THREE.DoubleSide, envMapIntensity: 0.9
  });
  const finMat = new THREE.MeshStandardMaterial({
    color: spec.fin, roughness: 0.45, metalness: 0.05, side: THREE.DoubleSide,
    transparent: true, opacity: 0.94, depthWrite: true
  });
  injectUndulate(bodyMat, uni); injectUndulate(finMat, uni);

  const depthMat = new THREE.MeshDepthMaterial({ depthPacking: THREE.RGBADepthPacking });
  injectUndulate(depthMat, uni);

  const group = new THREE.Group();
  const body = new THREE.Mesh(spec.bodyGeo, bodyMat);
  body.castShadow = true; body.customDepthMaterial = depthMat;
  const fins = new THREE.Mesh(spec.finGeo, finMat);
  fins.castShadow = true; fins.customDepthMaterial = depthMat;
  group.add(body, fins);
  scene.add(group);

  const size = spec.min + Math.random() * (spec.max - spec.min);
  group.scale.setScalar(size);

  const f = {
    spec, group, uni, size,
    pos: new THREE.Vector3(),
    vel: new THREE.Vector3(),
    wanderTarget: new THREE.Vector3(),
    wanderT: Math.random()*3,
    panic: 0,
    roll: 0,
    prevDir: new THREE.Vector3(0,0,1),
    phase: Math.random()*6.283
  };
  resetFish(f, true);
  fishes.push(f);
  return f;
}

function resetFish(f, randomPos){
  if (randomPos){
    f.pos.set((Math.random()-0.5)*8.4, 0.9 + Math.random()*3.6, (Math.random()-0.5)*3.6);
  }
  const dir = new THREE.Vector3(Math.random()-0.5, (Math.random()-0.5)*0.25, Math.random()-0.5).normalize();
  f.vel.copy(dir).multiplyScalar(1.2);
  f.wanderTarget.set((Math.random()-0.5)*8.0, 0.9 + Math.random()*3.5, (Math.random()-0.5)*3.4);
  f.wanderT = 1.5 + Math.random()*3;
  f.panic = 0; f.roll = 0;
  f.group.position.copy(f.pos);
  f.prevDir.copy(dir);
}

function removeFish(){
  const f = fishes.pop();
  if (!f) return;
  scene.remove(f.group);
  f.group.traverse(o => { if (o.isMesh){ o.material.dispose(); if (o.customDepthMaterial) o.customDepthMaterial.dispose(); } });
}

/* ------------------------------------------------------------------ */
/*  pointer / disturbance                                              */
/* ------------------------------------------------------------------ */
const pointer = {
  mode: 'auto',                 // 'auto' | 'real'
  ndc: new THREE.Vector2(),
  point: new THREE.Vector3(),
  has: false,
  lastMove: -999,
  autoOn: true
};
const raycaster = new THREE.Raycaster();
const ray = raycaster.ray;

/* cursor orb + rings */
const orb = new THREE.Mesh(
  new THREE.SphereGeometry(0.11, 20, 14),
  new THREE.MeshBasicMaterial({ color: 0xbdf3ff, transparent: true, opacity: 0.9 })
);
scene.add(orb);
const orbLight = new THREE.PointLight(0x9fe9ff, 6, 4.5, 2);
scene.add(orbLight);
const orbHalo = new THREE.Mesh(
  new THREE.SphereGeometry(0.3, 18, 12),
  new THREE.MeshBasicMaterial({ color: 0x7fe9ff, transparent: true, opacity: 0.13, blending: THREE.AdditiveBlending, depthWrite: false })
);
scene.add(orbHalo);

const ringGeo = new THREE.RingGeometry(0.55, 0.72, 40);
const rings = [];
for (let i = 0; i < 10; i++){
  const m = new THREE.Mesh(ringGeo, new THREE.MeshBasicMaterial({
    color: 0x9ff0ff, transparent: true, opacity: 0, side: THREE.DoubleSide,
    blending: THREE.AdditiveBlending, depthWrite: false
  }));
  m.visible = false;
  scene.add(m);
  rings.push({ mesh: m, life: 0, dur: 1 });
}
function ping(pos, scale = 1){
  const r = rings.find(r => r.life <= 0);
  if (!r) return;
  r.life = 1; r.dur = 1.1; r.scale = scale;
  r.mesh.position.copy(pos);
  r.mesh.visible = true;
}

/* bubbles */
const bubbleGeo = new THREE.SphereGeometry(0.05, 10, 8);
const bubbleMat = new THREE.MeshPhysicalMaterial({
  color: 0xffffff, roughness: 0.05, metalness: 0, transparent: true, opacity: 0.35,
  transmission: 0.6, thickness: 0.1, depthWrite: false
});
const bubbles = [];
for (let i = 0; i < 34; i++){
  const m = new THREE.Mesh(bubbleGeo, bubbleMat);
  m.visible = false;
  scene.add(m);
  bubbles.push({ mesh: m, life: 0, x: 0, y: 0, z: 0, sp: 0.4, ph: Math.random()*6.28, r: 0.5 });
}
function spawnBubble(x, y, z, r){
  const b = bubbles.find(b => b.life <= 0);
  if (!b) return;
  b.life = 1; b.x = x; b.y = y; b.z = z; b.r = r;
  b.sp = 0.5 + Math.random()*0.7;
  b.ph = Math.random()*6.28;
  b.mesh.visible = true;
}

/* ------------------------------------------------------------------ */
/*  input                                                              */
/* ------------------------------------------------------------------ */
const overlay = document.getElementById('overlay');
let started = false;

function startDemo(){
  if (started) return;
  started = true;
  overlay.classList.add('hide');
  pointer.mode = 'real';
  pointer.has = false;
}
overlay.addEventListener('click', startDemo);
window.addEventListener('keydown', startDemo);

window.addEventListener('pointermove', (e) => {
  pointer.ndc.x = (e.clientX / window.innerWidth) * 2 - 1;
  pointer.ndc.y = -(e.clientY / window.innerHeight) * 2 + 1;
  pointer.mode = 'real';
  pointer.has = true;
  pointer.lastMove = performance.now();
});
window.addEventListener('pointerdown', (e) => {
  pointer.ndc.x = (e.clientX / window.innerWidth) * 2 - 1;
  pointer.ndc.y = -(e.clientY / window.innerHeight) * 2 + 1;
  pointer.mode = 'real'; pointer.has = true; pointer.lastMove = performance.now();
});

/* ------------------------------------------------------------------ */
/*  HUD                                                                */
/* ------------------------------------------------------------------ */
const popCountEl = document.getElementById('popCount');
const fleeCountEl = document.getElementById('fleeCount');
const calmBarEl = document.getElementById('calmBar');
const popLabelEl = document.getElementById('popLabel');
const autoBtn = document.getElementById('autoBtn');

const TARGET_POP = 14;
function setPopulation(n){
  n = Math.max(2, Math.min(28, n));
  while (fishes.length < n) spawnFish(Math.floor(Math.random()*SPECIES.length));
  while (fishes.length > n) removeFish();
  popCountEl.textContent = fishes.length;
  popLabelEl.textContent = fishes.length + (fishes.length === 1 ? ' fish' : ' fish');
}
document.getElementById('more').addEventListener('click', () => setPopulation(fishes.length + 1));
document.getElementById('less').addEventListener('click', () => setPopulation(fishes.length - 1));
document.getElementById('reset').addEventListener('click', () => {
  fishes.forEach(f => resetFish(f, true));
});
autoBtn.addEventListener('click', () => {
  pointer.autoOn = !pointer.autoOn;
  autoBtn.classList.toggle('on', pointer.autoOn);
});
document.getElementById('startle').addEventListener('click', () => {
  const p = new THREE.Vector3((Math.random()-0.5)*6, 1.5 + Math.random()*2.5, (Math.random()-0.5)*3);
  ping(p, 2.4);
  for (const f of fishes){
    const d = tmpA.copy(f.pos).sub(p);
    if (d.lengthSq() < 0.001) d.set(1,0,0);
    d.normalize();
    f.vel.addScaledVector(d, 5.5);
    f.panic = 1;
  }
  for (let i = 0; i < 14; i++) spawnBubble(p.x + (Math.random()-0.5), p.y + (Math.random()-0.5)*0.6, p.z + (Math.random()-0.5), 1);
});
setPopulation(TARGET_POP);

/* ------------------------------------------------------------------ */
/*  main loop                                                          */
/* ------------------------------------------------------------------ */
const autoPos = new THREE.Vector3();
const clockT = new THREE.Clock();
let elapsed = 0;

function updatePointer(t){
  const idle = (performance.now() - pointer.lastMove) / 1000;
  const useAuto = pointer.autoOn && (!pointer.has || idle > 2.6);

  if (useAuto){
    pointer.mode = 'auto';
    autoPos.set(
      Math.sin(t * 0.42) * 4.1 + Math.sin(t * 0.23) * 1.1,
      2.5 + Math.sin(t * 0.61) * 1.55,
      Math.cos(t * 0.33) * 1.9 + Math.sin(t * 0.17) * 0.7
    );
    pointer.point.copy(autoPos);
  } else {
    pointer.mode = 'real';
    raycaster.setFromCamera(pointer.ndc, camera);
    // closest point to the tank centre along the ray, for the visual marker
    const plane = tmpA.set(0,0,1);
    const hit = new THREE.Vector3();
    if (raycaster.ray.intersectPlane(new THREE.Plane(plane, 0), hit)){
      hit.x = THREE.MathUtils.clamp(hit.x, -5.4, 5.4);
      hit.y = THREE.MathUtils.clamp(hit.y, 0.4, 5.1);
      hit.z = 0;
      pointer.point.copy(hit);
    }
  }
  orb.position.copy(pointer.point);
  orbHalo.position.copy(pointer.point);
  orbLight.position.copy(pointer.point);
  const pulse = 1 + 0.12*Math.sin(t*4.0);
  orb.scale.setScalar(pulse);
  orbHalo.scale.setScalar(1 + 0.18*Math.sin(t*2.6));
}

function updateFish(f, dt, t){
  const p = f.pos, v = f.vel;
  const acc = tmpD.set(0, 0, 0);

  /* wander */
  f.wanderT -= dt;
  tmpA.copy(f.wanderTarget).sub(p);
  if (f.wanderT <= 0 || tmpA.length() < 0.9){
    f.wanderT = 1.8 + Math.random() * 3.2;
    f.wanderTarget.set((Math.random()-0.5)*8.2, 0.95 + Math.random()*3.4, (Math.random()-0.5)*3.4);
    tmpA.copy(f.wanderTarget).sub(p);
  }
  if (tmpA.lengthSq() > 1e-6) acc.addScaledVector(tmpA.normalize(), 0.95);

  /* separation */
  for (let i = 0; i < fishes.length; i++){
    const g = fishes[i];
    if (g === f) continue;
    tmpB.copy(p).sub(g.pos);
    const l = tmpB.length();
    if (l < 1.15 && l > 1e-4) acc.addScaledVector(tmpB.divideScalar(l), (1.15 - l) * 1.9);
  }

  /* walls */
  const push = (val, limit, dir) => {
    const d = dir * (limit - val);
    return d > 0 ? d * 2.6 : 0;
  };
  if (p.x >  BOUND.x - 1.0) acc.x -= (p.x - (BOUND.x - 1.0)) * 2.6;
  if (p.x < -BOUND.x + 1.0) acc.x += ((-BOUND.x + 1.0) - p.x) * 2.6;
  if (p.y >  BOUND.yMax - 0.6) acc.y -= (p.y - (BOUND.yMax - 0.6)) * 3.0;
  if (p.y <  BOUND.yMin + 0.5) acc.y += ((BOUND.yMin + 0.5) - p.y) * 3.4;
  if (p.z >  BOUND.z - 0.6) acc.z -= (p.z - (BOUND.z - 0.6)) * 2.6;
  if (p.z < -BOUND.z + 0.6) acc.z += ((-BOUND.z + 0.6) - p.z) * 2.6;

  /* flee the pointer */
  let fdist = Infinity, dirx = 0, diry = 0, dirz = 0;
  if (pointer.mode === 'auto'){
    tmpB.copy(p).sub(pointer.point);
    fdist = tmpB.length();
    if (fdist > 1e-4){ dirx = tmpB.x/fdist; diry = tmpB.y/fdist; dirz = tmpB.z/fdist; }
  } else {
    tmpB.copy(p).sub(ray.origin);
    let tp = tmpB.dot(ray.direction);
    if (tp < 0) tp = 0;
    tmpC.copy(ray.direction).multiplyScalar(tp).add(ray.origin);
    tmpB.copy(p).sub(tmpC);
    fdist = tmpB.length();
    if (fdist > 1e-4){ dirx = tmpB.x/fdist; diry = tmpB.y/fdist; dirz = tmpB.z/fdist; }
  }
  const R = 2.05;
  if (fdist < R){
    const k = 1 - fdist / R;
    const s = k * k * 15.0;
    acc.x += dirx * s; acc.y += diry * s; acc.z += dirz * s;
    if (f.panic < k) f.panic = k;
    if (Math.random() < dt * 5 * k){
      spawnBubble(p.x + (Math.random()-0.5)*0.3, p.y - 0.15, p.z + (Math.random()-0.5)*0.3, 0.6 + Math.random()*0.8);
      if (Math.random() < 0.25) ping(tmpC.copy(p), 0.7);
    }
  }

  /* integrate */
  v.addScaledVector(acc, dt);
  f.panic = Math.max(0, f.panic - dt * 0.85);
  const maxS = 1.75 + f.panic * 3.6;
  const minS = 0.85;
  let sp = v.length();
  if (sp > maxS) v.multiplyScalar(maxS / sp);
  else if (sp < minS && sp > 1e-5) v.multiplyScalar(minS / sp);
  else if (sp <= 1e-5) v.set(0.9, 0, 0);

  p.addScaledVector(v, dt);
  p.x = THREE.MathUtils.clamp(p.x, -BOUND.x, BOUND.x);
  p.y = THREE.MathUtils.clamp(p.y, BOUND.yMin, BOUND.yMax);
  p.z = THREE.MathUtils.clamp(p.z, -BOUND.z, BOUND.z);

  /* orientation */
  tmpA.copy(v).normalize();
  if (Math.abs(tmpA.y) > 0.97) tmpA.x += 0.08;
  tmpA.normalize();
  dummy.position.copy(p);
  dummy.lookAt(p.x + tmpA.x, p.y + tmpA.y, p.z + tmpA.z);
  f.group.quaternion.slerp(dummy.quaternion, 1 - Math.pow(0.0006, dt));

  /* banking */
  const turn = f.prevDir.x * tmpA.z - f.prevDir.z * tmpA.x;
  f.roll += (THREE.MathUtils.clamp(-turn * 5.0, -0.55, 0.55) - f.roll) * Math.min(1, dt * 5);
  qRoll.setFromAxisAngle(zAxis, f.roll);
  f.group.quaternion.multiply(qRoll);
  f.prevDir.copy(tmpA);

  /* tail beat */
  const speed = v.length();
  f.uni.uTime.value += dt * (1.9 + speed * 1.05);
  f.uni.uWave.value = 0.075 + f.panic * 0.055;

  f.group.position.copy(p);
}

function animate(){
  requestAnimationFrame(animate);
  const dt = Math.min(clockT.getDelta(), 0.05);
  elapsed += dt;
  timeU.value = elapsed;

  updatePointer(elapsed);

  for (const f of fishes) updateFish(f, dt, elapsed);

  /* bubbles */
  for (const b of bubbles){
    if (b.life <= 0) continue;
    b.life -= dt * 0.22;
    b.y += b.sp * dt;
    b.x += Math.sin(elapsed * 2.2 + b.ph) * 0.012;
    b.z += Math.cos(elapsed * 1.8 + b.ph) * 0.012;
    const s = b.r * (0.7 + 0.3*Math.sin(elapsed*4 + b.ph));
    b.mesh.position.set(b.x, b.y, b.z);
    b.mesh.scale.setScalar(s);
    if (b.y > WATER_Y - 0.05 || b.life <= 0){ b.life = 0; b.mesh.visible = false; }
  }
  /* occasional ambient bubbles */
  if (Math.random() < dt * 2.2){
    const x = (Math.random()-0.5)*10, z = (Math.random()-0.5)*4.4;
    spawnBubble(x, 0.2, z, 0.5 + Math.random()*0.6);
  }

  /* rings */
  for (const r of rings){
    if (r.life <= 0){ r.mesh.visible = false; continue; }
    r.life -= dt / r.dur;
    const k = 1 - Math.max(r.life, 0);
    r.mesh.scale.setScalar((0.4 + k * 2.4) * (r.scale || 1));
    r.mesh.material.opacity = Math.max(r.life, 0) * 0.6;
    r.mesh.quaternion.copy(camera.quaternion);
  }

  /* gentle camera parallax */
  const px = pointer.mode === 'real' ? pointer.ndc.x : Math.sin(elapsed * 0.17) * 0.6;
  const py = pointer.mode === 'real' ? pointer.ndc.y : Math.sin(elapsed * 0.13) * 0.35;
  camera.position.x += (camBase.x + px * 0.55 - camera.position.x) * Math.min(1, dt * 2.2);
  camera.position.y += (camBase.y + py * 0.32 - camera.position.y) * Math.min(1, dt * 2.2);
  camera.position.z += (camBase.z - camera.position.z) * Math.min(1, dt * 2.2);
  camLook.set(px * 0.18, 2.55 + py * 0.10, 0);
  camera.lookAt(camLook);

  /* HUD */
  let fleeing = 0, calm = 0;
  for (const f of fishes){ if (f.panic > 0.25) fleeing++; calm += 1 - f.panic; }
  calm = fishes.length ? calm / fishes.length : 1;
  fleeCountEl.textContent = fleeing;
  calmBarEl.style.width = (calm * 100).toFixed(0) + '%';

  renderer.render(scene, camera);
}

/* ------------------------------------------------------------------ */
/*  resize                                                             */
/* ------------------------------------------------------------------ */
function resize(){
  const w = window.innerWidth, h = window.innerHeight;
  renderer.setSize(w, h, false);
  camera.aspect = w / h;
  const portrait = camera.aspect < 1.0;
  camBase.set(0, portrait ? 2.95 : 2.75, portrait ? 11.4 : 11.0);
  camera.updateProjectionMatrix();
}
window.addEventListener('resize', resize);
resize();

/* auto-pilot marker note + start */
setTimeout(() => { if (!started) overlay.classList.add('mini'); }, 5200);
animate();
</script>
</body>
</html>
Reasoning trace (23,077 tokens)
Let me build a 3D aquarium with Three.js. Features:

- Glass tank, water volume (fog or translucent box), sand floor, rocks, plants (animated seaweed), bubbles.
- Fish with swim animation (tail wiggle via vertex manipulation or simple bone-free approach using groups of cones/spheres).
- Mouse moves create a "repulsion" force on fish; fish steer away from the cursor ray.
- UI: overlay panel with fish count, instructions, maybe a toggle to add fish, and pointer "sonar" ring indicator.
- Demo mode: fish swim on their own, and there's an AI "mouse" that moves around when idle so the effect is visible without interaction. Requirement: games must start in self-playing demo mode behind "Click or press a key to start". This is a demo/scene, not exactly a game, but let's include an idle autopilot that moves a virtual pointer around so fish flee. And a click-to-start overlay that when dismissed enables actual pointer control.

Let me design the fish geometry: use a lathe or custom shape. Simplest good-looking: build fish from a stretched sphere (SphereGeometry scaled) for body + tail cone + fins, merged into a group; animate tail by rotating tail mesh with sine oscillation. For swimming motion, use a shader? Simpler: rotate tail fin group around Y axis with sine of time and phase. Also body slight sine bend via scaling? Keep it simple: body group with tail rotation.

Better: use a custom BufferGeometry for the fish body generated procedurally with per-vertex wiggle in vertex shader (onBeforeCompile) — gives nice undulation. Let's do a modified shader: in vertex shader, offset x by sin(y*k + time) * amp * (smoothstep along body). Use MeshStandardMaterial with onBeforeCompile injecting.

That's fairly doable. Let's write:

```js
const fishMat = new THREE.MeshStandardMaterial({color: 0xff8844, roughness:0.5, metalness:0.1});
fishMat.onBeforeCompile = (shader) => {
  shader.uniforms.uTime = {value:0};
  shader.uniforms.uPhase = {value: 0};
  shader.vertexShader = 'uniform float uTime;\nuniform float uPhase;\n' + shader.vertexShader;
  shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>', `
    #include <begin_vertex>
    float t = clamp((transformed.y + 0.5) / 1.0, 0.0, 1.0); // body param from head(0) to tail(1)
    ...
  `);
```
Hmm, need a coordinate convention. Let's define fish geometry along +Z? Three.js objects usually face -Z. Let's define the fish body along X axis for easy math? Let's just build custom geometry: an ellipsoid-ish body where length runs along Z from z=+0.6 (nose) to z=-0.6 (tail), height y, width x. Then undulation: displace x by sin(z*w - t*speed + phase) * amp * clamp((0.6 - z)/1.2).

For tail wiggle: we can build the tail as part of the same geometry, so the undulation covers it.

Alternatively simpler & robust: build fish from several meshes (body ellipsoid, dorsal fin, tail fin plane) and animate tail by rotating. Less risk. But the shader approach gives a nicer look. I'll do a hybrid: custom body geometry generated via parametric sphere deformation (so it's a smooth fish shape, tapering to tail), plus separate tail fin mesh that we rotate... Actually undulation shader handles tail if tail is in same geometry.

Let's generate fish body geometry procedurally:

Parametric: for i in 0..N (along length s from 0 at nose to 1 at tail), radius profile r(s) = sin(pi * s^0.7) type shape tapering. Use a loft: rings of vertices around the axis. Then nose at s=0 radius 0, tail at s=1 radius small but nonzero connecting to caudal fin.

Caudal fin: triangle fan in the XZ? Caudal fin is vertical (in the YZ plane, extending in -Z). Create a small custom geometry: vertices (0,0.25,-0.6), (0,-0.25,-0.6), (0,0,-1.0). Actually with undulation shader on the same material, the fin will also wiggle — fine if we apply same transform in shader based on z position.

So: create a single merged geometry: body loft + caudal fin, with attributes. Apply shader undulation based on z.

Fish orientation: swim heading computed from velocity; set group.lookAt? Use quaternion from direction vector: set group.rotation.y = atan2(dir.x, dir.z)... Let's use: object.lookAt(pos + dir) but lookAt points -Z toward target in Three.js (cameras look -Z, but Object3D.lookAt makes +Z? Actually Object3D.lookAt rotates the object so that its +Z axis points at the target — wait, for non-camera objects, lookAt makes the object's +Z axis point toward target? Let's check: Three.js Object3D.lookAt uses `m.lookAt(eye, target, up)` with eye = position, target... `Matrix4.lookAt(eye, target, up)` builds a matrix where -Z? Actually Matrix4.lookAt constructs z axis = normalize(eye - target), so +Z points away from target. Then for cameras, they use lookAt with reversed? In Three.js, Object3D.lookAt: `if (this.isCamera || this.isLight) m1.lookAt(position, target, up) else m1.lookAt(target, position, up)`. So for regular objects, m1.lookAt(target, position, up) → z = normalize(target - position) so +Z points at target. Hmm, and Object3D default forward for non-camera... Many examples use lookAt and the object's +Z faces the target. So if fish nose is at +Z, lookAt works with nose toward target. But I want nose at -Z? Let's define fish nose at +Z and tail at -Z, then lookAt(pos+vel) points +Z toward movement direction.

Undulation shader displacement based on z: displacement in x = sin(z*freq - t*speed)*amp * factor(z) where factor increases toward tail (z negative). Let's define s = (0.6 - z)/1.2 in [0,1] head→tail. displacement = sin(-s*5.0 + time*8 + phase) * 0.12 * s^1.5.

Also yaw the head slightly? Fine.

Boid-ish behavior: each fish has position, velocity, and steering behaviors:
- wander: random target direction changes periodically
- avoid walls (tank bounds)
- avoid pointer: if pointer ray is close to fish, apply strong repulsion
- separation from other fish (keep distance)
- attraction to plants/rocks? optional
- speed clamp; smooth turn via quaternion slerp or lerp of direction vector.

Pointer interaction: Use raycaster from camera through mouse; compute closest point on ray to each fish; if distance < radius (say 1.2 units), fish flees. Also show a "ripple"/glow sphere at ray point and a subtle sonar ring.

Also should have the pointer ray visible as a subtle light cone? Maybe show a "hover glow" sphere at intersection with tank walls.

Tank: glass box with transparent material, water surface plane with animated ripples (shader), sand at bottom with slight displacement, rocks (icosahedron deformed), seaweed (tube geometry animated by shader sway), bubbles rising (small spheres with transparent material, drifting), caustics? Could add a simple caustic light pattern via animated texture on sand — generating a canvas texture with noise. Let's do a canvas-based caustic texture animated (offset & scale) — nice touch, cheap.

Water fog: scene.fog = new THREE.FogExp2(0x0a2a3a, 0.06) to give depth.

Lighting: hemisphere + directional with shadows? Shadows add cost but fine. Use a spotlight from above with shadow map for fish and rocks onto sand. Let's enable shadowMap PCFSoft, spotlight casting shadows, fish cast shadows. Keep 1024 map.

UI overlay: title "AQUARIUM — 3D", instructions "Move your cursor near the fish and they'll scatter", stats: fish count, calmness meter. Buttons: add fish, toggle autopilot (demo pointer), change water color? Keep minimal but polished.

Auto-pilot: when no mouse movement for 2.5s, a virtual pointer moves along a lissajous path in the tank, fish flee from it. Show hint "Auto-pilot active". When user moves mouse, autopilot pauses.

Start overlay: "Click or press a key to start" — per instructions. But also requirement: "Something meaningful must be visible on first paint". The overlay should be semi-transparent so the aquarium is visible behind it, and the fish should already be swimming (demo mode running behind overlay). Then clicking dismisses overlay and enables mouse interaction.

Hmm, "The iframe only receives keyboard events after the user clicks it" — fine.

Make sure fish are swimming in the background even before start, so screenshot at 3s shows swimming fish + overlay hint. The overlay should be small/elegant, not blocking the whole view.

Let's implement the responsive layout: at 375px, the info panel collapses to smaller text at bottom.

Scene composition:
- Camera at (0, 2.2, 8.5) looking at (0, 1.2, 0), PerspectiveCamera fov 45.
- Tank dims: width 12 (x -6..6), height 6 (y 0..6), depth 6 (z -3..3). Sand at y=0.
- Glass walls: BoxGeometry(12,6,6) with MeshPhysicalMaterial transparent, roughness 0.05, transmission? Transmission is expensive-ish but okay for 1 object. Use simpler: MeshPhysicalMaterial with transparent opacity 0.12, side: DoubleSide, plus edges lines for the frame. Add rim: use envmap? Without envmap, glass looks flat. Use a simple gradient env via PMREMGenerator with RoomEnvironment (addon available at three/addons/environments/RoomEnvironment.js). That gives nice reflections. Let's include RoomEnvironment for scene.environment — good lighting for fish and glass.

But careful: importing addons is allowed via import map ("three/addons/"). Yes.

- Water volume: a box slightly smaller with a blue translucent material, front face? Might obscure fish. Instead use fog + water surface plane at top with animated normals + a subtle blue tint box with side: BackSide and low opacity? Let's do a large Box with material transparent opacity 0.06, color light blue, depthWrite false, side FrontSide — adds a blue wash.

- Water surface: PlaneGeometry rotated -PI/2 at y=5.9 with custom shader showing ripples and a bright edge, semi-transparent.

- Sand: PlaneGeometry(12,6, 60,30) rotated, vertices displaced with noise (simple sin-based), MeshStandardMaterial color 0xd9c9a3, receive shadows. Add a caustics texture overlay as a second plane just above with additive blending and animated canvas texture.

- Rocks: 3-4 icosahedron/dodecahedron meshes deformed, dark gray/brown, cast shadows.

- Seaweed: several ribbon planes (PlaneGeometry 0.3 x 2, segments) with shader sway; double-sided green material. Or use TubeGeometry curves. Let's use custom plane geometry with vertices along height, animate in vertex shader with sway = sin(time + y*k + phase)*amp*(y/height)^2.

- Coral: a couple of branching structures? Maybe skip; add anemone-ish cylinders.

- Bubbles: 20 small spheres rising from a spot, with slight wobble; use Points? Use instanced spheres (InstancedMesh) with transparent material.

- Fish: two species (clownfish-ish orange with white stripes, and a blue tang). Simpler: create per-fish body geometry with per-vertex color stripes? Let's create two variants of geometry: body with stripe pattern via vertex colors or texture. Use canvas texture for stripes mapped onto body geometry. UV mapping on custom loft geometry: u around circumference, v along length. Then canvas texture with stripes works.

I'll create the body geometry with UVs: u = angle/2pi, v = s.

Fish geometry generation:

```js
function makeFishGeometry(opts){
  const segs = 24; // along length
  const radial = 12;
  // profile: height h(s), width w(s)
  ...
}
```
Profile: define using a function: 
h(s) = H * Math.pow(Math.sin(Math.PI * Math.pow(s,0.55)), 0.9) ... need tail end small but not zero. Let's define:
base = Math.sin(Math.PI * s) for 0..1 → 0 at both ends. Then add tail: for s>0.8, keep a thin peduncle.
Let me define explicit profile function:

```js
function profile(s){ // s 0 nose -> 1 tail end
  // body bulge
  const b = Math.sin(Math.PI * Math.pow(s, 0.62)) ; // peaks around s~0.3
  const bulge = Math.pow(Math.max(b,0), 0.8);
  let r = bulge;
  if (s > 0.78) {
    const t = (s-0.78)/0.22;
    r = r*(1-t) + 0.06*t; // thin peduncle
  }
  return r;
}
```
Hmm, at s=0 r=0 → nose point; at s=1 r=0.06 → tail thin, then caudal fin attached.

Height and width: h(s) = r * heightScale, w(s) = r * widthScale. Use heightScale 0.55 (tall fish) and width 0.32 relative to length 1.2.

Also belly rounding differs but fine.

Build vertices: for i in 0..segs, s=i/segs, ring of radial+1 vertices (to have seam duplicated for UV). Center axis at z = L*(0.5 - s) where L=1.2 → nose z=+0.6, tail z=-0.6.

x = w(s)*cos(theta), y = h(s)*sin(theta) with theta from 0..2pi. Add slight belly offset: y += 0.05*(1-s)? whatever.

Indices: quads between rings.

Nose cap: at s=0 r=0 so ring degenerate — fine (vertices coincide). Same for tail.

Caudal fin: separate small geometry (triangle) merged into the same geometry buffer so the shader displaces it too, but its UV/positions: it's at z from -0.6 to -1.0, y from -0.3 to +0.3, x=0. Add as extra triangles with proper normals (double side). We can create a separate mesh with same material and merge via BufferGeometryUtils.mergeGeometries (addon available: three/addons/utils/BufferGeometryUtils.js). Use that.

Actually simpler: build a single BufferGeometry manually including fin triangles. But merging is easy. Let's use BufferGeometryUtils.mergeGeometries([body, fin]) — need both have position, normal, uv attributes. Fine.

Pectoral/dorsal fins: small triangles in the same merged geometry with color? They would need different color; use vertex colors to differentiate (fin color darker). Let's add vertex colors: body colors via canvas texture, fin with vertex color... mixing texture and vertex color multiplies. Simpler: keep texture with stripes; fins get stripes too but UV mapped weirdly. Alternatively set fin UVs to a region of texture that's a plain color. Since texture is stripes along u/v, mapping fin uv to v near 0.5 region where stripe exists... eh.

Alternative approach: Use two materials in a group: body mesh (textured) + fin mesh (solid color material, but then undulation shader must apply to both — same shader code, so both need onBeforeCompile). That's fine: apply the same onBeforeCompile to both materials with shared uniforms object.

Let's do: create a function makeFishMaterial(color, stripes) returning material with shared uniforms {uTime}. Use onBeforeCompile on both body material and fin material, with the same injected code and shared uniform objects so they stay in sync.

Simplify further: use a single material with a canvas texture for the body including stripes and fin colors via UV mapping to specific texture areas. Hmm, complexity.

Decision: two meshes in fish group:
- bodyMesh: geometry from loft, material A (textured stripes).
- finMesh: merged fin geometry, material B (solid color).
Both materials get the same undulation injection with shared uniform objects (uTime, uSpeed, uPhase per fish — need per-fish phase! Materials are shared across fish... phase differs per fish).

Problem: shared material → shared phase. Solution: use per-fish material clones (cheap enough for ~14 fish: 28 materials). Or use instance attributes. Cloning is simplest: create material per fish via a factory that creates new MeshStandardMaterial and onBeforeCompile with its own uniforms object. Then animate material.userData.uniforms.uTime.value each frame. Fine, 14 fish * 2 materials = 28 shaders? Each unique material compiles its own program... but program cache: onBeforeCompile changes the shader source so cache key differs per material? Three.js program cache key includes material properties but custom program cache key defaults... For onBeforeCompile, the material's customProgramCacheKey is needed to differentiate. Materials with same type+params share program only if cache key matches; onBeforeCompile modifies source but the default cache key doesn't include the injected code, so materials with identical params share a program — that's fine and desirable (they'd share the compiled program but each material has its own uniforms). But wait, if programs are shared, each material still has its own uniform values? Uniforms are per-material (materialProperties.uniforms) yes, from shader.uniforms assigned in onBeforeCompile — each material gets its own. Good. But since the injected code is identical, sharing program is fine.

However, the injected code uses uniform names uTime, uPhase etc. If a program is shared between materials with same parameters, the uniform declarations are same. Fine.

But careful: to be safe, set material.customProgramCacheKey = () => 'fish' so all share one program.

Also, per-fish scale for size variety: group.scale.

Fish colors: species A orange with white stripes (clownfish), species B yellow tang solid yellow, species C blue-ish. Use canvas textures: create striped texture for species A; solid color for others with slight gradient.

Texture creation: canvas 256x128, fill base color, draw white vertical bands (3 bands) with rounded shapes.

UVs on loft: u = theta/2pi, v = s. Stripes along length → bands at certain v values. For clownfish, stripes are vertical bars (perpendicular to body axis) → bands in v. So draw vertical stripes in the canvas at specific v positions (v maps to canvas y if we set texture.flipY default true; v=0 at bottom of canvas). Whatever, symmetric-ish.

Fin material: same species accent color.

Fish movement logic:

State: pos (Vector3), vel (Vector3), heading.
Each frame:
1. Compute desired steering:
   - wander: target point changes every ~2-4s; steer toward it.
   - boundary avoidance: if near walls, steer inward.
   - pointer avoidance: distance from fish to pointer world point (ray closest point). If dist < fleeRadius (1.6), steer strongly away, magnitude scales with (1 - dist/radius)^2. Also slight speed boost.
   - separation from other fish: if dist < 0.8, push away.
2. Steering force added to vel; clamp speed to [1.0, 3.2] (increase when fleeing).
3. pos += vel*dt; clamp inside bounds (with margin).
4. Orient: desired direction = vel normalized; smoothly rotate group quaternion: use quaternion.setFromUnitVectors(new Vector3(0,0,1), dir) then slerp — but that loses banking and roll. Use lookAt then slerp: compute target quaternion via a temp Object3D lookAt? Easier: use Matrix4.lookAt then setFromRotationMatrix and slerp.

Let's do:
```js
const dummy = new THREE.Object3D();
dummy.position.copy(fish.pos); dummy.lookAt(fish.pos.clone().add(fish.vel));
fish.mesh.quaternion.slerp(dummy.quaternion, 0.08);
```
Plus banking: apply roll proportional to turning — could add slight z rotation after slerp. Keep simple, maybe add roll based on lateral acceleration: mesh.rotateZ? Let's add small roll: after slerp, multiply by a quaternion from axis Z angle = clamp(lateral turn). I'll skip or implement simply.

Undulation speed: uSpeed depends on speed: uTime advances faster when fleeing.

Pointer handling: on mousemove over canvas, compute NDC, raycaster.setFromCamera, intersect with a plane at the fish depth? Compute closest point on ray to fish: 
```js
const ray = raycaster.ray;
const toFish = fish.pos.clone().sub(ray.origin);
const t = toFish.dot(ray.direction);
const closest = ray.origin.clone().add(ray.direction.clone().multiplyScalar(t));
const d = closest.distanceTo(fish.pos);
```
If d < radius → flee.

Also show a visual "disturbance" marker: a glowing sphere at closest point on ray? Better: a ring/sphere at the intersection of ray with the tank's mid-plane (z=0 plane) — compute intersection with plane z=0 and show a translucent sphere + ring there, plus ripple in water? Let's show a small glowing "cursor orb" with a pulsing halo at the point where the ray passes closest to the camera-facing plane, i.e., intersect ray with plane z = 0 (tank center) → place orb there. Also emit bubbles near disturbed fish.

When idle > 2.5s, autopilot: virtual pointer moves on a lissajous path inside tank; use that as the avoidance point (with visible orb marker labeled "auto").

Bubbles emission: when a fish is fleeing, spawn a few bubbles near it.

Caustics: canvas texture generated procedurally with overlapping sine-based patterns? A cheap approach: generate a texture with random light blobs (radial gradients) and animate by scrolling two layers with different speeds and multiply blend, using AdditiveBlending on a plane just above the sand. Looks decent.

Let's create a caustics canvas: 256x256, fill black, draw ~40 radial gradient white blobs with random positions, blur via ctx.filter = 'blur(4px)'. Then in a shader on the plane, sample texture twice with scrolling UVs and take min/pow. Use ShaderMaterial with additive blending and uniforms time.

Water surface: ShaderMaterial with two scrolling sine waves for normal-ish highlights, color gradient, transparent, plus fresnel-ish alpha. Simple: vertex displacement small + fragment color based on sin patterns, alpha 0.35.

Also add god rays? skip.

Background: dark gradient backdrop plane behind tank + fog. Add a large sphere/box with a gradient texture? Use scene.background = new THREE.Color(0x041018) and a subtle radial gradient plane behind. Let's make a big backdrop plane at z=-8 with gradient canvas texture (dark blue to near black).

Tank frame: edges of box using LineSegments with EdgesGeometry in dark metal color, plus corner posts maybe. Adds "aquarium" look.

Add a stand/wooden cabinet under the tank? Nice touch: a box under y=0 of darker wood color. Yes, adds realism.

Also glass front pane highlight: a thin plane with gradient? The glass box material with transmission might be heavy. Use MeshPhysicalMaterial with transmission 0.9, thickness 0.3, roughness 0.05, ior 1.45, and scene.environment = RoomEnvironment — transmission requires the renderer to do a separate pass; works but with 1 mesh fine. However transmission + transparent could conflict. Let's use a simpler glass: MeshPhysicalMaterial({transparent:true, opacity:0.12, roughness:0.02, metalness:0, transmission:0}) with side: DoubleSide, depthWrite:false. Plus specular via environment gives reflections. Add a subtle white streak plane on the front glass for a reflection highlight.

Hmm, with opacity 0.12 and env map, we get subtle reflections. Good enough.

Shadows: spotlight from above-front, castShadow, mapSize 1024. Fish cast shadows onto sand → nice. But shadow of transparent water? Not needed.

Performance: 14 fish, seaweed ~10 ribbons, bubbles 30 → fine.

Let me also add UI controls:
- Panel top-left: title "AQUARIUM", subtitle "3D reef tank".
- Panel bottom-left or bottom-right: instructions, "Fish: 12" slider? Add buttons: "+ Fish", "Calm / Panic" toggle? Keep: buttons "+ Fish", "Reset", and toggle "Auto-pilot".
- Stats: "Calmness" meter bar that drops when fish are fleeing. Nice feedback.

Also a "sonar" pulse effect: when pointer moves, emit expanding ring at pointer location.

Let me also ensure no horizontal scrollbar at 375px: use viewport meta, width=device-width, overflow hidden.

Now write the code.

Details on the start overlay: A centered card with "Click or press a key to start" plus small hint text. On click/keydown → fade out overlay, set started=true. But the demo auto-pilot should be running already so the screenshot shows action. The requirement says games must start in self-playing demo mode behind the message. So fish swim automatically; autopilot pointer circles → fish flee. Good.

Actually maybe make the overlay less obtrusive: place it center with backdrop blur, and the aquarium visible around. Fine.

Let me write the HTML.

Structure:
```html
<div id="app"><canvas id="c"></canvas></div>
<div id="ui">... panels ...</div>
<div id="overlay">...</div>
```

CSS: dark theme, glassy panels with backdrop-filter blur, rounded, subtle borders, Inter-ish system font stack.

Let's write the JS carefully.

```js
import * as THREE from 'three';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';
```

Wait: BufferGeometryUtils path in 0.170: 'three/addons/utils/BufferGeometryUtils.js' exports mergeGeometries. Yes (in newer versions it's mergeGeometries; older had mergeBufferGeometries). 0.170 has mergeGeometries. Good.

Maybe avoid BufferGeometryUtils dependency risk and build geometry manually. I'll build everything manually — body loft + fins in one geometry array with indices. Actually simpler: I'll write my own merge: create arrays.

Let me write makeFishBody(segs, radial, profile) returning BufferGeometry, and makeFins() returning BufferGeometry (non-indexed triangles), then combine manually: convert body to non-indexed? Just concatenate positions/normals/uvs and indices offset. Easy enough:

```js
function mergeGeos(list){
  let vCount=0, iCount=0;
  for(const g of list){ vCount += g.attributes.position.count; iCount += g.index ? g.index.count : g.attributes.position.count; }
  const pos = new Float32Array(vCount*3), nor = ..., uv = ...;
  const idx = new Uint32Array(iCount);
  let vo=0, io=0;
  for(const g of list){
    pos.set(g.attributes.position.array, vo*3);
    nor.set(g.attributes.normal.array, vo*3);
    if(g.attributes.uv) uv.set(g.attributes.uv.array, vo*2);
    const gi = g.index ? g.index.array : null;
    const n = g.attributes.position.count;
    if(gi){ for(let k=0;k<gi.length;k++) idx[io+k]=gi[k]+vo; io+=gi.length;}
    else { for(let k=0;k<n;k++) idx[io+k]=k+vo; io+=n; }
    vo += n;
  }
  ...
}
```

But fins need different material than body. So keep them separate meshes; the undulation shader is applied to both materials. But fins and body are separate meshes in the same group → they'd move together as group, good.

Fins geometry (flat triangles in various planes):
- caudal (tail): points (0,0.28,-0.58),(0,-0.28,-0.58),(0,0,-1.02) → triangle in YZ plane, normal ±X. DoubleSide material so fine.
- dorsal: (0,0.22,-0.05),(0,0.22,-0.45),(0,0.42,-0.28) in YZ plane.
- pectoral: on sides: from (0.12,0,-0.05) to (0.4,-0.1,-0.28)... keep small triangles.

For the undulation shader, displacement must be a function of the vertex's z position along the body so fins follow the body wave. Since fins have vertices with z, using z-based factor works.

Undulation code injection:

```glsl
uniform float uTime;
uniform float uPhase;
uniform float uWave;   // amplitude
uniform float uFreq;
void undulate(){
  float s = clamp((0.55 - transformed.z) / 1.5, 0.0, 1.0); // 0 at nose(+z) -> 1 at tail
  float amp = uWave * pow(s, 1.6);
  float w = sin(uTime * uFreq - s * 4.0 + uPhase);
  transformed.x += w * amp;
}
```
Hmm at s=0 (nose) no displacement; at tail max. The wave should travel head→tail: phase = uTime*uFreq - s*4.0 means maxima travel toward increasing s as time increases? sin(ωt - ks) constant phase → s increases with t. Yes wave travels tailward. Good.

But careful: transformed.z used before rotation — geometry local coords, good. Note that `transformed` is defined in begin_vertex. Also need to handle normals? Not necessary.

Injection into MeshStandardMaterial's vertex shader: replace '#include <begin_vertex>' with the include + our code. Also need shadow depth material to match (customDepthMaterial) — otherwise shadows won't wiggle; minor, skip (shadow of fish slightly static) — acceptable. Could create customDepthMaterial with same injection for correctness; let's add it for quality: MeshDepthMaterial with RGBADepthPacking and same onBeforeCompile. It's a bit more code but fine. Let's do it: depthMat = new THREE.MeshDepthMaterial({depthPacking: THREE.RGBADepthPacking}); inject similar code. Actually the shadow would be a static fish silhouette — barely noticeable. I'll include it anyway; low risk.

Hmm, extra risk of errors. I'll include but carefully.

Let me write injection helper:

```js
function injectUndulation(mat, uniforms){
  mat.onBeforeCompile = (shader) => {
    shader.uniforms.uTime = uniforms.uTime;
    shader.uniforms.uPhase = uniforms.uPhase;
    shader.uniforms.uFreq = uniforms.uFreq;
    shader.uniforms.uWave = uniforms.uWave;
    shader.vertexShader = `
      uniform float uTime; uniform float uPhase; uniform float uFreq; uniform float uWave;
    ` + shader.vertexShader.replace('#include <begin_vertex>', `
      #include <begin_vertex>
      float s = clamp((0.55 - transformed.z) / 1.5, 0.0, 1.0);
      float amp = uWave * pow(s, 1.7);
      transformed.x += sin(uTime * uFreq - s * 4.2 + uPhase) * amp;
    `);
    mat.userData.shader = shader;
  };
  mat.customProgramCacheKey = () => 'undulate';
}
```

Note: uniform objects shared between body material and fin material of the same fish — pass same uniforms object.

Each fish: uniforms = { uTime:{value:0}, uPhase:{value: rand*6.28 }, uFreq:{value: 6}, uWave:{value:0.1} }.

Texture for body: canvas.

```js
function makeFishTexture(base, stripe, bands){
  const c = document.createElement('canvas'); c.width=256; c.height=128;
  const ctx=c.getContext('2d');
  ctx.fillStyle=base; ctx.fillRect(0,0,256,128);
  if(bands) { ctx.fillStyle=stripe; bands.forEach(v=>{ const y = (1-v)*128; ... draw rounded band }); }
  const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t;
}
```
Since UV v maps... In Three.js, UV (0,0) is bottom-left of the texture image (flipY true default). v=s along body: s=0 (nose) at v=0 → bottom of canvas. Bands specified as v positions → y = (1-v)*128.

Band shape: a stripe that wraps around the body: draw a vertical stripe with some wavy edges and white borders (clownfish has white bands with black edges). Let's draw: for each band v: fill a rounded vertical strip of width ~14px, with black outline 3px then white.

Additionally add subtle darker gradient at top (dorsal darker).

I'll write it with gradients.

Let me make 3 species:
1. Clownfish: base #ff7a1a, bands white at v=0.18, 0.5, 0.82 with black edging.
2. Yellow tang: base #ffc933, no bands, slight gradient.
3. Blue tang: base #3a7bd5, with a yellow tail region (v>0.8 yellow) — draw yellow ellipse at tail.
4. Cardinalfish: red with silver stripes.

Let's implement base fill + optional bands + optional tail patch.

Geometry profile function per species for body shape: e.g., tang more oval (disc-like), clownfish more elongated.

Let me define makeBodyGeometry(shape) with parameters: length, heightScale, widthScale, profilePeak, tailThin.

Simplify: profile(s) = pow(sin(PI * pow(s, p)), 0.75) with p controlling fullness; for s>0.85 blend to tailThin.

Let me test mentally: s=0 → 0. pow(s,0.62) at s=0.25 → 0.41, sin(0.41π)=0.97 → good bulge near nose quarter. For a more elongated fish p=0.85 → bulge later.

Height/width ratio: clownfish h=0.30,w=0.16 relative to length 1.2? Let's set length L=1.2 (z from 0.6 to -0.6) then profile radius max r=1 scaled: h(s)=r*H, w(s)=r*W with H=0.28, W=0.14 for clownfish. So body height 0.56 max, width 0.28. Good.

Tail fin extends to z=-0.95.

Then scale group by fish size 0.6–1.1.

Now movement code.

Tank bounds: x ∈ [-5.5, 5.5], y ∈ [0.5, 5.4], z ∈ [-2.6, 2.6]. Fish size ~1.2*scale so keep margin.

Fish behavior per frame (dt):

```js
const acc = new THREE.Vector3();
// wander target
f.wanderTimer -= dt;
if(f.wanderTimer<=0){ f.wanderTimer = 1.5+Math.random()*3; f.wanderTarget.set(rand in bounds); }
// seek target
const toTarget = f.wanderTarget.clone().sub(f.pos);
if(toTarget.length() < 0.6) pick new target
acc.add(toTarget.normalize().multiplyScalar(0.7));
// separation
for each other fish g: d = f.pos - g.pos; if(d.length()<1.0) acc.add(d.normalize()*(1.0-d.length())*1.5)
// walls
if(f.pos.x > 4.6) acc.x -= (f.pos.x-4.6)*2.0; etc.
// pointer avoidance
compute closest point on pointer ray
d = f.pos - closest; dist
if(dist < FLEE_R){ const k = (1 - dist/FLEE_R); acc.add(d.normalize().multiplyScalar(k*k*9.0)); f.panic = max(f.panic,k); }
```
Then vel += acc*dt; clamp speed: maxSpeed = 1.6 + panic*3.5; minSpeed 0.9. vel.normalize().multiplyScalar(clamp(len)) — careful: if we clamp length after adding, then vel never slows below min. Use: vel.addScaledVector(acc, dt); then speed = vel.length(); target speed handled by scaling toward desired: if speed>max → scale down; if speed<min → scale up. Then pos += vel*dt.

Turning: steering naturally changes direction. Orientation slerp toward vel direction.

Also fish should not go too near sand: y > 0.6.

Panic decay: f.panic *= 0.98 per frame → used for speed and stats.

Camera: static, slight parallax based on mouse (subtle). Nice: camera position offset by mouse * 0.3, lookAt target. But pointer ray computed with camera that moves — fine, compute ray each frame from current camera.

Water surface at y=5.8; tank top open? Let's make tank glass box from y=0 to 6, with water surface plane at 5.6.

Let me now write the whole thing. Also need renderer settings: antialias true, pixelRatio min(devicePixelRatio,2), shadowMap enabled, toneMapping ACESFilmic, outputColorSpace SRGB (default).

Use scene.environment = pmrem from RoomEnvironment for nice reflections; scene.background = gradient texture? Use a big backdrop sphere with gradient or set background color + backdrop plane. I'll create a backdrop: large plane at z=-12 with a radial gradient canvas texture (dark teal). Plus scene.fog = FogExp2(0x05131c, 0.045).

Fog affects fish at distance → nice depth.

Caustics plane at y=0.02 over sand with additive shader.

Let me write the caustics shader:

```glsl
uniform sampler2D uTex; uniform float uTime;
varying vec2 vUv;
void main(){
  vec2 p = vUv * 3.0;
  float a = texture2D(uTex, p + vec2(uTime*0.02, uTime*0.013)).r;
  float b = texture2D(uTex, p*1.3 - vec2(uTime*0.017, uTime*0.023)).r;
  float c = min(a,b);
  c = pow(c, 3.0) * 1.6;
  gl_FragColor = vec4(vec3(0.55,0.85,1.0)*c, c);
}
```
With AdditiveBlending, transparent true, depthWrite false.

Caustics texture: canvas 256, black bg, draw 60 radial gradients white-ish random, blur. Then the "min" of two samples creates line-ish patterns. OK.

Now seaweed sway shader: use MeshStandardMaterial with onBeforeCompile on the vertex shader:
```glsl
float h = clamp((position.y - uBase)/uHeight, 0.0, 1.0);
transformed.x += sin(uTime*1.2 + position.y*1.5 + uPhase) * 0.25 * h*h;
transformed.z += cos(uTime*0.9 + position.y*1.2 + uPhase) * 0.18 * h*h;
```
Good.

Build seaweed as ribbon: PlaneGeometry(width, height, 1, 12) translated so base at y=0, then we can curve it (bend in x by y) to make it arc. Use a custom shape: build plane and bend.

Bubbles: InstancedMesh of sphere geometry (radius 0.04) with MeshPhysicalMaterial? Use MeshStandardMaterial transparent opacity 0.35, color white, roughness 0.1, metalness 0. Update instance matrices each frame: bubble rises from bottom emitter at random x,z, wobble, respawn at top.

Let's cap bubbles to 28.

Also small "detritus" particles floating (Points) to add life: 300 points slowly drifting, tiny, additive. Nice.

Let me write the code now in full.

UI text:
- Top-left panel:
  "AQUARIUM" / "Move your cursor into the tank — the fish scatter from your pointer."
- Bottom-left: controls: [Add fish] [Calm water]? Let's have:
  - Slider: population 4..24 (updates immediately)
  - Toggle: "Auto-swim" (auto pointer) on/off
  - Button "Reset"
- Right side: "Tank stats": fish count, fleeing count, calmness meter.

Keep the design tidy: one left panel with everything, and small hint bottom-right.

Now write the final code. Must be careful with no errors.

Potential pitfall: `import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'` — in 0.170 RoomEnvironment exists at examples/jsm/environments/RoomEnvironment.js and exports class RoomEnvironment extends Scene. Usage:
```js
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
```
In 0.170 RoomEnvironment constructor takes no renderer arg (older versions took renderer). In 0.170: `class RoomEnvironment extends Scene { constructor() {...} }`. Yes since r151-ish. Good.

Alternatively avoid the addon risk: build a simple gradient environment using PMREMGenerator.fromScene with a custom scene of colored boxes. Let's just use RoomEnvironment; if it errors, the whole thing breaks. Safer: create our own env: 
```js
const envScene = new THREE.Scene();
// add a few emissive boxes
```
Hmm, PMREM from scene with meshes requires materials; MeshBasicMaterial with colors works.

I'll implement a custom small env scene (avoids addon dependency entirely) — but RoomEnvironment gives nicer results. Risk assessment: RoomEnvironment.js in 0.170 exists. I'm fairly confident. But to be extra safe, I could do a try/catch dynamic import. Dynamic import with try/catch:

```js
let envTex = null;
try{
  const { RoomEnvironment } = await import('three/addons/environments/RoomEnvironment.js');
  envTex = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
}catch(e){ /* fallback */ }
```
Top-level await in module script is allowed in modern Chrome. Good — use that with fallback to a manual env scene.

Actually simpler: build manual env scene always (no addon): create scene with a large sphere with gradient? PMREM from scene needs meshes. Let's do manual:

```js
function buildEnvironment(){
  const s = new THREE.Scene();
  const geo = new THREE.BoxGeometry(1,1,1);
  const add=(col,x,y,z,sx,sy,sz,intensity)=>{
    const m = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({color:col}));
    m.position.set(x,y,z); m.scale.set(sx,sy,sz); s.add(m);
  };
  // big dim room
  add(0x223344, 0,0,0, 40,40,40) — but basic material inside-out? normals outward; from inside we see backfaces → culled! Use material.side = THREE.BackSide for the room box.
```
Use side: BackSide for the room. Then add bright panels (light boxes) for reflections.

I'll do manual env with BackSide room + several bright emissive boxes (MeshBasicMaterial bright colors). PMREM will capture them. Good enough and no addon dependency.

Actually MeshBasicMaterial color values are clamped to 1 → reflections will be dim-ish. Use color multiplied beyond 1? MeshBasicMaterial color can't exceed 1 per channel in sRGB... but we can set `material.color.setRGB(3,3,3)`? Color values >1 are allowed in three (they're just floats) and with tone mapping off in the PMREM render? PMREM renders to HalfFloat targets so values >1 are preserved. Let's set color.setRGB(4,4,4) for bright panels. I think three clamps color via `setHex` to sRGB, but setRGB with colorSpace param can produce >1 values. `color.setRGB(4,4,4)` → stored as is (linear). Fine.

Simplify: room BackSide color 0x0a1826, plus panels with setRGB(6,6,6) white-ish for specular highlights on fish and glass.

OK.

Now, let me write everything.

Also mouse interaction should work even before "start"? The overlay says click to start; after start, pointer control is active. But the iframe only receives keyboard events after click; mouse events probably always received? "The iframe only receives keyboard events after the user clicks it" — mouse move events might also be restricted. So auto-pilot matters.

Implementation: `started` flag; before start, auto-pilot pointer is active and moves. After start, real mouse controls pointer when moved; auto-pilot resumes after 3s of no mouse movement.

Also handle touch: pointermove events with pointerType touch.

Let me define pointer target in tank space: we compute via ray from camera through NDC. For auto-pilot, we directly set a world position.

For the "flee" test, we need closest point on ray to fish. For auto-pilot (a point), distance directly.

Unified: keep `pointerPoint` (world position) and `pointerActive`. Compute from real mouse: ray origin/dir. For avoidance, use distance from fish to the ray (line), which is more forgiving.

But visual marker: place orb at ray-plane intersection (z=0 plane) or at closest point to tank center. Let's compute intersection with the plane z = 0 (tank middle) if within bounds, else closest point to camera ray at distance. Simpler: orb position = ray.at(t) where t = distance to tank center projected. I'll intersect with the tank's bounding box? Use ray.intersectBox(box) for the marker position — but box includes the front glass at z=3, so orb sits on the front glass, not where the user "points". Better: intersect with plane z=0 → orb floats mid-tank. Good.

Let me write the ray math:
```js
const ndc = new THREE.Vector2();
raycaster.setFromCamera(ndc, camera);
const ray = raycaster.ray;
const plane = new THREE.Plane(new THREE.Vector3(0,0,1), 0);
ray.intersectPlane(plane, orbPos);
```

Now the marker: a small sphere (radius 0.08) emissive cyan + a ring (torus) pulsing + a point light? Point light adds cost; use one shared PointLight following the orb with low intensity, distance 4. That will illuminate fish near the pointer — cool effect. Enable only when started.

Let's write the code.

```js
const clock = new THREE.Clock();
function animate(){
  requestAnimationFrame(animate);
  const dt = Math.min(clock.getDelta(), 0.05);
  const t = clock.elapsedTime;
  ...
}
```

Fish materials need uTime updated: for each fish: fish.uniforms.uTime.value += dt * (1 + panic*2) * speedFactor; and uFreq base 6.

Let's write the fish creation function:

```js
function createFish(species, size){
  const u = { uTime:{value:Math.random()*10}, uPhase:{value:Math.random()*6.283}, uFreq:{value:5.5+Math.random()*2}, uWave:{value:0.09} };
  const bodyMat = new THREE.MeshStandardMaterial({ map: species.texture, roughness:0.42, metalness:0.08, side:THREE.FrontSide });
  const finMat = new THREE.MeshStandardMaterial({ color: species.fin, roughness:0.5, metalness:0.05, side:THREE.DoubleSide, transparent:true, opacity:0.92 });
  injectUndulation(bodyMat,u); injectUndulation(finMat,u);
  const g = new THREE.Group();
  const body = new THREE.Mesh(species.geometry, bodyMat);
  const fins = new THREE.Mesh(species.finGeometry, finMat);
  ...
}
```
Note: finMat transparent with opacity might cause sorting issues; use opacity 0.95 transparent false? Keep transparent: true, opacity 0.95, depthWrite true — fine.

customDepthMaterial: create per fish? One per fish (needs same uniforms). Let's create depth material with the same uniforms injection:
```js
const depthMat = new THREE.MeshDepthMaterial({depthPacking: THREE.RGBADepthPacking});
injectUndulation(depthMat, u);
body.customDepthMaterial = depthMat;
```
Injection code for depth shader: MeshDepthMaterial vertex shader also has '#include <begin_vertex>'. Yes.

But careful: the depth material's uniforms: onBeforeCompile gives shader.uniforms; we set shader.uniforms.uTime = u.uTime etc. Works.

But sharing uniform objects across three materials is fine.

Also customProgramCacheKey for depth material: 'undulate-depth' to avoid conflicting with standard's 'undulate' key? The cache key is per material type, so separate namespaces. But to be safe: 'undulate' for both is fine since they're different shader types... Actually the program cache key combines material type + parameters, and the key from customProgramCacheKey. Standard material and depth material have different shaderIDs so different cache entries. Fine, but I'll differentiate anyway.

Fish geometry shared per species (BufferGeometry instances can be shared across meshes — yes).

Now geometry construction.

```js
function buildBodyGeometry(species){
  const segs = 28, radial = 14;
  const positions=[], normals=[], uvs=[], indices=[];
  const L = 1.2;
  for(let i=0;i<=segs;i++){
    const s = i/segs;
    const r = species.profile(s);
    const h = r*species.h, w = r*species.w;
    const z = L*(0.5 - s);
    for(let j=0;j<=radial;j++){
      const th = (j/radial)*Math.PI*2;
      const x = Math.cos(th)*w;
      const y = Math.sin(th)*h;
      positions.push(x,y,z);
      normals.push(0,0,0); // compute later
      uvs.push(j/radial, s);
    }
  }
  for(let i=0;i<segs;i++) for(let j=0;j<radial;j++){
    const a = i*(radial+1)+j, b=a+radial+1;
    indices.push(a,b,a+1, b,b+1,a+1);
  }
  // compute normals
}
```
Winding: need outward normals. Let's determine: vertex at angle th: x=cos, y=sin, z decreasing with i (nose at +z). For a quad (a=(i,j), b=(i+1,j), a+1=(i,j+1)). Hmm let's just compute normals via computeVertexNormals and if the lighting looks inverted, flip indices. Let's reason: Consider a cylinder along z axis from z=+0.6 (i=0) to z=-0.6. At j=0: th=0 → x=+w, y=0 (right side). j=1: th small positive → x slightly less, y positive (up). So increasing j goes counterclockwise in XY viewed from +Z (standard math orientation). Viewed from outside at +X looking in -X direction, we see y up and z to the left? Hmm.

Use the right-hand rule test: triangle (a, a+1, b) where a=(i,j), a+1=(i,j+1), b=(i+1,j) [i+1 has smaller z]. Vectors: e1 = a+1 - a ≈ (-sin, cos, 0)*r*dth (tangential, counterclockwise in XY), e2 = b - a ≈ (0,0,-dz). Normal = e1 × e2 = (cos*(-dz)... let's compute: e1=(ex,ey,0), e2=(0,0,-1) (unit). e1×e2 = (ey*(-1) - 0*0, 0*0 - ex*(-1), 0) = (-ey, ex, 0). With e1 tangential = (-sin th, cos th, 0) → normal = (-cos th, -sin th, 0) → inward! So winding (a, a+1, b) gives inward normals. Therefore we should use (a, b, a+1) order: e1 = b-a = (0,0,-1), e2 = a+1-a = tangential. normal = e1×e2 = (0,0,-1)×(-sin,cos,0) = (0*0 - (-1)*cos, (-1)*(-sin) - 0*0, 0*cos - 0*(-sin)) = (cos, sin, 0) → outward. 

So indices: (a, b, a+1) and (b, b+1, a+1). Check second: b=(i+1,j), b+1=(i+1,j+1), a+1=(i,j+1): e1=b+1-b = tangential, e2=a+1-b = (0,0,+1)-ish. normal = tangential×(0,0,1) = (ey*1-0, 0-ex*1, 0) = (ey, -ex, 0) = (cos, sin, 0)? With e1=(-sin,cos,0): ey=cos, ex=-sin → (cos, sin, 0). Outward. 

So indices.push(a,b,a+1, b,b+1,a+1). Matches what I wrote first. Good, then computeVertexNormals gives outward normals. But wait — in my earlier test I used (a,a+1,b) and got inward; the code writes (a,b,a+1) → correct.

Also s=0 ring has r=0 → degenerate triangles at nose; computeVertexNormals may produce NaN for zero-area triangles? computeVertexNormals handles zero-area by contributing zero vector; the vertex normal at the nose might be 0 → black nose spot. To avoid, make the nose slightly rounded: at s=0, r = 0 but let's instead start s from 0.02 with a cap? Simplest: set r(0) = 0.02 (tiny) so triangles aren't degenerate; nose looks pointy-ish but ok. Actually a small sphere-ish nose: use profile with r(0)=0.03.

Also close the tail? The tail end (s=1) has r = tailThin (e.g., 0.05) → open hole at the tail, but the caudal fin covers it and it's inside. With FrontSide material, looking from behind you'd see through the hole into the body interior → could look odd but the fin covers mostly. Let's cap the tail with a small disc: add a center vertex at z=-0.6 and fan. Or simply set the body material side to DoubleSide to avoid see-through. DoubleSide costs little and avoids artifacts. Let's use DoubleSide for the body.

Hmm DoubleSide with computeVertexNormals is fine.

Now profile function per species:

```js
function makeProfile(kind){
  return (s)=>{
    let r;
    if(kind==='round'){ r = Math.pow(Math.sin(Math.PI*Math.pow(s,0.8)),0.62); }
    else { r = Math.pow(Math.sin(Math.PI*Math.pow(s,0.6)),0.8); }
    if(s>0.82){ const t=(s-0.82)/0.18; r = r*(1-t)+0.045*t; }
    r = Math.max(r, 0.02);
    return r;
  };
}
```
Hmm at s=1: r=0.045 (thin tail). Good.

Check s=0: pow(0,0.6)=0, sin(0)=0 → r=0 → max(0.02). Good.

For 'round' (tang): s=0.5 → pow(0.5,0.8)=0.574, sin(0.574π)=0.975 → ^0.62=0.98. s=0.8→pow(0.8,0.8)=0.836,sin(0.836π)=0.5,^0.62=0.65. ok.

Height/width: 
- clownfish: h=0.30, w=0.155
- tang (round): h=0.33, w=0.10 (disc-shaped, laterally compressed)
- cardinal: h=0.26, w=0.11

Fins geometry: build per species using simple triangles.

Caudal fin: for species, shape differs slightly but use same.

Dorsal fin: triangle above body.
Pectoral fins: small triangles at sides (will be visible as flapping? we can animate pectoral by rotating meshes; skip).

Let's create fins as a single BufferGeometry (non-indexed) with normals computed, and UVs (map to a region?). Fin material is solid color, no texture, so no UVs needed. But onBeforeCompile injection uses transformed.z — fine.

Wait: our undulation shader displaces x based on z. The caudal fin (in YZ plane at x=0) will be displaced in x → the tail sweeps left-right. 

Fin geometry construction (positions array of triangles):

Caudal: 
(0, 0.26, -0.58), (0, -0.26, -0.58), (0, 0.0, -1.0) → but also make it forked: two triangles: (0,0.26,-0.58),(0,0.0,-1.0),(0,-0.05,-0.72) and (0,-0.26,-0.58),(0,0.05,-0.72),(0,0,-1.0). Simplify: one triangle + a notch. Let's just do a single triangle plus a smaller triangle to create a forked look. Simpler: single triangle is fine.

Also the caudal fin should be double-sided — finMat DoubleSide ✓.

Dorsal: (0, 0.22, -0.05), (0, 0.22, -0.42), (0, 0.42, -0.26) — wait dorsal fin should attach along the top of the body where the body height at that s. Body at s=0.35 → z = 1.2*(0.5-0.35)=0.18, r≈0.95 → h=0.3*0.95=0.285. At s=0.6 → z=-0.12, r≈0.9→h=0.27. So dorsal triangle vertices: (0,0.25,0.2),(0,0.25,-0.2),(0,0.45,-0.05). Good.

Anal fin below: (0,-0.25,0.0),(0,-0.22,-0.3),(0,-0.38,-0.15).

Pectoral: at x=±0.12: triangle (0.10,0.0,0.12),(0.10,0.0,-0.12),(0.34,-0.12,-0.05) and mirrored for -x. These are in a plane roughly; give slight thickness not needed.

OK.

Now the pointer & fleeing effect also could push water: add ripple ring in the water surface? Skip.

Stats UI: 
- "POPULATION: 12"
- "FLEEING: 3"
- Calmness bar: calmness = 1 - average panic.

Controls: +/- buttons for population, toggle auto-pilot, "Scare them" button? Let's add a "Swirl" button that makes all fish flee? Could be fun: clicking the tank scatters all fish. But clicking also starts. Let's add button "Startle" that creates a strong repulsion pulse at a point.

Let me finalize UI:

Left panel (top-left):
```
AQUARIUM
3D reef tank · Three.js
Move the pointer near the fish and they scatter.
```
Bottom-left panel:
```
POPULATION  [−] 12 [+]
[AUTO-PILOT ✓]  [STARTLE]
```
Right-top panel: tank status: fleeing count + calmness bar.

Bottom-center: hint "Click or press a key to start" — this is the overlay. The overlay is a full-screen dim + centered card. To keep the scene visible, use rgba(3,10,16,0.45) backdrop and a card. Should be fine.

Make overlay dismiss on click anywhere / key press. Also auto-dismiss after ~6 seconds? The instruction says behind the message the demo runs. If the overlay stays forever, the screenshot still shows it (fine) but the user might be annoyed. I'll auto-fade it after 5s? But the grader might screenshot at 3s — overlay visible then. Then it fades at 5s so the user sees the clean scene. Hmm, but requirement "Click or press a key to start" message should be displayed. Keep it but auto-hide after 7s (with fade). Actually better: keep it until interaction but make it non-blocking visually (small pill at bottom center) after first 2.5s? Let's do: full overlay card initially; after 4.5s, shrink the card into a small bottom-center pill "Click or press a key to start · auto-pilot running" that stays. Simpler: keep the centered card but make it compact and elegant. I'll auto-fade the card after 6s to a small bottom pill. Let's implement: overlay element with class "big"; after 6s add class "mini" → moves to bottom, smaller text. Keep pointer-events so clicking still dismisses.

Simplify: overlay card centered; on click/keydown → fade out (opacity 0, pointer-events none). Also after 6 seconds, apply 'mini' class which translates it to bottom and scales down text. Fine.

Let me now write actual code.

Also responsive: at width < 700px, panels shrink, hide some text, reduce fish count default? Keep.

Let me write the code in full now.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>3D Aquarium</title>
<style> ... </style>
</head>
<body>
<canvas id="scene"></canvas>
<div id="hud"> panels </div>
<div id="overlay">...</div>
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/prefix"}}</script>
```
Note: the task's importmap uses "three/addons/" → "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/". Use exactly that.

Now the script.

I'll write it as a module script.

Let me carefully compose the full code.

```js
import * as THREE from 'three';

/* ---------- renderer / scene ---------- */
const canvas = document.getElementById('scene');
const renderer = new THREE.WebGLRenderer({canvas, antialias:true, alpha:false});
renderer.setPixelRatio(Math.min(window.devicePixelRatio||1, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
renderer.outputColorSpace = THREE.SRGBColorSpace;

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x040c14);
scene.fog = new THREE.FogExp2(0x061722, 0.035);

const camera = new THREE.PerspectiveCamera(42, window.innerWidth/window.innerHeight, 0.1, 100);
camera.position.set(0, 2.6, 10.5);
const camTarget = new THREE.Vector3(0, 2.4, 0);
camera.lookAt(camTarget);
```

Env map:
```js
const pmrem = new THREE.PMREMGenerator(renderer);
pmrem.compileEquirectangularShader();
const envScene = new THREE.Scene();
// room
const roomGeo = new THREE.BoxGeometry(30,20,30);
const roomMat = new THREE.MeshBasicMaterial({side:THREE.BackSide});
roomMat.color.setRGB(0.05,0.09,0.12);
const room = new THREE.Mesh(roomGeo, roomMat);
envScene.add(room);
// light panels
function panel(x,y,z,sx,sy,sz,r,g,b){
  const m = new THREE.Mesh(new THREE.PlaneGeometry(sx,sy), new THREE.MeshBasicMaterial({side:THREE.DoubleSide}));
  m.material.color.setRGB(r,g,b);
  m.position.set(x,y,z); m.rotation.y = ...; envScene.add(m);
}
```
Panels need to face the center: use PlaneGeometry and lookAt(0,0,0).

Add: big white panel above (0,9,0) size 20x20 rgb(3,3,3); a cyan panel at left; warm panel right.

Then `const envRT = pmrem.fromScene(envScene, 0.03); scene.environment = envRT.texture;` Also maybe scene.environmentIntensity = 0.5 (three r163+ supports scene.environmentIntensity). 0.170 supports it. Use 0.6.

Lights:
- HemisphereLight(0x9fd8ff, 0x1a1208, 0.5)
- DirectionalLight from above-front (0xffffff, 1.0) position (3,8,5) with shadows: shadow camera bounds ±8, mapSize 1024.
- A cyan PointLight at top (0x66e0ff, 1.5, 20) at (0,5.5,0) to simulate aquarium lamp.

Tank geometry:
const TANK = {w:12, h:6, d:6};
Water fill height 5.4.

Sand: PlaneGeometry(12,6,80,40) rotate -PI/2, displace vertices with noise (sin combos), computeVertexNormals. Material MeshStandardMaterial({color:0xcdb894, roughness:0.95}). receiveShadow.

Also add a slight darker bottom / gradient via vertex colors? Skip.

Rocks: create 5 rocks from IcosahedronGeometry(0.5, 1) with random vertex displacement, scale/rotate, color 0x5a5f63 roughness 0.9. castShadow/receiveShadow.

Seaweed: 8 ribbons at random positions.

Also a "castle"? skip.

Water surface plane at y=5.4 with shader.

Glass: BoxGeometry(12,6,6) centered at (0,3,0), MeshPhysicalMaterial({transparent:true, opacity:0.10, roughness:0.05, metalness:0, side:THREE.DoubleSide, depthWrite:false, envMapIntensity:1.2}). Plus EdgesGeometry lines.

Hmm transparent box with DoubleSide renders both front and back faces → 4 layers of tint; opacity 0.08 is subtle. OK.

Also add the front glass highlight: a plane at z=3.01 with a gradient texture (white streak) opacity 0.06. Let's add a subtle diagonal streak: PlaneGeometry(3,6) rotated slightly, with a canvas gradient. Maybe skip for simplicity; the env reflections should do.

Cabinet/stand: BoxGeometry(12.4,1.2,6.4) at y=-0.6, color 0x2a2118 roughness .8, with slight wood color. Plus a darker top rim. Keep simple.

Backdrop: large plane at z=-14 with gradient canvas texture (radial dark blue). Also side panels? The fog + background color suffices. I'll add a big plane with a radial gradient for a vignette look.

Let me include a subtle "room" ambience: floor plane at y=-1.2 with dark color and slight reflection? Skip.

Fish count default: 14.

Let me write fish species definitions:

```js
function makeTexture(spec){
  const c=document.createElement('canvas'); c.width=512; c.height=256;
  const ctx=c.getContext('2d');
  // base gradient
  const g=ctx.createLinearGradient(0,0,0,256);
  g.addColorStop(0, spec.top); g.addColorStop(0.55, spec.base); g.addColorStop(1, spec.bottom);
  ctx.fillStyle=g; ctx.fillRect(0,0,512,256);
  // scale speckles
  ...
  // bands
  if(spec.bands){ for(const v of spec.bands){ ... } }
  if(spec.tailColor){ ctx.fillStyle=spec.tailColor; ctx.fillRect(0,0,512, (1-spec.tailV)*256); } // v<tailV tail
```
Wait v=0 nose at bottom of canvas (because flipY). Let's define tail region as v > 0.75 → canvas y from 0 to (1-0.75)*256 = 64. So fillRect(0,0,512,64).

Band drawing at v: canvas y = (1-v)*256 is the v-line; the band is a vertical strip in the canvas? Bands perpendicular to the body axis = constant v = horizontal lines in the canvas! Because v is the body-length coordinate and u is around. So a stripe at constant v = a horizontal line across the canvas (spanning all u). Yes! So draw horizontal bands across the full width at y=(1-v)*H with thickness ~18px.

Good, simpler: horizontal bands.

Clownfish: 3 white bands with black edges at v = 0.16, 0.5, 0.84.

Also add slight dark line along the top (dorsal darkening) via gradient — done with gradient.

Add speckle/noise: draw many small translucent dots for scale texture.

Texture wrap: default ClampToEdge; u wraps around body seam (u from 0..1 around circumference) — the seam is at theta=0 (right side of fish). With clamp, at u=1 vs u=0 mismatch visible if texture not seamless horizontally. Our texture is mostly horizontal bands → seamless except for speckles. Make bands full-width so seamless-ish. Fine.

Now, let's write the fish update and the whole loop.

Also implement the "startle" button: apply a strong repulsion from a random tank point to all fish, plus spawn bubbles.

Bubbles implementation: array of {x,y,z,r,phase}. Use a single InstancedMesh with 40 instances.

Floating particles: THREE.Points with 400 particles in a box, additive, tiny, slowly drifting (update positions in shader with time? use PointsMaterial and update buffer each frame - 400*3 floats per frame is fine). Or use a shader with time uniform → simpler to just do a static Points with a slowly rotating group and slight vertical drift via shader. I'll use a small custom ShaderMaterial for motes that drifts them with time (no CPU cost).

Let me write the motes shader:
```glsl
// vertex
uniform float uTime; uniform float uPixelRatio;
attribute float aSeed;
void main(){
  vec3 p = position;
  p.y = mod(p.y + uTime*(0.05+0.08*aSeed) , 6.0) - 0.0; // wrap
  p.x += sin(uTime*0.3 + aSeed*10.0)*0.3;
  p.z += cos(uTime*0.25 + aSeed*7.0)*0.2;
  vec4 mv = modelViewMatrix * vec4(p,1.0);
  gl_PointSize = (2.0+3.0*aSeed) * uPixelRatio * (8.0/ -mv.z);
  gl_Position = projectionMatrix * mv;
}
// fragment
void main(){
  float d = length(gl_PointCoord - 0.5);
  float a = smoothstep(0.5,0.0,d);
  gl_FragColor = vec4(vec3(0.7,0.9,1.0), a*0.35);
}
```
Careful with mod wrap: p.y range initially 0..6, mod(p.y + drift, 6.0) keeps 0..6 ✓.

Now writing the final code. Let me produce it.

Also handle resize.

I need to double check the shader injection for MeshStandardMaterial: '#include <begin_vertex>' exists in the standard vertex shader ✓.

For the depth material (MeshDepthMaterial), its vertex shader also includes begin_vertex ✓.

One concern: `shader.uniforms.uTime = uniforms.uTime;` — uniforms.uTime is {value: n}; assigning the same object reference to shader.uniforms means updating uniforms.uTime.value updates the shader uniform ✓.

Let me write out the code text now, being careful.

Also: fish must avoid the glass and stay in bounds: bounds x ±5.2, y 0.55..5.0, z ±2.4.

Tank interior is 12 wide × 6 deep? Wait TANK.d = 6 → z from -3 to 3 ✓, height 6 (y 0..6), width 12 (x -6..6). Sand plane 12×6 ✓.

Let me make the camera see the whole tank: at z=10.5 with fov 42, the visible height at distance 10.5 is 2*10.5*tan(21°)=8.06 → tank height 6 fits with room. Width visible = 8.06*aspect(1.6)=12.9 → tank width 12 fits ✓. Good. Camera target (0,2.7,0).

At 375px width (portrait), aspect ~0.47 → visible width = 8.06*0.47=3.8 → tank cropped. For narrow screens, move camera back: set camera z based on aspect: z = 10.5 * max(1, 1.4/aspect)? Let's compute: we want visible width ≥ 13 → distance ≥ 13/(2*tan(21°)*aspect) = 13/(0.767*aspect) = 16.9/aspect. At aspect 1.6 → 10.6 ✓. At aspect 0.47 → 36 → too far (tank would be tiny vertically but the tank would fit width). Instead for narrow screens, rotate the camera to look more along the tank? Simpler: on narrow screens, scale down: set camera distance = max(10.5, 16.9/aspect * 0.75). Let's define:
```js
function fitCamera(){
  const aspect = w/h;
  const dist = Math.max(10.6, 15.0/Math.max(aspect,0.35)/ (2*Math.tan(fov/2)) );
}
```
Hmm let me simplify: visible width at distance d = 2*d*tan(fov/2)*aspect. We want ≥ 13.5 → d ≥ 13.5/(2*tan(21°)*aspect) = 13.5/(0.767*aspect) = 17.6/aspect. Also want visible height ≥ 7 → d ≥ 7/(2*tan21°)=9.1. So d = max(9.1, 17.6/aspect). At aspect 1.6 → d=11 (ok). At aspect 0.47 → d=37 → tank appears small in the middle of a tall screen with lots of empty space. Alternatively on portrait, we could rotate the tank? Better: on portrait, accept cropping of the tank sides (the user sees the middle of the tank) — but the tank edges look better visible. Compromise: d = max(9.5, 13.5/aspect) (allow slight cropping of width). At aspect 0.47 → d=28.7 → visible width 10.3 (tank 12 → slight crop). Visible height = 2*28.7*tan21 = 22 → tank occupies 6/22 = 27% of the height → too small.

Better for portrait: change camera to a closer, more "inside the tank" view? Or make the layout responsive by scaling the whole scene? Simplest: for portrait, use a lower FOV? No.

Option: For narrow screens, set the camera to look at the tank from a 3/4 angle closer, so the tank's diagonal fits. E.g., position camera at (6, 3, 8) distance ~11.4, looking at center; the tank's projected width from an angle is smaller than 12? The tank diagonal is ~14.1; viewed at an angle, the silhouette width is maybe ~10. Hmm.

Simplest robust approach: keep camera framing based on aspect with a modest cap: d = clamp(17.6/aspect, 9.5, 18). At aspect 0.47 → 17.6/0.47=37 → capped at 18 → visible width = 0.767*18*0.47 = 6.5 → we see the middle 6.5 of the 12-wide tank. Visible height = 2*18*tan21 = 13.8 → the tank (6 tall) occupies 43% of height, positioned center — acceptable but there'd be a lot of empty space above/below. Hmm, we could also crop vertically by moving the camera closer and accepting a horizontal crop.

Alternative: on portrait, tilt the camera to look slightly down and zoom in so the tank fills more; add a CSS note. Honestly, for the demo the portrait view will just show the middle of the tank, which is fine as long as it looks good (fish visible, sand, plants).

Let's do: d = max(9.2, min(17.6/aspect, 16)) and also raise camera y a bit for portrait so we look slightly down into the tank. With d=16, aspect 0.47: visible width = 0.767*16*0.47 = 5.8 → shows middle half. Visible height 12.3 → tank 6 tall = 49% of screen height. The rest is water/background. That's OK but the tank's top and bottom edges would be visible with dark space above/below. Acceptable.

Actually maybe better for portrait: crop so the tank fills the frame vertically-ish: visible height ~8 → d = 8/0.767 = 10.4, visible width = 0.767*10.4*0.47=3.75 → very narrow slice (3.75 of 12 wide). We'd see only a third of the tank. Fish would be big. Hmm, that's a reasonable "immersive" portrait view. I think showing the middle slice is fine and looks intentional.

I'll use: portrait → d = 11.5, visible width 4.1, height 8.8 → tank slightly cropped vertically (6 of 8.8 fine, plus cabinet). Good — the tank fills the frame nicely.

So: if aspect < 1: d = 11.5 and camera y = 3.0; else d = 11.0 (visible width 13.5 ✓).

Let me define:
```js
function resize(){
  const w=innerWidth,h=innerHeight;
  renderer.setSize(w,h);
  camera.aspect=w/h;
  const aspect=w/h;
  const dist = aspect < 1.0 ? 11.5 : 11.0;
  baseCamPos.set(0, aspect<1? 2.9 : 2.7, dist);
  camera.updateProjectionMatrix();
}
```
And parallax modifies camera.position around baseCamPos.

Visible width at aspect 1.0 (square): 0.767*11 = 8.4 → tank cropped. For aspect between 1 and 1.4 maybe crop slightly is fine.

Hmm for a square-ish window (e.g., 800x800), we'd see only 8.4 of 12 wide. Acceptable.

OK.

Now, fish spawn: random positions inside bounds, random velocity.

Let me write the code.

One more feature: mouse "disturbance" also creates a ripple ring in the water surface? Let's add a simple expanding ring sprite at the pointer location in 3D (a ring mesh lying in the XZ plane? no, the pointer is mid-water) — use a sphere with additive shader that expands and fades. Implement: a Mesh with RingGeometry facing the camera (billboard) using additive material, scale up over time and fade. Spawn on pointer movement (throttled) and when fish flee.

Let's implement "pulse rings" as billboarded ring meshes with a pool of 8.

Ring shader: MeshBasicMaterial with map? Use RingGeometry(0.6, 0.75, 32) with transparent material opacity animated; billboard by copying camera quaternion.

Good.

Now writing everything concretely. I'll write the code in a single module script.

Let me also ensure the pointer ray uses the camera after parallax updates — recompute each frame from stored NDC.

Write the final code.

```js
/* ========== SPECIES ========== */
function textureFromSpec(spec){...}

const SPECIES = [
  { name:'clown', base:'#ff7a1a', top:'#ff9c3d', bottom:'#e05a10', fin:'#ffb347', bands:[0.16,0.5,0.84], bandColor:'#ffffff', bandEdge:'#1a1208', profile:'long', h:0.30, w:0.155, scale:[0.55,0.85] },
  { name:'tang', base:'#ffc42e', top:'#ffdd6a', bottom:'#f0a018', fin:'#ffe08a', profile:'round', h:0.34, w:0.105, scale:[0.6,0.95] },
  { name:'cardinal', base:'#e8452f', top:'#ff6a4d', bottom:'#b32a1c', fin:'#ff8a6a', bands:[0.3,0.62], bandColor:'#ffd9c0', profile:'long', h:0.26, w:0.11, scale:[0.5,0.8] },
  { name:'blue', base:'#2f6fd0', top:'#4f92ff', bottom:'#1d4a99', fin:'#ffd23f', tailColor:'#ffd23f', profile:'round', h:0.30, w:0.10, scale:[0.55,0.9] },
];
```

Texture function with bands drawing:

```js
function textureFromSpec(spec){
  const W=512,H=256;
  const c=document.createElement('canvas'); c.width=W; c.height=H;
  const ctx=c.getContext('2d');
  const grad=ctx.createLinearGradient(0,0,0,H);
  grad.addColorStop(0,spec.top); grad.addColorStop(0.5,spec.base); grad.addColorStop(1,spec.bottom);
  ctx.fillStyle=grad; ctx.fillRect(0,0,W,H);
  // speckles
  for(let i=0;i<1400;i++){
    ctx.fillStyle = `rgba(0,0,0,${0.02+Math.random()*0.06})`;
    ctx.beginPath(); ctx.arc(Math.random()*W, Math.random()*H, 1+Math.random()*3, 0, 6.283); ctx.fill();
  }
  // tail patch
  if(spec.tailColor){ ctx.fillStyle=spec.tailColor; const y=(1-spec.tailV)*H; ctx.fillRect(0,0,W,y); }
  // bands
  if(spec.bands){
    for(const v of spec.bands){
      const y=(1-v)*H, th=spec.bandThick||22;
      ctx.fillStyle=spec.bandEdge||'rgba(0,0,0,0.6)';
      ctx.fillRect(0,y-th/2-4,W,th+8);
      ctx.fillStyle=spec.bandColor;
      ctx.fillRect(0,y-th/2,W,th);
    }
  }
  const t=new THREE.CanvasTexture(c);
  t.colorSpace=THREE.SRGBColorSpace;
  t.anisotropy=4;
  return t;
}
```
Bands drawn as full-width horizontal stripes = rings around the body ✓.

Note the band covers the full canvas width (u direction) → the band appears on both sides of the fish continuously ✓.

Now, geometry per species computed once.

Let me now write the final code. I'll be thorough but concise.

For the "calmness" meter: compute average of (1-panic) across fish → bar width.

Also update HUD numbers each ~200ms.

Time to write it all out.

Regarding the start overlay and pointer: before start, the real mouse shouldn't affect fish (the iframe may not deliver mousemove until click anyway). We'll enable real pointer tracking only after started; before that, auto-pilot is active.

Auto-pilot path: a lissajous in tank space:
```js
autoPos.set(Math.sin(t*0.45)*3.6, 2.6 + Math.sin(t*0.7)*1.5, Math.cos(t*0.33)*1.8);
```
This moves smoothly → fish flee from it.

Also the auto-pilot should occasionally "charge" through the fish.

Let's finalize the pointer representation:
```js
const pointer = { mode:'auto', ndc:new THREE.Vector2(), active:true, point:new THREE.Vector3(), rayOrigin:new THREE.Vector3(), rayDir:new THREE.Vector3() };
```
For real mouse: set ndc; each frame compute ray from camera; mode='real'.
For auto: point = autoPos; we treat it as a point source (distance directly).

Flee calc:
```js
function fleeVector(f){
  let px,py,pz;
  if(pointer.mode==='auto'){ closest = pointer.point; dist = f.pos.distanceTo(closest); }
  else { compute closest point on ray; dist = ... }
}
```

Let me code that.

Now writing the actual final answer. I'll take care with syntax.

Also add `renderer.setAnimationLoop(animate)` instead of requestAnimationFrame — either fine.

Potential pitfall: `THREE.SRGBColorSpace` exists ✓.

Let me write the water surface shader:

```glsl
// vertex
varying vec2 vUv; varying vec3 vPos;
void main(){ vUv=uv; vec3 p=position; p.z += sin(p.x*2.0 + uTime*1.3)*0.04 + sin(p.y*3.0 - uTime*0.9)*0.03; vPos=p; gl_Position=projectionMatrix*modelViewMatrix*vec4(p,1.0); }
```
For a plane rotated -PI/2, local position.x is world x, local y is world -z. Displacement along local z = world y ✓.

Fragment:
```glsl
uniform float uTime; uniform vec3 uColor;
varying vec2 vUv;
void main(){
  float w = sin(vUv.x*40.0 + uTime*2.0)*0.5+0.5;
  float w2 = sin(vUv.y*33.0 - uTime*1.4)*0.5+0.5;
  float a = 0.10 + 0.10*w*w2;
  vec3 c = mix(uColor, vec3(1.0), w*0.25);
  gl_FragColor = vec4(c, a);
}
```
Plus fresnel? Keep simple, transparent true, depthWrite false, side DoubleSide.

Also I want the water surface visible from below-ish — the camera is at y=2.7 looking at 2.4, so we look slightly up? The surface at y=5.4 is above the camera → we'd see its underside. Set side: DoubleSide ✓.

Now the caustics plane: PlaneGeometry(12,6) at y=0.02 rotated -PI/2, ShaderMaterial additive.

Hmm but the sand is displaced; the caustics plane flat at y=0.02 might intersect the sand bumps. Displacement amplitude small (0.06) → make caustics at y=0.08 and depthWrite false, and use polygonOffset. Fine.

Let's write the code.

I also want the aquarium lamp: a cylinder lamp above the tank with a SpotLight shining down. Add a simple lamp fixture: a thin box at y=7 above the tank, with a spotlight below it. Nice detail. Maybe skip the fixture, just the spotlight.

Add the spotlight: new THREE.SpotLight(0xd8f4ff, 120, 25, Math.PI*0.32, 0.5, 1.6) at (0,8,1) target (0,0,0), castShadow, mapSize 1024, bias -0.0005. Intensity in three r155+ uses physically correct units by default (WebGLRenderer.useLegacyLights removed in 0.165?). In 0.170, lights are physical: spot light intensity in candela. For a spotlight at distance ~8, intensity ~200 gives a decent illumination. Let's use decay 1.4 and intensity 250. I'll tune by eye... can't test. Safer: use DirectionalLight (intensity 2.2) for the main light plus HemisphereLight (0.8) and a PointLight for the lamp glow (intensity 30, distance 20, decay 2). Directional light shadows are simpler and more predictable.

I'll use: DirectionalLight(0xffffff, 2.0) at (4, 9, 6) castShadow; HemisphereLight(0x8fd8ff, 0x241a10, 0.9); PointLight(0x7fe6ff, 25, 22, 2) at (0,5.2,0) — lamp glow; plus a second small directional from behind (0x66ccff, 0.6) at (-6,4,-6).

With ACES tone mapping and these intensities things should look decent.

Shadows: directional shadow camera: left/right ±8, top/bottom ±8, near 0.5, far 30. mapSize 2048 (or 1024 for perf). Use 2048.

Now writing.

Final answer code. Let me produce it carefully and completely.

I'll now write the whole HTML.

Extra: ensure `prefers-reduced-motion`? skip.

Let me write the HUD HTML:

```html
<div class="panel panel--tl">
  <h1>Aquarium</h1>
  <p class="sub">3D reef tank · Three.js</p>
  <p class="hint">Sweep your pointer through the water — the fish scatter from your hand.</p>
</div>

<div class="panel panel--tr" id="stats">
  <div class="row"><span>Population</span><b id="popCount">14</b></div>
  <div class="row"><span>Fleeing</span><b id="fleeCount">0</b></div>
  <div class="meter"><i id="calmBar"></i></div>
  <div class="meter-label">Calmness</div>
</div>

<div class="panel panel--bl">
  <div class="btnrow">
    <button id="less">−</button><span id="popLabel">14 fish</span><button id="more">+</button>
  </div>
  <div class="btnrow">
    <button id="autoBtn" class="toggle on">Auto-pilot</button>
    <button id="startle">Startle</button>
  </div>
</div>
```

Overlay:
```html
<div id="overlay">
  <div class="card">
    <div class="pulse"></div>
    <h2>Click or press a key to start</h2>
    <p>Auto-pilot is swimming the pointer for you.</p>
  </div>
</div>
```

CSS styling with backdrop-filter, rounded, cyan accents.

Let me write the final code now. Also handle button clicks not triggering the overlay dismissal weirdly (button clicks also dismiss overlay, fine).

Time to write it out fully.

One more thing: since the iframe receives keyboard only after click, but mouse events? Probably the sandbox delivers mousemove to the iframe. Regardless, auto-pilot covers it.

Writing final code.

For the fish "wander target" and "flee", I need to ensure fish don't all cluster. Fine.

Let me write the update function for fish:

```js
const tmpV = new THREE.Vector3(), tmpV2 = new THREE.Vector3(), tmpQ = new THREE.Object3D();

function updateFish(f, dt, t){
  const p = f.pos, v = f.vel;
  tmpV.set(0,0,0);
  // wander
  f.wanderT -= dt;
  const d = tmpV2.copy(f.wanderTarget).sub(p);
  const dist = d.length();
  if(f.wanderT<=0 || dist<0.8){
    f.wanderT = 2+Math.random()*3;
    f.wanderTarget.set(rand(-4.6,4.6), rand(0.9,4.8), rand(-2.0,2.0));
    d.copy(f.wanderTarget).sub(p); 
  }
  tmpV.add(d.normalize().multiplyScalar(0.9));
  // separation
  for(const g of fishes){ if(g===f) continue; const dd = tmpV2.copy(p).sub(g.pos); const l = dd.length(); if(l<1.1 && l>1e-4) tmpV.add(dd.multiplyScalar((1.1-l)/1.1*1.6/ l * l)); }
```
Hmm the multiply: dd is a vector of length l; dd/l is unit; add unit * (1.1-l)/1.1 * 1.6. Let me write: tmpV.addScaledVector(dd.divideScalar(l), (1.1-l)*1.6).

But tmpV2 reused → careful with nested usage. Use separate temps.

Wall avoidance:
```js
const margin = 1.0;
if(p.x > 5.2-margin) tmpV.x -= (p.x-(5.2-margin))*2.5;
if(p.x < -5.2+margin) tmpV.x += ((-5.2+margin)-p.x)*2.5;
similar y (0.9 .. 4.9), z (-2.3..2.3)
```
Use bounds constants.

Flee:
```js
let fx=0,fy=0,fz=0, fdist=Infinity;
if(pointerActive){
  if(pointer.mode==='auto'){ const dd = tmpA.copy(p).sub(pointer.point); fdist = dd.length(); if(fdist<1e-4) dd.set(1,0,0); dd.divideScalar(Math.max(fdist,1e-4)); }
  else { // closest point on ray
    const toP = tmpA.copy(p).sub(ray.origin);
    const tproj = toP.dot(ray.direction);
    const closest = tmpB.copy(ray.direction).multiplyScalar(tproj).add(ray.origin);
    const dd = tmpC.copy(p).sub(closest);
    fdist = dd.length();
    dd.divideScalar(Math.max(fdist,1e-4));
  }
  const R = 2.0;
  if(fdist < R){ const k = 1 - fdist/R; const strength = k*k*14; tmpV.addScaledVector(dd, strength); f.panic = Math.min(1, f.panic + k*0.35); }
}
```
Note: for the ray mode, the closest point may be behind the camera? tproj should be positive since the fish is in front. Clamp tproj to >= 0.

Also the flee should also affect speed: f.panic decays.

Speed:
```js
v.addScaledVector(tmpV, dt);
const maxS = 1.7 + f.panic*3.2;
let sp = v.length();
if(sp>maxS) v.multiplyScalar(maxS/sp);
if(sp<0.9) { if(sp>1e-4) v.multiplyScalar(0.9/sp); else v.set(0.9,0,0); }
p.addScaledVector(v, dt);
```
Wait if sp < 0.9 we multiply up but only if sp>1e-4; fine.

Also clamp p within bounds after moving:
```js
p.x = clamp(p.x, -5.2, 5.2); p.y = clamp(p.y, 0.75, 5.0); p.z = clamp(p.z, -2.3, 2.3);
```

Orientation:
```js
tmpQ.position.copy(p); tmpQ.lookAt(p.x+v.x, p.y+v.y, p.z+v.z);
f.mesh.quaternion.slerp(tmpQ.quaternion, 1-Math.pow(0.001, dt)); // smooth
```
Actually slerp factor: use 0.08 per frame at 60fps → dt-based: 1-Math.pow(0.02, dt) ≈ 0.1 at dt=0.016? 0.02^0.016 = e^{0.016*ln0.02}= e^{-0.0627}=0.939 → factor 0.061. Hmm. Let's use factor = 1 - Math.pow(0.0005, dt) → at dt=0.016: 0.0005^0.016 = e^{-0.1216}=0.885 → factor 0.115. Good.

But tmpQ.lookAt with up vector (0,1,0) — if the fish swims straight up, lookAt degenerates. Clamp direction mostly horizontal? Add: if |v.y| dominant, add small x. Let's ensure the direction isn't parallel to up: if abs(dir.y)>0.98, tilt dir.x += 0.05.

Also add banking: rotate around the fish's local z axis based on lateral acceleration. Compute the turn: cross of prev dir and new dir → yaw rate → roll. Let's implement simply: rollTarget = clamp(-lateralTurn * 2.0, -0.5, 0.5); f.roll = lerp(f.roll, rollTarget, dt*4); then apply: mesh.quaternion.multiply(quatFromAxisAngle(z-axis local, f.roll)). Implement with a temp quaternion q.setFromAxisAngle(new Vector3(0,0,1), f.roll) and mesh.quaternion.multiply(q).

Compute lateral turn: lateral = dir.cross(v).y? Let's compute the signed turn: crossY = dirPrev.x*dirNew.z - dirPrev.z*dirNew.x gives the y component of the cross product = turn direction. Use f.prevDir.

Fine, let's include it, it adds realism.

Undulation uniform update: f.uni.uTime.value += dt*(3.0 + f.panic*4.0) * (0.8 + speed*0.25); uFreq fixed 5.5; uWave = 0.07 + panic*0.05.

Hmm the wave frequency: uTime advances at 3-7 units/sec; with the shader sin(uTime*uFreq - s*4.2) where uFreq=5.5 → the wave temporal frequency = 5.5 rad per time unit * 3 units/s = 16.5 rad/s ≈ 2.6 Hz. Reasonable for a fish tail beat.

Also the tail beat should speed up with speed: uTime advance ∝ speed. Good.

Let me set uFreq = 6.0 and advance uTime by dt*(2.0 + speed*0.9) → at speed 1.2 → 3.1/s * 6 = 18.6 rad/s ≈ 3 Hz. Good.

Bubble spawn when fleeing: occasionally add bubbles at the fish position.

Now bubbles array update:
```js
for(const b of bubbles){ b.y += b.speed*dt; b.x += Math.sin(t*b.wob+ b.phase)*0.001; ... }
```
For instanced mesh, set matrices.

Let's implement bubbles as individual Mesh objects (30 meshes) — simpler. Use a shared SphereGeometry and material. 30 draw calls, fine.

OK, writing the final code. Let me produce it.

Also the "startle" button: set a startle impulse: for each fish, add velocity away from a random point in the tank, and set panic=1.

Let me write everything now, final.

I'll include `window.addEventListener('resize', resize)`.

Also `document.addEventListener('visibilitychange')`? no.

Final code writing. I'll be careful about the number of lines but it's fine.

Also: the demo must look good at first paint → the fish should be spread out and swimming, the tank lit.

Let me write the initial fish positions: random within bounds but ensure spread: use a grid-ish jitter.

Let's go.

One more: `scene.environmentIntensity` may not exist in 0.170? It was added in r163 (scene.environmentIntensity). 0.170 → yes exists. I'll guard: if ('environmentIntensity' in scene) scene.environmentIntensity = 0.55;

Now write.

Also note about `THREE.PMREMGenerator` fromScene with a scene containing BackSide room — standard technique ✓.

Write the final answer.

For the HUD stats update, use requestAnimationFrame-driven throttle.

Let me write the code text.

Also make sure that clicking a HUD button doesn't accidentally startle (use stopPropagation where needed) — but clicking anywhere dismisses the overlay, which is desired.

Final code:

I'll write it now completely.

```js
import * as THREE from 'three';

/* ---------------- renderer ---------------- */
const canvas = document.getElementById('view');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference:'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
renderer.outputColorSpace = THREE.SRGBColorSpace;
```

etc.

Let me write out everything now in the answer. I'll aim for clean, working code.

For the seaweed ribbon geometry:

```js
function makeSeaweed(height, width, segments, bend){
  const g = new THREE.PlaneGeometry(width, height, 2, segments);
  g.translate(0, height/2, 0);
  const pos = g.attributes.position;
  for(let i=0;i<pos.count;i++){
    const x=pos.getX(i), y=pos.getY(i);
    const t = y/height;
    // bend along x
    pos.setX(i, x + bend*t*t);
    pos.setZ(i, Math.sin(t*2.2)*0.05);
  }
  g.computeVertexNormals();
  return g;
}
```
Sway in the shader with the same height factor.

Sway injection into MeshStandardMaterial:
```js
mat.onBeforeCompile = (shader)=>{
  shader.uniforms.uTime = swayUniform; // shared global time
  shader.uniforms.uPhase = {value: phase};
  shader.vertexShader = 'uniform float uTime; uniform float uPhase;\n' + shader.vertexShader.replace('#include <begin_vertex>', `
    #include <begin_vertex>
    float h = clamp(transformed.y / uHeight, 0.0, 1.0);
    transformed.x += sin(uTime*1.1 + transformed.y*1.6 + uPhase)*0.28*h*h;
    transformed.z += cos(uTime*0.8 + transformed.y*1.2 + uPhase)*0.16*h*h;
  `);
};
```
Need uHeight uniform per seaweed (or use a fixed height). Use per-material uHeight.

Since each seaweed has its own material (different color), we get separate programs; set customProgramCacheKey to 'sway' so they share the program (all same code). But careful: if they share a program, the uniforms per material are still per-material ✓.

Note: uniform `uHeight` differs per material but the program is shared → uniform values are set per material from materialProperties.uniforms ✓ (WebGLRenderer sets uniforms per material draw call).

Alright, writing final.

For the caustics and water shaders I use ShaderMaterial with uniforms; the time uniform shared object updated each frame.

Let me write the answer now.

Also add a subtle vignette overlay via CSS radial gradient on top of the canvas (pointer-events none) → nice.

Final code writing (long). Go.