Models 44

DeepSeek V4.1 Flash 1017

by DeepSeekReasoningClean
Open in new window

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

How it went

Total time
1 min 29 s
Cost
$0.014
Output tokens
28,649
Spent thinking
21,435 tokens
Speed
325 tok/s
Console errors
0
First token after
676 ms
First visible text after
1 min 10 s
Prompt tokens
460
Code size
21.1 KB
Finish reason
stop
Served by
Ionstream
Model id
deepseek/deepseek-v4.1-flash-1017
Reasoning sent
{"effort":"low"}
Max output
64,000 tokens
Generated
Nov 11, 2026

What it was asked

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

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

Code run-1.html · 651 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>3D Aquarium</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body {
    height: 100%; overflow: hidden; background: #02121a;
    font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
    -webkit-font-smoothing: antialiased;
  }
  canvas { display: block; }

  #vignette {
    position: fixed; inset: 0; pointer-events: none;
    background: radial-gradient(ellipse at 50% 42%, rgba(0,0,0,0) 38%, rgba(0,12,20,0.6) 100%);
  }

  #hud {
    position: fixed; top: 22px; left: 26px; pointer-events: none;
    color: #d8f6ff; text-shadow: 0 2px 18px rgba(0,20,30,0.95);
  }
  #hud h1 {
    font-size: 19px; font-weight: 600; letter-spacing: 0.24em; text-transform: uppercase;
  }
  #hud p {
    margin-top: 7px; font-size: 12.5px; letter-spacing: 0.05em; opacity: 0.6; max-width: 46ch;
  }

  #hint {
    position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%);
    padding: 9px 20px; border-radius: 999px; white-space: nowrap;
    background: rgba(8, 40, 54, 0.5); border: 1px solid rgba(140, 230, 255, 0.22);
    backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
    color: #c9f2ff; font-size: 12.5px; letter-spacing: 0.09em;
    pointer-events: none; opacity: 1; transition: opacity 1s ease;
  }
  #hint.hide { opacity: 0; }

  @media (max-width: 560px) {
    #hud { top: 16px; left: 16px; }
    #hud h1 { font-size: 14px; letter-spacing: 0.18em; }
    #hud p { font-size: 10.5px; max-width: 30ch; }
    #hint { font-size: 11px; padding: 7px 14px; bottom: 18px; }
  }
</style>
</head>
<body>
<div id="vignette"></div>
<header id="hud">
  <h1>Aquarium</h1>
  <p>Move your cursor through the water — the fish dart away from it</p>
</header>
<div id="hint">Move your mouse to startle the fish</div>

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

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

/* ------------------------------------------------------------------ */
/*  Renderer / scene / camera                                          */
/* ------------------------------------------------------------------ */
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x073a4e, 0.030);

const camera = new THREE.PerspectiveCamera(52, window.innerWidth / window.innerHeight, 0.1, 400);
camera.position.set(0, 1.0, 13.5);
camera.lookAt(0, 0.6, 0);

/* ------------------------------------------------------------------ */
/*  Background gradient (canvas texture)                               */
/* ------------------------------------------------------------------ */
(function makeBackground() {
  const c = document.createElement('canvas');
  c.width = 8; c.height = 256;
  const ctx = c.getContext('2d');
  const g = ctx.createLinearGradient(0, 0, 0, 256);
  g.addColorStop(0.00, '#128ba8');
  g.addColorStop(0.28, '#0b6280');
  g.addColorStop(0.58, '#073a52');
  g.addColorStop(0.82, '#03202e');
  g.addColorStop(1.00, '#010d16');
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, 8, 256);
  const tex = new THREE.CanvasTexture(c);
  tex.colorSpace = THREE.SRGBColorSpace;
  scene.background = tex;
})();

/* ------------------------------------------------------------------ */
/*  Lights                                                             */
/* ------------------------------------------------------------------ */
scene.add(new THREE.HemisphereLight(0x9fe8ff, 0x0a2a33, 1.15));

const sun = new THREE.DirectionalLight(0xffffff, 2.1);
sun.position.set(7, 15, 9);
sun.castShadow = true;
sun.shadow.mapSize.set(1024, 1024);
sun.shadow.camera.left = -15;
sun.shadow.camera.right = 15;
sun.shadow.camera.top = 15;
sun.shadow.camera.bottom = -15;
sun.shadow.camera.near = 1;
sun.shadow.camera.far = 45;
sun.shadow.bias = -0.0012;
scene.add(sun);

const rimLight = new THREE.PointLight(0x2ad8ff, 90, 40, 2);
rimLight.position.set(-7, 6, 6);
scene.add(rimLight);

const warmLight = new THREE.PointLight(0xff9a5c, 45, 34, 2);
warmLight.position.set(8, -1, -5);
scene.add(warmLight);

/* ------------------------------------------------------------------ */
/*  Helpers                                                            */
/* ------------------------------------------------------------------ */
const rand = (a, b) => a + Math.random() * (b - a);

function makeCircleTexture() {
  const s = 64;
  const c = document.createElement('canvas');
  c.width = c.height = s;
  const ctx = c.getContext('2d');
  const g = ctx.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
  g.addColorStop(0.0, 'rgba(255,255,255,1)');
  g.addColorStop(0.35, 'rgba(205,242,255,0.7)');
  g.addColorStop(1.0, 'rgba(170,228,255,0)');
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, s, s);
  const t = new THREE.CanvasTexture(c);
  t.colorSpace = THREE.SRGBColorSpace;
  return t;
}

function makeSandTexture() {
  const s = 512;
  const c = document.createElement('canvas');
  c.width = c.height = s;
  const ctx = c.getContext('2d');
  ctx.fillStyle = '#c2ad86';
  ctx.fillRect(0, 0, s, s);
  for (let i = 0; i < 26000; i++) {
    const x = Math.random() * s;
    const y = Math.random() * s;
    const bright = Math.random() > 0.5;
    ctx.fillStyle = bright
      ? `rgba(255,246,214,${0.04 + Math.random() * 0.13})`
      : `rgba(70,56,36,${0.04 + Math.random() * 0.13})`;
    ctx.fillRect(x, y, 1 + Math.random() * 2, 1 + Math.random() * 2);
  }
  // faint ripple bands
  ctx.globalAlpha = 0.07;
  for (let i = 0; i < 26; i++) {
    ctx.strokeStyle = i % 2 ? '#fff4d0' : '#6b5a3a';
    ctx.lineWidth = 2 + Math.random() * 6;
    ctx.beginPath();
    const y = Math.random() * s;
    ctx.moveTo(0, y);
    for (let x = 0; x <= s; x += 32) {
      ctx.lineTo(x, y + Math.sin(x * 0.03 + i) * 9);
    }
    ctx.stroke();
  }
  ctx.globalAlpha = 1;

  const t = new THREE.CanvasTexture(c);
  t.wrapS = t.wrapT = THREE.RepeatWrapping;
  t.repeat.set(7, 7);
  t.colorSpace = THREE.SRGBColorSpace;
  t.anisotropy = renderer.capabilities.getMaxAnisotropy();
  return t;
}

