Models 44

GLM 5.3 Flash

by Z.aiReasoningClean
Open in new window

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

How it went

Total time
4 min 23 s
Cost
$0.021
Output tokens
41,321
Spent thinking
33,400 tokens
Speed
157.7 tok/s
Console errors
0
First token after
896 ms
First visible text after
3 min 46 s
Prompt tokens
424
Code size
23.2 KB
Finish reason
stop
Served by
Venice
Model id
z-ai/glm-5.3-flash
Reasoning sent
{"effort":"max"}
Max output
64,000 tokens
Generated
Nov 10, 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 · 668 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>The Lagoon — Interactive 3D Aquarium</title>
<style>
  :root{ --ink:#e8f7ff; }
  *{margin:0;padding:0;box-sizing:border-box}
  html,body{width:100%;height:100%;overflow:hidden;background:#041a29;touch-action:none;overscroll-behavior:none}
  body{font-family:"Avenir Next","Segoe UI",system-ui,-apple-system,sans-serif;color:var(--ink)}
  canvas{display:block;touch-action:none}
  #ui{position:fixed;top:20px;left:24px;z-index:5;pointer-events:none;text-shadow:0 2px 14px rgba(0,10,25,.85)}
  #ui h1{font-size:clamp(17px,2.6vw,26px);font-weight:600;letter-spacing:.22em;text-transform:uppercase}
  #ui h1 span{color:#7fe3ff}
  #hint{margin-top:7px;font-size:clamp(11px,1.5vw,13.5px);letter-spacing:.08em;opacity:.82;transition:opacity 1.2s}
  #hint.dim{opacity:.25}
  #legend{position:fixed;right:22px;bottom:18px;z-index:5;pointer-events:none;font-size:11.5px;letter-spacing:.08em;opacity:.55;text-shadow:0 2px 10px rgba(0,10,25,.8);text-align:right}
  #vignette{position:fixed;inset:0;z-index:4;pointer-events:none;
    background:radial-gradient(120% 90% at 50% 42%,transparent 55%,rgba(1,12,22,.62) 100%)}
</style>
<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>
</head>
<body>
<div id="ui">
  <h1>The <span>Lagoon</span></h1>
  <div id="hint">Move your cursor near the fish to startle them · click for a shockwave</div>
</div>
<div id="legend">40 fish · 7 reef species<br/>cursor = predator &nbsp;·&nbsp; click = shockwave</div>
<div id="vignette"></div>

<script type="module">
import * as THREE from 'three';

/* ---------------- renderer / scene / camera ---------------- */
const renderer = new THREE.WebGLRenderer({antialias:true});
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
scene.background = new THREE.Color('#07293e');
scene.fog = new THREE.FogExp2('#07293e', 0.016);

const camera = new THREE.PerspectiveCamera(52, innerWidth/innerHeight, 0.1, 200);
camera.position.set(0, 1.0, 24);

scene.add(new THREE.HemisphereLight(0xbfe8ff, 0x0c2b33, 1.15));
const sun = new THREE.DirectionalLight(0xeaf7ff, 2.1);
sun.position.set(5, 14, 6);
scene.add(sun);
scene.add(new THREE.AmbientLight(0x39647f, 0.55));

const BOUND = {x:13.5, y:5.6, z:6.0};

/* ---------------- procedural textures ---------------- */
function causticCanvas(){
  const s=256, c=document.createElement('canvas'); c.width=c.height=s;
  const g=c.getContext('2d');
  g.globalCompositeOperation='lighter';
  for(let i=0;i<70;i++){
    const cx=Math.random()*s, cy=Math.random()*s, r=8+Math.random()*26, w=Math.random()*2-1;
    g.beginPath();
    const N=26;
    for(let k2=0;k2<=N;k2++){
      const a=k2/N*Math.PI*2;
      const rr=r*(1+0.35*Math.sin(a*3+w*7)+0.18*Math.sin(a*5+w*13));
      const px=cx+Math.cos(a)*rr, py=cy+Math.sin(a)*rr;
      k2?g.lineTo(px,py):g.moveTo(px,py);
    }
    g.closePath();
    g.filter='blur(1.4px)';
    g.strokeStyle='rgba(160,225,255,0.15)';
    g.lineWidth=1.6+Math.random()*2.4;
    g.stroke();
  }
  return c;
}
const causticCanvasEl = causticCanvas();
function causticTexture(rx,ry){
  const t=new THREE.CanvasTexture(causticCanvasEl);
  t.wrapS=t.wrapT=THREE.RepeatWrapping;
  t.repeat.set(rx,ry);
  return t;
}

function makeFishTexture(base, accent, pattern, darkTop){
  const c=document.createElement('canvas'); c.width=128; c.height=64;
  const g=c.getContext('2d');
  g.fillStyle=base; g.fillRect(0,0,128,64);
  const grd=g.createLinearGradient(0,0,0,64);
  if(darkTop){
    grd.addColorStop(0,'rgba(8,40,70,0.5)');
    grd.addColorStop(0.55,'rgba(255,255,255,0.12)');
    grd.addColorStop(1,'rgba(255,255,255,0.35)');
  }else{
    grd.addColorStop(0,'rgba(255,255,255,0.22)');
    grd.addColorStop(0.55,'rgba(255,255,255,0)');
    grd.addColorStop(1,'rgba(10,20,50,0.4)');
  }
  g.fillStyle=grd; g.fillRect(0,0,128,64);
  if(pattern===0){ g.fillStyle=accent; for(let i=0;i<4;i++) g.fillRect(14+i*30,0,9,64); }
  else if(pattern===1){
    g.fillStyle=accent;
    for(let i=0;i<24;i++){
      g.globalAlpha=0.85; g.beginPath();
      g.arc(8+Math.random()*112, 6+Math.random()*52, 2.5+Math.random()*3.5, 0, 7);
      g.fill();
    }
    g.globalAlpha=1;
  }
  const t=new THREE.CanvasTexture(c);
  t.colorSpace=THREE.SRGBColorSpace;
  return t;
}

function rayTexture(){
  const c=document.createElement('canvas'); c.width=64; c.height=256;
  const g=c.getContext('2d');
  const v=g.createLinearGradient(0,0,0,256);
  v.addColorStop(0,'rgba(255,255,255,0.85)');
  v.addColorStop(1,'rgba(255,255,255,0)');
  g.fillStyle=v; g.fillRect(0,0,64,256);
  g.globalCompositeOperation='destination-in';
  const h=g.createLinearGradient(0,0,64,0);
  h.addColorStop(0,'rgba(0,0,0,0)'); h.addColorStop(0.5,'rgba(0,0,0,1)'); h.addColorStop(1,'rgba(0,0,0,0)');
  g.fillStyle=h; g.fillRect(0,0,64,256);
  return new THREE.CanvasTexture(c);
}

/* ---------------- environment ---------------- */
let floorMat, floorCaustic, surfaceTex, bubbleMat;

{ // sandy floor with animated caustics
  floorCaustic = causticTexture(8,4);
  const geo=new THREE.PlaneGeometry(90,44,52,26);
  geo.rotateX(-Math.PI/2);
  const pos=geo.attributes.position;
  for(let i=0;i<pos.count;i++){
    const x=pos.getX(i), z=pos.getZ(i);
    pos.setY(i, Math.sin(x*0.32)*Math.cos(z*0.45)*0.22 + Math.sin(x*0.11+z*0.19)*0.28);
  }
  geo.computeVertexNormals();
  floorMat=new THREE.MeshStandardMaterial({
    color:0xd8bf85, roughness:1, metalness:0,
    emissive:0x9fdcff, emissiveMap:floorCaustic, emissiveIntensity:0.5
  });
  const floor=new THREE.Mesh(geo,floorMat);
  floor.position.y=-7;
  scene.add(floor);
}

{ // glass tank
  const box=new THREE.BoxGeometry(BOUND.x*2+1.6, 13.4, BOUND.z*2+1.6);
  const edges=new THREE.LineSegments(
    new THREE.EdgesGeometry(box),
    new THREE.LineBasicMaterial({color:0x8fd8ff, transparent:true, opacity:0.28})
  );
  scene.add(edges);
  const inner=new THREE.Mesh(box, new THREE.MeshBasicMaterial({
    color:0xaee4ff, transparent:true, opacity:0.05, side:THREE.BackSide, depthWrite:false
  }));
  scene.add(inner);
}

{ // water surface shimmer
  surfaceTex = causticTexture(7,3.5);
  const geo=new THREE.PlaneGeometry(70,34);
  geo.rotateX(Math.PI/2);
  const surface=new THREE.Mesh(geo, new THREE.MeshBasicMaterial({
    color:0xbfeeff, map:surfaceTex, transparent:true, opacity:0.22, depthWrite:false
  }));
  surface.position.y=6.7;
  scene.add(surface);
}

const rays=[];
{ // soft light shafts
  const rt=rayTexture();
  for(let i=0;i<4;i++){
    const m=new THREE.Mesh(new THREE.PlaneGeometry(2.6,12), new THREE.MeshBasicMaterial({
      map:rt, transparent:true, opacity:0.10+Math.random()*0.05,
      blending:THREE.AdditiveBlending, depthWrite:false, side:THREE.DoubleSide, fog:false
    }));
    m.position.set(-9+i*6+Math.random()*2, 2.5, -4+Math.random()*5);
    m.rotation.z=(Math.random()-0.5)*0.3;
    m.rotation.y=Math.random()*0.6-0.3;
    rays.push(m); scene.add(m);
  }
}

const weedMats=[];
function addWeed(x,z,h,w){
  const geo=new THREE.PlaneGeometry(w,h,1,14);
  geo.translate(0,h/2,0);
  const mat=new THREE.ShaderMaterial({
    side:THREE.DoubleSide,
    uniforms:{
      uTime:{value:0}, uPhase:{value:Math.random()*20},
      uAmp:{value:0.45+Math.random()*0.5},
      uColA:{value:new THREE.Color('#0a4d2e')},
      uColB:{value:new THREE.Color('#37b06a')},
      uFogColor:{value:new THREE.Color('#07293e')},
      uFogDensity:{value:0.016}
    },
    vertexShader:`
      uniform float uTime,uPhase,uAmp;
      varying float vY; varying float vDist;
      void main(){
        vY=uv.y;
        vec3 p=position;
        float k=pow(uv.y,1.6);
        p.x+=sin(uTime*1.05+uPhase+uv.y*3.2)*uAmp*k;
        p.z+=cos(uTime*0.8+uPhase*1.7+uv.y*2.4)*uAmp*0.6*k;
        vec4 mv=modelViewMatrix*vec4(p,1.0);
        vDist=length(mv.xyz);
        gl_Position=projectionMatrix*mv;
      }`,
    fragmentShader:`
      uniform vec3 uColA,uColB,uFogColor; uniform float uFogDensity;
      varying float vY; varying float vDist;
      void main(){
        vec3 col=mix(uColA,uColB,vY)*(0.55+0.6*vY);
        float f=1.0-exp(-uFogDensity*uFogDensity*vDist*vDist);
        col=mix(col,uFogColor,clamp(f,0.0,1.0));
        gl_FragColor=vec4(col,1.0);
      }`
  });
  weedMats.push(mat);
  const m=new THREE.Mesh(geo,mat);
  m.position.set(x,-7,z);
  m.rotation.y=Math.random()*Math.PI;
  scene.add(m);
}
for(let i=0;i<14;i++){
  const edge=Math.random()<0.5;
  const x=edge?(Math.random()<0.5?-1:1)*(9+Math.random()*4.5):(Math.random()*26-13);
  const z=(Math.random()<0.5?-1:1)*(3.5+Math.random()*2.8);
  addWeed(x, z, 2.2+Math.random()*3.2, 0.35+Math.random()*0.3);
}

{ // rocks and coral
  const rockGeo=new THREE.IcosahedronGeometry(1,1);
  const rockMat=new THREE.MeshStandardMaterial({color:0x64757e, roughness:0.95, flatShading:true});
  for(let i=0;i<9;i++){
    const m=new THREE.Mesh(rockGeo, rockMat);
    const s=0.7+Math.random()*1.6;
    m.scale.set(s, s*(0.5+Math.random()*0.35), s*(0.75+Math.random()*0.5));
    m.position.set(-12.5+Math.random()*25, -7+0.25*s, -5.5+Math.random()*8);
    m.rotation.set(Math.random(),Math.random()*6,Math.random());
    scene.add(m);
  }
  const coralCols=[0xff7a59,0xff5f9e,0xffb35f,0xff8f6b];
  for(let i=0;i<6;i++){
    const g=new THREE.Group();
    const mat=new THREE.MeshStandardMaterial({color:coralCols[i%coralCols.length], roughness:0.7});
    const n=4+Math.floor(Math.random()*4);
    for(let k2=0;k2<n;k2++){
      const h=0.5+Math.random()*0.9;
      const t=new THREE.Mesh(new THREE.ConeGeometry(0.09+Math.random()*0.08, h, 6), mat);
      t.position.set((Math.random()-0.5)*0.7, h*0.42, (Math.random()-0.5)*0.7);
      t.rotation.set((Math.random()-0.5)*0.7, Math.random()*6, (Math.random()-0.5)*0.7);
      g.add(t);
    }
    g.position.set(-12+Math.random()*24, -6.95, -5+Math.random()*8);
    scene.add(g);
  }
}

{ // rising bubbles (GPU animated points)
  const BUBBLES=160;
  const base=new Float32Array(BUBBLES*3), speed=new Float32Array(BUBBLES),
        phase=new Float32Array(BUBBLES), size=new Float32Array(BUBBLES);
  for(let i=0;i<BUBBLES;i++){
    base[i*3]=(Math.random()*2-1)*12.5;
    base[i*3+1]=-6.8;
    base[i*3+2]=(Math.random()*2-1)*5.5;
    speed[i]=0.5+Math.random()*1.1;
    phase[i]=Math.random()*100;
    size[i]=5+Math.random()*11;
  }
  const geo=new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.BufferAttribute(base,3));
  geo.setAttribute('aSpeed', new THREE.BufferAttribute(speed,1));
  geo.setAttribute('aPhase', new THREE.BufferAttribute(phase,1));
  geo.setAttribute('aSize', new THREE.BufferAttribute(size,1));
  bubbleMat=new THREE.ShaderMaterial({
    transparent:true, depthWrite:false,
    uniforms:{uTime:{value:0}},
    vertexShader:`
      uniform float uTime;
      attribute float aSpeed,aPhase,aSize;
      varying float vFade;
      void main(){
        float range=14.6;
        float life=fract((uTime*aSpeed+aPhase)/range);
        vec3 p=position;
        p.y=-6.8+life*range;
        p.x+=sin(uTime*1.2+aPhase*3.1+life*8.0)*0.3;
        p.z+=cos(uTime*0.9+aPhase*2.3)*0.3;
        vFade=sin(life*3.14159);
        vec4 mv=modelViewMatrix*vec4(p,1.0);
        gl_PointSize=aSize*(120.0/-mv.z);
        gl_Position=projectionMatrix*mv;
      }`,
    fragmentShader:`
      varying float vFade;
      void main(){
        vec2 uv=gl_PointCoord-0.5;
        float d=length(uv);
        float a=smoothstep(0.5,0.40,d)*0.16;
        a+=smoothstep(0.05,0.0,abs(d-0.35))*0.65;
        a+=smoothstep(0.10,0.0,length(uv-vec2(-0.14,0.16)))*0.55;
        a*=vFade;
        if(a<0.02) discard;
        gl_FragColor=vec4(0.78,0.94,1.0,a);
      }`
  });
  const pts=new THREE.Points(geo,bubbleMat);
  pts.frustumCulled=false;
  scene.add(pts);
}

/* ---------------- fish ---------------- */
function finGeo(len, spread, notch){
  const sh=new THREE.Shape();
  sh.moveTo(0,0);
  sh.quadraticCurveTo(len*0.35, spread*0.35, len, spread);
  sh.quadraticCurveTo(len*notch, 0, len, -spread);
  sh.quadraticCurveTo(len*0.35, -spread*0.35, 0, 0);
  const g=new THREE.ShapeGeometry(sh);
  g.rotateY(Math.PI/2);
  return g;
}
const tailGeo=finGeo(0.78,0.5,0.3);
const dorsalGeo=(()=>{
  const sh=new THREE.Shape();
  sh.moveTo(-0.28,0);
  sh.quadraticCurveTo(-0.02,0.5,0.16,0.42);
  sh.quadraticCurveTo(0.3,0.28,0.36,0);
  sh.lineTo(-0.28,0);
  const g=new THREE.ShapeGeometry(sh);
  g.rotateY(Math.PI/2);
  return g;
})();
const bodyGeo=new THREE.SphereGeometry(1,22,16);
const pectGeo=new THREE.SphereGeometry(1,10,8);
const eyeGeo=new THREE.SphereGeometry(0.09,10,8);
const eyeMat=new THREE.MeshStandardMaterial({color:0x0b0e12, roughness:0.2, metalness:0.3});

function buildFish(cfg){
  const group=new THREE.Group();
  const body=new THREE.Mesh(bodyGeo, new THREE.MeshStandardMaterial({map:cfg.tex, roughness:0.5, metalness:0.05}));
  body.scale.set(0.42,0.6,1.0);
  group.add(body);

  const finMat=new THREE.MeshStandardMaterial({
    color:new THREE.Color(cfg.accent), transparent:true, opacity:0.85,
    side:THREE.DoubleSide, roughness:0.65
  });

  const tailGroup=new THREE.Group();
  tailGroup.position.z=-0.78;
  tailGroup.add(new THREE.Mesh(tailGeo, finMat));
  group.add(tailGroup);

  const dorsal=new THREE.Mesh(dorsalGeo, finMat);
  dorsal.position.set(0,0.46,-0.05);
  group.add(dorsal);

  const fins=[];
  for(const side of [-1,1]){
    const f=new THREE.Mesh(pectGeo, finMat);
    f.scale.set(0.035,0.16,0.3);
    f.position.set(side*0.37,-0.1,0.32);
    f.rotation.y=side*(Math.PI-0.55);
    f.rotation.z=side*-0.35;
    f.userData={baseZ:f.rotation.z};
    fins.push(f);
    group.add(f);
  }
  for(const side of [-1,1]){
    const e=new THREE.Mesh(eyeGeo, eyeMat);
    e.position.set(side*0.27,0.14,0.74);
    group.add(e);
  }
  group.scale.setScalar(cfg.size);
  return {group, tailGroup, fins};
}

const _v1=new THREE.Vector3(), _v2=new THREE.Vector3(), _v3=new THREE.Vector3(), _v4=new THREE.Vector3();
const _dummy=new THREE.Object3D();

