Interactive 3D aquariumOne-line
MiniMax M3.1 Flash
by MiniMaxReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 1 min 17 s
- Cost
- $0.0077
- Output tokens
- 12,797
- Spent thinking
- 3,745 tokens
- Speed
- 168.1 tok/s
- Console errors
- 0
- First token after
- 476 ms
- First visible text after
- 30.2 s
- Prompt tokens
- 613
- Code size
- 27.5 KB
- Finish reason
- stop
- Served by
- Fireworks
- Model id
minimax/minimax-m3.1-flash- Reasoning sent
{"effort":"medium"}- 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 · 739 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>Reef Tank — 3D Aquarium</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#03090e;overflow:hidden;
font-family:"Trebuchet MS",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#cfeef7}
#app{position:fixed;inset:0}
canvas{display:block}
#hud{position:fixed;left:16px;top:14px;z-index:5;pointer-events:none;
text-shadow:0 2px 10px rgba(0,0,0,.8)}
#hud h1{margin:0;font-size:19px;letter-spacing:.16em;font-weight:700;color:#e8fbff}
#hud p{margin:4px 0 0;font-size:12px;letter-spacing:.06em;opacity:.72}
#legend{position:fixed;right:14px;top:14px;z-index:5;pointer-events:none;text-align:right;
font-size:11px;letter-spacing:.05em;opacity:.65;line-height:1.7}
#overlay{position:fixed;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;
background:radial-gradient(ellipse at 50% 45%,rgba(4,30,42,.55),rgba(2,8,12,.9));
backdrop-filter:blur(2px);cursor:pointer;transition:opacity .5s ease}
#overlay.hide{opacity:0;pointer-events:none}
.card{text-align:center;padding:28px 34px;border-radius:16px;
border:1px solid rgba(120,220,240,.25);background:rgba(6,26,36,.55);
box-shadow:0 20px 60px rgba(0,0,0,.6);max-width:88vw}
.card .big{font-size:22px;letter-spacing:.14em;font-weight:700;color:#eafcff;margin-bottom:10px}
.card .small{font-size:13px;opacity:.78;line-height:1.7}
.pulse{display:inline-block;animation:p 1.6s ease-in-out infinite}
@keyframes p{0%,100%{opacity:.45}50%{opacity:1}}
#status{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:6;
font-size:11px;letter-spacing:.18em;opacity:.55;pointer-events:none;text-align:center}
@media (max-width:600px){
#hud h1{font-size:15px}
#legend{display:none}
.card .big{font-size:17px}
}
</style>
</head>
<body>
<div id="app"></div>
<div id="hud">
<h1>REEF TANK</h1>
<p>move the cursor near a fish — it darts away</p>
</div>
<div id="legend">
DEMO MODE · AUTOPILOT FISH<br>
CLICK TO TAKE THE CONTROLS
</div>
<div id="status">THREE.JS · PROCEDURAL AQUARIUM</div>
<div id="overlay">
<div class="card">
<div class="big"><span class="pulse">▶</span> Click or press a key to start</div>
<div class="small">A glass reef with schooling fish, swaying kelp and drifting bubbles.<br>
Move your mouse through the tank and the fish will scatter from your cursor.</div>
</div>
</div>
<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}
</script>
<script type="module">
import * as THREE from 'three';
/* ------------------------------------------------------------------ */
/* Setup */
/* ------------------------------------------------------------------ */
const app = document.getElementById('app');
const renderer = new THREE.WebGLRenderer({antialias:true, powerPreference:'high-performance'});
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
app.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x04121a);
scene.fog = new THREE.FogExp2(0x062430, 0.032);
const camera = new THREE.PerspectiveCamera(45, innerWidth/innerHeight, 0.1, 200);
camera.position.set(0, 3.4, 15.5);
const camTarget = new THREE.Vector3(0, 2.6, 0);
camera.lookAt(camTarget);
/* ------------------------------------------------------------------ */
/* Helpers / seeded random */
/* ------------------------------------------------------------------ */
let seed = 1337;
function rnd(){ seed = (seed*1664525 + 1013904223) % 4294967296; return seed/4294967296; }
function rr(a,b){ return a + (b-a)*rnd(); }
function pick(arr){ return arr[Math.floor(rnd()*arr.length)]; }
/* ------------------------------------------------------------------ */
/* Lighting */
/* ------------------------------------------------------------------ */
scene.add(new THREE.HemisphereLight(0x9fe9ff, 0x0a2430, 0.85));
const sun = new THREE.DirectionalLight(0xfff3d6, 1.5);
sun.position.set(4, 11, 6);
sun.castShadow = true;
sun.shadow.mapSize.set(1024,1024);
sun.shadow.camera.left=-9; sun.shadow.camera.right=9;
sun.shadow.camera.top=9; sun.shadow.camera.bottom=-9;
sun.shadow.camera.far=40; sun.shadow.bias=-0.0012;
scene.add(sun);
const back = new THREE.DirectionalLight(0x2ec6ff, 0.65);
back.position.set(-6, 4, -8);
scene.add(back);
const fill = new THREE.PointLight(0x66e0ff, 12, 22, 2);
fill.position.set(-4, 1.2, 3.5);
scene.add(fill);
/* ------------------------------------------------------------------ */
/* Tank geometry constants */
/* ------------------------------------------------------------------ */
const TW = 12, TH = 7, TD = 8; // tank inner width / height / depth
const WATER_TOP = TH - 0.75;
const BX = TW/2 - 0.6, BY0 = 0.75, BY1 = WATER_TOP - 0.6, BZ = TD/2 - 0.6;
/* ------------------------------------------------------------------ */
/* Procedural textures */
/* ------------------------------------------------------------------ */
function makeCanvas(w,h){ const c=document.createElement('canvas'); c.width=w;c.height=h; return c; }
const sandC = makeCanvas(512,512);
{
const ctx = sandC.getContext('2d');
ctx.fillStyle='#d8c49a'; ctx.fillRect(0,0,512,512);
for(let i=0;i<26000;i++){
const x=rnd()*512, y=rnd()*512, s=rnd()*2.1+0.4;
const t=rnd();
ctx.fillStyle = t<0.5 ? 'rgba(255,255,255,'+(0.05+rnd()*0.16)+')'
: 'rgba(90,70,40,'+(0.04+rnd()*0.14)+')';
ctx.beginPath(); ctx.arc(x,y,s,0,6.283); ctx.fill();
}
// ripple bands
ctx.globalAlpha=0.06;
for(let y=0;y<512;y+=4){
ctx.strokeStyle = (y/4)%2 ? '#ffffff' : '#7a6142';
ctx.beginPath();
for(let x=0;x<=512;x+=8){
const yy = y + Math.sin(x*0.05 + y*0.12)*2.2;
x===0 ? ctx.moveTo(x,yy) : ctx.lineTo(x,yy);
}
ctx.stroke();
}
ctx.globalAlpha=1;
}
const sandTex = new THREE.CanvasTexture(sandC);
sandTex.colorSpace = THREE.SRGBColorSpace;
sandTex.wrapS = sandTex.wrapT = THREE.RepeatWrapping;
sandTex.repeat.set(3,2);
const causticC = makeCanvas(256,256);
{
const ctx = causticC.getContext('2d');
const img = ctx.createImageData(256,256);
for(let y=0;y<256;y++)for(let x=0;x<256;x++){
const a = x*0.075, b = y*0.09;
let v = Math.sin(a + Math.sin(b)*1.6) + Math.sin(b + Math.cos(a*0.8)*1.6) + Math.sin((a+b)*0.55);
v = Math.max(0, v/3);
v = Math.pow(v, 5.0);
const i=(y*256+x)*4;
img.data[i]=255; img.data[i+1]=255; img.data[i+2]=235;
img.data[i+3]=Math.min(255, v*255*1.4)|0;
}
ctx.putImageData(img,0,0);
}
const causticTex = new THREE.CanvasTexture(causticC);
causticTex.wrapS = causticTex.wrapT = THREE.RepeatWrapping;
causticTex.repeat.set(3,2.2);
const gradC = makeCanvas(4,256);
{
const ctx = gradC.getContext('2d');
const gr = ctx.createLinearGradient(0,0,0,256);
gr.addColorStop(0,'#0b3b52'); gr.addColorStop(0.45,'#062430'); gr.addColorStop(1,'#020a10');
ctx.fillStyle=gr; ctx.fillRect(0,0,4,256);
}
const gradTex = new THREE.CanvasTexture(gradC);
gradTex.colorSpace = THREE.SRGBColorSpace;
/* ------------------------------------------------------------------ */
/* Backdrop + tank shell */
/* ------------------------------------------------------------------ */
const backdrop = new THREE.Mesh(
new THREE.PlaneGeometry(90,50),
new THREE.MeshBasicMaterial({map:gradTex, fog:false})
);
backdrop.position.set(0,6,-22);
scene.add(backdrop);
// floor
const floorGeo = new THREE.PlaneGeometry(TW, TD, 70, 46);
{
const pos = floorGeo.attributes.position;
for(let i=0;i<pos.count;i++){
const x=pos.getX(i), y=pos.getY(i);
const h = Math.sin(x*0.42)*0.14 + Math.cos(y*0.5)*0.13 + Math.sin((x+y)*0.22)*0.1 + rr(-0.03,0.03);
pos.setZ(i,h);
}
floorGeo.computeVertexNormals();
}
const floor = new THREE.Mesh(floorGeo, new THREE.MeshStandardMaterial({
map:sandTex, color:0xf0dcb4, roughness:0.95, metalness:0.0
}));
floor.rotation.x = -Math.PI/2;
floor.position.y = 0;
floor.receiveShadow = true;
scene.add(floor);
// caustic light layer over the sand
const causticPlane = new THREE.Mesh(
new THREE.PlaneGeometry(TW, TD),
new THREE.MeshBasicMaterial({
map:causticTex, transparent:true, blending:THREE.AdditiveBlending,
depthWrite:false, opacity:0.5, fog:false
})
);
causticPlane.rotation.x = -Math.PI/2;
causticPlane.position.y = 0.09;
scene.add(causticPlane);
// glass walls (subtle transparent shell) + frame
const glassMat = new THREE.MeshPhysicalMaterial({
color:0xbdf1ff, transparent:true, opacity:0.09, roughness:0.05, metalness:0,
side:THREE.DoubleSide, depthWrite:false
});
const glassBox = new THREE.Mesh(new THREE.BoxGeometry(TW+0.4, TH+0.4, TD+0.4), glassMat);
glassBox.position.y = TH/2;
scene.add(glassBox);
const frameMat = new THREE.MeshStandardMaterial({color:0x123340, roughness:0.45, metalness:0.75});
const edge = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(TW+0.42, TH+0.42, TD+0.42)),
new THREE.LineBasicMaterial({color:0x2e88a6, transparent:true, opacity:0.75})
);
edge.position.y = TH/2;
scene.add(edge);
// corner posts
for(const sx of [-1,1]) for(const sz of [-1,1]){
const post = new THREE.Mesh(new THREE.BoxGeometry(0.16,TH+0.4,0.16), frameMat);
post.position.set(sx*(TW/2+0.2), TH/2, sz*(TD/2+0.2));
scene.add(post);
}
// water surface
const waterMat = new THREE.MeshStandardMaterial({
color:0x5fd8ff, transparent:true, opacity:0.28, roughness:0.12, metalness:0.5,
side:THREE.DoubleSide
});
const water = new THREE.Mesh(new THREE.PlaneGeometry(TW, TD, 1,1), waterMat);
water.rotation.x = -Math.PI/2;
water.position.y = WATER_TOP;
scene.add(water);
/* ------------------------------------------------------------------ */
/* Rocks, corals, pebbles */
/* ------------------------------------------------------------------ */
const rockMat = new THREE.MeshStandardMaterial({color:0x6d7f86, roughness:0.9, flatShading:true});
for(let i=0;i<9;i++){
const g = new THREE.IcosahedronGeometry(rr(0.25,0.85), 1);
const p = g.attributes.position;
for(let k=0;k<p.count;k++){
const s = 1 + rr(-0.18,0.18);
p.setXYZ(k, p.getX(k)*s, p.getY(k)*s*0.8, p.getZ(k)*s);
}
g.computeVertexNormals();
const m = new THREE.Mesh(g, rockMat);
m.position.set(rr(-BX,BX), rr(0.05,0.35), rr(-BZ,BZ));
m.rotation.set(rr(0,3),rr(0,6),rr(0,3));
m.castShadow = true; m.receiveShadow = true;
scene.add(m);
}
const coralColors = [0xff6f61,0xffa24b,0xc86bff,0x4fd6c9,0xff5fa2];
for(let i=0;i<6;i++){
const g = new THREE.Group();
const col = pick(coralColors);
const mat = new THREE.MeshStandardMaterial({color:col, roughness:0.65, flatShading:true});
const branches = 3 + Math.floor(rnd()*3);
for(let b=0;b<branches;b++){
const h = rr(0.35,0.95);
const br = new THREE.Mesh(new THREE.CylinderGeometry(0.035,0.11,h,6), mat);
br.position.set(rr(-0.2,0.2), h/2, rr(-0.2,0.2));
br.rotation.set(rr(-0.35,0.35),0,rr(-0.35,0.35));
br.castShadow=true;
g.add(br);
const tip = new THREE.Mesh(new THREE.SphereGeometry(0.07,8,6), mat);
tip.position.set(br.position.x + Math.sin(br.rotation.z)*h*0.5, h, br.position.z);
g.add(tip);
}
g.position.set(rr(-BX+0.8,BX-0.8), 0.1, rr(-BZ+0.7,BZ-0.7));
scene.add(g);
}
const pebbleGeo = new THREE.SphereGeometry(0.07,8,6);
const pebbleMat = new THREE.MeshStandardMaterial({color:0xbfae8c, roughness:0.85});
const pebbles = new THREE.InstancedMesh(pebbleGeo, pebbleMat, 120);
{
const m = new THREE.Matrix4(), q = new THREE.Quaternion(), s = new THREE.Vector3(), p = new THREE.Vector3();
for(let i=0;i<120;i++){
p.set(rr(-BX,BX), 0.03, rr(-BZ,BZ));
q.setFromEuler(new THREE.Euler(rr(0,3),rr(0,6),rr(0,3)));
const sc = rr(0.5,1.6); s.set(sc,sc*0.6,sc);
m.compose(p,q,s); pebbles.setMatrixAt(i,m);
}
}
pebbles.receiveShadow = true;
scene.add(pebbles);
/* ------------------------------------------------------------------ */
/* Kelp / seaweed */
/* ------------------------------------------------------------------ */
const kelp = [];
function makeKelp(x,z,h,color){
const group = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({color, roughness:0.6, metalness:0.05, side:THREE.DoubleSide});
const segs = 6;
for(let i=0;i<segs;i++){
const sh = h/segs;
const w = 0.26 * (1 - i/segs*0.55);
const blade = new THREE.Mesh(new THREE.PlaneGeometry(w, sh, 1, 6), mat);
blade.position.set(0, sh*i + sh/2, 0);
blade.rotation.y = rnd()*Math.PI;
const grp = new THREE.Group();
grp.add(blade);
grp.position.y = i===0?0:0;
group.add(grp);
}
group.position.set(x, 0.05, z);
scene.add(group);
kelp.push({group, phase:rnd()*6.28, amp:rr(0.06,0.16), base:group.rotation.z});
return group;
}
for(let i=0;i<26;i++){
const h = rr(1.2,3.4);
makeKelp(rr(-BX+0.4,BX-0.4), rr(-BZ+0.4,BZ-0.4), h, pick([0x2f8f4e,0x3aa35c,0x1f7a4a,0x4cb36a,0x2b7f5c]));
}
// a few tall background plants
for(let i=0;i<10;i++){
const h = rr(2.6,4.4);
makeKelp(rr(-BX+0.6,BX-0.6), rr(-BZ,BZ-0.3), h, pick([0x1b6a52,0x246b45,0x175e4d]));
}
/* ------------------------------------------------------------------ */
/* Fish */
/* ------------------------------------------------------------------ */
const SPECIES = [
{name:'clown', body:0xff8a1f, fin:0xffffff, accent:0xffffff, size:0.85, tail:'fork'},
{name:'blue', body:0x2f7bff, fin:0x9fd8ff, accent:0x0d3f8a, size:0.75, tail:'fork'},
{name:'yellow', body:0xffd23f, fin:0xfff2a8, accent:0xc98b00, size:1.0, tail:'fan'},
{name:'silver', body:0xc9dbe6, fin:0xe8f6ff, accent:0x6f95a8, size:1.25, tail:'fork'},
{name:'pink', body:0xff77b0, fin:0xffd3e6, accent:0xb03c72, size:0.7, tail:'fan'}
];
const bodyGeo = new THREE.SphereGeometry(1, 26, 18);
const noseGeo = new THREE.ConeGeometry(0.19, 0.3, 14);
const eyeGeo = new THREE.SphereGeometry(0.055, 10, 8);
const pupilGeo= new THREE.SphereGeometry(0.03, 8, 6);
function tailShape(kind){
const s = new THREE.Shape();
if(kind==='fork'){
s.moveTo(0,0);
s.quadraticCurveTo(0.18,0.30, 0.62,0.52);
s.quadraticCurveTo(0.30,0.06, 0.30,0);
s.quadraticCurveTo(0.30,-0.06, 0.62,-0.52);
s.quadraticCurveTo(0.18,-0.30, 0,0);
} else {
s.absarc(0,0,0.55, Math.PI/2, -Math.PI/2, true);
s.lineTo(0,0);
}
return new THREE.ShapeGeometry(s, 16);
}
const tailGeos = {fork:tailShape('fork'), fan:tailShape('fan')};
const dorsalGeo = (()=>{ const s=new THREE.Shape(); s.moveTo(-0.42,0); s.quadraticCurveTo(0,0.42,0.42,0); s.lineTo(-0.42,0); return new THREE.ShapeGeometry(s,12); })();
const ventralGeo = (()=>{ const s=new THREE.Shape(); s.moveTo(-0.3,0); s.quadraticCurveTo(0,-0.3,0.3,0); s.lineTo(-0.3,0); return new THREE.ShapeGeometry(s,12); })();
const fishList = [];
function makeFish(spec){
const g = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({color:spec.body, roughness:0.35, metalness:0.25});
const finMat = new THREE.MeshStandardMaterial({color:spec.fin, roughness:0.4, metalness:0.1,
side:THREE.DoubleSide, transparent:true, opacity:0.92});
const accMat = new THREE.MeshStandardMaterial({color:spec.accent, roughness:0.4, metalness:0.1});
const body = new THREE.Mesh(bodyGeo, bodyMat);
body.scale.set(0.235, 0.30, 0.60);
body.castShadow = true;
g.add(body);
const nose = new THREE.Mesh(noseGeo, bodyMat);
nose.rotation.x = Math.PI/2;
nose.position.z = 0.66;
nose.scale.set(1,1.1,1);
g.add(nose);
// belly shading
const belly = new THREE.Mesh(bodyGeo, new THREE.MeshStandardMaterial({
color:0xffffff, roughness:0.5, transparent:true, opacity:0.35}));
belly.scale.set(0.24,0.31,0.61);
belly.position.y = -0.045;
g.add(belly);
// tail
const tailPivot = new THREE.Group();
const tail = new THREE.Mesh(tailGeos[spec.tail], finMat);
tail.position.z = -0.58;
tail.rotation.y = Math.PI/2;
tail.scale.setScalar(0.95);
tailPivot.add(tail);
g.add(tailPivot);
// dorsal
const dorsal = new THREE.Mesh(dorsalGeo, finMat);
dorsal.position.set(0, 0.24, -0.02);
dorsal.scale.set(0.7,0.7,1);
g.add(dorsal);
const ventral = new THREE.Mesh(ventralGeo, finMat);
ventral.position.set(0,-0.24,-0.06);
ventral.rotation.z = Math.PI;
ventral.scale.set(0.6,0.6,1);
g.add(ventral);
// pectoral fins
const pecs = [];
for(const s of [-1,1]){
const pivot = new THREE.Group();
pivot.position.set(s*0.19, 0.02, 0.16);
const fin = new THREE.Mesh(new THREE.CircleGeometry(0.13, 12), finMat);
fin.scale.set(1,0.6,1);
fin.rotation.y = s*Math.PI/2;
pivot.add(fin);
g.add(pivot);
pecs.push(pivot);
}
// eyes
for(const s of [-1,1]){
const e = new THREE.Mesh(eyeGeo, new THREE.MeshStandardMaterial({color:0xf8f8f8, roughness:0.25}));
e.position.set(s*0.155, 0.07, 0.42);
g.add(e);
const p = new THREE.Mesh(pupilGeo, new THREE.MeshStandardMaterial({color:0x0a0a0a, roughness:0.15}));
p.position.set(s*0.20, 0.07, 0.45);
g.add(p);
}
// stripe band for clownfish look
if(spec.name==='clown'){
for(let i=0;i<3;i++){
const band = new THREE.Mesh(bodyGeo, accMat);
band.scale.set(0.242,0.305,0.09);
band.position.set(0,0,0.32 - i*0.28);
g.add(band);
}
} else {
const band = new THREE.Mesh(bodyGeo, accMat);
band.scale.set(0.243,0.12,0.5);
band.position.set(0,0.11,0.0);
g.add(band);
}
g.scale.setScalar(spec.size);
scene.add(g);
const fish = {
group:g, spec, tailPivot, pecs,
pos:new THREE.Vector3(rr(-BX,BX), rr(BY0,BY1), rr(-BZ,BZ)),
vel:new THREE.Vector3(rr(-1,1),rr(-0.2,0.2),rr(-1,1)).normalize().multiplyScalar(0.8),
target:new THREE.Vector3(),
panic:0, phase:rnd()*6.28, speedBase:rr(0.9,1.5), wanderT:0
};
g.position.copy(fish.pos);
pickNewTarget(fish);
fishList.push(fish);
return fish;
}
function pickNewTarget(f){
f.target.set(rr(-BX,BX), rr(BY0,BY1), rr(-BZ,BZ));
f.wanderT = rr(2,6);
}
for(let i=0;i<15;i++) makeFish(SPECIES[i % SPECIES.length]);
/* ------------------------------------------------------------------ */
/* Bubbles + marine snow */
/* ------------------------------------------------------------------ */
const bubbleGeo = new THREE.SphereGeometry(0.06, 10, 8);
const bubbleMat = new THREE.MeshPhysicalMaterial({
color:0xd9f6ff, transparent:true, opacity:0.45, roughness:0.05, metalness:0,
transmission:0.0, clearcoat:1
});
const bubbles = new THREE.InstancedMesh(bubbleGeo, bubbleMat, 90);
const bData = [];
for(let i=0;i<90;i++){
bData.push({
x:rr(-BX+0.3,BX-0.3), y:rr(0,WATER_TOP), z:rr(-BZ+0.3,BZ-0.3),
r:rr(0.5,2.4), s:rr(0.5,1.8), ph:rnd()*6.28
});
}
scene.add(bubbles);
const snowCount = 380;
const snowPos = new Float32Array(snowCount*3);
const snowVel = new Float32Array(snowCount);
for(let i=0;i<snowCount;i++){
snowPos[i*3]=rr(-BX,BX); snowPos[i*3+1]=rr(0,WATER_TOP); snowPos[i*3+2]=rr(-BZ,BZ);
snowVel[i]=rr(0.04,0.16);
}
const snowGeo = new THREE.BufferGeometry();
snowGeo.setAttribute('position', new THREE.BufferAttribute(snowPos,3));
const snow = new THREE.Points(snowGeo, new THREE.PointsMaterial({
color:0xcdeeff, size:0.035, transparent:true, opacity:0.55, depthWrite:false
}));
scene.add(snow);
/* ------------------------------------------------------------------ */
/* Mouse interaction */
/* ------------------------------------------------------------------ */
const raycaster = new THREE.Raycaster();
const ndc = new THREE.Vector2(0,0);
let pointerActive = false;
let interactive = false;
let mouseNX = 0, mouseNY = 0;
const marker = new THREE.Mesh(
new THREE.TorusGeometry(0.55, 0.025, 8, 40),
new THREE.MeshBasicMaterial({color:0x7ce7ff, transparent:true, opacity:0.0, depthWrite:false, fog:false})
);
scene.add(marker);
const markerCore = new THREE.Mesh(
new THREE.SphereGeometry(0.07,10,8),
new THREE.MeshBasicMaterial({color:0xbdf3ff, transparent:true, opacity:0.0, depthWrite:false, fog:false})
);
scene.add(markerCore);
addEventListener('pointermove', e=>{
mouseNX = (e.clientX/innerWidth)*2-1;
mouseNY = -(e.clientY/innerHeight)*2+1;
ndc.set(mouseNX, mouseNY);
pointerActive = true;
});
addEventListener('pointerdown', start);
addEventListener('keydown', start);
function start(){
if(interactive) return;
interactive = true;
document.getElementById('overlay').classList.add('hide');
document.getElementById('legend').innerHTML = 'CONTROLS ACTIVE<br>MOVE CURSOR TO SCARE THE FISH';
document.getElementById('status').textContent = 'MOVE YOUR MOUSE THROUGH THE TANK';
}
addEventListener('resize', ()=>{
camera.aspect = innerWidth/innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
/* ------------------------------------------------------------------ */
/* Simulation */
/* ------------------------------------------------------------------ */
const tmpA = new THREE.Vector3(), tmpB = new THREE.Vector3(), tmpC = new THREE.Vector3();
const dummy = new THREE.Object3D();
const rayPlane = new THREE.Plane(new THREE.Vector3(0,0,1), 0);
const fleePoint = new THREE.Vector3();
let time = 0;
const FLEE_RADIUS = 2.35; // world units around the cursor ray
const FLEE_RADIUS2 = FLEE_RADIUS*FLEE_RADIUS;
function updateFish(dt){
// camera ray from pointer
raycaster.setFromCamera(ndc, camera);
const ray = raycaster.ray;
for(const f of fishList){
// --- flee from cursor -------------------------------------------------
let fleeing = false;
if(interactive && pointerActive){
const d2 = ray.distanceSqToPoint(f.pos);
if(d2 < FLEE_RADIUS2){
fleeing = true;
f.panic = Math.min(1, f.panic + dt*3.2);
}
}
f.panic = Math.max(0, f.panic - dt*0.75);
const force = tmpA.set(0,0,0);
// seek wander target
f.wanderT -= dt;
const distToTarget = f.pos.distanceTo(f.target);
if(distToTarget < 0.9 || f.wanderT < 0){ pickNewTarget(f); }
tmpB.copy(f.target).sub(f.pos).normalize();
force.addScaledVector(tmpB, 0.55);
// separation & cohesion (loose schooling)
tmpC.set(0,0,0);
let neighbors = 0;
for(const o of fishList){
if(o===f) continue;
const d = f.pos.distanceTo(o.pos);
if(d < 1.5){ tmpC.addScaledVector(tmpB.copy(o.pos).sub(f.pos).normalize(), (1.5-d)*0.9); neighbors++; }
}
force.add(tmpC);
// flee impulse
if(fleeing){
ray.closestPointToPoint(f.pos, tmpB);
tmpC.copy(f.pos).sub(tmpB);
if(tmpC.lengthSq() < 1e-6) tmpC.set(0,0,1);
tmpC.normalize();
force.addScaledVector(tmpC, 9.0 * (0.4 + f.panic));
}
// wall avoidance
const margin = 1.0;
if(f.pos.x > BX-margin) force.x -= (f.pos.x-(BX-margin))*5;
if(f.pos.x < -BX+margin) force.x -= (f.pos.x-(-BX+margin))*5;
if(f.pos.z > BZ-margin) force.z -= (f.pos.z-(BZ-margin))*5;
if(f.pos.z < -BZ+margin) force.z -= (f.pos.z-(-BZ+margin))*5;
if(f.pos.y > BY1-margin) force.y -= (f.pos.y-(BY1-margin))*6;
if(f.pos.y < BY0+margin*0.6) force.y += ((BY0+margin*0.6)-f.pos.y)*6;
// gentle vertical bob
force.y += Math.sin(time*1.4 + f.phase)*0.25;
f.vel.addScaledVector(force, dt);
// speed clamp
const maxSpeed = f.speedBase*(1 + f.panic*2.6);
const minSpeed = f.speedBase*0.45;
let sp = f.vel.length();
if(sp > maxSpeed) f.vel.multiplyScalar(maxSpeed/sp);
else if(sp < minSpeed) f.vel.multiplyScalar(minSpeed/Math.max(sp,0.001));
f.pos.addScaledVector(f.vel, dt);
f.pos.x = THREE.MathUtils.clamp(f.pos.x, -BX-0.35, BX+0.35);
f.pos.y = THREE.MathUtils.clamp(f.pos.y, BY0-0.15, BY1+0.2);
f.pos.z = THREE.MathUtils.clamp(f.pos.z, -BZ-0.25, BZ+0.25);
f.group.position.copy(f.pos);
// orient
tmpB.copy(f.pos).add(f.vel);
dummy.position.copy(f.pos);
dummy.up.set(0,1,0);
dummy.lookAt(tmpB);
f.group.quaternion.slerp(dummy.quaternion, Math.min(1, dt*6));
// fin animation
const speed = f.vel.length();
const beat = time*(6 + speed*4) + f.phase;
f.tailPivot.rotation.y = Math.sin(beat)*0.45;
f.pecs[0].rotation.z = Math.sin(beat*0.9)*0.7 - 0.3;
f.pecs[1].rotation.z = -Math.sin(beat*0.9)*0.7 + 0.3;
f.group.rotation.z += Math.sin(beat*0.5)*0.02;
}
}
function updateEnvironment(dt){
// kelp sway
for(const k of kelp){
k.group.rotation.z = k.base + Math.sin(time*0.9 + k.phase)*k.amp;
k.group.rotation.x = Math.sin(time*0.65 + k.phase*1.3)*k.amp*0.6;
}
// caustics drift
causticTex.offset.x = Math.sin(time*0.14)*0.12 + time*0.012;
causticTex.offset.y = Math.cos(time*0.11)*0.1 + time*0.008;
causticPlane.material.opacity = 0.38 + Math.sin(time*0.7)*0.08;
// bubbles
const m = new THREE.Matrix4();
for(let i=0;i<bData.length;i++){
const b = bData[i];
b.y += b.r*dt*0.35;
b.x += Math.sin(time*1.6+b.ph)*0.0035;
if(b.y > WATER_TOP-0.05){ b.y = 0.05; b.x = rr(-BX+0.3,BX-0.3); b.z = rr(-BZ+0.3,BZ-0.3); }
const s = b.s*(0.8+0.2*Math.sin(time*3+b.ph));
m.makeScale(s,s,s);
m.setPosition(b.x + Math.sin(time*2+b.ph)*0.05, b.y, b.z);
bubbles.setMatrixAt(i,m);
}
bubbles.instanceMatrix.needsUpdate = true;
// marine snow
const arr = snowGeo.attributes.position.array;
for(let i=0;i<snowCount;i++){
arr[i*3+1] -= snowVel[i]*dt;
arr[i*3] += Math.sin(time*0.4 + i)*0.0012;
if(arr[i*3+1] < 0){ arr[i*3+1] = WATER_TOP; arr[i*3] = rr(-BX,BX); arr[i*3+2] = rr(-BZ,BZ); }
}
snowGeo.attributes.position.needsUpdate = true;
// gentle camera parallax
const tx = interactive ? mouseNX*1.4 : Math.sin(time*0.22)*0.9;
const ty = 3.4 + (interactive ? mouseNY*0.6 : Math.sin(time*0.17)*0.35);
camera.position.x += (tx - camera.position.x)*Math.min(1, dt*2.2);
camera.position.y += (ty - camera.position.y)*Math.min(1, dt*2.2);
camera.lookAt(camTarget);
// cursor marker in the tank
if(interactive && pointerActive && raycaster.ray.intersectPlane(rayPlane, fleePoint)){
const inside = Math.abs(fleePoint.x) < BX+1 && Math.abs(fleePoint.z) < BZ+1 && fleePoint.y > 0 && fleePoint.y < WATER_TOP;
const target = inside ? 0.55 : 0.0;
marker.material.opacity += (target - marker.material.opacity)*Math.min(1, dt*6);
markerCore.material.opacity += ((inside?0.6:0) - markerCore.material.opacity)*Math.min(1, dt*6);
if(inside){
marker.position.copy(fleePoint);
marker.quaternion.copy(camera.quaternion);
const s = 1 + Math.sin(time*4)*0.12;
marker.scale.setScalar(s);
markerCore.position.copy(fleePoint);
markerCore.scale.setScalar(0.9+Math.sin(time*5)*0.15);
}
} else {
marker.material.opacity *= 0.9;
markerCore.material.opacity *= 0.9;
}
}
/* ------------------------------------------------------------------ */
/* Loop */
/* ------------------------------------------------------------------ */
const clock = new THREE.Clock();
function animate(){
requestAnimationFrame(animate);
let dt = Math.min(clock.getDelta(), 0.05);
time += dt;
updateFish(dt);
updateEnvironment(dt);
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>Reasoning trace (3,745 tokens)
Let me build a nice 3D aquarium with Three.js. Features: glass tank, sand, plants, bubbles, multiple fish (procedurally built from primitives), lighting, caustic light, mouse raycast -> fish flee away.
Fish should swim autonomously (AI demo mode) and flee from mouse pointer. Also "Click or press a key to start" overlay.
Let me design:
- Scene, fog, renderer with shadows maybe (keep perf moderate).
- Tank: box with transparent glass walls (MeshPhysicalMaterial transmission might be heavy; use transparent opacity).
- Sand floor with slight noise displacement.
- Rocks.
- Plants: procedurally generated seaweed via tube geometry / curves, swaying animation.
- Bubbles: small spheres rising.
- Fish: several species built from scaled spheres (body, tail via cone-ish shapes), each with a tail wiggle animation. Use Object3D groups.
- Behavior: boids-lite steering: each fish has wander target, avoid walls, avoid mouse point in 3D (project mouse ray into tank), when close within radius -> accelerate away.
- Mouse pointer: raycast against tank interior plane (z = fish depth) — simpler: use raycaster against a plane at z=0 (tank center plane) to get world point, and also handle x/y. Actually fish swim in 3D volume; compute distance in 3D from ray point to fish, scaled. Use raycaster setFromCamera, then intersect with a sphere around the ray? Better: compute closest point on ray to fish, distance. If distance < fleeRadius, push away.
Also camera follows mouse slightly for parallax.
Overlay: "Click or press a key to start" -> starts demo mode with AI playing (fish swimming automatically). After click, mouse control active.
Let's write good fish geometry: use a lathe or scaled sphere for body, tail fin from a flattened cone, dorsal fin, pectoral fins, eyes. Colors varied. Animate tail via rotation.
Plant: create a seaweed using a CatmullRomCurve3 and TubeGeometry with sway in vertex shader? Simpler: group of ribbons (PlaneGeometry with segments) with sine sway rotation. I'll do tube curves with per-frame rotation sway.
Caustics on sand: use a shader-modified material? Could add a canvas-generated texture with animated offset for the sand (light patterns). I'll generate a caustic texture procedurally with canvas and animate texture offset + emissive overlay.
Let me keep it performant: ~12 fish, bubbles instanced.
Water surface at top: plane with transparent material and animated normal via canvas texture offset.
Lighting: hemisphere + directional with shadows off maybe, plus point light.
Write code.
Ray-based flee: for each fish, compute closest point on mouse ray. Raycaster.ray.distanceSqToPoint(fish.position) gives squared distance from ray to point. If < R^2, compute direction away: point - closestPointOnRay. Plus fish should also keep fleeing for a while (panic timer).
Also avoid walls: clamp inside bounds with steering.
Boids: separation/cohesion/alignment lightly for schooling among same species.
Fish movement: velocity steering toward target + wander; update rotation to face velocity (lookAt with up). Fish model faces +Z? I'll build body along +X? Let's build along +Z so lookAt works naturally (object's +Z faces target). Actually Object3D.lookAt makes -Z? For cameras -Z. For regular objects lookAt rotates +Z toward target. Yes, Object3D.lookAt makes the object's +Z axis point at target.
Build fish: body sphere scaled (0.35 x 0.5 x 0.9) along Z; tail at z=-0.9; head z=+0.5 with mouth; eyes at z=0.45, x=±0.18.
Tail wiggle: rotate tail fin around Y axis sinusoidally; also yaw body slight.
Fins: use ShapeGeometry for tail (flat triangle-ish shape in XY plane) at z=-0.9.
Species variants: color palettes, sizes, and tail shapes (forked vs round).
Let me write the code carefully.
Caustics texture: canvas 256, draw overlapping radial gradients with 'lighter' composite; animate offset. Use it as emissiveMap-ish on sand material with additive blending plane? Simpler: sand material map = sand texture (procedural noise canvas), and add a separate transparent plane above sand with caustic texture, additive blending, animated.
Bubbles: instanced mesh of small spheres, rising, wrapping, with slight x sway.
Also add small "snow" particles (marine snow) as points.
Godrays? skip.
Also tank glass: BoxGeometry with MeshPhysicalMaterial transmission=1 might look great but expensive; use transparent MeshPhysicalMaterial with opacity 0.12, roughness 0, plus edges (LineSegments of EdgesGeometry) for glass frame. Front wall could be omitted to see clearly — but keep subtle.
Let me code.
Resize handling.
Overlay start message with CSS; on click/pointerdown/keydown hide and set interactive = true (mouse flee active). But before interaction, the AI plays: fish swim normally (demo). Also show "Demo mode" hint.
Let me write the HTML.
Procedural sand texture: canvas with noise speckles.
Caustic canvas: use random blurred circles? I'll do a simple function generating pattern via sin waves in image data for a nice caustic look:
for each pixel, v = sin(x*0.08 + t) etc. But we animate with texture offset instead. Generate static caustic canvas: combine of several sine-based wave functions producing sharp bright lines. I'll do:
value = pow(max(0, sin(a)+sin(b)+sin(c)) /3, 4) style. Let's implement.
Now the code.
Also water surface plane at y = waterTop, with slight wave via shader? Use MeshPhysicalMaterial transparent with normal map animated offset. Fine.
Floor: sand mound — use PlaneGeometry with vertex displacement noise (sin/cos) for dunes.
Tank dimensions: width 12, height 7, depth 8. Water fill y from 0 to 6.
Camera at (0, 3, 14) looking at (0,2.5,0), fov 45.
Fish swim region: x ±5.4, y 0.8..5.5, z ±3.5.
Sway plants.
Let me write.
Flee: ray from camera through mouse NDC. Compute per fish closest point.
Also add "startle" radius visually? Maybe a subtle ring marker at mouse 3D point. Could show a glowing sphere at the ray point at fish depth — approximate using intersection with plane z=0. That's nice visual feedback: a small ripple ring at that location. I'll add a ring mesh (torus) at intersection with z=0 plane, fading.
Let's write the code now.
Fish flee behavior implementation:
```
f.panic = 0;
const d2 = ray.distanceSqToPoint(f.pos);
if(d2 < R*R) { f.panic = 1; // direction away
const cp = ray.closestPointToPoint(f.pos, tmp);
dir = f.pos - cp; normalize;
}
```
Steering forces: seek target (wander point), panic force, wall avoidance, separation.
Wander: each fish has target point; when reached (or timer), pick new random target inside bounds.
Speed: normal 0.8-1.4 units/s, panic up to 4.
Update: vel += force*dt; clamp speed; pos += vel*dt; rotate group to face vel: use quaternion slerp toward lookAt quaternion. Compute matrix lookAt: use dummy Object3D.lookAt(target) then slerp.
Tail animation speed scales with speed.
Alright, write.
Also plants sway using rotation.z = sin(time*speed + phase)*0.1.
Bubbles: instanced mesh 120 spheres.
Marine snow points: 400 points with PointsMaterial small size, drifting down.
Write final code. Careful with three r170 API: `renderer.outputColorSpace = THREE.SRGBColorSpace`, `renderer.toneMapping = THREE.ACESFilmicToneMapping`.
Let's produce the file.
For fish geometry reuse: create geometries once (body sphere, tail shape per species type, fins), materials per color.
I'll create a makeFish(species) returning group + userData with tail object etc.
Body: SphereGeometry(1, 24, 16) scaled (0.28,0.38,0.62)? Let's define body via scaled sphere: scale (0.22, 0.3, 0.55) -> elongated. Plus head taper: use a cone? Use LatheGeometry for a nicer fish body profile: points from tail to head: (0.0,0)... Actually lathe axis is Y; I'd rotate. Alternative: use a custom Shape? Simpler: use two spheres: body sphere scaled, plus a smaller sphere near head slightly smaller -> tapered. Use a cone for nose: ConeGeometry(0.18, 0.3) rotated to point +Z at head z=0.55.
Simplest decent: body = sphere scaled (0.22,0.30,0.62) at z=0; nose cone at z=0.62 rotated x = PI/2 so tip points +Z.
Tail fin: ShapeGeometry of a forked shape in XY plane, placed at z=-0.62, double-sided, slightly scaled.
Dorsal fin: ShapeGeometry triangle on top, at y=0.28.
Pectoral fins: small flattened spheres at x=±0.18, z=0.12, animated flapping.
Eyes: small spheres black at x=±0.16,y=0.06,z=0.45 with white.
Stripes: skip, use color variety.
Good. Write code now.
Tail wiggle: tail.rotation.y = sin(t*freq)*0.4 (tail plane is XY, rotating around Y sweeps it laterally - yes).
Let me write everything.
Also fish scale overall per species 0.8-1.4.
Time to write out full HTML.
Also add a "coral" cluster: torus knots or random cones.
Let me write.
Code:
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style> ... </style>
</head>
<body>
<div id="app"></div>
<div id="overlay">...</div>
<script type="importmap">...</script>
<script type="module"> ... </script>
</body>
</html>
```
Overlay text: "Click or press a key to start" plus subtitle "Move your mouse near the fish — they'll dart away".
Also a HUD title top-left, and instructions bottom.
Now JS.
I'll write helper rand functions with seeded random for reproducibility (nice).
Let me write out.
```js
import * as THREE from 'three';
```
Scene setup.
Tank glass: create box frames using BoxGeometry(12.8,7.8,8.8) with MeshPhysicalMaterial({transmission:0.9, thickness:0.5, roughness:0.05, ior:1.33, transparent:true, opacity:0.35, side:THREE.DoubleSide}) — transmission requires transmissionRenderTarget; r170 supports it but with a single transmissive mesh it renders background through. Might be heavy but ok. Risk: transmission in WebGL requires `renderer.transmissionResolutionScale`? default fine. But transmissive mesh in front of everything may hide fish oddly since transmission renders scene behind excluding transmissive. Could look good actually (glass front refracting). But also we need to see the whole tank interior—transmission box would show scene behind through it. Since box encloses everything, transmission renders the interior scene into the transmission buffer... Actually transmissive objects are rendered after opaque; the transmission render target contains opaque scene (which is inside the box) so looking through front face you'd see interior refracted. Should be fine, but the box's back face also. Might get artifacts. Simpler & safer: use simple transparent material (opacity 0.08, roughness 0, metalness 0, envMap-ish) + EdgesGeometry lines for the tank frame. I'll do that, plus a top glass rim.
I'll skip envMap; use MeshPhysicalMaterial transparent with clearcoat.
Water volume: add a large box inside with MeshBasicMaterial transparent opacity 0.06 color teal to give water tint? Could cause sorting issues. Instead use scene fog: FogExp2 with teal color, subtle. Fog affects everything nicely. But fog color should match background gradient. Use scene.background = gradient texture (canvas). Fog with that color.
Let's do background: a large sphere/plane gradient? Use scene.background = new THREE.Color(0x04141c) plus a radial vignette via CSS on top? Simpler: set renderer clear color to dark teal and add a big backdrop plane behind tank with gradient canvas texture. Fog color 0x062028.
Ok code writing now, final.
Caustics plane: at y=0.06 covering sand, material MeshBasicMaterial with map=causticTex, blending AdditiveBlending, transparent, opacity 0.35, depthWrite false. Animate texture.offset.
Sand: PlaneGeometry(12,8,60,40) rotated -PI/2, displaced with dunes, material MeshStandardMaterial with map sandTex, color 0xd9c9a3, roughness 1.
Background gradient: canvas 2x256 vertical gradient, used as scene.background? scene.background can be a texture (equirect mapping). Instead use a big PlaneGeometry behind at z=-9 with MeshBasicMaterial map gradient. Good.
Add back wall of tank: sand extends; back wall could be dark blue glass. Fine.
Let's write.
For the mouse ring indicator: torus radius 0.5, tube 0.03, MeshBasicMaterial color 0x66e0ff transparent opacity .6, positioned at ray∩plane z=0, rotated to face camera, scale pulsing. Show only when interactive.
Also the camera could subtly orbit with mouse: camera.position.x = lerp toward mouseX*1.2. Yes.
Time to finalize code text.
Fish count: 14.
Let me write the full JS.
```js
const clock = new THREE.Clock();
```
Update loop with dt clamped 0.05.
Wall avoidance: for each axis, if pos beyond bound - margin, add force inward.
Bounds: x ±5.2, y 0.7..5.4, z ±3.2.
Boids separation: loop all fish pairs (14 -> 91 pairs, fine).
Now writing.
Pectoral fin flap: fin.rotation.z? pectoral fins as flattened ellipsoids attached with pivot group; rotate group around Z axis? For side fins, flapping is around the forward axis (Z) so they move up/down. I'll create fin as a mesh with position offset and set mesh.rotation.z = sin(t)*0.5, but rotation about own center. Use pivot group at body side, fin mesh offset outward, pivot.rotation.z = sin*0.6 (rotating in XY plane around Z axis? no, pivot at body side, rotating about Z axis moves fin in XY plane -> up/down-ish). Fine.
Write final.
Also add a small "pebble" scatter and 3-4 coral pieces (IcosahedronGeometry deformed, colorful).
Ok.
Final code, careful about syntax.
Let me write the makeFish function:
```js
function makeFish(spec){
const g = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({color: spec.color, roughness:0.35, metalness:0.15});
const matDark = ...
const body = new THREE.Mesh(bodyGeo, mat); body.scale.set(0.24,0.32,0.62); g.add(body);
const nose = new THREE.Mesh(coneGeo, mat); nose.rotation.x = Math.PI/2; nose.position.z=0.62; nose.scale.set(1,1,1) -> cone radius 0.2 height 0.3. Actually cone rotated x=PI/2 points tip along +Z? Cone default tip at +Y. Rotating +90° about X maps +Y to +Z? Rotation about X by +90°: Y->Z? R_x(90): y-> z? Let's compute: rotation about X by angle a: y' = y cos a - z sin a; z' = y sin a + z cos a. For a=90: y'= -z, z'= y. So vector +Y=(0,1,0) -> (0,0,1) = +Z. Yes tip at +Z. Good.
```
Tail shape via THREE.Shape:
forked: moveTo(0,0); lineTo(0,0.42); quadraticCurveTo(0.28,0.2, 0.55,0); quadraticCurveTo(0.28,-0.2, 0,-0.42); close. That's in XY plane. Then place at z=-0.6, rotation.y animated.
Round tail: ellipse shape.
Dorsal fin shape: triangle.
Materials for fins: MeshStandardMaterial({color: spec.fin, transparent:true, opacity:0.9, side:DoubleSide}).
Eyes: sphere white r 0.06 at (±0.16, 0.07, 0.44), pupil black r 0.035 slightly outward.
Scale group by spec.size.
Now let's write all of it in one go.
Also fish needs slight roll based on turn; keep simple.
Let's produce the final answer.