/* ------------------------------------------------------------------ */
/*  Sea floor + rocks                                                  */
/* ------------------------------------------------------------------ */
const floor = new THREE.Mesh(
  new THREE.PlaneGeometry(90, 90),
  new THREE.MeshStandardMaterial({ map: makeSandTexture(), color: 0x8d7f60, roughness: 1, metalness: 0 })
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -4.7;
floor.receiveShadow = true;
scene.add(floor);

const rockMat = new THREE.MeshStandardMaterial({ color: 0x2c3c46, roughness: 0.95, flatShading: true });
for (let i = 0; i < 11; i++) {
  const rock = new THREE.Mesh(new THREE.IcosahedronGeometry(1, 0), rockMat);
  const s = rand(0.55, 1.9);
  rock.position.set(rand(-15, 15), -4.7 + s * 0.25, rand(-12, 4));
  rock.scale.set(s, s * rand(0.45, 0.85), s * rand(0.8, 1.25));
  rock.rotation.set(Math.random() * 3, Math.random() * 6, Math.random() * 3);
  rock.castShadow = true;
  rock.receiveShadow = true;
  scene.add(rock);
}

/* ------------------------------------------------------------------ */
/*  Seaweed                                                            */
/* ------------------------------------------------------------------ */
function makeBladeGeometry() {
  const g = new THREE.PlaneGeometry(0.32, 3.4, 1, 7);
  g.translate(0, 1.7, 0);
  const pos = g.attributes.position;
  for (let i = 0; i < pos.count; i++) {
    const t = pos.getY(i) / 3.4;
    pos.setX(i, pos.getX(i) * (1 - t * 0.88));
  }
  g.computeVertexNormals();
  return g;
}

const bladeGeo = makeBladeGeometry();
const weedMat = new THREE.MeshStandardMaterial({
  color: 0x2f9a63, roughness: 0.85, metalness: 0.05,
  side: THREE.DoubleSide, emissive: 0x0b3d24, emissiveIntensity: 1
});
const weeds = [];
for (let i = 0; i < 18; i++) {
  const plant = new THREE.Group();
  plant.position.set(rand(-16, 16), -4.7, rand(-11, 4));
  const blades = 3 + Math.floor(Math.random() * 3);
  for (let b = 0; b < blades; b++) {
    const blade = new THREE.Mesh(bladeGeo, weedMat);
    blade.rotation.y = Math.random() * Math.PI * 2;
    blade.rotation.z = (Math.random() - 0.5) * 0.35;
    blade.scale.setScalar(rand(0.7, 1.5));
    blade.position.set(rand(-0.25, 0.25), 0, rand(-0.25, 0.25));
    blade.castShadow = true;
    plant.add(blade);
  }
  plant.userData.phase = Math.random() * Math.PI * 2;
  plant.userData.speed = rand(0.7, 1.5);
  scene.add(plant);
  weeds.push(plant);
}

/* ------------------------------------------------------------------ */
/*  Light shafts                                                       */
/* ------------------------------------------------------------------ */
const shaftMat = new THREE.ShaderMaterial({
  transparent: true,
  depthWrite: false,
  blending: THREE.AdditiveBlending,
  side: THREE.DoubleSide,
  uniforms: {
    uColor: { value: new THREE.Color(0x9fe8ff) },
    uTime: { value: 0 }
  },
  vertexShader: `
    varying vec2 vUv;
    varying vec3 vN;
    varying vec3 vV;
    void main() {
      vUv = uv;
      vec4 mv = modelViewMatrix * vec4(position, 1.0);
      vN = normalize(normalMatrix * normal);
      vV = normalize(-mv.xyz);
      gl_Position = projectionMatrix * mv;
    }
  `,
  fragmentShader: `
    uniform vec3 uColor;
    uniform float uTime;
    varying vec2 vUv;
    varying vec3 vN;
    varying vec3 vV;
    void main() {
      float rim = pow(1.0 - abs(dot(normalize(vN), normalize(vV))), 2.0);
      float fade = smoothstep(0.0, 0.55, vUv.y) * (1.0 - smoothstep(0.88, 1.0, vUv.y));
      float a = (0.30 + rim) * fade * 0.085;
      gl_FragColor = vec4(uColor, a);
    }
  `
});

const shaftGroup = new THREE.Group();
for (let i = 0; i < 7; i++) {
  const h = rand(16, 24);
  const cone = new THREE.Mesh(new THREE.ConeGeometry(rand(1.6, 3.2), h, 14, 1, true), shaftMat);
  cone.position.set(rand(-13, 13), 5 - h * 0.5, rand(-11, 2));
  cone.rotation.z = rand(-0.12, 0.12);
  cone.rotation.x = rand(-0.08, 0.08);
  shaftGroup.add(cone);
}
scene.add(shaftGroup);

/* ------------------------------------------------------------------ */
/*  Bubbles                                                            */
/* ------------------------------------------------------------------ */
const BUBBLES = 260;
const bubblePositions = new Float32Array(BUBBLES * 3);
const bubbleSpeeds = new Float32Array(BUBBLES);
const bubblePhase = new Float32Array(BUBBLES);

for (let i = 0; i < BUBBLES; i++) {
  bubblePositions[i * 3 + 0] = rand(-16, 16);
  bubblePositions[i * 3 + 1] = rand(-5, 9);
  bubblePositions[i * 3 + 2] = rand(-12, 7);
  bubbleSpeeds[i] = rand(0.35, 1.5);
  bubblePhase[i] = Math.random() * Math.PI * 2;
}

const bubbleGeo = new THREE.BufferGeometry();
bubbleGeo.setAttribute('position', new THREE.BufferAttribute(bubblePositions, 3));
const bubbleMat = new THREE.PointsMaterial({
  size: 0.17,
  map: makeCircleTexture(),
  transparent: true,
  opacity: 0.55,
  depthWrite: false,
  blending: THREE.AdditiveBlending,
  sizeAttenuation: true,
  color: 0xcfeeff
});
const bubbles = new THREE.Points(bubbleGeo, bubbleMat);
scene.add(bubbles);

/* ------------------------------------------------------------------ */
/*  Pointer glow                                                       */
/* ------------------------------------------------------------------ */
const glow = new THREE.Sprite(new THREE.SpriteMaterial({
  map: makeCircleTexture(),
  color: 0xaeeaff,
  transparent: true,
  opacity: 0.22,
  blending: THREE.AdditiveBlending,
  depthWrite: false
}));
glow.scale.set(3.6, 3.6, 1);
scene.add(glow);

/* ------------------------------------------------------------------ */
/*  Fish                                                               */
/* ------------------------------------------------------------------ */
const fishList = [];
const palette = [0xff7a45, 0xffd166, 0x4ee1c1, 0xff6b9d, 0x9b7bff, 0x5ec8ff, 0xff5566, 0x8ef29d, 0xffa3d1, 0xffb347];

function makeFish(color, scale) {
  const g = new THREE.Group();

  const bodyMat = new THREE.MeshStandardMaterial({
    color,
    roughness: 0.36,
    metalness: 0.32,
    emissive: new THREE.Color(color).multiplyScalar(0.16)
  });

  const finColor = new THREE.Color(color).lerp(new THREE.Color(0xffffff), 0.35);
  const finMat = new THREE.MeshStandardMaterial({
    color: finColor,
    roughness: 0.5,
    metalness: 0.12,
    transparent: true,
    opacity: 0.78,
    side: THREE.DoubleSide,
    emissive: new THREE.Color(color).multiplyScalar(0.22)
  });

  // Body (forward = +Z)
  const bodyGeo = new THREE.SphereGeometry(1, 20, 14);
  bodyGeo.scale(0.28, 0.44, 0.85);
  const body = new THREE.Mesh(bodyGeo, bodyMat);
  body.castShadow = true;
  g.add(body);

  // Tail fin (pivot at the tail root, wags around Y)
  const tail = new THREE.Group();
  tail.position.z = -0.76;
  const tailGeo = new THREE.BufferGeometry();
  tailGeo.setAttribute('position', new THREE.Float32BufferAttribute([
    0, 0, 0,   0, 0.55, -0.92,   0, 0, -0.5,
    0, 0, 0,   0, 0, -0.5,        0, -0.55, -0.92
  ], 3));
  tailGeo.computeVertexNormals();
  tail.add(new THREE.Mesh(tailGeo, finMat));
  g.add(tail);

  // Dorsal fin
  const dorsalGeo = new THREE.BufferGeometry();
  dorsalGeo.setAttribute('position', new THREE.Float32BufferAttribute([
    0, 0.40, 0.24,   0, 0.78, -0.28,   0, 0.36, -0.52
  ], 3));
  dorsalGeo.computeVertexNormals();
  g.add(new THREE.Mesh(dorsalGeo, finMat));

  // Eyes
  const eyeMat = new THREE.MeshStandardMaterial({ color: 0x08131a, roughness: 0.18, metalness: 0.7 });
  const eyeGeo = new THREE.SphereGeometry(0.085, 10, 8);
  const eyeL = new THREE.Mesh(eyeGeo, eyeMat);
  eyeL.position.set(0.16, 0.14, 0.62);
  g.add(eyeL);
  const eyeR = new THREE.Mesh(eyeGeo, eyeMat);
  eyeR.position.set(-0.16, 0.14, 0.62);
  g.add(eyeR);

  g.userData.tail = tail;
  g.scale.setScalar(scale);
  return g;
}

const FISH_COUNT = 26;
const _tmp = new THREE.Vector3();

for (let i = 0; i < FISH_COUNT; i++) {
  const color = palette[i % palette.length];
  const scale = rand(0.7, 1.45);
  const fish = makeFish(color, scale);

  fish.position.set(rand(-7.5, 7.5), rand(-2.2, 4.0), rand(-5.5, 5.5));

  const dir = new THREE.Vector3(rand(-1, 1), rand(-0.3, 0.3), rand(-1, 1)).normalize();
  const tail = fish.userData.tail;

  fish.userData = {
    tail,
    vel: dir.multiplyScalar(2),
    cruise: rand(1.5, 2.9),
    phase: Math.random() * Math.PI * 2,
    wag: rand(7, 13),
    ax: 0, ay: 0, az: 0,
    flee: 0
  };

  fish.lookAt(_tmp.copy(fish.position).add(fish.userData.vel));
  scene.add(fish);
  fishList.push(fish);
}

/* ------------------------------------------------------------------ */
/*  Pointer input                                                      */
/* ------------------------------------------------------------------ */
const pointer = new THREE.Vector3(0, 0, 0);
const pointerTarget = new THREE.Vector3(0, 0, 0);
const raycaster = new THREE.Raycaster();
const pointerPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
const _ndc = new THREE.Vector2();

let time = 0;
let lastPointerTime = -100;
let pointerEverMoved = false;
const hintEl = document.getElementById('hint');
let hintHidden = false;

function hideHint() {
  if (hintHidden) return;
  hintHidden = true;
  hintEl.classList.add('hide');
}

window.addEventListener('pointermove', (e) => {
  _ndc.set((e.clientX / window.innerWidth) * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1);
  raycaster.setFromCamera(_ndc, camera);
  if (raycaster.ray.intersectPlane(pointerPlane, pointerTarget)) {
    lastPointerTime = time;
    pointerEverMoved = true;
    hideHint();
  }
}, { passive: true });

window.addEventListener('pointerdown', hideHint, { passive: true });
setTimeout(hideHint, 9000);

/* ------------------------------------------------------------------ */
/*  Simulation                                                         */
/* ------------------------------------------------------------------ */
const FLEE_RADIUS = 4.6;
const BOUND = { x: 8.2, yTop: 4.4, yBot: -2.7, z: 6.0 };

function updatePointer(dt) {
  const idle = time - lastPointerTime;
  if (!pointerEverMoved || idle > 2.2) {
    const s = time;
    pointerTarget.set(
      Math.sin(s * 0.55) * 7.0,
      Math.sin(s * 0.83 + 1.3) * 2.3 + 0.7,
      Math.cos(s * 0.42) * 3.2
    );
  }
  pointer.lerp(pointerTarget, 1 - Math.exp(-5.5 * dt));
  glow.position.copy(pointer);
}

function updateFish(dt, t) {
  const p = pointer;

  // --- steering forces
  for (const f of fishList) {
    const u = f.userData;
    const ph = u.phase;

    const tx = Math.sin(t * 0.31 + ph) * 6.0;
    const ty = 1.0 + Math.sin(t * 0.47 + ph * 1.7) * 2.3;
    const tz = Math.sin(t * 0.24 + ph * 0.8) * 4.0;

    let ax = (tx - f.position.x) * 0.45;
    let ay = (ty - f.position.y) * 0.45;
    let az = (tz - f.position.z) * 0.45;

    const dx = f.position.x - p.x;
    const dy = f.position.y - p.y;
    const dz = f.position.z - p.z;
    const d2 = dx * dx + dy * dy + dz * dz;

    let flee = 0;
    if (d2 < FLEE_RADIUS * FLEE_RADIUS) {
      const d = Math.sqrt(d2) + 1e-4;
      const w = 1 - d / FLEE_RADIUS;
      flee = w * w;
      const k = (flee * 55) / d;
      ax += dx * k;
      ay += dy * k;
      az += dz * k;
    }

    if (f.position.x > BOUND.x) ax -= (f.position.x - BOUND.x) * 26;
    if (f.position.x < -BOUND.x) ax += (-BOUND.x - f.position.x) * 26;
    if (f.position.y > BOUND.yTop) ay -= (f.position.y - BOUND.yTop) * 26;
    if (f.position.y < BOUND.yBot) ay += (BOUND.yBot - f.position.y) * 26;
    if (f.position.z > BOUND.z) az -= (f.position.z - BOUND.z) * 26;
    if (f.position.z < -BOUND.z) az += (-BOUND.z - f.position.z) * 26;

    u.ax = ax; u.ay = ay; u.az = az;
    u.flee = flee;
  }

  // --- separation (soft collision avoidance)
  for (let i = 0; i < fishList.length; i++) {
    const A = fishList[i];
    for (let j = i + 1; j < fishList.length; j++) {
      const B = fishList[j];
      let dx = B.position.x - A.position.x;
      let dy = B.position.y - A.position.y;
      let dz = B.position.z - A.position.z;
      const d2 = dx * dx + dy * dy + dz * dz;
      const minD = 1.3;
      if (d2 < minD * minD && d2 > 1e-5) {
        const d = Math.sqrt(d2);
        const push = ((minD - d) / minD) * 16 / d;
        dx *= push; dy *= push; dz *= push;
        A.userData.ax -= dx; A.userData.ay -= dy; A.userData.az -= dz;
        B.userData.ax += dx; B.userData.ay += dy; B.userData.az += dz;
      }
    }
  }

  // --- integrate + orient
  for (const f of fishList) {
    const u = f.userData;

    u.vel.x += u.ax * dt;
    u.vel.y += u.ay * dt;
    u.vel.z += u.az * dt;

    u.vel.multiplyScalar(Math.exp(-1.9 * dt));

    const sp = u.vel.length();
    if (sp > 11) {
      u.vel.multiplyScalar(11 / sp);
    } else if (sp < u.cruise) {
      if (sp > 1e-4) u.vel.multiplyScalar(u.cruise / sp);
      else u.vel.set(0, 0, 1).multiplyScalar(u.cruise);
    }

    f.position.addScaledVector(u.vel, dt);

    _tmp.copy(f.position).add(u.vel);
    f.lookAt(_tmp);
    f.rotateZ(Math.sin(t * 1.7 + u.phase) * 0.12);

    u.tail.rotation.y = Math.sin(t * u.wag + u.phase) * (0.35 + u.flee * 0.75);
  }
}

function updateBubbles(dt, t) {
  const arr = bubbleGeo.attributes.position.array;
  for (let i = 0; i < BUBBLES; i++) {
    const i3 = i * 3;
    arr[i3 + 1] += bubbleSpeeds[i] * dt;
    arr[i3] += Math.sin(t * 1.6 + bubblePhase[i]) * dt * 0.22;
    if (arr[i3 + 1] > 9) {
      arr[i3 + 1] = -5.2;
      arr[i3] = rand(-16, 16);
      arr[i3 + 2] = rand(-12, 7);
    }
  }
  bubbleGeo.attributes.position.needsUpdate = true;
}

function updateWeeds(t) {
  for (const plant of weeds) {
    const u = plant.userData;
    plant.rotation.z = Math.sin(t * u.speed + u.phase) * 0.1;
    plant.rotation.x = Math.cos(t * u.speed * 0.8 + u.phase) * 0.07;
  }
}

/* ------------------------------------------------------------------ */
/*  Resize                                                             */
/* ------------------------------------------------------------------ */
window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

/* ------------------------------------------------------------------ */
/*  Loop                                                               */
/* ------------------------------------------------------------------ */
const clock = new THREE.Clock();

renderer.setAnimationLoop(() => {
  const dt = Math.min(clock.getDelta(), 0.05);
  time += dt;

  updatePointer(dt);
  updateFish(dt, time);
  updateBubbles(dt, time);
  updateWeeds(time);
  shaftMat.uniforms.uTime.value = time;

  camera.position.x = Math.sin(time * 0.12) * 0.7;
  camera.position.y = 1.0 + Math.sin(time * 0.17) * 0.28;
  camera.lookAt(0, 0.6, 0);

  renderer.render(scene, camera);
});
</script>
</body>
</html>
Reasoning trace (21,435 tokens)
We need to produce a single HTML file with Three.js 3D aquarium, fish swimming away from mouse. Use import map. Need first paint meaningful, mouse interaction. Since iframe may not get mouse events until click? Actually mouse events do work in iframe? Pointer events typically work. But keyboard needs click. Let's add a "Click or press a key to start" overlay that fades, but mouse interaction should work anytime. Actually the requirement: games must start in demo mode. This isn't a game, but we can still show a small hint. Let's make fish swim away from the mouse pointer projected onto a plane, and also auto-demo: an invisible "cursor" moving around when mouse hasn't moved? Nice touch: fish flee from an invisible "demo pointer" that wanders in figure-eight until user moves mouse. That makes it visible on screenshot.

Let's design:

- Scene with fog, gradient background (custom shader or CSS behind transparent canvas? Use a large sphere with gradient shader for underwater look).
- Caustics-ish: use a plane with shader for sandy floor? Simpler: floor plane with subtle noise texture generated procedurally via canvas. And light shafts as transparent cones/planes.
- Bubbles: points rising.
- Fish: procedurally generated geometry — body (sphere scaled), tail (cone or custom shape), fins. Use InstancedMesh? Simpler: build a fish as a THREE.Group with meshes, then clone material variants. ~30 fish. Each fish has a tail that wags via rotation. Use a shared geometry.

Better performance: 30 fish * few meshes each = fine.

Fish behavior: 
- Each fish has position, velocity, desired direction.
- Wander: steering using smooth noise-ish (sin of time with per-fish phase).
- Flee: if distance to mouse 3D point < radius, add force away.
- Bounds: keep within tank box (steer inward).
- Avoid others a bit? Optional, keep simple but add separation for nice look.

Orientation: face velocity direction, use quaternion lookAt or set rotation from direction with smoothing. Use a dummy Object3D and lookAt.

Fish model: 
- Body: SphereGeometry(0.5, 16, 12) scaled (1.6, 0.9, 0.6)? Actually fish point along +Z or +X? Let's make fish along +Z direction (forward = +Z). Then lookAt works: object.lookAt(pos + vel) — but lookAt makes -Z point at target for cameras; for objects, lookAt also orients -Z? Actually Object3D.lookAt rotates so that the object's +Z axis points toward target for non-camera objects... Let me recall: Object3D.lookAt uses Matrix4.lookAt(eye, target, up) which for cameras points -Z at target. For non-camera objects, the code: if (this.isCamera || this.isLight) m1.lookAt(position, target, up) else m1.lookAt(target, position, up). So for regular objects, it points +Z towards target. Yes — so forward = +Z. Good.

So build fish with nose toward +Z.

Tail: a flattened cone or a custom triangle shape at -Z, rotating around Y for wagging. Use a Shape / ExtrudeGeometry? Simpler: a triangle plane (two-sided) for the tail fin. Use BufferGeometry custom triangle with double-sided material. And a body.

Let's make fish body from a scaled sphere with vertex morph? Could add a tail wiggle via rotating the whole fish slightly (yaw sine) — that reads well.

I'll build:
- body: SphereGeometry(1, 20, 14) scaled to (0.42, 0.6, 1.0) — so length along z ~2.
- Actually simpler: use a lathe? Let's just do sphere scaled (0.38, 0.55, 1.1).
- tail: triangle plane at z=-1.1, wagging.
- dorsal fin: triangle plane on top.
- pectoral fins: two small triangles.
- eyes: two small spheres, black.

Colors: use MeshStandardMaterial with colors from palette, varying hue. Add slight emissive for visibility.

Lighting: hemisphere light + directional light + point light. Fog for depth (FogExp2 with dark blue).

Background: gradient via sphere with shader (BackSide) — dark teal to deep blue.

Environment: 
- sand floor plane at y=-6 with procedural canvas texture (noise).
- some rocks (icosahedron geometry, dark), seaweed (thin bent cylinders/planes swaying).
- bubbles: Points with round sprite texture generated via canvas.
- light shafts: cones with additive transparent material, slowly rotating.

Mouse: on mousemove, convert to NDC, raycast onto a plane at z=0? Better: use raycaster to intersect a plane facing camera at the aquarium center... Simpler: unproject the pointer to a plane at z = 0 (in world) — actually fish live in a box; the mouse gives a 2D direction. Use a plane parallel to camera at z=0: use raycaster.ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0,0,1), 0), point). That gives a point in the z=0 plane. Fish flee from that point in 3D but fish at z != 0 will feel less. That's fine and looks good.