class Fish{
  constructor(cfg){
    const b=buildFish(cfg);
    this.group=b.group; this.tailGroup=b.tailGroup; this.fins=b.fins;
    this.pos=this.group.position;
    this.pos.set((Math.random()*2-1)*BOUND.x*0.9,(Math.random()*2-1)*BOUND.y*0.8,(Math.random()*2-1)*BOUND.z*0.8);
    this.vel=new THREE.Vector3(Math.random()-0.5,(Math.random()-0.5)*0.4,Math.random()-0.5).normalize().multiplyScalar(cfg.cruise);
    this.acc=new THREE.Vector3();
    this.size=cfg.size;
    this.cruise=cfg.cruise;
    this.school=!!cfg.school;
    this.offset=new THREE.Vector3((Math.random()-0.5)*2.6,(Math.random()-0.5)*1.6,(Math.random()-0.5)*2.6);
    this.goal=new THREE.Vector3();
    this.goalTimer=0;
    this.scare=0;
    this.wag=Math.random()*20;
    this.dodge=new THREE.Vector3(1,0,0);
    this.prevYaw=null;
    this.bank=0;
    scene.add(this.group);
  }
  update(dt,t,ray,leader){
    const p=this.pos, v=this.vel, a=this.acc;
    a.set(0,0,0);

    if(this.school){
      _v1.copy(leader).add(this.offset).sub(p).normalize().multiplyScalar(this.cruise*1.3);
      a.addScaledVector(_v1.sub(v), 2.4);
    }else{
      this.goalTimer-=dt;
      if(this.goalTimer<=0 || p.distanceTo(this.goal)<1.6){
        this.goal.set((Math.random()*2-1)*(BOUND.x-2),(Math.random()*2-1)*(BOUND.y-1.4),(Math.random()*2-1)*(BOUND.z-1.6));
        this.goalTimer=3+Math.random()*5;
      }
      _v1.copy(this.goal).sub(p).normalize().multiplyScalar(this.cruise);
      a.addScaledVector(_v1.sub(v), 0.9);
    }
    a.y+=Math.sin(t*1.25+this.wag)*0.35;

    // soft wall avoidance
    const mx=2.4, my=1.8, mz=2.2, k=7;
    if(p.x> BOUND.x-mx) a.x-=k*(p.x-(BOUND.x-mx))/mx;
    if(p.x<-BOUND.x+mx) a.x+=k*((-BOUND.x+mx)-p.x)/mx;
    if(p.y> BOUND.y-my) a.y-=k*(p.y-(BOUND.y-my))/my;
    if(p.y<-BOUND.y+my) a.y+=k*((-BOUND.y+my)-p.y)/my;
    if(p.z> BOUND.z-mz) a.z-=k*(p.z-(BOUND.z-mz))/mz;
    if(p.z<-BOUND.z+mz) a.z+=k*((-BOUND.z+mz)-p.z)/mz;

    // flee from the pointer ray
    if(ray){
      _v1.subVectors(p, ray.origin);
      const proj=_v1.dot(ray.direction);
      if(proj>0){
        _v2.copy(ray.origin).addScaledVector(ray.direction, proj);
        const d=p.distanceTo(_v2);
        const R=4.4+this.size;
        if(d<R){
          const f=1-d/R;
          _v3.subVectors(p,_v2).normalize();
          if(this.scare<0.25){
            this.dodge.set(Math.random()-0.5,(Math.random()-0.5)*0.6,Math.random()-0.5);
            if(this.dodge.lengthSq()<0.02) this.dodge.set(1,0,0);
            this.dodge.normalize();
            v.addScaledVector(_v3, 1.6);
          }
          a.addScaledVector(_v3, 85*f);
          a.addScaledVector(this.dodge, 26*f);
          this.scare=1;
        }
      }
    }
    this.scare=Math.max(0,this.scare-dt*0.9);

    // integrate
    v.addScaledVector(a,dt);
    const maxSp=this.cruise*(1+2.6*this.scare);
    let spd=v.length();
    if(spd>maxSp){ v.multiplyScalar(maxSp/spd); spd=maxSp; }
    if(spd<0.4 && spd>1e-5){ v.multiplyScalar(0.4/spd); spd=0.4; }
    p.addScaledVector(v,dt);

    if(Math.abs(p.x)>BOUND.x){ p.x=Math.sign(p.x)*BOUND.x; v.x*=-0.6; }
    if(Math.abs(p.y)>BOUND.y){ p.y=Math.sign(p.y)*BOUND.y; v.y*=-0.6; }
    if(Math.abs(p.z)>BOUND.z){ p.z=Math.sign(p.z)*BOUND.z; v.z*=-0.6; }

    // orientation + banking into turns
    const yaw=Math.atan2(v.x,v.z);
    if(this.prevYaw===null) this.prevYaw=yaw;
    let dyaw=yaw-this.prevYaw;
    if(dyaw>Math.PI)dyaw-=2*Math.PI; else if(dyaw<-Math.PI)dyaw+=2*Math.PI;
    this.prevYaw=yaw;
    const tb=THREE.MathUtils.clamp(-dyaw/Math.max(dt,1e-4)*0.22,-0.65,0.65);
    this.bank+=(tb-this.bank)*Math.min(1,dt*4);
    _v4.copy(v).normalize();
    _dummy.position.set(0,0,0);
    _dummy.lookAt(_v4);
    _dummy.rotateZ(this.bank);
    this.group.quaternion.slerp(_dummy.quaternion, 1-Math.exp(-6*dt));

    // tail & fin beat (faster when scared / fast)
    this.wag+=dt*(6.5+spd*2.2+this.scare*11);
    this.tailGroup.rotation.y=Math.sin(this.wag)*(0.42+0.3*this.scare);
    for(let i=0;i<this.fins.length;i++){
      const f=this.fins[i];
      f.rotation.z=f.userData.baseZ+Math.sin(this.wag*0.85+i*2.1)*0.28;
    }
  }
}

const schoolTex=makeFishTexture('#a9d6ee','#ffffff',-1,true);
const PALETTES=[
  {base:'#ff9130',accent:'#ffe9c9',pattern:0},
  {base:'#ffd23f',accent:'#ff8c2a',pattern:1},
  {base:'#4f8dff',accent:'#a5e8ff',pattern:0},
  {base:'#ff5f7e',accent:'#ffe9c9',pattern:1},
  {base:'#8fe07f',accent:'#ffd23f',pattern:0},
  {base:'#a882ff',accent:'#ffe9c9',pattern:1},
  {base:'#2fd6c3',accent:'#0a3a44',pattern:0},
];
PALETTES.forEach(p=>p.tex=makeFishTexture(p.base,p.accent,p.pattern,false));

const fishes=[];
for(let i=0;i<26;i++){
  const pal=PALETTES[i%PALETTES.length];
  fishes.push(new Fish({size:0.55+Math.random()*0.5, cruise:1.5+Math.random()*1.2, tex:pal.tex, accent:pal.accent}));
}
for(let i=0;i<14;i++){
  fishes.push(new Fish({size:0.3+Math.random()*0.08, cruise:2.7+Math.random()*0.8, tex:schoolTex, accent:'#e8f7ff', school:true}));
}

const leader=new THREE.Vector3();
function updateLeader(t){
  leader.set(Math.sin(t*0.42)*8+Math.sin(t*1.05)*1.6, Math.sin(t*0.75)*2.1, Math.cos(t*0.53)*3.4);
}

function separation(){
  for(let i=0;i<fishes.length;i++){
    const fi=fishes[i], pi=fi.pos, ai=fi.acc;
    for(let j=i+1;j<fishes.length;j++){
      const fj=fishes[j], pj=fj.pos;
      const dx=pi.x-pj.x, dy=pi.y-pj.y, dz=pi.z-pj.z;
      const r=(fi.size+fj.size)*0.85+0.35;
      const d2=dx*dx+dy*dy+dz*dz;
      if(d2<r*r && d2>1e-6){
        const d=Math.sqrt(d2), inv=1/d;
        const ov=(1-d/r)*6;
        const nx=dx*inv*ov, ny=dy*inv*ov, nz=dz*inv*ov;
        ai.x+=nx; ai.y+=ny; ai.z+=nz;
        fj.acc.x-=nx; fj.acc.y-=ny; fj.acc.z-=nz;
      }
    }
  }
}

/* ---------------- shockwave pulses ---------------- */
const pulses=[];
const pulseGeo=new THREE.RingGeometry(0.86,1.0,48);
pulseGeo.rotateX(-Math.PI/2);
function spawnPulse(pt){
  const mat=new THREE.MeshBasicMaterial({color:0xaef0ff, transparent:true, opacity:0.75,
    side:THREE.DoubleSide, depthWrite:false, blending:THREE.AdditiveBlending, fog:false});
  const mesh=new THREE.Mesh(pulseGeo,mat);
  mesh.position.copy(pt);
  scene.add(mesh);
  pulses.push({mesh, t:0, mat});
  for(const f of fishes){
    const d=f.pos.distanceTo(pt);
    if(d<9.5){
      const kk=1-d/9.5;
      f.scare=1;
      _v1.subVectors(f.pos,pt).normalize();
      f.vel.addScaledVector(_v1, 4.5*kk);
    }
  }
}

/* ---------------- input ---------------- */
const pointer=new THREE.Vector2(0,0.1);
let pointerActive=false, moved=0;
const raycaster=new THREE.Raycaster();
const hPlane=new THREE.Plane(new THREE.Vector3(0,1,0),0);
const hitPt=new THREE.Vector3();
const hintEl=document.getElementById('hint');

function setPointer(e){
  pointer.x=(e.clientX/innerWidth)*2-1;
  pointer.y=-(e.clientY/innerHeight)*2+1;
  pointerActive=true;
}
addEventListener('pointermove',e=>{
  moved+=Math.abs(e.movementX||0)+Math.abs(e.movementY||0);
  setPointer(e);
  if(moved>80) hintEl.classList.add('dim');
});
addEventListener('pointerdown',e=>{
  setPointer(e);
  raycaster.setFromCamera(pointer,camera);
  if(!raycaster.ray.intersectPlane(hPlane,hitPt)) raycaster.ray.at(16,hitPt);
  spawnPulse(hitPt);
  hintEl.classList.add('dim');
});

/* ---------------- camera framing ---------------- */
function fitCamera(){
  const aspect=innerWidth/innerHeight;
  camera.aspect=aspect;
  camera.fov=aspect<0.9?66:52;
  const halfH=Math.atan(Math.tan(THREE.MathUtils.degToRad(camera.fov/2))*aspect);
  camera.position.z=THREE.MathUtils.clamp(15.2/Math.tan(halfH)+3.5, 22, 38);
  camera.updateProjectionMatrix();
}
fitCamera();
addEventListener('resize',()=>{
  renderer.setSize(innerWidth,innerHeight);
  fitCamera();
});

/* ---------------- main loop ---------------- */
const clock=new THREE.Clock();
const ghost=new THREE.Vector2();
let lastGhostPulse=-3.8; // a "ghost fin" patrols so the scene is alive before any input

function animate(){
  requestAnimationFrame(animate);
  const dt=Math.min(clock.getDelta(),0.05);
  const t=clock.elapsedTime;

  if(pointerActive){
    raycaster.setFromCamera(pointer,camera);
  }else{
    ghost.set(Math.sin(t*0.5)*0.55, Math.sin(t*0.74+1.4)*0.38);
    raycaster.setFromCamera(ghost,camera);
    if(t-lastGhostPulse>6.5){
      lastGhostPulse=t;
      if(!raycaster.ray.intersectPlane(hPlane,hitPt)) raycaster.ray.at(16,hitPt);
      spawnPulse(hitPt);
    }
  }
  const ray=raycaster.ray;

  updateLeader(t);
  separation();
  for(const f of fishes) f.update(dt,t,ray,leader);

  for(let i=pulses.length-1;i>=0;i--){
    const pu=pulses[i];
    pu.t+=dt*0.95;
    pu.mesh.scale.setScalar(0.5+pu.t*11);
    pu.mat.opacity=0.7*(1-pu.t);
    if(pu.t>=1){ scene.remove(pu.mesh); pu.mat.dispose(); pulses.splice(i,1); }
  }

  bubbleMat.uniforms.uTime.value=t;
  for(const wm of weedMats) wm.uniforms.uTime.value=t;
  floorCaustic.offset.set(t*0.012, t*0.006);
  surfaceTex.offset.set(-t*0.02, t*0.013);
  floorMat.emissiveIntensity=0.45+Math.sin(t*0.8)*0.1;
  for(let i=0;i<rays.length;i++){
    rays[i].material.opacity=(0.10+0.04*Math.sin(t*0.5+i*1.7))*(0.9+0.1*Math.sin(t*0.23+i));
  }

  const nd=pointerActive?pointer:ghost;
  const s=Math.min(1,dt*2.2);
  camera.position.x+=(nd.x*3.1-camera.position.x)*s;
  camera.position.y+=((1.0+nd.y*1.9)-camera.position.y)*s;
  camera.lookAt(0,0.3,0);

  renderer.render(scene,camera);
}
animate();
</script>
</body>
</html>
Reasoning trace (33,400 tokens)
I need to create a 3D aquarium with Three.js where fish swim away from the mouse. Requirements:

1. Single HTML file with import map for Three.js 0.170.0
2. Fish swim around, and when the mouse moves near them, they flee
3. Must be visible on first paint - the aquarium scene itself is the demo, so it should render immediately
4. Works in 1280×800 and adapts to 375px
5. No console errors
6. No forbidden APIs (no localStorage, alert, etc.)
7. Since it uses mouse (not keyboard), but I could still add a subtle hint. Actually the mouse works in the iframe? The iframe receives mouse events by default I believe - actually pointer events work without clicking. But keyboard needs a click first. I'll use mouse/touch which should work directly. Let me add a small hint about moving the mouse.

Let me design the aquarium:

- Scene: underwater environment with fog (blue-green), caustics effect maybe via animated light or shader, bubbles, seaweed, sand floor, rocks, glass tank edges maybe.
- Fish: procedurally built fish (body from lathe/sphere scaled, tail fin that wags, different colors). Maybe 25-40 fish with boid-like behavior: wander, avoid walls, flee from mouse cursor ray.
- Mouse interaction: project mouse into 3D (ray onto a plane at certain depth, or closest point on ray to fish). When distance < threshold, fish flee with stronger velocity, plus a visual "startle" - maybe speed up, tail wag faster.
- Fish orientation: face velocity direction, banking on turns.
- Tail wag animation: tail fin rotation oscillates faster when speed increases.
- Bubbles rising, seaweed swaying (shader or simple rotation), light rays (god rays fake with transparent planes).
- Caustics: could do a simple animated texture on floor via canvas texture, or a shader. Keep simple: animated caustics on the sand via a custom shader or a moving light. Maybe a canvas-generated caustic texture applied to floor, animated by offsetting. Simple approach: create caustic texture procedurally on canvas (Voronoi-ish), use it as a light map with animated offset. Or use two overlapping textures moving in opposite directions.

Fish construction:
- Body: sphere geometry scaled (1, 0.45, 0.55) or use a lathe. Sphere scaled is fine and cheap.
- Tail: cone or a flattened triangle shape; use a plane with shape? Simple: ConeGeometry rotated, flattened, or use two triangles. I'll make a tail from a flattened cone or custom BufferGeometry. Easiest good-looking: use a cone squashed on one axis, attached at the back, rotating around Y for wag.
- Fins: small flattened cones on sides.
- Eye: small black spheres.
- Colors: random from a nice palette (orange clownfish, blue tang, yellow, etc.). Could use MeshStandardMaterial or MeshPhongMaterial. With fog it will look underwater.

Maybe add a "big fish" or a school? Keep performance fine.

Also stripes? Could add stripes via a canvas texture per color variant. Might be nice but adds complexity. I can generate a small canvas texture with stripes and apply to body — clownfish look. Let's do a few texture variants: striped, dotted, plain. Generate canvas textures at startup.

Behavior (boids-lite):
- Each fish has position, velocity, maxSpeed (normal ~1.5), wanderTarget changes occasionally.
- Forces: seek wander point, wall avoidance (steer back inside bounds), separation from other fish (light), flee from mouse point.
- Flee: compute vector from mousePoint to fish; if dist < fleeRadius, add strong force away, and temporarily boost maxSpeed. Also trigger "startle" timer for faster tail wag and maybe slight color? Keep simple: faster wag.

Mouse point: cast ray from camera through mouse; find point on ray at the depth of tank center plane, or find closest point on the ray to each fish — better: for each fish, compute distance from fish to the mouse ray. If within radius, flee direction = fish position minus closest point on ray. That's more accurate 3D interaction. Let's do that: ray from camera through mouse. distance from fish to ray = |(p - origin) - ((p-origin)·dir)dir|. If < radius, flee.

Touch support: touchmove updates mouse.

Tank bounds: a box, e.g., x ∈ [-14,14], y ∈ [-6,6], z ∈ [-6,6]. Camera at (0, 2, 22) looking at origin. Fog so edges fade.

Walls of tank: maybe draw a subtle glass box (wireframe or translucent). Since it's "aquarium", a glass tank with frame could look nice, but with camera outside, glass adds refraction complexity — just add subtle edges (lines) and a darker vignette. Or skip walls visually, just an open water scene with sandy floor. I'll add faint glass panels (MeshPhysicalMaterial transparent) plus edges. Careful with transparency sorting. Maybe just edges lines (LineSegments of box) plus slight translucent panels with low opacity. Should be fine.

Floor: sand-colored plane with some bumps? Plane with vertex noise, sand color, receives caustic texture animation. Caustics: generate caustic canvas texture (draw random cells / Voronoi lines). Animate two copies offset.

Simple caustic generation: draw many random arcs/lines with white strokes on transparent, blur-ish. Use globalCompositeOperation 'lighter'. Then use as map on a slightly emissive material? Better: use it as a second material layer: floor material with map = sand texture, plus a second plane above with the caustic texture, blending additive, opacity ~0.25, and animate texture.offset and rotation slightly. Two planes moving oppositely for shimmer.

Seaweed: several strands made of stacked cylinders? Simpler: use a plane ribbon with shader sway, or a chain of segments rotated by sine in animate. Easiest: create a seaweed as a group of segments (cylinders) with hierarchical rotation animated by phase. Or a custom shader on a tall plane geometry (segments along height) that bends with sin(time + y). Shader approach is elegant: green plane, vertex shader displaces x by sin(time*speed + y * freq) * (y/height)^2. I'll do a ShaderMaterial with lighting faked (just gradient color + slight side shading). Use DoubleSide. That looks good.

Bubbles: small spheres or sprites rising, wrap at top. Use Points with a circular sprite texture (canvas radial gradient). Animate positions in JS or shader. Points with per-point speed in attribute and shader animation is efficient. I'll do a simple ShaderMaterial for points: y increases with time*warp, x wobble sin. Reset when above top via mod.

God rays: a few large transparent planes from top with gradient opacity, slightly rotating. Could be nice: cone of light using additive material with vertical gradient texture. Keep subtle.

Ambient fish startle visual: fish could dart, and maybe emit a bubble burst? Extra. Keep: dart + faster tail.

HUD: title "Aquarium — move your mouse to startle the fish", subtle. Also FPS? No need.

Also handle window resize.

Auto-demo mode: since this is not a game needing start, but instructions say games must start in self-playing demo behind "Click or press a key to start". This is an interactive scene, not exactly a game. But to be safe, everything visible on first paint: fish already swimming. Mouse works on hover (pointermove works in iframe without click? I believe pointer events are delivered to iframes normally; keyboard needs focus). I'll still show a hint "Move your mouse to startle the fish" that fades after a few seconds or on first move. Also add touch.

Also add a gentle camera parallax: camera slightly moves toward mouse for depth feel. But careful: mouse ray computation should use camera at time of projection — recompute ray from current camera each frame with last mouse NDC. Fine.

Also maybe a click creates a ripple that scares all fish nearby strongly? Click = big startle pulse. Nice touch: on click, shockwave ring expanding and all fish within larger radius dart. Fun and visible. Add expanding torus/ring mesh fading.

Let me also add a "shoal" of tiny fish (a school) that moves as a group — adds visual richness. Maybe 20 tiny fish that follow a moving leader point with noise. Nice.

Performance: ~35 fish, each fish a Group with ~5 meshes (body, tail, 2 fins, 2 eyes). ~200 meshes, fine.

Lighting: hemisphere light (blue from top, dark bottom), directional light from above (like surface light), maybe a point light. Fog: FogExp2 with teal color.

Background: gradient? Scene background color deep blue; fog matches. Could add gradient via large sphere with shader — simpler: scene.background = color, plus fog. Add a big back plane? Fog handles it.

Caustic texture generation:
```js
function makeCausticTexture() {
  const c = document.createElement('canvas'); c.width = c.height = 512;
  const ctx = c.getContext('2d');
  ctx.fillStyle = 'rgba(0,0,0,0)';
  ...
  // draw random smooth blobs: many overlapping arcs
}
```
A known trick: draw ~100 random "cells": for each, pick center, draw a few concentric wobbly circles with white stroke, low alpha, blur via shadowBlur. Use ctx.filter = 'blur(2px)' — supported in Chrome. Use lighter composite.

