Interactive 3D aquariumOne-line
Qwen3.8 Flash
by QwenReasoning1 console error
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 2 min 15 s
- Cost
- $0.0046
- Output tokens
- 9,805
- Spent thinking
- 0 tokens
- Speed
- 76.4 tok/s
- Console errors
- 1
- First token after
- 6.6 s
- First visible text after
- 6.6 s
- Prompt tokens
- 447
- Code size
- 26.0 KB
- Finish reason
- stop
- Served by
- Alibaba
- Model id
qwen/qwen3.8-flash- Reasoning sent
{"effort":"none"}- Max output
- 64,000 tokens
- Generated
- Nov 11, 2026
Console errors
Caught in the first 3 seconds after load, in a sandboxed iframe like the one above.
finR.translateX is not a function
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 · 508 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Abyssal Reef — an interactive 3D aquarium</title>
<style>
:root{
--serif: Georgia, 'Times New Roman', serif;
--sans: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--gold:#f2c76b;
--coral:#ff9a5e;
--teal:#4fd0d6;
--ink:#eaf4f7;
--muted:#9fc2cf;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden;background:#02121a;font-family:var(--sans);color:var(--ink)}
canvas{display:block}
#ui{position:fixed;inset:0;z-index:10;pointer-events:none}
.panel{position:absolute;pointer-events:auto}
/* Brand */
.brand{top:clamp(16px,3vw,34px);left:clamp(16px,3vw,34px);max-width:min(46ch,60vw)}
.kicker{font-family:var(--mono);font-size:.7rem;letter-spacing:.34em;text-transform:uppercase;color:var(--gold);opacity:.85;display:flex;align-items:center;gap:.6em}
.kicker::before{content:"";width:26px;height:1px;background:linear-gradient(90deg,var(--gold),transparent)}
h1{font-family:var(--serif);font-weight:700;font-size:clamp(2.4rem,7vw,5rem);line-height:.92;letter-spacing:-.01em;margin-top:.12em;
text-shadow:0 2px 30px rgba(0,0,0,.5)}
h1 em{font-style:italic;color:var(--gold)}
.sub{margin-top:.9em;font-size:clamp(.9rem,1.6vw,1.05rem);line-height:1.5;color:var(--muted);max-width:34ch}
.sub b{color:var(--ink);font-weight:600}
/* Stats */
.stats{top:clamp(16px,3vw,34px);right:clamp(16px,3vw,34px);text-align:right;font-family:var(--mono);font-size:.72rem;letter-spacing:.06em;color:var(--muted)}
.stats .row{display:flex;justify-content:flex-end;gap:.5em;align-items:baseline;margin-bottom:.35em}
.stats .num{color:var(--ink);font-size:1.05rem;font-weight:600}
.stats .live{color:var(--teal)}
.stats .live::after{content:"";display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--teal);margin-left:.5em;vertical-align:middle;animation:pulse 1.6s infinite}
@keyframes pulse{0%,100%{opacity:.3}50%{opacity:1}}
/* Controls */
.controls{bottom:clamp(16px,3vw,30px);left:clamp(16px,3vw,34px);display:flex;flex-wrap:wrap;gap:14px 20px;align-items:flex-end;
background:rgba(4,22,30,.5);border:1px solid rgba(120,200,215,.14);border-radius:14px;padding:14px 18px;backdrop-filter:blur(8px)}
.ctrl{display:flex;flex-direction:column;gap:6px;min-width:120px}
.ctrl label{font-family:var(--mono);font-size:.62rem;letter-spacing:.18em;text-transform:uppercase;color:var(--muted)}
input[type=range]{-webkit-appearance:none;appearance:none;width:130px;height:3px;border-radius:3px;background:rgba(120,200,215,.25);cursor:pointer}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:var(--gold);box-shadow:0 0 10px rgba(242,199,107,.7);transition:transform .15s}
input[type=range]::-webkit-slider-thumb:hover{transform:scale(1.25)}
input[type=range]::-moz-range-thumb{width:14px;height:14px;border:none;border-radius:50%;background:var(--gold);box-shadow:0 0 10px rgba(242,199,107,.7)}
.btn{font-family:var(--mono);font-size:.66rem;letter-spacing:.14em;text-transform:uppercase;color:var(--ink);
background:rgba(120,200,215,.1);border:1px solid rgba(120,200,215,.28);padding:9px 14px;border-radius:9px;cursor:pointer;transition:.2s}
.btn:hover{background:rgba(242,199,107,.16);border-color:var(--gold);color:var(--gold);transform:translateY(-1px)}
.btn[aria-pressed=true]{background:rgba(242,199,107,.22);border-color:var(--gold);color:var(--gold)}
/* Hint */
.hint{bottom:clamp(16px,3vw,30px);right:clamp(16px,3vw,34px);font-family:var(--mono);font-size:.7rem;letter-spacing:.08em;color:var(--muted);
display:flex;align-items:center;gap:.6em;transition:opacity .6s}
.hint .dot{width:8px;height:8px;border-radius:50%;background:var(--coral);box-shadow:0 0 12px var(--coral);animation:bob 2.4s ease-in-out infinite}
@keyframes bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}}
.hint.hide{opacity:0}
/* Intro */
#intro{position:fixed;inset:0;z-index:20;display:grid;place-items:center;text-align:center;
background:radial-gradient(ellipse at center,rgba(2,16,22,.15) 0%,rgba(1,10,15,.72) 100%);transition:opacity .8s ease}
#intro.gone{opacity:0;pointer-events:none}
.intro-inner{padding:20px}
.intro-kicker{font-family:var(--mono);font-size:.72rem;letter-spacing:.4em;text-transform:uppercase;color:var(--gold)}
.intro-title{font-family:var(--serif);font-size:clamp(3rem,11vw,7rem);line-height:.9;margin:.1em 0 .15em;
text-shadow:0 4px 40px rgba(0,0,0,.6)}
.intro-title em{font-style:italic;color:var(--gold)}
.intro-sub{color:var(--muted);font-size:clamp(.95rem,2vw,1.15rem);max-width:40ch;margin:0 auto;line-height:1.5}
.cta{margin-top:2.2em;display:inline-flex;align-items:center;gap:.7em;font-family:var(--mono);font-size:.8rem;letter-spacing:.24em;text-transform:uppercase;
color:var(--ink);border:1px solid rgba(242,199,107,.5);padding:14px 26px;border-radius:40px;cursor:pointer;
transition:.25s;animation:glow 2.6s ease-in-out infinite}
.cta:hover{background:rgba(242,199,107,.16);border-color:var(--gold);color:var(--gold);transform:translateY(-2px)}
@keyframes glow{0%,100%{box-shadow:0 0 0 rgba(242,199,107,0)}50%{box-shadow:0 0 30px rgba(242,199,107,.35)}}
.cta small{font-size:.72em;opacity:.7;letter-spacing:.1em}
/* Vignette */
.vignette{position:fixed;inset:0;z-index:5;pointer-events:none;
background:radial-gradient(ellipse at 50% 42%,transparent 45%,rgba(1,10,16,.55) 100%)}
@media (max-width:640px){
.sub{display:none}
.stats{font-size:.62rem}
.controls{gap:10px 14px;padding:10px 12px}
input[type=range]{width:96px}
.hint{display:none}
}
</style>
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}</script>
</head>
<body>
<div id="ui">
<div class="panel brand">
<div class="kicker">Interactive · WebGL · Three.js</div>
<h1>Abyssal <em>Reef</em></h1>
<p class="sub">A living tank of schooling fish. Bring your cursor close and the whole shoal <b>darts away</b> from you.</p>
</div>
<div class="panel stats">
<div class="row"><span>FPS</span><span class="num" id="fps">—</span></div>
<div class="row"><span>SHOAL</span><span class="num" id="count">—</span></div>
<div class="row"><span>STATUS</span><span class="num live" id="status">calm</span></div>
</div>
<div class="panel controls">
<div class="ctrl">
<label for="speed">Swim speed</label>
<input id="speed" type="range" min="0.4" max="1.8" step="0.05" value="1">
</div>
<div class="ctrl">
<label for="flee">Flee radius</label>
<input id="flee" type="range" min="2" max="8" step="0.2" value="4.2">
</div>
<button class="btn" id="feedBtn">Feed ▸ scatter</button>
<button class="btn" id="orbitBtn" aria-pressed="false">Auto-orbit</button>
</div>
<div class="panel hint" id="hint"><span class="dot"></span> move your cursor to scare them</div>
</div>
<div class="vignette"></div>
<div id="intro">
<div class="intro-inner">
<div class="intro-kicker">Three.js Aquarium</div>
<div class="intro-title">Abyssal <em>Reef</em></div>
<p class="intro-sub">Watch the shoal drift in calm water — then reach in and send them scattering through the light shafts.</p>
<div class="cta" id="startBtn">Enter the tank <small>· click or press any key</small></div>
</div>
</div>
<script type="module">
import * as THREE from 'three';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
/* ---------- helpers ---------- */
const clamp = (v,a,b)=>Math.min(b,Math.max(a,v));
const lerp = (a,b,t)=>a+(b-a)*t;
function smoothstep(e0,e1,x){const t=clamp((x-e0)/(e1-e0),0,1);return t*t*(3-2*t);}
function randColor(){const p=[[0xff8a3d,0xffd15c],[0x2ec5b6,0xa8f0e0],[0xff5d73,0xffb08a],[0x6ea8ff,0xbcd4ff],[0xffc24d,0xfff0b0]];
const c=p[(Math.random()*p.length)|0];return [new THREE.Color(c[0]),new THREE.Color(c[1])];}
/* ---------- renderer / scene / camera ---------- */
let W=innerWidth,H=innerHeight;
const renderer=new THREE.WebGLRenderer({antialias:true,powerPreference:'high-performance'});
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
renderer.setSize(W,H);
renderer.shadowMap.enabled=true;
renderer.shadowMap.type=THREE.PCFSoftShadowMap;
renderer.toneMapping=THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure=1.05;
document.body.appendChild(renderer.domElement);
const scene=new THREE.Scene();
scene.fog=new THREE.FogExp2(0x0a3540,0.026);
const pmrem=new THREE.PMREMGenerator(renderer);
scene.environment=pmrem.fromScene(new RoomEnvironment(),0.04).texture;
const camera=new THREE.PerspectiveCamera(52,W/H,0.1,120);
camera.position.set(0,1.5,14);
/* ---------- lights ---------- */
scene.add(new THREE.HemisphereLight(0x9fe0ff,0x062a33,0.55));
const sun=new THREE.DirectionalLight(0xdff3ff,1.5);
sun.position.set(4,16,6);
sun.castShadow=true;
sun.shadow.mapSize.set(1024,1024);
sun.shadow.camera.near=1;sun.shadow.camera.far=40;
sun.shadow.camera.left=-14;sun.shadow.camera.right=14;sun.shadow.camera.top=14;sun.shadow.camera.bottom=-14;
sun.shadow.bias=-0.0006;
scene.add(sun);
const warm=new THREE.PointLight(0xffb060,22,26,2);warm.position.set(-5,-1,3);scene.add(warm);
const cool=new THREE.PointLight(0x4fd0ff,16,26,2);cool.position.set(6,1,-4);scene.add(cool);
/* ---------- textures ---------- */
function sandTex(){const s=256,c=document.createElement('canvas');c.width=c.height=s;const x=c.getContext('2d');
x.fillStyle='#cbb083';x.fillRect(0,0,s,s);
for(let i=0;i<6000;i++){x.globalAlpha=Math.random()*0.12;const g=Math.random();
x.fillStyle=g<0.5?'#a58c5f':'#e7d4ac';x.beginPath();x.arc(Math.random()*s,Math.random()*s,Math.random()*2+0.4,0,7);x.fill();}
x.globalAlpha=1;const t=new THREE.CanvasTexture(c);t.wrapS=t.wrapT=THREE.RepeatWrapping;t.repeat.set(6,6);return t;}
function causticTex(){const s=256,c=document.createElement('canvas');c.width=c.height=s;const x=c.getContext('2d');
x.fillStyle='#000';x.fillRect(0,0,s,s);x.globalCompositeOperation='lighter';
for(let i=0;i<26;i++){const cx=Math.random()*s,cy=Math.random()*s,r=30+Math.random()*90;
const g=x.createRadialGradient(cx,cy,0,cx,cy,r);g.addColorStop(0,'rgba(190,255,255,0.5)');g.addColorStop(1,'rgba(190,255,255,0)');
x.fillStyle=g;x.beginPath();x.arc(cx,cy,r,0,7);x.fill();}
const t=new THREE.CanvasTexture(c);t.wrapS=t.wrapT=THREE.RepeatWrapping;return t;}
function bubbleTex(){const s=64,c=document.createElement('canvas');c.width=c.height=s;const x=c.getContext('2d');
const g=x.createRadialGradient(s*0.4,s*0.35,1,s*0.5,s*0.5,s*0.5);
g.addColorStop(0,'rgba(255,255,255,0.95)');g.addColorStop(0.3,'rgba(210,245,255,0.5)');g.addColorStop(0.75,'rgba(150,220,255,0.15)');g.addColorStop(1,'rgba(150,220,255,0)');
x.fillStyle=g;x.beginPath();x.arc(s*0.5,s*0.5,s*0.5,0,7);x.fill();
x.strokeStyle='rgba(255,255,255,0.5)';x.lineWidth=2;x.beginPath();x.arc(s*0.5,s*0.5,s*0.42,0,7);x.stroke();
return new THREE.CanvasTexture(c);}
function bgTex(){const w=512,h=512,c=document.createElement('canvas');c.width=w;c.height=h;const x=c.getContext('2d');
let g=x.createLinearGradient(0,0,0,h);g.addColorStop(0,'#12617a');g.addColorStop(0.5,'#0a3b4c');g.addColorStop(1,'#03121a');
x.fillStyle=g;x.fillRect(0,0,w,h);
g=x.createRadialGradient(w*0.5,h*0.05,0,w*0.5,h*0.05,h*0.9);g.addColorStop(0,'rgba(190,240,255,0.35)');g.addColorStop(1,'rgba(190,240,255,0)');
x.fillStyle=g;x.fillRect(0,0,w,h);
for(let i=0;i<200;i++){x.globalAlpha=Math.random()*0.06;x.fillStyle='#dff';x.beginPath();x.arc(Math.random()*w,Math.random()*h,Math.random()*1.5,0,7);x.fill();}
x.globalAlpha=1;const t=new THREE.CanvasTexture(c);t.colorSpace=THREE.SRGBColorSpace;return t;}
scene.background=bgTex();
/* ---------- bounds ---------- */
const B={x:8,y:3.2,z:6};
const home=new THREE.Vector3(0,0.3,0);
/* ---------- floor + caustics ---------- */
const floorMat=new THREE.MeshStandardMaterial({map:sandTex(),roughness:0.95,metalness:0});
const floor=new THREE.Mesh(new THREE.PlaneGeometry(60,60),floorMat);
floor.rotation.x=-Math.PI/2;floor.position.y=-B.y;floor.receiveShadow=true;scene.add(floor);
const causticMat=new THREE.MeshBasicMaterial({map:causticTex(),transparent:true,blending:THREE.AdditiveBlending,depthWrite:false,opacity:0.5,color:0x9fefff});
const caustic=new THREE.Mesh(new THREE.PlaneGeometry(30,30),causticMat);
caustic.rotation.x=-Math.PI/2;caustic.position.y=-B.y+0.02;scene.add(caustic);
/* ---------- rocks ---------- */
const rockMat=new THREE.MeshStandardMaterial({color:0x3a4a52,roughness:0.95,metalness:0.02,flatShading:true});
for(let i=0;i<7;i++){
const r=new THREE.Mesh(new THREE.IcosahedronGeometry(0.6+Math.random()*1.1,0),rockMat);
r.scale.set(1,0.6+Math.random()*0.5,1+Math.random()*0.4);
r.position.set((Math.random()*2-1)*(B.x-1.5),-B.y+r.geometry.parameters.radius*0.3,(Math.random()*2-1)*(B.z-1.5));
r.rotation.set(Math.random(),Math.random(),Math.random());r.castShadow=r.receiveShadow=true;scene.add(r);
}
/* ---------- kelp ---------- */
const kelp=[];
const kelpGeo=new THREE.PlaneGeometry(0.5,3.4,1,10);
{const p=kelpGeo.attributes.position;for(let i=0;i<p.count;i++)p.setY(i,p.getY(i)+1.7);kelpGeo.computeBoundingBox();}
const kelpUniforms={uTime:{value:0},uWind:{value:new THREE.Vector2(0.5,0.2)},uH:{value:kelpGeo.boundingBox.max.y}};
const kelpVert=`
uniform float uTime; uniform vec2 uWind; uniform float uH; varying vec2 vUv;
void main(){ vUv=uv; vec3 pos=position;
float t=pos.y/uH; float sway=sin(uTime*1.1+pos.y*0.9)*0.12*t;
pos.x+=sway*uWind.x; pos.z+=sway*uWind.y;
gl_Position=projectionMatrix*modelViewMatrix*vec4(pos,1.0);}`;
const kelpFrag=`
uniform float uTime; varying vec2 vUv;
void main(){ float h=vUv.y;
vec3 base=vec3(0.03,0.16,0.11); vec3 tip=vec3(0.16,0.42,0.20);
vec3 col=mix(base,tip,h);
float stripe=smoothstep(0.45,0.55,abs(sin(vUv.x*18.0)));
col*=0.72+0.28*stripe;
float edge=smoothstep(0.0,0.12,vUv.x)*smoothstep(1.0,0.88,vUv.x);
float fade=smoothstep(0.0,0.05,h);
gl_FragColor=vec4(col,edge*fade*0.9);}`;
for(let i=0;i<26;i++){
const m=new THREE.ShaderMaterial({uniforms:kelpUniforms,vertexShader:kelpVert,fragmentShader:kelpFrag,side:THREE.DoubleSide,transparent:true,depthWrite:false});
const mesh=new THREE.Mesh(kelpGeo,m);
mesh.position.set((Math.random()*2-1)*(B.x-0.6),-B.y,(Math.random()*2-1)*(B.z-0.6));
mesh.rotation.y=Math.random()*Math.PI;mesh.scale.setScalar(0.7+Math.random()*0.9);
scene.add(mesh);
if(Math.random()<0.5){const c=mesh.clone();c.material=m;c.rotation.y+=Math.PI/2;c.scale.multiplyScalar(0.9);scene.add(c);}
}
/* ---------- coral fans ---------- */
const corals=[];
const fanMat=new THREE.MeshStandardMaterial({color:0xffffff,roughness:0.5,metalness:0,side:THREE.DoubleSide,transparent:true,opacity:0.92});
for(let i=0;i<4;i++){
const grp=new THREE.Group();
const cols=[0xff6f91,0xff9a5e,0xf2c76b,0x7fd0ff];
const col=new THREE.Color(cols[i]);
for(let j=0;j<5;j++){
const a=(j/5)*0.9-0.45;
const len=0.9+Math.random()*0.7;
const g=new THREE.TorusGeometry(len,0.035,6,20,Math.PI);
const m=new THREE.Mesh(g,fanMat.clone());m.material.color.copy(col);
m.rotation.z=a;m.position.y=len*0.5;grp.add(m);
}
grp.position.set((Math.random()*2-1)*(B.x-1.5),-B.y+0.1,(Math.random()*2-1)*(B.z-1.5));
grp.scale.setScalar(0.8+Math.random()*0.6);grp.userData.phase=Math.random()*7;
scene.add(grp);corals.push(grp);
}
/* ---------- fish ---------- */
function buildFishGeo(){
const body=new THREE.CylinderGeometry(0.001,0.12,0.7,10);body.rotateX(Math.PI/2);body.translate(0,0,0.05);
const tail=new THREE.ConeGeometry(0.16,0.34,4);tail.rotateX(-Math.PI/2);tail.translate(0,0,-0.45);
const dorsal=new THREE.BoxGeometry(0.02,0.2,0.3);dorsal.translate(0,0.13,0.05);
const finL=new THREE.BoxGeometry(0.02,0.09,0.18);finL.translate(0.12,-0.02,0.02);
const finR=finL.clone();finR.translateX(-0.24);
const geo=THREE.BufferGeometryUtils.mergeGeometries([body,tail,dorsal,finL,finR],true);
geo.computeVertexNormals();return geo;
}
class Fish{
constructor(geo,i){
this.i=i;this.speedMul=0.8+Math.random()*0.5;this.swimPhase=Math.random()*7;
const[c0,c1]=randColor();
const mat=new THREE.MeshPhysicalMaterial({color:c0,emissive:c1.clone().multiplyScalar(0.16),roughness:0.35,metalness:0.05,clearcoat:0.6,clearcoatRoughness:0.3});
this.mesh=new THREE.Mesh(geo,mat);this.mesh.castShadow=true;
this.pos=new THREE.Vector3((Math.random()*2-1)*B.x,(Math.random()*2-1)*B.y,(Math.random()*2-1)*B.z);
this.vel=new THREE.Vector3((Math.random()-0.5),(Math.random()-0.5)*0.4,(Math.random()-0.5)).normalize();
this.updaters=[];this.group=new THREE.Group();this.group.add(this.mesh);scene.add(this.group);
}
addUpdater(fn){this.updaters.push(fn);}
update(dt,time){for(const fn of this.updaters)fn(dt,time,this);}
}
// wagging tail via onBeforeCompile
function attachTail(fish){
fish.mesh.material.onBeforeCompile=(sh)=>{
sh.uniforms.uTime={value:0};fish.tailU=sh.uniforms.uTime;
sh.vertexShader=sh.vertexShader.replace('#include <begin_vertex>',`
#include <begin_vertex>
float _tw = sin(uTime*7.0 + ${fish.i.toFixed(2)}*1.3) * 0.55;
transformed.z += _tw * smoothstep(0.0,-0.55, position.z);
transformed.x *= 1.0 - 0.25*smoothstep(0.0,-0.55, position.z);`);
sh.vertexShader='uniform float uTime;\n'+sh.vertexShader;
};
fish.mesh.material.customProgramCacheKey=()=>'tail'+fish.i;
}
// swim undulation
function attachSwim(fish){
const u={value:0};fish.swimU=u;
fish.mesh.material.onBeforeCompile=(sh)=>{
sh.uniforms.uSwim=u;
sh.vertexShader=sh.vertexShader.replace('#include <begin_vertex>',`
#include <begin_vertex>
float _s = sin(position.z*3.2 + uSwim) * 0.035 * smoothstep(-0.35, 0.55, position.z);
transformed.x += _s;`);
sh.vertexShader='uniform float uSwim;\n'+sh.vertexShader;
};
fish.mesh.material.customProgramCacheKey=()=>'swim'+fish.i;
}
const fishGeo=buildFishGeo();
const fishes=[];
const FISH_COUNT=W<640?18:26;
for(let i=0;i<FISH_COUNT;i++){
const f=new Fish(fishGeo,i);attachTail(f);attachSwim(f);fishes.push(f);scene.add(f.group);
}
/* behaviour */
const _steer=new THREE.Vector3(),_tmp=new THREE.Vector3(),_axis=new THREE.Vector3();
const _look=new THREE.Matrix4();
const UP=new THREE.Vector3(0,1,0);
const steer=new THREE.Vector3();
function flock(f){
steer.set(0,0,0);
let sepN=0,aliN=0,cohN=0;const ali=new THREE.Vector3(),coh=new THREE.Vector3(),sep=new THREE.Vector3();
for(let j=0;j<fishes.length;j++){
if(j===f.i)continue;const o=fishes[j];const d=_tmp.subVectors(o.pos,f.pos);const dist=d.length();
if(dist<0.001)continue;
if(dist<0.9){sep.addScaledVector(d,-1/dist);sepN++;}
if(dist<2.2){ali.add(o.vel);aliN++;coh.add(o.pos);cohN++;}
}
if(sepN){sep.normalize();steer.addScaledVector(sep,1.6);}
if(aliN){ali.normalize();steer.addScaledVector(ali,0.9);}
if(cohN){coh.divideScalar(cohN);_tmp.subVectors(coh,f.pos).normalize();steer.addScaledVector(_tmp,0.6);}
}
function wander(f,t){
const n=Math.sin(t*0.3+f.i*1.7)*0.5+Math.cos(t*0.23+f.i*2.3)*0.5;
const e=Math.sin(t*0.19+f.i*0.9)*0.5+Math.cos(t*0.31+f.i*1.1)*0.5;
_tmp.set(n,e*0.4,0);_tmp.applyAxisAngle(UP,f.i*2.4);steer.add(_tmp.multiplyScalar(0.5));
}
function boundaries(f){
const m=0.7;
if(f.pos.x>B.x-m)steer.x-=1.2;else if(f.pos.x<-B.x+m)steer.x+=1.2;
if(f.pos.y>B.y-m)steer.y-=1.2;else if(f.pos.y<-B.y+m)steer.y+=1.2;
if(f.pos.z>B.z-m)steer.z-=1.2;else if(f.pos.z<-B.z+m)steer.z+=1.2;
steer.addScaledVector(_tmp.subVectors(home,f.pos),0.03);
}
function flee(f,p){
const dx=f.pos.x-p.x,dy=f.pos.y-p.y,dz=f.pos.z-p.z;
const d=Math.sqrt(dx*dx+dy*dy+dz*dz)||0.0001;
if(d<fleeRadius){const s=(1-d/fleeRadius);steer.x+=dx/d*s*10;steer.y+=dy/d*s*10;steer.z+=dz/d*s*10;}
}
/* ---------- bubbles ---------- */
const bt=bubbleTex();
const bubCount=120;
const bubPos=new Float32Array(bubCount*3),bubSpd=new Float32Array(bubCount),bubPh=new Float32Array(bubCount);
for(let i=0;i<bubCount;i++){bubPos[i*3]=(Math.random()*2-1)*B.x;bubPos[i*3+1]=-B.y+Math.random()*(B.y*2);bubPos[i*3+2]=(Math.random()*2-1)*B.z;
bubSpd[i]=0.4+Math.random()*1.1;bubPh[i]=Math.random()*7;}
const bubGeo=new THREE.BufferGeometry();bubGeo.setAttribute('position',new THREE.BufferAttribute(bubPos,3));
const bubMat=new THREE.PointsMaterial({map:bt,size:0.18,transparent:true,depthWrite:false,blending:THREE.AdditiveBlending,sizeAttenuation:true,color:0xcdefff,opacity:0.85});
const bubbles=new THREE.Points(bubGeo,bubMat);scene.add(bubbles);
/* ---------- light shafts ---------- */
const shafts=[];
const shaftMat=new THREE.ShaderMaterial({
transparent:true,depthWrite:false,blending:THREE.AdditiveBlending,side:THREE.DoubleSide,
uniforms:{uTime:{value:0}},
vertexShader:`varying vec2 vUv;void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
fragmentShader:`varying vec2 vUv;uniform float uTime;
void main(){float top=smoothstep(1.0,0.2,vUv.y);float bot=smoothstep(0.0,0.15,vUv.y);
float side=smoothstep(0.0,0.35,vUv.x)*smoothstep(1.0,0.65,vUv.x);
float flick=0.7+0.3*sin(uTime*0.7+vUv.x*8.0);
float a=top*bot*side*flick*0.16;gl_FragColor=vec4(vec3(0.72,0.9,1.0),a);}`
});
for(let i=0;i<3;i++){
const w=1.6+i*0.5;const g=new THREE.PlaneGeometry(w,B.y*2+w*1.4);
const m=new THREE.Mesh(g,shaftMat);m.position.set(-4+i*4,1.5,-3+i*1.5);m.rotation.x=-0.12;
m.renderOrder=2;scene.add(m);shafts.push(m);
}
/* ---------- pointer ray ---------- */
const raycaster=new THREE.Raycaster();
const plane=new THREE.Plane(new THREE.Vector3(0,0,1),0);
const ndc=new THREE.Vector2(0,0);
const pointerWorld=new THREE.Vector3();
let pointerActive=false,pointerIdle=0;
function updatePointerWorld(){
plane.normal.set(0,0,1);plane.constant=-camera.position.z;
raycaster.setFromCamera(ndc,camera);
if(!raycaster.ray.intersectPlane(plane,pointerWorld)){pointerWorld.set(camera.position.x,camera.position.y,camera.position.z);}
}
/* ---------- state / UI ---------- */
let simSpeed=1,fleeRadius=4.2,autoOrbit=false,introMode=true,scareLevel=0;
const el=id=>document.getElementById(id);
el('count').textContent=FISH_COUNT;
el('speed').addEventListener('input',e=>simSpeed=parseFloat(e.target.value));
el('flee').addEventListener('input',e=>fleeRadius=parseFloat(e.target.value));
el('orbitBtn').addEventListener('click',e=>{autoOrbit=!autoOrbit;e.currentTarget.setAttribute('aria-pressed',autoOrbit);});
el('feedBtn').addEventListener('click',()=>{
for(const f of fishes){f.vel.x+=(Math.random()-0.5)*6;f.vel.y+=(Math.random()-0.5)*4;f.vel.z+=(Math.random()-0.5)*6;}
scareLevel=1;
});
let started=false;
function start(){if(started)return;started=true;introMode=false;el('intro').classList.add('gone');setTimeout(()=>el('intro').remove(),900);}
addEventListener('keydown',start,{once:true});
el('startBtn').addEventListener('click',start);
el('intro').addEventListener('click',start);
addEventListener('pointermove',e=>{
ndc.x=(e.clientX/innerWidth)*2-1;ndc.y=-(e.clientY/innerHeight)*2+1;
if(started){pointerActive=true;pointerIdle=0;if(e.clientX>120||e.clientY>120)el('hint')?.classList.add('hide');}
},{passive:true});
addEventListener('pointerleave',()=>{pointerActive=false;});
/* ---------- loop ---------- */
const clock=new THREE.Clock();
let fpsAcc=0,fpsFrames=0;
const statusEl=el('status'),fpsEl=el('fps');
function animate(){
requestAnimationFrame(animate);
const dt=Math.min(clock.getDelta(),0.05);const time=clock.elapsedTime;
// ambient life
kelpUniforms.uTime.value=time;
shaftMat.uniforms.uTime.value=time;
caustic.material.map.offset.x=time*0.012;caustic.material.map.offset.y=time*0.008;
caustic.material.opacity=0.4+0.15*Math.sin(time*0.8);
for(const c of corals)c.rotation.z=Math.sin(time*0.6+c.userData.phase)*0.05;
// bubbles
{const p=bubGeo.attributes.position.array;
for(let i=0;i<bubCount;i++){p[i*3+1]+=bubSpd[i]*dt;p[i*3]+=Math.sin(time+bubPh[i])*0.004;
if(p[i*3+1]>B.y){p[i*3+1]=-B.y;p[i*3]=(Math.random()*2-1)*B.x;p[i*3+2]=(Math.random()*2-1)*B.z;}}
bubGeo.attributes.position.needsUpdate=true;}
// intro autopilot target
if(introMode){pointerWorld.set(Math.sin(time*0.5)*4.5,Math.sin(time*0.33)*2.2,Math.cos(time*0.4)*3.5);}
else if(pointerActive){updatePointerWorld();pointerIdle+=dt;}
else{pointerWorld.lerp(home,0.02);}
// fish
for(const f of fishes){
steer.set(0,0,0);
flock(f);wander(f,time);boundaries(f);flee(f,pointerWorld);
const maxSp=1.7*f.speedMul*simSpeed;
f.vel.addScaledVector(steer,dt*2.4);
if(f.vel.lengthSq()>maxSp*maxSp)f.vel.setLength(maxSp);
f.pos.addScaledVector(f.vel,dt);
f.pos.x=clamp(f.pos.x,-B.x,B.x);f.pos.y=clamp(f.pos.y,-B.y,B.y);f.pos.z=clamp(f.pos.z,-B.z,B.z);
f.group.position.copy(f.pos);
if(f.vel.lengthSq()>0.0004){
_tmp.copy(f.pos).add(f.vel);_look.lookAt(_tmp,f.pos,UP);
f.group.quaternion.slerp(_look.decompose(new THREE.Vector3(),f.group.quaternion,new THREE.Vector3())[1],0.12);
}
if(f.tailU)f.tailU.value=time;
if(f.swimU)f.swimU.value=time*6*f.speedMul;
f.update(dt,time);
}
// scare level -> status + camera shake
let near=0;for(const f of fishes){const d=f.pos.distanceTo(pointerWorld);if(d<fleeRadius)near++;}
scareLevel=Math.max(scareLevel*dt*0.0,clamp(near/FISH_COUNT*2,0,1));
statusEl.textContent=scareLevel>0.05?'startled':(pointerActive&&started?'watching':'calm');
statusEl.style.color=scareLevel>0.05?'var(--coral)':'var(--teal)';
// camera
let camTargetX=0;
if(autoOrbit)camTargetX=Math.sin(time*0.12)*5;
else if(started&&!introMode)camTargetX=ndc.x*2.2;
camera.position.x=lerp(camera.position.x,camTargetX,0.03);
camera.position.y=lerp(camera.position.y,1.5+ndc.y*0.6,0.03);
camera.position.z=14+Math.sin(time*0.1)*0.4;
const sh=scareLevel*0.06;
camera.position.x+=(Math.random()-0.5)*sh;camera.position.y+=(Math.random()-0.5)*sh;
camera.lookAt(0,0.2,0);
renderer.render(scene,camera);
// fps
fpsAcc+=dt;fpsFrames++;
if(fpsAcc>=0.5){fpsEl.textContent=Math.round(fpsFrames/fpsAcc);fpsAcc=0;fpsFrames=0;}
}
animate();
addEventListener('resize',()=>{W=innerWidth;H=innerHeight;camera.aspect=W/H;camera.updateProjectionMatrix();renderer.setSize(W,H);});
</script>
</body>
</html>