Better: compute a 3D "pointer" point by raycasting and also give it a z influenced by... just z=0 plane. Fine.

Demo pointer: when no mouse movement for a while, use an auto-moving point (Lissajous). Set mouseActive=false after e.g. 4s of no movement. Also on touch devices, use pointermove.

Actually, careful: the requirement says a screenshot is taken ~3 seconds after load. So the demo should show fish swimming naturally, plus the auto pointer making them scatter. Good.

Let's also add "Click or press a key to start" message? The prompt says games must. This isn't a game but the instruction says the demo uses mouse... Actually mouse events should work in the iframe without click (mousemove usually works). But the guidance: "if the demo uses the keyboard, display a clear..." We don't use keyboard. Still, a hint overlay at the bottom like "Move your mouse — fish will flee" is nice, and it's not blocking. I'll add a subtle hint that fades after a while, but also show a small persistent title. Let's not block the scene with a click-to-start overlay since mouse works. Hmm, but many sandboxes require a click for pointer events too? Generally iframes do receive mouse events. I'll include a non-blocking overlay hint that disappears on first pointer move. Actually, let's keep a small always-visible hint in the corner, and a centered "Move the mouse to stir the water" card that fades out after first pointermove or after 5s. That's safe.

Let's write the code.

Structure:

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Aquarium</title>
<style>...</style>
</head>
<body>
<div id="ui">...</div>
<script type="importmap">...</script>
<script type="module">
import * as THREE from 'three';
...
</script>
</body>
</html>
```

Now code details.

Renderer: antialias true, pixelRatio min(2, dpr), toneMapping ACESFilmic, outputColorSpace sRGB (default in r170).

Camera: PerspectiveCamera(50, aspect, 0.1, 200), position (0, 0.5, 12).

Scene fog: FogExp2(0x03202b, 0.045). Hmm, with camera at z=12 and tank depth ~16, fog density 0.03 gives nice depth.

Background gradient sphere: radius 60, ShaderMaterial with side BackSide, depthWrite false.

```glsl
varying vec3 vPos;
void main(){ vPos = position; gl_Position = ...}
frag:
float h = normalize(vPos).y * 0.5 + 0.5;
vec3 top = vec3(0.05,0.35,0.45);
vec3 bottom = vec3(0.01,0.03,0.08);
```

Hmm, maybe use a nicer teal: top #0a4a5c, bottom #02121a.

Water surface: a plane at y = 8 with a shader making ripples, or just a subtle translucent plane. Might skip. Add a "surface" plane with additive lines? Let's add a simple surface with animated shader using sin waves for shine. Maybe keep it simple: a large plane at y=8 with MeshBasicMaterial color 0x1a6f80 transparent opacity 0.25, and some light-shaft cones below.

Actually let's do light rays: several elongated cones with additive blending, low opacity, from above. They rotate slowly. Good visual.

Fish count: 26.

Tank bounds: x ∈ [-9, 9], y ∈ [-3.5, 5.5], z ∈ [-7, 7].

Wait camera at z=12 with fov 50 → visible height at z=0 is 2*12*tan(25°)=11.2, width = 11.2*1.6=17.9. So x ±9 is fine. Fish z from -7 to 7.

Floor: at y=-4.5, plane 40x40 rotated -PI/2, with sand texture from canvas.

Let's generate sand texture: canvas 256x256, fill with base color, add random dots. Then texture.wrapS/T repeat, repeat 6,6.

Rocks: 8 icosahedrons, random scale, dark slate color, flatShading.

Seaweed: 14 plants, each a set of 3-4 tapered planes? Simpler: use a thin cylinder with segments and animate vertices in the animation loop? Or build a "blade" as a plane with a few segments and sway via shader. Let's do a simple approach: create a curved blade using LatheGeometry? Hmm.

Simplest: each plant is a group of 4 blades; each blade is a PlaneGeometry(0.25, 2.5, 1, 8) with vertices tapered and animated by rotating the group's children with a subtle sine. Actually animating a plane's vertices per frame for 14*4=56 planes is fine CPU-wise (small vertex counts), but let's just do rotation of the whole blade group — looks ok but less organic.

Alternative: use a custom shader material for the seaweed with vertex displacement based on time and y. That's clean:

```js
const weedMat = new THREE.MeshStandardMaterial({color:0x2f7a3a, side:THREE.DoubleSide, ...});
```
With onBeforeCompile injecting sway. That's a bit of code. Let's just do it with a custom ShaderMaterial? Standard material with onBeforeCompile gives nice lighting. Let's do onBeforeCompile:

```js
mat.onBeforeCompile = (shader) => {
  shader.uniforms.uTime = uniforms.uTime;
  shader.vertexShader = 'uniform float uTime;\n' + shader.vertexShader;
  shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>', `
    #include <begin_vertex>
    float sway = sin(uTime*1.2 + position.y*1.5 + instOffset) * 0.35 * (position.y);
    transformed.x += sway;
  `);
};
```

Hmm instOffset per plant needs a per-mesh uniform. I can create separate material per plant (14 materials is fine) with its own uniform. Or use a single uniform array... simpler: create material per plant, cheap.

Actually, simpler still: just use object rotation on a group of blades — each blade plane anchored at base (translate geometry so base at y=0), and rotate the group around z with sine. That's totally fine and looks decent. Let's do that, no shader needed. But the blade would be rigid... For a small demo it's fine, and combining rotation on group with slight rotation per blade gives organic motion.

I'll do shader-free: each plant group at position, rotate group.rotation.z = sin(t*speed+phase)*0.12, and each blade has rotation.y offset.

OK.

Bubbles: THREE.Points with 200 positions, rising with wrap; use a circular sprite texture generated on canvas. PointsMaterial with map, sizeAttenuation, transparent, opacity 0.5, depthWrite false, blending additive.

Fish behavior code:

```js
const FISH_COUNT = 28;
const fishList = [];
for (let i=0;i<FISH_COUNT;i++){
  const fish = makeFish(colors[i%...]);
  fish.position.set(rand(-7,7), rand(-2,4), rand(-5,5));
  fish.userData = {
    vel: new THREE.Vector3(rand(-1,1), rand(-0.2,0.2), rand(-1,1)).normalize().multiplyScalar(1.5),
    speed: rand(1.4, 2.4),
    phase: Math.random()*Math.PI*2,
    wagSpeed: rand(6,10),
    offset: new THREE.Vector3(rand(-1,1), rand(-1,1), rand(-1,1)).multiplyScalar(0.35),
  };
  scene.add(fish);
  fishList.push(fish);
}
```

Update per fish:
```js
const desired = new THREE.Vector3();
const wanderTarget = new Vector3(
  Math.sin(t*0.5 + f.phase)*6,
  Math.cos(t*0.37 + f.phase*1.3)*2.5 + 1.5,
  Math.sin(t*0.42 + f.phase*2.0)*4.5
);
```
Hmm, each fish should have a home target wandering. Actually a simple approach: each fish wanders around a slowly moving target. Compute steering:

```js
steer += (target - pos) * 0.1  (seek)
```
Plus flee from pointer:
```js
const d = pos.distanceTo(pointer);
if (d < fleeRadius) { const away = pos.clone().sub(pointer).normalize().multiplyScalar((fleeRadius-d)/fleeRadius * strength); }
```
Also flee direction should account for fish unable to be exactly at pointer; fine.

Boundary avoidance: if x < minX, add force +x proportional. Use soft box.

Then velocity = lerp toward desiredVelocity.

Let's implement:

```js
const tmp = new THREE.Vector3();
for (const f of fishList){
  const u = f.userData;
  const t = time;
  // wander: target point orbits
  const tx = Math.cos(t*0.35 + u.phase)*6.5;
  const ty = 1.5 + Math.sin(t*0.5 + u.phase*1.7)*2.2;
  const tz = Math.sin(t*0.28 + u.phase*0.9)*4.5;

  const acc = new THREE.Vector3();
  acc.x += (tx - f.position.x)*0.35;
  acc.y += (ty - f.position.y)*0.35;
  acc.z += (tz - f.position.z)*0.35;

  // flee
  const dx = f.position.x - pointer.x; ...
  const distSq = ...
  if (distSq < fleeR*fleeR) {
    const d = Math.sqrt(distSq)||0.001;
    const w = (1 - d/fleeR);
    acc.x += dx/d * w * 60;
    ...
  }
  // bounds
  if (f.position.x > BX) acc.x -= (f.position.x-BX)*8; ...
```

Then clamp acceleration magnitude, integrate velocity, damp, normalize to speed, integrate position.

Simpler: 
```js
u.vel.addScaledVector(acc, dt*?);
```
Let's use a semi-physical approach with damping:

```js
u.vel.addScaledVector(acc, dt);
u.vel.multiplyScalar(1 - 2.0*dt); // drag
// clamp speed
const sp = u.vel.length();
const maxSp = u.speed * 2.5; // when fleeing
if (sp > maxSp) u.vel.multiplyScalar(maxSp/sp);
// also maintain minimum speed
if (sp < 0.5) u.vel.multiplyScalar(0.5/(sp+1e-4));
pos.addScaledVector(u.vel, dt);
```

Hmm acc values like 0.35*(target-pos) with distances up to ~13 gives 4.5, times dt → fine.

Actually let's simplify: steer with force, then desired velocity = vel normalized * speed, and vel = lerp(vel, desiredVel, 1-exp(-k*dt)). Hmm, need flee to give a burst. Let's do: velocity is the state; accel from steering + flee; then drag; then clamp to [minSpeed, maxSpeed] where maxSpeed increases when fleeing.

I'll implement carefully:

```js
const acc = new THREE.Vector3();
// wander
acc.addScaledVector(toTarget, 0.55);  // spring
// flee
const toFish = pos - pointer; d = length
if (d < R) { acc.addScaledVector(toFish.normalize(), (1-d/R)^2 * 220); }
// bounds
// separation (optional)
// integrate
vel.addScaledVector(acc, dt);
vel.multiplyScalar(Math.exp(-1.6*dt)); // drag
const speed = vel.length();
const maxSpeed = 9;  // fleeing max
const cruise = u.speed;
if (speed > maxSpeed) vel.multiplyScalar(maxSpeed/speed);
// keep moving
if (speed < cruise) vel.multiplyScalar(...) // no, just set direction
```

Hmm with the spring pull of 0.55 and drag 1.6, terminal velocity ≈ accel/drag = 0.55*13/1.6 ≈ 4.5. Good. Flee: 220*(1/2)^2=55 accel with drag 1.6 → terminal 34, too fast. Clamp maxSpeed to ~14. Let's set flee strength 160 and maxSpeed 14.

It'll look like a burst. Good.

Also need to add a little vertical bobbing.

Orientation: 
```js
const look = tmp.copy(f.position).add(f.userData.vel); // or velocity
f.lookAt(look);
```
But lookAt uses up (0,1,0) and fish's +Z toward look. Also add roll/bank: shift the up vector based on lateral acceleration. Optional. Keep simple, but add a slight rotation.z wobble.

Also the tail wag: fish.userData.tail.rotation.y = Math.sin(t*wagSpeed + phase)*0.5.

Actually if the whole fish model is built along +Z with tail at -Z, the tail wagging around the Y axis at the tail pivot works.

Let me build the fish:

```js
function makeFish(color, accent){
  const g = new THREE.Group();

  const bodyMat = new THREE.MeshStandardMaterial({color, roughness:0.45, metalness:0.25, emissive: new THREE.Color(color).multiplyScalar(0.12)});
  const finMat = new THREE.MeshStandardMaterial({color: accent, roughness:0.6, metalness:0.1, transparent:true, opacity:0.9, side:THREE.DoubleSide});

  const bodyGeo = new THREE.SphereGeometry(1, 18, 14);
  bodyGeo.scale(0.34, 0.52, 1.0);
  const body = new THREE.Mesh(bodyGeo, bodyMat);
  g.add(body);
  ...
}
```

Wait, scale on geometry: SphereGeometry(1,...) then scale(x,y,z) → ellipsoid. Good, radius 1 * 0.34 etc. Body length 2 in z.

Tail: create a triangle shape geometry:
```js
const tailShape = new THREE.Shape();
tailShape.moveTo(0,0); tailShape.lineTo(-0.9, 0.55); tailShape.lineTo(-0.75, 0); tailShape.lineTo(-0.9,-0.55); tailShape.closePath();
```
Hmm, a triangle tail: from origin going back. In XZ? The tail plane should be in the XZ... no. The fish's tail is a vertical fin (in the XY plane? no). Fish tail fin is vertical, spanning Y and Z, thin in X. So we make the shape in the XY plane and then it's a flat plane in XY... we need it in ZY plane. So build shape in XY and rotate the mesh by PI/2 around Y so it lies in ZY plane. Or build geometry directly.

Simpler: build a custom BufferGeometry with vertices in 3D.

Tail fin vertices (in fish local space, tail at z=-0.85):
- A: (0, 0, -0.85)
- B: (0, 0.45, -1.6)
- C: (0, 0.1, -1.4)  hmm

Let's define a tail as a triangle-ish shape:
p0 = (0, 0, -0.85)   root
p1 = (0, 0.60, -1.75)  top tip
p2 = (0, 0, -1.5)     notch (between)
p3 = (0, -0.60, -1.75) bottom tip

Triangles: (p0, p1, p2) and (p0, p2, p3). Good — a forked tail. DoubleSide material.

Actually, winding order for the front face doesn't matter since DoubleSide.

Dorsal fin: triangle on top: (0, 0.5, 0.2), (0, 0.9, -0.4), (0, 0.45, -0.6). Also thin in x. Fine.

Pectoral fins: two small triangles at sides. Let's do simple triangles at (±0.3, -0.1, 0.25) pointing back. Maybe skip or make them small.

Eyes: two spheres at (±0.22, 0.18, 0.72), radius 0.1, dark.

Let me write a helper to create a triangle geometry from arrays.

```js
function tri(p1,p2,p3){
  const g = new THREE.BufferGeometry();
  const arr = new Float32Array([...p1,...p2,...p3]);
  g.setAttribute('position', new THREE.BufferAttribute(arr,3));
  g.computeVertexNormals();
  return g;
}
```

For tail wagging: create a pivot group at z=-0.85 (the tail root), add the tail mesh with vertices defined relative to the root (i.e., pivot at origin). Then rotate pivot.rotation.y.

So tail geometry: 
p0 = (0,0,0)
p1 = (0, 0.60, -0.9)
p2 = (0, 0, -0.65)
p3 = (0, -0.60, -0.9)
pivot at (0,0,-0.85).

Good.

Fish bobbing: not needed.

Now fish colors: palette of tropical hues: 0xff7a3d, 0xffd166, 0x4ecdc4, 0xff6b9d, 0x9b6bff, 0x5ec8ff, 0xff4e4e, 0x7ef29d.

Add emissive 0.15 of color for glow.

Also add subtle scale of ~0.9-1.3 per fish.

Lighting: 
- HemisphereLight(0x66ccff, 0x0a2a33, 0.7)
- DirectionalLight(0xffffff, 1.6) at (5, 12, 8), castShadow? Shadows would be nice but costly; fish shadows on the sand could be nice. Let's enable shadows with a directional light and a shadow camera covering the tank. 28 fish casting shadows — fine. Maybe skip shadows for perf/simplicity... but shadows add realism. Let's enable: renderer.shadowMap.enabled = true, type PCFSoftShadowMap. dirLight.castShadow, shadow.mapSize 1024, camera bounds ±14. Floor receives, fish cast. Fields: set castShadow on fish body meshes.

Hmm, might be heavy but fine at 1024.

Actually, with fog and dark water, shadows are barely visible. But still nice. I'll enable it.

Also add a spotlight-ish point light from above with cyan color.

Let's write the code now.

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

const canvas = document.getElementById('scene'); // or create renderer
```
Actually just renderer = new THREE.WebGLRenderer({antialias:true}) and append to body.

Full code:

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

const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x04202b, 0.028);

const camera = new THREE.PerspectiveCamera(52, innerWidth/innerHeight, 0.1, 200);
camera.position.set(0, 1.2, 13);
camera.lookAt(0, 0.5, 0);

const renderer = new THREE.WebGLRenderer({antialias:true, powerPreference:'high-performance'});
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
```

Background: use a big sphere with a gradient shader.

```js
const bgGeo = new THREE.SphereGeometry(80, 32, 16);
const bgMat = new THREE.ShaderMaterial({
  side: THREE.BackSide, depthWrite:false, fog:false,
  uniforms: { top:{value:new THREE.Color(0x1a6f8a)}, bottom:{value:new THREE.Color(0x02131c)} },
  vertexShader: `varying vec3 vP; void main(){ vP = position; gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
  fragmentShader: `varying vec3 vP; uniform vec3 top; uniform vec3 bottom;
    void main(){ float h = clamp(vP.y/80.0*0.5+0.5, 0.0, 1.0); vec3 c = mix(bottom, top, pow(h,1.2)); gl_FragColor = vec4(c,1.0);}`
});
```
Note tone mapping applies to ShaderMaterial? Only if the shader includes tonemapping chunks. Raw ShaderMaterial won't get tone mapping, so colors will be as authored — that's fine, might be brighter relative. Actually in r170, output happens with outputColorSpace conversion only for materials with colorspace_fragment chunk. Raw ShaderMaterial without the chunk will look different (no sRGB conversion → appears darker/more saturated?). Without the encoding chunk, the linear value is written directly to an sRGB-encoded framebuffer... Actually since renderer outputColorSpace = SRGB, the renderer doesn't automatically convert for custom shaders unless the shader includes `#include <colorspace_fragment>`. So the color written goes to the canvas as-is (which is interpreted as sRGB). So if I write hex 0x1a6f8a as a color, THREE.Color with color management on converts hex to linear-sRGB working space. Then it's written raw into the framebuffer, which is interpreted as sRGB → appears darker.