Fish textures: canvas 128x64 with base color and stripes. Map onto sphere; stripes will wrap. Good enough.

Body shape: SphereGeometry(1, 24, 16), scale (1.1, 0.55, 0.42)? Actually fish forward direction: I'll build fish facing +Z? Typically model faces +X or -Z. I'll make fish face +Z forward, with tail at -Z, using lookAt to orient. Actually easier: use Object3D.lookAt(target) which orients +Z toward target... Object3D.lookAt orients the object's +Z axis toward the target? For cameras it's -Z. For regular objects, lookAt rotates so that +Z points toward target. Yes, for non-camera objects, +Z faces the target. So build fish with nose at +Z, tail at -Z.

Body: sphere scaled: scale.set(0.5(w half? ), ...). Let's set body radius 1, then group.scale by size. Body mesh: geometry sphere r=1, mesh.scale.set(0.55, 0.62, 1.0)? Hmm body proportions: length along Z ~ 2 (r=1 scaled z=1), height 0.6, width 0.45. So mesh.scale.set(0.45, 0.6, 1.0)? That makes nose at z=1, tail attach at z≈-0.9.

Tail: custom triangle-ish fin: use a flattened cone: ConeGeometry(0.5, 0.8, 4) rotated to point backward, flattened on x. Or BufferGeometry with two triangles forming a V-tail. I'll do simple: ConeGeometry(0.45, 0.9, 3) rotated so it points -Z, flattened x (scale x 0.15), positioned at z=-1.2. Wag: rotate around Y axis at pivot; put tail in a sub-group pivoted at z=-0.8 so rotation swings the tail.

Dorsal fin: flattened cone on top pointing up-back. Side fins: small flattened spheres/cones angled.

Eyes: two small spheres near nose, black with white? Just black; plus tiny white highlight sphere maybe. Keep black.

Tail wag: tailGroup.rotation.y = sin(t*speedFreq + phase) * amp; amp/freq increase with speed. Also body slight roll on turn: bank using angular velocity of heading — simpler: roll proportional to lateral steering. Could approximate with rotation.z = clamp(turnRate). We can compute turn by comparing velocity direction change. Simpler: bank = (lateral acceleration) — skip, or subtle: roll = sin slight. I'll compute yaw rate: previous heading angle vs current; store fish.yaw smoothly; roll = -yawRate * factor. Might be jittery; smooth it. Let's attempt lightly with damping; if complexity grows, keep small constant subtle roll by velocity.x relative. Keep simple: banking via cross product of prevVel and vel y-component.

Movement update (per frame dt):
- steering: desired = normalize(wanderTarget - pos) * cruiseSpeed
- wall avoidance: for each axis, if pos beyond bound - margin, add force inward proportional. Simpler: desired velocity blended: if outside soft bounds, steer toward center strongly.
- separation: check nearby fish (O(n²) with 40 fish = 1600 checks, fine).
- flee: ray distance check; force = dir * fleeStrength * (1 - d/radius); also boost speed multiplier decaying.
- vel += (desired - vel) * turnFactor * dt plus flee force added directly.
- clamp speed between min and max (max = cruise * speedBoost).
- pos += vel*dt; keep inside hard bounds clamp with bounce.

Speed boost: fish.scare = 1 when fleeing, decays: scare *= exp(-dt*1.5). maxSpeed = cruise*(1 + 2.5*scare). Tail freq = base + scare*...

Also whisker: when fish flee, maybe small chance to drop a bubble? skip.

School fish: one leader point moving on Lissajous path; tiny fish steer to leader with offsets and jitter, also flee mouse (stronger since small). Reuse same fish builder with small scale and single color. I'll just include them in the same flock system but with a school behavior flag: attract to leader strongly. Simpler: create a "school" of 16 small fish whose wander target is shared and updated along a path, plus strong cohesion. Implement: each frame, schoolTarget moves along curve; small fish desired = schoolTarget + offset_i. Also flee.

I'll implement one Fish class with params; small fish get attract-to-schoolTarget force.

Ring pulse on click: mesh ring (RingGeometry) expanding at the click point on the mid-plane; scale up, fade opacity; also set scare for all fish within big radius (e.g., 8 units) — scare amount scaled by distance. Also maybe camera shake tiny? skip.

Bubbles: Points with ~120 points; shader: position attribute base pos; time uniform; y = mod(baseY + speed*t, height) - ... wobble x/z with sin(t + phase). gl_PointSize attenuated by distance. Texture: radial gradient circle with transparent edge, additive-ish blending, opacity low. Points material transparent depthWrite false.

Light rays: 3-4 planes with gradient texture (white to transparent), positioned near top slanting, additive, low opacity, slowly swaying rotation. Generate gradient canvas.

Seaweed shader material:
```glsl
vertex: 
  uniform float uTime;
  varying float vY;
  void main(){
    vY = uv.y;
    vec3 p = position;
    float sway = sin(uTime*1.2 + position.y*0.6 + uPhase) * pow(uv.y, 1.5) * uAmp;
    p.x += sway; p.z += cos(uTime*0.9 + uPhase)*pow(uv.y,1.5)*uAmp*0.6;
    ...
  }
```
Use PlaneGeometry(w, h, 1, 12) with uv.y from 0..1. Color gradient darker at bottom via varying vY mixing two greens. DoubleSide. uPhase random per plant — need per-material uniforms; make material per plant (few plants ~10) fine.

Rock: a few dodecahedron/icosahedron geometries with flat shading, gray, scattered on floor, scaled randomly. Maybe merge? Fine as meshes.

Floor: PlaneGeometry big, with sand color; add vertex noise for dunes (PlaneGeometry 64x64 segments, displace y with noise, compute normals). Caustic overlay planes slightly above following same displacement? That's tricky with displacement — keep floor flat-ish and caustics flat planes at y=0.01 slightly above; if floor has small bumps, caustic plane may intersect. Option: put caustic texture directly as emissiveMap on floor material with two animated maps? Standard material has one emissiveMap. Could use onBeforeCompile to add second caustic layer... simpler: use MeshStandardMaterial with emissive white, emissiveMap = causticTex, animate emissiveMap.offset in render loop — one layer shimmer. Good enough: animate offset in circular motion + scale pulse. Do that. And maybe secondary: modulate emissiveIntensity.

Actually caustics look better when they also appear on fish? Overkill. Floor only fine. Also add caustics to back wall? no.

Vignette / UI: overlay with title and hint; CSS gradient vignette using pointer-events none. Bottom hint fades out after first significant mouse move? Keep it always visible but subtle; also show "click for a shockwave" hint. Fine.

Camera parallax: target camera position = base + (mouseNDC.x * 2.5, mouseNDC.y * 1.5), lerp; lookAt center. Must recompute raycaster with updated camera each frame — yes, do raycast in animate using current camera and pointer NDC.

Pointer NDC from mousemove: px = (e.clientX/w)*2-1 etc.

Touch: touchstart/touchmove set pointer; also trigger pulse on touchstart.

Let me make sure no console errors: guard everything. Three r170: use THREE.FogExp2, MeshStandardMaterial etc. Note: in newer three versions, `outputColorSpace` default SRGB. Use ACESFilmicToneMapping maybe for nicer look; set toneMapping = THREE.ACESFilmicToneMapping, toneMappingExposure 1.1. Colors adjust.

Import map given. Use module script: `<script type="module">` with `import * as THREE from 'three'`. Don't need addons (no OrbitControls since mouse used for scaring). Could include subtle auto camera drift.

Edge case: sandboxed iframe, mouse events fine.

Now write code carefully.

Structure:

```html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Coral Aquarium</title>
<style> ... </style>
<script type="importmap">...</script>
</head>
<body>
<div id="ui">
  <h1>Kelp &amp; Neon — Interactive Aquarium</h1>
  <p id="hint">Move your cursor to startle the fish · click for a shockwave</p>
</div>
<canvas id="c"></canvas>
<script type="module">
import * as THREE from 'three';
... all code
</script>
</body>
</html>
```

UI styling: absolute top-left, gradient text, backdrop blur subtle. Vignette overlay via body::after radial-gradient.

Renderer: new THREE.WebGLRenderer({canvas, antialias:true}); setPixelRatio(min(devicePixelRatio,2)); setSize.

Scene: background new THREE.Color(0x03253a) deep teal; fog FogExp2(0x06344a? , 0.028). Let me choose background 0x04283d and fog same 0.025 — camera at 22 distance, tank extends to z=8; fog density tune so far edge slightly faded: density 0.022 gives exp(-(0.022*30)^2)= exp(-0.435)=0.65 visibility at 30 units — decent. FogExp2 factor = exp(-(d*density)^2)? Actually FogExp2: factor = 1 - exp(-density² * depth²)... In three: fogFactor = 1.0 - exp( - fogDensity * fogDensity * fogDepth * fogDepth ). So at depth 30, density 0.02: exp(-0.0004*900)=exp(-0.36)=0.70 → 30% fogged. Fine.

Lights:
- HemisphereLight(0x9fd8ff, 0x0a2a33, 0.9)
- DirectionalLight(0xcfefff, 1.6) pos (4, 12, 3)
- Maybe a soft point light warm near center? skip.

Floor y=-7. Tank bounds: x ±15, y from -6.5 to 6.5, z ±7. Camera (0, 1.5, 24), lookAt (0,0,0). FOV 55.

Floor at y = -7, plane size 80.

Glass tank: subtle — add LineSegments box edges with LineBasicMaterial color 0x7fd4ff transparent opacity 0.18, sized to bounds. Also maybe corner posts? Edges enough. Actually a full box edge outline of the swim bounds might look odd floating mid-water with floor below. The tank: make it a proper tank: floor glass walls? I'll do: EdgesGeometry of BoxGeometry(30, 13, 14) centered y=0 — bottom edge at -6.5, floor at -7 (sand slightly below glass). Looks like tank interior. Add translucent panels MeshPhysicalMaterial({transmission...}) heavy; instead MeshBasicMaterial color 0xbfe9ff opacity 0.03 side BackSide? Very subtle tint on far walls only: use BoxGeometry with MeshBasicMaterial({color:0x9fdcff, transparent:true, opacity:0.05, side:THREE.BackSide}) so we see inside faces. That gives subtle depth. OK.

Caustic texture generation:

```js
function makeCaustic(size=256){
  const c=document.createElement('canvas');c.width=c.height=size;
  const x=c.getContext('2d');
  x.clearRect(0,0,size,size);
  x.globalCompositeOperation='lighter';
  for(let i=0;i<60;i++){
    const cx=Math.random()*size, cy=Math.random()*size, r=10+Math.random()*30;
    x.beginPath();
    for(let a=0;a<=Math.PI*2+0.01;a+=Math.PI*2/24){
      const rr=r*(1+0.35*Math.sin(a*3+ i)+0.2*Math.sin(a*5+i*2));
      const px=cx+Math.cos(a)*rr, py=cy+Math.sin(a)*rr;
      a===0?x.moveTo(px,py):x.lineTo(px,py);
    }
    x.closePath();
    x.strokeStyle='rgba(180,230,255,0.16)';
    x.lineWidth=2+Math.random()*2;
    x.filter='blur(1.5px)';
    x.stroke();
  }
  const t=new THREE.CanvasTexture(c);
  t.wrapS=t.wrapT=THREE.RepeatWrapping;
  return t;
}
```
This gives wobbly rings; overlapping gives cell-like caustics. Reasonable.