Hmm. To avoid confusion, I'll set the colors with `new THREE.Color().setHex(0x1a6f8a, THREE.SRGBColorSpace)`? Actually when ColorManagement is enabled, `new THREE.Color(hex)` converts sRGB→Linear. Then to make the shader output look right, I should add `#include <colorspace_fragment>` at the end (needs gl_FragColor). Simpler: define the color as raw vec3 values without conversion... 

Simplest approach: use `gl_FragColor = vec4(c, 1.0); #include <colorspace_fragment>` — need to use `gl_FragColor` and the chunk uses `gl_FragColor`. In r170, colorspace_fragment does `gl_FragColor = linearToOutputTexel( gl_FragColor );`. Yes that works with ShaderMaterial.

Alternatively, skip the shader sphere and just set scene.background to a gradient texture created via canvas (2D). That's easier and guaranteed correct color: create a canvas 2x256 with a vertical gradient, make a THREE.CanvasTexture, set as scene.background. Also set colorSpace = SRGBColorSpace. That gives a nice backdrop and is simple. But the background is a screen-space image filling the viewport; that's fine for an aquarium.

Hmm, but the fog color should match the background at the horizon. With fog it will fade to the fog color. Let's use a gradient canvas texture with colorSpace sRGB, and set fog color to a mid tone.

Actually, background texture — the renderer draws it as a full-screen quad (for equirect/2D textures, `background` with a regular texture maps to... In three.js, if scene.background is a Texture with mapping = UVMapping, it's rendered as a full-screen background? Yes, since r108 or so, a plain texture background is drawn as a fullscreen quad, stretched. It is stretched to fill viewport (aspect distortion) but for a vertical gradient that's perfect.

I'll do that.

Let me now write everything.

```js
// --- background gradient
function makeGradientTexture() {
  const c = document.createElement('canvas');
  c.width = 8; c.height = 256;
  const ctx = c.getContext('2d');
  const g = ctx.createLinearGradient(0,0,0,256);
  g.addColorStop(0, '#0b6a86');
  g.addColorStop(0.35, '#0a4b63');
  g.addColorStop(0.7, '#05303f');
  g.addColorStop(1, '#01121b');
  ctx.fillStyle = g; ctx.fillRect(0,0,8,256);
  const tex = new THREE.CanvasTexture(c);
  tex.colorSpace = THREE.SRGBColorSpace;
  return tex;
}
scene.background = makeGradientTexture();
```

Hmm but the gradient goes from light at top to dark at bottom — but the water surface should be at top. Fine.

Fog: FogExp2(0x073c50, 0.032) — match mid gradient.

Hmm, with background at the top being light teal and the fog color being dark teal, distant objects fade to dark. Should be okay.

Let's set fog color 0x08384a.

Now the sand floor:

```js
function makeSandTexture(){
  const c = document.createElement('canvas');
  c.width = c.height = 512;
  const ctx = c.getContext('2d');
  ctx.fillStyle = '#c9b48a'; // sand
  ctx.fillRect(0,0,512,512);
  for (let i=0;i<20000;i++){
    const x = Math.random()*512, y = Math.random()*512;
    const v = Math.random();
    ctx.fillStyle = `rgba(${v>0.5?255:90},${v>0.5?240:70},${v>0.5?200:50},${0.06+Math.random()*0.12})`;
    ctx.fillRect(x,y, 1+Math.random()*2, 1+Math.random()*2);
  }
  // a few darker ripples
  ...
  const t = new THREE.CanvasTexture(c);
  t.wrapS = t.wrapT = THREE.RepeatWrapping;
  t.repeat.set(6,6);
  t.colorSpace = THREE.SRGBColorSpace;
  return t;
}
```

Floor: PlaneGeometry(60, 60) rotated -PI/2, position y=-4.6, MeshStandardMaterial map, roughness 1. Color tint darker.

Rocks: a few IcosahedronGeometry(1, 0) with flatShading, random scale, at the bottom.

Now, the water surface plane at y = 7.5 with a shader? Let's just add a subtle bright plane with additive blending and animated opacity... Could skip. Let's add a large plane at y=8 with MeshBasicMaterial({color:0x8fe9ff, transparent:true, opacity:0.06, side:DoubleSide, blending: Additive}) and animate with a sine wave via vertex shader? Not necessary. Skip, use light shafts instead.

Light shafts: create 5 cones, ConeGeometry(2.5, 22, 12, 1, true) positioned at y=6, rotated slightly, material MeshBasicMaterial({color: 0x9fe8ff, transparent:true, opacity:0.05, blending: AdditiveBlending, depthWrite:false, side:DoubleSide}). Rotate slowly.

Actually a cone with openEnded and vertical, with a fade via vertexColors or a gradient texture. Simple approach: use a custom shader with fade based on y and on view angle. Let's do a simple ShaderMaterial:

```glsl
varying vec2 vUv;
void main(){ vUv = uv; gl_Position = ... }
frag: float a = smoothstep(0.0,0.3,vUv.y) * smoothstep(1.0,0.7,vUv.y) ... 
```
Cone UV: v goes 0 at bottom to 1 at top? Let's just do `float a = pow(vUv.y, 1.5)` ... hmm, plus fade at the rim. We can use the normal·view dot for a soft edge:

```glsl
float rim = pow(1.0 - abs(dot(normalize(vNormal), normalize(vViewDir))), 2.0);
```
That gives a soft edge glow. Combined with vertical fade. Let's do it. It's a nice touch.

Actually simpler and safe: use a vertical gradient alpha via a canvas texture on the cone. Meh. Let's do the shader — it's fine.

I'll define:

```js
const shaftMat = new THREE.ShaderMaterial({
  transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide,
  uniforms: { uColor: {value: new THREE.Color(0x9fe8ff)}, uTime: {value:0} },
  vertexShader: `
    varying vec2 vUv; varying vec3 vN; varying vec3 vV;
    void main(){
      vUv = uv;
      vec4 mv = modelViewMatrix * vec4(position, 1.0);
      vN = normalize(normalMatrix * normal);
      vV = normalize(-mv.xyz);
      gl_Position = projectionMatrix * mv;
    }`,
  fragmentShader: `
    uniform vec3 uColor; uniform float uTime;
    varying vec2 vUv; varying vec3 vN; varying vec3 vV;
    void main(){
      float rim = pow(1.0 - abs(dot(normalize(vN), normalize(vV))), 1.6);
      float fade = smoothstep(0.0, 0.55, vUv.y) * smoothstep(1.0, 0.85, vUv.y);
      float a = rim * fade * 0.13;
      gl_FragColor = vec4(uColor, a);
    }`
});
```
Hmm the cone UV: for ConeGeometry, uv.y goes 0 at bottom to 1 at top. The cone's apex is at top. With smoothstep(0.0,0.55,vUv.y) → fades near the bottom. And smoothstep(1.0, 0.85, ...) — this is reversed smoothstep: at vUv.y=1 → 0, at 0.85 → 1. Wait smoothstep(edge0=1.0, edge1=0.85, x) with edge0 > edge1 — GLSL smoothstep requires edge0 < edge1 for defined behavior; otherwise it's undefined. Use `1.0 - smoothstep(0.85, 1.0, vUv.y)`. Let's use clean code.

Also for the cone: since the cone tapers to a point at the top, we want it wider at the bottom (like a light shaft). ConeGeometry(radius, height) has the radius at the bottom. Good.

OK. Let's put the shafts at y from 8 down to -2.

Now, let's write the fish.

```js
function makeFish(color){
  const group = new THREE.Group();
  const bodyMat = new THREE.MeshStandardMaterial({
    color, roughness: 0.35, metalness: 0.35,
    emissive: new THREE.Color(color).multiplyScalar(0.15)
  });
  const finMat = new THREE.MeshStandardMaterial({
    color: new THREE.Color(color).offsetHSL(0, -0.15, 0.18),
    roughness: 0.6, metalness: 0.1, transparent: true, opacity: 0.85, side: THREE.DoubleSide
  });
  ...
}
```

Wait, MeshStandardMaterial with emissive — with fog, the fish fade out. Fine.

Body geometry: `new THREE.SphereGeometry(1, 16, 12)` then `.scale(0.30, 0.44, 0.85)` and translate? The body center at origin. Then the nose at z = +0.85. Total length 1.7.

Hmm, but the tail then attaches at z=-0.85.

Let's set the fish scale to ~1.0 overall and make different fish sizes with group.scale.setScalar(s).

Eyes at (±0.19, 0.16, 0.62) with radius 0.075 sphere, black material... but with the body at that point (ellipsoid: at z=0.62, the cross-section radius factor is sqrt(1-(0.62/0.85)^2)=sqrt(1-0.532)=0.684, so x half-width = 0.30*0.684=0.205, y half = 0.44*0.684=0.30). So an eye at x=0.19, y=0.16 sits roughly on the surface. Fine, use radius 0.085 and place at x=±0.16.

Hmm, this is fiddly. Let's just place the eyes at x=±0.17, y=0.14, z=0.60, radius 0.09, and they'll stick out a bit — looks fine.

Tail pivot.

Also, add a "lateral stripe" ? Skip.

Let's write the fish creation:

```js
function makeFish(color, seed){
  const g = new THREE.Group();

  const bodyMat = new THREE.MeshStandardMaterial({
    color: color, roughness: 0.38, metalness: 0.3,
    emissive: new THREE.Color(color).multiplyScalar(0.18)
  });
  const finColor = new THREE.Color(color).lerp(new THREE.Color(0xffffff), 0.25);
  const finMat = new THREE.MeshStandardMaterial({
    color: finColor, roughness: 0.55, metalness: 0.1,
    transparent: true, opacity: 0.75, side: THREE.DoubleSide,
    emissive: new THREE.Color(color).multiplyScalar(0.1)
  });

  const bodyGeo = new THREE.SphereGeometry(1, 18, 12);
  bodyGeo.scale(0.30, 0.45, 0.85);
  const body = new THREE.Mesh(bodyGeo, bodyMat);
  body.castShadow = true;
  g.add(body);

  // tail pivot
  const tail = new THREE.Group();
  tail.position.set(0, 0, -0.78);
  const tailGeo = triGeom([0,0,0], [0,0.55,-0.85], [0,0.05,-0.5], ...)
```

Hmm, the forked tail: I want a shape with 4 points: root (0,0,0), upper tip (0,0.55,-0.85), notch (0,0,-0.55), lower tip (0,-0.55,-0.85). Triangles: (root, upper, notch) and (root, notch, lower). With root at origin, the triangles share the root point. Good.

That's a tail like a "V". 

Dorsal fin: tri (0,0.42,0.15),(0,0.75,-0.25),(0,0.35,-0.5) — a triangle on top of the body. Body's top at y=0.45 at z=0, at z=0.15 the y half = 0.45*sqrt(1-(0.15/0.85)^2)=0.45*0.984=0.44. Good, so the fin's base at y=0.42 is just inside the body.

Pectoral fins: skip or add small ones at (±0.25, -0.05, 0.15) as small triangles pointing out and back. Let's add them: geometry tri for the right side: (0.2,0,0.1), (0.5,-0.1,-0.2), (0.2,-0.15,-0.1). Eh, minor. I'll skip pectorals to keep the code smaller, and add a small second top fin? Nah.

Actually, let's add pectoral fins with a simple rotation — cheap. Maybe skip for cleanliness. I'll add a small dorsal fin only.

Eyes: 2 spheres.

OK.

Now the animation loop.

```js
const clock = new THREE.Clock();
let time = 0;

const pointer = new THREE.Vector3(0,0,0);
const pointerTarget = new THREE.Vector3();
const raycaster = new THREE.Raycaster();
const plane = new THREE.Plane(new THREE.Vector3(0,0,1), 0);
let hasPointer = false, lastPointerMove = -999;
```

On pointermove:
```js
window.addEventListener('pointermove', e => {
  const x = (e.clientX/innerWidth)*2-1;
  const y = -(e.clientY/innerHeight)*2+1;
  raycaster.setFromCamera({x,y}, camera);
  raycaster.ray.intersectPlane(plane, pointerTarget);
  lastPointerMove = time;
  hasPointer = true;
});
```

Demo pointer: if !hasPointer or time - lastPointerMove > 3, the pointer target moves on a Lissajous path in screen space:
```js
const autoX = Math.sin(time*0.55)*6.5;
const autoY = Math.sin(time*0.9)*2.2 + 0.5;
const autoZ = Math.cos(time*0.4)*3;
```
And blend smoothly. Actually, we should smoothly interpolate the pointer depth too: set the plane distance? Let's just use z = 0 for the real pointer, and the auto pointer also z=0 mostly. Fine.

Actually, for a more 3D feel, use the pointer's z from the auto path so the fish at different depths react.

Let's keep pointer z = 0 always (the plane), and it's fine since fish are within ±5 z.

Hmm, but then fish at z=-5 flee from a point at z=0 — they will move away mostly in x/y, which is fine.

The smooth pointer: `pointer.lerp(pointerTarget, 1 - Math.exp(-8*dt))`.

Also draw a subtle "cursor" indicator? Like a faint ring/halo where the pointer is, to show the interaction. Nice touch: a small sphere with additive glow that follows the pointer. Let's add a subtle glowing point (a sprite) at the pointer position. Actually it might look weird. I'll add a soft ring — maybe a Points sprite with a radial gradient texture. Let's add a small soft glow sprite that's subtly visible. It helps communicate "here is the mouse". I'll add it with low opacity.

Hmm, when the screenshot is taken, the auto pointer is invisible... but the glow would show the fish scattering location. That's fine, it looks like a light source. Let's do it.

Now fish update loop, as designed.

Also add a gentle collective rotation of the whole scene? No.

Let me also handle window resize.

Also, need to make sure the pointer events are not blocked. Use window.addEventListener('pointermove').

Now write the full update:

```js
function updateFish(dt, t){
  const p = pointer;
  for (const f of fishList){
    const u = f.userData;
    const ax = ..., etc.
    // wander target
    const tx = Math.sin(t*0.33 + u.phase) * 6.2;
    const ty = 1.2 + Math.sin(t*0.51 + u.phase*1.7) * 2.4;
    const tz = Math.sin(t*0.27 + u.phase*0.8) * 4.2;

    let ax = (tx - f.position.x) * 0.9;
    let ay = (ty - f.position.y) * 0.9;
    let az = (tz - f.position.z) * 0.9;

    // flee
    const dx = f.position.x - p.x, dy = f.position.y - p.y, dz = f.position.z - p.z;
    const d2 = dx*dx+dy*dy+dz*dz;
    const R = 4.2;
    let fleeing = false;
    if (d2 < R*R){
      const d = Math.sqrt(d2) + 0.001;
      const w = (1 - d/R);
      const k = w*w*260;
      ax += dx/d*k; ay += dy/d*k; az += dz/d*k;
      fleeing = true;
    }
    // bounds
    const bx = 8.5, byTop = 5.0, byBot = -2.6, bz = 6.0;
    if (f.position.x > bx) ax -= (f.position.x - bx)*30;
    if (f.position.x < -bx) ax += (-bx - f.position.x)*30;
    ... similarly
```

Wait, spring constant 0.9 with drag... Let's evaluate: the wander spring: ax = (tx - x)*0.9. Max distance ~14 → ax=12.6. With drag coefficient 1.6, terminal velocity = ax/drag ≈ 8. That's too fast for cruise. Let's use 0.35 spring → terminal ~3. And flee k=260 with 1 active → terminal 160. Clamp max speed at 12.

Actually with the drag computed as `vel *= Math.exp(-1.6*dt)`, terminal velocity = accel/1.6. For cruise accel ~ 0.35*13 = 4.5 → 2.8 units/s. Good, that's a comfortable cruise.

Flee accel 260*(1-d/R)^2 at d→0: 260 → velocity 162 → clamped to maxSpeed 14. Way too fast, that's a teleport. Let's use k = 60 and maxSpeed 12. Terminal = 60/1.6 = 37 → clamped at 12. Hmm still clamped. The fish would accelerate to 12 in ~0.3s. That's a burst of speed — desired. But 12 units/s crosses the 17-unit tank in 1.4s. That's fast but ok for a "dart away".

Let's use k = 45 and maxSpeed 10. Fine.

Then integrate:
```js
u.vel.x += ax*dt; ...
u.vel.multiplyScalar(Math.exp(-1.8*dt));
const sp = u.vel.length();
const maxSp = 10;
if (sp > maxSp) u.vel.multiplyScalar(maxSp/sp);
// maintain a minimum cruise speed
const minSp = u.cruise * 0.55;
if (sp < minSp) u.vel.setLength(minSp);  // hmm, setLength when sp is tiny... use multiplyScalar(minSp/(sp+1e-5))
```
Careful with setLength(0). Use `if (sp < minSp && sp > 1e-4) u.vel.multiplyScalar(minSp/sp);`

Then position += vel*dt.

Then orientation: 
```js
const target = tmpVec.copy(f.position).add(u.vel);
f.lookAt(target);
```
Then add roll: f.rotateZ(...)? After lookAt, the local Z is forward. Rolling around the local Z axis. `f.rotateZ(Math.sin(t*2 + u.phase)*0.15)`. Hmm, rotateZ rotates around the local Z, which is forward. That gives a bank. Good, plus a subtle bank into turns. Keep it small.

Also add bob: f.position.y += sin(...)*0.002? No — that would accumulate. Instead, add the bob to the render position: use a child group? Simpler: skip the bob; the wander already provides motion.

Tail wag: `u.tail.rotation.y = Math.sin(t*u.wagSpeed + u.phase) * 0.5;`

Also vary the wag speed with speed: wagSpeed * (0.6 + speed/4). Let's just use `t * u.wagSpeed`.

Hmm, the fish model's group is being rotated by lookAt, and the tail is a child, so it's all consistent.

Now, different fish sizes: group.scale.setScalar(u.size), 0.7-1.4.

But then the position and the physics operate on the group position. Fine.

Also, when a fish is scaled, the collision radius for separation should scale. Skip separation.

Wait, if there's no separation, fish will overlap sometimes. That's ok visually, but let's add a simple separation for the nearest 2 fish? That's O(n²) with 28 fish = 392 ops per frame — that's nothing. Let's add a soft separation for realism. Actually, keep it simple: I'll add a light separation using a loop over all pairs (only for i<j, applying half to each). 28² /2 = 392. Fine.

Hmm, but it adds code complexity. I'll include it — it's just a few lines and makes the school look more natural.

Let's write:

```js
// separation
for (let i=0;i<fishList.length;i++){
  const a = fishList[i];
  for (let j=i+1;j<fishList.length;j++){
    const b = fishList[j];
    const dx = b.position.x-a.position.x, dy=..., dz=...;
    const d2 = dx*dx+dy*dy+dz*dz;
    const minD = 1.2;
    if (d2 < minD*minD && d2 > 1e-6){
      const d = Math.sqrt(d2);
      const push = (minD - d)/minD * 12 / d;
      // apply to velocities
      ...
    }
  }
}
```
That requires storing velocities on userData (we do). But applying directly to velocity needs to happen before integration. Let's compute accelerations in a first pass and then integrate in a second pass. I'll just add the separation force to a per-fish `acc` vector stored in userData.

Let me restructure: 
- Pass 1: for each fish, compute acc (wander + flee + bounds) into u.acc.
- Pass 2: separation, adding into u.acc.
- Pass 3: integrate.

That's clean.

OK, also add schooling: some fish swim near each other. Not needed.

Let's write out the final code.

One more thing: the light shafts need the uTime uniform update. Use a single material instance shared by all cones? Each cone needs a different rotation but the same material is fine. Yes, use one material for all shafts — the UV-based shading is the same.

But the shafts would need to be positioned around the tank. Let's create 6 cones at random x/z, y=6.5, height 18, radius 2-3, opacity 0.05.

Rotation: slight tilt, e.g., rotation.z = 0.1.

Hmm, and to animate the shafts slowly, update the material's uTime only (no geometric change). That's fine, or rotate slightly.

Bubbles: 
```js
const bubbleCount = 260;
const bubblePos = new Float32Array(bubbleCount*3);
const bubbleSpeed = new Float32Array(bubbleCount);
for i: x = rand(-12,12), y = rand(-5,8), z = rand(-8,8), speed = rand(0.4,1.4)
```
Points material with a circular sprite texture, size 0.14, sizeAttenuation, transparent, opacity 0.55, depthWrite false.

Update: y += speed*dt; if y > 8, y = -5, randomize x,z.

Also they should sway: x += sin(t + i)*0.002.

OK.

Let's write the sprite texture:
```js
function makeCircleTexture(){
  const s=64; const c=document.createElement('canvas'); c.width=c.height=s;
  const ctx=c.getContext('2d');
  const g=ctx.createRadialGradient(s/2,s/2,0,s/2,s/2,s/2);
  g.addColorStop(0,'rgba(255,255,255,1)');
  g.addColorStop(0.4,'rgba(200,240,255,0.65)');
  g.addColorStop(1,'rgba(180,230,255,0)');
  ctx.fillStyle=g; ctx.fillRect(0,0,s,s);
  const t=new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t;
}
```

Good.

Now the UI overlay:
- Title top-left: "Aquarium" with a subtitle.
- A hint at the bottom center: "Move your cursor — the fish will flee" that fades after first pointer move.

Let's keep the hint visible but subtle, and add a fading big card on load.

Actually, since mouse events may not be delivered until the iframe is focused... In most browsers, mouse events are delivered. The instruction says keyboard requires a click. So I'll show a hint but not block anything.

Let's write the HTML/CSS:

```html
<div id="hud">
  <h1>Aquarium</h1>
  <p>Move your cursor through the water — the fish flee from it</p>
</div>
<div id="hint">Move your mouse</div>
```

CSS: fixed, pointer-events none, font-family system-ui, color rgba(220,245,255,0.85), text-shadow for legibility.

Let's make the hint a pill at the bottom center that fades out on the first pointermove.

OK, writing final code now.

One concern: `renderer.setSize` — the canvas is behind the HUD; set body margin 0, overflow hidden, canvas display block.

Also add a vignette overlay div with radial gradient for cinematic look — subtle.

Let's now write the whole thing.

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

// Renderer
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x073a4e, 0.028);