Floor material: MeshStandardMaterial({color:0xd9c08a? sand but underwater → 0xcbb677 tinted by light; map maybe subtle noise? Just color, roughness 1. emissive: 0xffffff? emissive with emissiveMap: emissive color set to 0x8fd8ff (cyan), emissiveMap caustic, emissiveIntensity 0.35. Animate caustic.offset.x += dt*0.01 etc. Note emissiveMap requires emissive non-black. Good.

Also secondary caustic plane? Skip.

Fish texture variants:

```js
function fishTexture(base, stripe, type){
  canvas 128x64; fill base; if type stripes: vertical bars; if dots: circles; gradient shading darker on bottom? add slight vertical gradient overlay.
  texture colorSpace = SRGBColorSpace.
}
```
Apply to body sphere; fins/tail use plain color material matching stripe color or darker base.

Palettes: clown: base #ff8c1a stripes white; tang: #2f6df6 with dark? plain with yellow tail — keep simpler: variants: [{base:'#ff9a2a', accent:'#fff'},{base:'#3f7dff', accent:'#ffd23f'},{base:'#ffd23f'},{base:'#ff5470'},{base:'#7fe0ff'},{base:'#a279ff'}]. Stripe types: 'stripes','dots','none'.

Body material: MeshStandardMaterial({map:tex, roughness:0.55, metalness:0.1, emissive: base dim? maybe slight}). Fins: MeshStandardMaterial({color:accent, transparent:true, opacity:0.85, side:DoubleSide, roughness:0.6}).

Tail geometry: build a fin shape via Shape:
```js
const shape=new THREE.Shape();
shape.moveTo(0,0);
shape.quadraticCurveTo(0.5,0.12, 0.85,0.45);
shape.quadraticCurveTo(0.55,0.0, 0.85,-0.45);
shape.quadraticCurveTo(0.5,-0.12, 0,0);
```
ShapeGeometry, then rotate so it lies in the Y-Z plane pointing back: The shape is in XY plane; we want the tail fin extending in -Z with vertical spread in Y. So create shape where x = backward distance, y = vertical spread; then rotate Y by 90deg so x→-z? Let's think: ShapeGeometry lies in XY plane facing +Z. If I keep it in XY plane and place at rear... the fin should be visible from the side (camera sees side of fish). Camera looks down -Z at fish swimming in XY-ish plane mostly. Fish face +Z (toward/away camera sometimes). Tail extends -Z with spread in Y. So fin plane should be the ZY plane? No wait — a tail fin is a flat surface; its large faces face sideways (±X) so it's visible from side view. So the fin plane spans Z (backward) and Y (vertical), normal along X. ShapeGeometry in XY: spans X,Y, normal +Z. Transform: rotateY(-90°): x→? rotation Y by -90° maps +X to -Z? rotateY(θ): x' = x cosθ + z sinθ; z' = -x sinθ + z cosθ. θ=-π/2: cos=0,sin=-1: x' = -z, z' = x. So point (1,0,0) → (0,0,1): +X→+Z. We want +X→-Z, so θ=+π/2: x'=z, z'=-x: (1,0,0)→(0,0,-1). Yes rotateY(Math.PI/2) maps shape's +X to world -Z. 

So tail shape drawn with x from 0 (attach) to +0.8 (tip backward), y spread ±0.45. After rotateY(π/2), extends -Z. Tail group at z=-0.75 (rear of body), rotate group.rotation.y for wag. Geometry translated so attach point at origin: shape starts at (0,0) good.

Dorsal fin: same shape smaller rotated to XY plane (as is, normal ±Z... need normal ±X too if flat sideways — dorsal fin is also vertical along body plane; it spans X (fore-aft) and Y (up), normal Z... hmm actually dorsal fin also is flat sideways: spans fore-aft (Z) and up (Y), normal X. Same transform: draw shape spanning x backward, y up; rotateY(π/2). Position top of body (y≈0.55), z≈0.

Pectoral fins: small, at sides, angled; use small shape rotated; simpler: small flattened cones. Or skip pectorals, keep dorsal+tail+eyes. Add two small side fins with slight flap animation? Nice: side fins flap with sin. Use small ShapeGeometry fin drawn in XY, placed at (±0.42, -0.05, 0.25), rotated to angle outward. Flap rotation.z oscillation. Let's include; cheap.

Eyes: SphereGeometry(0.07) black at (±0.28? , 0.12, 0.78). Body half-width at that point: body scale x 0.45 so half-width ~0.45 at center; at z=0.78 sphere surface narrower. Eye positions (±0.30, 0.14, 0.72), radius 0.085. Add tiny white glint sphere offset — skip, use MeshStandardMaterial black with high metalness low roughness for shine? Eyes: MeshBasicMaterial? Standard with color 0x111111, roughness 0.2 gives specular from lights. Fine.

Fish sizes: random 0.5–1.0 scale; small school fish 0.3.

Cruise speed: proportional to size maybe: speed = 2.2/size-ish. Set cruise = 1.6 + rnd*1.2, small fish 2.5.

Now flock update:

```js
class Fish {
 constructor(opts){...}
 update(dt, t, ray, bounds){...}
}
```

Flee from ray: 

```js
// closest point on ray to fish position p
const v = new THREE.Vector3().subVectors(p, ray.origin);
const proj = v.dot(ray.direction);
const closest = ray.origin.clone().addScaledVector(ray.direction, proj);
const d = p.distanceTo(closest);
if(d < fleeRadius){ dir = p - closest normalized; force = dir * strength * (1-d/radius); fish.scare = 1 }
```

fleeRadius ~ 4.5, strength ~ 30 (as accel). Also add slight perpendicular (sideways dodge) — random per fish sign for variety: add component along fish's preferred dodge vector.

Wall avoid:

```js
const m = 2.0; // margin
steer force: for each axis, if p.x > bx-m: f.x -= (p.x-(bx-m))/m * wallForce; similarly others.
```

Wander: each fish has wanderAngle changing: vel steering smooth: desired = current heading rotated slightly by noise. Simplest: fish has target point re-randomized when reached or every few seconds; desired = seek target at cruise. Plus vertical bob sin.

I'll do: fish.goal = random point in bounds; when within 2 or timer expires, pick new. desired = (goal-pos).norm * cruise. steering = (desired - vel)*turn. Also small sin bob added to goal? Add sin(t*f+phase)*0.4 to vy force.

Separation: for fish i, loop j>i, if dist < sepRadius (1.2*sizeAvg), push apart force. O(n²/2) with 45 fish ≈ 1000, fine.

Velocity update: vel += (steerAccel + fleeAccel + wallAccel + sepAccel) * dt; speed clamp: max = cruise*(1+2.2*scare)+..., min = 0.6*cruise? Min low so they can slow. clamp to max only; also clamp min 0.4.

Scare decay: scare = max(0, scare - dt*1.2) or exponential.

Orientation: if speed > 0.01: 
```js
const targetQuat = look from vel: 
tmpMat.lookAt(velDir... ) 
```
Use fish.group.quaternion.slerp(qTarget, 1 - exp(-dt*6)). qTarget from Matrix4().lookAt(pos, pos+vel, up). Then mesh roll banking: apply extra rotation: bank around local Z based on turn rate: compute yawRate = (atan2(vel.x, vel.z) - prevYaw)/dt smoothed; roll = clamp(-yawRate*0.5, ±0.6). Apply after lookAt via group.rotateZ(roll)? Since we slerp quaternion each frame from lookAt, then apply rotateZ(bank) — bank applied post each frame fresh (not accumulated) because quaternion resets from lookAt target each frame. Good.

Tail wag: fish.wagPhase += dt * (6 + speed*3 + scare*10); tailGroup.rotation.y = sin(wagPhase)*0.5*(0.5+speed/maxRef?) Keep amp ~0.45. Side fins flap similar smaller.

School: schoolLeader path: 
```js
schoolT += dt*0.25;
leader.x = sin(schoolT*0.7)*9 + sin(schoolT*1.3)*2;
leader.y = sin(schoolT*0.9)*2.5;
leader.z = cos(schoolT*0.6)*4;
```
Small fish: desired = leader + personalOffset (fixed random offset, slowly rotating). Strong seek force; separation stronger for them (avoid overlap). They also flee ray with priority.

Bubbles: implement with Points and shader:

attributes: aBase(vec3), aSpeed, aPhase, aSize.
vertex:
```glsl
uniform float uTime;
attribute float aSpeed; attribute float aPhase; attribute float aSize;
varying float vA;
void main(){
  vec3 p = aBase;
  float range = 15.0;
  p.y = aBase.y + mod(uTime*aSpeed + aPhase, range);
  p.x += sin(uTime*0.8 + aPhase*3.0 + p.y*0.4)*0.3;
  p.z += cos(uTime*0.7 + aPhase*2.0)*0.3;
  vA = smoothstep(15.0, 13.0, p.y... 
```
Hmm need fade near top: compute life = fract((uTime*aSpeed+aPhase)/range); alpha = sin(life*PI) for fade in/out. Also size maybe grows. gl_PointSize = aSize * (300/ -mvPosition.z).

fragment: sample circle: use texture uniform (canvas radial) or compute: d = length(gl_PointCoord-0.5); alpha = smoothstep(0.5,0.35,d)*0.5; also rim highlight: ring: alpha += smoothstep(0.02,0.0,abs(d-0.38))*0.6? Simple: alpha = smoothstep(0.5,0.42,d) * 0.35 + smoothstep(0.45,0.4,d)*smoothstep(0.33,0.4,d)*0.3 (ring). Keep simple ring-ish bubble: center transparent-ish, ring bright. color white-cyan, additive blending, depthWrite false.

Bubbles base positions scattered in tank, aBase.y = -6.5 (start at floor), range to top 13 → mod range 13.5.

God rays: make 3 planes with gradient canvas texture (white fading down), additive, opacity 0.12, positioned upper area tilted, slowly rotate around Y? Rays as PlaneGeometry(3, 12) hanging from y=6, rotation.z slight tilt, rotation.y varying, animate opacity subtle sin. Place behind fish (z negative) and some near. They're billboards? Static planes fine since camera mostly static. With camera parallax small, fine.

Click pulse: 
```js
function pulse(point){ create ring Mesh (RingGeometry(0.9,1.0,48)) at point facing camera (lookAt(camera.position)); push to pulses array {mesh, t:0}. In update: scale = 1 + t*14; opacity = (1-t)*0.7; t += dt*1.4; remove when t>1. Also scare fish within 9 units of point: scareAmount = 1 - d/9.
```
Material: MeshBasicMaterial({color:0x9fe8ff, transparent, side:DoubleSide, depthWrite:false, blending:Additive}).

Also small camera punch? skip.

Hint fade: after first pointermove, keep hint but change? I'll show hint for 6s then fade to lower opacity, reappear never. Simpler: hint stays with subtle pulse; fine. Actually instructions: for keyboard need "Click or press a key to start" — we use mouse only, so not required. But sandboxed iframe: does it receive mousemove without click? Pointer events yes. Good. Add touchmove.

Title UI: top-left, small; maybe top-center. Add FPS-ish? no.

Also show fish count? A tiny caption "12 species · 42 fish". Cute.

Resize handler: camera.aspect, renderer.setSize.

Ensure no errors: THREE.CanvasTexture colorSpace: tex.colorSpace = THREE.SRGBColorSpace for color maps.

Let me now also handle initial "screenshot in 3s": scene visible immediately with fish swimming; hint visible. Good.

Camera parallax: base (0,1.2,24); target += ndc*(3, 1.8). Lerp 3/s. lookAt(0,0.5,0).

One more nice thing: fish slightly attracted to food? no. Keep scope.

Let me write the full code now, carefully.

Details for fish geometry creation:

```js
function makeFishTexture(base, accent, pattern){
  const c = document.createElement('canvas'); c.width=128; c.height=64;
  const g = c.getContext('2d');
  g.fillStyle = base; g.fillRect(0,0,128,64);
  // belly lighter
  const grd = g.createLinearGradient(0,0,0,64);
  grd.addColorStop(0,'rgba(255,255,255,0.25)');
  grd.addColorStop(0.5,'rgba(255,255,255,0)');
  grd.addColorStop(1,'rgba(0,0,20,0.35)');
  g.fillStyle = grd; g.fillRect(0,0,128,64);
  g.fillStyle = accent;
  if(pattern===0){ // vertical stripes
    for(let i=0;i<4;i++){ const x=18+i*28; g.globalAlpha=0.9; g.fillRect(x,0,10,64); }
  } else if(pattern===1){ // dots
    for(let i=0;i<26;i++){ g.globalAlpha=0.85; g.beginPath(); g.arc(Math.random()*128, Math.random()*64, 3+Math.random()*4, 0, 7); g.fill(); }
  }
  const tex = new THREE.CanvasTexture(c);
  tex.colorSpace = THREE.SRGBColorSpace;
  return tex;
}
```

Note sphere UV: v from top to bottom; fine.

Fish builder:

```js
function buildFish(opts){
  const group = new THREE.Group();
  const s = opts.size;
  const bodyGeo = new THREE.SphereGeometry(1, 20, 14);
  const bodyMat = new THREE.MeshStandardMaterial({map: opts.tex, roughness:0.5, metalness:0.05});
  const body = new THREE.Mesh(bodyGeo, bodyMat);
  body.scale.set(0.42, 0.6, 1.0);
  group.add(body);
  
  const finMat = new THREE.MeshStandardMaterial({color: opts.accent, transparent:true, opacity:0.85, side:THREE.DoubleSide, roughness:0.6, metalness:0});
  
  // tail
  const tailGroup = new THREE.Group();
  tailGroup.position.z = -0.8;
  const tailGeo = tailFinGeo.clone(); // shape rotated
  const tail = new THREE.Mesh(tailGeo, finMat);
  tailGroup.add(tail);
  group.add(tailGroup);
  
  // dorsal
  const dorsal = new THREE.Mesh(dorsalGeo, finMat);
  dorsal.position.set(0, 0.5, -0.1);
  group.add(dorsal);
  
  // pectorals
  ...
  // eyes
  const eyeGeo = new THREE.SphereGeometry(0.09, 10, 8);
  const eyeMat = new THREE.MeshStandardMaterial({color:0x0a0a0a, roughness:0.25, metalness:0.2});
  ...
  group.scale.setScalar(s);
  return {group, tailGroup, fins:[fL,fR], ...}
}
```

Tail fin shape: 

```js
function finShape(len, spread, notch){
  const sh = new THREE.Shape();
  sh.moveTo(0,0);
  sh.quadraticCurveTo(len*0.4, spread*0.4, len, spread);
  sh.quadraticCurveTo(len*notch, 0, len, -spread);
  sh.quadraticCurveTo(len*0.4, -spread*0.4, 0, 0);
  return sh;
}
// tail: len 0.7, spread 0.42, notch 0.35
const tailGeo = new THREE.ShapeGeometry(finShape(0.7,0.45,0.35));
tailGeo.rotateY(Math.PI/2); // +X → -Z
```
Check rotateY(π/2) again: rotateY(θ) with θ=π/2: x' = x cos + z sin = z; z' = -x sin + z cos = -x. So (1,0,0)→(0,0,-1). Good: fin extends toward -Z, in YZ plane, normal along X. 

Dorsal: shape spanning X backward and Y up, but same rotation maps +X→-Z: dorsal fin attaches at front-top sweeping back. Shape: moveTo(0.35,0) → tip at (-? ) Let's draw in XY where +X backward, +Y up: moveTo(-0.3,0) (front), quadraticCurveTo(0.2, 0.5, 0.55, 0.15)?? Let me design: dorsal from z=+0.25 (front) to z=-0.35, height 0.4. In shape coords (x=back distance): front at x=-0.25, back at x=0.35. Points: start (-0.25, 0), curve up to (0.05, 0.45), down to (0.35, 0). Use shape: moveTo(-0.25,0); quadraticCurveTo(-0.05,0.5, 0.15,0.42); quadraticCurveTo(0.3,0.3, 0.35,0); close. Then rotateY(π/2) and place y=0.5 (top of body which is 0.6*scale... body half-height 0.6). Position (0, 0.52, -0.05). After rotation shape's y stays y. OK.

Pectoral fins: small fin shape (len 0.3, spread 0.18), rotateY(π/2) so plane YZ... wait pectorals stick out sideways, flat-ish horizontal-ish. Simplest: small shape in XZ? Make small fin, geometry rotated: create shape (x back, y spread), rotateY(π/2) → plane YZ, then rotateZ to angle outward and rotateY to sweep. Honestly small flattened cones look fine: ConeGeometry(0.12, 0.5, 6) rotated to point outward-back. Let me just use ShapeGeometry small fin, then mesh.rotation.set to orient: For left fin at (+x side): fin plane should be roughly horizontal (normal ~Y) pointing back-out. Take tail-style geometry (plane YZ extending -Z), then rotateX(-π/2)? rotateX(-90°): y→z... point (0,1,0) → y' = y cos - z sin? rotateX(θ): y' = y cosθ - z sinθ; z' = y sinθ + z cosθ. θ=-π/2: y'= z, z' = -y. Point (0,1,0)→(0,0,-1)? Wait y'=cos(-90)*1 - 0 = 0; z' = sin(-90)*1 = -1. So +Y→-Z. Hmm that flattens vertical spread into -Z. I want spread along... 

Overthinking. Pectoral fins: use small spheres scaled (0.04, 0.18, 0.3)? A flattened ellipsoid angled back looks like a fin from most angles. Do that: SphereGeometry(1,8,6) scaled (0.03,0.16,0.28) positioned at (±0.4,-0.08,0.3), rotated z=±0.5, y=±0.5? rotation.y = ∓0.6 (sweep back), rotation.z = ∓0.4 (angle down-out). Material finMat. Flap: rotation.z base + sin(t*w+phase)*0.25. But mesh.rotation set each frame overrides — store base and set rotation.z = baseZ + flap. Left/right mirrored. Good enough.

Now Fish class:

```js
class Fish {
  constructor(cfg){
    this.mesh = buildFish(cfg); // {group, tailGroup, fins}
    this.pos = this.mesh.group.position;
    this.pos.set(rand bounds);
    this.vel = random dir * cruise;
    this.cruise = cfg.cruise;
    this.size = cfg.size;
    this.school = cfg.school||false;
    this.offset = random small offset for school;
    this.goalTimer = 0; this.goal = new THREE.Vector3();
    this.scare = 0;
    this.wag = Math.random()*10;
    this.yaw = 0; this.prevYaw=null; this.bank=0;
  }
}
```

Update:

```js
update(dt, t, mouseRay, others, schoolLeader){
  const p = this.pos, v = this.vel;
  // steer accumulates accel
  acc.set(0,0,0);
  
  // goal wander
  this.goalTimer -= dt;
  if(this.goalTimer<=0 || p.distanceTo(this.goal)<1.5){
    this.goal.set(rand(-bx+2,bx-2), rand(-by+1,by-1), rand(-bz+2,bz-2));
    this.goalTimer = 4+Math.random()*5;
  }
  desired.copy(this.goal).sub(p).normalize().multiplyScalar(this.cruise);
  acc.addScaledVector(desired.sub(v), this.school? 2.2 : 0.8);
  
  if(this.school){
    desired.copy(schoolLeader).add(this.offset).sub(p).multiplyScalar(1.8);
    // desired as velocity target
    tmp.copy(desired).normalize().multiplyScalar(this.cruise*1.4);
    acc.addScaledVector(tmp.sub(v), 2.0);
  }
  
  // vertical bob
  acc.y += Math.sin(t*1.3 + this.wag)*0.3;
  
  // walls
  const m=2.2;
  wall force each axis...
  
  // separation
  ...
  
  // flee ray
  if(mouseRay){
    v0.subVectors(p, mouseRay.origin);
    const proj = v0.dot(mouseRay.direction);
    if(proj>0){
      cp.copy(mouseRay.origin).addScaledVector(mouseRay.direction, proj);
      const d = p.distanceTo(cp);
      const R = 4.2 + this.size; 
      if(d<R){
        flee.copy(p).sub(cp).normalize();
        const f = (1-d/R);
        acc.addScaledVector(flee, 40*f);
        // dodge sideways
        acc.addScaledVector(this.dodge, 15*f);
        this.scare = Math.min(1, this.scare + f*3*dt*10?) just set this.scare = 1;
        this.scare = 1;
      }
    }
  }
  this.scare = Math.max(0, this.scare - dt*0.8);
  
  // integrate
  v.addScaledVector(acc, dt);
  const maxSp = this.cruise*(1+2.4*this.scare);
  const spd = v.length();
  if(spd>maxSp) v.multiplyScalar(maxSp/spd);
  if(spd<0.5) v.setLength(0.5)? careful when spd=0.
  p.addScaledVector(v, dt);
  
  // hard clamp
  clamp with reflect: if p.x> bx: p.x=bx; v.x*=-0.7; etc.
  
  // orient
  if(spd>0.05){
    dir.copy(v).normalize();
    m4.lookAt(ZERO, dir, UP); // lookAt(eye,target,up) - matrix lookAt makes -Z? THREE.Matrix4.lookAt(eye, target, up) constructs rotation where -Z points from eye to target? Actually Matrix4.lookAt orients the -Z axis toward target? For Object3D.lookAt, +Z faces target for non-camera. Matrix4.lookAt(eye, target, up): "Constructs a rotation matrix, looking from eye towards target oriented by up." This matrix's -Z? Let me recall: Matrix4.lookAt is used by camera (which looks down -Z). Object3D.lookAt for non-camera does: m.lookAt(target, position, up) — swaps eye/target to flip. So to get +Z facing dir: m4.lookAt(dir, ZERO? ...). Easier: use a helper Object3D: 
  }
```

Simplest robust: create one dummy = new THREE.Object3D(); each frame: dummy.position.copy(p); dummy.lookAt(p + vel) — wait Object3D.lookAt makes the object's +Z? Three docs: "Rotates the object to face a point in world space... This method does not support objects having non-uniformly scaled parent(s)." For cameras and lights, they look down -Z, other objects +Z. Internally Object3D.lookAt: if isCamera||isLight: m.lookAt(position, target, up) else m.lookAt(target, position, up). So for plain Object3D, +Z ends up pointing at target. 

So: dummy.position.set(0,0,0); dummy.lookAt(dir) → dummy.quaternion = orientation with +Z along dir. Then apply bank: dummy.rotateZ(bank). Then group.quaternion.slerp(dummy.quaternion, k). Note dummy at origin, dir normalized — lookAt(target) from origin toward dir. But careful: lookAt with target directly above/below (dir ≈ ±Y) causes flip; fish rarely move straight up; add tiny x bias if needed — acceptable.

Bank: compute yaw = atan2(v.x, v.z); dYaw = wrapAngle(yaw - prevYaw); smoothBank += (clamp(-dYaw/dt * 0.35, -0.7, 0.7) - smoothBank)*min(1,dt*4); bank = smoothBank. Handle dt=0.

Then group.position is p (we use group.position directly as this.pos reference). group.quaternion.slerp(q, 1-Math.exp(-dt*5)).

Tail: this.wag += dt*(7 + spd*2.5 + this.scare*12); tailGroup.rotation.y = Math.sin(this.wag)*0.55;

Fins flap: fins[i].rotation.z = baseZ[i] + Math.sin(this.wag*0.8 + i*Math.PI)*0.3;

Separation detail:

```js
for(let j=i+1;...){ dist between; if(d < r) push both }
```
Use squared distances. r = (a.size+b.size)*0.9 + 0.3. Force magnitude ~ (1-d/r)*3 along diff vector. Apply to acc of both (opposite). Need acc arrays accessible — do separation in outer loop over fishes after computing? Structure: first loop computes per-fish acc (goal, walls, flee) stored; then separation pass adds to two accs; then integrate pass. Or integrate inside single loop but separation needs accs of both pre-integration... simplest: store acc per fish in fish.acc (Vector3 reused), pass 1 fills acc, pass 2 separation adds, pass 3 integrates. Fine.

Dodge vector: per fish random unit vector perpendicular-ish to flee dir — just random vector regenerated each startle: this.dodge randomized when scare goes 0→1. Check: if(this.scare<0.1 before) randomize dodge. dodge = random vector with y limited, orthogonalized against flee? Just random; fine.

Pulse scare: for each fish, d to pulse point; if d<9: fish.scare=1, add velocity kick away: v.addScaledVector(dirAway, (1-d/9)*6).

Now bubbles shader material. Write:

```js
const BUBBLE_COUNT = 140;
positions in aBase attr (start y random in range to distribute), aSpeed 0.6..1.6, aPhase random*range, aSize 6..16.
```

Shader:

```glsl
// vertex
uniform float uTime;
attribute vec3 aBase;
attribute float aSpeed;
attribute float aPhase;
attribute float aSize;
varying float vFade;
void main(){
  float range = 14.5;
  float life = fract((uTime*aSpeed + aPhase)/range);
  vec3 p = aBase;
  p.y = -6.8 + life*range;
  p.x += sin(uTime*1.1 + aPhase*7.0 + life*9.0)*0.35;
  p.z += cos(uTime*0.9 + aPhase*5.0)*0.35;
  vFade = sin(life*3.14159);
  vec4 mv = modelViewMatrix * vec4(p,1.0);
  gl_PointSize = aSize * (140.0 / -mv.z);
  gl_Position = projectionMatrix * mv;
}
// fragment
varying float vFade;
void main(){
  vec2 uv = gl_PointCoord - 0.5;
  float d = length(uv);
  float a = smoothstep(0.5,0.42,d)*0.18;         // faint body
  a += smoothstep(0.06,0.0,abs(d-0.36))*0.7;     // rim
  a += smoothstep(0.10,0.0,length(uv-vec2(-0.13,0.15)))*0.5; // highlight blob
  a *= vFade;
  if(a<0.01) discard;
  gl_FragColor = vec4(0.75,0.95,1.0, a);
}
```
blending: THREE.NormalBlending with transparent, depthWrite false. Additive might blow out; use NormalBlending, or Additive subtle. I'll use NormalBlending with nice alpha. Actually highlight blob: length(uv - offset) where offset outside center... uv-vec2(-0.13,0.15) gives distance to point at (-0.13,0.15) which is upper-left; smoothstep 0.10→0 gives soft dot. ok.

Note: attribute names: three provides `position` attribute automatically required? For ShaderMaterial with BufferGeometry, we can skip position but three computes bounding sphere from position attribute — raycast not needed. Points frustum culling uses geometry.boundingSphere computed from position attr; if missing, error? computeBoundingSphere would fail... We won't call it, but renderer might for frustum culling. Provide a dummy position attribute same as aBase? Simplest: name the base positions as `position` and use it in shader (position IS available as attribute vec3 position by default in ShaderMaterial). So use built-in `position` as base pos. 

points.frustumCulled = false to be safe.

Seaweed ShaderMaterial:

```js
function seaweedMaterial(){
 return new THREE.ShaderMaterial({
  uniforms:{uTime:{value:0}, uPhase:{value:Math.random()*10}, uAmp:{value:0.6+Math.random()*0.5}, uColorA:{value:new THREE.Color(...)}, uColorB:{...}},
  vertexShader:`
    uniform float uTime,uPhase,uAmp;
    varying float vY; varying float vX;
    void main(){
      vY = uv.y;
      vec3 p = position;
      float k = pow(uv.y, 1.6);
      p.x += sin(uTime*1.1 + uPhase + uv.y*3.0)*uAmp*k;
      p.z += cos(uTime*0.8 + uPhase*1.7 + uv.y*2.5)*uAmp*0.6*k;
      gl_Position? need projectionMatrix * modelViewMatrix * vec4(p,1);
    }`,
  fragmentShader:`
    varying float vY;
    uniform vec3 uColorA,uColorB;
    void main(){
      vec3 col = mix(uColorA, uColorB, vY);
      // fake lighting by vY shading
      gl_FragColor = vec4(col*(0.5+0.5*vY), 1.0);
    }`,
  side:THREE.DoubleSide
 });
}
```
Colors: A deep green 0x0b4d2a, B brighter 0x2fae66. Add slight transparency? opaque fine.

Seaweed geometry: PlaneGeometry(0.5, h, 1, 16) — uv.y along height? PlaneGeometry uv: (0,0) bottom-left? uv.y goes 0 at bottom to 1 at top? PlaneGeometry uv (0,1) top-left... uv v=1 at top. So uv.y works as height factor. position.y spans -h/2..h/2; we translate geometry so base at y=0: geometry.translate(0, h/2, 0).

Taper: could scale x by uv — skip; width 0.35 fine. Make several plants with random heights 2–5, positions near back and sides, random rotation.y, slight scale. 12 plants.

Rocks: IcosahedronGeometry(1, 0) with random scale (1-2.2, 0.6-1.2, 1-2), color 0x5a6a72 flat shading, roughness 1. positions on floor. 8 rocks. Also a few coral? Coral: small group of cone/tube shapes colored pink/orange? Add simple coral clusters: 3-5 cones (ConeGeometry radius .15 h .8) angled outward from a point, color 0xff7a59 / 0xff5f9e, nice pop. 5 clusters. 

Sand texture: give floor slight noise via vertex displacement: PlaneGeometry(80,40,48,24), rotateX(-π/2), displace y = noise... simple: y = sin(x*0.5)*cos(y*0.4)*0.15 + random small. Use deterministic pseudo-random via sin hash. computeVertexNormals. Color 0xd6bd7f but under blue light appears sandy; hemisphere light will tint. Also add slight roughness map? skip.

Glass box edges: BoxGeometry(BX*2, BY*2, BZ*2) where BX=15, BY=6.8, BZ=7; EdgesGeometry + LineBasicMaterial({color:0x8fd8ff, transparent:true, opacity:0.25}). Plus inner translucent box BackSide opacity 0.04.

Wait floor at y=-7 and box bottom at -6.8 — sand dunes up to ~0.2 could poke; box y-center 0, height 13.6 → bottom -6.8. Fish bounds BY = 6.0 so fish stay inside. ok, set bounds: bx=14, by=5.8, bz=6.2. Box slightly bigger: 14.8/6.6/6.8.

Fog color and background: background 0x06263c, fog 0x0a3a52 density 0.02? Blend floor sand into fog nicely. Let me set bg = new THREE.Color('#052436'); fog color '#0d3c56' — mismatch creates halo; keep same: '#07293e' both.

God ray planes: gradient canvas: vertical linear gradient white→transparent; texture; PlaneGeometry(2.2, 11); MeshBasicMaterial({map, transparent, opacity:0.13, blending:Additive, depthWrite:false, side:DoubleSide}); positions y=3.5, various x/z, rotation.z = ±0.15, rotation.y random; animate slight sway rotation.y += sin small. 4 rays. Rays only in top half: position y = 3 so spans -2.5..8.5 — top above waterline fine.

Water surface: add a plane at top y=6.9 with shimmering? A plane with MeshBasicMaterial light blue opacity 0.15 seen from below gives surface feel; animate its material offset with caustic texture as map (transparent). Let me add: surface plane facing down (rotateX(π/2)), material: MeshBasicMaterial({color:0xbfe9ff, transparent:true, opacity:0.12, side:DoubleSide? }) plus caustic map with alpha? Simple flat subtle plane OK. Add it.

Camera parallax & ray: use raycaster.setFromCamera(pointerNDC, camera) each frame → raycaster.ray.

Pointer events on window; also touchstart → pulse. Click on canvas triggers pulse at ray point: take point along ray at distance = distance to y=0 plane? Better: point at fixed distance 18 from camera, or intersect plane z=0? Use closest approach to tank center: point = ray.origin + dir * t where t chosen so point.z = 0? Simple: intersect with plane y=0 if dir.y≠0 else t=20. Use Plane(new Vector3(0,1,0), 0) → ray.intersectPlane. If null (parallel), use t=20. Good — pulse ring lies horizontal at y=0 facing up. Ring should face camera-ish for visibility... horizontal ring at click height looks like a ripple — nice. Make ring horizontal (rotateX(-π/2)) expanding — like a ripple in water. 

Pulse also spawns ring on the sand? y=0 mid-water fine.

UI hint: also detect first move: after user moves pointer 3+ events, swap hint text? Keep static; fine.

Sound: none.

Fish count: 26 normal + 14 school small = 40. Meshes each ~7 → 280 draw calls + env ~40. OK.

Let me also ensure `prefers-reduced-motion`? skip.

Mobile: touch drag = move pointer (scare). touchstart also pulse — maybe pulse on tap only if not moved? Keep pulse on touchstart: acceptable.

Prevent context menu? no.

Now, initial camera drift even without mouse: add slow automatic sway: parallax target uses pointer if active else gentle sin. I'll blend: camTargetX = pointerActive? ndc.x*3 : sin(t*0.1)*1.5.

Write the CSS: 

```css
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%;overflow:hidden;background:#041c2c}
canvas{display:block}
#ui{position:fixed;top:18px;left:22px;z-index:10;color:#dff4ff;font-family:...;pointer-events:none;text-shadow:0 2px 12px rgba(0,0,0,.6)}
h1{font-size:clamp(18px,3vw,28px);letter-spacing:.14em;font-weight:600;text-transform:uppercase}
#hint{font-size:clamp(11px,1.6vw,14px);opacity:.75;margin-top:6px;letter-spacing:.06em}
#hud bottom-right small: fish count.
vignette: body::after {content:'';position:fixed;inset:0;pointer-events:none;background:radial-gradient(ellipse at center, transparent 55%, rgba(0,10,20,.55));}
```
Font: system stack, fine (no external fonts allowed anyway... "No external files, fonts" — use system stack).

Add a subtle animated "water" tint overlay? The vignette suffices.

Now write final code with care about three r170 APIs:
- THREE.SRGBColorSpace exists.
- renderer.outputColorSpace default 'srgb' fine.
- FogExp2 ok.
- EdgesGeometry ok.

Matrix for orienting fish: I'll do per fish: 

```js
const _dummy = new THREE.Object3D();
_dummy.position.set(0,0,0);
_dummy.lookAt(dir); // dir = normalized velocity, must be non-zero
_dummy.rotateZ(bank);
group.quaternion.slerp(_dummy.quaternion, 1 - Math.exp(-6*dt));
```
Object3D.lookAt uses world position; dummy has no parent → position (0,0,0), target dir. Works.

Edge: dir near (0,±1,0): lookAt with up (0,1,0) parallel → degenerate but three handles by z-up fallback? Object3D.lookAt: if x,y,z of look direction... It uses Matrix4.lookAt which handles the degenerate case by nudging (there's protection: if up parallel, it adjusts z). Matrix4.lookAt has: if length of cross is 0, target.z += 0.0001? Yes there's a guard. Fine.

Bank computation:

```js
const yaw = Math.atan2(v.x, v.z);
if(this.prevYaw === null) this.prevYaw = yaw;
let dy = yaw - this.prevYaw;
if(dy > Math.PI) dy -= 2*Math.PI; else if(dy<-Math.PI) dy += 2*Math.PI;
this.prevYaw = yaw;
const targetBank = THREE.MathUtils.clamp(-dy/Math.max(dt,1e-4)*0.25, -0.6, 0.6);
this.bank += (targetBank - this.bank)*Math.min(1, dt*5);
```
rotateZ sign: turning left (yaw increasing) should roll into turn... sign whatever looks fine; keep.

Order: quaternion from lookAt then rotateZ(bank) — rotateZ rotates around local Z (forward axis) = roll. 

Walls force:

```js
const k = 6;
if(p.x >  bx-m) acc.x -= k*(p.x-(bx-m))/m;
if(p.x < -bx+m) acc.x += k*((-bx+m)-p.x)/m;
... same y (with by), z.
```
m=2.5. This is accel up to k. Good.

Hard clamp:

```js
['x','y','z'] with bounds bx,by,bz:
if(p.x> bx){p.x= bx; v.x=Math.min(v.x,0)... actually v.x = -Math.abs(v.x)*0.6;}
```
Fine.

Separation pass:

```js
for(i..){for(j=i+1..){
  dx etc from positions; d2; r = (fi.size+fj.size)*0.8+0.4; r2=r*r;
  if(d2<r2 && d2>1e-6){
    d=sqrt; inv=1/d; overlap=(1-d/r);
    nx=dx*inv, ny.., nz..;
    fi.acc += n*overlap*sep; fj.acc -= ...
  }
}}
```
sep strength ~ 5. Positions: fish.pos is group.position reference (Vector3). Access directly.

Where fi.acc = preallocated Vector3.

Main loop:

```js
const clock = new THREE.Clock();
function animate(){
  requestAnimationFrame(animate);
  const dt = Math.min(clock.getDelta(), 0.05);
  const t = clock.elapsedTime;
  // camera
  ...
  raycaster.setFromCamera(pointer, camera);
  const ray = pointerActive? raycaster.ray : null; // fish shouldn't flee before any mouse input... but demo mode: to show behavior without mouse, simulate a virtual pointer that moves automatically! 
```

Important: "Something meaningful must be visible... judged in action" and games need self-playing demo. Here: to showcase the flee behavior on the auto screenshot at 3s, have a virtual "ghost pointer" that wanders automatically until the user moves the real mouse. Great idea: ghost pointer moves on Lissajous path in NDC, converted to ray → fish startle and flee visibly without input. Once real pointer moves, ghost disables. Also ghost causes occasional pulses? No, just flee. Show hint text accordingly: "Fish flee from your cursor — a ghost fin guides them until you take over" too wordy. Hint: "Move your cursor to startle the fish · click for a shockwave". Fine.

Ghost NDC: gx = sin(t*0.5)*0.5, gy = sin(t*0.83+1.7)*0.35. That crosses fish areas. When ghost active and fish flee, still visible. 

Also ghost should not trigger pulse. ok.

Pointer handling:

```js
let pointer = new THREE.Vector2(0,0), pointerActive=false;
window.addEventListener('pointermove', e=>{
  pointer.x = (e.clientX/innerWidth)*2-1;
  pointer.y = -(e.clientY/innerHeight)*2+1;
  pointerActive = true;
});
```
Touch: pointermove covers touch drags (pointer events unified). Also add 'pointerdown' → pulse using same ray; set pointer too.

For pulse ray point: raycaster from pointer; intersect plane y=0: 

```js
const plane = new THREE.Plane(new THREE.Vector3(0,1,0), 0);
const pt = new THREE.Vector3();
if(!raycaster.ray.intersectPlane(plane, pt)) raycaster.ray.at(15, pt);
spawnPulse(pt);
```

Also auto-pulse? Maybe ghost triggers a pulse every ~7s for demo liveliness. Yes: if ghost active and t since last ghost pulse > 6, pulse at ray point. Fun for the screenshot. But ghost pulse also needs the ring visible — good showcase. Do it: first ghost pulse at t=1.2s so the 3s screenshot likely shows ring + scattering fish. 

Bubbles: Points shader with uTime uniform update.

Seaweed uniforms update each frame: store array of materials.

Caustic animate: causticTex.offset.x = t*0.015; offset.y = Math.sin(t*0.1)*0.08 + t*0.008; Also emissiveIntensity = 0.3 + sin(t*0.7)*0.08.

Water surface plane: also could use caustic texture as map with color; add: surfaceMat.map = caustic2? Use same texture, color tint via material color multiply. Set transparent opacity 0.18. Animate offset opposite. But shared texture offset conflicts (floor uses own texture instance). Create two textures from same canvas: CanvasTexture each. fine: makeCausticTexture() returns canvas; create two CanvasTextures.

Check: MeshStandardMaterial emissiveMap uses uv; floor plane uv 0..1 across 80 units → texture repeat set to e.g. (6,3): repeat on texture. Set tex.repeat.set(7,4), wrapping repeat.

Rocks/corals placed avoiding center? random x∈±13, z∈±6, y=-6.9.

School leader also drawn? invisible point fine; maybe a slightly bigger "leader" fish? The 14 school fish follow invisible leader — fine.

Small fish textures: one variant (silver-blue) for all school fish: base '#9fd0e8' accent '#ffffff' dots? plain with gradient. ok.

Now sizes: normal fish size 0.55–1.0; school fish 0.32.

Cruise: normal 1.8±0.6; school 3.0.

Check flee radius relative: fish at various z; ray passes through whole tank; radius 4.2 — mouse ray near tank center startles many. Good drama. Strength accel 40*f; with dt 16ms → up to 0.64/frame*... velocity change ~ 40*0.016=0.64/s... too weak vs cruise 2. Need stronger: accel 90*f → 1.44/frame-ish; over 0.5s ≈ +45? Wait accel 90 units/s² for 0.3s → Δv=27. That's a dart. Set fleeStrength = 80. Speed cap maxSp = cruise*(1+2.4*scare) ≈ 2*3.4 ≈ 6.8 units/s. Tank 28 wide — crosses in 4s. Good visible darting.

Also add velocity kick on scare start: when scare crosses from <0.2 to 1: v.addScaledVector(fleeDir, 2.5). Implement: if(this.scare < 0.2) { dodge randomize; kick } then scare=1. kick 2.0.

dt clamp 0.05 ok.

Now the school: desired toward leader+offset with strong gain; plus flee overrides (acc added later, larger). Also school fish separation r smaller (0.5). Their scare triggers scatter — since seek force strong (gain 2.0 toward leader), they'll regroup quickly. Good.

Ghost ray: raycaster.setFromCamera(ghostNDC, camera). ray = raycaster.ray — reuse raycaster each frame; but ghost & real: if pointerActive use real pointer else ghost. The flee check uses this ray.

Pulses array update:

```js
for(pulse of pulses){ pulse.t += dt*1.3; const s = 0.4 + pulse.t*10; pulse.mesh.scale.setScalar(s); pulse.mesh.material.opacity = 0.65*(1-pulse.t); if(pulse.t>=1) remove+dispose }
```
Ring geometry: RingGeometry(0.85, 1, 48) rotateX(-Math.PI/2) at creation.

Also second smaller ring? fine one.

Pulse scare: 

```js
for(f of fishes){ const d = f.pos.distanceTo(pt); if(d<9){ const k=(1-d/9); f.scare=1; f.vel.addScaledVector(_away(f.pos,pt), 4*k); } }
```
_away = dir from pt to pos normalized.

Camera: 

```js
const camX = pointerActive? pointer.x : ghostX;
const camY = pointerActive? pointer.y : ghostY;
camera.position.x += ((camX*3.2) - camera.position.x)*Math.min(1,dt*2.5);
camera.position.y += (1.2 + camY*1.8 - camera.position.y)*...;
camera.position.z = 24;
camera.lookAt(0,0.4,0);
```
Note: ghost drives camera too — subtle drift, nice.

lookAt each frame fine.

Lights and their defaults with r170: renderer.useLegacyLights removed; intensities: hemi 1.0, dir 2.0? In r155+ default lighting mode changed (useLegacyLights false): intensities in physical units-ish; typical: DirectionalLight intensity 2-3 works, Hemisphere 1.5. I'll tune: hemi 1.2, dir 2.2, ambient? Add AmbientLight 0x3a6a8a 0.5. Tone mapping ACES exposure 1.15.

Fish material colors under ACES get muted; bump colors saturation. ok.

Water tint on fish: fog handles depth.

One more consideration: `scene.fog` affects ShaderMaterial? Custom shaders (bubbles, seaweed) ignore fog unless implemented — bubbles additive small, fine; seaweed at back might not fade with fog — looks slightly off but acceptable; could manually mix fog in fragment using camera-space depth:

```glsl
// vertex: vViewZ = -mv.z;
// fragment: float fogF = 1.0 - exp(-pow(vViewZ*uFogDensity, 2.0)); gl_FragColor.rgb = mix(col, uFogColor, fogF);
```
Add to seaweed shader: pass uFogColor + uFogDensity uniforms matching scene. Let's include for seaweed since it's near back wall. Bubbles skip (subtle anyway). Also god-ray planes additive near back — additive with fog mismatch minor; they're low opacity, skip.

Also the fish eye material with fog default true fine.

Let me now also double check ShaderMaterial for Points: need `transparent:true, depthWrite:false`. gl_PointCoord available. Good.

The renderer alpha default false, clear color = scene.background. OK.

Resize: on window resize update camera.aspect & renderer size. In sandboxed iframe, innerWidth/innerHeight fine; use renderer.setSize(innerWidth, innerHeight, false)? Pass updateStyle true default fine since canvas CSS 100%? I'll set canvas style width/height 100% and call setSize with false to avoid style overwrite... simpler: setSize(w,h) standard; CSS canvas{display:block}. Fine.

devicePixelRatio clamp 2.

Now write out everything. Also include noscript? no.

Title copy: "The Lagoon — an interactive aquarium". HUD bottom-left: "42 fish · 3 schools? ..." bottom-right small: "three.js r170". Cute credit.

Hint pulse animation via CSS keyframes opacity.

Let me make sure about "Something meaningful visible on first paint": scene renders immediately, ghost activity starts t=0 (ghost ray affecting fish from start; first ghost pulse at 1.2s). 

Code for ghost: 

```js
const ghost = new THREE.Vector2();
function ghostPos(t){ ghost.set(Math.sin(t*0.45)*0.55, Math.sin(t*0.71+1.3)*0.4); return ghost; }
let lastGhostPulse = 0;
in animate: 
const active = pointerActive ? pointer : ghostPos(t);
if(!pointerActive && t - lastGhostPulse > 6.5){ lastGhostPulse = t + Math.random()*2; doPulseFromRay(); }
```
Hmm doPulseFromRay uses raycaster with current NDC — set from active. Structure:

```js
const ndc = pointerActive ? pointer : ghost;
raycaster.setFromCamera(ndc, camera);
const ray = raycaster.ray;
if(!pointerActive && t>0.8 && t-lastGhostPulse>6){ lastGhostPulse=t; ray.intersectPlane(hPlane, pt) or at(18); spawnPulse(pt, true); }
```
Wait first ghost pulse ~1.2s: condition t-lastGhostPulse>6 with lastGhostPulse=-5 → at t≥1. OK set lastGhostPulse=-5+... set lastGhostPulse = -4.8 so first at ~1.2s.

spawnPulse(pt): ring + scare fish.

Also pulse while ghost: ring at ray∩y=0 plane — good.

Now separation loop uses fish.pos which references group.position — but group.position updated in integrate; ensure this.pos = group.position set in constructor after buildFish.

For school leader: 

```js
const leader = new THREE.Vector3();
leader.set(Math.sin(t*0.5)*8 + Math.sin(t*1.1)*1.5, Math.sin(t*0.8)*2.2, Math.cos(t*0.4)*3.5);
```

School offsets: random within sphere r=1.5, y limited ±1.

Everything into code. Also small detail: fish group needs frustumCulled default true — geometry-based culling per mesh fine.

Dispose on pulse removal: scene.remove; material.dispose? minor, do it.

Now write final HTML. Watch string escapes in template literals inside shaders — fine.

Also ensure `renderer.setAnimationLoop`? Use requestAnimationFrame classic.

Potential pitfall: ShapeGeometry curve segments default 12 — fine.

Tail fin orientation double-check: ShapeGeometry in XY, +X right. rotateY(π/2) maps +X→-Z, +Y→+Y, normal +Z→+X. So fin lies in YZ plane facing +X. Fish faces +Z. Tail at z=-0.8 extending to -1.5. 

Dorsal: same rotation; shape drawn with +X back and +Y up: after rotation extends -Z and +Y. Position (0, 0.45, -0.1): base of shape at y=0 → sits at 0.45 rising to 0.45+0.45. Body top at y=0.6*scale... group scale applies to whole; internal coordinates: body sphere radius 1 scaled (0.42,0.6,1.0) → top at 0.6. Dorsal base y=0.45 overlaps body (good, embedded), tip at ~0.9.

Tail position z=-0.75; body back at z=-1.0 (radius 1 scaled z 1.0). So tail group at -0.75 embedded, extends to -1.45. Wag pivot at -0.75. Good.

Eyes at (±0.27, 0.14, 0.75): body at z=0.75: cross-section: (z/1)^2+(x/0.42)^2+(y/0.6)^2=1 → x max at y=0.14: sqrt(1-0.5625-0.0544)= sqrt(0.383)=0.619 *0.42=0.26. So eye at x=0.27 pokes slightly out — perfect for eyes.

Pectoral ellipsoids at (±0.34, -0.1, 0.35): body half-width at z=0.35,y=-0.1: sqrt(1-0.1225-0.0278)=0.9 → x=0.378. So x=0.34 inside-ish; scale them outward: position x ±0.38. rotation.y = ∓0.7 (sweep back): for right fin (+x), rotate y negative swings tip backward? Ellipsoid long axis is z (0.28). rotation.y rotates z toward x. For right side want tip pointing back-outward: rotation.y = -0.6 gives +Z→ rotates toward +X? rotateY(-0.6): z' = -x sinθ + z cosθ... point (0,0,1) → (sin(-0.6),0,cos(-0.6)) = (-0.56,0,0.83): tip moves toward -X — wrong for right fin; use +0.6 for right: tip → (+0.56,0,0.83) forward-out — want backward-out: angle beyond 90°: rotation.y = π-0.6? Simpler: mirror by rotation.y = 2.5 for right (tip toward (+0.6? ...). Let me param: rotation.y = θ, tip dir = (sinθ, 0, cosθ). Want tip ≈ (+0.5,0,-0.85) for right fin → θ ≈ π - 0.53 ≈ 2.6. Left: tip (-0.5,0,-0.85) → θ ≈ -(π-0.53) = -2.6. Also rotation.z tilt: right fin tilt down-out: rotate z by +0.4? rotation order default XYZ — combined fine, minor. Flap animates rotation.z around base ±0.3: store base rotation.y, animate rotation.z = baseZ + sin. baseZ right = 0.5 (tilt down? rotation.z rotates x-y: for right fin tilting tips down: negative? whatever, ±). I'll set: right fin: rot.y=2.55, baseZ=-0.45; left: rot.y=-2.55, baseZ=0.45. Flap: rotation.z = baseZ + sin(wag*0.9+phase)*0.25. Looks plausible.

Fin material transparent both sides — fine.

Fish body slight emissive for vibrance: emissive = base color *0.06? With map, emissive adds flat color; skip or tiny. Skip.

Colors list with accent:
```js
const PALETTES = [
 {base:'#ff9130', accent:'#ffe9c9', pattern:0},   // clown
 {base:'#ffd23f', accent:'#ff9130', pattern:1},   // yellow dotted
 {base:'#4f8dff', accent:'#9fe8ff', pattern:0},   // blue striped
 {base:'#ff5f7e', accent:'#ffffff', pattern:1},
 {base:'#8fe07f', accent:'#ffd23f', pattern:0},
 {base:'#b07fff', accent:'#ffe9c9', pattern:1},
 {base:'#2fd6c3', accent:'#0a3a44', pattern:0},
];
```
Precreate textures per palette (7 textures) and reuse.

Fish creation:

```js
const fishes = [];
for(let i=0;i<26;i++){
  const pal = PALETTES[i%PALETTES.length];
  const size = 0.55+Math.random()*0.5;
  fishes.push(new Fish({size, cruise:1.6+Math.random()*1.2, tex:pal.tex, accent:pal.accent, school:false}));
}
for(let i=0;i<14;i++){ size 0.3, cruise 2.6+..., tex:schoolTex, accent:'#dff4ff', school:true }
```

Store palettes with prebuilt textures:

```js
PALETTES.forEach(p=> p.tex = makeFishTexture(p.base,p.accent,p.pattern));
```

Random start positions inside bounds, random velocities.

Also initial group.position set; ensure random z spread.

Check Fish constructor: buildFish returns {group, tailGroup, finL, finR, baseFinZ...}. Set this.pos = group.position; scene.add(group).

Everything assembled in init().

Let me count code size — will be long but fine.

One more check: three module import via importmap inside single HTML — allowed CDN given by task. Use `<script type="module">` after importmap script. 

Edge: sandboxed iframe may block module scripts? No, module scripts fine with CDN.

Write the code now.

Also, texture for god rays:

```js
function makeRayTexture(){
  canvas 64x256; gradient vertical: rgba(255,255,255,0.9) at top → 0 at bottom; also horizontal fade: multiply by horizontal gradient (transparent edges). Implement: fill with vertical gradient; then globalCompositeOperation='destination-in'; horizontal gradient (alpha 0→1→0). 
}
```

Surface plane: PlaneGeometry(60,30) rotateX(π/2) at y=6.7, material MeshBasicMaterial({map: causticTexSurface, transparent:true, opacity:0.25, color:0x9fdcff, depthWrite:false}); map wrap repeat (6,3); animate offset. Seen from below: plane rotated X +π/2 → normal -Y facing down? rotateX(Math.PI/2) makes plane normal -Y? PlaneGeometry normal +Z; rotateX(π/2): +Z→ -Y? rotateX(θ=π/2): y' = -z, z' = y. Normal (0,0,1) → (0,-1,0). Yes faces down. Good, visible from below; MeshBasicMaterial default FrontSide → visible from below. Good.

Floor displacement: after rotateX(-π/2), vertices have y=0; displace position attribute y? PlaneGeometry rotated: positions (x, 0, z)? rotateX(-π/2) maps +Y→+Z? Original plane XY; after rotateX(-π/2): (x, y, 0)→ (x, 0·?...) point (0,1,0) → y' = y cos(-90) - z sin(-90) = 0 + z? Let me: rotateX(θ): y' = y cosθ - z sinθ; z' = y sinθ + z cosθ. θ=-π/2: cos=0, sin=-1 → y' = z; z' = -y. Point (x,y,0) → (x, 0, -y). So plane in XZ with normal? Normal (0,0,1) → (0, 1, 0)?? z'= -y applies to vector (0,0,1): y' = 0*0 - 1*(-1) = 1; z' = 0*(-1)+1*0 = 0 → (0,1,0) up. 

Displace: pos.setY(i, noise). Since after rotation, y component is 0 for all → set y = h(x,z). noise: h = Math.sin(x*0.35)*Math.cos(z*0.5)*0.22 + Math.sin(x*0.13+z*0.21)*0.3 - 0.15. computeVertexNormals. Floor at y≈-7: mesh.position.y = -7.

Caustics as emissiveMap on displaced floor: uv unchanged, fine.

Sand color: 0xd8c084; under blue-ish lights it'll look right.

Rocks on floor at y=-6.9+.

Seaweed positions y=-7 base.

Corals: cluster: for c in 5: group at (x, -6.9, z); 4-6 cones: ConeGeometry(0.12,0.9,6), rotation.z random ±0.6, rotation.x random ±0.6, position small offsets, color from [0xff7a59, 0xff5f9e, 0xffb35f], roughness 0.8. Also tube sponges? enough.

Now the fish "scare" also increases tail wag amp: tailGroup.rotation.y = sin(wag)*(0.4+0.3*scare).

HUD counts: set text via JS: `${fishes.length} fish · move your cursor`.

Let me also add subtle "caustic light" spotlight? no.

Potential issue: fish texture on sphere — stripes vertical in canvas map around sphere horizontally (u wraps around Y axis). Sphere UV: u around Y-axis. Our fish long axis is Z after scale (sphere scaled z). Stripe orientation: we want vertical bands along body length (z) → bands vary with u? u goes around; on the sides of sphere, u varies with longitude (x direction). Body long axis is z (scaled), so longitude corresponds to z-ish. Stripes as vertical lines in canvas (x of canvas = u) → bands along u → bands around long axis? Hmm: canvas x → u → longitude (rotation around Y). Our body: sphere scaled (0.42, 0.6, 1.0) — long axis Z. Longitude wraps around Y: at u=0/0.5/1 the points at ±X? Sphere: u=0 at +X? phi from +X? In three SphereGeometry, vertex.x = -r cos(phi)... whatever: bands along u will appear as bands around the long axis (like rings) — that's actually what we want: vertical stripes across the body (perpendicular to spine). Since long axis is Z and u corresponds to rotation around Y (through the long axis? no—around vertical Y axis). The sphere's own long axis default is Y; after our scale, long axis Z. Longitude circles around Y-axis pass through nose/tail? Circle around Y passes through +Z and -Z (nose & tail) — yes longitude goes pole(+-Y)... ugh: SphereGeometry: poles along Y. u = longitude around Y. Points at same u form meridian from pole to pole — passing through equator at some longitude. Stripes in canvas-x (constant u ranges) → meridian wedges → on our fish (long axis Z = equator direction), these wedges appear as bands crossing the body from top to bottom at intervals along z. Yes! Vertical stripes on the body. Dots similarly scattered. 

But poles at top/bottom of fish (y axis) — pinch points at dorsal top; stripes converge there — acceptable (looks like natural pattern).

UV v: canvas y → v: 0 at bottom? SphereGeometry uv v=0 at top pole? In three, v=0 at bottom? Actually uv.y = 1 - (thetaRow / heightSegments)? v=1 at top... My gradient: I darken bottom of canvas (y=64) — canvas y=64 maps to v=0 or 1? Texture v=0 corresponds to canvas bottom (v=0 → y=height) since textures flipped Y. flipY=true default: v=0 → bottom of image. Sphere v=0 at... theta start 0 at top (phiStart... theta from +Y). uv.v = 1 - v? In three SphereGeometry code: uv.y = 1 - (iy / heightSegments), iy=0 at top → uv.y=1 at top → v=1 top → canvas top (flipY). So canvas top = fish top. My gradient: top light (0.25 white), bottom dark → correct.

Also the "belly" gradient — good.

Pattern stripes at canvas x: they'll map around; ok.

School fish texture: base '#a8d8f0', accent '#0a3a5a'?? small dots too noisy at small size; use plain gradient + darker back: pattern none but add darker top: gradient from rgba(0,30,60,0.35) top to light bottom? invert: fish darker on top: for school: top rgba(20,60,100,.45). Let me parameterize: makeFishTexture(base, accent, pattern, darkTop=false). School: darkTop true, pattern -1.

Fine — implement options object.

Now finalize numbers: tank bounds bx=13.5, by=5.6, bz=6.0. Box glass: (28.5, 13.4, 13.0) centered (0, 0.3? ) Let me set glass box centered at y=0 with height 13.4: -6.7..6.7. Floor at -7 with dunes up to -6.5 → dune tips may cross glass bottom; reduce dune amp: -7 + up to +0.25 → -6.75 ≈ near -6.7 fine.

Bubbles range 14.5 starting -6.8 → tops at 7.7 above surface 6.7 → fade out via sin(life·π) anyway near top alpha→0? sin(pi·~1)=0 yes fade before. good: life 1 at y=7.7 but alpha sin→0 at 7.7, at surface y=6.7 → life=(6.7+6.8)/14.5=0.93 → alpha=sin(0.93π)=0.22 low. Good.

Bubbles inside glass x ±12, z ±5.5.

Camera FOV 52, position z 24; tank half width 14 → horizontal half-angle needed atan(14/24)=30.3°; FOV 52 vertical, aspect 1280/800=1.6 → hFOV = 2*atan(tan(26°)*1.6)=2*atan(0.778)=75.7° → half 37.9° > 30.3 ✓. On mobile portrait 375×700: aspect 0.53 → hFOV half = atan(tan26°*0.53)=atan(0.258)=14.5° — tank edges cut off. Adapt: increase camera z on small aspect: camera.position.z = aspect<1 ? 34 : 24, or compute: needed half-width 15 → z = 15/tan(hFOVhalf). Set in resize: `const zf = 15.5/Math.tan(THREE.MathUtils.degToRad(26)) / Math.min(aspect, 2.2)`? tan(26°)=0.4877 → 15.5/0.4877 = 31.8 at aspect 1 → /aspect... hFOVhalf tan = tan(vfov/2)*aspect. Need tanH ≥ 15.5/z → z = 15.5/(tan26°*aspect) when aspect*... For aspect 1.6: z = 31.8/1.6 = 19.9 → but we want some margin + framing: use z = max(23, 33/aspect)? aspect1.6→ 23; aspect 0.53 → 62 too far (fog!). Fog density 0.02 at 62 → factor exp(-(1.24)²)=0.21 visibility — too fogged. Alternative: shrink world for small screens? Simpler: on narrow screens increase FOV to 68 and set z = clamp(33/aspect, 24, 38). aspect 0.53 → 62... clamp 38: half-width visible = tan(34°)*0.53*38 = 0.674*0.53*38 = 13.6 — slightly tight, acceptable (fish bounds 13.5). Fog at 38+15 depth ~ exp(-(0.02*45)²)... density: let me lower fog to 0.016: at depth 45: exp(-0.518)=0.6 visible. Fine. Also mobile: lower pixel ratio to 1.5.

I'll implement fitCamera(): 

```js
function fitCamera(){
  const aspect = innerWidth/innerHeight;
  camera.fov = aspect < 0.9 ? 66 : 52;
  camera.aspect = aspect;
  const halfV = THREE.MathUtils.degToRad(camera.fov/2);
  const halfH = Math.atan(Math.tan(halfV)*aspect);
  const need = 15.0;
  let z = need/Math.tan(halfH) + 4;
  camera.position.z = THREE.MathUtils.clamp(z, 22, 40);
  camera.updateProjectionMatrix();
}
```
aspect1.6: halfH = atan(0.4877*1.6)=37.98° tan=0.781 → z=15/0.781+4=23.2 ✓. aspect 0.53: halfH=atan(0.4877*0.53)=14.5° tan 0.2585 → z=62 clamp 40 → visible half width = tan(halfH)*(40-4?) use distance ~36: 0.2585*36=9.3 — tank edges cut but scene still full-bleed immersive — acceptable & looks fine (fish swim through view). Also raise fog understanding: camera 40 → far side 47: exp(-(0.016*47)^2)=exp(-0.566)=0.57. ok.

Actually to be kinder on mobile, also scale bounds? Keep as is; fish still visible center.

Parallax: camera.position.x/y lerp; then fitCamera sets z only. lookAt(0,0,0).

Now, write it all. I'll be careful with every API call.

Also `renderer.toneMapping = THREE.ACESFilmicToneMapping`.

For the UI, add small legend bottom-right: "cursor = predator · click = shockwave".

Let me write the final answer.

Also check: no localStorage etc. No alert. Good. No console errors: ensure all uniforms referenced exist; ensure raycaster.ray used only after setFromCamera.

Ghost pulse: spawnPulse needs a point: compute pt = ray.at(20) projected to y=0? Just use intersectPlane; if null use ray.at(18).

Pulse scare radius 9 — fish scatter dramatically. Ghost pulses every ~6-8s.

Total fish 40; separation O(820 pairs) fine.

Text hint fade: after pointerActive && user moved > 30px cumulative, reduce hint opacity via class. Implement: on pointermove accumulate dist; if >60 add class 'dim' to hint (CSS transition opacity 0.35). Keep title.

Final check on seaweed fog uniforms: uFogColor = new THREE.Color('#07293e'), density 0.016. Fragment: 

```glsl
float fogDepth = vDist;
float fogF = 1.0 - exp(-uFogDensity*uFogDensity*fogDepth*fogDepth);
col = mix(col, uFogColor, clamp(fogF,0.0,1.0));
```
vDist = length(mvPosition.xyz).

Write vertex for seaweed with mvPosition computed after sway.

Rocks standard material fog auto ✓.

Also god-ray planes: MeshBasicMaterial fog:false option? default fog:true would fade them; they're additive faint; set fog:false to keep glow. Material param fog:false works.

Bubble ShaderMaterial: no fog — fine.

Surface plane basic material fog true fine.

One more: LineBasicMaterial for glass edges with fog — fine, opacity 0.3.

Inner glass BackSide box: MeshBasicMaterial({color:0x9fdcff, transparent:true, opacity:0.045, side:THREE.BackSide, depthWrite:false}) — renders inside faces behind fish? With depthWrite false and transparency, order matters: it may render over fish depending on order. BackSide box: we see its far walls (inside faces). Fish inside box in front of far walls — if box renders after fish without depth test? depthTest still true; far walls behind fish → fish drawn... transparent objects sorted back-to-front by distance: far walls farther than fish → drawn first → fish over. Should be OK.

Now write everything.

Actually also set scene.background slightly gradient? Solid fine with fog+vignette.

Let me write the code.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>The Lagoon — Interactive 3D Aquarium</title>
<style>
  :root{ --ink:#e8f7ff; }
  *{margin:0;padding:0;box-sizing:border-box}
  html,body{width:100%;height:100%;overflow:hidden;background:#041a29}
  body{font-family:"Avenir Next","Segoe UI",system-ui,sans-serif;color:var(--ink)}
  canvas{display:block}
  #ui{position:fixed;top:20px;left:24px;z-index:5;pointer-events:none;text-shadow:0 2px 14px rgba(0,10,25,.85)}
  #ui h1{font-size:clamp(17px,2.6vw,26px);font-weight:600;letter-spacing:.22em;text-transform:uppercase}
  #ui h1 span{color:#7fe3ff}
  #hint{margin-top:7px;font-size:clamp(11px,1.5vw,13.5px);letter-spacing:.08em;opacity:.8;transition:opacity 1.2s}
  #hint.dim{opacity:.28}
  #legend{position:fixed;right:22px;bottom:18px;z-index:5;pointer-events:none;font-size:11.5px;letter-spacing:.08em;opacity:.55;text-shadow:0 2px 10px rgba(0,10,25,.8)}
  #vignette{position:fixed;inset:0;z-index:4;pointer-events:none;
    background:radial-gradient(120% 90% at 50% 42%,transparent 55%,rgba(1,12,22,.62) 100%)}
</style>
<script type="importmap">...</script>
</head>
<body>
<div id="ui">
  <h1>The <span>Lagoon</span></h1>
  <div id="hint">Move your cursor near the fish to startle them · click for a shockwave</div>
</div>
<div id="legend">cursor = predator &nbsp;·&nbsp; click = shockwave</div>
<div id="vignette"></div>
<script type="module">
import * as THREE from 'three';
... 
</script>
</body>
</html>
```

Legend text repeats hint — make legend: "40 fish · 7 reef species · three.js r170".

Now the module script. I'll write it fully and mentally lint.

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

/* ---------- basics ---------- */
const renderer = new THREE.WebGLRenderer({antialias:true});
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.12;
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
scene.background = new THREE.Color('#07293e');
scene.fog = new THREE.FogExp2('#07293e', 0.016);

const camera = new THREE.PerspectiveCamera(52, innerWidth/innerHeight, 0.1, 200);
camera.position.set(0, 1.0, 24);

scene.add(new THREE.HemisphereLight(0xbfe8ff, 0x0c2b33, 1.15));
const sun = new THREE.DirectionalLight(0xeaf7ff, 2.1);
sun.position.set(5, 14, 6);
scene.add(sun);
scene.add(new THREE.AmbientLight(0x39647f, 0.55));
```

Bounds const:

```js
const BOUND = {x:13.5, y:5.6, z:6.0};
```

Caustic canvas & textures:

```js
function causticCanvas(){
  const s=256, c=document.createElement('canvas'); c.width=c.height=s;
  const g=c.getContext('2d');
  g.globalCompositeOperation='lighter';
  for(let i=0;i<70;i++){
    const cx=Math.random()*s, cy=Math.random()*s, r=8+Math.random()*26, w=(Math.random()*2-1);
    g.beginPath();
    const N=26;
    for(let k=0;k<=N;k++){
      const a=k/N*Math.PI*2;
      const rr=r*(1+0.35*Math.sin(a*3+w*7)+0.18*Math.sin(a*5+w*13));
      const px=cx+Math.cos(a)*rr, py=cy+Math.sin(a)*rr;
      k?g.lineTo(px,py):g.moveTo(px,py);
    }
    g.closePath();
    g.filter='blur(1.4px)';
    g.strokeStyle='rgba(160,225,255,0.15)';
    g.lineWidth=1.6+Math.random()*2.4;
    g.stroke();
  }
  return c;
}
const causticCanvasEl = causticCanvas();
function causticTexture(rx,ry){
  const t=new THREE.CanvasTexture(causticCanvasEl);
  t.wrapS=t.wrapT=THREE.RepeatWrapping;
  t.repeat.set(rx,ry);
  return t;
}
```

Floor:

```js
{
  const geo=new THREE.PlaneGeometry(90,44,52,26);
  geo.rotateX(-Math.PI/2);
  const pos=geo.attributes.position;
  for(let i=0;i<pos.count;i++){
    const x=pos.getX(i), z=pos.getZ(i);
    pos.setY(i, Math.sin(x*0.32)*Math.cos(z*0.45)*0.22 + Math.sin(x*0.11+z*0.19)*0.28);
  }
  geo.computeVertexNormals();
  const mat=new THREE.MeshStandardMaterial({
    color:0xd8bf85, roughness:1, metalness:0,
    emissive:0x9fdcff, emissiveMap:floorCaustic, emissiveIntensity:0.5
  });
  const floor=new THREE.Mesh(geo,mat);
  floor.position.y=-7;
  scene.add(floor);
}
```
floorCaustic = causticTexture(8,4).

Glass:

```js
{
  const box=new THREE.BoxGeometry(BOUND.x*2+1.6, 13.4, BOUND.z*2+1.6);
  box.translate(0,0.0,0); // centered y0 → -6.7..6.7
  const edges=new THREE.LineSegments(new THREE.EdgesGeometry(box), new THREE.LineBasicMaterial({color:0x8fd8ff, transparent:true, opacity:0.28}));
  scene.add(edges);
  const inner=new THREE.Mesh(box, new THREE.MeshBasicMaterial({color:0xaee4ff, transparent:true, opacity:0.05, side:THREE.BackSide, depthWrite:false}));
  scene.add(inner);
}
```
box height 13.4 centered 0 → bottom -6.7, top 6.7. Floor dune max -7+0.5+... amplitudes 0.22+0.28=0.5 → -6.5 > -6.7 pokes through glass bottom near edges? dune max at some (x,z); glass bottom at -6.7; dune top -6.5 above glass bottom → sand visible slightly below/above edge line — visually fine (sand under glass edge is natural).

Surface:

```js
const surfaceTex = causticTexture(7,3.5);
const surface=new THREE.Mesh(new THREE.PlaneGeometry(70,34), new THREE.MeshBasicMaterial({color:0xbfeeff, map:surfaceTex, transparent:true, opacity:0.22, depthWrite:false}));
surface.geometry.rotateX(Math.PI/2);
surface.position.y=6.7;
scene.add(surface);
```
Wait material.map with transparent uses map alpha; caustic canvas has transparent background (we never fill opaque) — strokes only. So map alpha = stroke alpha → nice shimmer lines. color tint multiplies. 

But MeshBasicMaterial map alpha used only if transparent — yes. Good.

Rays:

```js
function rayTexture(){
  const c=document.createElement('canvas'); c.width=64; c.height=256;
  const g=c.getContext('2d');
  const v=g.createLinearGradient(0,0,0,256);
  v.addColorStop(0,'rgba(255,255,255,0.85)');
  v.addColorStop(1,'rgba(255,255,255,0)');
  g.fillStyle=v; g.fillRect(0,0,64,256);
  g.globalCompositeOperation='destination-in';
  const h=g.createLinearGradient(0,0,64,0);
  h.addColorStop(0,'rgba(0,0,0,0)'); h.addColorStop(0.5,'rgba(0,0,0,1)'); h.addColorStop(1,'rgba(0,0,0,0)');
  g.fillStyle=h; g.fillRect(0,0,64,256);
  return new THREE.CanvasTexture(c);
}
const rayTex=rayTexture();
const rays=[];
for(let i=0;i<4;i++){
  const m=new THREE.Mesh(new THREE.PlaneGeometry(2.6,12), new THREE.MeshBasicMaterial({map:rayTex, transparent:true, opacity:0.10+Math.random()*0.05, blending:THREE.AdditiveBlending, depthWrite:false, side:THREE.DoubleSide, fog:false}));
  m.position.set(-9+i*6+Math.random()*2, 2.5, -4+Math.random()*5);
  m.rotation.z=(Math.random()-0.5)*0.3;
  m.rotation.y=Math.random()*0.6-0.3;
  rays.push(m); scene.add(m);
}
```

Seaweed:

```js
const weedMats=[];
function addWeed(x,z,h,w){
  const geo=new THREE.PlaneGeometry(w,h,1,14);
  geo.translate(0,h/2,0);
  const mat=new THREE.ShaderMaterial({
    side:THREE.DoubleSide,
    uniforms:{
      uTime:{value:0}, uPhase:{value:Math.random()*20},
      uAmp:{value:0.45+Math.random()*0.5},
      uColA:{value:new THREE.Color('#0a4d2e')},
      uColB:{value:new THREE.Color('#37b06a')},
      uFogColor:{value:new THREE.Color('#07293e')},
      uFogDensity:{value:0.016}
    },
    vertexShader:`
      uniform float uTime,uPhase,uAmp;
      varying float vY; varying float vDist;
      void main(){
        vY=uv.y;
        vec3 p=position;
        float k=pow(uv.y,1.6);
        p.x+=sin(uTime*1.05+uPhase+uv.y*3.2)*uAmp*k;
        p.z+=cos(uTime*0.8+uPhase*1.7+uv.y*2.4)*uAmp*0.6*k;
        vec4 mv=modelViewMatrix*vec4(p,1.0);
        vDist=length(mv.xyz);
        gl_Position=projectionMatrix*mv;
      }`,
    fragmentShader:`
      uniform vec3 uColA,uColB,uFogColor; uniform float uFogDensity;
      varying float vY; varying float vDist;
      void main(){
        vec3 col=mix(uColA,uColB,vY)*(0.55+0.6*vY);
        float f=1.0-exp(-uFogDensity*uFogDensity*vDist*vDist);
        col=mix(col,uFogColor,clamp(f,0.0,1.0));
        gl_FragColor=vec4(col,1.0);
      }`
  });
  weedMats.push(mat);
  const m=new THREE.Mesh(geo,mat);
  m.position.set(x,-7,z);
  m.rotation.y=Math.random()*Math.PI;
  scene.add(m);
}
for(let i=0;i<14;i++){
  const edge=Math.random()<0.5;
  const x=edge?(Math.random()<0.5?-1:1)*(9+Math.random()*4.5):(Math.random()*26-13);
  const z=(Math.random()<0.5?-1:1)*(3.5+Math.random()*2.8);
  addWeed(x, z, 2.2+Math.random()*3.2, 0.35+Math.random()*0.3);
}
```
Some weeds near front might overlap fish view — fine.

Rocks & coral:

```js
{
  const rockGeo=new THREE.IcosahedronGeometry(1,1);
  const rockMat=new THREE.MeshStandardMaterial({color:0x64757e, roughness:0.95, flatShading:true});
  for(let i=0;i<9;i++){
    const m=new THREE.Mesh(rockGeo, rockMat);
    const s=0.7+Math.random()*1.6;
    m.scale.set(s, s*(0.5+Math.random()*0.35), s*(0.75+Math.random()*0.5));
    m.position.set(-12.5+Math.random()*25, -7+0.25*s, -5.5+Math.random()*8);
    m.rotation.set(Math.random(),Math.random()*6,Math.random());
    scene.add(m);
  }
  const coralCols=[0xff7a59,0xff5f9e,0xffb35f,0xff8f6b];
  for(let i=0;i<6;i++){
    const g=new THREE.Group();
    const col=coralCols[i%coralCols.length];
    const mat=new THREE.MeshStandardMaterial({color:col, roughness:0.7});
    const n=4+Math.floor(Math.random()*4);
    for(let k=0;k<n;k++){
      const h=0.5+Math.random()*0.9;
      const t=new THREE.Mesh(new THREE.ConeGeometry(0.09+Math.random()*0.08, h, 6), mat);
      t.position.set((Math.random()-0.5)*0.7, h*0.42, (Math.random()-0.5)*0.7);
      t.rotation.set((Math.random()-0.5)*0.7, Math.random()*6, (Math.random()-0.5)*0.7);
      g.add(t);
    }
    g.position.set(-12+Math.random()*24, -6.95, -5+Math.random()*8);
    scene.add(g);
  }
}
```
rock y: -7+0.25*s so sits in sand.

Bubbles:

```js
const BUBBLES=160;
{
  const base=new Float32Array(BUBBLES*3), speed=new Float32Array(BUBBLES), phase=new Float32Array(BUBBLES), size=new Float32Array(BUBBLES);
  for(let i=0;i<BUBBLES;i++){
    base[i*3]=(Math.random()*2-1)*12.5;
    base[i*3+1]=-6.8;
    base[i*3+2]=(Math.random()*2-1)*5.5;
    speed[i]=0.5+Math.random()*1.1;
    phase[i]=Math.random()*100;
    size[i]=5+Math.random()*11;
  }
  const geo=new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.BufferAttribute(base,3));
  geo.setAttribute('aSpeed', new THREE.BufferAttribute(speed,1));
  geo.setAttribute('aPhase', new THREE.BufferAttribute(phase,1));
  geo.setAttribute('aSize', new THREE.BufferAttribute(size,1));
  const mat=new THREE.ShaderMaterial({
    transparent:true, depthWrite:false,
    uniforms:{uTime:{value:0}},
    vertexShader:`
      uniform float uTime;
      attribute float aSpeed,aPhase,aSize;
      varying float vFade;
      void main(){
        float range=14.6;
        float life=fract((uTime*aSpeed+aPhase)/range);
        vec3 p=position;
        p.y=-6.8+life*range;
        p.x+=sin(uTime*1.2+aPhase*3.1+life*8.0)*0.3;
        p.z+=cos(uTime*0.9+aPhase*2.3)*0.3;
        vFade=sin(life*3.14159);
        vec4 mv=modelViewMatrix*vec4(p,1.0);
        gl_PointSize=aSize*(120.0/-mv.z);
        gl_Position=projectionMatrix*mv;
      }`,
    fragmentShader:`
      varying float vFade;
      void main(){
        vec2 uv=gl_PointCoord-0.5;
        float d=length(uv);
        float a=smoothstep(0.5,0.40,d)*0.16;
        a+=smoothstep(0.05,0.0,abs(d-0.35))*0.65;
        a+=smoothstep(0.10,0.0,length(uv-vec2(-0.14,0.16)))*0.55;
        a*=vFade;
        if(a<0.02) discard;
        gl_FragColor=vec4(0.78,0.94,1.0,a);
      }`
  });
  const pts=new THREE.Points(geo,mat);
  pts.frustumCulled=false;
  pts.userData.mat=mat;
  scene.add(pts);
  bubbleMat=mat;
}
```
declare `let bubbleMat;` before.

GLSL: `attribute float aSpeed,aPhase,aSize;` — multiple attributes in one statement valid GLSL? Yes, comma-separated declarations allowed: `attribute float a, b;` valid. OK.

Fish textures:

```js
function makeFishTexture(base, accent, pattern, darkTop){
  const c=document.createElement('canvas'); c.width=128; c.height=64;
  const g=c.getContext('2d');
  g.fillStyle=base; g.fillRect(0,0,128,64);
  const grd=g.createLinearGradient(0,0,0,64);
  if(darkTop){ grd.addColorStop(0,'rgba(8,40,70,0.5)'); grd.addColorStop(0.55,'rgba(255,255,255,0.12)'); grd.addColorStop(1,'rgba(255,255,255,0.35)'); }
  else{ grd.addColorStop(0,'rgba(255,255,255,0.22)'); grd.addColorStop(0.55,'rgba(255,255,255,0)'); grd.addColorStop(1,'rgba(10,20,50,0.4)'); }
  g.fillStyle=grd; g.fillRect(0,0,128,64);
  if(pattern===0){ g.fillStyle=accent; for(let i=0;i<4;i++) g.fillRect(14+i*30, 0, 9, 64); }
  else if(pattern===1){ g.fillStyle=accent; for(let i=0;i<24;i++){ g.globalAlpha=0.85; g.beginPath(); g.arc(8+Math.random()*112, 6+Math.random()*52, 2.5+Math.random()*3.5, 0, 7); g.fill(); } g.globalAlpha=1; }
  const t=new THREE.CanvasTexture(c);
  t.colorSpace=THREE.SRGBColorSpace;
  return t;
}
```

Fish geometry builder with shared geos:

```js
function finGeo(len, spread, notch){
  const sh=new THREE.Shape();
  sh.moveTo(0,0);
  sh.quadraticCurveTo(len*0.35, spread*0.35, len, spread);
  sh.quadraticCurveTo(len*notch, 0, len, -spread);
  sh.quadraticCurveTo(len*0.35, -spread*0.35, 0, 0);
  const g=new THREE.ShapeGeometry(sh);
  g.rotateY(Math.PI/2);
  return g;
}
const tailGeo=finGeo(0.78,0.5,0.3);
const dorsalGeo=(()=>{ 
  const sh=new THREE.Shape();
  sh.moveTo(-0.28,0);
  sh.quadraticCurveTo(-0.02,0.5,0.16,0.42);
  sh.quadraticCurveTo(0.3,0.28,0.36,0);
  sh.lineTo(-0.28,0);
  const g=new THREE.ShapeGeometry(sh);
  g.rotateY(Math.PI/2);
  return g;
})();
const eyeGeo=new THREE.SphereGeometry(0.09,10,8);
const eyeMat=new THREE.MeshStandardMaterial({color:0x0b0e12, roughness:0.2, metalness:0.3});
const bodyGeo=new THREE.SphereGeometry(1,22,16);
const pectGeo=new THREE.SphereGeometry(1,10,8);
```

buildFish:

```js
function buildFish(cfg){
  const group=new THREE.Group();
  const bodyMat=new THREE.MeshStandardMaterial({map:cfg.tex, roughness:0.5, metalness:0.05});
  const body=new THREE.Mesh(bodyGeo, bodyMat);
  body.scale.set(0.42,0.6,1.0);
  group.add(body);
  const finMat=new THREE.MeshStandardMaterial({color:new THREE.Color(cfg.accent), transparent:true, opacity:0.85, side:THREE.DoubleSide, roughness:0.65});
  const tailGroup=new THREE.Group();
  tailGroup.position.z=-0.78;
  tailGroup.add(new THREE.Mesh(tailGeo, finMat));
  group.add(tailGroup);
  const dorsal=new THREE.Mesh(dorsalGeo, finMat);
  dorsal.position.set(0,0.46,-0.05);
  group.add(dorsal);
  const fins=[];
  for(const side of [-1,1]){
    const f=new THREE.Mesh(pectGeo, finMat);
    f.scale.set(0.035,0.16,0.3);
    f.position.set(side*0.37,-0.1,0.32);
    f.rotation.y=side*Math.PI-0.55; // hmm
    ...
```
Wait earlier: right fin (side=+1) rotation.y ≈ 2.6 ≈ π-0.55; left ≈ -(π-0.55) = -2.6. So rotation.y = side*(π-0.55)? side=1 → 2.59 ✓; side=-1 → -2.59 ✓. But also fin scale non-uniform then rotated — rotation applies to scaled mesh fine (scale in local axes before rotation). baseZ: right -0.45? rotation.z tilts: with rotation order XYZ, rotation.z applied... Euler XYZ: R = Rx·Ry·Rz? three applies in order 'XYZ' meaning rotation about X then Y then Z? Actually Euler order 'XYZ' → matrix = RxRyRz? In three, order XYZ: the rotations applied as Rz first? makeRotationFromEuler with XYZ: R = Rx * Ry * Rz. So z applied first (innermost, in local frame). Tip direction after Rz then Ry... close enough visually; small angles. Set base rotZ = side*-0.4 (tilt down-out?) For right fin pointing back-out, rotating around z (local, pre-y): slight down tilt. Visual detail; pick side*-0.35 and flap around that. Store f.userData={baseY:f.rotation.y, baseZ:f.rotation.z, side}. fins.push(f).

Eyes:

```js
for(const side of [-1,1]){
  const e=new THREE.Mesh(eyeGeo, eyeMat);
  e.position.set(side*0.27,0.14,0.74);
  group.add(e);
}
group.scale.setScalar(cfg.size);
return {group, tailGroup, fins};
```
Fin scale before rotation: note scale applied in object space then rotation — with nonuniform scale + rotation, could shear? Mesh transform = T·R·S — no shear. fine.

Fish class:

```js
const _v1=new THREE.Vector3(), _v2=new THREE.Vector3(), _v3=new THREE.Vector3(), _v4=new THREE.Vector3();
const _dummy=new THREE.Object3D();
const UP=new THREE.Vector3(0,1,0);

class Fish{
  constructor(cfg){
    const b=buildFish(cfg);
    this.group=b.group; this.tailGroup=b.tailGroup; this.fins=b.fins;
    this.pos=this.group.position;
    this.pos.set((Math.random()*2-1)*BOUND.x*0.9,(Math.random()*2-1)*BOUND.y*0.8,(Math.random()*2-1)*BOUND.z*0.8);
    this.vel=new THREE.Vector3(Math.random()-0.5, (Math.random()-0.5)*0.4, Math.random()-0.5).normalize().multiplyScalar(cfg.cruise);
    this.acc=new THREE.Vector3();
    this.size=cfg.size;
    this.cruise=cfg.cruise;
    this.school=!!cfg.school;
    this.offset=new THREE.Vector3((Math.random()-0.5)*2.6,(Math.random()-0.5)*1.6,(Math.random()-0.5)*2.6);
    this.goal=new THREE.Vector3();
    this.goalTimer=0;
    this.scare=0;
    this.wag=Math.random()*20;
    this.dodge=new THREE.Vector3(1,0,0);
    this.prevYaw=null;
    this.bank=0;
    scene.add(this.group);
  }
  update(dt,t,ray,leader){
    const p=this.pos, v=this.vel, a=this.acc;
    a.set(0,0,0);
    // wander / school
    if(this.school){
      _v1.copy(leader).add(this.offset).sub(p);
      _v1.normalize().multiplyScalar(this.cruise*1.3);
      a.addScaledVector(_v1.sub(v), 2.4);
    } else {
      this.goalTimer-=dt;
      if(this.goalTimer<=0 || p.distanceTo(this.goal)<1.6){
        this.goal.set((Math.random()*2-1)*(BOUND.x-2),(Math.random()*2-1)*(BOUND.y-1.4),(Math.random()*2-1)*(BOUND.z-1.6));
        this.goalTimer=3+Math.random()*5;
      }
      _v1.copy(this.goal).sub(p).normalize().multiplyScalar(this.cruise);
      a.addScaledVector(_v1.sub(v), 0.9);
    }
    a.y+=Math.sin(t*1.25+this.wag)*0.35;
    // walls
    const m=2.4, k=7;
    if(p.x> BOUND.x-m) a.x-=k*(p.x-(BOUND.x-m))/m;
    if(p.x<-BOUND.x+m) a.x+=k*((-BOUND.x+m)-p.x)/m;
    if(p.y> BOUND.y-m) a.y-=k*(p.y-(BOUND.y-m))/m;
    if(p.y<-BOUND.y+m) a.y+=k*((-BOUND.y+m)-p.y)/m;
    if(p.z> BOUND.z-m) a.z-=k*(p.z-(BOUND.z-m))/m;
    if(p.z<-BOUND.z+m) a.z+=k*((-BOUND.z+m)-p.z)/m;
    // flee
    if(ray){
      _v1.subVectors(p, ray.origin);
      const proj=_v1.dot(ray.direction);
      if(proj>0){
        _v2.copy(ray.origin).addScaledVector(ray.direction, proj);
        const d=p.distanceTo(_v2);
        const R=4.4+this.size;
        if(d<R){
          const f=1-d/R;
          _v3.subVectors(p,_v2).normalize();
          if(this.scare<0.25){
            this.dodge.set(Math.random()-0.5,(Math.random()-0.5)*0.6,Math.random()-0.5).normalize();
            v.addScaledVector(_v3, 1.6);
          }
          a.addScaledVector(_v3, 85*f);
          a.addScaledVector(this.dodge, 26*f);
          this.scare=1;
        }
      }
    }
    this.scare=Math.max(0,this.scare-dt*0.9);
    // integrate
    v.addScaledVector(a,dt);
    const maxSp=this.cruise*(1+2.6*this.scare);
    let spd=v.length();
    if(spd>maxSp){ v.multiplyScalar(maxSp/spd); spd=maxSp; }
    if(spd<0.4 && spd>1e-5){ v.multiplyScalar(0.4/spd); spd=0.4; }
    p.addScaledVector(v,dt);
    // hard bounds
    if(Math.abs(p.x)>BOUND.x){ p.x=Math.sign(p.x)*BOUND.x; v.x*=-0.6; }
    if(Math.abs(p.y)>BOUND.y){ p.y=Math.sign(p.y)*BOUND.y; v.y*=-0.6; }
    if(Math.abs(p.z)>BOUND.z){ p.z=Math.sign(p.z)*BOUND.z; v.z*=-0.6; }
    // orientation + bank
    const yaw=Math.atan2(v.x,v.z);
    if(this.prevYaw===null) this.prevYaw=yaw;
    let dy=yaw-this.prevYaw;
    if(dy>Math.PI)dy-=2*Math.PI; else if(dy<-Math.PI)dy+=2*Math.PI;
    this.prevYaw=yaw;
    const tb=THREE.MathUtils.clamp(-dy/Math.max(dt,1e-4)*0.22,-0.65,0.65);
    this.bank+=(tb-this.bank)*Math.min(1,dt*4);
    _v4.copy(v).normalize();
    _dummy.position.set(0,0,0);
    _dummy.lookAt(_v4);
    _dummy.rotateZ(this.bank);
    this.group.quaternion.slerp(_dummy.quaternion, 1-Math.exp(-6*dt));
    // tail & fins
    this.wag+=dt*(6.5+spd*2.2+this.scare*11);
    this.tailGroup.rotation.y=Math.sin(this.wag)*(0.42+0.3*this.scare);
    for(let i=0;i<this.fins.length;i++){
      const f=this.fins[i];
      f.rotation.z=f.userData.baseZ+Math.sin(this.wag*0.85+i*2.1)*0.28;
    }
  }
}
```

Note: `_dummy.lookAt(_v4)` — Object3D.lookAt takes (vector) or (x,y,z); vector ok.

Wait: wall avoid y bounds: BOUND.y-m = 5.6-2.4 = 3.2; fish mostly within ±3.2 vertically — fine tank feels deep-ish. m for y should be smaller: use my=1.8: allow up to ±(5.6-1.8)=3.8. I'll use m=2.4 for x/z, my=1.8 for y. Adjust code accordingly.

Note about bank sign relative to lookAt+rotateZ: fine.

Also, `Math.sign(0)` = 0 edge: p.x could be exactly 0 only if |0|>BOUND false. fine.

Spawning fish & school:

```js
const fishes=[];
const schoolTex=makeFishTexture('#a9d6ee','#ffffff',-1,true);
const PALETTES=[
 [' #ff9130','#ffe9c9',0], ... 
].map(...)
```
Write plainly:

```js
const PALETTES=[
  {base:'#ff9130',accent:'#ffe9c9',pattern:0},
  {base:'#ffd23f',accent:'#ff8c2a',pattern:1},
  {base:'#4f8dff',accent:'#a5e8ff',pattern:0},
  {base:'#ff5f7e',accent:'#ffe9c9',pattern:1},
  {base:'#8fe07f',accent:'#ffd23f',pattern:0},
  {base:'#a882ff',accent:'#ffe9c9',pattern:1},
  {base:'#2fd6c3',accent:'#0a3a44',pattern:0},
];
PALETTES.forEach(p=>p.tex=makeFishTexture(p.base,p.accent,p.pattern,false));
for(let i=0;i<26;i++){
  const pal=PALETTES[i%PALETTES.length];
  fishes.push(new Fish({size:0.55+Math.random()*0.5, cruise:1.5+Math.random()*1.2, tex:pal.tex, accent:pal.accent}));
}
for(let i=0;i<14;i++){
  fishes.push(new Fish({size:0.3+Math.random()*0.08, cruise:2.7+Math.random()*0.8, tex:schoolTex, accent:'#e8f7ff', school:true}));
}
```
School fish size 0.3 with cruise 2.7 — fine.

Hmm: school fish accent '#e8f7ff' fins white; ok.

Leader:

```js
const leader=new THREE.Vector3();
function updateLeader(t){
  leader.set(Math.sin(t*0.42)*8+Math.sin(t*1.05)*1.6, Math.sin(t*0.75)*2.1, Math.cos(t*0.53)*3.4);
}
```

Separation pass:

```js
function separation(){
  for(let i=0;i<fishes.length;i++){
    const fi=fishes[i], pi=fi.pos, ai=fi.acc;
    for(let j=i+1;j<fishes.length;j++){
      const fj=fishes[j], pj=fj.pos;
      const dx=pi.x-pj.x, dy=pi.y-pj.y, dz=pi.z-pj.z;
      const r=(fi.size+fj.size)*0.85+0.35;
      const d2=dx*dx+dy*dy+dz*dz;
      if(d2<r*r && d2>1e-6){
        const d=Math.sqrt(d2), inv=1/d;
        const ov=(1-d/r)*6;
        const nx=dx*inv*ov, ny=dy*inv*ov, nz=dz*inv*ov;
        ai.x+=nx; ai.y+=ny; ai.z+=nz;
        fj.acc.x-=nx; fj.acc.y-=ny; fj.acc.z-=nz;
      }
    }
  }
}
```

Pulses:

```js
const pulses=[];
const pulseGeo=new THREE.RingGeometry(0.86,1.0,48);
pulseGeo.rotateX(-Math.PI/2);
function spawnPulse(pt){
  const mat=new THREE.MeshBasicMaterial({color:0xaef0ff, transparent:true, opacity:0.75, side:THREE.DoubleSide, depthWrite:false, blending:THREE.AdditiveBlending, fog:false});
  const mesh=new THREE.Mesh(pulseGeo,mat);
  mesh.position.copy(pt);
  scene.add(mesh);
  pulses.push({mesh, t:0, mat});
  for(const f of fishes){
    const d=f.pos.distanceTo(pt);
    if(d<9.5){
      const k=1-d/9.5;
      f.scare=1;
      _v1.subVectors(f.pos,pt).normalize();
      f.vel.addScaledVector(_v1, 4.5*k);
    }
  }
}
```

Input:

```js
const pointer=new THREE.Vector2(0,0.1);
let pointerActive=false, moved=0;
const raycaster=new THREE.Raycaster();
const hPlane=new THREE.Plane(new THREE.Vector3(0,1,0),0);
const hitPt=new THREE.Vector3();

function setPointer(e){
  pointer.x=(e.clientX/innerWidth)*2-1;
  pointer.y=-(e.clientY/innerHeight)*2+1;
  pointerActive=true;
}
addEventListener('pointermove',e=>{
  moved+=Math.abs(e.movementX||0)+Math.abs(e.movementY||0);
  setPointer(e);
  if(moved>80) hintEl.classList.add('dim');
});
addEventListener('pointerdown',e=>{
  setPointer(e);
  raycaster.setFromCamera(pointer,camera);
  if(!raycaster.ray.intersectPlane(hPlane,hitPt)) raycaster.ray.at(16,hitPt);
  spawnPulse(hitPt);
  hintEl.classList.add('dim');
});
```
e.movementX on pointermove fine; may be undefined for touch → ||0.

Animate:

```js
const clock=new THREE.Clock();
let lastGhostPulse=-4.5;
const ghost=new THREE.Vector2();
const hintEl=document.getElementById('hint');

function animate(){
  requestAnimationFrame(animate);
  const dt=Math.min(clock.getDelta(),0.05);
  const t=clock.elapsedTime;
  
  // pointer (real or ghost)
  if(pointerActive){ raycaster.setFromCamera(pointer,camera); }
  else{
    ghost.set(Math.sin(t*0.5)*0.55, Math.sin(t*0.74+1.4)*0.38);
    raycaster.setFromCamera(ghost,camera);
    if(t-lastGhostPulse>6+Math.random()*0.0){ lastGhostPulse=t; if(!raycaster.ray.intersectPlane(hPlane,hitPt)) raycaster.ray.at(16,hitPt); spawnPulse(hitPt); }
  }
```
Hmm 6+Math.random() each frame re-rolls; just use 6.5. lastGhostPulse initial -4.5 → first pulse at t=2.0. Good (screenshot ~3s shows aftermath).

```js
  const ray=raycaster.ray;
  updateLeader(t);
  separation();
  for(const f of fishes) f.update(dt,t,ray,leader);
  
  // pulses
  for(let i=pulses.length-1;i>=0;i--){
    const pu=pulses[i];
    pu.t+=dt*1.25;
    pu.mesh.scale.setScalar(0.5+pu.t*11);
    pu.mat.opacity=0.7*(1-pu.t);
    if(pu.t>=1){ scene.remove(pu.mesh); pu.mat.dispose(); pulses.splice(i,1); }
  }
  
  // env animations
  bubbleMat.uniforms.uTime.value=t;
  for(const wm of weedMats) wm.uniforms.uTime.value=t;
  floorCaustic.offset.set(t*0.012, t*0.006);
  surfaceTex.offset.set(-t*0.02, t*0.013);
  floorMat.emissiveIntensity=0.45+Math.sin(t*0.8)*0.1;
  for(let i=0;i<rays.length;i++){ rays[i].material.opacity=(0.10+0.04*Math.sin(t*0.5+i*1.7))*(0.9+0.1*Math.sin(t*0.23+i)); }
  
  // camera
  const nd=pointerActive?pointer:ghost;
  const px=nd.x*3.1, py=nd.y*1.9;
  const s=Math.min(1,dt*2.2);
  camera.position.x+=(px-camera.position.x)*s;
  camera.position.y+=(1.0+py-camera.position.y)*s;
  camera.lookAt(0,0.3,0);
  
  renderer.render(scene,camera);
}
animate();
```

fitCamera & resize:

```js
function fitCamera(){
  const aspect=innerWidth/innerHeight;
  camera.aspect=aspect;
  camera.fov=aspect<0.9?66:52;
  const halfH=Math.atan(Math.tan(THREE.MathUtils.degToRad(camera.fov/2))*aspect);
  camera.position.z=THREE.MathUtils.clamp(15.2/Math.tan(halfH)+3.5, 22, 38);
  camera.updateProjectionMatrix();
}
fitCamera();
addEventListener('resize',()=>{
  renderer.setSize(innerWidth,innerHeight);
  fitCamera();
});
```
But animate sets camera.position.y lerp and x; z fixed by fitCamera ✓. But parallax z? fine.

Note: animate modifies camera.position.x/y — fitCamera sets z only; ok.

Also floorMat & floorCaustic need scope access — declare at module level:

```js
let floorMat, floorCaustic, surfaceTex, bubbleMat;
```
then assign in blocks (remove const).

Also `hintEl` defined before use in listener — order: define hintEl top of script after DOM exists (script at end of body ✓).

Legend static.

Double-check ShaderMaterial for bubbles: uses attribute name `position` — provided. `varying float vFade` both shaders ✓.

Check three r170 ShaderMaterial with Points: gl_PointSize set ✓.

One concern: `renderer.setPixelRatio(Math.min(devicePixelRatio,2))` — devicePixelRatio global ok.

Check `THREE.MathUtils.clamp` exists ✓.

Check RingGeometry exists ✓.

EdgesGeometry(BoxGeometry) ✓.

The seaweed ShaderMaterial: no lights; colors flat but with vY gradient fine.

Check that ShaderMaterial default `fog:false` — yes custom shaders unaffected unless fog:true + fog chunks; we did manual fog for seaweed.

Glass inner box: opacity 0.05 BackSide — nearly invisible but adds tint on far side. plus edges. OK.

Now initial paint: everything static until rAF; textures generated synchronously; fish placed; first frame fish mid-tank with random vel; ghost active from t=0 (ray at sin(0)*0.55=0, y sin(1.4)*0.38≈0.37) — ray through center startles fish near center quickly. First ghost pulse at t≈2.0s → visible ring + scatter at screenshot time (~3s). 

Potential visual: fish may overlap seaweed fine.

Camera lookAt each frame: with parallax ±3.1 — tank stays framed.

Also ensure no `#` heading start — output is just code block. Output rules: exactly one fenced code block, no text before/after. The response must be only the code block.

Let me also double check the `finGeo` shape closes nicely; ShapeGeometry triangulation fine.

Potential issue: dorsal shape `sh.lineTo(-0.28,0)` then auto-close. fine.

One subtle bug: in Fish.update, `_v1.sub(v)` mutates _v1 after multiplyScalar — sequence: _v1.copy(leader).add(offset).sub(p) → normalize → multiplyScalar(cruise*1.3) → a.addScaledVector(_v1.sub(v), 2.4) ✓ desired−vel steering.

For non-school: _v1.copy(goal).sub(p).normalize().multiplyScalar(cruise); a.addScaledVector(_v1.sub(v),0.9) ✓.

Dodge normalize: random vector could be near zero length → normalize gives NaN? Math.random()-0.5 three times zero-prob; safe-ish. Guard: add 0.01 if length<0.1: 

```js
this.dodge.set(...); if(this.dodge.lengthSq()<0.02) this.dodge.set(1,0,0); this.dodge.normalize();
```
ok.

Velocity could become exactly 0 if forces cancel → atan2(0,0)=0 fine; lookAt(0,0,0) from origin → degenerate: lookAt target equal position → three handles: Matrix4.lookAt with eye==target gives... _Z = eye-target = 0 → normalized 0 → guard exists (`if length²===0 → z.z? three code: if (x.lengthSq()===0) ... z.z or something`). Three's Matrix4.lookAt: 

```js
if ( _x.lengthSq() === 0 ) { // eye and target are in the same position
  target.z += 0.0001;
```
wait it checks z axis: `_z.subVectors(eye,target); if (_z.lengthSq()===0){ _z.z=1; }`. Yes guard exists. And vel min clamp 0.4 also prevents zero mostly. OK.

Slerp with dt: fine.

Now performance: 40 fish × (1 body +1 tail +1 dorsal +2 pect +2 eyes =7 meshes) = 280 + env ~50 + bubbles = ~340 draw calls. Fine on most GPUs.

School fish cruise 2.7, steering gain 2.4 toward leader — they'll loop around tank. Their own goal wander skipped (school branch). They also have separation among all fish — with r for size 0.3 pairs: r=0.3*0.85*2+0.35≈0.86 — good, they cluster but don't overlap.

The leader path max |x| ~9.6 within bounds ✓ y ±2.1 z ±3.4 ✓.

Also, normal fish may cross school — fine.

The ghost pulse every 6.5s could annoy? It's demo showcase; real pointer disables. fine.

Edge: `pointerActive` set on pointerdown too ✓.

HTML structure & CSS done. Also add `<meta name="color-scheme" content="dark">`? optional skip.

One more: `body::after` replaced by #vignette div (used div). ✓.

Fonts: system-ui stack — allowed (no external font).

Finally, ensure the importmap script tag exactly as given:
```html
<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>
```

Module script: `import * as THREE from 'three';`.

Let me assemble and double check every variable declared: bubbleMat (let), floorCaustic (let), surfaceTex (let), floorMat (let), weedMats (const array), rays (const), pulses, fishes, leader, clock, ghost, raycaster, hPlane, hitPt, pointer, hintEl.

In floor block: floorCaustic=causticTexture(8,4); floorMat=new ...

In surface block: surfaceTex=causticTexture(7,3.5).

Bubble block: bubbleMat=mat.

Also note: two CanvasTextures from same canvas — both upload separately, fine.

Order of definitions: functions before use (function declarations hoisted; const arrow not) — I'll define helpers first, then scene content, then classes, then fish, then input, then loop. Fish class uses buildFish & scene — buildFish defined before class instantiation ✓ (function declarations hoisted anyway).

`separation()` uses fishes — defined ✓.

`spawnPulse` uses _v1 ✓ and fishes ✓.

pulseGeo created before spawnPulse called ✓.

Legend element text: "40 fish · 7 species · cursor = predator · click = shockwave". Set via JS or static HTML. Static.

Title: "The Lagoon" with span colored.

Check contrast in 375px: UI clamp sizes fine; legend may overlap hint? legend bottom-right small — ok.

overflow hidden body; canvas block. In iframe 1280×800 fine.

One more possible error source: `geo.rotateX` on PlaneGeometry — returns geometry ✓ chainable.

`new THREE.PlaneGeometry(70,34)` then `surface.geometry.rotateX(Math.PI/2)` — I wrote rotate on geometry via method ✓.

For surface plane rotated +π/2: normal → (0,-1,0) computed earlier ✓ faces down toward camera/fish ✓.

Also set `surface.material.side` default front — visible from below ✓.

Fog on MeshBasicMaterial for surface: default fog true; at distance ~24-30, slight fade ok.

Now, fish body emissive? none — with ACES tone mapping and dir light 2.1, bodies should be bright enough. Add slight envMap? skip.

Let me reconsider directional light intensity: r170 non-legacy: DirectionalLight intensity 2.1 with ACES → decent. Hemisphere 1.15. Ambient 0.55. Should be luminous enough.

Write final. Also wrap numbers: exposure 1.15.

Also I should double-check `Fish` constructor `scene.add` — scene defined earlier ✓.

dt first frame: clock.getDelta() after creation ~0 → fine.

lookAt with vector: Object3D.lookAt(vector) ✓.

`_dummy.rotateZ(bank)` — rotateZ on Object3D updates quaternion ✓.

Note: group.quaternion.slerp(target, t) — Quaternion.slerp(qb, t) instance method: `q.slerp(qb, t)` interpolates this→qb ✓.

I think we're set. One more: the hint says "click for a shockwave" — pointerdown anywhere including on canvas ✓ (listener on window).

Also prevent touch scrolling: CSS `touch-action:none` on canvas/body to make pointermove work on touch drag. Add `html,body{touch-action:none}`? touch-action on body: set canvas{touch-action:none}. Add.

Also `overscroll-behavior:none`.

Compose final HTML now. Careful: inside template literals in JS shaders, backticks fine; the outer code block fenced with ```html — content contains backticks (JS template literals) — that's fine inside fenced block.

Double check bubble fragment discard ok.

Also one nuance: attribute declarations in ShaderMaterial: three injects built-in attributes (position, normal, uv) for ShaderMaterial (not RawShaderMaterial) — declaring `attribute float aSpeed` custom ✓. Don't redeclare position ✓ (I use position directly in vertex shader — it's auto-declared by three's prefix ✓).

For the seaweed vertex shader I use `uv` — auto-declared ✓. `modelViewMatrix`, `projectionMatrix` auto ✓.

In seaweed fragment: uses varyings ✓.

Everything good. Write it.

Actually, let me reconsider the ghost pulse at t≈2: `lastGhostPulse=-4.5` initial; condition `t-lastGhostPulse>6.5` → t>2.0. Then lastGhostPulse=t=2.0; next at 8.5 etc. ✓.

And the screenshot at ~3s: pulse spawned at 2.0 expands till 3.0 (t param 1.25/s → dies at 2.0+0.8=2.8s) — gone by 3s, but fish scattered & scared (scare decays over ~1.1s) — still darting. Ring visible ~2.0-2.8s; screenshot might miss ring but fish clearly reacting + bubbles + scene richness. Good enough. Maybe slow pulse: pu.t+=dt*1.0 and opacity hold? Make pulse last longer: dt*0.9 → 1.1s. And ghost pulse period 5.5 → pulses at 2.0, 7.5... Screenshot 3s: first ring just ended. Shift first pulse earlier: lastGhostPulse=-3.8 → first at t=2.7 → ring alive 2.7–3.8 → screenshot at 3s shows ring ✓. Set -3.8, period 6.5.

Also with ghost ray continuously sweeping, fish near center perpetually startled — lively demo ✓.

Final answer: single code block. No text outside.

Let me also handle the case WebGL unavailable — skip (sandbox supports).

Write out now, complete and careful.