const camera = new THREE.PerspectiveCamera(52, window.innerWidth/window.innerHeight, 0.1, 400);
camera.position.set(0, 1.0, 13.5);
camera.lookAt(0, 0.5, 0);
```

Background gradient:

```js
const bgCanvas = document.createElement('canvas');
bgCanvas.width = 4; bgCanvas.height = 256;
const bgCtx = bgCanvas.getContext('2d');
const bgGrad = bgCtx.createLinearGradient(0,0,0,256);
bgGrad.addColorStop(0, '#0e7d9c');
bgGrad.addColorStop(0.4, '#0a5470');
bgGrad.addColorStop(0.75, '#063244');
bgGrad.addColorStop(1, '#02141f');
bgCtx.fillStyle = bgGrad; bgCtx.fillRect(0,0,4,256);
const bgTex = new THREE.CanvasTexture(bgCanvas);
bgTex.colorSpace = THREE.SRGBColorSpace;
scene.background = bgTex;
```

Hmm, with a plain Texture background, three.js renders it as a fullscreen quad. Yes, but I recall it may require `mapping = EquirectangularReflectionMapping` for env. For a plain texture with UVMapping, it renders as a fullscreen quad since r118-ish. Let me double check: In WebGLBackground, `if (background && background.isTexture)` → `if (background.mapping === EquirectangularReflectionMapping || ...) { ... cube ... } else { planeMesh with backgroundMaterial }`. Yes, it uses a plane mesh. Good — but the plane covers the whole screen using the texture with UVs, which is exactly what we want (aspect distortion, fine for a vertical gradient).

Note the plane mesh is affected by the background color and tone mapping? `backgroundMaterial` is a ShaderMaterial with `toneMapped: false`? Hmm, in WebGLBackground the background material is a ShaderMaterial with `toneMapped: false`? Let me recall... The planeMesh material: 

```js
const planeMesh = new Mesh(new PlaneGeometry(2,2), new ShaderMaterial({...}));
planeMesh.material.toneMapped = false; // ??? 
```
I believe there is a `#include <tonemapping_fragment>` and `<colorspace_fragment>` in the background shader, and the texture colorspace handles it. In recent versions the background is tone-mapped. Either way, it'll look fine.

Actually, to be safe and controlled, let me just use a CSS gradient on the body as the scene background and set `renderer.setClearAlpha(0)`? No, then the fog color and background won't match the same way... but it would actually look fine: translucent canvas over a CSS gradient. Hmm, but I want the fog to blend into the background. If the canvas is transparent and CSS shows a gradient, the fog color needs to match the CSS gradient at the horizon. Since the gradient is vertical, the fog color would only match at one height.

Simplest is the texture background. Let's go with that. If it works, great.

Actually, an even more reliable approach: use `scene.background = new THREE.Color(0x04202b)` and add a big background sphere with a gradient shader. Hmm, the texture is simpler.

Let's go with the texture background — I'm confident it works in r170.

Now writing the rest.

Also the tone mapping: with ACESFilmic, colors get desaturated/darkened. Let's use exposure 1.15.

Let me write out the whole thing now, carefully.

```js
/* ---------- Lights ---------- */
const hemi = new THREE.HemisphereLight(0x9fe8ff, 0x0a2a33, 1.1);
scene.add(hemi);

const dir = new THREE.DirectionalLight(0xffffff, 2.2);
dir.position.set(6, 14, 8);
dir.castShadow = true;
dir.shadow.mapSize.set(1024, 1024);
dir.shadow.camera.left = -14;
dir.shadow.camera.right = 14;
dir.shadow.camera.top = 14;
dir.shadow.camera.bottom = -14;
dir.shadow.camera.near = 1;
dir.shadow.camera.far = 40;
dir.shadow.bias = -0.001;
scene.add(dir);

const fill = new THREE.PointLight(0x2ad8ff, 40, 40, 2);
fill.position.set(-6, 6, 6);
scene.add(fill);
```

Hmm, PointLight intensity in three r155+ uses physically correct lighting by default (WebGLRenderer.useLegacyLights = false). Intensity 40 with distance 40 decay 2 → at distance 10, illuminance = 40/100 = 0.4. Fine.

Let's include a subtle blue rim light from behind.

Note: `renderer.useLegacyLights` was removed in r165; lights are physical. DirectionalLight intensity 2.2 is bright but with ACES it's fine.

Now the sand.

```js
const sandTex = makeSandTexture();
const floor = new THREE.Mesh(
  new THREE.PlaneGeometry(80, 80),
  new THREE.MeshStandardMaterial({ map: sandTex, color: 0x8a7a5a, roughness: 1, metalness: 0 })
);
floor.rotation.x = -Math.PI/2;
floor.position.y = -4.6;
floor.receiveShadow = true;
scene.add(floor);
```

Hmm, with fog exp2 0.028, at distance 30 the fog factor is 1-exp(-(0.028*30)^2) = 1-exp(-0.7) = 0.5. Good, the floor fades.

Rocks: place them at the bottom near the front.

```js
const rockMat = new THREE.MeshStandardMaterial({color: 0x2b3a44, roughness: 0.9, flatShading: true});
for (let i=0;i<9;i++){
  const r = new THREE.Mesh(new THREE.IcosahedronGeometry(1, 0), rockMat);
  r.position.set(rand(-14,14), -4.6 + rand(0.1,0.6), rand(-10,4));
  const s = rand(0.6, 1.9);
  r.scale.set(s, s*rand(0.5,0.9), s*rand(0.8,1.2));
  r.rotation.set(Math.random(), Math.random()*6, Math.random());
  r.castShadow = true; r.receiveShadow = true;
  scene.add(r);
}
```

Seaweed: blades near the rocks.

```js
const weedMat = new THREE.MeshStandardMaterial({color: 0x2f8f5a, roughness:0.8, side: THREE.DoubleSide, emissive: 0x0a2a18});
```
Hmm emerald green: 0x2f8f5a.

Blade geometry: PlaneGeometry(0.22, 3, 1, 6) translated so the base is at y=0, then taper the vertices.

```js
function makeBlade(){
  const g = new THREE.PlaneGeometry(0.28, 3, 1, 6);
  g.translate(0, 1.5, 0);
  const pos = g.attributes.position;
  for (let i=0;i<pos.count;i++){
    const y = pos.getY(i);
    const t = y/3;
    pos.setX(i, pos.getX(i) * (1 - t*0.85));
  }
  g.computeVertexNormals();
  return g;
}
```
Then each plant group holds 3-5 blades rotated around Y.

Animate: group.rotation.z = Math.sin(t*speed + phase)*0.12; group.rotation.x similar.

But the plant group is at the base position; the blade's base is at y=0 so rotating the group works.

Good.

Now the light shafts.

Alright, writing the final code.

I also want the fish to be clearly visible in the screenshot. They're colorful against a dark blue background. 

Let me be careful with the fish material: emissive helps them pop.

Also each fish: `f.userData.vel` etc.

Let me write the final code.

```js
const fishList = [];
const palette = [0xff7a45, 0xffd166, 0x4ee1c1, 0xff6b9d, 0x9b7bff, 0x5ec8ff, 0xff5566, 0x8ef29d, 0xffa3d1];

const BOUNDS = { x: 8.2, yTop: 4.6, yBot: -2.8, z: 6.0 };

function makeFish(color, scale){
  const g = new THREE.Group();
  const bodyMat = new THREE.MeshStandardMaterial({
    color, roughness: 0.35, metalness: 0.35,
    emissive: new THREE.Color(color).multiplyScalar(0.16)
  });
  const finMat = new THREE.MeshStandardMaterial({
    color: new THREE.Color(color).lerp(new THREE.Color(0xffffff), 0.35),
    roughness: 0.5, metalness: 0.15, transparent: true, opacity: 0.8,
    side: THREE.DoubleSide, emissive: new THREE.Color(color).multiplyScalar(0.2)
  });

  const bodyGeo = new THREE.SphereGeometry(1, 20, 14);
  bodyGeo.scale(0.28, 0.44, 0.85);
  const body = new THREE.Mesh(bodyGeo, bodyMat);
  body.castShadow = true;
  g.add(body);

  // tail
  const tail = new THREE.Group();
  tail.position.z = -0.76;
  const tg = new THREE.BufferGeometry();
  tg.setAttribute('position', new THREE.Float32BufferAttribute([
    0,0,0,   0,0.55,-0.9,   0,0,-0.5,
    0,0,0,   0,0,-0.5,      0,-0.55,-0.9
  ], 3));
  tg.computeVertexNormals();
  const tailMesh = new THREE.Mesh(tg, finMat);
  tail.add(tailMesh);
  g.add(tail);

  // dorsal
  const dg = new THREE.BufferGeometry();
  dg.setAttribute('position', new THREE.Float32BufferAttribute([
    0, 0.42, 0.22,   0, 0.78, -0.28,   0, 0.38, -0.5
  ], 3));
  dg.computeVertexNormals();
  g.add(new THREE.Mesh(dg, finMat));

  // eyes
  const eyeMat = new THREE.MeshStandardMaterial({color: 0x0a0f14, roughness:0.2, metalness:0.6});
  const eyeGeo = new THREE.SphereGeometry(0.085, 10, 8);
  const eL = new THREE.Mesh(eyeGeo, eyeMat); eL.position.set(0.16, 0.14, 0.62); g.add(eL);
  const eR = new THREE.Mesh(eyeGeo, eyeMat); eR.position.set(-0.16, 0.14, 0.62); g.add(eR);

  g.userData.tail = tail;
  g.scale.setScalar(scale);
  return g;
}
```

Wait, the eye positions: x=±0.16 pushes the eyes out, but the body at z=0.62 has a half-width of 0.28*sqrt(1-(0.62/0.85)^2) = 0.28*sqrt(1-0.532)=0.28*0.684=0.19. So x=0.16 is inside; the sphere radius 0.085 will poke out. Good.

But actually the eye is at y=0.14 and z=0.62; the ellipsoid at that point: (x/0.28)²+(y/0.44)²+(z/0.85)² = 1. At y=0.14, z=0.62: (0.62/0.85)²=0.532, (0.14/0.44)²=0.101 → 0.633; remaining 0.367 → x = 0.28*0.606 = 0.17. So the surface is at x=0.17, and the eye at 0.16 with radius 0.085 pokes out 0.075. Good.

Now, with the fish scaled by `scale` (0.7-1.5), the eye geometry is shared — that's fine.

Alright, the fish update code:

```js
const _v = new THREE.Vector3();
const _a = new THREE.Vector3();
```

Let's just allocate per-frame vectors — 28 fish * a few vectors per frame at 60fps is wasteful but ok. Better to reuse. I'll use module-level temporaries.

Update function:

```js
function updateFish(dt, t){
  const p = pointer;
  for (const f of fishList){
    const u = f.userData;
    const ph = u.phase;
    const tx = Math.sin(t*0.31 + ph) * 6.0;
    const ty = 1.0 + Math.sin(t*0.47 + ph*1.7) * 2.3;
    const tz = Math.sin(t*0.24 + ph*0.8) * 4.0;

    let ax = (tx - f.position.x) * 0.45;
    let ay = (ty - f.position.y) * 0.45;
    let az = (tz - f.position.z) * 0.45;

    const dx = f.position.x - p.x;
    const dy = f.position.y - p.y;
    const dz = f.position.z - p.z;
    const d2 = dx*dx + dy*dy + dz*dz;
    const R = 4.5;
    let flee = 0;
    if (d2 < R*R){
      const d = Math.sqrt(d2) + 0.0001;
      const w = 1 - d/R;
      flee = w*w;
      const k = flee * 55 / d;
      ax += dx*k; ay += dy*k; az += dz*k;
    }

    // bounds
    const B = 8.2;
    if (f.position.x > B) ax -= (f.position.x - B) * 25;
    if (f.position.x < -B) ax += (-B - f.position.x) * 25;
    if (f.position.y > 4.4) ay -= (f.position.y - 4.4) * 25;
    if (f.position.y < -2.6) ay += (-2.6 - f.position.y) * 25;
    if (f.position.z > 6.0) az -= (f.position.z - 6.0) * 25;
    if (f.position.z < -6.0) az += (-6.0 - f.position.z) * 25;

    u.ax = ax; u.ay = ay; u.az = az;
    u.flee = flee;
  }

  // separation
  for (let i=0;i<fishList.length;i++){
    const A = fishList[i];
    for (let j=i+1;j<fishList.length;j++){
      const Bf = fishList[j];
      let dx = Bf.position.x - A.position.x;
      let dy = Bf.position.y - A.position.y;
      let dz = Bf.position.z - A.position.z;
      const d2 = dx*dx+dy*dy+dz*dz;
      const md = 1.35;
      if (d2 < md*md && d2 > 1e-5){
        const d = Math.sqrt(d2);
        const push = (md - d) / md * 14 / d;
        dx *= push; dy *= push; dz *= push;
        A.userData.ax -= dx; A.userData.ay -= dy; A.userData.az -= dz;
        Bf.userData.ax += dx; Bf.userData.ay += dy; Bf.userData.az += dz;
      }
    }
  }

  // integrate
  for (const f of fishList){
    const u = f.userData;
    u.vel.x += u.ax * dt;
    u.vel.y += u.ay * dt;
    u.vel.z += u.az * dt;
    const damp = Math.exp(-1.9*dt);
    u.vel.multiplyScalar(damp);
    const sp = u.vel.length();
    if (sp > 11) u.vel.multiplyScalar(11/sp);
    else if (sp < u.cruise) {
      if (sp > 1e-4) u.vel.multiplyScalar(u.cruise/sp);
      else u.vel.set(0,0,1).multiplyScalar(u.cruise);
    }
    f.position.addScaledVector(u.vel, dt);

    // orientation
    _tmp.copy(f.position).add(u.vel);
    f.lookAt(_tmp);
    // bank
    f.rotateZ(Math.sin(t*1.7 + u.phase) * 0.12);
    // tail wag
    u.tail.rotation.y = Math.sin(t * u.wag + u.phase) * (0.35 + u.flee*0.6);
  }
}
```

Hmm, `f.rotateZ` after lookAt modifies the quaternion, but each frame lookAt resets the rotation from the up vector, so it's fine (no accumulation).

Wait, careful: lookAt sets rotation but rotateZ multiplies. Since lookAt is called each frame, it resets. Good.

But `f.rotateZ` also affects the tail? Tail is a child, so no, the tail rotation is local and unaffected. Fine.

The min-speed enforcement: `sp < u.cruise` — since the drag reduces velocity, the min speed enforcement keeps a cruising speed. But that means velocities are always at the cruise speed. That's what we want for a fish that always swims.

Hmm, but the flee check will be constantly overridden by max speed. Fine.

Actually, the min speed enforcement of `cruise` means the drag has no effect on the direction. It just re-normalizes the velocity each frame. That's actually how boids work: steer to a desired velocity. OK.

Hmm, but there's a subtlety: the velocity direction is what matters, and magnitude is forced to cruise. So the flee acceleration changes direction over time. Fine.

`u.cruise` between 1.6 and 3.0.

Let's also make sure the fish don't all end up pointing in the same direction. The wander phases differ.

Now the pointer.

```js
const pointer = new THREE.Vector3(0, 0, 0);
const pointerTarget = new THREE.Vector3(0, 0, 0);
const raycaster = new THREE.Raycaster();
const planeZ = new THREE.Plane(new THREE.Vector3(0,0,1), 0);
let lastMove = -100;
let pointerActive = false;

window.addEventListener('pointermove', (e) => {
  const nx = (e.clientX / window.innerWidth) * 2 - 1;
  const ny = -(e.clientY / window.innerHeight) * 2 + 1;
  raycaster.setFromCamera(_ndc.set(nx, ny), camera);
  raycaster.ray.intersectPlane(planeZ, pointerTarget);
  lastMove = time;
  pointerActive = true;
  hideHint();
});
```

Wait, raycaster.setFromCamera expects a Vector2. `_ndc` is a Vector2.

`ray.intersectPlane(plane, target)` returns null if it doesn't intersect (parallel). Our plane is z=0 facing +z, and the camera looks roughly down -z, so it always intersects. Fine.

Also handle touch: pointermove covers touch.

Auto-demo when the pointer has been idle:

```js
if (!pointerActive || time - lastMove > 2.5){
  const s = time;
  pointerTarget.set(Math.sin(s*0.5)*7.5, Math.sin(s*0.83)*2.6, 0.5 + Math.cos(s*0.37)*2.5);
}
```

Hmm, pointerTarget z = 0.5 + cos*2.5 gives a depth variation. Nice.

Then `pointer.lerp(pointerTarget, 1 - Math.exp(-6*dt))`.

Then the glow sprite at pointer.

But should the glow be visible when auto-demo? It would look like a glowing orb moving around and fish fleeing it. That's fine and even cool. But it might look like a bug if the user isn't moving the mouse... Actually it's a nice touch — a "phantom" cursor. I'll keep it subtle with opacity 0.25. Hmm, but for the screenshot, it might look odd. Let's make it a small soft glow, low opacity.

OK.

Also, during auto-demo, when the mouse is idle, the pointer target is doing Lissajous. The lerp smooths it.

One issue: the initial `pointer` is at (0,0,0), and the fish start at random positions. Fine.

Hint hide: on pointermove, add a class to fade out. Also fade after 6s.

Now let's handle the bubble update and the seaweed sway, and the shafts.

Also, the water surface: add a subtle horizontal plane at y=8 with a gradient fade? Skip.

Add "god rays" done.

I'd like to add a vignette via CSS overlay for mood: `background: radial-gradient(ellipse at center, transparent 45%, rgba(0,10,20,0.55) 100%)`. Nice.

Let's finalize the whole file.

Also, `time` needs to be defined before the pointermove handler uses it. It's a module-level `let time = 0;` — but it's used inside the handler which runs later. Fine, as long as `time` is declared before the handler fires. Since it's a module-level `let`, it's hoisted in TDZ but the handler runs after initialization. Fine.

Let me write the final code.

One more consideration: `renderer.setAnimationLoop` vs requestAnimationFrame. Use setAnimationLoop.

Also handle visibility: dt clamped to 0.05.

Let's write it.

```js
const clock = new THREE.Clock();
let time = 0;

renderer.setAnimationLoop(() => {
  const dt = Math.min(clock.getDelta(), 0.05);
  time += dt;
  updatePointer(dt);
  updateFish(dt, time);
  updateBubbles(dt, time);
  updateWeeds(time);
  shaftMat.uniforms.uTime.value = time;
  camera.position.x = Math.sin(time * 0.12) * 0.6;
  camera.position.y = 1.0 + Math.sin(time * 0.17) * 0.25;
  camera.lookAt(0, 0.6, 0);
  renderer.render(scene, camera);
});
```

Careful: the camera moves, which changes the raycast. That's fine since we update the pointer each frame based on the last NDC — no, we only update the target on pointermove. The camera moves slowly, so the pointer target's world position stays fixed while the camera moves — that's actually fine.

Hmm, but the auto-demo pointerTarget is set directly in world coords, so no issue.

Let me now also double check the pointer's raycast plane at z=0 and the camera at z≈13.5. A pointer at NDC x=1 gives a ray at ~26° from the forward axis, hitting z=0 at x ≈ 13.5*tan(26°) ≈ 6.6? Actually with fov 52 and aspect 1.6, the horizontal half-angle = atan(tan(26°)*1.6) = atan(0.488*1.6)=atan(0.78)=38°. So at distance 13.5, x = ±10.5. That's outside the fish bounds (8.2), but the fish are at z between -6 and 6, so when the cursor is at the edge, it's beyond the fish. The fish would then flee from a point outside the tank — still works, they flee toward the center. Good.

Now the code for the glow sprite. I'll use a `THREE.Sprite` with the circle texture, additive, color 0x9ee8ff, scale 3, opacity 0.18.

Actually, it might look like a bokeh. Let's make it a small soft ring: scale 2.5, opacity 0.15. Fine.

Writing out the final now. I'll be careful with syntax.

Regarding `THREE.Float32BufferAttribute` — available. Good.

Also, the fish geometry for the tail: the triangles are (0,0,0), (0,0.55,-0.9), (0,0,-0.5) and (0,0,0), (0,0,-0.5), (0,-0.55,-0.9). The last triangle winding makes the normal point in -x for the first and ... doesn't matter, DoubleSide.

Ok, the dorsal fin: (0,0.42,0.22), (0,0.78,-0.28), (0,0.38,-0.5). Fine.

Now writing the seaweed:

```js
const weeds = [];
const weedGroup = new THREE.Group();
const bladeGeo = makeBladeGeo();
const weedMat = new THREE.MeshStandardMaterial({color: 0x2f9a63, roughness:0.85, metalness:0.05, side: THREE.DoubleSide, emissive: 0x0c3a22, emissiveIntensity: 1});
for (let i=0;i<16;i++){
  const plant = new THREE.Group();
  const x = (Math.random()-0.5)*26;
  const z = -8 + Math.random()*12;
  plant.position.set(x, -4.6, z);
  const blades = 3 + Math.floor(Math.random()*3);
  for (let b=0;b<blades;b++){
    const m = new THREE.Mesh(bladeGeo, weedMat);
    m.rotation.y = Math.random()*Math.PI*2;
    m.rotation.z = (Math.random()-0.5)*0.3;
    m.scale.setScalar(0.7 + Math.random()*0.7);
    m.position.set(Math.random()*0.4-0.2, 0, Math.random()*0.4-0.2);
    plant.add(m);
  }
  plant.userData.phase = Math.random()*Math.PI*2;
  plant.userData.speed = 0.7 + Math.random()*0.8;
  weedGroup.add(plant);
  weeds.push(plant);
}
scene.add(weedGroup);
```

Update: `plant.rotation.z = Math.sin(t*sp + ph)*0.09;` — the blades are children so the whole plant sways.

Hmm, weeds at x ±13 might be outside the view... at z=0, the view half-width is ~10.5. Since weeds are at z=-8..4, they'd be more visible. Fine.

Blade geometry function:

```js
function makeBladeGeo(){
  const g = new THREE.PlaneGeometry(0.3, 3.2, 1, 6);
  g.translate(0, 1.6, 0);
  const pos = g.attributes.position;
  for (let i=0;i<pos.count;i++){
    const y = pos.getY(i);
    const t = y/3.2;
    pos.setX(i, pos.getX(i) * (1 - t*0.9));
  }
  g.computeVertexNormals();
  return g;
}
```

OK.

Now let's finalize. I'll write everything out.

For the shader material shaft, let me use a simpler approach and just use a mesh with a vertical gradient texture... no, shader is fine. Let me write it carefully.

```js
const shaftMat = new THREE.ShaderMaterial({
  transparent: true,
  depthWrite: false,
  blending: THREE.AdditiveBlending,
  side: THREE.DoubleSide,
  uniforms: {
    uColor: { value: new THREE.Color(0x9fe8ff) },
    uTime: { value: 0 }
  },
  vertexShader: `
    varying vec2 vUv;
    varying vec3 vN;
    varying vec3 vV;
    void main(){
      vUv = uv;
      vec4 mv = modelViewMatrix * vec4(position, 1.0);
      vN = normalize(normalMatrix * normal);
      vV = normalize(-mv.xyz);
      gl_Position = projectionMatrix * mv;
    }
  `,
  fragmentShader: `
    uniform vec3 uColor;
    uniform float uTime;
    varying vec2 vUv;
    varying vec3 vN;
    varying vec3 vV;
    void main(){
      float rim = pow(1.0 - abs(dot(normalize(vN), normalize(vV))), 1.5);
      float fade = smoothstep(0.0, 0.6, vUv.y) * (1.0 - smoothstep(0.9, 1.0, vUv.y));
      float a = rim * fade * 0.10;
      gl_FragColor = vec4(uColor, a);
    }
  `
});
```

Hmm, the rim term makes the center of the cone transparent and the edges bright, which is a bit odd for a light shaft but it reads as a volumetric beam. Actually, for additive light shafts you'd want it brighter in the middle... but the rim-lit look makes it look like a cone outline. Let's use the inverse: `pow(abs(dot(n,v)), 0.7)`? Hmm, for a cone, the front and back faces have normals pointing away from the axis. A soft volumetric look is achieved by a low alpha and no rim. Let's use:

`float a = fade * 0.045;`

Very subtle. Combined with the additive blending, it will read as a soft glow. But a uniform cone looks like a hard-edged shape. With over 6 cones and low opacity, maybe it's fine.

Let's use a soft radial based on the UV: `float edge = smoothstep(0.0, 0.15, vUv.x) * smoothstep(1.0, 0.85, vUv.x);` Hmm, cone UVs: u wraps around the circumference, so a smoothstep on u doesn't help.

Alternatively: use a plane with a gradient texture, billboarded? That's a common "god ray" approach: a vertical quad with a soft horizontal gradient, additive. But when viewed from an angle, it looks flat.

Let's just use a cone with low opacity + rim. I'll go with `rim` version but stronger falloff on the center... Let's keep it simple:

```glsl
float rim = pow(1.0 - abs(dot(normalize(vN), normalize(vV))), 2.0);
float a = (0.35 + rim) * fade * 0.06;
```

That gives a soft cone with brighter edges. Good. Fine.

Actually, I realize the alpha might be too low to be noticeable. Multiply by 0.09 and use 6 cones. It's a subtle background detail. Fine.

Alright, writing the entire file now.

Double-check: `THREE.PlaneGeometry(2,2)` etc. all fine.

Also for the sprite:

```js
const glowTex = makeCircleTexture();
const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: glowTex, color: 0xaee8ff, transparent:true, opacity:0.16, blending: THREE.AdditiveBlending, depthWrite:false, depthTest:false }));
glow.scale.set(3.5,3.5,1);
scene.add(glow);
```

depthTest false so it always shows. But it would show through the fish. Since it's a soft glow, that's maybe fine, but it might look odd. Let's keep depthTest true, so fish occlude it. Hmm, then when the pointer is over a fish it disappears. That's actually good feedback. Keep depthTest true.

Actually with depthTest true and the sprite at z=0 exactly, and fish passing through... fine.

Hmm, one thing: the glow sprite at the pointer position — the fish flee from it, so it will look like the fish avoid the glowing orb. 

Now let's write out everything. I'll be careful with the code.

Final concern: performance with 28 fish each with ~5 meshes = 140 draw calls + shadows. That's fine.

Alright, write it.

Also add `renderer.domElement.style.display='block'` — actually the canvas is appended to body; set CSS `canvas{display:block}`.

Let me write the HUD in HTML:

```html
<div id="vignette"></div>
<header id="hud">
  <h1>3D Aquarium</h1>
  <p>Move your cursor through the water — the fish flee from it</p>
</header>
<div id="hint">Move your mouse to startle the fish</div>
```

CSS: 

```css
* { margin:0; padding:0; box-sizing:border-box; }
html,body { height:100%; overflow:hidden; background:#02121a; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
canvas { display:block; }
#vignette { position:fixed; inset:0; pointer-events:none; background: radial-gradient(ellipse at 50% 45%, rgba(0,0,0,0) 40%, rgba(0,12,20,0.55) 100%); }
#hud { position:fixed; top:22px; left:26px; pointer-events:none; color:#d8f6ff; text-shadow:0 2px 18px rgba(0,20,30,0.9); }
#hud h1 { font-size:20px; font-weight:600; letter-spacing:0.22em; text-transform:uppercase; }
#hud p { margin-top:6px; font-size:12.5px; letter-spacing:0.06em; opacity:0.62; }
#hint { position:fixed; left:50%; bottom:26px; transform:translateX(-50%); padding:9px 18px; border-radius:999px; background:rgba(8,40,54,0.55); border:1px solid rgba(140,230,255,0.25); backdrop-filter:blur(6px); color:#c9f2ff; font-size:12.5px; letter-spacing:0.08em; pointer-events:none; transition:opacity 0.8s ease; white-space:nowrap; }
#hint.hide { opacity:0; }
@media (max-width:520px){ #hud h1{font-size:15px} #hud p{font-size:11px} #hint{font-size:11px; padding:7px 14px} }
```

Also `#hint` shouldn't cause overflow at 375px width: white-space nowrap with 375px width... "Move your mouse to startle the fish" at 12px ≈ 200px. Fine.

Now write everything.

I'll now produce the final file.

One last check on `raycaster.ray.intersectPlane`: signature `intersectPlane(plane, target)` returns Vector3|null.

And `raycaster.setFromCamera(coords, camera)` where coords is a Vector2 with NDC.

Write it.

Also, for `pointerTarget`, when the pointer is inactive at the start, we set the auto-demo, so the fish immediately react.

Let's finalize the code:

```js
let lastPointerTime = -10;
let pointerEverMoved = false;

function updatePointer(dt){
  const idle = time - lastPointerTime;
  if (!pointerEverMoved || idle > 2.2){
    const s = time;
    pointerTarget.set(
      Math.sin(s*0.55)*7.0,
      Math.sin(s*0.83 + 1.3)*2.4 + 0.6,
      Math.cos(s*0.42)*3.2
    );
  }
  pointer.lerp(pointerTarget, 1 - Math.exp(-5*dt));
  glow.position.copy(pointer);
}
```

`time` is a module-level variable that starts at 0 and is incremented in the loop. `lastPointerTime` is set to `time`. Since the loop starts after, the first frames have idle = 0 - (-10) = 10 > 2.2 → auto. Good.

Now, initial `pointerTarget` is (0,0,0) and pointer is (0,0,0), so it lerps out. Good.

Also I need to hide the hint after the first move and after some seconds.

Alright. Writing.

For the fish initial positions, spread them out and give them cruise speeds.

Let me write out the code fully now.

```js
const FISH_COUNT = 26;
for (let i = 0; i < FISH_COUNT; i++) {
  const color = palette[i % palette.length];
  const scale = 0.75 + Math.random() * 0.75;
  const f = makeFish(color, scale);
  f.position.set(
    (Math.random() - 0.5) * 15,
    -2 + Math.random() * 6,
    (Math.random() - 0.5) * 11
  );
  const dir = new THREE.Vector3(Math.random()-0.5, (Math.random()-0.5)*0.25, Math.random()-0.5).normalize();
  f.userData = {
    ...f.userData,
    vel: dir.multiplyScalar(2),
    cruise: 1.4 + Math.random() * 1.2,
    phase: Math.random() * Math.PI * 2,
    wag: 7 + Math.random() * 5,
    ax: 0, ay: 0, az: 0, flee: 0
  };
  f.lookAt(f.position.clone().add(f.userData.vel));
  scene.add(f);
  fishList.push(f);
}
```

Note: f.userData.tail is set in makeFish, and I spread f.userData to preserve it. Good.

Careful: `dir.multiplyScalar(2)` mutates dir; fine.

Now the full update as described.

I think we're set. Let me write the final answer.

Actually, one more: since `f.lookAt` is called with world coordinates and the fish is a direct child of the scene (no transform), that's fine.

Also, since the fish has a non-uniform... no, uniform scale.

Writing the final code.

Wait — one thing about `f.rotateZ(...)`: `Object3D.rotateZ` rotates around the local Z axis. After lookAt, the local Z is the forward direction. Actually, rotateZ rotates about the object's own Z axis, which is forward. So the fish banks. Good.

Final answer below.