Interactive 3D aquariumOne-line
GLM 5.3
by Z.aiReasoning2 console errors
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 17 min 40 s
- Cost
- $0.439
- Output tokens
- 122,064
- Spent thinking
- 105,097 tokens
- Speed
- 115.2 tok/s
- Console errors
- 2
- First token after
- 441 ms
- First visible text after
- 16 min 14 s
- Prompt tokens
- 424
- Code size
- 46.7 KB
- Finish reason
- stop
- Served by
- InferenceNet
- Model id
z-ai/glm-5.3- Reasoning sent
{"effort":"max"}- Max output
- 943,718 tokens
- Generated
- Nov 12, 2026
Console errors
Caught in the first 3 seconds after load, in a sandboxed iframe like the one above.
THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index 1. All geometries must have compatible attributes; make sure index attribute exists among all geometries, or in none of them. Cannot read properties of null (reading 'computeBoundingSphere')
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 · 1221 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#03151f">
<title>Tideglass · a pocket aquarium</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden;background:#03151f}
body{font-family:ui-sans-serif,system-ui,"Segoe UI",Helvetica,Arial,sans-serif;-webkit-user-select:none;user-select:none}
#c{position:fixed;inset:0;width:100%;height:100%;display:block;cursor:none;touch-action:none}
#vig{position:fixed;inset:0;pointer-events:none;background:radial-gradient(130% 105% at 50% 38%,rgba(0,0,0,0) 50%,rgba(2,12,22,.6) 100%),linear-gradient(rgba(6,34,50,.32),rgba(0,0,0,0) 24%)}
#ui{position:fixed;inset:0;pointer-events:none;color:#e7f4f6;animation:fadein 2s ease .25s both}
@keyframes fadein{from{opacity:0}to{opacity:1}}
.brand{position:absolute;top:26px;left:32px}
.over{font-size:10px;letter-spacing:.42em;text-transform:uppercase;opacity:.55;margin-bottom:10px}
h1{font-family:Georgia,"Times New Roman",serif;font-style:italic;font-weight:400;font-size:clamp(30px,4vw,44px);letter-spacing:.04em;text-shadow:0 3px 26px rgba(0,18,32,.95)}
.rule{width:54px;height:1px;margin:13px 0 11px;background:linear-gradient(90deg,rgba(231,244,246,.95),rgba(231,244,246,0))}
#status{display:flex;align-items:center;gap:9px;font-size:11px;letter-spacing:.18em;text-transform:uppercase;opacity:.85}
#dot{width:7px;height:7px;border-radius:50%;background:#7ee0a3;box-shadow:0 0 9px rgba(255,255,255,.35);flex:none}
#hint{position:absolute;bottom:24px;left:50%;transform:translateX(-50%);font-size:10.5px;letter-spacing:.24em;text-transform:uppercase;opacity:.55;white-space:nowrap}
#hint em{font-style:normal;opacity:.5;padding:0 .7em}
#legend{position:absolute;right:30px;bottom:22px;text-align:right;font-size:10px;letter-spacing:.18em;text-transform:uppercase;line-height:2.1;opacity:.8}
#legend i{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:9px;vertical-align:1px}
@media (max-width:900px){#legend{display:none}}
@media (max-width:640px){
.brand{top:18px;left:20px}
#hint{bottom:16px;font-size:9px;letter-spacing:.14em;white-space:normal;width:88%;text-align:center}
}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="vig"></div>
<div id="ui">
<div class="brand">
<div class="over">Pocket aquarium · nº 07</div>
<h1>Tideglass</h1>
<div class="rule"></div>
<div id="status"><i id="dot"></i><span id="statusText">the water is calm</span></div>
</div>
<div id="hint">move to startle the shoal<em>·</em>click to feed<em>·</em>drag to look around</div>
<div id="legend">
<div><i style="background:#ff8a1e"></i>ocellaris clownfish — 12</div>
<div><i style="background:#35d5ff"></i>neon tetra — 40</div>
<div><i style="background:#ffd21c"></i>yellow tang — 9</div>
<div><i style="background:#dfe6ec"></i>veil angelfish — 6</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';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
/* ---------------- helpers ---------------- */
const TAU = Math.PI * 2;
const clamp = (v,a,b)=>Math.max(a,Math.min(b,v));
const sstep = (a,b,x)=>{ x=clamp((x-a)/(b-a),0,1); return x*x*(3-2*x); };
const bandf = (t,c,w,s)=> sstep(c-w-s,c-w,t)*(1-sstep(c+w,c+w+s,t));
const rand = (a=1,b)=> b===undefined ? Math.random()*a : a+Math.random()*(b-a);
const wrapPI = a=>{ a%=TAU; if(a>Math.PI)a-=TAU; if(a<-Math.PI)a+=TAU; return a; };
const PR = Math.min(window.devicePixelRatio||1, 1.75);
const timeU = { value: 0 };
const _v1=new THREE.Vector3(), _v2=new THREE.Vector3(), _v3=new THREE.Vector3();
const _q=new THREE.Quaternion(), _e=new THREE.Euler(), _m=new THREE.Matrix4();
const UP=new THREE.Vector3(0,1,0);
const raycaster=new THREE.Raycaster();
/* ---------------- renderer / scene ---------------- */
const canvas=document.getElementById('c');
const renderer=new THREE.WebGLRenderer({canvas, antialias:true});
renderer.setPixelRatio(PR);
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled=true;
renderer.shadowMap.type=THREE.PCFSoftShadowMap;
const scene=new THREE.Scene();
scene.fog=new THREE.FogExp2(0x0b4157, 0.024);
scene.background=new THREE.Color(0x0b4157);
const camera=new THREE.PerspectiveCamera(55, innerWidth/innerHeight, 0.1, 300);
camera.position.set(4.2, 4.9, 15.8);
const controls=new OrbitControls(camera, canvas);
controls.target.set(0, 4.4, -2);
controls.enablePan=false;
controls.enableDamping=true;
controls.dampingFactor=0.06;
controls.minDistance=7; controls.maxDistance=19;
controls.minPolarAngle=1.22; controls.maxPolarAngle=1.58;
controls.minAzimuthAngle=-1.15; controls.maxAzimuthAngle=1.15;
controls.rotateSpeed=0.6; controls.zoomSpeed=0.6;
controls.autoRotate=true; controls.autoRotateSpeed=0.26;
controls.update();
const hemi=new THREE.HemisphereLight(0x9fd4e8, 0x123240, 0.85);
scene.add(hemi);
const sun=new THREE.DirectionalLight(0xfff3dd, 2.4);
sun.position.set(7,19,5);
sun.castShadow=true;
sun.shadow.mapSize.set(1024,1024);
sun.shadow.camera.near=4; sun.shadow.camera.far=50;
sun.shadow.camera.left=-24; sun.shadow.camera.right=24;
sun.shadow.camera.top=20; sun.shadow.camera.bottom=-20;
sun.shadow.bias=-0.0004; sun.shadow.normalBias=0.03;
scene.add(sun); scene.add(sun.target); sun.target.position.set(0,0,-3);
const fill=new THREE.DirectionalLight(0x7fc4e6, 0.5);
fill.position.set(-6,8,18); scene.add(fill);
/* ---------------- shared GLSL (fake caustics) ---------------- */
const CAUSTIC_GLSL = `
float causticField(vec2 p, float t){
vec2 q = p;
float v = 0.0;
float w = 0.62;
for (int k = 0; k < 3; k++){
float fk = float(k);
q += vec2(sin(q.y*1.65 + t*(0.9+0.13*fk) + fk*2.4), cos(q.x*1.42 - t*(0.8+0.11*fk) + fk*1.7))*0.45;
v += w * abs(sin(q.x*1.5 + t*0.7) * sin(q.y*1.62 - t*0.85));
w *= 0.58;
}
v /= 1.19;
return pow(clamp(1.0 - v*1.7, 0.0, 1.0), 5.0);
}`;
function injectCaustics(mat, strength, scale){
mat.onBeforeCompile=(sh)=>{
sh.uniforms.uTime=timeU;
sh.uniforms.uCau={value:strength};
sh.uniforms.uCauS={value:scale};
sh.vertexShader=sh.vertexShader
.replace('#include <common>','#include <common>\nvarying vec3 vCauP;')
.replace('#include <begin_vertex>','#include <begin_vertex>\nvCauP=(modelMatrix*vec4(transformed,1.0)).xyz;');
sh.fragmentShader=sh.fragmentShader
.replace('#include <common>','#include <common>\nuniform float uTime;uniform float uCau;uniform float uCauS;varying vec3 vCauP;\n'+CAUSTIC_GLSL)
.replace('#include <map_fragment>',`#include <map_fragment>
{
float cau = causticField(vCauP.xz*uCauS, uTime*0.55)*0.75 + causticField(vCauP.xz*uCauS*2.1+13.7, uTime*0.8)*0.35;
diffuseColor.rgb *= (1.0 + cau*uCau);
}`);
};
}
function injectWag(mat){
mat.onBeforeCompile=(sh)=>{
sh.uniforms.uTime=timeU;
sh.vertexShader=sh.vertexShader
.replace('#include <common>','#include <common>\nattribute vec2 aTail;\nuniform float uTime;')
.replace('#include <begin_vertex>',`#include <begin_vertex>
{
float env = 0.22 + 0.78 * (1.0 - smoothstep(-0.60, 0.18, transformed.z));
transformed.x += sin(aTail.x + transformed.z * 4.3) * env * (0.016 + 0.045 * aTail.y) * 2.4;
}`);
};
}
function injectSwayKelp(mat){
mat.onBeforeCompile=(sh)=>{
sh.uniforms.uTime=timeU;
sh.vertexShader=sh.vertexShader
.replace('#include <common>','#include <common>\nattribute float aPhase;\nuniform float uTime;')
.replace('#include <begin_vertex>',`#include <begin_vertex>
{
float h = clamp(transformed.y, 0.0, 1.0);
float b = h * h;
transformed.x += sin(uTime * 0.85 + aPhase + h * 2.4) * b * 0.16;
transformed.z += cos(uTime * 0.62 + aPhase * 1.7 + h * 1.9) * b * 0.11;
}`);
};
}
function injectSwayAnemone(mat){
mat.onBeforeCompile=(sh)=>{
sh.uniforms.uTime=timeU;
sh.vertexShader=sh.vertexShader
.replace('#include <common>','#include <common>\nattribute float aPhase;\nuniform float uTime;')
.replace('#include <begin_vertex>',`#include <begin_vertex>
{
float h = clamp(transformed.y, 0.0, 1.0);
float s = pow(h, 1.5);
transformed.x += sin(uTime * 1.7 + aPhase) * s * 0.13;
transformed.z += cos(uTime * 1.35 + aPhase * 1.31) * s * 0.13;
}`);
};
}
/* ---------------- backdrop dome ---------------- */
const dome=new THREE.Mesh(
new THREE.SphereGeometry(120,32,20),
new THREE.ShaderMaterial({
uniforms:{ uSun:{value:new THREE.Vector3(0.32,0.9,0.24).normalize()} },
side:THREE.BackSide, depthWrite:false,
vertexShader:`
varying vec3 vD;
void main(){
vec4 wp=modelMatrix*vec4(position,1.0);
vD=wp.xyz-cameraPosition;
gl_Position=projectionMatrix*viewMatrix*wp;
}`,
fragmentShader:`
uniform vec3 uSun; varying vec3 vD;
void main(){
vec3 d=normalize(vD);
vec3 bot=vec3(0.012,0.055,0.085), mid=vec3(0.030,0.145,0.200), top=vec3(0.100,0.320,0.420);
vec3 col=mix(bot,mid,smoothstep(-0.45,0.15,d.y));
col=mix(col,top,smoothstep(0.15,0.75,d.y));
float g=pow(max(dot(d,uSun),0.0),20.0);
col+=vec3(0.45,0.65,0.75)*g*0.4;
gl_FragColor=vec4(col,1.0);
#include <colorspace_fragment>
}`
})
);
dome.frustumCulled=false; dome.renderOrder=-10; scene.add(dome);
/* ---------------- sand ---------------- */
function sandHeight(x,z){
return 0.55*Math.sin(x*0.15+1.7)*Math.cos(z*0.23-0.6)
+ 0.28*Math.sin(x*0.43+z*0.31+0.5)
+ 0.22*Math.sin(z*0.55-x*0.13+2.1)
+ 0.4*sstep(-4,-13,z);
}
const obstacles=[];
{
const g=new THREE.PlaneGeometry(120,84,110,76);
g.rotateX(-Math.PI/2);
const p=g.attributes.position;
const cols=new Float32Array(p.count*3);
const cA=new THREE.Color(0xb99f7d), cB=new THREE.Color(0x8a7154), c=new THREE.Color();
for(let i=0;i<p.count;i++){
const x=p.getX(i), z=p.getZ(i);
p.setY(i, sandHeight(x,z));
const n=Math.abs(Math.sin(x*12.9898+z*78.233)*43758.5453)%1;
c.copy(cA).lerp(cB, clamp(n*0.55 + 0.45*Math.sin(x*0.8+z*1.3)+0.5*0.45,0,1));
cols[i*3]=c.r; cols[i*3+1]=c.g; cols[i*3+2]=c.b;
}
g.setAttribute('color',new THREE.BufferAttribute(cols,3));
g.computeVertexNormals();
const mat=new THREE.MeshStandardMaterial({vertexColors:true,roughness:1,metalness:0});
injectCaustics(mat,0.62,0.85);
const sand=new THREE.Mesh(g,mat);
sand.receiveShadow=true;
scene.add(sand);
}
/* ---------------- rocks ---------------- */
function makeRockGeo(seed){
const g=new THREE.IcosahedronGeometry(1,2);
const p=g.attributes.position;
const cols=new Float32Array(p.count*3);
const cA=new THREE.Color(0x45444b), cB=new THREE.Color(0x74726a), cM=new THREE.Color(0x3f6b52), cW=new THREE.Color(0x6b5f50);
const c=new THREE.Color();
for(let i=0;i<p.count;i++){
const x=p.getX(i),y=p.getY(i),z=p.getZ(i);
const h=Math.abs(Math.sin(x*12.9898+y*78.233+z*37.719+seed*94.673)*43758.5453)%1;
const s=0.72+h*0.55;
const ny=y*s*0.8;
p.setXYZ(i,x*s,ny,z*s);
const yt=clamp((ny+0.75)/1.5,0,1);
c.copy(cA).lerp(cB,yt).lerp(cW,h*0.35).lerp(cM,sstep(0.55,0.95,yt)*0.55);
cols[i*3]=c.r; cols[i*3+1]=c.g; cols[i*3+2]=c.b;
}
g.setAttribute('color',new THREE.BufferAttribute(cols,3));
g.computeVertexNormals();
return g;
}
{
const mat=new THREE.MeshStandardMaterial({vertexColors:true,roughness:0.95,flatShading:true});
injectCaustics(mat,0.32,0.9);
const geos=[makeRockGeo(1),makeRockGeo(2),makeRockGeo(3)];
const spots=[[-11.2,-6.5,2.3],[-8.6,-8.3,1.5],[9.5,-7.8,2.0],[12.2,-4.6,1.25],[-0.6,-10.2,1.55],[3.4,-9.2,1.0],[-14.2,-1.2,1.7],[6.4,0.6,0.7],[-4.6,1.8,0.6],[12.6,1.6,0.95]];
spots.forEach((r,i)=>{
const x=r[0], z=r[1], s=r[2];
const m=new THREE.Mesh(geos[i%3],mat);
const y=sandHeight(x,z)+s*0.18;
m.position.set(x,y,z);
m.scale.set(s,s*rand(0.72,0.95),s);
m.rotation.y=rand(TAU);
m.castShadow=true; m.receiveShadow=true;
scene.add(m);
obstacles.push({x,y:y+s*0.25,z,r:s*0.95});
});
}
/* ---------------- kelp ---------------- */
const bladeGeo=new THREE.PlaneGeometry(0.16,1,1,12);
bladeGeo.translate(0,0.5,0);
{
const p=bladeGeo.attributes.position;
const cols=new Float32Array(p.count*3);
const c1=new THREE.Color(0x0e2f22), c2=new THREE.Color(0x3f8f5c), c3=new THREE.Color(0x83b96f);
const c=new THREE.Color();
for(let i=0;i<p.count;i++){
const y=p.getY(i);
p.setX(i,p.getX(i)*(1-Math.pow(y,1.35)*0.75));
p.setZ(i,Math.sin(y*2.6)*0.055);
if(y<0.6) c.copy(c1).lerp(c2,y/0.6); else c.copy(c2).lerp(c3,(y-0.6)/0.4);
cols[i*3]=c.r; cols[i*3+1]=c.g; cols[i*3+2]=c.b;
}
bladeGeo.setAttribute('color',new THREE.BufferAttribute(cols,3));
bladeGeo.computeVertexNormals();
}
const kelpSpots=[];
[[-13.4,-7.2,9],[13.6,-8.2,8],[-9.6,-9.8,6],[8.8,-1.2,5],[2.4,-10.3,6],[13.9,1.6,4]].forEach(cl=>{
for(let i=0;i<cl[2];i++){
const a=rand(TAU), r=rand(0.25,1.35);
kelpSpots.push([cl[0]+Math.cos(a)*r, cl[1]+Math.sin(a)*r, rand(2.4,5.4)]);
}
});
for(let i=0;i<22;i++) kelpSpots.push([rand(-14.5,14.5),rand(-9.5,9.5),rand(0.7,1.8)]);
{
const mat=new THREE.MeshStandardMaterial({vertexColors:true,roughness:0.8,side:THREE.DoubleSide});
injectSwayKelp(mat);
const kelp=new THREE.InstancedMesh(bladeGeo,mat,kelpSpots.length);
kelp.frustumCulled=false; kelp.receiveShadow=true;
const phases=new Float32Array(kelpSpots.length);
const pal=[0x2c6b4f,0x3f8f6a,0x57a06a,0x2f8f7a,0x6fae6a].map(h=>new THREE.Color(h));
const kt=new THREE.Color();
kelpSpots.forEach((s,i)=>{
phases[i]=rand(TAU);
_e.set(0,rand(TAU),rand(-0.06,0.06)); _q.setFromEuler(_e);
const h=s[2];
_v1.set(h*rand(0.6,0.95),h,h*rand(0.6,0.95));
_v2.set(s[0],sandHeight(s[0],s[1])-0.06,s[1]);
_m.compose(_v2,_q,_v1);
kelp.setMatrixAt(i,_m);
kt.copy(pal[i%pal.length]).multiplyScalar(rand(0.8,1.15));
kelp.setColorAt(i,kt);
});
bladeGeo.setAttribute('aPhase',new THREE.InstancedBufferAttribute(phases,1));
kelp.instanceColor.needsUpdate=true;
scene.add(kelp);
}
/* ---------------- anemones (clownfish homes) ---------------- */
const homes=[];
const tentGeo=new THREE.CylinderGeometry(0.018,0.06,1,5,4);
tentGeo.translate(0,0.5,0);
{
const p=tentGeo.attributes.position;
const cols=new Float32Array(p.count*3);
const c1=new THREE.Color(0x8e2657), c2=new THREE.Color(0xe26b9e), c3=new THREE.Color(0xffd9ec);
const c=new THREE.Color();
for(let i=0;i<p.count;i++){
const y=p.getY(i);
if(y<0.55) c.copy(c1).lerp(c2,y/0.55); else c.copy(c2).lerp(c3,(y-0.55)/0.45);
cols[i*3]=c.r; cols[i*3+1]=c.g; cols[i*3+2]=c.b;
}
tentGeo.setAttribute('color',new THREE.BufferAttribute(cols,3));
const ph=new Float32Array(64);
for(let i=0;i<64;i++) ph[i]=rand(TAU);
tentGeo.setAttribute('aPhase',new THREE.InstancedBufferAttribute(ph,1));
}
const anemBaseGeo=new THREE.SphereGeometry(0.5,14,10);
{
const p=anemBaseGeo.attributes.position;
const cols=new Float32Array(p.count*3);
const c1=new THREE.Color(0x6d2246), c2=new THREE.Color(0xc25585);
const c=new THREE.Color();
for(let i=0;i<p.count;i++){
c.copy(c1).lerp(c2,clamp(p.getY(i)+0.5,0,1));
cols[i*3]=c.r; cols[i*3+1]=c.g; cols[i*3+2]=c.b;
}
anemBaseGeo.setAttribute('color',new THREE.BufferAttribute(cols,3));
}
{
const tentMat=new THREE.MeshStandardMaterial({vertexColors:true,roughness:0.55});
injectSwayAnemone(tentMat);
const baseMat=new THREE.MeshStandardMaterial({vertexColors:true,roughness:0.75});
const build=(x,z,s)=>{
const baseY=sandHeight(x,z);
const grp=new THREE.Group(); grp.position.set(x,baseY,z); scene.add(grp);
const base=new THREE.Mesh(anemBaseGeo,baseMat);
base.scale.set(1.15*s,0.62*s,1.15*s); base.position.y=0.14*s; base.castShadow=true;
grp.add(base);
const n=Math.round(26*s);
const im=new THREE.InstancedMesh(tentGeo,tentMat,n);
im.frustumCulled=false;
const tj=new THREE.Color();
for(let i=0;i<n;i++){
const th=rand(TAU), yy=rand(0.18,0.95);
const rr=Math.sqrt(Math.max(0,1-yy*yy));
_v1.set(Math.cos(th)*rr,yy,Math.sin(th)*rr);
_q.setFromUnitVectors(UP,_v1);
_v3.set(_v1.x*0.575*s, _v1.y*0.31*s+0.14*s, _v1.z*0.575*s);
_v2.set(1, rand(0.5,0.95)*s, 1);
_m.compose(_v3,_q,_v2);
im.setMatrixAt(i,_m);
im.setColorAt(i, tj.setRGB(rand(0.85,1.15),rand(0.85,1.15),rand(0.85,1.15)));
}
im.instanceColor.needsUpdate=true;
grp.add(im);
obstacles.push({x,y:baseY+0.5*s,z,r:0.85*s});
homes.push({x,y:baseY+1.7*s,z});
};
build(-8.3,-5.5,1.15);
build(6.9,-7.4,0.9);
}
/* ---------------- water surface (seen from below) ---------------- */
{
const g=new THREE.PlaneGeometry(130,90,50,36);
g.rotateX(Math.PI/2);
const mat=new THREE.ShaderMaterial({
uniforms:{ uTime:timeU, uDeep:{value:new THREE.Color(0x0a3450)}, uLite:{value:new THREE.Color(0xaee8ff)} },
fog:true,
vertexShader:`
uniform float uTime; varying vec2 vP;
#include <fog_pars_vertex>
void main(){
vec3 p=position;
p.y += sin(p.x*0.32+uTime*0.7)*0.10 + sin(p.z*0.45-uTime*0.5)*0.08;
vP=p.xz;
vec4 mvPosition=modelViewMatrix*vec4(p,1.0);
gl_Position=projectionMatrix*mvPosition;
#include <fog_vertex>
}`,
fragmentShader:`
uniform float uTime; uniform vec3 uDeep; uniform vec3 uLite;
varying vec2 vP;
#include <fog_pars_fragment>
${CAUSTIC_GLSL}
void main(){
float c = causticField(vP*0.06, uTime*0.5)*0.8 + causticField(vP*0.16+7.3, uTime*0.75)*0.5;
vec3 col = mix(uDeep, uLite, clamp(c*1.15,0.0,1.0));
gl_FragColor=vec4(col,1.0);
#include <fog_fragment>
#include <colorspace_fragment>
}`
});
const surf=new THREE.Mesh(g,mat);
surf.position.y=11.8; surf.frustumCulled=false;
scene.add(surf);
}
/* ---------------- god rays ---------------- */
{
const geo=new THREE.PlaneGeometry(1,1);
for(let i=0;i<7;i++){
const mat=new THREE.ShaderMaterial({
uniforms:{ uTime:timeU, uSeed:{value:rand(10)}, uOp:{value:rand(0.05,0.10)} },
transparent:true, depthWrite:false, blending:THREE.AdditiveBlending, side:THREE.DoubleSide,
vertexShader:`
uniform float uTime; uniform float uSeed; varying vec2 vUv;
void main(){
vUv=uv;
vec3 p=position;
p.x+=sin(uTime*0.22+uSeed*7.0)*0.55;
gl_Position=projectionMatrix*modelViewMatrix*vec4(p,1.0);
}`,
fragmentShader:`
uniform float uTime; uniform float uSeed; uniform float uOp; varying vec2 vUv;
void main(){
float ex=smoothstep(0.0,0.38,vUv.x)*(1.0-smoothstep(0.62,1.0,vUv.x));
float ey=pow(vUv.y,2.8);
float fl=0.55+0.45*sin(uTime*0.5+uSeed*9.0);
float a=ex*ey*fl*uOp;
gl_FragColor=vec4(vec3(0.55,0.82,1.0),a);
#include <colorspace_fragment>
}`
});
const m=new THREE.Mesh(geo,mat);
m.scale.set(rand(0.9,2.6),11.2,1);
m.position.set(rand(-10,10),5.8,rand(-8,1));
m.rotation.y=rand(-0.5,0.5);
m.rotation.z=rand(-0.09,0.09);
m.renderOrder=5; m.frustumCulled=false;
scene.add(m);
}
}
/* ---------------- bubbles ---------------- */
{
const NB=90;
const base=new Float32Array(NB*3), seed=new Float32Array(NB), rate=new Float32Array(NB), size=new Float32Array(NB);
const em=[[-8.4,-5.2],[9.3,-6.6]];
for(let i=0;i<NB;i++){
const e=em[i%2];
const x=e[0]+rand(-0.55,0.55), z=e[1]+rand(-0.55,0.55);
base[i*3]=x; base[i*3+1]=sandHeight(x,z)+0.15; base[i*3+2]=z;
seed[i]=rand(100); rate[i]=rand(1.0,2.1); size[i]=rand(0.35,0.85);
}
const g=new THREE.BufferGeometry();
g.setAttribute('position',new THREE.BufferAttribute(base,3));
g.setAttribute('aSeed',new THREE.BufferAttribute(seed,1));
g.setAttribute('aRate',new THREE.BufferAttribute(rate,1));
g.setAttribute('aSize',new THREE.BufferAttribute(size,1));
const mat=new THREE.ShaderMaterial({
uniforms:{uTime:timeU,uPR:{value:PR}},
transparent:true, depthWrite:false, fog:true,
vertexShader:`
uniform float uTime; uniform float uPR;
attribute float aSeed; attribute float aRate; attribute float aSize;
varying float vA;
#include <fog_pars_vertex>
void main(){
float h = mod(aSeed*13.7 + uTime*aRate, 11.0);
float wob = 0.05 + h*0.05;
vec3 p = vec3(position.x + sin(uTime*1.6+aSeed)*wob, position.y + h, position.z + cos(uTime*1.3+aSeed*1.7)*wob);
vA = smoothstep(0.0,0.5,h) * (1.0 - smoothstep(10.2,11.0,h));
vec4 mvPosition = modelViewMatrix*vec4(p,1.0);
gl_PointSize = max(aSize*(1.0+h*0.10)*uPR*(150.0/max(1.0,-mvPosition.z)), 2.0);
gl_Position = projectionMatrix*mvPosition;
#include <fog_vertex>
}`,
fragmentShader:`
varying float vA;
#include <fog_pars_fragment>
void main(){
vec2 q=gl_PointCoord-0.5; float d=length(q)*2.0;
float edge=1.0-smoothstep(0.82,1.0,d);
float hole=smoothstep(0.5,0.85,d);
float rim=edge*(0.12+0.88*hole);
float hi=1.0-smoothstep(0.0,0.4,length(q-vec2(-0.14,0.16)));
float a=clamp(rim+hi*0.7,0.0,1.0)*vA*0.8;
vec3 col=mix(vec3(0.72,0.88,1.0),vec3(1.0),hi*0.8);
gl_FragColor=vec4(col,a);
#include <fog_fragment>
#include <colorspace_fragment>
}`
});
const pts=new THREE.Points(g,mat);
pts.frustumCulled=false;
scene.add(pts);
}
/* ---------------- marine snow ---------------- */
{
const NS=220;
const base=new Float32Array(NS*3), seed=new Float32Array(NS), size=new Float32Array(NS);
for(let i=0;i<NS;i++){
base[i*3]=rand(-15,15); base[i*3+1]=rand(0.8,11.2); base[i*3+2]=rand(-10,10);
seed[i]=rand(100); size[i]=rand(0.5,1.4);
}
const g=new THREE.BufferGeometry();
g.setAttribute('position',new THREE.BufferAttribute(base,3));
g.setAttribute('aSeed',new THREE.BufferAttribute(seed,1));
g.setAttribute('aSize',new THREE.BufferAttribute(size,1));
const mat=new THREE.ShaderMaterial({
uniforms:{uTime:timeU,uPR:{value:PR}},
transparent:true, depthWrite:false, fog:true,
vertexShader:`
uniform float uTime; uniform float uPR;
attribute float aSeed; attribute float aSize;
varying float vA;
#include <fog_pars_vertex>
void main(){
vec3 p=position;
p.x+=sin(uTime*0.17+aSeed*3.1)*0.7;
p.z+=cos(uTime*0.13+aSeed*2.3)*0.7;
p.y=0.7+mod(position.y-uTime*(0.05+aSeed*0.04),10.4);
vA=0.5+0.5*sin(aSeed*7.0);
vec4 mvPosition=modelViewMatrix*vec4(p,1.0);
gl_PointSize=max(aSize*uPR*(70.0/max(1.0,-mvPosition.z)),1.0);
gl_Position=projectionMatrix*mvPosition;
#include <fog_vertex>
}`,
fragmentShader:`
varying float vA;
#include <fog_pars_fragment>
void main(){
vec2 q=gl_PointCoord-0.5;
float d=length(q)*2.0;
float a=(1.0-smoothstep(0.3,0.9,d))*0.16*vA;
gl_FragColor=vec4(vec3(0.78,0.9,0.95),a);
#include <fog_fragment>
#include <colorspace_fragment>
}`
});
const pts=new THREE.Points(g,mat);
pts.frustumCulled=false;
scene.add(pts);
}
/* ---------------- fish colours ---------------- */
const COL={
orange:new THREE.Color(0xff8a1e), orangeD:new THREE.Color(0xd45500), clownW:new THREE.Color(0xf4f2e9),
black:new THREE.Color(0x232629), belly:new THREE.Color(0xffc98a),
silver:new THREE.Color(0xd9e2e6), neon:new THREE.Color(0x35d5ff), red:new THREE.Color(0xff2e46),
white:new THREE.Color(0xf2f7f9), steel:new THREE.Color(0x9fb4bd),
yellow:new THREE.Color(0xffd21c), yellowD:new THREE.Color(0xdf9c08), yellowL:new THREE.Color(0xffe664),
angelS:new THREE.Color(0xdae2e8), angelT:new THREE.Color(0x8fa0ad), angelD:new THREE.Color(0x2b343d)
};
const FIN={
clownBase:new THREE.Color(0xffa53c), clownDark:new THREE.Color(0x1f2126), clownW:new THREE.Color(0xf6f4ea),
tetraA:new THREE.Color(0xbfcdd2), tetraB:new THREE.Color(0x7f95a0),
tangA:new THREE.Color(0xffe25e), tangB:new THREE.Color(0xfff3b8),
angelA:new THREE.Color(0x525f6e), angelB:new THREE.Color(0x10141a)
};
/* ---------------- fish geometry ---------------- */
function makeFishGeo(def){
const H=def.height, W=def.width;
const prof=t=>Math.sin(Math.PI*Math.pow(clamp(t,0,1),1.15));
const topAt=t=>prof(t)*H*1.16;
const botAt=t=>-prof(t)*H*0.95;
const parts=[];
const body=new THREE.SphereGeometry(1,24,16);
const bp=body.attributes.position;
const bcol=new Float32Array(bp.count*3);
const cc=new THREE.Color();
for(let i=0;i<bp.count;i++){
const x=bp.getX(i), y=bp.getY(i), z=bp.getZ(i);
const t=(z+1)*0.5;
const pr=prof(t);
const sy=0.95+0.21*sstep(0,0.8,y);
bp.setXYZ(i, x*pr*W, y*pr*H*sy, t-0.42);
def.bodyColor(cc, t, (y+1)*0.5);
bcol[i*3]=cc.r; bcol[i*3+1]=cc.g; bcol[i*3+2]=cc.b;
}
body.computeVertexNormals();
body.setAttribute('color',new THREE.BufferAttribute(bcol,3));
parts.push(body.toNonIndexed());
function shapeGeo(pts){
const sh=new THREE.Shape();
sh.moveTo(pts[0][0],pts[0][1]);
for(let i=1;i<pts.length;i++) sh.lineTo(pts[i][0],pts[i][1]);
sh.closePath();
const g=new THREE.ShapeGeometry(sh,4);
g.rotateY(Math.PI/2);
return g;
}
function paintFin(g,progFn,colorFn){
const p=g.attributes.position;
const arr=new Float32Array(p.count*3);
const c=new THREE.Color();
for(let i=0;i<p.count;i++){
colorFn(c, clamp(progFn(p.getX(i),p.getY(i),p.getZ(i)),0,1));
arr[i*3]=c.r; arr[i*3+1]=c.g; arr[i*3+2]=c.b;
}
g.setAttribute('color',new THREE.BufferAttribute(arr,3));
}
// caudal fin
{
const tf=def.tail, z0=-0.40;
const pts=[[0,0.05],[tf.len*0.8,tf.spread],[tf.len*0.55,tf.fork*tf.spread+0.015],
[tf.len*0.55,-tf.fork*tf.spread-0.015],[tf.len*0.8,-tf.spread],[0,-0.05]];
const g=shapeGeo(pts); g.translate(0,0,z0);
paintFin(g,(x,y,z)=>(z0-z)/tf.len, def.finColor('tail'));
parts.push(g);
}
// dorsal fin
{
const df=def.dorsal, z0=df.t0-0.42, h=df.h, ns=4;
const bottom=[];
for(let k=0;k<=ns;k++) bottom.push([df.len*k/ns, topAt(clamp(df.t0-df.len*k/ns,0.02,0.98))-0.02]);
const pts=[[0,bottom[0][1]],[df.len*0.30,bottom[0][1]+h*0.92],
[df.len*0.72,bottom[3][1]+h*0.78],[df.len,bottom[ns][1]+h*0.28]];
for(let k=ns;k>=0;k--) pts.push(bottom[k]);
const g=shapeGeo(pts); g.translate(0,0,z0);
const yb=topAt(df.t0)-0.05;
paintFin(g,(x,y,z)=>(y-yb)/h, def.finColor('dorsal'));
parts.push(g);
}
// ventral fin
{
const vf=def.ventral, z0=vf.t0-0.42, h=vf.h, ns=4;
const bottom=[];
for(let k=0;k<=ns;k++) bottom.push([vf.len*k/ns, botAt(clamp(vf.t0-vf.len*k/ns,0.02,0.98))+0.02]);
const pts=[[0,bottom[0][1]],[vf.len*0.30,bottom[0][1]-h*0.92],
[vf.len*0.72,bottom[3][1]-h*0.78],[vf.len,bottom[ns][1]-h*0.28]];
for(let k=ns;k>=0;k--) pts.push(bottom[k]);
const g=shapeGeo(pts); g.translate(0,0,z0);
const yb=botAt(vf.t0)+0.05;
paintFin(g,(x,y,z)=>(yb-y)/h, def.finColor('ventral'));
parts.push(g);
}
// pectoral fins
{
const tp=0.66, z0=tp-0.42;
const x0=prof(tp)*W*0.85, y0=-prof(tp)*H*0.32;
const pts=[[0,0.03],[0.17,-0.055],[0.13,0.075]];
for(const side of [1,-1]){
const sh=new THREE.Shape();
sh.moveTo(pts[0][0],y0+pts[0][1]);
sh.lineTo(pts[1][0],y0+pts[1][1]);
sh.lineTo(pts[2][0],y0+pts[2][1]);
sh.closePath();
const g=new THREE.ShapeGeometry(sh,4);
g.rotateY(Math.PI/2);
g.rotateY(-side*0.55);
g.translate(side*x0,0,z0);
paintFin(g,(x,y,z)=>Math.hypot(x-side*x0,y-y0,z-z0)/0.17, def.finColor('pect'));
parts.push(g);
}
}
// angelfish pelvic streamers
if(def.streamer){
const py=botAt(0.52)+0.05, pz=0.10;
const pts=[[0,0],[0.08,-0.05],[0.46,-0.42],[0.50,-0.37],[0.10,-0.02]];
for(const side of [1,-1]){
const sh=new THREE.Shape();
sh.moveTo(pts[0][0],py+pts[0][1]);
for(let i=1;i<pts.length;i++) sh.lineTo(pts[i][0],py+pts[i][1]);
sh.closePath();
const g=new THREE.ShapeGeometry(sh,4);
g.rotateY(Math.PI/2);
g.translate(side*0.02,0,pz);
paintFin(g,(x,y,z)=>Math.hypot(x-side*0.02,y-py,z-pz)/0.46, def.finColor('streamer'));
parts.push(g);
}
}
// eyes
{
const te=0.88;
const ex=prof(te)*W*0.72, ey=H*0.14, ez=te-0.42;
const eyeCol=new THREE.Color(0x0b0e12);
for(const side of [1,-1]){
const g=new THREE.SphereGeometry(0.035,8,6);
g.translate(side*ex,ey,ez);
paintFin(g,()=>0,(c)=>c.copy(eyeCol));
parts.push(g.toNonIndexed());
}
}
const merged=mergeGeometries(parts);
merged.computeBoundingSphere();
return merged;
}
/* ---------------- species ---------------- */
const fishDefs=[
{
key:'tetra', count:40, len:0.58, height:0.155, width:0.10,
cruise:1.9, maxS:4.6, coh:1.4, ali:2.2, neighR:2.5, wander:0.5, prefY:5.6,
tail:{len:0.17,spread:0.13,fork:0.5},
dorsal:{t0:0.55,len:0.09,h:0.06}, ventral:{t0:0.42,len:0.08,h:0.05},
bodyColor:(c,t,y)=>{
c.copy(COL.silver);
c.lerp(COL.red, sstep(0.62,0.30,t)*(1-sstep(0.42,0.60,y)));
c.lerp(COL.neon, sstep(0.48,0.70,y)*sstep(0.10,0.30,t));
c.lerp(COL.white, (1-sstep(0.34,0.12,y))*0.5);
c.lerp(COL.steel, sstep(0.55,1,y)*0.45);
},
finColor:()=>(c,pr)=>{ c.copy(FIN.tetraA).lerp(FIN.tetraB, sstep(0.45,1,pr)*0.7); }
},
{
key:'clown', count:12, len:0.85, height:0.20, width:0.135,
cruise:1.25, maxS:3.4, coh:0.6, ali:1.0, neighR:3.4, wander:0.55, prefY:3.1,
tail:{len:0.26,spread:0.21,fork:0.18},
dorsal:{t0:0.50,len:0.20,h:0.13}, ventral:{t0:0.46,len:0.15,h:0.10},
bodyColor:(c,t,y)=>{
c.copy(COL.orange).lerp(COL.orangeD, sstep(0.55,1,y)*0.55);
c.lerp(COL.belly, (1-sstep(0.30,0.08,y))*0.4);
let bd=0,bw=0;
const bands=[[0.26,0.05],[0.55,0.055],[0.88,0.045]];
for(const b of bands){
bd=Math.max(bd, bandf(t,b[0],b[1]+0.028,0.03));
bw=Math.max(bw, bandf(t,b[0],Math.max(0.012,b[1]-0.02),0.03));
}
c.lerp(COL.black, Math.min(1,bd)*0.85);
c.lerp(COL.clownW, Math.min(1,bw));
},
finColor:(part)=>{
if(part==='tail') return (c,pr)=>{ c.copy(FIN.clownW).lerp(FIN.clownDark, sstep(0.5,1,pr)*0.85); };
if(part==='pect') return (c,pr)=>{ c.copy(FIN.clownBase).lerp(FIN.clownDark, sstep(0.45,1,pr)*0.5); };
return (c,pr)=>{ c.copy(FIN.clownBase).lerp(FIN.clownDark, sstep(0.5,1,pr)*0.7); };
}
},
{
key:'tang', count:9, len:1.05, height:0.33, width:0.13,
cruise:1.05, maxS:3.0, coh:0.25, ali:0.5, neighR:4.0, wander:0.9, prefY:2.5,
tail:{len:0.16,spread:0.30,fork:0.06},
dorsal:{t0:0.42,len:0.34,h:0.30}, ventral:{t0:0.42,len:0.30,h:0.26},
bodyColor:(c,t,y)=>{
c.copy(COL.yellow).lerp(COL.yellowD, sstep(0.45,0.95,y)*0.6);
c.lerp(COL.yellowL, sstep(0.70,0.97,t)*0.6);
},
finColor:()=>(c,pr)=>{ c.copy(FIN.tangA).lerp(FIN.tangB, sstep(0.45,1,pr)*0.55); }
},
{
key:'angel', count:6, len:1.15, height:0.40, width:0.075,
cruise:0.85, maxS:2.4, coh:0.35, ali:0.7, neighR:4.4, wander:0.45, prefY:6.2,
tail:{len:0.30,spread:0.22,fork:0.10},
dorsal:{t0:0.56,len:0.30,h:0.40}, ventral:{t0:0.50,len:0.26,h:0.44}, streamer:true,
bodyColor:(c,t,y)=>{
c.copy(COL.angelS).lerp(COL.angelT, sstep(0.45,0.95,y)*0.5);
const b=bandf(t,0.36,0.035,0.02)+bandf(t,0.60,0.03,0.02)+bandf(t,0.84,0.04,0.015);
c.lerp(COL.angelD, Math.min(1,b));
},
finColor:()=>(c,pr)=>{ c.copy(FIN.angelA).lerp(FIN.angelB, sstep(0.4,1,pr)*0.85); }
}
];
const fishes=[];
const speciesList=[];
function addSpecies(def){
const geo=makeFishGeo(def);
const mat=new THREE.MeshStandardMaterial({vertexColors:true,roughness:0.42,metalness:0.05,side:THREE.DoubleSide});
injectWag(mat);
const mesh=new THREE.InstancedMesh(geo,mat,def.count);
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
mesh.castShadow=true;
mesh.frustumCulled=false;
const tailAttr=new THREE.InstancedBufferAttribute(new Float32Array(def.count*2),2);
tailAttr.setUsage(THREE.DynamicDrawUsage);
geo.setAttribute('aTail',tailAttr);
scene.add(mesh);
speciesList.push({mesh,tailAttr});
const tint=new THREE.Color();
for(let i=0;i<def.count;i++){
const f={def,mesh,tailAttr,idx:i,seed:rand(100),
pos:new THREE.Vector3(),vel:new THREE.Vector3(),
quat:new THREE.Quaternion(),scl:new THREE.Vector3(),
tailPhase:rand(TAU),effort:0.45,excite:0,prevYaw:0,roll:0,nCount:0,
sep:new THREE.Vector3(),coh:new THREE.Vector3(),ali:new THREE.Vector3(),home:null};
if(def.key==='tetra') f.pos.set(2.5+rand(-2.4,2.4), 5.6+rand(-1.6,1.6), 0.5+rand(-2,2));
else if(def.key==='clown'){
const h=homes[i%homes.length]; f.home=h;
f.pos.set(h.x+rand(-2,2), clamp(h.y+rand(-0.6,1.6),1.8,6), h.z+rand(-2,2));
}
else if(def.key==='tang') f.pos.set(rand(-12,12), rand(2.2,4), rand(-8,8));
else f.pos.set(rand(-10,10), rand(5,8.4), rand(-7,7));
f.pos.y=clamp(f.pos.y,1.9,10.2);
const a=rand(TAU);
f.vel.set(Math.sin(a),rand(-0.2,0.2),Math.cos(a)).multiplyScalar(def.cruise*0.8);
const s=def.len*rand(0.86,1.14);
f.scl.set(s,s,s);
f.prevYaw=Math.atan2(f.vel.x,f.vel.z);
tailAttr.setXY(i,f.tailPhase,f.effort);
mesh.setColorAt(i,tint.setRGB(rand(0.9,1.06),rand(0.9,1.06),rand(0.9,1.06)));
_m.compose(f.pos,f.quat,f.scl);
mesh.setMatrixAt(i,_m);
fishes.push(f);
}
mesh.instanceColor.needsUpdate=true;
}
fishDefs.forEach(addSpecies);
/* ---------------- food ---------------- */
const MAXF=90;
const foods=[];
const foodGeo=new THREE.BufferGeometry();
const fPos=new Float32Array(MAXF*3), fCol=new Float32Array(MAXF*3), fSize=new Float32Array(MAXF), fFade=new Float32Array(MAXF);
foodGeo.setAttribute('position',new THREE.BufferAttribute(fPos,3).setUsage(THREE.DynamicDrawUsage));
foodGeo.setAttribute('aColor',new THREE.BufferAttribute(fCol,3).setUsage(THREE.DynamicDrawUsage));
foodGeo.setAttribute('aSize',new THREE.BufferAttribute(fSize,1).setUsage(THREE.DynamicDrawUsage));
foodGeo.setAttribute('aFade',new THREE.BufferAttribute(fFade,1).setUsage(THREE.DynamicDrawUsage));
const foodMat=new THREE.ShaderMaterial({
uniforms:{uPR:{value:PR}},
transparent:true, depthWrite:false, fog:true,
vertexShader:`
uniform float uPR;
attribute float aSize; attribute float aFade; attribute vec3 aColor;
varying vec3 vC; varying float vF;
#include <fog_pars_vertex>
void main(){
vC=aColor; vF=aFade;
vec4 mvPosition=modelViewMatrix*vec4(position,1.0);
gl_PointSize=max(aSize*uPR*(150.0/max(1.0,-mvPosition.z)),2.0);
gl_Position=projectionMatrix*mvPosition;
#include <fog_vertex>
}`,
fragmentShader:`
varying vec3 vC; varying float vF;
#include <fog_pars_fragment>
void main(){
vec2 q=gl_PointCoord-0.5;
float d=length(q)*2.0;
float a=(1.0-smoothstep(0.55,0.95,d))*vF;
gl_FragColor=vec4(vC,a);
#include <fog_fragment>
#include <colorspace_fragment>
}`
});
const foodPts=new THREE.Points(foodGeo,foodMat);
foodPts.frustumCulled=false;
scene.add(foodPts);
function sprinkle(px,pz,count){
for(let i=0;i<count;i++){
if(foods.length>=MAXF) foods.shift();
foods.push({
p:new THREE.Vector3(px+rand(-0.9,0.9), 10.9+rand(0,0.5), pz+rand(-0.9,0.9)),
v:new THREE.Vector3(rand(-0.15,0.15),0,rand(-0.15,0.15)),
seed:rand(50), size:rand(0.5,0.85), life:rand(16,26), fade:1, rest:false, dead:false,
c:new THREE.Color([0xff9b4a,0xe8703a,0xffd07a][i%3])
});
}
}
function updateFood(dt){
for(let i=foods.length-1;i>=0;i--) if(foods[i].dead) foods.splice(i,1);
const t=timeU.value;
for(const fo of foods){
if(!fo.rest){
fo.v.y=Math.max(fo.v.y-1.6*dt,-0.55);
fo.p.x+=(fo.v.x+Math.sin(t*2.1+fo.seed)*0.12)*dt;
fo.p.z+=(fo.v.z+Math.cos(t*1.7+fo.seed*1.3)*0.12)*dt;
fo.p.y+=fo.v.y*dt;
const gy=sandHeight(fo.p.x,fo.p.z)+0.07;
if(fo.p.y<=gy){ fo.p.y=gy; fo.rest=true; }
fo.fade=1;
} else {
fo.life-=dt;
fo.fade=clamp(fo.life/2.5,0,1);
if(fo.life<=0) fo.dead=true;
}
}
let n=0;
for(const fo of foods){
if(n>=MAXF) break;
fPos[n*3]=fo.p.x; fPos[n*3+1]=fo.p.y; fPos[n*3+2]=fo.p.z;
fCol[n*3]=fo.c.r; fCol[n*3+1]=fo.c.g; fCol[n*3+2]=fo.c.b;
fSize[n]=fo.size; fFade[n]=fo.fade;
n++;
}
foodGeo.setDrawRange(0,n);
foodGeo.attributes.position.needsUpdate=true;
foodGeo.attributes.aColor.needsUpdate=true;
foodGeo.attributes.aSize.needsUpdate=true;
foodGeo.attributes.aFade.needsUpdate=true;
}
/* ---------------- cursor ring & click ripples ---------------- */
const ringMat=new THREE.ShaderMaterial({
uniforms:{uTime:timeU,uOp:{value:0}},
transparent:true, depthWrite:false, blending:THREE.AdditiveBlending,
vertexShader:`
varying vec2 vUv;
void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
fragmentShader:`
uniform float uTime; uniform float uOp; varying vec2 vUv;
void main(){
vec2 q=vUv-0.5; float r=length(q)*2.0;
float ring=1.0-smoothstep(0.03,0.11,abs(r-0.72));
float halo=(1.0-smoothstep(0.0,0.35,abs(r-0.72)))*0.35;
float dot0=1.0-smoothstep(0.02,0.10,r);
float puls=0.75+0.25*sin(uTime*4.0);
float a=(ring*puls+dot0+halo)*uOp;
gl_FragColor=vec4(vec3(0.62,0.94,1.0),a);
#include <colorspace_fragment>
}`
});
const ring=new THREE.Mesh(new THREE.PlaneGeometry(3.3,3.3),ringMat);
ring.frustumCulled=false; ring.renderOrder=8;
scene.add(ring);
const ringPos=new THREE.Vector3(0,4.5,0), ringTarget=new THREE.Vector3(0,4.5,0);
const ripplePool=[];
for(let i=0;i<4;i++){
const m=new THREE.Mesh(new THREE.PlaneGeometry(3.4,3.4), new THREE.ShaderMaterial({
uniforms:{uK:{value:1}},
transparent:true, depthWrite:false, blending:THREE.AdditiveBlending,
vertexShader:`
varying vec2 vUv;
void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
fragmentShader:`
uniform float uK; varying vec2 vUv;
void main(){
vec2 q=vUv-0.5; float r=length(q)*2.0;
float rr=0.12+0.88*uK;
float ring=1.0-smoothstep(0.0,0.12,abs(r-rr));
float a=ring*(1.0-uK)*0.5;
gl_FragColor=vec4(vec3(0.62,0.94,1.0),a);
#include <colorspace_fragment>
}`
}));
m.visible=false; m.frustumCulled=false;
scene.add(m);
ripplePool.push({m,age:1});
}
function spawnRipple(x,y,z){
let r=ripplePool[0];
for(const q of ripplePool) if(q.age>=r.age) r=q;
r.age=0; r.m.visible=true; r.m.position.set(x,y,z);
}
/* ---------------- input ---------------- */
const pointer=new THREE.Vector2(0,0);
let pointerActive=false, lastInteract=-999, nextAuto=1.35, rotDir=1, elapsed=0;
function setPointer(e){
pointer.x=(e.clientX/innerWidth)*2-1;
pointer.y=-(e.clientY/innerHeight)*2+1;
}
addEventListener('pointermove',(e)=>{ setPointer(e); pointerActive=true; lastInteract=elapsed; },{passive:true});
addEventListener('blur',()=>pointerActive=false);
document.addEventListener('mouseleave',()=>pointerActive=false);
canvas.addEventListener('contextmenu',e=>e.preventDefault());
let downX=0, downY=0, downT=0;
canvas.addEventListener('pointerdown',(e)=>{
downX=e.clientX; downY=e.clientY; downT=performance.now();
setPointer(e); pointerActive=true; lastInteract=elapsed;
});
canvas.addEventListener('pointerup',(e)=>{
const moved=Math.hypot(e.clientX-downX,e.clientY-downY);
if(performance.now()-downT<300 && moved<9){
setPointer(e);
raycaster.setFromCamera(pointer,camera);
const ro=raycaster.ray.origin, rd=raycaster.ray.direction;
let tt=12;
if(rd.y>1e-3){ const tc=(10.9-ro.y)/rd.y; if(tc>2&&tc<60) tt=tc; }
_v1.copy(rd).multiplyScalar(tt).add(ro);
const px=clamp(_v1.x,-13.5,13.5), pz=clamp(_v1.z,-9,9);
sprinkle(px,pz,8);
spawnRipple(px,clamp(_v1.y,1.6,10.6),pz);
nextAuto=elapsed+34;
}
});
/* ---------------- simulation ---------------- */
let avgE=0;
const _acc=new THREE.Vector3();
function updateFish(dt){
const t=timeU.value;
raycaster.setFromCamera(pointer,camera);
const ro=raycaster.ray.origin, rd=raycaster.ray.direction;
const n=fishes.length;
for(let i=0;i<n;i++){ const f=fishes[i]; f.sep.set(0,0,0); f.coh.set(0,0,0); f.ali.set(0,0,0); f.nCount=0; }
for(let i=0;i<n;i++){
const a=fishes[i];
for(let j=i+1;j<n;j++){
const b=fishes[j];
const dx=a.pos.x-b.pos.x, dy=a.pos.y-b.pos.y, dz=a.pos.z-b.pos.z;
const d2=dx*dx+dy*dy+dz*dz;
const sepR=(a.def.len+b.def.len)*0.62;
if(d2<sepR*sepR){
const d=Math.sqrt(d2)||1e-4;
const push=(1-d/sepR)*7/d;
a.sep.x+=dx*push; a.sep.y+=dy*push; a.sep.z+=dz*push;
b.sep.x-=dx*push; b.sep.y-=dy*push; b.sep.z-=dz*push;
}
if(a.def===b.def && d2<a.def.neighR*a.def.neighR){
a.coh.add(b.pos); a.ali.add(b.vel); a.nCount++;
b.coh.add(a.pos); b.ali.add(a.vel); b.nCount++;
}
}
}
let exciteSum=0;
for(let i=0;i<n;i++){
const f=fishes[i], def=f.def;
_acc.set(0,0,0);
if(f.nCount>0){
_v1.copy(f.coh).divideScalar(f.nCount).sub(f.pos);
const dC=_v1.length();
if(dC>1e-4) _acc.addScaledVector(_v1.divideScalar(dC), def.coh*Math.min(1,dC*0.45));
_v1.copy(f.ali).divideScalar(f.nCount).sub(f.vel);
_acc.addScaledVector(_v1, def.ali*0.5);
}
_acc.add(f.sep);
_acc.x+=Math.sin(t*0.37+f.seed)*def.wander;
_acc.y+=Math.sin(t*0.45+f.seed*1.71)*def.wander*0.5;
_acc.z+=Math.cos(t*0.31+f.seed*2.33)*def.wander;
_acc.y+=(def.prefY-f.pos.y)*0.22;
if(f.home){
const hx=f.home.x-f.pos.x, hz=f.home.z-f.pos.z;
const hd=Math.hypot(hx,hz)||1e-4;
if(hd>4.5){ _acc.x+=hx/hd*0.8; _acc.z+=hz/hd*0.8; }
const near=1-sstep(3,6,hd);
_acc.y+=(f.home.y-f.pos.y)*0.55*near;
}
if(f.pos.x>13.5) _acc.x-=(f.pos.x-13.5)*2.6; else if(f.pos.x<-13.5) _acc.x+=(-13.5-f.pos.x)*2.6;
if(f.pos.z>9) _acc.z-=(f.pos.z-9)*2.6; else if(f.pos.z<-9) _acc.z+=(-9-f.pos.z)*2.6;
if(f.pos.y>9.6) _acc.y-=(f.pos.y-9.6)*3.2; else if(f.pos.y<1.7) _acc.y+=(1.7-f.pos.y)*3.2;
for(let k=0;k<obstacles.length;k++){
const r=obstacles[k];
const dx=f.pos.x-r.x, dy=f.pos.y-r.y, dz=f.pos.z-r.z;
const rr=r.r+0.55, d2=dx*dx+dy*dy+dz*dz;
if(d2<rr*rr){
const d=Math.sqrt(d2)||1e-4;
const kk=(rr-d)*6/d;
_acc.x+=dx*kk; _acc.y+=dy*kk; _acc.z+=dz*kk;
}
}
let best=null, bd=9.5;
for(let k=0;k<foods.length;k++){
const fo=foods[k];
if(fo.dead) continue;
const d=f.pos.distanceTo(fo.p);
if(d<bd){ bd=d; best=fo; }
}
if(best){
_v1.copy(best.p).sub(f.pos);
const dl=_v1.length();
if(dl>1e-4) _acc.addScaledVector(_v1.divideScalar(dl), 3.0+(9.5-dl)*1.2);
if(bd<3) f.excite=Math.max(f.excite,0.35);
if(dl<0.30+def.len*0.35){
best.dead=true;
f.excite=Math.min(1.3,f.excite+0.3);
spawnRipple(best.p.x,best.p.y,best.p.z);
}
}
// flee the cursor (distance to the pointer's ray through the tank)
if(pointerActive){
_v1.copy(f.pos).sub(ro);
const tt=clamp(_v1.dot(rd),3,60);
_v2.copy(rd).multiplyScalar(tt).add(ro);
_v3.copy(f.pos).sub(_v2);
const d=_v3.length();
if(d<3.4){
const k=1-d/3.4;
if(d>1e-3) _acc.addScaledVector(_v3.divideScalar(d), 60*k*k);
f.excite=Math.max(f.excite, k*1.25);
}
}
const al=_acc.length();
if(al>60) _acc.multiplyScalar(60/al);
f.excite*=Math.exp(-dt*1.6);
const maxS=def.maxS*(1+f.excite*1.3);
f.vel.addScaledVector(_acc,dt);
f.vel.multiplyScalar(Math.exp(-dt*0.15));
let sp=f.vel.length();
const minS=def.cruise*0.55;
if(sp>maxS){ f.vel.multiplyScalar(maxS/sp); sp=maxS; }
else if(sp<minS){
if(sp>1e-5) f.vel.multiplyScalar(minS/sp); else f.vel.set(minS,0,0);
sp=minS;
}
f.pos.addScaledVector(f.vel,dt);
f.pos.x=clamp(f.pos.x,-15.5,15.5);
f.pos.y=clamp(f.pos.y,1.4,10.6);
f.pos.z=clamp(f.pos.z,-10.5,10.5);
f.effort+=((sp/def.maxS)+f.excite*0.5-f.effort)*Math.min(1,dt*5);
f.tailPhase=(f.tailPhase+dt*(5+f.effort*9+f.excite*5))%TAU;
const yaw=Math.atan2(f.vel.x,f.vel.z);
const hs=Math.hypot(f.vel.x,f.vel.z);
const pitch=clamp(-Math.atan2(f.vel.y,hs||1e-4),-0.6,0.6);
const turnRate=dt>1e-4?wrapPI(yaw-f.prevYaw)/dt:0;
const tRoll=clamp(-turnRate*0.22,-0.5,0.5);
f.roll+=(tRoll-f.roll)*Math.min(1,dt*3.5);
f.prevYaw=yaw;
_e.set(pitch,yaw,f.roll,'YXZ');
_q.setFromEuler(_e);
f.quat.slerp(_q,1-Math.exp(-dt*7));
_m.compose(f.pos,f.quat,f.scl);
f.mesh.setMatrixAt(f.idx,_m);
f.tailAttr.setXY(f.idx,f.tailPhase,f.effort);
exciteSum+=f.excite;
}
avgE=exciteSum/n;
for(const s of speciesList){ s.mesh.instanceMatrix.needsUpdate=true; s.tailAttr.needsUpdate=true; }
}
/* ---------------- status + fx ---------------- */
const statusEl=document.getElementById('statusText');
const dotEl=document.getElementById('dot');
const STATES=[['the water is calm','#7ee0a3'],['the fish are curious','#7fd4ff'],['the shoal stirs','#ffd166'],['the shoal scatters!','#ff8a7a']];
let stateIdx=0;
function setStatus(e){
const idx=e<0.10?0:e<0.32?1:e<0.6?2:3;
if(idx!==stateIdx){
stateIdx=idx;
statusEl.textContent=STATES[idx][0];
dotEl.style.background=STATES[idx][1];
}
}
function updateFX(dt){
if(pointerActive){
raycaster.setFromCamera(pointer,camera);
const ro=raycaster.ray.origin, rd=raycaster.ray.direction;
let tt=rd.z<-1e-4?(0-ro.z)/rd.z:12;
tt=clamp(tt,2,40);
_v1.copy(rd).multiplyScalar(tt).add(ro);
_v1.x=clamp(_v1.x,-14.5,14.5); _v1.y=clamp(_v1.y,1.4,10.4); _v1.z=clamp(_v1.z,-10,10);
ringTarget.copy(_v1);
}
ringPos.lerp(ringTarget,1-Math.exp(-dt*14));
ring.position.copy(ringPos);
ring.quaternion.copy(camera.quaternion);
const want=pointerActive?0.5:0;
ringMat.uniforms.uOp.value+=(want-ringMat.uniforms.uOp.value)*Math.min(1,dt*6);
for(const r of ripplePool){
if(r.age<1){
r.age=Math.min(1,r.age+dt/0.9);
r.m.material.uniforms.uK.value=r.age;
r.m.quaternion.copy(camera.quaternion);
if(r.age>=1) r.m.visible=false;
}
}
if(elapsed>nextAuto){
if(nextAuto<5) sprinkle(1.5+rand(-1,1), 0.5+rand(-1,1), 9);
else sprinkle(rand(-6,6), rand(-5,4), 7);
nextAuto=elapsed+rand(28,40);
}
setStatus(avgE);
}
/* ---------------- main loop ---------------- */
const clock=new THREE.Clock();
function tick(){
requestAnimationFrame(tick);
const dt=Math.min(clock.getDelta(),0.033);
elapsed+=dt;
timeU.value=elapsed;
if(elapsed-lastInteract>16){
controls.autoRotate=true;
if(Math.abs(controls.getAzimuthalAngle())>0.95) rotDir=-rotDir;
controls.autoRotateSpeed=0.26*rotDir;
} else controls.autoRotate=false;
controls.update();
camera.position.y=clamp(camera.position.y,1.5,10.6);
updateFood(dt);
updateFish(dt);
updateFX(dt);
renderer.render(scene,camera);
}
tick();
addEventListener('resize',()=>{
camera.aspect=innerWidth/innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth,innerHeight);
});
</script>
</body>
</html>Reasoning trace (105,097 tokens)
The user wants a 3D aquarium with Three.js where fish swim away when the mouse moves near them. I need to:
1. Load Three.js via the specified import map
2. Create a self-contained HTML file with everything inline
3. Make it look great at 1280×800 and adapt to 375px
4. Something meaningful on first paint (fish swimming immediately)
5. Games must start in self-playing demo mode... well, this isn't exactly a game, but I should ensure it's visually interesting immediately. The mouse interaction is the key feature — fish flee from mouse. Since a screenshot is taken ~3 seconds after load without interaction, the aquarium must be alive and beautiful on its own.
Let me design this:
**Scene concept:**
- An aquarium tank (glass box) viewed from outside, or an underwater scene filling the screen?
- I think a proper aquarium tank look is more charming: a glass tank with visible edges, water volume with fog, caustics-like lighting, sand floor, rocks, seaweed/kelp plants that sway, bubbles rising, and colorful fish swimming with a boids-like flocking behavior + flee-from-mouse.
Actually, maybe a full-screen underwater scene with subtle tank framing could look better. Let me think about what looks most impressive.
Option A: A tank sitting in a room — harder to make look good without good lighting/reflections.
Option B: Camera inside the tank, full-screen water view with sand, plants, rocks, god rays, caustics — very pretty and immersive.
Option C: A hybrid: full-screen underwater view but with subtle vignette and a "glass" edge feel.
I'll go with a full-screen underwater aquarium view — the camera looks into the aquarium, water fills the frame, with fog for depth, animated caustic light patterns on the sand, swaying kelp, drifting bubbles, floating particles (marine snow), rocks, and a school(s) of fish with smooth procedural animation (tail wagging via vertex shader or per-mesh bone-less deformation).
**Fish:**
- Build fish procedurally from geometry: body (elongated shape), tail fin that wags. I can build a fish using a custom geometry and animate via vertex shader (bend based on x position and time) — that's elegant and performant. Or simpler: build fish from a few meshes (body + tail) and rotate the tail. But shader-based bending looks much smoother.
Let me do a shader-deformed fish: a single geometry per fish species, with `onBeforeCompile` on a MeshStandardMaterial (or MeshPhongMaterial) injecting a vertex shader that wags the tail: displacement `z += sin(time * freq + x * wave) * amplitude * smoothstep(...)` based on body coordinate x. Each fish instance has its own phase — but with instancing, phase can come from instance matrix or an instanced attribute.
Plan: Use `InstancedMesh` for each fish species (e.g., 3 species with different colors/shapes), with an instanced attribute for phase/speed. Each frame, update instance matrices for position/orientation from the simulation (CPU-side boids: ~60-120 fish is fine to update on CPU each frame).
Actually, with ~100 fish updating matrices per frame on CPU — that's fine, matrix composition is cheap.
Vertex shader deformation with instancing: the shader runs per-vertex with instanceMatrix applied. I want the tail wag to happen in fish-local space before the instance matrix. With `onBeforeCompile`, I can inject code into `begin_vertex` to modify `transformed` before `#include <begin_vertex_vertex>`... Actually in three.js, instancing is applied in `project_vertex` via `instanceMatrix` after `begin_vertex` sets `transformed`. Wait: order is:
```
#include <begin_vertex> // transformed = vec3(position)
#include <morphtarget_vertex>
#include <skinning_vertex>
#include <displacementmap_vertex>
#include <project_vertex> // mvPosition = vec4(transformed,1.0); then instanceMatrix applied
```
Yes, `project_vertex` applies instanceMatrix: `mvPosition = instanceMatrix * mvPosition`. So modifications to `transformed` in `begin_vertex` injection happen in fish-local space.
I need a per-instance phase attribute: `attribute float aPhase; attribute float aSpeed;` — set via InstancedBufferAttribute. And uniform `uTime`. Inject after `#include <begin_vertex>`:
```glsl
float wag = sin(uTime * aSpeed + aPhase + transformed.x * 2.0);
float bendFactor = smoothstep(0.3, -1.0, transformed.x); // more at tail (negative x)
transformed.z += wag * 0.25 * bendFactor * (1.0 + uFlee); // wag faster when fleeing?
transformed.y += ...
```
Hmm, tail at negative x or positive x? I'll model fish facing +X (nose at +X, tail at -X). Then orientation: compute yaw so fish faces velocity direction.
Body geometry: I can create a fish body by lathe or by deforming a sphere/capsule. Simplest good-looking: take a `SphereGeometry(1, 16, 16)` and scale vertices to elongate along x, taper toward tail, and flatten sides (z). Plus a tail fin: a flattened cone/triangle, and dorsal fin. Merge geometries via BufferGeometryUtils? I can import addons: `three/addons/utils/BufferGeometryUtils.js` — allowed via import map ("three/addons/"). I could also just create the fins as part of the same geometry manually by generating my own BufferGeometry. Let me think.
Simplest robust approach: construct fish geometry from a sphere deformed + a couple of triangles as fins, merged with BufferGeometryUtils.mergeGeometries. That requires consistent attributes. SphereGeometry and cone/cylinder geometries all have position/normal/uv, so merge works if I remove groups or use `mergeGeometries(geos, false)`.
Let me design one species:
- Body: sphere scaled to (1.6, 0.55, 0.38) with taper: for x < 0, scale y,z down toward tail. I'll modify vertex positions directly:
For each vertex of a sphere of radius 1: p = (x, y, z). New: X = x * 1.0 (keep), then apply profile: bodyRadius(t) where t from -1 (tail) to +1 (nose). Actually easier: start from sphere, map x in [-1,1]; scale factor s(x) = based on profile: nose region tapers too. E.g.:
```
const px = pos.x; // -1..1
const t = (px + 1) / 2; // 0 at tail, 1 at nose
const profile = Math.sin(Math.pow(t, 0.8) * Math.PI) adjusted;
```
Then set x' = px * L (length), y' = y * profile * H, z' = z * profile * W * flatten.
- Tail fin: a triangle-ish quad in the x-y plane at the tail, thin in z. Could use a custom BufferGeometry (two triangles) — but need normals. I can create a simple plane geometry (PlaneGeometry) rotated, positioned at tail, scaled — then it has proper normals. Tail: PlaneGeometry(0.5, 0.6) rotated so plane is vertical (x-y plane), positioned at x = -L*... Actually plane in x-y: default PlaneGeometry lies in XY plane facing +Z. Good — vertical tail fin. Shape it: taper vertices to make a forked tail? I can modify plane vertices to form a nice caudal fin shape (triangle/fan). Or just use a custom shape via `ShapeGeometry`: define a 2D fin outline with THREE.Shape, extrude slightly or leave flat (ShapeGeometry is flat, single-sided — with side: DoubleSide material it's fine, but the InstancedMesh material is one material... I can use DoubleSide for the whole fish material; body is closed so double side is fine, slight cost but ok).
- Dorsal fin: another shape on top. Pectoral fins: two small shapes on sides.
Merging: BufferGeometryUtils.mergeGeometries([body, tail, dorsal, pecL, pecR]). All must have same attributes: position, normal, uv. Sphere has uv; ShapeGeometry has uv; PlaneGeometry has uv. Good.
To keep the vertex-shader wag coherent across merged parts, all parts must share the same local coordinate frame — yes, since we bake transforms into vertices.
But the wag in the shader displaces z based on x — the fins will wag with the body naturally since they share coordinates.
For different species, I can vary: body proportions (length, height, color), fin shapes, colors. Use vertex colors per part? That'd let me color fins differently from body (e.g., translucent-ish fins). With `vertexColors: true` on material and adding a `color` attribute to merged geometry. That's nice: body gradient (dark top → light belly — counter-shading!), colored fins, stripe patterns maybe via color attribute based on position (e.g., vertical stripes for a clownfish-like species: color by sin(x*k)). Clownfish-like stripes would be charming! Stripes: in body geometry, set vertex color mix between orange and white based on band function of x. Fins orange with black edges maybe. That gives a really nice look with zero textures.
Let me define 3 species:
1. "Clownfish": orange body with white bands + black outlines, moderate size. ~14 fish, they could hover near rocks/anemone area.
2. "Neon tetra / blue-yellow school fish": small, sleek, blue-silver with yellow tail — big school of ~40, tight flocking.
3. "Yellow tang": taller body, disc-like, all yellow with slight darker fin edges. ~8.
Maybe 4th: a big slow one? "Royal gramma"? Keep 3-4 species. Let's do 4:
4. "Angelfish-like" tall silver with black stripes — 6, slow graceful.
Actually let me limit complexity but make it look rich. 3 species is fine, maybe 4. Let's do:
- Species A: orange clownfish, bands (boids with slight anemone affinity? skip affinity—just boids).
- Species B: blue-green neon school (small, many, tight school) — 45 fish.
- Species C: yellow tang (tall disc) — 10.
- Species D: angelfish (silver with black vertical stripes, tall) — 8.
Hmm, that's 4 different geometries to build. Each built by the same generator with parameters (length, height, width, fin shapes, colorer function). Doable with a parametric generator.
**Behavior:**
- Boids per species: cohesion, alignment, separation within species; avoidance of tank bounds (soft), avoidance of rocks (maybe simple sphere obstacles), and mouse flee.
- Mouse: raycast mouse into water volume — the pointer position projected onto a plane (z = mid-depth) gives a 3D point; fish within radius R of that point get a repulsion force. Also could make it based on distance from the mouse ray (line), which feels more natural in 3D: fish flee from the cursor ray (the line from camera through mouse into the scene). Distance from point to ray segment within the tank. That's better: fish near where you're pointing flee, regardless of depth. Use ray closest-point distance, clamp parameter t to tank depth range.
Flee force: strength ~ (1 - d/R)^2 * strong. Also temporarily boost wag speed (uFlee uniform per species or store per-fish "excitement" → could use instanced attribute updated occasionally... updating an InstancedBufferAttribute each frame for excitement is fine, small array). Actually simpler: global uniform per mesh for wag boost based on how many fish fleeing; or per-instance attribute `aWag` updated each frame via `instanceAttr.needsUpdate = true`. That's cheap for ≤100 floats. Let's do per-instance `aSpeedWag` recomputed as base + excitement. Hmm, but simpler: keep `aPhase` static, and pass excitement via updating `aWag` array each frame (only when changed). Fine — set needsUpdate every frame, it's a tiny buffer.
Also fun: fish speed multiplier when fleeing — handled in simulation.
**Environment:**
- Water: scene.background gradient? Use a large background: fog color matching. Underwater ambience: hemisphere light (water surface tint from above, darker below), directional light from above (sun through water), maybe a spotlight.
- Caustics: animated light patterns on the sand. Implementation options:
- A shader on the sand plane: procedural caustic using layered noise/voronoi-ish sin functions animated over time. That's a classic trick: `caustic = pow(abs(sin(...)*sin(...)), k)`. I can do a decent fake caustic in the sand's fragment shader via onBeforeCompile injecting into `#include <emissivemap_fragment>` or modifying diffuse color. Actually easier: make the sand material use onBeforeCompile to add caustic to outgoing light: inject after `#include <map_fragment>` modify `diffuseColor.rgb *= (1.0 + caustic * strength)`.
- Procedural caustic function (classic "voronoi-ish" using sin/cos warping):
```glsl
float caustic(vec2 uv, float t){
vec2 p = uv*6.0;
float c = 0.0;
... layered
}
```
A well-known cheap caustic:
```glsl
vec2 p = mod(uv*TAU, TAU)-250.0; // from the famous shader
```
The famous "Seascape"-ish caustics by Dave Hoskins / "Water caustic" by joltz0r. Simpler: sum of two moving sin patterns:
`float a = sin(p.x + t) * sin(p.y + t*1.3);` layered 3 octaves with abs and pow — gives blobby moving patterns. Good enough at low cost. I'll write my own:
```glsl
float caustics(vec2 p, float t) {
float c = 0.0;
vec2 q = p;
for (int i=0;i<3;i++){
q += vec2(sin(q.y*1.7 + t*0.9), cos(q.x*1.3 - t*1.1)) * 0.6;
c += 1.0/length(vec2(sin(q.x+t), cos(q.y+t*0.8)));
}
...
}
```
Hmm, that could be expensive/artifacty. Safer approach: interference pattern:
```glsl
float caustics(vec2 uv, float t){
float v = 0.0;
float w = 1.0;
vec2 p = uv;
for(int i=0;i<3;i++){
p += vec2(sin(p.y*1.4 + t*(0.7+w*0.2)), cos(p.x*1.2 - t*(0.6+w*0.3)))*0.35;
v += w * abs(sin(p.x*2.1 + t) * sin(p.y*2.3 - t*1.2));
w *= 0.55;
}
return pow(v*0.8, 3.0);
}
```
Something like that yields moving blobby web patterns. I'll tune constants; even if not physically accurate, animated mottled light on sand reads as caustics. Also apply subtle caustic tint to fish? Maybe skip; keep on sand and rocks (rocks share sand material? different). Apply on sand only, plus maybe a faint version on rocks via same material approach... I can give rocks a material with the same injection. Simple: write a helper `addCaustics(material, strength)` that uses onBeforeCompile to multiply diffuseColor by caustic based on world xz position and uTime.
- God rays: a few large translucent additive planes/cones from above with a shader fading, slowly rotating/drifting. Could do simple: several elongated triangles (custom geometry) with additive blending, depthWrite false, opacity animated. Nice touch. Or a big cylinder with gradient shader. Keep it simple: ~6 elongated quads slanted, additive, low opacity, slowly swaying via rotation of a group. Looks great with fog. Let me implement god ray quads: PlaneGeometry(width ~ random, height = water depth), material: ShaderMaterial or MeshBasicMaterial with vertex alpha gradient texture — no textures allowed... procedural: use a ShaderMaterial with vertical gradient alpha and horizontal soft edges. Simple shader:
```glsl
varying vec2 vUv;
float a = smoothstep(0.,0.25,vUv.y)*smoothstep(1.,0.6,vUv.y) * smoothstep(0.,0.4,vUv.x)*smoothstep(1.,0.6,vUv.x);
gl_FragColor = vec4(color, a * uOpacity);
```
Additive blending. These read as light shafts. Also animate opacity flicker.
- Water surface: a plane at the top of the tank viewed from below — animated waves (vertex displacement via shader or CPU) with a slightly reflective look; from below it shimmers. I can make a plane with segments, displace via CPU each frame (small grid, e.g., 48×48, fine) or shader. Shader cheaper: onBeforeCompile displacement + normal recompute is complex; CPU update of 2k verts is fine. Actually simpler: keep static plane with a shader-animated normal-ish gradient... Hmm. A visible wavy surface with total internal reflection look is hard without env maps. Alternative: MeshPhysicalMaterial with transmission? Expensive and needs something to refract.
Simpler approach that looks good: dark blue plane with animated ripple pattern in fragment shader (fake) — from below, it reads as the water surface shimmer. Use a ShaderMaterial: color gradient + moving interference highlights (like caustics but brighter, bluish white), semi-transparent? If it's the top boundary of the tank, we mostly see its underside lit by the "sun". I'll make a big plane at top with a custom shader: base deep color + moving sin-based highlight streaks + fresnel-ish. Good enough and cheap.
Do we even need a top surface? If the camera is inside the water looking somewhat downward/forward, the surface at top adds a lot of realism. Yes, add it.
- Sand floor: plane with gentle noise displacement (static, CPU-generated with a few sin octaves) + caustics shader + slight sparkle? Sand color: warm beige. Displacement: ripple dunes via sin functions; compute normals via computeVertexNormals.
- Rocks: a few icosahedron-based rocks (IcosahedronGeometry with vertex noise displacement), gray-brown, scattered, partially buried. Use one or two geometries reused with random scale/rotation. Also caustic applied subtly.
- Kelp/seaweed: tall strands that sway. Implementation: each strand is a chain of segments... elegant approach: a tall thin plane (e.g., 12 segments high, 1 wide) with vertex shader sway based on height (uv.y) and time, double-sided, green gradient. Use InstancedMesh with per-instance phase and position. Sway: `transformed.x += sin(uTime*speed + phase + uv.y*3.0) * uv.y^2 * amp`. Since instancing applies after, local sway works. Blades: shape like a leaf: taper width with height — modify geometry vertices: width *= (1 - uv.y*0.7) with slight curve. A group of 15-25 blades in clusters near rocks. Color: green-teal gradient (darker at base). Also a couple of shorter broad-leaf plants.
Even better: "sea grass" clusters of many thin blades. With instancing: one blade geometry, ~40 instances at cluster positions with random heights via instance matrix scale. Good.
- Bubbles: rising from a couple of spots (like an air stone near rocks). Implementation: Points with custom shader (round soft sprite via gl_PointCoord), rising with wobble, reset at top. ~80 bubbles, two emitters. Points material: ShaderMaterial with uniform size attenuation manually in shader: `gl_PointSize = size * (300.0 / -mvPosition.z)`. Circle with soft edge: `float d = length(gl_PointCoord-0.5); alpha = smoothstep(0.5,0.35,d) * ...` plus a highlight offset for bubble look: ring: `alpha = smoothstep(0.5,0.42,d)*smoothstep(0.28,0.42,d)` gives ring; plus small specular dot. Additive-ish normal blending with transparency.
- Floating particles (marine snow / plankton): Points, tiny, drifting slowly — adds depth. ~200 points.
- Tank boundaries: The "aquarium" concept — maybe include subtle tank walls? If camera is inside, walls are invisible; instead use fog to imply depth, plus maybe a subtle vignette. I think a full underwater scene with implied boundaries via soft repulsion (fish stay within an invisible box) is fine, and I can add a hint of the front glass? Eh — a "3D aquarium" reads as a tank. But visually, immersive underwater is prettier. I'll present it as looking into the tank: camera inside near front glass. Add subtle floating dust to sell water.
Maybe add frame: The page around the canvas: I'll design the HTML overlay: title, small caption, instructions ("Move your mouse — the fish will scatter"), maybe a subtle hint UI. Also a "click to feed"? Extra interaction beyond mouse-flee: clicking drops food flakes that fish swim toward! That would be a lovely addition and makes it a real interactive aquarium. But careful: task says demo must work without interaction; food on click is optional extra. It's a nice touch: click anywhere → food pellets sink slowly → nearby fish are attracted, create feeding frenzy. That adds "wow". I'll implement: click/tap spawns 6-10 food morsels at mouse ray point at surface level, sinking with drag; fish seek nearest food within range; on contact, food is consumed (shrink/remove) and fish does quick dart. Manageable. And in auto/demo mode (no interaction yet), occasionally auto-drop food? The demo mode requirement is for games; here the scene is alive anyway. But an automatic occasional feeding in the first seconds could look great in the screenshot... The screenshot at ~3s: fish swimming + caustics is already good. Maybe drop an automatic food sprinkle at t=1.5s so screenshot shows gathering fish? Could be chaotic; skip or do one gentle sprinkle. I'll do a small auto sprinkle at ~2s — actually that makes the 3s screenshot show a feeding flock which is interesting. Let's do it: auto-sprinkle once shortly after load. Hmm, but then fish clumped at a spot — combined with scatter behavior it shows variety. I'll do it once at ~2.2s, small amount.
Wait — "Games must start in a self-playing demo mode (a simple AI plays) behind a 'Click or press a key to start' message". This isn't a game; it's an interactive scene. There's no keyboard needed. The demo works without any input: fish swim on their own. I think the overlay should say something like "Move your cursor near the fish — they'll scatter. Click to drop food." No keyboard. I'll not add a blocking overlay; just a small caption that fades. But the rules say "if the demo uses the keyboard, display a clear message" — I don't use keyboard. Mouse works without click focus? Mouse move events should work in the iframe without click (mouse events are not blocked like keyboard). Yes, pointer/mouse events go to the iframe on hover. So flee-on-hover works immediately.
**Camera:** Slight slow orbit/sway of camera (idle motion) to make it feel alive; plus subtle parallax with mouse (camera eases toward mouse offset slightly). OrbitControls? Could allow drag to orbit — but drag conflicts with click-to-feed... I can use OrbitControls with pan disabled and limit angles, and distinguish click (short, no move) vs drag for feeding. That adds nice interactivity: drag to look around. But it also may complicate. Alternative: no OrbitControls; camera has gentle auto-drift + mouse parallax. Simpler and less bug-prone. But dragging to look around is nice... Let me include OrbitControls but disabled zoom? Actually mouse wheel zoom is nice too. Conflict: click-to-feed vs drag: use pointerdown time & movement threshold; if pointerup within 250ms and <6px movement → feed. That's standard. I'll include OrbitControls with damping, min/max polar & azimuth, min/max distance. But then camera parallax from mouse conflicts — drop parallax, orbit handles it. Also on mobile touch: orbit works via touch; tap = feed. Good.
Hmm, but OrbitControls target and the mouse-ray for flee: the flee ray uses camera + mouse NDC — works regardless of orbit. OK.
Enable: controls.enableDamping = true; controls.enablePan = false; limits: minDistance 4, maxDistance 26; maxPolarAngle to not go under sand; minPolarAngle to not go above surface... Actually camera should stay inside the tank region roughly. I'll set target at tank center (0, 3.5, 0), distance limits keep it inside-ish. Fish bounds box is like x∈[-14,14], y∈[1,11], z∈[-9,9]; camera distance max 20 might exit back wall; fine as long as fog hides. Let me set fog so far plane murky.
Also constrain camera position: after controls.update(), clamp y between 1.2 and 10.5, and maybe clamp within |x|,|z|... OrbitControls with fixed target and distance limits handles most; polar limits handle y mostly. I'll clamp y post-update and also clamp distance to target to max.
**Water color/fog:** Deep teal-blue fog: `scene.fog = new THREE.FogExp2(0x06283d, 0.028)` or similar. Background: gradient — use a big sphere? With FogExp2 and background color matching, the distance fades to water color. Set background to similar deep blue. Maybe slightly lighter at top: a large inverted sphere with gradient shader (Baker...) — a custom gradient dome is easy: SphereGeometry, ShaderMaterial with world-position-based gradient (top lighter aqua, bottom deep), side: BackSide, fog: false. That gives vertical ambience. Also sun glow spot? Keep gradient simple.
Lighting:
- HemisphereLight(sky = light aqua 0xbfe8ff-ish, ground = dark blue, intensity ~0.6)
- DirectionalLight from above-front (sun), intensity ~1.2, slight warm-white.
- A soft blue point light somewhere for fill.
- Maybe ambient subtle.
Fish materials: MeshStandardMaterial? For performance with instancing and vertex colors, MeshLambertMaterial is cheaper and looks fine underwater (soft). Standard with roughness could look nicer with lights... Lambert with vertexColors and flat-ish shading is fine; but specular glints on fish are nice → MeshPhongMaterial with low shininess gives wet look. Use Phong, shininess ~60, specular subtle. Good.
Also fish should get slight fog automatically — yes.
Shadows? Directional shadow on sand from fish would be lovely but expensive with instanced deformations... InstancedMesh casts shadows fine (depth material won't have my vertex displacement though — shadow would be undeformed, which is subtle since wag is small; but shadows also require shadow map setup + PCF soft — could add cost. With ~100 fish + plants, shadow map is OK (one 1024 map). The deformation mismatch would be minor. Rocks and plants casting shadows on sand adds a lot of depth. Let me enable shadows: renderer.shadowMap.enabled, type PCFSoft; dir light with orthographic shadow camera covering tank ~ 34×22. Fish castShadow = true (instanced OK), sand receiveShadow = true, rocks cast+receive, kelp cast? Kelp sway is in vertex shader → shadow won't sway; might look off but subtle. I'll enable kelp shadows too — static shadow of kelp looks fine actually (blades are thin). Fish shadows: cast=true. The fish shadow deformation mismatch is acceptable (wag is subtle).
Actually wait — the shadow depth material for InstancedMesh does apply instanceMatrix (three handles instancing in depth material automatically), just not my custom onBeforeCompile displacement. Fine.
**Boids simulation details:**
Per fish state: pos (Vector3), vel (Vector3), species, phase, wagExcite. Update per frame (dt clamped):
Forces:
- separation: within species (and maybe cross-species weak), radius ~0.9, push
- alignment/cohesion: within species, radius ~4 (scaled by species sociability)
- wander: small perlin-ish noise via sin of time+phase
- bounds: soft spring toward inside when beyond margin
- obstacle avoid: rocks (spheres): steer away within radius
- mouse flee: from ray point p(t) clamped to box; force ~ (1 - d/R)² * K, R ~ 3.2; also fish get speed boost & excitement
- food attract: if food exists within 8 units, steer toward nearest, strong when close; eat when <0.25.
- depth preference per species (some like mid-water, tangs near bottom): gentle spring toward preferred y.
Velocity: clamp speed between min and max (max boosted when fleeing). Orient: smooth quaternion toward velocity heading — compute yaw = atan2(-vz? ) Let's define fish model faces +X. To orient +X toward v: yaw around Y: angle = atan2(-v.z, v.x)? For a vector v, the rotation about Y that maps +X to (vx, 0, vz) normalized: yaw = atan2(-v.z, v.x)?? Let's verify: rotating +X by yaw θ around Y: x' = cosθ, z' = -sinθ. So to get (vx, vz): cosθ = vx/|v|, -sinθ = vz/|v| → θ = atan2(-vz, vx). Yes. Pitch: rotation about Z? For up/down tilt: pitch φ about local z-axis... After yaw, tilt nose up/down: rotate about the local Z axis (which after yaw points...). Simpler: build quaternion from yaw (Y axis) then pitch around the fish's local Z: q = qYaw * qPitch where qPitch = rotation about Z axis by φ = atan2(v.y, sqrt(vx²+vz²))... For a fish facing +X, pitching nose up means rotating about Z? Rotating about +Z by positive angle moves +X toward +Y — yes, nose up. But order: we want yaw applied first in world, pitch about the yawed z-axis: q = qYaw.multiply(qPitch). Also add gentle roll on turns (banking): roll about local X proportional to turn rate — nice touch. Use quaternion slerp for smoothness: build target quaternion each frame, then `fish.quat.slerp(target, 1 - exp(-k*dt))`.
Hmm — with InstancedMesh, each fish's transform is composed on CPU: I need per-fish quaternion stored, then compose matrix = position + quaternion + scale. Fine: keep per-fish quat, slerp toward target.
Banking: compute turn = yawRate; add roll into target quaternion: qRoll about local X: qTarget = qYaw * qPitch * qRoll. Let me just do it: build Euler? Euler order 'YXZ' or 'YZX'... Easiest: THREE.Euler with order 'YXZ': yaw (Y), then pitch... but pitch about local Z not X in my frame. I could instead model the fish facing +Z! Convention: three.js lookAt orients +Z? Actually Object3D.lookAt makes -Z point at target for cameras; for regular objects lookAt orients +Z toward target. If I model fish along +Z (nose +Z), then Euler order 'YXZ' with yaw about Y, pitch about X gives standard heading, and roll about Z. That's cleaner: yaw = atan2(vx, vz) (for facing +Z: rotating +Z by yaw about Y: z' = cosθ, x' = sinθ → θ = atan2(vx, vz)). Pitch about X: positive pitch rotates +Z toward -Y (nose down)? Rotating about +X: +Z goes toward +Y? Rotation about X by θ: y' = y cosθ - z sinθ; z' = y sinθ + z cosθ. For +Z (0,0,1): y' = -sinθ, z' = cosθ. So positive θ about X moves nose down (toward -Y). So pitch = -atan2(vy, horizSpeed) → nose up when vy>0 with negative pitch. Whatever — set pitch = atan2(-vy, h) about X. Then roll about Z for banking (sign tuned). Use `new THREE.Euler(pitch, yaw, roll, 'YXZ')` — with YXZ order, applied yaw first? Three.js Euler order 'YXZ' means rotation matrix = R = Ry * Rx * Rz? In three.js, Euler order 'XYZ' means R = Rx*Ry*Rz? Let me recall: three.js Euler 'XYZ' applies X first? The quaternion setFromEuler composes in the order specified such that 'XYZ' = Rz applied first? Hmm. In three.js, for Euler order 'XYZ', the rotation matrix is Rx·Ry·Rz? Actually makeRotationFromEuler for 'XYZ' builds matrix equal to R = RX * RY * RZ. So it applies Rz first (rightmost applied first to the vector). So for 'YXZ': R = RY * RX * RZ → RZ applied first (roll in local frame), then RX (pitch), then RY (yaw). That's exactly intrinsic yaw-pitch-roll: yaw applied last in world.
So with fish nose along +Z: Euler(pitchX, yawY, rollZ, 'YXZ') works: vector transformed = Ry(Rx(Rz(v))). Local +Z → roll keeps it, pitch tilts, yaw turns.
So model fish geometry with nose at +Z, tail at -Z, wag displacement along X. Adjust vertex shader: `transformed.x += wag(amount based on transformed.z)` with amplitude increasing toward tail (negative z). Body profile along z.
Rebuild geometry generator accordingly:
- Sphere radius 1 → for each vertex: t = (z+1)/2 ∈ [0,1], tail t=0, nose t=1.
- Profile function: body radius factor r(t) = sin(π * pow(t, 0.75))^0.8-ish, giving fuller front, tapered tail. Also nose taper: at t=1 r→0 (sin π = 0) good.
- Position: Z' = (t - 0.35) * length (so body center offset), X' = x_orig * r * width * flattenX? Wait: sphere coords: x, y, z original each in [-1,1] with x²+y²+z²=1. For body: new = (x * r(t) * halfWidth, y * r(t) * halfHeight, z * length-ish). Hmm but I want cross-section round-ish but flattened sideways (x is side axis). Fish cross section: taller than wide: height factor 0.9, width factor 0.45.
Actually since original z also affects position along body, mapping z→body axis directly with remap: bodyZ = z * len/2 * 1.15 maybe stretch nose. Fine: bodyZ = z * L, and radius factor from t = z (t = (z+1)/2). That keeps sphere topology smooth. Then X = x * prof(t) * W, Y = y * prof(t) * H.
- Tail fin (caudal): a flat shape in the Z-Y plane at tail end (z ≈ -L - a bit). Build via ShapeGeometry in 2D (shape coordinates x,y) then rotate: shape in (x=shape.x→z axis? ). ShapeGeometry lies in XY plane. I want fin plane = ZY plane (vertical, along body axis): rotate geometry by -90° about Y so shape-x maps to world -Z? Rotating about Y by θ: x' = x cosθ + z sinθ... Let's just do: finGeo = new ShapeGeometry(shape); finGeo.rotateY(Math.PI/2) maps x→? rotateY(π/2): x' = z*sin? For a point (x,y,0): rotateY(π/2) → (x cos90 + 0*sin90, y, -x sin90 + 0) = (0, y, -x). So shape x-axis maps to -Z: shape.x positive → z negative (toward tail). That works: define tail shape with x from 0 (peduncle) to 0.7 (fin tips) going toward tail. Then translate to position. Good, and normals point along ±X after rotation (fine, DoubleSide).
Tail shape (forked caudal fin): moveTo(0, 0.08); lineTo(0.62, 0.42); lineTo(0.5, 0.06); lineTo(0.58,-0.38); lineTo(0,-0.08); close. Something like a fork. I'll parametrize per species (fork depth, size).
- Dorsal fin: shape in Z-Y plane on top: from mid-back sweeping back. Build similarly, positioned along body top: shape x maps to -Z (rearward). Dorsal: moveTo(0,0); lineTo(-0.1? ...). Since shape.x → -Z, shape x positive goes toward tail. Dorsal fin: from front (x small) rising and sloping back. Place at top of body: y offset = body top height at that t. Just design with y starting 0 at attachment.
- Pectoral fins: small shapes near head sides angled — two small planes rotated. These also wag? They're static relative to body; fine, or flap slightly — shader wag includes them since displacement depends on z only (fins near front barely move — correct).
- For angelfish: tall body (H big), big dorsal+ventral fins, trailing anal fin.
- Eyes: could add via vertex color? A dark spot near nose sides. With vertex colors, I can color two small sphere geometries black positioned at head sides — merge into geometry with black color attribute. Simple: two tiny spheres (radius 0.06*len) at (±width*0.8 at nose area). Sphere geometry small segments (8,6). Add white ring? Just black dot is fine, maybe dark with tiny white? Keep black dots.
Merged geometry needs consistent attributes: position, normal, uv, color. ShapeGeometry has position/normal/uv, no color — I'll add color attributes manually to each part before merging (fill with part color; body gets per-vertex colored pattern).
Body coloring per species via function color(t (0..1 along body), y (vertical -1..1), x) → THREE.Color:
- Clownfish: orange base #ff7a1a; white bands at t≈0.15-0.25, 0.45-0.6, 0.85-0.95 with black edge (darken near band edges). Belly slightly lighter. Fins: orange with black tips/edges → for fin parts use color with edge darkening by uv? ShapeGeometry uv = shape coords — I can compute color per vertex by distance from shape edge... simpler: fins solid color with slight variation: tail fin black-edged: color = lerp(orange, black, edgeFactor) where edgeFactor from uv.x maybe (tips). For tail: uv.x toward tips → darker. I'll pass a per-part colorizer callback (u, v) → color.
- Neon tetra: body: top blue-green stripe (y>0.1): bright #37e0ff; below silver #cfd8dd; rear half red (t<0.45): #ff3040 lower half. Small fish — colors subtle but nice in school.
- Yellow tang: uniform bright yellow #ffd21f, fins slightly lighter/translucent look (can't do translucency per-part with one material — could set fins color slightly whiter).
- Angelfish: silver #d9dee3 with vertical black stripes (bands by t), long fins darker smoky.
That's 4 species. Body parameters: length L, height H, width W, tail size, dorsal size, sociability (schooling strength), preferred depth, speed range, count.
Counts: tetras 42, clownfish 12, tangs 9, angels 6 → 69 fish total. Good.
Per species InstancedMesh with its own material & geometry. Per-instance attributes: aPhase (static), aGlow? and aWag (dynamic). Also maybe aScale via instance matrix scale (size variance) — yes bake into matrix scale.
**Wag shader injection:**
```js
mat.onBeforeCompile = (shader) => {
shader.uniforms.uTime = timeUniform; // shared object {value:0}
shader.vertexShader = shader.vertexShader
.replace('#include <common>', `#include <common>
attribute float aPhase;
attribute float aWag;
uniform float uTime;`)
.replace('#include <begin_vertex>', `#include <begin_vertex>
{
float ph = aPhase;
float w = sin(uTime * (6.0 + aWag * 14.0) + ph + transformed.z * 3.5);
float amp = (0.06 + aWag * 0.12);
float tf = smoothstep(0.35, -1.2, transformed.z / uLen); // toward tail
transformed.x += w * amp * (0.25 + tf) * uLen;
// slight body curve
}`);
};
```
Hmm — careful: transformed coordinates are pre-instance scale local units. If instance matrix includes per-fish scale, local geometry same for all; z ranges [-L…L]. So use uLen = L/2 or just compute tf from transformed.z directly with constants relative to species length — but species differ in L. I can pass uLen uniform per material (species length). Or normalize geometry to unit length at build time (build with L=1, scale in instance matrix by actual size). Normalizing to unit-length geometry is cleaner: geometry built with nose at z=+0.5, tail at z=-0.5, then instance matrix scale = actualLength. But then height/width proportions get baked relative to unit length — fine, build with relative proportions. And wag constants in normalized space.
So: build all species geometry with unit body length: z ∈ [-0.55, 0.5] (tail fin extends beyond). Per-instance scale vector (len, len*heightFactor? ) — if I scale uniformly by len, then height proportion is baked as H/L in geometry. Non-uniform per-fish variation would look weird anyway; uniform scale with small variance (0.85–1.15) is good.
Wag: amplitude relative: amp ~ 0.06 normalized → scaled by instance matrix. Wait — displacement in `transformed` happens BEFORE instanceMatrix, so the instance scale scales the wag too — consistent.
Note: attributes for InstancedMesh: `geometry.setAttribute('aPhase', new THREE.InstancedBufferAttribute(phases, 1))` — works since geometry is used by InstancedMesh (instanced rendering). Yes.
Shadow depth material: my onBeforeCompile isn't applied → shadow won't wag; fine.
**Kelp sway shader:** similar injection on kelp material:
```
transformed.x += sin(uTime*1.2 + aPhase + uv.y*4.0) * uv.y*uv.y * 0.5;
transformed.z += cos(uTime*0.9 + aPhase*1.3 + uv.y*3.0) * uv.y*uv.y * 0.3;
```
uv.y from geometry (blade from y=0 to 1). Blade geometry: PlaneGeometry(width, height, 1, 8), translate up by height/2 so base at 0; taper: x *= (1 - (y/height)^1.2 * 0.8)? Modify vertices. uv.y ∈ [0,1]. Color gradient via vertex colors (dark base → bright tip) with vertexColors material. Per-instance: scale for height variance, random yaw. aPhase instanced attr. DoubleSide. Nice.
Also add a few broad-leaf plants (like vallisneria ribbon twist)? Keep just grass blades + maybe some round-leaf bushes. Keep scope manageable: two kelp cluster types: tall thin blades (tall grass) and shorter reddish plants? Use same blade with different colors/scale. OK.
**Bubble shader (Points):**
attributes per particle: aSeed. Position updated CPU or in shader? Shader-driven: position = base + rise over time modulo — do it in shader: y = mod(y0 + t*speed, height) + floor; x wobble = sin. All in vertex shader with uniforms — zero CPU cost. Points count 90. gl_PointSize with perspective attenuation.
fragment: ring + highlight:
```glsl
vec2 q = gl_PointCoord - 0.5;
float d = length(q);
float ring = smoothstep(0.5, 0.38, d) - smoothstep(0.34, 0.2, d)*0.7;
float hi = smoothstep(0.16, 0.0, length(q - vec2(-0.12, 0.14)));
alpha = clamp(ring*0.8 + hi, 0.0, 1.0) * uOpacity;
color = mix(waterColor, white, hi);
```
Blending: NormalBlending with transparent, depthWrite false. Slight refraction look not needed.
**Marine snow (Points):** tiny white dots alpha 0.25, drifting via shader sin offsets. 160 points, size ~1.5px scaled.
**Caustic helper:**
```js
function addCaustics(mat, strength){
mat.onBeforeCompile = (s)=>{
s.uniforms.uTime = timeU;
s.uniforms.uCaustic = {value: strength};
s.fragmentShader = s.fragmentShader
.replace('#include <common>', `#include <common>
uniform float uTime; uniform float uCaustic;
varying vec3 vWPos;`)
.replace('#include <dithering_fragment>', `#include <dithering_fragment>`)...
```
Better: modify diffuse: inject into `#include <map_fragment>` after — but I need world position varying: add in vertex `#include <worldpos_vertex>` gives worldPosition only when needed... Simplest: add my own varying: in vertex replace `#include <begin_vertex>` adding `vWPos = (modelMatrix * vec4(transformed,1.0)).xyz;` — but with instancing, modelMatrix doesn't include instanceMatrix; for sand/rocks no instancing (or rocks instanced? If rocks use InstancedMesh, world pos needs instanceMatrix). I'll keep rocks as regular meshes (few). Kelp instanced but no caustics needed on kelp.
Fragment: after `#include <emissivemap_fragment>`? To modulate light: replace `#include <lights_fragment_begin>`? Easiest: multiply `diffuseColor.rgb` right after map/color_fragment:
```js
.replace('#include <map_fragment>', `#include <map_fragment>
float ca = causticPattern(vWPos.xz * 1.2, uTime);
diffuseColor.rgb *= (1.0 + ca * uCaustic);`)
```
And define causticPattern in common injection. This modulates base color — with Lambert/Phong lighting applied after, caustics scale with light. Good.
Caustic GLSL function (world xz):
```glsl
float causticPattern(vec2 p, float t){
vec2 i = p;
float c = 0.0;
float inten = .006; // classic
for (int n = 0; n < 3; n++){
float tt = t * (1.0 - (3.5 / float(n+1)));
i = p + vec2(cos(tt - i.x) + sin(tt + i.y), sin(tt - i.y) + cos(tt + i.x));
c += 1.0/length(vec2(p.x / (sin(i.x+tt)/inten), p.y / (cos(i.y+tt)/inten)));
}
c /= 3.0;
c = 1.17 - pow(c, 1.4);
return pow(abs(c), 8.0);
}
```
This is the well-known caustic shader (by joltz0r / adapted "Seascape" caustics). It produces nice caustic blobs. It uses `p = mod(uv*TAU, TAU)-250` typically with input uv in [0,1]*6.28. Let me adapt properly:
Classic:
```glsl
vec2 p = mod(uv*6.28318, 6.28318) - 250.0;
...
```
The -250 offset is needed for the pattern to look right. I'll implement:
```glsl
float caustic(vec2 uv, float t){
vec2 p = mod(uv*6.28318, 6.28318) - 250.0;
vec2 i = vec2(p);
float c = 1.0;
float inten = .005;
for (int n = 0; n < 4; n++){
float tt = t * (1.0 - (3.5 / float(n+1)));
i = p + vec2(cos(tt - i.x) + sin(tt + i.y), sin(tt - i.y) + cos(tt + i.x));
c += 1.0/length(vec2(p.x / (sin(i.x+tt)/inten), p.y / (cos(i.y+tt)/inten)));
}
c /= 4.0;
c = 1.17 - pow(c, 1.4);
return pow(abs(c), 8.0);
}
```
With uv = worldPos.xz * scale (scale ~0.35 → uv spans several periods). This is a known-good shader. The `mod` wrapping creates discontinuity lines every 6.28 — with scale 0.35, repeats every ~18 units; visible seam? The pattern is roughly periodic so seams are subtle. Commonly used with uv in [0,1] over a quad — seams rarely noticed. I'll use uv = vWPos.xz * 0.22 and accept seams; or avoid mod: skip `mod` and just use p = uv*6.28318 - 250 — the mod only wraps to keep continuity for tiling; without mod the pattern is still fine (the -250 offset sets phase). Actually the offset -250 is important for appearance. I'll keep mod — seams appear as pattern jumps; at 0.22 scale, seam every 28.5 units; tank is 30 wide → maybe one seam line. Hmm. Alternatively scale smaller so period ≥ scene: scale = 6.28/34 ≈ 0.185 → uv*6.28 wraps every 34 units: uv = x*0.185, wraps at x=34. Scene x∈[-17,17] → exactly one wrap boundary at x=±17 edges. Fine — seams at extreme edges hidden by fog/vignette. But wait, mod(uv*TAU, TAU) wraps when uv*TAU = TAU*k → uv = k → world x = k/0.185 = 5.4k → wraps every 5.4 units!! Because uv = x*0.185, uv*TAU increments by 2π per 5.4 units... no wait: mod(uv*6.28318, 6.28318): uv*6.28318 wraps each time uv increases by 1. uv = x*0.185 → wraps each 5.4 world units. Many seams! To have period ≥ 34 units: uv scale = 1/34. Then uv spans [-0.5, 0.5] — pattern spatial frequency then depends on internal math... The caustic pattern's intrinsic scale: with p = mod(...)-250 and the iteration using i offsets around p magnitude 250, the pattern features size in uv units is ~0.05? Hmm hard to reason. Let me instead skip the classic and write my own seamless pattern with sin/cos fields — guaranteed seamless and tunable:
```glsl
float causField(vec2 p, float t){
// layered warped sine interference, seamless (periodic functions only)
float v = 0.0;
vec2 q = p;
for (int k = 0; k < 3; k++){
float fk = float(k+1);
q += 0.35 * vec2(sin(p.y*1.7 + t*0.8 + fk), cos(p.x*1.6 - t*0.7 + fk*1.7));
v += abs(sin(q.x*fk*1.2 + t*0.9) * sin(q.y*fk*1.1 - t*0.8));
}
v /= 3.0;
return pow(1.0 - v, 4.0); // sharp bright ridges where v small
}
```
Roughly: sin*sin gives cells; abs makes ridge lines; 1-v then pow → bright thin web lines that move. This should look like caustics. I'll tune: `pow(clamp(1.0-v,0.,1.), 6.0)*strength`. Let me think: v ∈ [0, 0.33] avg ~0.16. 1-v ∈ [0.67,1]. pow6 → [0.09, 1]. So brightness varies 0.09–1 with peaks along lines where both sines ≈ 0. Moving over time via t terms. Looks plausible. Add slight vertical fade on sand (caustics stronger in shallows? uniform fine).
I might also add caustics to rocks (reuse helper) — sure, with lower strength; needs vWPos varying per mesh material — same injection works for non-instanced rocks. Rocks will be InstancedMesh? If instanced, worldPosition varying must include instanceMatrix: `(modelMatrix * instanceMatrix * vec4(transformed,1.0))`. Hmm, but `worldpos_vertex` in three actually computes worldPosition including instanceMatrix when instancing... but it's conditional on defines. My custom injection: I'll include instanceMatrix handling: guard with `#ifdef USE_INSTANCING`. Simpler: don't instance rocks; create ~7 rock meshes sharing geometry with different transforms — 7 draw calls, fine. Kelp: instanced (needs custom sway with instancing handled automatically in project_vertex since `transformed` is pre-instance — good, and phase attribute instanced).
So caustic helper: vertex inject `vWPos = (modelMatrix * vec4(transformed, 1.0)).xyz;` for non-instanced meshes. Apply to: sand (big), rocks. Also could apply faint caustics to fish bodies — fish are instanced; vWPos would need instanceMatrix; I could inject `#ifdef USE_INSTANCING transformed... ` For fish, caustics on scales would be lovely... skip for simplicity; fish get light from directional anyway.
**Sand:** PlaneGeometry(80, 60, 90, 70) rotated -90° X. Vertex displacement: sum of sines: h = 0.35*sin(x*0.35 + 1.7)*sin(z*0.5) + 0.8*sin(x*0.12)*cos(z*0.15+2) + small ripples near front? Also gentle bowl. computeVertexNormals. Color: warm sand with slight variation via vertex colors (noise) — mix #c9b48f to #a58a68. Use vertexColors on Lambert/Phong. Wait sand should not be shiny → Lambert fine. Rocks Phong low shininess.
Actually can I use MeshStandardMaterial everywhere for nicer look? Standard is fine perf-wise for few meshes; fish instanced with Standard OK too. Let me use MeshStandardMaterial for fish (roughness 0.5, metalness 0.05... slight sheen) — actually Phong's specular is cheap and fish look wet. I'll use MeshStandardMaterial for sand/rocks/kelp (matte-ish) and fish: MeshStandardMaterial with roughness 0.35 gives nice highlight from directional light. Hmm, for the caustic injection and wag injection, onBeforeCompile works the same for all. OK.
Vertex colors: MeshStandardMaterial with vertexColors: true multiplies color attr. Good.
**Water surface (top plane):** Custom ShaderMaterial (no lighting needed):
```glsl
frag:
vec3 deep = #0a3550; vec3 light = #7fd4ff;
pattern = caustic-ish bright streaks moving;
color = mix(deep, light, streaks) + fresnel toward camera?
```
Seen from below at grazing angle mostly. I'll do: plane at y=12 (water top), size 80×60, side: DoubleSide (visible from below: need side DoubleSide or BackSide facing down). ShaderMaterial with side: THREE.DoubleSide, transparent: false? It would block background above — good, it IS the surface. Add fog: ShaderMaterial doesn't get scene fog automatically unless I add fog: true and include fog chunks — I can set `fog: true` in ShaderMaterial params and include `#include <fog_pars_fragment>` etc. Since surface is up high and often at distance, fog helps blend. Let me include fog chunks properly:
vertex:
```glsl
#include <common>
#include <fog_pars_vertex>
varying vec3 vW;
void main(){
vec4 mv = modelViewMatrix * vec4(position,1.0);
vW = position; (plane local = world since axis aligned at origin, but has rotation)
gl_Position = projectionMatrix * mv;
#include <fog_vertex> // needs mvPosition variable name
}
```
fog_vertex chunk expects `mvPosition`. Define `vec4 mvPosition = ...` then include. OK.
fragment:
```glsl
#include <common>
#include <fog_pars_fragment>
uniform float uTime;
varying vec3 vW;
void main(){
... pattern with vW.xz
gl_FragColor = vec4(col, 1.0);
#include <fog_fragment>
}
```
Pattern: bright wavy lines: `float s = pow(abs(sin(vW.x*0.9 + sin(vW.z*0.5 + t)*2.0 + t*0.3) * sin(vW.z*0.8 - t*0.2)), 3.0)` layered. Plus slight vertex displacement waves? Add gentle CPU wave displacement (plane 40×30 segments; update in shader: position.y stays since rotated...). I'll do vertex displacement in the same shader: displace along normal (up) with sin fields; compute in vertex shader, no normal needed (unlit). Actually since it's unlit custom shading, geometry normal irrelevant.
The underside shimmer: color mostly #0d4d6e base with bright #bfeeff streaks where pattern high, intensity falls toward far z (fog does that). Good.
Also add "sun glow" at surface center-top? Optional.
**Background gradient dome:** SphereGeometry(90, 32, 16), BackSide ShaderMaterial (fog: false), gradient by world y: bottom #04202f, mid #0b4a66, top #2f8fb8-ish, plus subtle radial sun glow toward light direction. Also could apply subtle animated noise — keep gradient + glow. fog must be false for it (it's the far backdrop; fog color should match gradient bottom-ish so scene blends: set FogExp2 color #0a3a52 (mid-bottom blend) and dome bottom similar → cohesive.
Actually with FogExp2 density 0.03, at 30 units, fog factor = 1-exp(-(0.03*30)²)= (0.9)²=0.81... exp2 formula: f = 1 - exp(-(d*density)^2). d=25, dens=0.03 → (0.75)²=0.56 → 43% fog. Reasonable. I'll tune density ~0.032 and camera far 200, dome radius 90 partially fogged? Dome has fog:false so it stays crisp — good as backdrop.
**Postprocessing?** Bloom would make it lush... UnrealBloomPass via addons — allowed. But adds complexity/perf cost and risk. Could skip. A CSS vignette + subtle blue gradient overlay could add mood without cost. I'll do CSS overlay vignette (radial-gradient) — cheap and looks nice. Also slight top-light overlay. And maybe subtle animated "scanline"? no.
Actually, I can fake bloom-ish glow with the god ray planes + additive particles. Skip postprocessing for reliability.
**HTML/CSS UI:**
- Full-viewport canvas.
- Title top-left: "Azure Reef" — hmm name. Something like "The Quiet Tank" / "Glasswater". Let me name it "Azure Hollow"? For an aquarium: "TIDEGLASS — a living aquarium". I'll go "Tideglass Aquarium"? Or "Coral Hollow". Title: "Tideglass" with subtitle "a living aquarium".
- Caption bottom center: "Move your cursor through the water — the fish will scatter. Click to sprinkle food."
- Small legend of species bottom-right? Could list species with color dots — nice touch, adds "designed" feel: "Clownfish ×12 · Neon tetra ×40 · Yellow tang ×9 · Angelfish ×6" etc. Maybe a small line in the corner.
- FPS-friendly: fine.
- Loading: everything is procedural — instant. First paint shows scene immediately.
Fonts: system fonts only (no external). Use a nice stack: "Georgia, 'Times New Roman', serif" for title? Or modern sans: ui-sans-serif... I'll use a serif display for title ("Cormorant"-like unavailable) → Georgia italic looks elegant. Title in small caps letter-spaced serif over the deep blue — elegant.
Also handle reduced motion? Skip.
**Mobile:** resize handler; touch works via OrbitControls; tap to feed. Text sizes adapt. Pointer events: use 'pointermove' for mouse pos; 'pointerdown/up' for feed detection; OrbitControls attaches its own listeners on renderer.domElement — fine, my listeners coexist.
Wait, one consideration: if user drags to orbit, pointermove still updates flee point — fine.
Feed mechanic details:
- On tap/click: compute point on ray at some depth: intersection with water plane (y=11.5) or fixed depth plane z=0? Better: intersect ray with a horizontal plane at y = surfaceY-0.5 (just under surface) → food spawns there and sinks. If ray points away (no hit), ignore.
- Food: array of {pos, vel, size, life}. Mesh: small Points or tiny meshes? Points with orange-ish flakes, drifting down with wobble, slight spread. Use a Points geometry with dynamic positions (max 60 foods), updated CPU. Fragment: soft round dot, color per-vertex (flake color varies: reddish-orange pellets). Food sinks slowly (0.5 u/s) with wobble; lifetime ~25s; eaten when fish close.
- Fish attraction: in boids, for each fish find nearest food within 7; steer strongly (hunger builds: if a fish ate recently, less attracted? keep simple: all attracted; eat when dist<0.35 → remove food, fish excitement bump 1.0 decaying; also small "nibble" speed burst. Also, fish's wag excitement increase.
- After eaten, quick flee-from-food-point? Just excitement decay.
Points buffer approach: positions Float32Array(MAXF*3), colors, sizes attr; draw range set to active count; update each frame. gl_PointSize by size attr & attenuation. Fine.
**Mouse flee ray:** compute pointer NDC; raycaster.setFromCamera; get ray origin+dir; for each fish: t = clamp(dot(fishPos - origin, dir), minT, maxT) where minT/maxT = distances to z-plane... Actually clamp t to [dNear, dFar] along tank depth: project: the tank spans z ∈ [-12, 12] in world; but with orbiting camera, clamping by z planes isn't axis-aligned to ray... simplest: t = clamp(dot(fishPos - origin, dir), 0, 60) then closest point = origin + dir*t; distance fish↔closestPoint. With t clamped to [0, maxDist], fish behind camera excluded naturally. But a fish near the ray far beyond the tank would also flee — fine, ray passes through tank only. However: when pointer is off-tank (pointing at background dome), the ray still passes through water region maybe not... ray from camera through empty area still traverses the box volume geometrically. If user hovers outside the tank silhouette, the ray may miss the fish volume — no fish near it, no flee. Good, that's natural.
Refinement: the flee should feel like the cursor is a "predator finger" — radius ~3.5 world units, strong falloff. Also add slight delay/anticipation? Immediate is fine, feels responsive.
Also fish near the front glass might flee toward camera making them huge briefly — clamp bounds keep them in.
**Simulation bounds:** box: x ∈ [-16,16], y ∈ [1.0, 10.5], z ∈ [-11,11]. Soft margins: when beyond margin (2 units inside), spring force inward proportional to overshoot. Also wander target to keep motion spread.
Boids performance: 69 fish × neighbors within same species: clownfish 12 → 144 pairs; tetras 42 → 1764 pair checks/frame — fine. Cross-species separation: also check all pairs across species? That's 69² ≈ 4.7k — fine too, but I'll do separation only within species plus light cross-species via simple pass: total pairs across all = 69*68/2 ≈ 2346 — trivial. I'll do a single neighbor loop over all fish for separation (all species), and species-scoped cohesion/alignment.
Structure: fishes = [{pos, vel, quat, species, excite, phase, scale, speedBase...}]. Per frame:
1. compute fleePoint (if pointer inside canvas & moved recently)
2. for each fish: accumulate forces.
3. integrate: vel += force*dt; clamp speed [min, max*(1+excite*0.9)]; pos += vel*dt.
4. orientation target from vel; slerp quat.
5. write instance matrices.
Neighbor search O(n²) with distance checks — 69 fish, ~5k sqrt — fine at 60fps.
Rocks obstacles: 3-4 spheres with radius; fish avoid: if dist < r+1.2, push out.
**dt:** clock.getDelta clamped to 0.033 to avoid jumps; use fixed-ish integration.
**Auto behaviors for liveliness:** each fish has occasional "dart": random impulse every few seconds (timers) — adds life. Also species depth preference.
**Water tint on fish at distance:** handled by fog.
**Extra polish — fish subtle color variation:** per-instance color? InstancedMesh supports setColorAt → instanceColor multiplies diffuse... with vertexColors also on. instanceColor multiplies too (three handles both). I can setColorAt with slight tint variation (0.9–1.05 brightness) — nice. Yes, `mesh.instanceColor` exists after setColorAt. It's applied in color_vertex chunk... requires material.vertexColors? No — instanceColor works via USE_INSTANCING_COLOR define automatically when instanceColor set. It multiplies vColor if vertex colors exist? In three's shader, `#ifdef USE_INSTANCING_COLOR vColor.xyz *= instanceColor.xyz;` — and vColor exists if USE_COLOR... If vertexColors true, both combine multiplicatively.
**Kelp clusters:** positions near back and sides: ~5 clusters × 6-10 blades. Also add a couple of "bushy" plants: use icosphere clusters green? Keep blades with varied heights (1.5–5.5). Also maybe tall kelp with leaf blades: second blade type wider with leafy protrusions — skip; one blade type + color variance is fine, plus some short red-purple blades for variety.
**Rocks:** IcosahedronGeometry(1, 2) displaced by noise per vertex (hash-based), flat-ish shading (computeVertexNormals after displacement, or keep faceted look with flatShading: true — rocks look good faceted). Make 3 variants; scatter ~9 rocks sizes 0.6–2.4. Colors: warm gray/brown vertex tint by height. Also maybe a "rock arch"? skip.
**Coral?** A few simple corals would add color pop: could do low-poly "brain coral" (sphere squashed, pinkish), fan coral (shape), tube corals (cylinders cluster, purple)... Let me add one simple coral type: cluster of small tapered cylinders ("tube coral") in magenta/purple near rocks — cheap and colorful. And maybe a big sea anemone at rock base: sphere with tentacles? Tentacles = thin cones... could animate sway via same kelp material injection! Anemone: ~24 thin cones around a center, swaying via vertex shader sway with uv? Cones don't have convenient uv.y... ConeGeometry has uv. Sway amplitude by (position.y/height). I can inject using position.y normalized by uniform height. Simpler: reuse blade approach: anemone tentacles as thin stretched blades? Hmm. Let me implement anemone as InstancedMesh of thin capsule-ish cones with a sway injection using local y/height. Use a small custom shader replace:
```
transformed.x += sin(uTime*2.0 + aPhase) * pow(max(transformed.y,0.0)/1.0, 1.5) * 0.25;
```
Cone geometry: CylinderGeometry(0.02, 0.06, 1, 5) translated up 0.5 → base at 0. Tip sways. ~26 tentacles per anemone, 2 anemones. Pink/purple tips, tinted. This is a nice detail. Clownfish hover near anemones! I can give clownfish a "home" attraction to anemone position — they wander but drift back. That's a lovely authentic touch: add gentle spring toward home within radius... Let me implement: clownfish home = anemone position + slight offsets; attraction force if farther than 6 units, weak. So they mostly hover around anemones.
But geometry color: cone with vertex colors gradient base→tip (white tip pink base). OK.
Total instanced meshes: tetras, clowns, tangs, angels, kelp, anemone tentacles → 6 draw calls + sand, rocks(9), surface, dome, godrays(6), bubbles(1), snow(1), food(1). ~30 draw calls.
**God rays:** 6 planes, additive ShaderMaterial shared with per-mesh uniform? Shared material with per-mesh phase via... simplest: one material, per-mesh onBeforeRender set uniform? Different meshes sharing material can't have distinct uniforms unless using per-mesh material clones. 6 clones is fine. Or one custom geometry with all rays merged and phase in attribute. Simplest: create material per ray (6 ShaderMaterials) with uniforms {uSeed, uOpacity, uTime shared object}. Each frame update uTime via shared uniform object (assign same object reference: {value: t} shared → update once). Yes: `const timeU = {value:0}` shared across all shader materials.
Ray plane: width 1.5–3, height from y=12 down to y=0 (or full 12), rotated slightly, positioned random x/z, tilt ~10-20°. Fragment:
```
alpha = verticalFade(strong at top, fade to bottom) * horizontalSoftEdge * (0.5 + 0.5*sin(t*0.5+seed)) * uOpacity
color = vec3(0.5, 0.8, 1.0)
```
Additive, depthWrite false, transparent true. Also rays should billboard toward camera? Angled planes look fine from front; slight rotation animation over time (sway ±3°). Also fade when camera looks along them? skip.
Set renderOrder to render after opaque. transparent handles that.
**Order of code:**
Let me now also double check three r170 API details:
- `THREE.InstancedMesh(geometry, material, count)`; set `instanceMatrix.setUsage(THREE.DynamicDrawUsage)`.
- `mergeGeometries` from 'three/addons/utils/BufferGeometryUtils.js' — exported name `mergeGeometries` (since r151+). r170: yes, `mergeGeometries`.
- OrbitControls from 'three/addons/controls/OrbitControls.js'.
- `renderer.outputColorSpace = THREE.SRGBColorSpace` default in r170 (default already). Use `THREE.ACESFilmicToneMapping`? tone mapping affects custom ShaderMaterials too (they include tonemapping_fragment? Custom ShaderMaterial does NOT apply tone mapping automatically unless including chunks. Mixed look risk: dome/surface custom shaders won't be tone-mapped while standard materials are — could cause mismatch in brightness/fog blending... Fog color also gets tone mapped? Fog applied in fragment before tonemapping for standard materials; my custom shaders with fog chunk: fog_fragment mixes fogColor then tonemapping only if included. To keep visual consistency simple: disable tone mapping (NoToneMapping) and pick colors carefully. With NoToneMapping, colors render linear→sRGB conversion still happens? outputColorSpace SRGB applies encoding to final. Custom ShaderMaterial: three appends colorspace conversion chunk? For ShaderMaterial, `#include <colorspace_fragment>` needed manually; not automatic. Ugh — mixing custom shaders and lit materials can cause color shifts (custom shader outputs treated as linear then... actually if ShaderMaterial doesn't include colorspace_fragment, gl_FragColor written directly = treated as already in output space (no conversion). Standard materials output through conversions. So my custom shader colors (surface, dome, bubbles) are effectively "as-authored" (sRGB values straight), while lit materials get linear→sRGB. If I author dome colors as hex converted via THREE.Color(hex) — Color(hex) in r170 with color management enabled converts sRGB hex → linear internally. If I then output those linear values directly without conversion, dome appears too dark. Fix: in my custom fragment shaders, either use raw vec3 values I author by eye (not THREE.Color auto-converted), or include `#include <colorspace_fragment>`. I can include the chunks: in fragment after setting gl_FragColor, `#include <colorspace_fragment>` (r152+ name) converts linear→output space. Then uniforms as THREE.Color work consistently. I'll include `#include <tonemapping_fragment>` no (no tone mapping), and `#include <colorspace_fragment>` yes in all custom materials. For fog chunk in custom shaders, fog_fragment uses fogColor uniform which scene sets (fog color linear? scene.fog.color with color management: Fog color is converted?) Fog colors in r170: Fog constructor uses Color... ColorManagement: THREE.ColorManagement.enabled default true; Color(hex) stores linear-ized. Fog uniforms get color in linear, and lit shaders convert at end. For my custom shader with fog + colorspace chunk, same pipeline → consistent. Good: include both fog chunk and colorspace chunk.
Similarly Points ShaderMaterials for bubbles/snow/food: include colorspace chunk.
For Points: also need `#include <common>`? For ShaderMaterial I write full shaders; can still include chunks. Fine.
- WebGLRenderer({antialias:true}); setPixelRatio(min(devicePixelRatio, 2)); setSize.
- Shadow: dirLight.castShadow = true; shadow.mapSize 1024; camera left/right/top/bottom covering tank (±20, ±16), near 1 far 60. Fish castShadow — InstancedMesh castShadow works. Kelp sway shadow mismatch: kelp shadow static while blade sways — since sway is smallish amplitude at tip (0.5 relative to blades of height ~3-4 → noticeable but acceptable) — actually might look odd (shadow stationary while plant moves). Option: exclude kelp from casting (still get nice rock+fish shadows). Kelp blades are thin; their shadows are thin streaks; exclude them: cleaner. Rocks, fish, anemone (sways too — same issue; anemone tentacle shadows fine to exclude; but anemone base...) Let me have rocks + fish cast; sand receive; anemone cast (small mismatch ok?) — exclude to be safe. Kelp receive? They can receive. Sand receiveShadow true.
Shadow with instanced fish + custom wag mismatch: the shadow won't wag but fish wag is subtle (±0.1 body length) — invisible in soft shadow.
- Also `renderer.shadowMap.type = THREE.PCFSoftShadowMap`.
- God rays & surface & dome: no shadows.
**Camera:** PerspectiveCamera(55°, aspect, 0.1, 300). Start position (0, 4.6, 17.5), target (0, 4.2, -2). OrbitControls: enablePan false, minDistance 6, maxDistance 26, minPolarAngle 0.6? Polar measured from +Y: horizontal = π/2 ≈ 1.57. Limits: [0.35π? ] Let me allow looking up at surface: minPolar 0.9 rad (~52° from vertical), maxPolar 1.62 rad (slightly below horizontal — prevent going under sand). Target (0, 4.5, -2). Also clamp camera.position.y within [1.3, 10.8] each frame post-update, and if clamped... OrbitControls will fight slightly; acceptable with limits set so it rarely hits. maxDistance 26 might let camera exit tank back through dome... The dome is at 90; fog hides. Exiting tank x beyond 16 shows fish from outside bounds — fish bounds box keeps them inside x∈[-16,16] z∈[-11,11], camera at distance 26 from target could go behind z=-11... Set maxDistance 22 and azimuth limits ±75° so camera stays in front-ish region: azimuth from +Z axis; camera orbits target (0,4.5,-2). With maxAzimuth 1.35 rad (~77°), camera x stays within ~ sin(77°)*22 ≈ 21 from center — beyond tank x=16. Reduce maxDistance to 18: sin(77)*18 ≈ 17.5, z stays ≥ -2 - cos... cos(77°)*18≈4 → z ∈ [-6, 20]. OK-ish. Or tighten azimuth ±65° (1.13 rad): x offset ≤ 16.3. Fine: maxDistance 18, azimuth ±1.15, polar [1.0, 1.58]. Camera y = target.y + r*cos(polar) ≥ 4.5 - 18*cos(1.58)=4.5-18*0.0092≈4.3... wait cos(polar): polar near π/2 → cos≈0 → y≈4.5?? No: y = target.y + r*cos(polar). polar max 1.58 → cos≈-0.009 → y ≈ 4.5 - 0.16. Hmm that keeps camera always around y≈4.5–(r*cos(minPolar)=18*cos(1.0)=9.7 → y up to 14!). cos(1.0)=0.5403; 18*0.54=9.7; y=14.2 above water surface (12)! Need polar min such that y ≤ ~10.5: r=18 → cos ≤ (10.5-4.5)/18=0.333 → polar ≥ 1.23 rad. Set polar ∈ [1.23, 1.58]: camera looks mostly horizontally/up slightly — good underwater feel (looking slightly up shows surface). Also at smaller distance user can look up more: polar limits are absolute, not per-distance. 1.23 min is fine.
But wait: can the user zoom to see fish from above? y stays ≤10.5 max. Surface at 12 — camera always below surface looking up at it.
**Pointer & feeding:**
pointermove → store NDC. pointerdown: record x,y,time. pointerup: if dist<7px && dt<300ms → feed at ray∩(y=10.8 plane... let me use plane y = 10.5 (just under surface at 12 minus bit) — actually spawn at y=11 near surface then sink through water column: plane y=11.2. If ray points up (no forward intersection within tank), fall back to point at t where z... just skip if no intersection or intersection outside |x|<17,|z|<11.5 bounds.
Also for touch: pointerup gives coords. OrbitControls handles touch drag; tap = feed.
Also — "click or press a key to start"? Not a game; I'll show a small hint that fades but keep controls visible at bottom. I think good UX: bottom center caption persistent (subtle), plus initial center message "touch the water" that fades after few seconds? The screenshot at 3s: UI visible. I'll show title top-left, and a bottom hint bar. No blocking overlay.
**Species data tuning:**
Tank: x±16, y[1,10.5], z±11.
- Tetras (school): count 40, len 0.55, speed 1.6–3.2, cohesion strong (0.9), align strong, sep 0.7 radius, pref depth y=5.5±, color neon.
- Clownfish: count 12, len 0.85, speed 0.8–1.8, moderate cohesion (0.35), home anemone, depth near anemone base+1.5.
- Tangs: count 9, len 1.1, speed 0.9–1.7, weak cohesion 0.15, pref depth 3 (near bottom, graze), wander more.
- Angels: count 6, len 1.25, speed 0.7–1.4, cohesion 0.2, pref depth 6, slow graceful (turn rate lower).
Excitement: flee boost factor; also near food excitement.
**Boids force scaling:** use dt-independent smoothing: force in units/s².
Let me write the update code:
```js
const flee = {active:false, point:new THREE.Vector3(), strength:0};
function computeFlee(){
if(!pointerActive) ... raycaster from camera;
// store ray origin/dir globally
}
```
Per fish:
```js
// to closest point on ray
v1.subVectors(origin, f.pos); // careful
t = clamp(-(dot(dir, f.pos - origin)), 0, 80)??
```
closest t = dot(f.pos - origin, dir). Clamp t ∈ [tNear, tFar]: tNear = distance from camera to tank front... simpler clamp [4, 60]. closest = origin + dir*t; d = dist(f.pos, closest). if d < R: factor = (1 - d/R); force += (f.pos - closest)/d * factor² * FLEE_STRENGTH * (with min d guard).
FLEE_STRENGTH ~ 30 (units/s²·factor²) with factor∈[0,1] → strong near cursor. Also excite = max(excite, factor).
Additionally, gentle "wave" propagation: fish farther also get mild alert if neighbor fleeing? Adds realism but complexity; skip.
Wander: perlin-ish: use time-based sin mix:
```js
w = sin(t*0.7 + phase)*..., per axis different freq
force += (sin(t*0.31+p1)*a1, sin(t*0.43+p2)*a2*0.6, sin(t*0.27+p3)*a1)
```
Amplitude ~0.8.
Also cruise: fish keep swimming — enforce min speed 60% of base: if speed < min, accelerate along heading.
Depth preference: force.y += (prefY - pos.y) * 0.25.
Bounds: margins 1.5; if pos.x > 16-m: force.x -= (pos.x - (16-m)) * k (k=2.5) etc.
Home (clowns): if distXZ(home) > 5.5: force += toHome * 0.5.
Food: nearest food within 8: force += (food - pos).normalize() * (6 + (8-d)); cap; when within 0.45 → consume.
Speed clamp: maxSpeed = base*(1 + excite*1.2); excite decays exp(-dt*1.8).
Orientation target: heading = vel; yaw = atan2(vel.x, vel.z); pitch = atan2? For nose +Z: after yaw, horizontal speed h = sqrt(vx²+vz²); pitch about X: nose up when vy>0 → pitchX = -atan2(vy, h)? Check: rotation about X by θ: +Z → (0, -sinθ, cosθ). For nose up (+Y) need -sinθ = vy_norm → θ = -asin(vyn). So pitchX = -atan2(vy, h) gives y' = -sin(θ)= vy/|v|... wait sin(-a) = -sin a: θ=-a where a=atan2(vy,h): -sinθ = -sin(-a) = sin a = vy/|v| ✓. So euler.x = -atan2(vy, h). Good. Roll (bank): roll about Z proportional to turn rate: track previous yaw; turnRate = wrapAngle(yaw - prevYaw)/dt; roll target = clamp(turnRate * 0.5, ±0.5). Smooth roll separately (lerp), include in euler z. Euler('YXZ') with (x=pitch, y=yaw, z=roll) — as computed above R = Ry·Rx·Rz. ✓.
Slerp: f.quat.slerp(targetQuat, 1 - exp(-6*dt)). Also to avoid twitch when speed tiny — min speed enforced so heading well-defined.
Matrix compose: compose(position, quat, scaleVec). Set into instanceMatrix; needsUpdate.
**Food points shader:**
```
attribute float aSize; attribute vec3 color? use 'color' with vertexColors? For ShaderMaterial, declare attributes manually: geometry attributes named arbitrarily.
```
I'll write raw ShaderMaterial with attributes: position (updated), aColor(vec3), aSize. gl_PointSize = aSize * (140 / -mv.z). fragment: soft circle with slight irregular flake shape? circle fine, maybe slightly squashed via gl_PointCoord transform. alpha 0.95. Also food gets fog? Food close to camera mostly; include fog chunks optionally — skip, food rarely far; but if it is far, no fog = glowing dots... include fog to be safe: add fog: true and chunks. OK.
Bubbles shader: also include fog chunks (they can be far). Snow too.
Hmm wait: ShaderMaterial with fog: true requires uniforms fogColor, fogNear/far or density — when using `#include <fog_pars_fragment>`, the uniforms needed are auto-injected? For ShaderMaterial, three adds fog uniforms automatically when material.fog = true? Yes: WebGLProgram binds fog uniforms if material.fog is true — the renderer sets FOG define & uniforms (fogColor, fogNear, fogFar / fogDensity) automatically for ShaderMaterial with `fog: true`. Confirmed: three injects fog uniforms from scene for any material with .fog=true. ✓.
**Bubble column:** two emitters at rocks (e.g., (-9, 0.3, -4) and (7,0.3,-6)). Bubbles rise 11 units over ~6-9s, wobble x/z by sin(t*f+seed)*0.15 growing with height. Points count 70, distributed between emitters. In shader:
```
float h = mod(uSeed*13.7 + uTime*aRate, 11.0); // rise
y = 0.2 + h;
xz = base + wobble
size grows slightly with h
```
All in shader, positions attribute = base xz + seed.
**Snow:** 220 points spread in box; drift: pos + sin fields slow; slow sink with wrap? Keep them just drifting within a range around base via sin offsets — simpler: y = base.y + sin(t*0.1+seed)*0.5 (no net sink). Fine.
Also add faint "back glass" hint? skip.
**Vignette CSS:** `#vignette { position:fixed; inset:0; pointer-events:none; background: radial-gradient(ellipse at 50% 42%, transparent 55%, rgba(1,10,20,0.55) 100%); }` plus a top gradient for depth. And subtle noise? skip.
**UI details:**
```html
<header>
<h1>TIDGLASS? </h1>
```
Name ideas: "Tideglass", "Lantern Reef", "The Blue Hour Tank", "Stillwater". I'll go with "TIDELIGHT" or "Tideglass Aquarium". Hmm: "Tideglass — a pocket aquarium". I like "Tideglass".
Top-left: small overline "POCKET AQUARIUM · №7" then h1 "Tideglass" italic serif, then rule. Bottom-left: species legend with colored dots and counts. Bottom-right: controls hint: "cursor — startle the fish · click — drop food · drag — look around". Center-bottom maybe. Keep minimal, letterspaced uppercase 10-11px, muted cyan-white.
Also live counter: "food in water: n"? Unneeded. Maybe show fish "mood": average calm/curious/alarmed! A fun dynamic label: compute average excitement → text: "calm" (<0.15), "curious", "startled" (>0.5) — updates live, cute. Small status pill bottom-center or with legend: "the shoal is calm". I'll add it to the header area: status line with a small dot colored by state. Nice dynamic touch that responds to mouse — demoable in screenshot? At 3s it's "calm" unless auto sprinkle. It shows interactivity affordance.
**Auto sprinkle:** at t=2.0s drop a small pinch of food at a preset point (e.g., (2, 10.5, -1)) — creates gathering motion in screenshot. Hmm — but at exactly 3s the fish might still be en route → visible convergence motion in still? A still shows fish mid-swim; fine either way. I'll auto-sprinkle at ~1.8s. Also sprinkle again every ~45s if user hasn't? Not needed; keep one, plus if user is idle (no interaction for 30s) do another to keep scene lively? Simple: every 40s auto-sprinkle if user hasn't clicked in last 40s. Eh, keep code simple: single initial sprinkle.
Hmm, actually maybe not auto-sprinkle: could make screenshot show fish clumped in one corner going to food which may look like a bug blob... A tight swirl around a food point actually looks cool (feeding frenzy). I'll do it but at a spot near center-front so it's visible: (1.5, 11, -2), 8 flakes.
**Testing correctness mentally** — biggest risks:
1. importmap + module script: must be `<script type="module">`. Import three and addons at top. ✓
2. onBeforeCompile injections must match exact chunk names for r170: `#include <common>`, `#include <begin_vertex>`, `#include <map_fragment>`, `#include <fog_vertex>`, `#include <fog_fragment>`, `#include <colorspace_fragment>` — all exist in r170. For lights: MeshStandardMaterial uses `#include <lights_physical_fragment>` etc. — not touched. `map_fragment` exists even without map (chunk is conditional-free; the include is always present in shader source — yes, include lines always present; the chunk content has #ifdef MAP. The include line `#include <map_fragment>` is always in the template). ✓
3. Vertex colors + instanced color: fine.
4. mergeGeometries with mismatched attribute sets → error. Ensure every sub-geometry gets: position, normal, uv, color. Sphere/Cylinder/Plane/Shape all have position/normal/uv. Add color to each via helper `paint(geo, colorFn)` creating color attribute. ✓ Also mergeGeometries requires same attribute names — also make sure no `groups` issues: `mergeGeometries(list, false)` merges into single group-less geometry. ✓
5. ShapeGeometry normal exists (0,0,1), uv generated from shape coords ✓. After rotateY, normals rotate ✓.
6. For fish fins ShapeGeometry rotated to ZY plane: double-sided needed — set material.side = DoubleSide. But then back faces of body render too (fine, closed body). Slight perf cost ok.
7. InstancedBufferAttribute on merged geometry used by multiple InstancedMeshes — each species needs its OWN geometry clone (phases differ per species)! If I share geometry across species InstancedMeshes, attributes shared → phases same arrays; that's fine actually (random anyway) — but aWag arrays must differ per species (dynamic per-fish excitement). Sharing geometry across meshes with different instanceMatrix is fine, but aWag would be shared → excitement per species? I want per-fish. So clone geometry per species (cheap) OR store excitement per fish but write into per-species attribute arrays. If geometry shared, one aWag array shared across species meshes — can't have per-species differences. So clone: `geo.clone()` per species, add its own aPhase/aWag. ✓
8. `instanceColor`: mesh.setColorAt(i, color) then instanceColor.needsUpdate.
9. Raycaster: use `raycaster.setFromCamera(ndc, camera)` then ray.origin/direction. ✓
10. OrbitControls + pointer events on same canvas: OrbitControls uses pointerdown too; both fine.
11. Ensure no console errors: careful with all variables declared.
**Fish geometry generator details:**
```js
function makeFishGeometry(opt){
// opt: length L (=1 normalized), height H, width W, tail:{...}, dorsal:{...}, ventral:{}, pectoral:{}, colorFn(t, y01) body color, finColor(u,v) etc.
const parts = [];
// body from sphere
const body = new THREE.SphereGeometry(1, 28, 20);
const pos = body.attributes.position;
const colors = new Float32Array(pos.count*3);
const c = new THREE.Color();
for(i...){
let x=pos.getX(i), y=pos.getY(i), z=pos.getZ(i);
const t = (z+1)/2; // 0 tail, 1 nose
const prof = profile(t);
const nz = (t-0.42) * 1.0 * L; // z from -0.42L to 0.58L
const nx = x * prof * W;
const ny = y * prof * H * (1 + 0.15*?);
pos.setXYZ(i, nx, ny, nz);
colors from colorFn(t, (y+1)/2, x) — also darken top? counter-shading handled in colorFn via y01.
}
body.computeVertexNormals(); // recompute after deformation — needed! original normals invalid.
body.setAttribute('color', new BufferAttribute(colors,3));
parts.push(body);
...
}
```
Profile: `prof(t) = Math.sin(Math.PI * Math.pow(t, 0.62 + opt.noseBias))`? Let me test: t=0 → 0; t=1 → sin(π)=0. pow(t, e) with e<1 pushes curvature: for e=0.65: t=0.5→0.63 → sin(2.0)=0.9. Peak around t where π t^e = π/2 → t = (0.5)^(1/0.65)= 0.32 → peak toward tail?? (0.5)^(1.538) = e^{1.538 * ln0.5} = e^{-1.066} = 0.344 → peak at t=0.34 → body thickest at 34% from tail = slightly aft of middle — for a fish, thickest ~40-45% from tail (just behind head). Use e=0.8: (0.5)^(1.25)=0.42 ✓. So prof = sin(π * t^0.8) maybe ^0.75. Then apply slight power to make belly fuller: prof^0.9. Also make top fuller than bottom (dorsal hump): asymmetric: scale ny by (y>0 ? 1.15 : 0.95)? With fish-like belly curve: bellies rounder: for y<0 multiply by 1.05? Let me add hump param: topScale=1.1, bottomScale=1.0. For angelfish, H=0.62 (relative to L=1) tall disc; tang H=0.55; clown 0.34; tetra 0.26.
Width W: clown 0.16, tetra 0.12, tang 0.13, angel 0.08 (very thin disc) — relative to unit length. These are half-widths in units of L. Sphere scaled: x∈[-W..W].
Hmm wait — body from sphere: cross-section at given t is circle of radius prof(t) scaled x by W, y by H → ellipse. Fine.
Nose: at t=1, prof=0 → pointed nose ✓. Might look too sharp; acceptable stylization.
**Fins:**
Tail (caudal) fin via Shape:
```js
function finShape(points){ shape = new THREE.Shape(); moveTo... }
```
Tail shape params: attach z = zTail (end of body ~ -0.42L minus small gap), fan height, fork.
Points (in fin local coords: +x = rearward (toward tail after rotation mapping shape.x→-z... wait earlier: rotateY(π/2): (x,y,0)→(0,y,-x). So shape +x → -z (rearward since nose is +z) ✓.
Tail shape (fan): base at (0, ±0.05) peduncle, extend to (0.32, ±0.28) with fork notch at (0.16, 0). Points: (0,0.05) → (0.3,0.3) → (0.16,0.02) → (0.16,-0.02)? Let me define:
```
(0, 0.06) → (0.34, 0.32) → (0.20, 0.04) → (0.20, -0.04) → (0.34, -0.32) → (0, -0.06)
```
close. That's a forked tail. Parametrize: tailLen, tailSpread, forkDepth. Clownfish: rounded tail (fork 0.1, spread 0.22). Tang: tall crescent. Angel: long trailing. Tetra: forked modest.
Scale by species size factor then translate to peduncle z0 = bodyEndZ + 0.02. After building ShapeGeometry: geo.rotateY(HALF_PI) → x→-z... check rotateY(π/2) on point (x,y,0): x' = x cos + z sin = 0; z' = -x sin + z cos = -x ✓; then translate z by z0 - small: position so base sits at tail: translate(0,0, z0) where z0 = -0.42L - 0.03? Body z min = (0-0.42)L = -0.42 (L=1). Attach at -0.40.
Also slight x offset 0 (fin in mid plane) ✓. Normals along ±x? ShapeGeometry normal (0,0,1) → after rotateY(π/2): (0,0,1)→(sin? normal rotates same: (0,0,1) → (z-axis becomes... rotateY(π/2) maps +z→+x? (0,0,1): x' = z*sin(π/2)=1 → (1,0,0) ✓ normal along +x. Good.
Dorsal fin shape (on top, sloping back): points with +x rearward: (0,0) at front base... base along back from zB1 to zB2 with height hFin: shape: (0, 0) → (0.05, 0.3) → (0.25, 0.32) → (0.3, 0.05) → close along bottom (0.3,0)→(0,0). Then rotate → plane ZY, translate: base y = body top at those z positions (~ prof*H at t) — approximating: attach at y ≈ bodyTop(tMid) - 0.02. Compute bodyTop at a few z. Simpler: translate fin so its base y sits at 0 and position base at the body top line? The body surface curves; embedding base slightly inside body (base y a bit below surface) avoids gaps: set base y = topAt(front)*0.8. I'll compute top height at attach: y0 = prof(t)*H*topScale at t of attachment * 0.85.
For each species, dorsal params: clown: small rounded (len 0.25, h 0.14); tetra: tiny; tang: tall sail (h 0.3, len 0.3); angel: big sail + long trailing (use two shapes maybe — keep one big).
Ventral/anal fin: similar mirrored below (h smaller), plus for angel long trailing "feelers": thin streamer shape (length 0.5, narrow) — nice angelfish silhouette. I'll add optional pelvic streamers param: two thin lines? Streamer as very thin triangle in ZY plane below rear: shape (0,0)→(0.45,-0.02)... hmm angelfish ventral fins hang down as long threads: shape: (0,0) → (0.02,-0.35) → (0.05,-0.05) → close; place below belly front. Two of them offset x=±0.04. OK add param.
Pectoral fins: small triangle-ish shapes at sides near front (t~0.7), angled outward: build shape small (0.16 long, 0.08 wide), rotateY(π/2 + tilt) so it's angled: rotate about z? After aligning in ZY plane, rotate around Y? To angle fin outward from body: rotate about the fin's local... Let me: fin geo in ZY plane at origin; rotateY(±0.5) rotates plane normal and sweeps rear tip outward? Point (x_s→ mapped to -z...) rotating about Y moves the rear tip in x: yes rotateY(θ): z' = -x sinθ + ... Let me just: after rotateY(π/2) the shape lies in ZY; then rotateY(±0.6) tilts the fin plane around vertical axis so tip moves outward (±x) ✓; then rotateZ slight to angle down; translate to (±W*0.8 at attach, y -0.05, z front). Looks plausible.
Eyes: two small spheres SphereGeometry(0.045, 8, 6) at x=±(profW*0.75), y=+0.03, z=+0.30 (near nose, on sides), colored near-black (#101418) with... add also white sclera? Small black bead reads as eye at this scale. Add subtle white ring: second sphere slightly larger white behind? Overkill—black beads. Hmm at distance eyes sell "fishiness" — include black + tiny white glint sphere? Just black.
For mouth? skip.
Fin colors: per-part color function. Implementation: `paintPart(geo, fn)` where fn(u,v,x,y,z) returns Color. For ShapeGeometry, uv = (x,y) of shape coordinates normalized? THREE.ShapeGeometry uv = raw x,y coords (unnormalized). So fin color can be function of shape coords — I can compute edges via distance from... simplest: color by shape x (rearward) e.g., gradient dark at tips. I'll pass finColorFn(shapeX, shapeY) using uv attribute.
Let me define per species a `palette`:
```js
clown: body(t,y01):
base = lerp(#c94b00? , orange #ff7a2a, ...)
bands: b = bandFn(t): white at [0.18,0.30], [0.48,0.62], [0.86,0.96] with smooth edges; color = mix(orange, white, b); also darken near band edges (blackish outline): edge = smoothstep near boundaries → mix toward #222 by edge*0.8.
belly lighten: y01<0.35 → lighten.
fins: orange, with black edge: edgeFactor by uv length... tail: white? clown tail white-ish with black? Actually clownfish tail fin is white/black-edged or orange. I'll do: fins base orange, tips black: mix by shapeX/0.34.
tetra: body: upper: at y01>0.55 stripe bright cyan #4de3ff (from nose to ~60% length: t>0.45); lower front silver #dfe7ea; rear lower red #ff2e4d (t<0.5 && y01<0.45): gradations. Fins: transparent-ish gray-white (can't alpha; use pale #cfd6da).
tang: yellow #ffcf1f body slightly darker top (#e6a70e), fins lighter #ffe066, tail's edge white? fine.
angel: silver #d7dde2 with darkening top, black vertical bands: t in [0.30,0.36],[0.52,0.58],[0.74,0.80] plus at eye region; fins: smoky dark #2a3440 gradient to lighter.
```
These patterns via smoothstep mixes. Write helper `sm(a,b,x)` = smoothstep.
Bands in t: note t is 0 at tail — clownfish bands: middle band around t 0.5 (mid-body), rear band near t 0.25, head band near t 0.85. Fine.
**Anemone:** base: SphereGeometry(0.55, 12, 8) squashed y 0.5, color magenta-pink #c04a7f-ish vertex colored darker base. Tentacles: CylinderGeometry(0.015, 0.05, 1, 5, 3) translated y+0.5 → base 0 tip 1 at y=1... tip radius 0.015 top small ✓ (radiusTop 0.015). Per tentacle instance: position on dome cap: random direction within top hemisphere of the base sphere; scale y length 0.5–0.8; orient outward (quaternion from up to dir). Color per instance via setColorAt: tip vs base gradient baked in vertex colors of geometry (bottom magenta deep → tip lighter pink/white?) plus instanceColor variation. Sway via shader injection on the anemone material: transformed.x/z sway by pow(yLocal,1.6). But instance matrices rotate tentacles; sway in local pre-instance space still fine (sway direction local; instances rotated variously — visually varied ✓).
Anemone count 2: one big at (-8.5, base on sand ~0.5, -5), one at (6.5, ..., -7). Clowns get homes near anemone 1 mostly + around.
Sway injection shared helper `injectSway(mat, ampFn)`. For anemone material (Standard, vertexColors) — same onBeforeCompile pattern as fish wag but with y-based sway; kelp similar with uv.y. Write generic:
```js
function makeSwayMaterial(baseMat, glslBody){ baseMat.onBeforeCompile = s => { inject time uniform + attributes; replace begin_vertex with body }; }
```
Each mesh needs own material instance (per-species uniforms like uAmp) — fine.
**God rays placement:** x∈[-10,10], z∈[-8,2], top y=12 (attach at surface), height 12 → down to 0? Better fade before sand: plane height 11, top at 11.8. Tilt: rotate z ±0.15, rotate x slight -0.1; also random rotateY so plane not parallel to camera all the time... planes with double side? side: DoubleSide (additive). Opacity 0.05-0.12, animate ±sin. Fragment gradient: alpha = (vUv.y)^2.2 (top bright) — vUv.y=1 top → bright near surface fading down. Horizontal: smoothstep edges. Flicker: 0.7+0.3*sin(t*0.4+seed).
Color: #9fd8ff-ish. Additive: contributes glow.
Also add a soft radial "sun" glow on the surface shader near center top — the water surface streaks converge — skip extra.
**Let me now also handle first-paint composition:** camera at (0, 4.6, 17.5) looking slightly toward center; anemone with clowns visible left; tetra school mid; god rays behind; kelp clusters framing edges; bubbles right side. Nice composition: put big rock arch left, kelp cluster right back, anemone mid-left foreground-ish.
Rock placement: cluster left: (-11, 0, -6) size 2.2; (-8.6,0,-7.5) 1.4; right: (9,0,-7) 1.8; (11,0,-4) 1.1; center back small: (0,0,-9) 1.2, (3,0,-8.6) 0.9; front low: (-4,0,2) 0.7 half-buried, (5,0,3) 0.6. Ensure not blocking camera path (camera z ≥ ~ -2..20, orbit near z 17; front rocks at z 2-3 with low height fine).
Kelp clusters: (-13.5,0,-8) tall, (13.8,0,-9) tall, (-7,0,-9.5), (10.5,0,-2) mid, (0,0,-10.5) tall behind, maybe (14,0, 0) near right edge... ~26 blades total across clusters + short grass tufts scattered (another 30 instances small). Total kelp instances ~60. All one InstancedMesh with per-instance matrix — blades with different heights via scale. Colors vary via setColorAt (green→teal→olive).
**Sand extent:** 80×60 might leave dome visible below horizon... dome bottom gradient dark handles it. Sand plane 90×70 centered (0, 0, -4)? Just big 100×80. Beyond fog anyway. Sand y=0 with dune displacement up to ~0.8; fish min y bound 1.2 keeps above.
Also sand vertex colors: slight mottling using pseudo-noise hash: c = mix(#b89b78, #8f7358, noise) + sparkle? Keep two-tone noise. And caustics add moving highlights.
**Front glass?** To reinforce "aquarium": maybe add a very subtle horizontal line at bottom like a tank rim? I'll skip physical tank; the underwater look is cleaner.
**Performance check:** ~70 instanced fish in 4 draws + 60 kelp + 26 anemone tentacles + rocks 9 + sand + surface + dome + rays 6 + 3 point clouds ≈ 40 draws, shadow pass renders casters (fish×4 + rocks 9) ≈ fine. AA on, pixelRatio ≤2. Should run 60fps easily.
**Now, the "signature moment":** the scatter interaction itself, plus feeding frenzy. Also the status line reacting. Also maybe an on-canvas ripple at the cursor? A subtle "cursor ring" in 3D at flee point — a small soft ring sprite at the flee point showing where fish will flee from — helps users see the mechanic! Implement: a ring (RingGeometry or shader circle) placed at flee point (closest point on ray), always... it should face camera (billboard). Use a Sprite? Sprite with custom material? Use a Mesh with PlaneGeometry + shader ring, quaternion = camera quaternion each frame. Opacity pulsing gently, radius ~ flee radius (3.2) * 0.35 scaled with distance? Ring radius should be constant world size 1.2? The flee radius is 3.2 — a ring of radius 3.2 might look big; draw at 1.4 with soft glow. It fades in only when pointer is over canvas and moving. This visual affordance makes the interaction discoverable. Yes, add: soft cyan ring + dot. Billboard via mesh.quaternion.copy(camera.quaternion).
**Cursor CSS:** hide default cursor over canvas? cursor: crosshair or custom none? If I hide cursor entirely, users may be confused; use `cursor: crosshair`? A fine custom cursor via CSS none + 3D ring might be enough + HTML dot? Keep default: `cursor: crosshair` subtle. Hmm, crosshair feels gamey. Keep `cursor: default`? I'll set none on canvas and rely on the 3D ring — if pointer leaves, ring hidden, CSS cursor none might confuse... Compromise: cursor: none; the ring + center dot follows precisely = a custom elegant cursor. On touch devices no cursor anyway. I'll do cursor:none with 3D cursor ring. Risk: if WebGL fails, no cursor... acceptable. Also the ring must track fast (update every frame from stored NDC — smooth). Add slight easing for organic feel? Track exactly (no lag) to be usable, maybe tiny lag 0.3. I'll do slight smoothing (lerp 0.35) — feels watery yet responsive.
**Status text logic:** compute avg excite each 0.25s: <0.12 "calm", <0.35 "curious", <0.6 "stirred", else "startled". Color dot: green/cyan/amber/red-ish pastels. Label: "the water is calm".
**Also nice: audio?** No autoplay audio (blocked + no assets). Skip.
**Code layout (single module script):**
```js
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
```
Sections: setup renderer/scene/camera/controls/lights; environment (dome, sand, rocks, kelp, anemone, surface, rays, bubbles, snow); fish factory & species; simulation; food; input; UI; loop.
Let me write the wag injection carefully with species-neutral normalized coords (body length 1, z∈[-0.42-tail, 0.58]):
Wag: amplitude envelope by z: `env = smoothstep(0.3, -0.55, transformed.z)` → 0 at nose-ish, 1 at tail end; plus small head sway: total amp = (0.02 + 0.14*env)*len... geometry normalized so amp 0.03 + 0.13*env; plus also slight yaw of whole body: `transformed.x += sin(...)*0.02` global. Also lateral speed of wag: freq = 5.5 + excite*9. And add small vertical bob? skip.
Frequency also scale with swim speed: real fish beat faster when fast. Use per-instance aWag = normalized speed factor (speed/baseMax): freq = 4.0 + 8.0*aWag + 6.0*aExcite? I'll combine into one dynamic attribute aWag ∈ [0..1.5] meaning effort: effort = (speed/maxSpeed) + excite. Store per-fish, write each frame: `wagAttr.array[i] = effort; needsUpdate`. Cheap.
Phase: per-fish random. Also spatial wave along body: sin(time*freq + phase + transformed.z * 4.0) — wave travels nose→tail? Should travel toward tail (backward): sin(ωt - k z)? For wave moving toward -z (tailward): phase = ωt + kz with... sin(ωt - kz) moves toward +z as t increases (constant phase: ωt - kz = c → z = ωt/k increasing → moves toward nose). I want tailward: sin(ωt + kz)?? constant: ωt + kz = c → z = (c-ωt)/k decreasing → moves toward tail ✓. So `sin(uTime*freq + aPhase + transformed.z*4.5)`. But freq varies per fish via attribute — the attribute multiplies inside sin making time term discontinuous when attribute changes... aWag changes slowly (smoothed effort) — the sin argument jumps slightly per frame causing phase jitter? If freq changes gradually, sin(freq*t) has derivative w.r.t. freq = t*cos(...) — t grows unbounded → changing freq multiplies by large t → huge jumps! Classic bug. Fix: integrate phase per fish on CPU: each fish keeps `tailPhase += dt * freq` and store the phase in dynamic attribute; shader uses sin(aTailPhase + z*4.5). Update phases on CPU (70 floats/frame) ✓. So dynamic attributes: aTailPhase (float). effort encoded into freq on CPU; amplitude could also scale with effort: add second dynamic attr or pack: aTail = vec2(phase, amp)? Use vec2 InstancedBufferAttribute, update both each frame: `attr.setXY(i, phase, amp)`.
Similarly kelp sway uses absolute time + static per-instance phase ✓ (no freq change). Anemone same ✓.
Food attraction and excitement: excitement per fish decays; when fleeing near cursor excite→factor.
**One more feature for depth: fish avoid the cursor ray — also small fish might "school tighter" when scared? skip.
**Numbers for forces** (tuned by feel, must be plausible):
- dt ~0.016s.
- separation: radius 0.8*lenScale; force = sum over neighbors (dir/dist)*(1-d/r)*8.
- cohesion: toward avg pos of same-species neighbors within 4: force = dir * 1.2.
- alignment: (avgVel - vel)*1.5.
- bounds k=3.
- depth pref k=0.4.
- wander 0.6.
- flee: (1-d/R)^2 * 55, R=3.4.
- food: within 9: dir * (10 + (9-d)*2); eat radius 0.4.
- max accel clamp ~40.
speed: base per species (tetra 2.2, clown 1.2, tang 1.0, angel 0.9); cruise = base; min = base*0.55; max = base*2.8; when excited max* (1+excite*1.5) up to ~3x — scattering darts look great.
Turning smoothness: since forces change heading, vel direction changes are bounded by accel clamp; also add drag: vel *= exp(-dt*0.8)? Slight damping stabilizes. Also lateral damping to avoid sideways drift... keep simple.
**Kelp sway amp:** blade height up to 4.5 — tips sway ±0.5. Amp relative in local unit blade height 1: sway = 0.35 * uv.y^1.8 with instance scale — instance scale multiplies displacement (since pre-instance) → sway scales with height ✓.
**Bubble emitter positions:** at (-8.4, 0.4, -5.2) near anemone rock and (9.2, 0.4, -6.8). Column height to 11.5.
**Snow:** box x±15, y 0.5..11, z±10.
**Now the water surface plane:** y=11.8, size 90×70. Shader:
```glsl
uniform float uTime; varying vec2 vXZ; varying float vFogDepth;
vertex:
vec3 p = position; // plane rotated: local xy → world xz after rotateX(-π/2)...
```
I'll build plane already rotated via PlaneGeometry(90,70,60,40).rotateX(Math.PI/2) — rotateX(π/2) maps plane normal +Z→ -Y? PlaneGeometry faces +Z; rotateX(+π/2): +Z→+Y? Rotation about X by +90°: z-axis → ... (0,0,1) → (0, -1, 0)? R_x(θ): y' = y cosθ - z sinθ; z' = y sinθ + z cosθ. θ=+π/2: (0,0,1) → y' = -1, z' = 0 → normal points -Y (down) — visible from below with FrontSide ✓. And local y maps: (0,1,0) → y'=0, z'=1 → local +y → world +z. Displacement waves in shader along world y = local... after rotation, position.y is world y ✓ (plane at y=0 local; we translate up 11.8). Vertex shader displace position.y += wave(position.x, position.z, t). Compute pattern in fragment from varying world xz. Normal unlit custom shading — fine.
fragment:
```glsl
float w1 = sin(vW.x*0.55 + uTime*0.9 + sin(vW.z*0.4)*1.5);
float w2 = sin(vW.z*0.7 - uTime*0.7 + sin(vW.x*0.35)*2.0);
float streak = pow(0.5+0.5*(w1*w2), 6.0); // thin lines
vec3 col = mix(uDeep, uLight, streak*0.85) + streak*0.4;
```
Hmm want it to look like the underside of a rippled surface: mostly darker blue with bright caustic-ish web. Use my caustic function too! Reuse caustic GLSL for the surface with different scale and brightness → cohesive look. surface: col = mix(deep #0b3a55, bright #bfeaff, caust*1.2). Then fog fades distance ✓.
Also make surface double-sided? Camera constrained below → FrontSide (facing down) visible ✓. But grazing angles near horizon show sliver — fine.
**Dome gradient:**
```glsl
col = mix(bottom #051c2b, mid #0d4a68, smoothstep(-8, 9, y)); then mix toward top #3e93b8 by smoothstep(8, 20, y); plus sun glow: pow(max(dot(dir, sunDir),0), 30)*0.5 warm.
```
Fog color: pick #0a3d58-ish matching mid tones.
Hmm one concern: dome radius 90 with camera far 300 ✓. Dome fog:false. But then distant fish/sand fade to fog color #0a3d58 while dome at that level shows gradient #0d4a68 — should match roughly; set fog color = #0b425f close to dome mid at horizon. Fine-tune: fogColor 0x0b4460, density 0.03.
**Light values (no tonemapping, standard materials):**
- Hemisphere: sky 0x9fd0e8 int 0.55, ground 0x1a3040 int? Hemisphere(sky, ground, intensity) single intensity 0.6.
- Directional: color 0xfff2d8, intensity 1.6? Standard materials with NoToneMapping and sRGB output: light intensities in r155+ are physically-based-ish; directional 2.0 typical with useLegacyLights false (default). Lambert surfaces lit by dir 2.0 + hemi 0.6 → could overexpose whites: sand base #b89b78*light... Let me set dir 1.6, hemi 0.5, ambient 0.15. Tune by reason; can't test — choose safe mid values. Fish colors are mid-bright so ok.
Shadow: dir light position (6, 18, 4), target (0,0,-3). Shadow camera ortho: left -20 right 20 top 16 bottom -16, near 4, far 40. bias -0.0005, normalBias 0.02? For instanced fish shadows, small artifacts fine.
**Also fish under caustic light?** skip.
**Fog on god rays:** additive planes + fog would gray them; set fog:false for ray material (ShaderMaterial default fog false ✓). Surface with fog:true ✓. Points: bubbles/snow fog:true ✓.
Wait: ShaderMaterial fog default false — good for rays/dome. For surface/bubbles/snow/food I set fog: true and include chunks.
**Chunk includes in ShaderMaterial:** need `#include <common>` for saturate? I'll write self-contained GLSL avoiding chunk reliance except fog + colorspace. `fog_pars_vertex` declares `varying float vFogDepth;` and fog_vertex sets `vFogDepth = -mvPosition.z;` requires local var mvPosition — I define it. `fog_fragment`: `gl_FragColor.rgb = mix(gl_FragColor.rgb, fogColor, fogFactor);` uses fogDensity define — renderer sets USE_FOG + FOG_EXP2 defines automatically for materials with fog:true ✓.
colorspace_fragment: `gl_FragColor = linearToOutputTexel(gl_FragColor);`? In r170 the chunk is:
```glsl
gl_FragColor = linearToOutputTexel( gl_FragColor );
```
✓ include at very end after fog (fog color is in linear space? fogColor uniform is linear; mix happens pre-conversion ✓ consistent with lit pipeline).
**Vertex colors + sRGB:** Color attributes: I fill Float32 arrays with THREE.Color values created via `new THREE.Color(hex)` — with color management enabled, Color(hex) converts sRGB→linear ✓ consistent.
For shader uniforms with colors: same ✓.
For raw GLSL vec3 constants I write, they're linear-space values; author accordingly (values appear brighter when converted out; e.g., vec3(0.5) linear → ~0.73 sRGB). I'll author surface/dome colors via uniforms using THREE.Color to be consistent.
**Instance matrix update:** compose per fish:
```js
m.compose(f.pos, f.quat, f.scl); mesh.setMatrixAt(f.idx, m);
```
then instanceMatrix.needsUpdate = true per species mesh.
**Now UI/HTML:**
```html
<div id="ui">
<div id="brand"><span class="over">POCKET AQUARIUM · Nº 07</span><h1>Tideglass</h1>
<div id="status"><i id="statusDot"></i><span id="statusText">the water is calm</span></div>
</div>
<div id="legend"> species dots </div>
<div id="hint">move your cursor to startle the shoal · click to drop food · drag to look around</div>
</div>
```
Legend bottom-right small; hint bottom-center. On small screens hide legend. Fonts: Georgia for h1 (italic), system sans for rest. Colors: rgba(210,235,245,0.85). Text shadow subtle.
Also a top-right "depth marker"? skip.
Hint should adapt: on touch devices ("tap to feed") — detect via pointer type; minor: I'll word it neutrally: "move through the water — the shoal scatters · click to feed · drag to look". Fine.
Also add fade-in animation of UI (CSS keyframes) and title.
**Edge cases:**
- Resize: update camera aspect, renderer size.
- pointer leaves canvas: set pointerActive false → ring hidden, no flee.
- Tab hidden: clock delta clamp handles.
**Auto food sprinkle:** at t=1.6s: spawnFood(new THREE.Vector3(2.5, 11.2, 0), 7). Also auto-sprinkle every 35s if no user click since — adds life for judges watching. Implement: lastUserFeed = -999; in loop: if(t - lastFeedTime > 32) sprinkle at random spot — with first at 1.6s. This keeps demo alive. Cap food count.
**Food sinking:** vel.y = -0.55 with wobble; when y < 0.25 rest on sand, life decays; remove after life. Fish eat resting food too (tangs bottom-feeding!). Cute.
**Eating effect:** small ring pulse at eat position? Maybe particle puff — skip; fish excitement bump is enough. Actually let me add a tiny "nibble ripple": reuse cursor ring mesh? Extra complexity — skip. Food shrink on eat (instant removal fine).
**Fish "excite" also boosts wag amplitude ✓ and max speed ✓.**
Let me also double check the flee feeling: fish within R=3.4 of cursor point get force (1-d/R)²*55; near cursor d→0 → accel 55 units/s² → within 0.2s velocity changes by 11 u/s — dramatic dart ✓; at d=1.7: factor .25 → 13.75 accel — decent. Plus excite raises max speed. Fish should scatter visibly with a nice ripple-through effect since nearer fish accelerate first.
Also add flee memory: after cursor leaves, excitement decays 1.8/s → fish resume.
**Ray for flee on touch:** while dragging (orbit), pointermove fires → flee point follows — fine/fun.
**Init camera aspect:** handle in resize().
**Check InstancedMesh + onBeforeCompile with shadows:** The depth material for shadows is separate (MeshDepthMaterial-ish); no aTail attribute usage → fine. But WAIT: the depth material won't know about aTailPhase/aTail attributes — unused attributes are fine (not bound).
But there's a subtle problem: the shadow depth shader for instanced mesh — automatically handles instanceMatrix ✓.
**Frustum culling:** InstancedMesh needs boundingSphere — computed from geometry; instances spread across tank → set `mesh.frustumCulled = false` (since instance matrices move far from origin, default culling uses geometry bounds only → wrong culling). Set frustumCulled=false on all instanced meshes & points ✓ important!
Also sand/surface big planes fine.
**Points frustumCulled false ✓.**
**Kelp instancing + shadows:** kelp castShadow false ✓ receive true? receiveShadow on instanced kelp: works (shadow sampled). Keep.
**Anemone tentacles:** castShadow false.
**Tangs graze near bottom:** prefY 2.2; kelp/rocks interplay fine.
**Now write the profile function for body:**
```js
function bodyProfile(t){
// fuller mid, tapered tail, pointed nose
const p = Math.sin(Math.PI * Math.pow(t, 0.78));
return Math.pow(p, 0.86);
}
```
At t=0: 0 → tail point; but peduncle should have small thickness before tail fin: at t=0.06: sin(π*0.06^0.78)= sin(π*0.109)=0.336^0.86≈0.4?? That's thick near very tail... hmm pow(t,0.78) at small t is larger than t: 0.06^0.78 = e^{0.78*ln0.06}=e^{0.78*(-2.81)}=e^{-2.19}=0.112 → sin(0.352 rad)=0.345 → ^0.86 = 0.41. Too thick at tail tip → tail too blunt. I want thin peduncle: profile should be small until t≈0.15 then swell. Better: p = sin(π * smoothstep-ish)... Use: p = sin(π * t^1.6)? t=0.5→0.5^1.6=0.33 → sin(1.04)=0.86; t=0.2→0.2^1.6=0.076→sin(0.24)=0.24 ✓ thin tail; peak: t^1.6=0.5→t=0.65 → peak at 65% toward nose — too forward. Compromise exponent 1.15: t=0.2→0.148→sin(0.465)=0.45 hmm still thick-ish. Fish profile: peduncle ~30% of max girth. 0.45 ok-ish. Peak at t^1.15=0.5→t=0.59 → thickest at 59% from tail — slightly forward-of-center — acceptable (many fish are thickest at 40-45% from head ✓). Also nose: t=0.9: 0.885^? sin(π*0.9^1.15)=sin(π*0.886)=sin(2.78)=0.35 → rounded nose ✓, t=1→0 ✓. Use prof = sin(π*t^1.15)^0.8? The ^0.8 boosts thin areas (0.35^0.8=0.43)... skip extra power, use plain sin value maybe *0.95. Also clamp min thickness near tail for peduncle: max(p, 0.06)? The tail fin attaches where body ends: at t=0 z=-0.42L. Fine.
Let me finalize: `prof(t) = Math.sin(Math.PI*Math.pow(t,1.15))`. Add belly asymmetry in ny: ny = y * prof * H * (y>0? (1+hump) : 1); where y>0 top: hump multiplies top making arched back ✓. Belly rounder: bottom scale 1.05 vs top 1.15 for back arch — fish have arched dorsal, flatter belly: bottom scale 0.95.
So: `ny = y * prof * H * (y > 0 ? 1.18 : 0.95)` — creates slight crease at waterline of the mapping... y>0 vs y<0 discontinuity across y=0 seam — vertices with y=0 exactly? Sphere has a ring at y=0? SphereGeometry vertices: latitude rows, one at equator y=0 ✓ exactly — those get 1.18 vs neighbors 0.95 → crease at equator! Smooth by blending: scale = mix over y: `s = 0.95 + 0.23 * smoothstep(0, 0.6, y)`? y range is -1..1 before scaling... Actually y is unit sphere coordinate -1..1: use `sy = 0.95 + 0.25*sm(y*1.2)` where sm smoothsteps 0..1 for y 0..0.83 — smooth ✓. Fine: sy = 0.95 + 0.25*smoothstep(0.0,0.8,y).
H per species relative to unit length: clown 0.19, tetra 0.15, tang 0.34, angel 0.42. W: clown 0.14, tetra 0.11, tang 0.13, angel 0.07.
Hmm angel 0.42*2 = 0.84 tall vs length 1.0 — disc-like ✓ with big dorsal/ventral fins → total height ~1.3 — good angelfish silhouette.
**Fin parameter sets per species** (unit length coords, z nose +0.58 max, tail -0.42):
tail fin (fan from peduncle at z=-0.40):
- len (rearward extent), spread (vertical half-extent), fork (notch depth 0..~0.6), thickness n/a.
clown: len 0.24, spread 0.20, fork 0.25, rounded (I'll implement fork as notch param; rounding via many points? shape with quadratic curves — use lineTo simple; small shapes fine).
tetra: len 0.18, spread 0.15, fork 0.4
tang: len 0.17, spread 0.30, fork 0.05 (crescent — add slight rearward sweep: points curve)
angel: len 0.30, spread 0.26, fork 0.1
dorsal: baseFront (t position along back where fin starts), baseLen, height, sweep.
clown: front t=0.52, len t 0.22, h 0.12
tetra: tiny h 0.06
tang: t=0.5, len 0.28, h 0.28 (sail)
angel: t=0.55, len 0.3, h 0.3 + second trailing part? Angel dorsal is tall sail sweeping back — one tall swept fin: front at t 0.6, len 0.35, h 0.38 with sweep so tip points back: shape points (0,0)→(0.12,0.30)→(0.34,0.38)... I'll make dorsal shape param "sweep": intermediate control. Implement dorsal as shape: (0,0) → (sweep*0.4, h*0.75) → (len, h) top tip... Actually simpler polygon: [ (0,0), (len*0.35, h*0.85), (len, h*0.55), (len, 0) ] — a swept-back fin: leading edge rises to 0.85h at 35%, then falls to 0.55h at rear where it meets body. For angel h big ✓. Ventral mirror with h*0.8.
angel pelvic streamers: two thin triangles hanging: shape [(0,0),(0.34,-0.30),(0.40,-0.26),(0.06,-0.02)] roughly — thin curved streamer. Place below front-mid at y = -belly*0.9, x=±0.03, plus slight outward tilt. Let me define streamer: pts [(0,0), (0.10,-0.06), (0.42,-0.34), (0.46,-0.30), (0.12,-0.02)] closed — long trailing thread ✓.
pectoral: small: shape [(0,0), (0.16,-0.03), (0.13,0.05)] triangle-ish; attach at t=0.68 (z≈+0.16), y = -0.03*H-ish, x=±W*0.85, rotated: rotateY(±0.9) around... after rotateY(π/2) base orientation, then rotateY(-0.9 * sign) — for right side (x+): tilt so rear tip moves outward +x: rotating shape further... Let me think concretely: fin plane initially ZY (normal +x). For right pectoral: rotate about Y by -0.5: this swings the -z tip toward... point at rear tip local (0,y,-0.16) (after mapping shape x=0.16→z=-0.16). rotateY(-0.5): x' = x cos(-0.5) + z sin(-0.5) = 0 + (-0.16)(-0.479)= +0.077; z' = -x sin + z cos = -0.16*0.878=-0.14. So rear tip moves to +x ✓ for right fin. Left fin rotateY(+0.5) → tip -x ✓. Then also rotate about Z by -0.4 to angle the fin downward-ish: rotateZ(-0.4) on (0,y,z): x' = x cos + y sin; affects y... For right fin: rotateZ(-0.35): point (0.077, -0.02, -0.14): x' = 0.077*0.94 + (-0.02)(-0.343)=0.079; y' = -0.077*(-0.343)? y' = -x sinθ + y cosθ = -0.077*(-0.343)+(-0.02)(0.94)= 0.026-0.019=0.007 hmm small change; fine — order matters little for small fins. Also drop the fin a bit: translate y -0.02. It'll read fine at fish scale. Then translate to body position.
Eyes: at t=0.88 → z = (0.88-0.42)=+0.46; prof at 0.88: sin(π*0.88^1.15)=sin(π*0.865)=sin(2.72)=0.41 → half-width there = 0.41*W*0.95≈0.39W... place eye at x=±W*0.35? Should sit on surface: x=±prof(0.88)*W*0.8, y=+H*0.10, z=+0.44. Radius 0.035. ✓
Colors — implement colorFns:
```js
const sm = (a,b,x)=>{ x = Math.min(1, Math.max(0,(x-a)/(b-a))); return x*x*(3-2*x); };
function band(t, c, w, soft){ return sm(c-w-soft, c-w, t) * (1 - sm(c+w, c+w+soft, t)); }
```
clown color(t, y01):
```js
let c1 = orange #ff8c1f; darker back: mix toward #d14a00 by sm(0.55,0.95,y01)*0.5;
belly: lighten by (1-sm(0.25,0.05,y01))*0.35 toward #ffd9a8?
bands: b = band(t,0.24,0.045,0.02)+band(t,0.52,0.05,0.02)+band(t,0.86,0.04,0.02) clamp 1
color = mix(color, #f6f7f2, b);
black edges: e = band edges: compute outline = sm(0.7,1,b)*(1-sm(...)) hmm simpler: edge = (b>0 && b<1) region: use bo = band(t,0.24,0.045+0.015,0.02) minus inner...
```
Getting fancy; simplify: outline = band(t,c,w+0.02,0.02) - band(t,c,w-0.02,0.02)? Might go negative... Use: color = mix(base, white, min(1,b)); then mix toward dark #1a1a1a by edge where edge = (sm over gradient of band)... Let me just do two-stage: wider dark band first: bd = band(t,c,w+0.022,0.02); color = mix(color, #23262b, bd*0.85); then white core bw = band(t,c,w-0.02,0.02); color = mix(color, #f2f3ee, bw). This creates white band with dark halo ✓ (dark wider behind white narrower). Order: apply dark first then white ✓.
tetra color(t,y01):
```js
base silver #cfd9de; top blue: mix toward #2fd6ff by sm(0.45,0.75,y01)*sm? blue stripe from head to adipose: along full t with fade at tail: stripe = sm(0.55,0.8,y01)*(1-sm(0.25,0.05,t))? Tetras: blue lateral stripe mid-body horizontal: y01∈[0.45,0.75] ✓ full length mostly. red: lower rear: red = (1-sm(0.5,0.45,y01))*... red covers lower half from t<0.55: redM = sm(0.6,0.35,t)*(1-sm(0.42,0.55,y01)); color = mix(color,#ff2e3e,redM); blue over red: apply blue after.
belly white: y01<0.3 lighten.
```
fins: pale #b9c4c8 slightly translucent look (can't alpha per-part; ok).
tang color: base #ffd21a; top mix toward #e8a20c by sm(0.5,0.95,y01); snout lighten #ffe063 by sm(0.8,1,t); fins #ffe45c; tail edge white #fffbd0 mix by uv? fin colorFn(u,v) can mix by distance... For tang fins, mix white toward tip: mix(#ffd21a? fine).
angel color: base #dce3e8; top mix #5a6a78 by sm(0.5,0.9,y01)*0.6; bands: bd = band(t,0.30,0.03,0.02)+band(t,0.55,0.03,0.02)+band(t,0.80,0.035,0.015) → mix toward #16191d min(1,bd); fins smoky: #4a5560.
Fin color function signature: for ShapeGeometry parts, uv=(shapeX, shapeY). finColor(u,v): per species per part name. I'll pass a function (part, u, v)→Color. Implement species-specific quickly.
**Anemone colors:** base rock-ish? base column magenta: vertex color from #7a2f57 (bottom) → #d95f9a (top). Tentacles: base #c2306f → tip #ff9ec9 → white tip #ffe3ef at very tip (y>0.9). Instanced tint via setColorAt: hue jitter (multiply 0.9–1.1).
**Kelp colors:** per instance setColorAt: palette between #2e7d4f, #3a9e6e, #7fb069? Underwater kelp more olive-teal: #2f6b4f, #57a06a, teal #3f8f7a. Gradient baked: base darker #14402c → tip lighter ×instance color. Vertex colors gradient + instanceColor multiply — instanceColor multiplies vertexColor ✓.
**Sand color noise:** hash(x,z) via sin: n = fract(sin(x*12.9898+z*78.233)*43758.5) classic; mix two sands; plus darker patches by low-freq sin. Vertex colors static ✓.
**Rocks colors:** vertex color by displaced height: lower #4a4a52 darker → top #7c7f74 with warm tint #6b6257; add slight green algae on top: mix #3f6b4a by sm(h)... nice touch: mossy tops.
**God ray fade near camera:** planes pass near camera? They're vertical at z∈[-8,2]; camera z≥ -2? camera can go to z=-6 (distance 18 from target z=-2, azimuth limits ±65°... cos65=0.42 → z = -2 - 18*0.42*cos? hmm camera z = target.z + r*cos(polar)*cos(azimuth)... r horizontal component = r sin(polar) ≈ r (polar≈π/2). z = -2 + 18*cos(azimuth): az=0 → z=16; az=±65° → z = -2+18*0.42=5.5. So camera z ≥ ~5.5? With min azimuth... wait azimuth 65° gives cos 0.42 → z=5.5; larger azimuth 85°: z=-2+1.57=-0.4. maxAzimuth 1.15 rad (65.9°) → z ≥ 5.4 ✓ camera never behind tank mid. Rays at z≤2 → 3+ units from camera ✓.
Also rays shouldn't intersect camera near plane... at distance ≥3 fine.
**Fish bounds z ±11 — rays near fish ok.**
**Cursor ring placement:** closest point on ray at clamped t [5, 40] — always inside/near tank ✓. Ring radius world 1.3, shader: soft ring + center dot, opacity 0.5, additive, depthWrite false, depthTest true (occluded by fish/rocks naturally = feels underwater ✓). Billboard: ring.quaternion.copy(camera.quaternion) each frame. Smooth position lerp 0.4. When pointer inactive: fade opacity to 0 (lerp material uniform).
Ring shader:
```glsl
frag: vec2 q = vUv-0.5; float r = length(q)*2.0;
ring = smoothstep(0.16,0.08,abs(r-0.78));
dot = smoothstep(0.14,0.02,r);
ripple = extra faint outer ring pulsing: 0.1*sin(uTime*3 - r*6)?
alpha = (ring*0.7 + dot*0.9) * uOp; color = #9fe8ff
```
Plane size 3.4 world units (radius 1.33 visible) ✓.
**Food points:** MAXF=64. attributes updated; hidden slots y=-100. drawRange = active count (compact array each frame — just rewrite positions each frame for active; maintain foods array objects; write into buffer sequentially). Size attr per food (0.5–0.8 world-ish → gl_PointSize = aSize*(160/ -z)). Colors: flakes #ff9b4a, #e86a3a, #ffd27a random.
Also foods slight rotation irrelevant (round dots). Maybe make flakes slightly irregular: alpha pattern via gl_PointCoord: shape = smoothstep(0.5,0.42,d) * (0.8+0.2*sin(...)) fine.
**Snow:** 240 points; sizes 1-2 px: gl_PointSize = aS*(60/-z)*? clamp min 1. alpha 0.18. color #cfe6ee.
**Bubbles:** sizes 4-10 px at mid distances: gl_PointSize = s*(170/-z). At z=-10 (27 away): 170/27≈6.3 × s(0.5–1.0) → 3-6px ✓.
**Let me now think about the water "surface from below" visibility from camera y≈4.5, polar min 1.23 → looks slightly upward? Camera looks AT target (0,4.5,-2): from y 4.5–10.5. When camera high (y=10.5, looking down at target) surface less visible; when low y=4.5 horizontal → surface visible in upper frame ✓. Initially camera at (0, 4.8, 16) → looking slightly... target (0,4.5,-2): nearly horizontal → surface band visible top of frame with god rays ✓ nice.
**Initial controls.autoRotate?** Slight auto-drift when idle: if no user interaction for 10s, gentle autoRotate 0.15 speed? OrbitControls.autoRotate with autoRotateSpeed 0.3, disable on first pointerdown. It makes first paint... autoRotate rotates azimuth continuously — nice alive feel. But keeps moving always after idle... I'll enable autoRotateSpeed 0.25 and toggle off on user interaction, re-enable after 20s idle? Simple: controls.autoRotate = true; on pointerdown set false; after 30s no interaction set true again. OK.
**Screenshot at 3s:** scene with fish mid-swim, autoRotate barely matters. Title + status visible.
**Write the status logic** update every frame cheaply.
**Potential pitfall — OrbitControls import path r170:** 'three/addons/controls/OrbitControls.js' ✓.
**mergeGeometries import:** `import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';` ✓ r170 exports mergeGeometries.
**Now, think about z-fighting / overlapping:** kelp bases on sand dunes: sand height varies ±0.7; kelp y should sit at sandHeight(x,z)+0? Compute same sand height function in JS for placements (I'll define sandHeight(x,z) function used both for geometry displacement and object placement ✓ important). Rocks: sink rocks by 30% radius. Anemone base on sand height ✓. Bubbles emitter y start sandHeight+0.2.
sandHeight(x,z):
```js
function sandHeight(x,z){
return 0.55*Math.sin(x*0.16+1.3)*Math.cos(z*0.21-0.8)
+ 0.3*Math.sin(x*0.42+z*0.27)
+ 0.22*Math.sin(z*0.5+ x*0.11+2.0)
+ 0.9*Math.exp(-((x*x)/(2*30)+(z+8)*(z+8)/50))*-1?
```
Keep gentle: base amplitude ~0.6, plus a broad rise toward back: + 0.5*smoothstep? add `+ 0.45*sm(-6,-14,z)`? Back higher → depth. Also rim? Keep simple sum of sines, max ~0.9. Fine. Fish min y 1.2 > 0.9+fish halfheight ✓.
**Draw order for transparency:** transparent objects: god rays, bubbles, snow, food, cursor ring, water surface (opaque - fine). Set renderOrder: rays 1? Additive rays drawn among transparents sorted by depth — additive blending order-independent visually (additive commutes) ✓. Bubbles normal blending order vs additive — minor. Set depthWrite false on all transparents ✓.
Snow: normal blending alpha 0.18, depthWrite false ✓. Food alpha 0.95 depthWrite false (round soft edge ok). Could set food depthWrite true to occlude properly? Soft sprites with depthWrite true cause halo quads; keep false; depthTest true → occluded by rocks/fish ✓ (fish drawn after? opaque drawn first ✓).
**Fish shadows on sand direction:** light from (6,18,4) → shadows fall toward (-x?) direction opposite light: shadow offset toward -6x... fine.
**HemisphereLight ground color** sandy 0x24413d? ground color affects up-facing? Hemisphere: skyColor from above, groundColor from below ✓ ground 0x1c3844 (dark teal) so sand doesn't glow from below.
Add subtle directional fill from front-top to light fish faces toward camera: dir2 color 0x88c8e8 intensity 0.5, position (0, 8, 20), no shadow. This ensures front-facing fish sides aren't too dark ✓ important since main light comes from behind-top (z=4 is in front-ish actually; light at (6,18,4) with tank z up to 11... camera at z 16 → light from behind camera top-right ✓ good modeling). Then fill light maybe unnecessary; add faint anyway 0.35.
**Now let me also double check InstancedBufferAttribute update per frame:** For aTail (vec2 dynamic): create with `new THREE.InstancedBufferAttribute(new Float32Array(n*2), 2)`, set usage DynamicDrawUsage; update via attr.setXY(i, phase, amp); attr.needsUpdate = true each frame. Works ✓.
**Fish per-fish scale:** scl = species.len * (0.85+rand*0.3) — one Vector3 per fish (uniform scalar vector). Store as f.scl (Vector3 set to s,s,s) with slight y variation? Keep uniform.
Also, wag amplitude attribute `amp` = 0.5 + effort*0.8 (relative); shader multiplies envelope.
Let me write fish shader injection:
```glsl
// attributes
attribute float aPhase; // static random 0..2π? actually unused if aTail holds phase
attribute vec2 aTail; // x: phase, y: ampFactor
uniform float uTime;
...
float ph = aTail.x;
float amp = aTail.y;
float env = 0.35 + 0.65 * smoothstep(0.25, -0.62, position.z); // wait use transformed
```
use `transformed`:
```glsl
float env = 0.3 + 0.7*smoothstep(0.22, -0.62, transformed.z);
float wag = sin(aTail.x + transformed.z*4.2);
float sideWave = wag * env * (0.035 + 0.10*amp);
transformed.x += sideWave;
// head slight counter sway for realism:
transformed.x += sin(aTail.x)*0.012;
// gentle fin flutter at tail tip:
```
Also nice: slight roll of body during wag? skip.
amp encodes effort 0..1.4: sideWave max ~ (0.035+0.14)*1 = 0.175 at tail — relative to length 1 — good visible wag ✓. Also fins at tail get same displacement via z ✓ (tail fin at z≈-0.4..-0.75 → env 1 ✓).
But careful: tail fin ShapeGeometry vertices after merge share coordinate frame ✓.
Wave along body: sin(phase + z*4.2): wavelength 2π/4.2 ≈ 1.5 body lengths — one full wave across body ✓ looks like swimming.
**Kelp sway shader:**
```glsl
attribute float aPhase;
uniform float uTime;
float h = clamp(position.y, 0.0, 1.0); // blade local y 0..1 (scaled at instance)
float sw = sin(uTime*0.9 + aPhase + h*2.2);
float sw2 = cos(uTime*0.7 + aPhase*1.7 + h*1.7);
float bend = h*h;
transformed.x += sw * bend * 0.42;
transformed.z += sw2 * bend * 0.3;
```
Blade geometry: PlaneGeometry(0.14, 1, 1, 10) translate y+0.5, taper by y: for each vertex: w = 1 - pow(y,1.3)*0.75; x*=w; slight z curve: z += sin(y*2.2)*0.06; vertex colors gradient. uv.y available ✓ but easier use position.y (0..1) ✓. Instance scale: height 2.2–5, width scale ~height*random. Sway displacement pre-instance → scales with height — tall blades sway up to 0.42*height?? That's huge (0.42*5=2.1 units). Reduce: sway amp 0.16 relative → 0.8 world at tip for tall blade — acceptable flowy. Hmm, since displacement multiplies by instance scale (bigger fish... bigger blades sway more) — nonlinear but organic. Set 0.14.
Also blade tip drift slower: freq 0.9 ok.
**Anemone sway:**
```glsl
attribute float aPhase; uniform float uTime;
float h = clamp(position.y, 0.0, 1.2);
float a = pow(h, 1.4);
transformed.x += sin(uTime*1.6 + aPhase)*a*0.16;
transformed.z += cos(uTime*1.3 + aPhase*1.3)*a*0.16;
```
Tentacle geometry: CylinderGeometry(0.02, 0.055, 1, 5, 4) translated +0.5 → y 0..1 ✓. Tip small ✓. But sway displacement pre-instance rotates with instance orientation — tentacles pointing sideways sway in their local x — varied ✓.
Anemone base mesh: SphereGeometry(0.5,14,10) scale y 0.55, color gradient vertex. Plus a small column: CylinderGeometry(0.42,0.5,0.35)? merge base parts into one geometry, position at sand height.
**Rock geometry displacement:**
```js
function rockGeo(seed){
const g = new THREE.IcosahedronGeometry(1, 2);
const p = g.attributes.position;
hash based on vertex position + seed:
n = fract(sin(dot(v, vec(12.98,78.23,37.7)))*43758) style in JS
scale each vertex by 1 + (n-0.5)*0.55; also flatten y by 0.75 after? squash after displacement: y*=0.8
computeVertexNormals? With flat shading: material.flatShading = true → normals computed per face in shader (flatShading uses derivatives) — no need normals accurate; still call computeVertexNormals for safety.
vertex colors by final y.
}
```
3 rock geos; ~9 rocks. flatShading true gives nice low-poly rock facets ✓.
IcosahedronGeometry(1,2) has shared vertices? Icosahedron via PolyhedronGeometry — vertices duplicated? PolyhedronGeometry generates non-indexed? It generates with index? PolyhedronGeometry is non-indexed I believe (positions expanded). If non-indexed, displacement per-vertex would tear faces (same position different vertex objects get same hash if hash depends on position → same displacement ✓ no cracks). Hash from position ✓ deterministic ✓.
flatShading on rock material ✓.
**Sand geometry:** PlaneGeometry(110, 80, 100, 70).rotateX(-π/2) → facing +Y ✓. Displace y = sandHeight(x,z). Vertex colors. computeVertexNormals. receiveShadow. Material MeshStandardMaterial({vertexColors, roughness:1}) + caustic injection (strength ~0.55) — sand bright base; caustics multiply (1+c*strength) up to 1.55 bright web ✓.
Rocks material: roughness 0.95, caustic injection 0.3.
**Caustic GLSL (shared string):**
```glsl
float causticField(vec2 p, float t){
vec2 q = p;
float v = 0.0;
float w = 0.55;
for(int k=0;k<3;k++){
q += vec2(sin(q.y*1.7 + t*0.9 + float(k)*1.3), cos(q.x*1.4 - t*0.8 + float(k)*2.1))*0.42;
v += w * abs(sin(q.x*1.5 + t*0.7) * sin(q.y*1.6 - t*0.9));
w *= 0.6;
}
v = v/1.14; // 0.55+0.33+0.198=1.078 ~ normalize
return pow(clamp(1.0 - v*1.6, 0.0, 1.0), 5.0);
}
```
Estimate: sin*sin ∈[0,1], abs avg 0.42? weighted avg v≈0.45; 1-0.72=0.28 pow5=0.002 baseline dark; ridges where v→0 → 1.0. Dynamic ridges ✓. p scale: pass vWPos.xz*0.5.
Actually also caustics should slowly drift overall: add t*0.15 to p before loop ✓.
For the water surface I'll reuse with different scale/brightness.
**Check: `#include <map_fragment>` injection for standard material with vertexColors:** modifies diffuseColor after vertex colors applied ✓ (color_fragment comes before map_fragment? Order in meshphysical/standard fragment: map_fragment then color_fragment? Actually: `#include <map_fragment>` then `#include <color_fragment>`? Let me recall meshphysical frag order:
```
#include <clipping_planes_fragment>
vec4 diffuseColor = vec4( diffuse, opacity );
...
#include <logdepthbuf_fragment>
#include <map_fragment>
#include <color_fragment>
#include <alphamap_fragment>
#include <alphatest_fragment>
...
```
So map_fragment before color_fragment. Injecting caustics after map_fragment then color_fragment multiplies vColor after → caustic applied to diffuse before vertex color multiply — same effect multiplicatively ✓ fine.
Wait, but if I multiply diffuseColor by (1+c*s) then vertex color multiplies — final = diffuse*vcolor*(1+cs) ✓ same as after. OK.
**One risk: onBeforeCompile material caching/customProgramCacheKey** — multiple materials with same onBeforeCompile code but different uniforms... three caches programs by shader code + defines; my injected uniforms differ per material instance but code same → they share program, uniforms set per material via shader.uniforms captured in closure ✓ standard pattern works (each material instance gets its own onBeforeCompile call → its own uniform objects bound). But three's program cache key: material.customProgramCacheKey defaults includes onBeforeCompile.toString() — same fn source → same key → program shared ✓ uniforms still per-material ✓. Good.
Shared timeUniform object: assign shader.uniforms.uTime = timeU (same object) → update once ✓.
**Fog + instanced fish**: ✓ automatic.
**Shadow acne on sand from itself:** sand receives shadow with big plane; bias fine.
Alright — also handle WebGL context creation failure? If fails, show message. Minor; add try/catch → but then errors? Keep simple.
**Let me also reconsider: fish flee radius vs camera distance.** The cursor point is clamped t∈[5,40] along ray — at typical scene distances fish at z ~ 0-8 and camera z 16 → fish 8-16 away; t clamp 40 doesn't bind. The 3D cursor ring placed at same closest point... ring at t = clamp(...,5,40) — but the closest point on ray to a *specific fish* varies per fish. The ring should mark the ray itself: pick fixed t = distance where ray crosses mid-tank z=0? Compute t at z=0 plane intersection (camera always front): t0 = (0 - origin.z)/dir.z if dir.z<0; if user aims high at surface, dir.z small negative still crosses; if aiming at dome edges maybe positive... fallback t = 14. Ring at origin+dir*t0 ✓ represents "where you're pointing in the tank". Fish flee uses their own closest point ✓ consistent enough.
For feeding: intersect ray with plane y=11.0 (surface food sprinkle) — if dir.y >= 0 (aiming up) fallback: intersect with z=0? If aiming up at surface from below, ray hits y=11 quickly → near top center — fine actually since dir.y>0 means pointing up: intersection exists ✓ (t>0). If dir.y<0 slightly, ray hits sand: food would spawn at sand?? For click, better: intersect with y=11 if dir.y>0.05 else intersect horizontal plane y = 5 (mid water)? Simplest: always intersect plane y=10.8; if t<=0 or intersection outside bounds, fallback to point at t=12 on ray clamped to bounds center. Let me: p = origin + dir*max(t_surface, 6)? If aiming downward, t_surface negative → use t=10: p deep mid — food sprinkles mid-water then sinks to sand ✓ natural. Clamp p within box x±15, z±10.5, y within [1.5, 10.6]: set spawn y = min(10.8, max(3, p.y))? Let me simply: spawnPoint = ray at t where y=10.8 if that t ∈ (0, 60), else ray at t=10; then clamp x,z to bounds; spawn y = 10.8 → food falls from near surface through the column at cursor x/z ✓ intuitive: click location column gets food from above.
Fish then swim up to eat ✓ lovely.
**Also auto sprinkle position random-ish center: (rand*8-4, 10.8, rand*6-3).**
**Food physics:** vel.y -= 3*dt (buoyant drag): terminal ~0.5: vel.y = max(vel.y - 2.5*dt, -0.55); wobble x,z: vel.x = sin(t*3+seed)*0.15... integrate pos += vel*dt; if pos.y < sandHeight+0.06 → rest (vel 0), start life decay 6s (fade alpha via color attr alpha? use aFade attr → shader alpha *= fade). Manage fade attr per food.
Eat check: fish within 0.5 of food → consume (remove): mark dead → compact.
Attraction: fish target nearest active food within 9. When food resting on sand, tangs (bottom) love it ✓ all fish eat.
Feeding excitement: on eat, fish.excite = min(1.2, excite+0.35)? For wag boost. Also cap simultaneous targeting — all fish swarm: fine, frenzy!
**Fish avoiding cursor even while feeding:** flee overrides: apply both forces; flee near cursor stronger (55 vs food 12) ✓.
**Status text also mentions food?** Keep excitement-based only.
Now — species list for legend: "ocellaris clownfish ×12", "neon tetra ×40", "yellow tang ×9", "veil angelfish ×6". With dots colored (#ff8c1f, #4de3ff, #ffd21a, #d7dde2). Anemone & kelp not in legend; maybe second row "sabelliferous anemone"? Keep fish only. Bottom-right vertical stack, small.
On mobile (≤640px): hide legend, shrink title, hint shorter (CSS content swap? Just hide the middle part via spans with class hide-sm).
**Title:** "Tideglass" — with a thin rule and overline "POCKET AQUARIUM · Nº 07". Status below: "◦ the shoal is calm" updating.
Also maybe FPS? no.
**Write CSS:**
```css
:root{ --ink: #dff3f7; }
html,body{margin:0;height:100%;overflow:hidden;background:#04202e;}
#c{position:fixed;inset:0;width:100%;height:100%;display:block;cursor:none;}
#ui{position:fixed;inset:0;pointer-events:none;font-family:...;color:var(--ink);}
.brand{position:absolute;top:28px;left:32px;}
.over{font-size:10px;letter-spacing:.34em;opacity:.66;}
h1{font: italic 400 clamp(30px, 4.4vw, 46px) Georgia, 'Times New Roman', serif; margin:...; letter-spacing:.02em; text-shadow: 0 2px 18px rgba(0,40,60,.8);}
#status{...}
.legend{position:absolute;right:30px;bottom:26px;text-align:right;font-size:10.5px;letter-spacing:.14em;line-height:1.9;text-transform:uppercase;opacity:.85}
.legend i{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:8px;vertical-align:1px;}
#hint{position:absolute;bottom:22px;left:50%;transform:translateX(-50%);font-size:10.5px;letter-spacing:.22em;text-transform:uppercase;opacity:.6;}
#vig{position:fixed;inset:0;pointer-events:none;background:radial-gradient(120% 90% at 50% 40%, transparent 55%, rgba(2,16,26,.55));}
fade-in: #ui{animation: fade 1.6s ease .3s both}
```
Add small separators "·" in hint.
Mobile: media max-width 640: .legend display none; .brand top 18 left 18; h1 size; #hint font 9px width 90% text-align center letter-spacing .12em.
**JS size** — this will be long (~700-900 lines). That's fine.
Let me carefully write helper for injecting shader material mods:
```js
const timeU = { value: 0 };
function instancedWag(mat){ // fish
mat.onBeforeCompile = (sh)=>{
sh.uniforms.uTime = timeU;
sh.vertexShader = sh.vertexShader
.replace('#include <common>', '#include <common>\nuniform float uTime;\nattribute vec2 aTail;\nattribute float aPhase;')
.replace('#include <begin_vertex>', `#include <begin_vertex>
{
float env = 0.28 + 0.72 * smoothstep(0.20, -0.60, transformed.z);
float wagAmp = 0.03 + 0.085 * aTail.y;
transformed.x += sin(aTail.x + transformed.z * 4.2) * env * wagAmp * 6.0 * 0.35;
}`);
};
}
```
Compute: 0.085*1.4(max amp)=0.119*env(1)*... times 0.35*6=2.1 → 0.25 tail — too much? 0.119*2.1=0.25 relative to body length — tail swing quarter body length each side — actually visible fish tail swings are large ✓ but fins might detach visually? Displacement is continuous in z so no detach; tail fin (z=-0.4..-0.75) env constant 1 → whole fin rigidly shifted — fin swings ±0.25 ✓ looks lively. Let me set factor so max ~0.18: (0.03+0.085*amp)*env with amp≤1.3 → 0.14*env. Just write `float wagAmp = (0.028 + 0.075*aTail.y);` and multiply by env*1.6? I'll simply choose: `transformed.x += sin(aTail.x + transformed.z*4.0) * env * (0.02 + 0.06*aTail.y) * 3.0;` → max (0.02+0.078)*3=0.29 hmm. Fine: aim max total 0.16: (0.016 + 0.045*aTail.y)*env*2.2 → max (0.016+0.0585)*2.2 = 0.164 ✓. cruise: aTail.y~0.45 → (0.016+0.02)*2.2=0.08*env ✓ gentle. Good.
Also add slight roll into wag? skip.
Wait — smoothstep(0.20, -0.60, z): at nose z=0.58 → 0? smoothstep(edge0=0.20, edge1=-0.60): since edge0>edge1 GLSL smoothstep with edge0>edge1 is undefined!! GLSL smoothstep requires edge0 < edge1 (results undefined otherwise; in practice it computes clamp((x-e0)/(e1-e0)) which works reversed giving 1-clamp — most GPUs compute the formula which handles reversed edges fine mathematically, but spec says undefined. Safer: `1.0 - smoothstep(-0.60, 0.20, transformed.z)`. Use that everywhere reversed. ✓ Important — I'll always order edges ascending.
env = 1 - smoothstep(-0.60, 0.20, z): at z=-0.60 → 1; z=0.20+ → 0 ✓ head barely wag — add head min 0.22: env = 0.22 + 0.78*(1 - smoothstep(-0.60, 0.20, z)). Head gentle sway ✓.
**Kelp:** env by position.y ascending ✓ (0..1): bend = pow(clamp(position.y,0.,1.), 1.7).
**Now simulation code structure:**
```js
const fishes = []; // all
const speciesMeshes = [];
function spawnSpecies(def){
build geometry (merge parts), material, InstancedMesh count;
for i: fish object { pos: random within bounds & near prefY, vel: random dir * cruise, quat identity, tailPhase rand, effort 0, excite 0, scl, idx, home? , def }
set aPhase static? aTail dynamic includes phase; aPhase unused then — remove aPhase, only aTail (vec2). ✓
mesh.setColorAt for variation.
}
```
Update:
```js
function updateFish(dt, t){
// flee ray
raycaster.setFromCamera(pointerNDC, camera);
const ro = raycaster.ray.origin, rd = raycaster.ray.direction;
// per fish
for each f:
f.forces reset (use preallocated vectors)
...
}
```
Per-fish temp vectors — allocate module-level temps to avoid GC.
Neighbor loops: to keep O(n²) with typed access, just loop arrays (69 fish) — but do separation within same double loop as cohesion to halve: for each pair (i,j) compute once: same species → accumulate cohesion/align both ways; always accumulate separation both ways if d<sepR.
```js
for(i...){ for(j=i+1...){
d = dist(pi,pj);
if(d<sepR){ push apart both }
if(sameSpecies && d<neighR){ cohesionSum[i]+=pj; count[i]++; velSum[i]+=vj; ...same for j }
}}
```
sepR could be scale-based; use per-species sepR = def.len*1.1? Between species use max of both*0.8. Simplify: sepR = 0.9*(si.len+sj.len)*0.55? Let me: sepR = (si.len + sj.len)*0.6; force = dir*(1-d/sepR)*9.
Cohesion radius per species: tetra 3.2 (tight school), clown 3.5, tang 4, angel 4.5 — use def.neighR.
After loops, per fish:
```js
acc.set(0,0,0);
if(count) acc += (centroid - pos).normalize()*def.coh + (avgVel - vel)*def.ali;
sep accumulated during loop: f.sep vector; acc += f.sep*...
wander: acc.x += sin(...)*0.7 etc with f.rand phases
bounds soft
depth pref
home (clown)
food attract
flee
excite decay & maxSpeed
vel += acc*dt; speed clamp; pos += vel*dt; hard clamp pos into bounds (safety)
orientation: compute yaw,pitch,roll; slerp; compose matrix; write aTail(phase += dt*(4+effort*10+excite*6), effort)
```
effort = clamp(speed/def.cruise, 0, 1) + excite*0.5.
tailPhase accumulation ✓ no jump.
Roll: track f.prevYaw; turn = wrapPI(yaw - prevYaw)/dt; targetRoll = clamp(-turn*0.35, -0.6, 0.6) — sign: banking into turn: for a fish turning left (yaw increasing?), roll should dip inner side... whichever sign looks natural; pick -turn*0.35 and hope ✓ (can't test; banking subtle either way, magnitude small 0.35*turnRate; turnRate maybe 2 rad/s → 0.7 clamp 0.6 rad — a bit much; scale 0.25 clamp 0.45).
Smooth: f.roll += (targetRoll - f.roll)*min(1, dt*4).
Prev yaw update after storing.
**Wrap angle helper** for yaw continuity:
```js
const wrapPI = a => Math.atan2(Math.sin(a), Math.cos(a));
```
**Food update loop + fish-food interaction:** during fish update, find nearest food — that's fishes×foods (70×~30=2100 dist checks/frame) fine.
**Raycasting per frame:** setFromCamera cheap ✓.
**cursor ring update:**
```js
t0 = compute from plane z=0: if |rd.z| > 1e-4: tt = (0 - ro.z)/rd.z; if tt>0.5 use else 12.
ringPos.lerp(target, 0.35); ring.material.uniforms.uOp lerp toward pointerActive?0.55:0
ring.quaternion.copy(camera.quaternion)
```
**Auto sprinkle schedule:**
```js
let nextAuto = 2.0;
in loop: if(t > nextAuto){ sprinkle(randomSpot); nextAuto = t + 34; }
```
Also if user feeds, push nextAuto = t + 34.
**Status:** compute avgExcite every frame (sum during update), thresholds update text if changed (avoid layout thrash — text small, fine, update only on change).
Let me write out actual species definitions:
```js
const SPECIES = [
{ name:'clown', count:12, len:0.85, height:0.20, width:0.135, cruise:1.25, maxS:3.6, coh:0.55, ali:1.1, neighR:3.4, prefY:2.8, wander:0.55, home:true,
finset:{...}, colorFn, finColorFn },
{ name:'tetra', count:40, len:0.5, height:0.15, width:0.10, cruise:1.9, maxS:5.0, coh:1.0, ali:2.2, neighR:2.6, prefY:5.6, wander:0.5 },
{ name:'tang', count:9, len:1.05, height:0.34, width:0.13, cruise:1.05, maxS:3.2, coh:0.22, ali:0.5, neighR:4.2, prefY:2.3, wander:0.85 },
{ name:'angel', count:6, len:1.15, height:0.40, width:0.075, cruise:0.85, maxS:2.6, coh:0.3, ali:0.7, neighR:4.5, prefY:6.2, wander:0.4 },
];
```
prefY clown 2.8 near anemone top (~1.2) ✓ hover above.
Anemone positions: A(-8.3, sandH, -5.5), B(6.8, sandH, -7.5). Clown homes: near A (60%) and B (40%): home = anemone pos + (0,1.3,0).
Spawn positions: near home for clowns; others random in box.
**Kelp instancing set:**
```js
clusters = [ {x:-13,z:-7,n:8,h:[3,5.2]}, {x:13.5,z:-8,n:7,...}, {x:-9.5,z:-9.8,n:6}, {x:9,z:-1.5,n:5}, {x:2.5,z:-10,n:6}, {x:14,z:1.5,n:4} ];
plus 26 scattered short blades (h 0.8–1.8) anywhere.
```
~62 instances.
Blade tint palette: ['#2c6b4f','#3f8f6a','#57a06a','#2f8f7a','#6fae6a'] jitter.
**God rays def:**
```js
rays: for i in 0..5: x=-9+i*3.4+jitter, z=-7+rand*8, w=1.2+rand*1.8, h=11.5, tiltZ=±0.06+..., rotY = rand small, opacity base 0.05+0.05rand, seed.
```
Material shader:
```glsl
uniform float uTime,uSeed,uOp; varying vec2 vUv;
frag:
float x = vUv.x; float y = vUv.y;
float edge = smoothstep(0.0,0.35,x)*smoothstep(1.0,0.65,x);
float fade = pow(y, 2.6);
float fl = 0.6+0.4*sin(uTime*0.37+uSeed*9.0);
float a = edge*fade*fl*uOp;
gl_FragColor = vec4(vec3(0.62,0.85,1.0)*a, a); // additive: color*alpha? With AdditiveBlending srcAlpha? default AdditiveBlending: src*srcAlpha + dst. So rgb*alpha added. Set gl_FragColor = vec4(color, a).
include colorspace: colorspace chunk converts rgb only (alpha untouched) ✓
```
transparent: true, blending: THREE.AdditiveBlending, depthWrite false, side: DoubleSide, fog: false.
Vertex: pass vUv, standard transform; add slight sway: position.x += sin(uTime*0.2+uSeed*6.0)*0.4 before matrix? Plane rotated; fine small.
Rays geometry: PlaneGeometry(w, h) positioned y = h/2 + 0.2 → top at ~11.7 just below surface 11.8 ✓. Slight rotation.z tilt.
**Water surface at y=11.8; godray top at 11.7 ✓.**
**Bubble columns rise to 11.4 then reset (fade near top? just reset).**
Also bubble wobble amplitude grows with h: wob = 0.1+h*0.05.
**Snow:** 240 pts, drift: pos.x += sin(uTime*0.13+seed)*0.4 etc in shader (static positions attr + time offsets), y stays.
Actually give snow slow sink: y = base.y - mod(uTime*0.12*aS + aO, 11.0); wrap → drifting down gently ✓ (marine snow sinks). Base y range 1..12.
**Cursor ring:** PlaneGeometry(3.2,3.2); shader as described; uniforms uTime, uOp.
**Also handle `pointerdown` on canvas → controls stop autoRotate permanently? set userInteracted=true; controls.autoRotate=false; idleTimer restarts.**
**Foods:** also attract fish only if not too many fish targeting? skip.
**Numbers check for bounds:** x ±16 (tank halfwidth 16, kelp at 14, rocks 11); plane extents sand 110 wide — beyond box there's sand visible with kelp at 14. Fish stay ±16; camera can see beyond to fog. OK.
**Fog density:** 0.03 → at 30 units factor 1-exp(-0.81)=0.55; dome at 90 would be 99.9% fog if it had fog — it doesn't ✓. Rocks at z=-9, camera 16: 25 → 43% fogged — moody ✓. Front fish at z=8: 8 units → 5% ✓ crisp.
Hmm 43% fog on back rocks might wash them; density 0.026: 25→35%. OK 0.028.
**Lighting sanity:** NoToneMapping, standard materials, sRGB out. Dir 2.0 might blow out sand top faces (normal facing up, dir from (6,18,4) normalized ~ mostly downward: dot ~0.93 → irradiance 2.0*cos / π? Standard material: diffuse = color * irradiance where irradiance = light.color*intensity*dotNL (with physically correct scaling... in r155+, directional intensity is in lux-ish but for non-physical usage intensity 1 with color gives color*out. Renderer with useLegacyLights=false: directLight irradiance = color*intensity; BRDF_Lambert = diffuse/π? In three's physical shader, diffuse output = albedo * irradiance * RECIPROCAL_PI? For Lambert: BRDF_Lambert = diffuse * RECIPROCAL_PI. So intensity 2.0 → color*2/π = 0.64*color. Hmm so effective multiply 0.64 — sand #b89b78*0.64 → mid. Plus hemi 0.5 (hemi irradiance similar /π?) → total ~0.8 factor. Reasonable. I'll set dir 2.6, hemi 0.65, fill 0.5 → sand ~1.0 factor nice. Fish lit similarly ✓. Colors will read slightly dark — but underwater dark-ish is fine with caustics. Let me bump: dir 3.0, hemi 0.8. Sand factor ~ 3/π*0.93 + 0.8*?/π... hemi: irradiance = mix(ground, sky)*intensity? Hemisphere irradiance gets π factor? getHemisphereLightIrradiance returns mix*intensity (no /π? it multiplies PI somewhere?). In three: hemisphere irradiance = (skyColor*dot-up + groundColor*(1-dot))*intensity — then used directly (no /π? then BRDF /π applies? For hemisphere lights three does `irradiance = mix(...) * intensity` and adds to light where later multiplied by BRDF (which has RECIPROCAL_PI) — hmm then hemi also /π. So total for sand normal-up with dir 3 + hemi 0.8: (3*0.93 + 0.8*1)/π ≈ (2.8+0.8)/3.14 = 1.15 ✓ bright sand ~ #b89b78*1.15 slightly overexposed white-ish — reduce dir to 2.4 → (2.23+0.8)/3.14=0.97 ✓. Fish sides get less dot — fine.
Add fill directional 0.5 from front: sides facing camera get (2.4*dot + ... ) ✓.
Specular highlights on fish (Standard, roughness 0.35, metalness 0.15?) — metallic 0 with roughness 0.4 gives soft sheen; add slight clearcoat? MeshPhysicalMaterial clearcoat heavier — skip, use Standard.
Kelp: Standard roughness .8, side DoubleSide, vertexColors.
**Emissive on fish?** Neon tetras' stripe would pop with slight emissive: use vertexColors with material.emissive? Emissive is uniform per material — can't vary per vertex... unless emissiveMap. skip.
OK — also fish `flatShading` false (smooth) ✓.
**Legend/status fonts:** ok.
**One more check — three@0.170.0 module + importmap in sandboxed iframe:** allowed per task (CDURLs given) ✓.
**Ring occlusion vs cursor:none:** if pointer over UI text (pointer-events none) still over canvas ✓ events reach canvas.
**OrbitControls & cursor:none:** dragging hides cursor — ring follows ✓ good.
**Preventing context menu?** right-click drag pans (pan disabled) — right click shows menu; fine.
**Let me now write the fish geometry code concretely.**
```js
function makeFishGeo(def){
const parts = [];
const L = 1;
// ---- body ----
const body = new THREE.SphereGeometry(1, 26, 18);
const bp = body.attributes.position;
const bc = new Float32Array(bp.count*3);
const col = new THREE.Color();
for(let i=0;i<bp.count;i++){
const x=bp.getX(i), y=bp.getY(i), z=bp.getZ(i);
const t=(z+1)/2;
const prof=Math.sin(Math.PI*Math.pow(t,1.15));
const sy=0.95+0.25*sstep(0.0,0.85,y); // top arch
const nx=x*prof*def.width;
const ny=y*prof*def.height*sy;
const nz=(t-0.42)*1.0; // -0.42..0.58
bp.setXYZ(i,nx,ny,nz);
def.bodyColor(col, t, (y+1)/2);
bc[i*3]=col.r; bc[i*3+1]=col.g; bc[i*3+2]=col.b;
}
body.computeVertexNormals();
body.setAttribute('color', new THREE.BufferAttribute(bc,3));
parts.push(body);
```
Wait nz formula: t∈[0,1] → nz = t-0.42 ∈[-0.42,0.58] ✓.
```js
// ---- caudal (tail) fin ----
const tf = def.tail; // {len, spread, fork, y0}
const ts = new THREE.Shape();
ts.moveTo(0, 0.05);
ts.lineTo(tf.len*0.75, tf.spread);
ts.lineTo(tf.len*0.55, tf.fork*tf.spread*0.9 + 0.01);
ts.lineTo(tf.len*0.55, -tf.fork*tf.spread*0.9 - 0.01);
ts.lineTo(tf.len*0.75, -tf.spread);
ts.lineTo(0, -0.05);
const tail = new THREE.ShapeGeometry(ts, 4);
tail.rotateY(Math.PI/2); // shape x -> -z
tail.translate(0, tf.y0||0, -0.40);
```
Hmm after rotateY(π/2), shape x maps to -z: shape point (len, y) → z = -len ✓ extends toward -z (rear) ✓. Then translate z by -0.40: base at z=-0.40 (body tail end at -0.42 ✓ overlapping slightly) ✓. Also shape y maps to y ✓ vertical fin ✓.
y0: tail vertical center offset (usually 0).
Fin colors:
```js
paintShape(tail, def.finColor('tail'));
```
where paintShape(geo, fn) iterates uv (shape coords before rotation — uv unchanged by rotate/translate ✓ uv = original shape x,y ✓ convenient!).
```js
function paintGeo(geo, fn){
const uv = geo.attributes.uv, n = uv.count, arr = new Float32Array(n*3), c=new THREE.Color();
for(i){ fn(c, uv.getX(i), uv.getY(i)); arr... }
geo.setAttribute('color', ...);
}
```
Dorsal:
```js
const df = def.dorsal; // {t0 (base start along t), len, h, sweep}
compute base y: yBase = topAt(df.t0) — approximate body top: prof(t)*height*1.2*?
```
body top at parameter t: prof(t)*def.height*sy(1) = prof*height*1.2? sy max = 0.95+0.25=1.2 at y=1 ✓. So topAt(t) = prof(t)*def.height*1.2*... wait ny = y*prof*H*sy with y=1: prof*H*1.2 ✓.
```js
const topY = (t)=> Math.sin(Math.PI*Math.pow(t,1.15))*def.height*1.18;
const ds = new THREE.Shape();
const zA = (df.t0-0.42), zB=(df.t0+df.len-0.42); // world z of base start/end...
```
Shape local coords: I'll build in "z,y" directly using shape x meaning +z?? Mapping confusion: rotateY(π/2) maps shape x→-z (rearward). For dorsal fin I want it to extend rearward from base start: shape x = rearward distance ✓ same as tail. Base start at world z0 = (df.t0-0.42); rear end z1 = z0 + df.len... rear = smaller z: shape x = df.len maps to z1 = z0 - df.len ✓.
Base y varies along the back: the shape bottom edge should follow body top curve — approximate: bottom y = topAt(t(z)) - 0.02 embedded. Points along: for k=0..4: shapeX_k = k/4*df.len; worldZ = z0 - shapeX_k; baseY = topAt((worldZ+0.42)) - 0.015 (embed); shape bottom = baseY... but shape y coordinates are absolute after translate? I bake absolute y into shape coords (shape y = world y) then no y translate ✓. So shape points: bottom edge points (shapeX_k, baseY_k), then top edge back: top curve: from front-bottom rise: (0, baseY0) → leading edge up to (sweep*len? ...)
Simplest: build polygon:
```js
shape.moveTo(0, y0b);
// leading edge up-back
shape.lineTo(df.len*0.28, y0b + df.h*0.92);
// tip trailing
shape.lineTo(df.len*0.85, y0b + df.h*0.75);
// rear top
shape.lineTo(df.len, y0b + df.h*0.35);
// bottom back along body (approx flat since body curve drops)
shape.lineTo(df.len, yEnd_b);
// back along bottom (a couple points)
shape.lineTo(df.len*0.5, yMid_b);
shape.closePath();
```
where y0b = topAt(df.t0)-0.02, yMid_b = topAt(df.t0+df.len*0.5)-0.02, yEnd_b = topAt(min(1, df.t0+df.len))-0.02.
Since shape y values are absolute world y ✓. This follows body curve nicely.
Ventral/anal fin mirrored: negate y around base: bottomAt(t) = -prof*def.height*0.95(sy at y=-1 = 0.95). v shape: y values negative below body: mirror the dorsal approach: y0b = botAt(t0)+0.02; heights downward: y0b - df.h*... ✓ same structure with minus.
For angel: dorsal h 0.42, len 0.34, t0 0.58; ventral (pelvic+anal combined) h 0.5, len 0.3, t0 0.5? Angelfish has huge ventral fin too ✓. Plus streamers optional: skip streamers to reduce complexity — angel's tall dorsal+ventral fins + disc body already reads "angelfish". Actually add the two pelvic streamers — they're iconic. Small shapes:
```js
streamer shape: (0,0) → (0.05,-0.08) → (0.5,-0.42) → (0.54,-0.37) → (0.1,-0.02) close
```
placed at world z = 0.10 (t 0.52), y = botAt(0.52)+0.03, x=±0.025 via translate; slight rotateY? they hang down slightly sweeping back — shape x rearward ✓ no rotate needed (already ZY plane). Two copies translated x ±0.02 (thin fins side by side) ✓.
Pectorals:
```js
const ps = new THREE.Shape();
ps.moveTo(0,0.02); ps.lineTo(0.15,-0.05); ps.lineTo(0.11,0.06); close
for(side of [1,-1]){
const g = new THREE.ShapeGeometry(ps,2);
g.rotateY(Math.PI/2);
g.rotateY(side*0.55); // sweep tip outward
g.rotateZ(-side*0.35)?
```
Hmm rotateZ after: rotates around z: for right fin (side=1) rotating -0.35: moves +x part down? point (0.07,0,-0.14): rotateZ(-0.35): x' = x cos + y sin = 0.07*0.94=0.066; y' = -x sin + y cos = -0.07*(-0.342)+0 = +0.024 hmm raises. I want fin angled slightly downward-back: skip rotateZ; translate to position: (side*def.width*profAt*0.8, -0.03, 0.12).
```
profAt t=0.62: sin(π*0.62^1.15)=sin(π*0.572)=sin(1.798)=0.974 → x = side*width*0.78.
```
✓.
Eyes:
```js
const eyeG = new THREE.SphereGeometry(0.034, 8, 6);
const tE=0.88, profE=Math.sin(Math.PI*Math.pow(tE,1.15)); // ~0.45
eye positioned: (± profE*def.width*0.72, def.height*0.16, (tE-0.42))
paint black #0a0d10; two copies (clone geometry translate ±x)
```
SphereGeometry translate ✓. Add tiny white glint? skip.
Merge all with mergeGeometries(parts.map... ensure all have color+uv+normal ✓ ShapeGeometry has all ✓).
Wait ShapeGeometry param curveSegments — fine default 12; my shapes are polygons, ok.
Also fin thin → visible from both sides: material DoubleSide ✓.
Normals of flat fins after rotateY: ShapeGeometry normals all (0,0,1) → rotated to (1,0,0) ✓ both sides lit? With DoubleSide three flips normal for backface ✓.
**Merged geometry boundingSphere:** computeBoundingSphere ✓ for raycast? Not needed (frustumCulled false). But three may compute anyway ✓ fine.
**Species def geometry params:**
clown: tail {len:0.26, spread:0.22, fork:0.18}, dorsal {t0:0.52, len:0.2, h:0.13}, ventral {t0:0.48, len:0.16, h:0.10}, pectoral yes, streamers no.
tetra: tail {len:0.18, spread:0.14, fork:0.45}, dorsal {t0:0.55, len:0.08, h:0.06}, ventral {t0:0.45, len:0.07, h:0.05}.
tang: tail {len:0.16, spread:0.30, fork:0.05}, dorsal {t0:0.42, len:0.34, h:0.30}, ventral {t0:0.42, len:0.30, h:0.26}. (tang's continuous dorsal/ventral fins sweeping — good)
angel: tail {len:0.30, spread:0.24, fork:0.08}, dorsal {t0:0.58, len:0.32, h:0.40}, ventral {t0:0.50, len:0.28, h:0.46}, streamers yes.
**bodyColor functions:**
```js
const C = (h)=> new THREE.Color(h);
const mixC = (a,b,t,out)=> out.copy(a).lerp(b, t);
```
Write with temp colors per call — fine perf at build time.
clown bodyColor(col, t, y):
```js
const ORANGE=C(0xff8a1e), DEEP=C(0xd85500), WHITE=C(0xf4f2ea), BLACK=C(0x23262a), BELLY=C(0xffc98a);
col.copy(ORANGE).lerp(DEEP, sstep(0.55,1.0,y)*0.55);
col.lerp(BELLY, (1-sstep(0.30,0.05,y))*0.5);
const bands=[[0.26,0.05],[0.55,0.055],[0.88,0.045]];
let bw=0, bd=0;
for(const [c,w] of bands){ bw=Math.max(bw, bandf(t,c,w,0.03)); bd=Math.max(bd, bandf(t,c,w+0.028,0.03)); }
col.lerp(BLACK, Math.min(1,bd)*0.85);
col.lerp(WHITE, Math.min(1,bw));
```
bandf(t,c,w,s)= sstep(c-w-s,c-w,t)*(1-sstep(c+w,c+w+s,t)).
Head band at 0.88 near eye ✓.
tetra:
```js
const SILVER=C(0xd9e2e6), BLUE=C(0x35d5ff), RED=C(0xff2e46), WHITE=C(0xf4f8fa);
col.copy(SILVER);
col.lerp(RED, sstep(0.62,0.35,t)*(1-sstep(0.45,0.62,y)) ); // rear lower red
col.lerp(BLUE, sstep(0.45,0.68,y)*sstep(0.12,0.3,t)*0.9); // blue stripe mid
col.lerp(WHITE, (1-sstep(0.35,0.15,y))*0.45); // belly
col.lerp(C(0x9fb4bd), sstep(0.5,1,y)*0.4); // darker back
```
tang:
```js
const YEL=C(0xffd21c), YD=C(0xe0a108), YL=C(0xffe664);
col.copy(YEL); col.lerp(YD, sstep(0.45,0.95,y)*0.6); col.lerp(YL, sstep(0.75,0.98,t)*0.7);
```
angel:
```js
const SIL2=C(0xdae2e8), DARK=C(0x2b343d);
col.copy(SIL2); col.lerp(C(0x8fa0ad), sstep(0.45,0.95,y)*0.5);
col.lerp(DARK, Math.min(1, bandf(t,0.36,0.035,0.02)+bandf(t,0.58,0.03,0.02)+bandf(t,0.82,0.04,0.015)) );
```
fin colors per part fn(c, u(shapeX), v(shapeY)):
clown fin: c.copy(C(0xffa03c)); tail: lerp black by sstep(len*0.5, len, u)*0.7; dorsal similar edge: by sstep(0,0.18,v)*? Let me: for dorsal/ventral: edge at top: darken by (v relative)... shape v absolute — varies. Simpler: all clown fins: orange → black tip by u (rearward/tipward): tail tip = high u ✓ dorsal tip = mid u... hmm dorsal tip at u≈0.28*len. Use max(u, v-based?) Per-part fn gets part name → customize: tail: dark by sstep(0.12, 0.26, u)*0.75; dorsal/ventral: dark by sstep(0.10, 0.22, u)*0.55? dorsal tip at u 0.28*len(0.2)=0.056?? u shape coords: dorsal len 0.2 → tip at u≈0.056. sstep scale mismatch. I'll pass part-specific thresholds computed from params — simplest: darken by normalized progress: pass param maxU per part and use sstep(0.55,0.95, u/maxU). Implement finColor(part)(c,u,v) closures with maxU.
tetra fin: pale #c2ccd0 with slight dark edge: lerp(#8ea1a8, sstep(0.6,0.95,u/maxU)*0.5).
tang fin: #ffdf5e lerp #fff0b0 by tips.
angel fin: smoky #39434d lerp #0f1216 edges by sstep(0.55,0.95,prog)*0.8.
streamers color = angel fin.
pectoral smaller: same fin fn ✓.
Eyes black.
**Now overall code. Let me also plan the "anemone" fish interplay: clown home attraction:**
```js
if(f.def.home && f.home){
dx = f.home - f.pos (xz dist); if(dist > 4.5) acc += dir*0.9;
}
```
And clowns hover: also mild: acc.y += (homeY - pos.y)*0.5 when within 6? homeY = anemone top ~1.6. prefY already 2.8 handles. OK light touch.
**Kelp collision?** fish pass through kelp — acceptable (kelp bends visually? no). Fine.
**Fish avoid rocks:** rocks list {pos, r}: for each fish if d < r+0.5: acc += dir*( (r+0.5-d) * 6 ). With 9 rocks × 70 fish = 630 dist checks ✓.
**Compose everything and write final HTML.** Also `<meta name=viewport>` for mobile. Title tag.
Let me think about the potential problem with `cursor: none` + screenshot: fine.
**Console errors to avoid:**
- Don't reference undefined uniforms.
- ShaderMaterial with fog:true but scene.fog set before compile ✓ (scene.fog assigned at setup).
- `#include <fog_vertex>` needs mvPosition defined ✓ I define.
- Points material: `gl_PointSize` — with pixelRatio! gl_PointSize is in device pixels: multiply by uPR uniform = renderer.getPixelRatio() ✓ add uniform uPR.
- InstancedBufferAttribute with vec2 → attribute vec2 aTail ✓.
- `mergeGeometries` — all geos must be non-indexed or all indexed? Mixed indexed/non-indexed → mergeGeometries handles? It requires consistent: it checks attributes existence; index handling: if some have index and some don't... I recall mergeGeometries fails if inconsistent ("All geometries must have compatible attributes"). Sphere/Cylinder are indexed; ShapeGeometry: is it indexed? ShapeGeometry is a non-indexed? ShapeGeometry triangulates via Earcut producing position array without index? Checking memory: ShapeGeometry sets index? I believe ShapeGeometry does NOT use an index (it builds faces directly). SphereGeometry uses index. Mixed → mergeGeometries handles both? Looking at BufferGeometryUtils.mergeGeometries: it checks `isIndexed = geometries[0].index !== null` and requires all to match: "THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index X. All geometries must have compatible attributes" if mismatch in index-ness? The code: `if ( isIndexed !== ( geometry.index !== null ) ) { console.error(...); return null; }`. Yes it errors on mixed. Solution: call `geo.toNonIndexed()` on indexed ones (sphere, cylinder) before merging, or convert all to nonIndexed. Body sphere 26×18 → non-indexed ~ 900 tris *3 verts = 2700 verts fine. I'll normalize: `parts.push(g.index ? g.toNonIndexed() : g)` — toNonIndexed returns copy ✓ (and my color attr must be set before conversion ✓ set color first then convert). Careful: set attributes (color) before toNonIndexed so it carries ✓.
Actually simpler: set colors, then `const flat = g.index? g.toNonIndexed(): g;` push. ✓
- Non-indexed + computeVertexNormals works ✓ (spheres become faceted?? toNonIndexed duplicates vertices per face but positions same; computeVertexNormals on non-indexed computes per-face normals → faceted sphere!! That would make fish look low-poly faceted. Solution: compute normals BEFORE toNonIndexed on the indexed geometry (smooth normals), then convert — normals copied per vertex ✓ smooth preserved. ✓ Order: deform → computeVertexNormals (indexed, smooth) → set color → toNonIndexed ✓.
- ShapeGeometry normals (0,0,1) rotated ✓.
- mergeGeometries(parts) with all non-indexed, attributes position/normal/uv/color consistent ✓.
- For anemone: base sphere + column cylinder merge similar (both indexed → keep indexed? tentacles separate InstancedMesh; base as single mesh: merge two indexed geos with same attrs + color → indexed merge OK if both indexed ✓ but I'll just use two meshes or convert non-indexed both. I'll merge with toNonIndexed for uniformity.)
- InstancedMesh count and attributes sizes must match count ✓.
- setColorAt before first render to allocate instanceColor ✓ then needsUpdate.
- OrbitControls damping requires controls.update() each frame ✓.
- Raycaster: `new THREE.Raycaster()` fine; use ray directly: raycaster.ray.origin after setFromCamera ✓.
- ShadowMap: renderer.shadowMap.enabled = true; light.castShadow; configure shadow.camera as OrthographicCamera: `light.shadow.camera.left = -22` etc ✓.
- Fish InstancedMesh castShadow=true, receiveShadow=false.
- Points don't cast.
**Pixel ratio & point size on hidpi:** uPR uniform ✓.
**Antialias & performance on 2x:** cap pixelRatio 1.75.
**Let me also reconsider the tail fin y position:** tail shape centered y 0 (y0=0) ✓ vertical median.
**Species orientation pitch clamp:** pitch big when rising fast; clamp ±0.6.
**OK let me also write the sand caustic strength uniform per material:** injection function param.
**Write injectCaustics(mat, strength, scale):**
```js
function injectCaustics(mat, strength, scale){
mat.onBeforeCompile = (sh)=>{
sh.uniforms.uTime = timeU;
sh.uniforms.uCau = {value: strength};
sh.uniforms.uCauScale = {value: scale};
sh.vertexShader = sh.vertexShader
.replace('#include <common>', '#include <common>\nvarying vec3 vCauPos;')
.replace('#include <begin_vertex>', '#include <begin_vertex>\nvCauPos = (modelMatrix * vec4(transformed,1.0)).xyz;');
sh.fragmentShader = sh.fragmentShader
.replace('#include <common>', `#include <common>
uniform float uTime; uniform float uCau; uniform float uCauScale; varying vec3 vCauPos;
float causticField(vec2 p, float t){...}`)
.replace('#include <map_fragment>', `#include <map_fragment>
{ float cau = causticField(vCauPos.xz * uCauScale, uTime*0.6); diffuseColor.rgb *= (1.0 + cau * uCau); }`);
};
}
```
Note: material might already have onBeforeCompile conflict? Each material one purpose ✓ (sand & rocks only caustics; fish only wag; kelp only sway; anemone sway — anemone also caustics? skip).
**But wait — fish ALSO want subtle caustics?** skip as decided.
**timeU*0.6 etc — timeU shared value updated to elapsed each frame ✓.**
**Water surface shader (custom ShaderMaterial):**
```js
const surfMat = new THREE.ShaderMaterial({
uniforms: { uTime: timeU, uDeep: {value: C(0x0a3550)}, uLite: {value: C(0xa8e4ff)}, uFogColor:... } // fog handled by chunks
fog: true, side: THREE.FrontSide (normal -Y? plane rotated +π/2 earlier gave normal -Y pointing down → visible from below with FrontSide? If normal points down (-Y) and camera below looking up: face normal toward camera ✓ frontside visible ✓.)
```
Wait rotateX(π/2) gave normal (0,0,1)→(0,-1,0) ✓ down-facing ✓.
vertex:
```glsl
#include <common>
#include <fog_pars_vertex>
uniform float uTime;
varying vec2 vP;
void main(){
vec3 p = position;
p.y += sin(p.x*0.35 + uTime*0.8)*0.10 + sin(p.z*0.5 - uTime*0.6)*0.08;
vP = p.xz;
vec4 mvPosition = modelViewMatrix * vec4(p,1.0);
gl_Position = projectionMatrix * mvPosition;
#include <fog_vertex>
}
```
fragment:
```glsl
#include <common>
#include <fog_pars_fragment>
uniform float uTime; uniform vec3 uDeep; uniform vec3 uLite;
varying vec2 vP;
float causticField(...) same fn (duplicate string)
void main(){
float c = causticField(vP*0.10, uTime*0.7);
float c2 = causticField(vP*0.23 + 7.0, uTime*0.9);
float gl = c*0.85 + c2*0.5;
vec3 col = mix(uDeep, uLite, clamp(gl*1.3, 0.0, 1.0));
gl_FragColor = vec4(col, 1.0);
#include <fog_fragment>
#include <colorspace_fragment>
}
```
Nice: surface gets brighter web pattern ✓.
Careful: two caustic scales — surface far away covers big region; scale 0.10 gives features of size... causticField internal frequencies ~1.5-1.7 per unit p → feature size ~2-4 units in p-space; with p=vP*0.1 → features every ~25 units — too big? For a surface spanning 90 wide that's ~3-4 web cells — plausible from below. Add c2 at 0.23 finer ✓.
For sand: p = xz*0.35 → features ~7-10 units — big soft caustic cells on sand — hmm caustic cells in real tanks are ~0.5-1.5 units. But small-scale shimmering may look noisy at distance. 0.35 → ~7 unit features, too big? causticField has warped space (q offsets 0.42 each octave) creating features around... honestly hard to predict exact visual scale; I'll pick sand scale 0.5 and add second layer 0.9*0.5 for richness:
```glsl
float cau = causticField(p1, t)*0.7 + causticField(p1*1.9+3.7, t*1.3)*0.4;
```
Then strength 0.5.
Alright.
**FogExp2 density 0.028 with scene.background?** set scene.background = new THREE.Color(0x0b4157)? With dome covering everything, background rarely visible (dome radius 90 covers all view directions from inside ✓). Set anyway for safety ✓ (match fog).
**Dome shader:**
```glsl
varying vec3 vDir; // world dir
frag:
float h = normalize(vDir).y;
vec3 col = mix(C_bot(0x03141f), C_mid(0x0b425f), smoothstep(-0.35, 0.25, h));
col = mix(col, C_top(0x2c7fa0), smoothstep(0.2, 0.85, h));
// sun glow:
vec3 sd = normalize(uSunDir);
float g = pow(max(dot(normalize(vDir), sd), 0.0), 24.0);
col += vec3(0.9,1.0,1.0)*g*0.35;
gl_FragColor = vec4(col,1.0);
#include <colorspace_fragment>
```
vertex: vDir = position (sphere centered origin — but camera moves; dome should be camera-centered? If dome static at origin radius 90 and camera within ±20 — fine, gradient slight shift acceptable ✓). vDir = worldPos - cameraPosition better: `vDir = (modelMatrix*vec4(position,1)).xyz - cameraPosition;` ✓.
uSunDir ~ direction to (6,18,4) normalized (0.31,0.93,0.21).
depthWrite false, side BackSide, and dome must render first / behind: set `dome.renderOrder = -1`? With depthWrite false and drawn first, others overwrite ✓. Also dome material depthTest? If drawn first with depthTest true and depthWrite false, subsequent opaque draw over it ✓ fine. Set renderOrder -1 to ensure it's first. Also frustumCulled false (sphere big, fine anyway).
**Status pill:** in brand block: `<div id="status"><i></i><span>the water is calm</span></div>`. Dot color via style background; states: calm #7ee0a3, curious #7fd4ff, stirred #ffd166, startled #ff8a7a. Text variants: "the water is calm" / "the fish are curious" / "the shoal stirs" / "the shoal scatters!".
avg excite thresholds: 0.08/0.3/0.55.
**Hint text:** "move to startle · click to feed · drag to look". With separators.
**Also credit bottom-left? "Tideglass · 60 fish · three.js" in legend maybe. Legend includes counts: e.g. "○ ocellaris clown — 12" etc. Format: dot + name + count.
**Now write simulation constants carefully to avoid NaN:** guard divisions by d (use d>1e-4), normalize with fallback.
**Write the loop:**
```js
const clock = new THREE.Clock();
let elapsed = 0;
function tick(){
requestAnimationFrame(tick);
let dt = Math.min(clock.getDelta(), 0.033);
elapsed += dt;
timeU.value = elapsed;
controls.update();
clampCamera();
updateSim(dt);
updateFood(dt);
updateFX(dt); // ring, status, autoRotate re-enable, autosprinkle
renderer.render(scene, camera);
}
```
**Pointer NDC update:**
```js
window.addEventListener('pointermove', e=>{
pointer.x = (e.clientX/innerWidth)*2-1;
pointer.y = -(e.clientY/innerHeight)*2+1;
pointerActive = true;
lastPointerT = elapsed;
});
pointerleave (document) → pointerActive=false. Use 'pointerout' on window with !relatedTarget? Simpler: listen document 'mouseleave' → pointerActive=false; also 'pointerdown' sets active (touch).
```
Touch: pointerdown gives pointer position → set NDC too (so ring appears at tap). pointermove during drag updates ✓.
**Camera clamp:**
```js
const cp = camera.position;
cp.y = clamp(cp.y, 1.4, 10.6);
// keep inside dome region: distance from origin < 80 ✓ trivially.
```
**Auto-rotate re-enable:** if elapsed - lastInteract > 18 → controls.autoRotate = true (speed 0.3) else false? Initial: autoRotate true for gentle motion at load — makes first screenshot slightly non-frontal — set initial azimuth slightly negative and autoRotateSpeed 0.22 ✓ nice drift.
On pointerdown: lastInteract = elapsed.
**Auto sprinkle:** times: first at 1.4s, then every 30s.
```js
if(elapsed > nextFeedAt){ sprinkleAt(new pos random); nextFeedAt = elapsed + 30 + rand*10; }
```
Also user click → nextFeedAt = elapsed + 34.
**sprinkle(pos, n=7):** for i: foods.push({p: pos + small jitter, v: (rand small, -0.3, small), seed, size 0.4-0.7, life: 26, rest:0, fade:1}).
MAX 80 foods: if exceed, remove oldest.
**Fish target food:** nearest within 9.5; force = dir*(3.5 + (9.5-d)*1.3). Eat radius 0.35+scale*0.2.
When fish eats: food.dead=true; fish.excite += 0.25 (max 1.3); also fish gets small velocity impulse toward/away? keep.
Food fade: when resting life countdown; fade = min(1, life/3). Remove when life<=0 or eaten.
**Food buffer write each frame:**
```js
let fi=0;
for(f of foods){ if(f.dead) continue; write pos, color(with fade? multiply color by fade... use aA attr alpha), size }
foodGeo.setDrawRange(0, fi);
attributes needsUpdate ✓
```
**Also cursor ring and 3D "disturbance":** maybe on click also spawn quick ripple ring at click point (expanding ring fading)? Nice feedback: reuse a second ring mesh that animates scale 0.2→3 & opacity 0.6→0 over 0.8s. Implement small pool of 4 ripples. Cheap & juicy ✓. They're billboards at click point in water.
OK. And **fish also flee from the click ripple point briefly?** Feeding attracts — opposite. Fine.
**Write everything now.** I'll be careful with GLSL string escaping inside template literals — fine in HTML script.
Also note: inside `<script type="module">`, characters like `<` in GLSL `if (a < b)` are fine in HTML (script content is CDATA-ish; only `</script>` sequence is dangerous — avoid writing "</script>" in strings ✓).
Let me draft the full code.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tideglass — a pocket aquarium</title>
<style> ... </style>
</head>
<body>
<canvas id="c"></canvas>
<div id="vig"></div>
<div id="ui">
<div class="brand">
<div class="over">Pocket aquarium · nº 07</div>
<h1>Tideglass</h1>
<div class="rule"></div>
<div id="status"><i id="dot"></i><span id="statusText">the water is calm</span></div>
</div>
<div id="hint">move to startle the shoal <em>·</em> click to feed <em>·</em> drag to look around</div>
<div id="legend">
<div><i style="background:#ff8a1e"></i>ocellaris clownfish — 12</div>
...
</div>
</div>
<script type="importmap">...</script>
<script type="module"> ... </script>
</body></html>
```
Counts: tetra 40, clown 12, tang 9, angel 6 = 67 fish.
Now JS in detail. I'll write it linearly with sections. Let me be very careful with every three.js call.
```js
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
/* ---------- helpers ---------- */
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
const sstep=(a,b,x)=>{x=clamp((x-a)/(b-a),0,1);return x*x*(3-2*x);};
const rand=(a=1,b)=> b===undefined? Math.random()*a : a+Math.random()*(b-a);
const bandf=(t,c,w,s)=> sstep(c-w-s,c-w,t)*(1-sstep(c+w,c+w+s,t));
const TAU=Math.PI*2;
const timeU={value:0};
const PR = Math.min(devicePixelRatio||1, 1.75);
```
Renderer:
```js
const canvas=document.getElementById('c');
const renderer=new THREE.WebGLRenderer({canvas, antialias:true});
renderer.setPixelRatio(PR);
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled=true;
renderer.shadowMap.type=THREE.PCFSoftShadowMap;
```
No tone mapping (default NoToneMapping ✓). outputColorSpace default SRGB ✓.
Scene:
```js
const scene=new THREE.Scene();
scene.fog=new THREE.FogExp2(0x0b4157, 0.028);
scene.background=new THREE.Color(0x0b4157);
const camera=new THREE.PerspectiveCamera(55, innerWidth/innerHeight, 0.1, 300);
camera.position.set(4.5, 4.9, 16.5);
const controls=new OrbitControls(camera, canvas);
controls.target.set(0, 4.4, -2);
controls.enablePan=false;
controls.enableDamping=true;
controls.dampingFactor=0.06;
controls.minDistance=7; controls.maxDistance=19;
controls.minPolarAngle=1.22; controls.maxPolarAngle=1.58;
controls.minAzimuthAngle=-1.15; controls.maxAzimuthAngle=1.15;
controls.autoRotate=true; controls.autoRotateSpeed=0.22;
controls.update();
```
Wait initial camera position (4.5,4.9,16.5) vs target (0,4.4,-2): distance = sqrt(4.5²+0.5²+18.5²)= sqrt(20+0.25+342)=19.1 > maxDistance 19 → controls.update() will pull it in — fine (or set within). Set camera (4.2,4.9,15.8): dist ≈ sqrt(17.6+0.25+316)=18.3 ✓.
Also azimuth initial: atan2(x=4.2, z=17.8)≈0.23 rad ✓ within limits.
Lights:
```js
const hemi=new THREE.HemisphereLight(0x9fd4e8, 0x123240, 0.85); scene.add(hemi);
const sun=new THREE.DirectionalLight(0xfff3dd, 2.6);
sun.position.set(7,19,5);
sun.castShadow=true;
sun.shadow.mapSize.set(1024,1024);
sun.shadow.camera.near=6; far=45;
sun.shadow.camera.left=-24; right=24; top=18; bottom=-18;
sun.shadow.bias=-0.0004; sun.shadow.normalBias=0.03;
scene.add(sun); scene.add(sun.target); sun.target.position.set(0,0,-3);
const fill=new THREE.DirectionalLight(0x7fc4e6, 0.5); fill.position.set(-6,8,18); scene.add(fill);
```
Shadow camera assignments: `sun.shadow.camera.left = -24;` etc ✓.
Dome:
```js
const domeGeo=new THREE.SphereGeometry(120, 32, 20);
```
camera far 300 ✓ (120 ok). Dome material as planned.
```js
const dome=new THREE.Mesh(domeGeo, domeMat); dome.frustumCulled=false; dome.renderOrder=-10; scene.add(dome);
```
Sand:
```js
function sandHeight(x,z){
return 0.55*Math.sin(x*0.15+1.7)*Math.cos(z*0.23-0.6)
+ 0.28*Math.sin(x*0.43+z*0.31+0.5)
+ 0.22*Math.sin(z*0.55-x*0.13+2.1)
+ 0.5*sstep(-4,-13,z)*0.8; // rises toward back
}
```
sstep(-4,-13,z) with a>b — my sstep assumes ascending? sstep(a,b,x) with a=-4,b=-13: (x-(-4))/(-13+4)= (x+4)/(-9) → for z=-13: ( -9)/(-9)=1 ✓ works since formula linear then clamped — my sstep doesn't require a<b ✓ (clamp handles). ✓ 0.5*sstep(...)*0.8 → up to 0.4 extra at back.
Plane:
```js
const sandGeo=new THREE.PlaneGeometry(120, 84, 110, 76);
sandGeo.rotateX(-Math.PI/2);
const sp=sandGeo.attributes.position; const scolors=new Float32Array(sp.count*3);
const cA=new THREE.Color(0xb99f7d), cB=new THREE.Color(0x8a7154), ctmp=new THREE.Color();
for i: x=sp.getX(i), z=sp.getZ(i); y=sandHeight(x,z); sp.setY(i,y);
const n = Math.abs(Math.sin(x*12.9898 + z*78.233)*43758.5453)%1; // hash 0..1
const m = 0.5 + 0.5*Math.sin(x*0.8+z*1.3); // patches
ctmp.copy(cA).lerp(cB, clamp(n*0.5 + m*0.5,0,1));
write
sandGeo.computeVertexNormals();
setAttribute color
const sandMat=new THREE.MeshStandardMaterial({vertexColors:true, roughness:1.0, metalness:0});
injectCaustics(sandMat, 0.55, 0.5);
const sand=new THREE.Mesh(sandGeo, sandMat); sand.receiveShadow=true; scene.add(sand);
```
Note x,z after rotateX(-π/2): plane local (x,y,0) → (x, 0, y)? rotateX(-90°): y'= y cos - z sin = z*? For (x,y,0): θ=-π/2: y' = y*cosθ - z*sinθ = 0 + 0 = y*0? cos(-90)=0, sin(-90)=-1: y' = y*0 - 0*(-1) = 0; z' = y*sinθ + z*cosθ = -y. So local (x,y)→(x,0,-y) ✓ plane on XZ ✓ getZ works after rotation ✓ (attribute baked ✓).
Rocks:
```js
function makeRock(seed){
const g=new THREE.IcosahedronGeometry(1,2);
const p=g.attributes.position;
for(i){ v=(x,y,z); n=hash3(x,y,z,seed); s=0.72+n*0.55; p.setXYZ(i, x*s, y*s*0.8, z*s); }
g.computeVertexNormals();
// colors
...
return g.toNonIndexed()? Icosahedron already non-indexed? PolyhedronGeometry: builds with... it uses its own subdividing; I think it's non-indexed (position only). Let me handle: const out = g.index? g.toNonIndexed(): g; but colors: set before conversion.
}
```
hash3: `function h3(x,y,z,s){ return Math.abs(Math.sin(x*12.9898+y*78.233+z*37.719+s*94.673)*43758.5453)%1; }` ✓ deterministic per position → no cracks ✓.
Colors after displacement: y from -0.8..0.8*s: normalize by max: `t=clamp((y+0.6)/1.2,0,1)`; base mix(0x4c4a49, 0x76746c) then mossy: lerp toward 0x3f6b52 by sstep(0.55,0.95,t)*0.6; also warm: lerp 0x6b5f52 by n*0.3.
Rock material: MeshStandardMaterial({vertexColors:true, roughness:0.95, flatShading:true}); injectCaustics(rockMat, 0.28, 0.6); castShadow, receiveShadow.
Rocks placement array:
```js
const rocks=[];
[[-11.2,-6.5,2.4,0.5],[-8.6,-8.2,1.5,1.1],[9.5,-7.6,2.0,2.2],[12,-4.5,1.2,3.3],[-0.5,-10,1.5,4.4],[3.4,-9,1.0,5.5],[-14,-1.5,1.6,0.9],[6.5,0.5,0.7,1.5],[-4.5,1.5,0.6,2.8],[12.5,1.5,0.9,1.7]]
forEach [x,z,r,seed]: mesh at y=sandHeight(x,z)+r*0.35... place: y = sandH + r*0.25 (sunk); scale r with slight nonuniform (1, 0.75+rand*0.3, 1); rotation.y random.
obstacles push {x, y: y+r*0.3, z, r: r*0.9}
```
Icosahedron rocks scaled non-uniform → flat shading normals ok (recomputed? scale in matrix — normals get inverse-transpose — three handles via normalMatrix ✓).
Kelp:
```js
const bladeGeo=new THREE.PlaneGeometry(0.16,1,1,12);
bladeGeo.translate(0,0.5,0);
const bp=bladeGeo.attributes.position; colors...
for i: x=bp.getX(i), y=bp.getY(i); // y 0..1
const w=1-Math.pow(y,1.4)*0.72;
bp.setX(i, x*w);
bp.setZ(i, Math.sin(y*2.5)*0.05);
color: base 0x123726 → tip 0x4f9f5f by y with slight variation
bladeGeo.computeVertexNormals();
const kelpMat=new THREE.MeshStandardMaterial({vertexColors:true, roughness:0.8, side:THREE.DoubleSide});
injectSway(kelpMat, kelp code);
const kelp=new THREE.InstancedMesh(bladeGeo, kelpMat, N);
kelp.frustumCulled=false; kelp.receiveShadow=true;
per instance: cluster pos + jitter r 0.5-1.2; yaw random; scale (h*rand(0.8,1.2) width? scale.set(w, h, w)); matrix compose.
setColorAt: tint from palette *0.9-1.1
```
N ~ 64: clusters 6 with counts [9,8,6,5,6,4]=38 + 26 scattered.
injectSway(mat, glsl): uses attribute aPhase; needs InstancedBufferAttribute aPhase on bladeGeo ✓ (instanced).
Anemone:
```js
tentGeo = new THREE.CylinderGeometry(0.02, 0.06, 1, 5, 4); translate(0,0.5,0); colors gradient along y.
anemoneMat Standard vertexColors DoubleSide? cylinders fine FrontSide; sway injection with aPhase.
base: sphere squashed + colors; baseMat similar.
For each anemone (2):
group at (x, sandHeight+0.1, z)
base mesh scale (1.1, 0.7, 1.1)
tentacles InstancedMesh(tentGeo, anemoneMat, 26): position on hemisphere: dir random upper hemisphere with y bias; len rand 0.55-0.95 (times base radius ~0.55): tip at dir*r*0.55... place tentacle base at surface point p = dir*0.5*0.55? and orientation: quaternion setFromUnitVectors(up, dir); scale (1, len, 1).
setColorAt jitter.
```
Tentacle instance matrix: base at dome surface: p = dir.normalize * 0.5 * (0.5 + 0.45*|dir.y|...) simpler: sample direction: theta rand TAU, phi such that y = rand(0.15, 0.95): dir=(cosθ*sqrt(1-y²), y, sinθ*sqrt(1-y²)); p = dir*0.5 (sphere radius 0.5 scaled y 0.7 → world (0.55,0.385?) hmm base sphere scaled (1.1,0.7,1.1) with radius 0.5 → world radii (0.55, 0.35, 0.55). Surface point: p=(dir.x*0.55, dir.y*0.35, dir.z*0.55). Orientation from up to dir ✓ scale y len.
Also anemone sway uses position.y (0..1 local) ✓ pre-instance.
Fish geometry builder as planned. Let me now also write injectWag:
```js
function injectWag(mat){
mat.onBeforeCompile=(sh)=>{
sh.uniforms.uTime=timeU;
sh.vertexShader=sh.vertexShader
.replace('#include <common>', '#include <common>\nattribute vec2 aTail;\nuniform float uTime;')
.replace('#include <begin_vertex>', `#include <begin_vertex>
{
float env = 0.22 + 0.78 * (1.0 - smoothstep(-0.60, 0.18, transformed.z));
transformed.x += sin(aTail.x + transformed.z * 4.3) * env * (0.016 + 0.045 * aTail.y) * 2.4;
}`);
};
}
```
max: (0.016+0.045*1.3)*2.4*1 = (0.0745)*2.4=0.18 ✓.
Species spawn:
```js
function buildSpecies(def){
const geo=makeFishGeo(def);
const mat=new THREE.MeshStandardMaterial({vertexColors:true, roughness:0.42, metalness:0.08, side:THREE.DoubleSide});
injectWag(mat);
const mesh=new THREE.InstancedMesh(geo, mat, def.count);
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
mesh.castShadow=true;
mesh.frustumCulled=false;
const tailAttr=new THREE.InstancedBufferAttribute(new Float32Array(def.count*2),2);
tailAttr.setUsage(THREE.DynamicDrawUsage);
geo.setAttribute('aTail', tailAttr);
...
}
```
Wait — geo is per species ✓ (makeFishGeo returns fresh).
Per fish:
```js
const f={ def, idx:i, pos:new THREE.Vector3(), vel:new THREE.Vector3(), quat:new THREE.Quaternion(), scl:new THREE.Vector3(), tailPhase:rand(TAU), effort:0.3, excite:0, prevYaw:0, roll:0, sep:new THREE.Vector3(), coh:new THREE.Vector3(), ali:new THREE.Vector3(), nCount:0, home:null, seed:rand(100) };
pos random: x rand(-13,13), y clamp(prefY + rand(-2,2), 1.6, 10), z rand(-9,9);
vel: random dir * cruise;
scl uniform def.len * rand(0.85,1.15);
mesh.setColorAt(i, c.setScalar? tint: new Color(1,1,1).multiplyScalar(rand(0.88,1.08)) with slight warm/cool: c.setRGB(rand jitter)...)
```
setColorAt with Color(0.9..1.1 each channel) ✓.
**Simulation update function** (the core):
```js
const v1=new THREE.Vector3(), v2=new THREE.Vector3(), v3=new THREE.Vector3();
const acc=new THREE.Vector3();
const raycaster=new THREE.Raycaster();
const euler=new THREE.Euler(); const qt=new THREE.Quaternion();
const mtx=new THREE.Matrix4();
function updateFish(dt){
const t=timeU.value;
raycaster.setFromCamera(pointer, camera);
const ro=raycaster.ray.origin, rd=raycaster.ray.direction;
// reset accumulators
for(const f of fishes){ f.sep.set(0,0,0); f.coh.set(0,0,0); f.ali.set(0,0,0); f.nCount=0; }
// pair loop
for(let i=0;i<fishes.length;i++){
const a=fishes[i];
for(let j=i+1;j<fishes.length;j++){
const b=fishes[j];
const dx=a.pos.x-b.pos.x, dy=a.pos.y-b.pos.y, dz=a.pos.z-b.pos.z;
const d2=dx*dx+dy*dy+dz*dz;
const sepR=(a.def.len+b.def.len)*0.85;
if(d2<sepR*sepR && d2>1e-6){
const d=Math.sqrt(d2);
const push=(1-d/sepR)*7.0/d;
a.sep.x+=dx*push; a.sep.y+=dy*push; a.sep.z+=dz*push;
b.sep.x-=dx*push; ...
}
if(a.def===b.def){
const nr=a.def.neighR; if(d2<nr*nr){
a.coh.add(b.pos); a.ali.add(b.vel); a.nCount++;
b.coh.add(a.pos); b.ali.add(a.vel); b.nCount++;
}
}
}
}
// per fish integrate
for(const f of fishes){
acc.set(0,0,0);
if(f.nCount>0){
v1.copy(f.coh).divideScalar(f.nCount).sub(f.pos);
const dC=v1.length();
if(dC>1e-4) acc.addScaledVector(v1.normalize(), f.def.coh * Math.min(1, dC*0.5));
v1.copy(f.ali).divideScalar(f.nCount).sub(f.vel);
acc.addScaledVector(v1, f.def.ali*0.5);
}
acc.add(f.sep);
// wander
acc.x += Math.sin(t*0.37+f.seed)* f.def.wander;
acc.y += Math.sin(t*0.45+f.seed*1.7)* f.def.wander*0.5;
acc.z += Math.cos(t*0.31+f.seed*2.3)* f.def.wander;
// depth pref
acc.y += (f.def.prefY - f.pos.y)*0.22;
// home
if(f.home){
const hx=f.home.x-f.pos.x, hz=f.home.z-f.pos.z;
const hd=Math.hypot(hx,hz);
if(hd>4.5){ acc.x+=hx/hd*0.7; acc.z+=hz/hd*0.7; }
acc.y += (f.home.y - f.pos.y)*0.30* sstep(4.5,7,hd)??
}
```
Home y force: when near home (hd<4.5), pull y toward home.y: acc.y += (f.home.y-f.pos.y)*0.5*sstep(6,3,hd)? Let me: near = 1 - sstep(3,6,hd): acc.y += (f.home.y-f.pos.y)*0.6*near. So clowns hover at anemone height when near ✓.
```js
// bounds
const m=2.2;
if(f.pos.x> 16-m) acc.x -= (f.pos.x-(16-m))*2.2;
if(f.pos.x<-16+m) acc.x += ((-16+m)-f.pos.x)*2.2;
if(f.pos.z> 11-m) acc.z -= (f.pos.z-(11-m))*2.2;
if(f.pos.z<-11+m) acc.z += ((-11+m)-f.pos.z)*2.2;
if(f.pos.y> 10.4-m*0.4) acc.y -= (f.pos.y-(10.4-m*0.4))*3.0; // stay under surface: margin 0.9
if(f.pos.y< 1.3+m*0.5) acc.y += ((1.3+m*0.5)-f.pos.y)*3.0;
```
Let me simplify with explicit numbers:
```js
if(f.pos.x>13.5) acc.x-=(f.pos.x-13.5)*2.5; if(f.pos.x<-13.5) acc.x+=(-13.5-f.pos.x)*2.5;
if(f.pos.z>9) acc.z-=(f.pos.z-9)*2.5; if(f.pos.z<-9) acc.z+=(-9-f.pos.z)*2.5;
if(f.pos.y>9.6) acc.y-=(f.pos.y-9.6)*3.0; if(f.pos.y<2.0) acc.y+=(2.0-f.pos.y)*3.0;
```
Soft margins inside ±13.5/±9/2-9.6 ✓ (hard clamp later ±15.5 etc for safety — actually let soft bounds BE near edges: box x±15, z±10.5, y 1.6..10.2 soft with hard clamp ±16.2/11.2/11.4.) Let me set soft: x±13.5, z±9, y 2..9.6; hard clamp: ±15.5, ±10.5, 1.4..10.6. Camera can see slightly beyond soft bounds — fine.
```js
// rocks
for(const r of obstacles){
const dx=f.pos.x-r.x, dy=f.pos.y-r.y, dz=f.pos.z-r.z;
const rr=r.r+0.55;
const d2=dx*dx+dy*dy+dz*dz;
if(d2<rr*rr && d2>1e-6){ const d=Math.sqrt(d2); const k=(rr-d)*6/d; acc.x+=dx*k; acc.y+=dy*k; acc.z+=dz*k; }
}
// food
let best=null,bd=9.5;
for(const fo of foods){ if(fo.dead)continue; const d=f.pos.distanceTo(fo.p); if(d<bd){bd=d;best=fo;} }
if(best){
v1.copy(best.p).sub(f.pos);
const dl=v1.length();
if(dl>1e-4){ v1.divideScalar(dl); acc.addScaledVector(v1, 3.0 + (9.5-dl)*1.2); }
if(dl < 0.30 + f.def.len*0.35){ best.dead=true; f.excite=Math.min(1.3, f.excite+0.3); eatPulse(best.p); }
}
// flee the cursor
if(pointerActive){
v1.copy(f.pos).sub(ro);
const tt=clamp(v1.dot(rd), 3.0, 60.0);
v2.copy(rd).multiplyScalar(tt).add(ro); // closest point
v3.copy(f.pos).sub(v2);
const d=v3.length();
const R=3.4;
if(d<R){
const k=(1-d/R); const k2=k*k;
if(d>1e-3){ v3.divideScalar(d); acc.addScaledVector(v3, 60*k2); }
f.excite=Math.max(f.excite, k*1.25);
}
}
// integrate
f.excite*=Math.exp(-dt*1.6);
const maxS=f.def.maxS*(1+f.excite*1.3);
f.vel.addScaledVector(acc, dt);
// mild drag
f.vel.multiplyScalar(Math.exp(-dt*0.15));
let sp=f.vel.length();
const minS=f.def.cruise*0.55;
if(sp>maxS){ f.vel.multiplyScalar(maxS/sp); sp=maxS; }
else if(sp<minS){ if(sp>1e-5) f.vel.multiplyScalar(minS/sp); else f.vel.set(minS,0,0); sp=minS; }
f.pos.addScaledVector(f.vel, dt);
// hard clamp
f.pos.x=clamp(f.pos.x,-15.5,15.5); f.pos.y=clamp(f.pos.y,1.4,10.6); f.pos.z=clamp(f.pos.z,-10.5,10.5);
// effort & tail
f.effort += ((sp/f.def.maxS)+f.excite*0.5 - f.effort)*Math.min(1,dt*5);
f.tailPhase += dt*(5.0 + f.effort*9.0 + f.excite*5.0);
// orientation
const yaw=Math.atan2(f.vel.x, f.vel.z);
const hs=Math.hypot(f.vel.x,f.vel.z);
const pitch=clamp(-Math.atan2(f.vel.y,hs),-0.55,0.55); wait sign
```
Earlier derived: pitchX = -atan2(vy, hs) gives nose-up for vy>0? Rotation about X by θ maps +Z → (0, -sinθ, cosθ). Want -sinθ = vy/|v| when vy>0 → sinθ = -vy/|v| → θ = -asin(vyn) = -atan2(vy,hs) ✓ pitch = -atan2(f.vel.y, hs) ✓ clamp ±0.6.
```js
const turn=wrapPI(yaw-f.prevYaw);
const targetRoll=clamp(-turn/dt*0.18, -0.5, 0.5); // guard dt>0
f.roll += (targetRoll-f.roll)*Math.min(1,dt*3.5);
f.prevYaw=yaw;
euler.set(pitch, yaw, f.roll, 'YXZ');
qt.setFromEuler(euler);
f.quat.slerp(qt, 1-Math.exp(-dt*7));
// write
mtx.compose(f.pos, f.quat, f.scl);
f.mesh.setMatrixAt(f.idx, mtx);
f.mesh.userData.tail.setXY(f.idx, f.tailPhase, f.effort);
}
// upload
for(const s of speciesList){ s.mesh.instanceMatrix.needsUpdate=true; s.tailAttr.needsUpdate=true; }
}
```
Wait — turn/dt: if dt tiny... dt≥ ~0. Fine.
Slerp factor 1-exp(-7dt) at 60fps ≈ 0.11 — smooth ✓.
Note: `f.mesh` — set mesh reference per fish (species mesh) & tail attr. I'll store f.mesh, f.tailAttr.
Also avgExcite accumulate for status: exciteSum += f.excite; count.
Species def numbers revisit:
- cruise: tetra 1.9, clown 1.25, tang 1.05, angel 0.85
- maxS: tetra 4.6, clown 3.4, tang 3.0, angel 2.4
- coh: tetra 1.5, clown 0.55, tang 0.22, angel 0.3 — cohesion force scale: acc contribution ~ coh*min(1,dC*0.5): tetra 1.5 max — decent; with ali 2.0 school aligns ✓.
- ali: tetra 2.0, clown 1.0, tang 0.4, angel 0.6.
- neighR: tetra 2.4, clown 3.2, tang 4.0, angel 4.2.
- wander: tetra 0.5, clown 0.6, tang 0.9, angel 0.45.
- prefY: tetra 5.5, clown 3.2, tang 2.4, angel 6.0.
Clown prefY 3.2 vs home anemone top ~1.5 — home override near home pulls to ~2 — good hover.
Flee force 60*k² near cursor strong ✓.
**Food system:**
```js
const MAXF=90;
const foods=[];
const foodGeo=new THREE.BufferGeometry();
const fPos=new Float32Array(MAXF*3), fCol=new Float32Array(MAXF*3), fSize=new Float32Array(MAXF), fFade=new Float32Array(MAXF);
foodGeo.setAttribute('position', new THREE.BufferAttribute(fPos,3).setUsage(DynamicDrawUsage));
foodGeo.setAttribute('aColor',...3), ('aSize',...1), ('aFade',...1);
const foodMat=new THREE.ShaderMaterial({ uniforms:{uTime:timeU,uPR:{value:PR}}, transparent:true, depthWrite:false, fog:true? maybe not, vertex:..., fragment:...});
const foodPts=new THREE.Points(foodGeo, foodMat); foodPts.frustumCulled=false; scene.add(foodPts);
```
fog:true on ShaderMaterial — must include fog chunks ✓ or skip fog (food always near). I'll skip fog for food (simpler): foods spawn near where user clicks — could be at far z=-10 → slightly glowing; acceptable, they're small. Actually include fog for correctness — it's just two includes. I'll include.
Food vertex shader:
```glsl
uniform float uPR;
attribute float aSize; attribute float aFade; attribute vec3 aColor;
varying float vFade; varying vec3 vC;
#include <fog_pars_vertex>? fog needs mvPosition — do:
void main(){
vC=aColor; vFade=aFade;
vec4 mvPosition = modelViewMatrix * vec4(position,1.0);
gl_PointSize = aSize * uPR * (140.0 / -mvPosition.z);
gl_Position = projectionMatrix * mvPosition;
#include <fog_vertex>
}
```
fragment:
```glsl
varying vec3 vC; varying float vFade;
#include <fog_pars_fragment>
void main(){
vec2 q=gl_PointCoord-0.5; float d=length(q);
float a=smoothstep(0.5,0.30,d)*vFade;
gl_FragColor=vec4(vC,a);
#include <fog_fragment>
#include <colorspace_fragment>
}
```
Note `#include <fog_pars_vertex>` declares varying vFogDepth ✓.
gl_PointSize min clamp: ensure ≥2: `max(gl_PointSize, 2.0*uPR)`? tiny flakes far → sub-pixel vanish — fine, or clamp 1.5. I'll clamp min 2.0.
Food update:
```js
function updateFood(dt){
const t=timeU.value;
for(const f of foods){
if(f.dead) continue;
f.v.y = Math.max(f.v.y - 1.6*dt, -0.55);
f.p.x += (f.v.x + Math.sin(t*2.1+f.seed)*0.12)*dt;
f.p.z += (f.v.z + Math.cos(t*1.7+f.seed*1.3)*0.12)*dt;
f.p.y += f.v.y*dt;
const gy = sandHeight(f.p.x,f.p.z)+0.06;
if(f.p.y<gy){ f.p.y=gy; f.v.set(0,0,0); f.rest=true; }
if(f.rest){ f.life-=dt; f.fade=Math.min(1,f.life/2.5); if(f.life<=0) f.dead=true; }
else f.fade=1;
}
// write buffers
let n=0;
for(const f of foods){
if(f.dead||n>=MAXF) continue;
fPos[n*3]=f.p.x; ... fCol — from f.c (THREE.Color); fSize[n]=f.size; fFade[n]=f.fade;
n++;
}
foodGeo.setDrawRange(0,n);
attrs needsUpdate
// prune dead occasionally
if(foods.length>MAXF || foods.some(dead)) filter: do periodic: if(foods.length && foods[0].dead) foods.shift()... simpler: every frame: for(let i=foods.length-1;i>=0;i--) if(foods[i].dead) foods.splice(i,1);
}
```
Splice fine (small arrays).
sprinkle:
```js
function sprinkle(px,pz,n=7){
for(i<n){ foods.push({p:new THREE.Vector3(px+rand(-0.8,0.8), 10.9+rand(0.5), pz+rand(-0.8,0.8)), v:new THREE.Vector3(rand(-0.2,0.2),0,rand(-0.2,0.2)), seed:rand(40), size:rand(0.35,0.6), life:rand(18,26), fade:1, rest:false, dead:false, c: flakeColor()}); }
}
```
flake colors: 0xff9b4a, 0xe8703a, 0xffd07a random.
**Click handling:**
```js
let downX=0,downY=0,downT=0;
canvas.addEventListener('pointerdown',e=>{ downX=e.clientX; downY=e.clientY; downT=performance.now(); lastInteract=elapsed; controls.autoRotate=false; pointerSet(e); pointerActive=true; });
canvas.addEventListener('pointerup',e=>{
const d=Math.hypot(e.clientX-downX,e.clientY-downY);
if(performance.now()-downT<280 && d<8){
// feed at column
pointerSet(e);
raycaster.setFromCamera(pointer,camera);
const ro=raycaster.ray.origin, rd=raycaster.ray.direction;
let tt = rd.y<-1e-4 ? (10.9-ro.y)/rd.y : 12;
if(!(tt>2&&tt<60)) tt=12;
v1.copy(rd).multiplyScalar(tt).add(ro);
const px=clamp(v1.x,-14,14), pz=clamp(v1.z,-9.5,9.5);
sprinkle(px,pz,8);
ripple(v1.x, Math.min(v1.y,10.8), v1.z);
nextAuto=elapsed+34;
}
});
```
Hmm if rd.y >= 0 (aiming up): food column at ray t=12 — decent.
Also pointermove on window:
```js
addEventListener('pointermove',e=>{ pointerSet(e); pointerActive=true; lastPointerMove=elapsed; });
document.addEventListener('mouseleave',()=>pointerActive=false);
```
For touch: pointermove fires on drag; pointerdown sets pointer ✓.
pointerSet: pointer.x=(e.clientX/innerWidth)*2-1 etc.
**Cursor ring:**
```js
const ringGeo=new THREE.PlaneGeometry(3.4,3.4);
const ringMat=new THREE.ShaderMaterial({uniforms:{uTime:timeU,uOp:{value:0}}, transparent:true, depthWrite:false, blending:THREE.AdditiveBlending, vertex/frag});
const ring=new THREE.Mesh(ringGeo,ringMat); ring.frustumCulled=false; scene.add(ring);
const ringPos=new THREE.Vector3(0,4.5,-2);
update each frame:
raycaster.setFromCamera(pointer,camera);
tt from plane z=0: rd.z<-1e-4 ? (0-ro.z)/rd.z : fallback 12; clamp tt (2,40);
target = ro+rd*tt; clamp y 1.5..10.5, x ±14.5, z ±10
ringPos.lerp(target, 1-Math.exp(-dt*14)); // fast but eased
ring.position.copy(ringPos); ring.quaternion.copy(camera.quaternion);
const want = pointerActive?0.5:0; ringMat.uniforms.uOp.value += (want - value)*min(1,dt*6);
```
Ring frag:
```glsl
uniform float uTime; varying vec2 vUv; uniform float uOp;
void main(){
vec2 q=vUv-0.5; float r=length(q)*2.0;
float ring=smoothstep(0.10,0.03,abs(r-0.72));
float puls=0.75+0.25*sin(uTime*4.0);
float dot0=smoothstep(0.10,0.02,r);
float halo=smoothstep(0.35,0.0,abs(r-0.72))*0.35;
float a=(ring*puls+dot0+halo)*uOp;
gl_FragColor=vec4(vec3(0.62,0.94,1.0), a);
#include <colorspace_fragment>
}
```
vertex: standard + vUv.
**Ripples on click:**
```js
const ripples=[]; // {mesh, age}
pool of 3 ring meshes with own materials (uniform uAge).
on ripple(x,y,z): mesh visible at pos, age 0.
update: age+=dt; k=age/0.9; scale=0.4+k*4.2; opacity=(1-k)*0.5; if k>=1 hide.
```
Reuse a shader with uAge uniform: radius = mix(0.05,1.0,k); ring at that radius:
```glsl
float rr = 0.12+0.88*k; float ring=smoothstep(0.09,0.02,abs(r-rr)); a=ring*(1-k)*0.6;
```
scale plane constant 3.4 ✓. 3 meshes pool cycling.
**Bubble points:**
```js
const NB=80;
positions: for each: emitter e (2), local xz offset o (rand disc r 0.5), base y 0, seed, rate rand 0.14..0.24 (height per second → rise time ~ 11/0.2 = 55s too slow; want 6-12s: rate = 11/ rand(6,12) = 0.9..1.8/s), size.
Store attributes: aBase(vec3: x,z of column + emitter y), aSeed, aRate, aSize, aWob.
vertex shader:
float h = mod(aSeed*17.0 + uTime*aRate, 11.2);
float wob = 0.06 + h*0.045;
vec3 p = vec3(aBase.x + sin(uTime*1.5+aSeed)*wob, aBase.y + h, aBase.z + cos(uTime*1.2+aSeed*1.7)*wob);
... pointsize = aSize*(1.0+h*0.06)*uPR*(160.0/-mv.z)
fade near very bottom (just spawned) & top: alpha = smoothstep(0.0,0.4,h)*smoothstep(11.2,10.4,h) — pass varying.
fragment ring:
q=gl_PointCoord-0.5; d=length(q)*2.0;
float rim = smoothstep(1.0,0.86,d) - smoothstep(0.72,0.45,d)*0.75; // ring
float hi = smoothstep(0.35,0.0,length(q-vec2(-0.13,0.15)))*0.9;
float a = clamp(rim+hi*0.6,0.0,1.0)*vA*0.85;
gl_FragColor=vec4(mix(vec3(0.75,0.9,1.0), vec3(1.0), hi), a); + fog + colorspace
```
Wait smoothstep(1.0,0.86,d): edge0=1.0 > edge1=0.86 reversed — avoid; rewrite: rim = (1.0 - smoothstep(0.86,1.0,d)) - smoothstep(0.45,0.72,d)*0.75... Let me: inner hole: `hole = smoothstep(0.45,0.75,d)`; rim = (1-smoothstep(0.85,1.0,d)) - hole*0.78 → at d<0.45: 1-0=1 → 1; at d 0.75: 1-0.78=0.22 hmm want more contrast: multiply: rim = (1-smoothstep(0.85,1.0,d)) * (0.15 + 0.85*smoothstep(0.55,0.8,d)) → center dim 0.15 edge bright ✓ ring look.
**Snow points:**
```js
NS=240; aBase(x,y,z random box), aSeed, aSize.
vertex: p = aBase + small sin drift; y = 11.5 - mod(aSeed*23.0 + uTime*(0.10+aSeed*0.05) + aBase.y, 10.8); wait combine: y = wrap(base.y - uTime*sink) into [0.6, 11.4]: y = 0.6 + mod(aBase.y - uTime*(0.08+aSeed*0.06), 10.8);
pointsize small: aSize*uPR*(70/-z), min 1.
alpha 0.16.
color #cfe4ec with slight variation via seed.
```
frag: soft dot + fog + colorspace.
Snow drift x: sin(uTime*0.2+aSeed)*0.5.
**God rays:**
```js
const rayGeo=new THREE.PlaneGeometry(1,1); per-ray material clone with uniforms {uTime, uSeed, uOp}
mesh scale.set(w, 11.4, 1); position (x, 5.9, z); rotation.z = tilt; rotation.y = ry.
```
Actually rays tilt: rotate around z by ±0.1 → top toward camera? tilt slanted ✓.
Rays data: 6 rays x∈[-9..9], z∈[-8..0].
**autoRotate idle:** if(elapsed-lastInteract>16) controls.autoRotate=true.
**Status update:**
```js
const stText=document.getElementById('statusText'), dotEl=document.getElementById('dot');
let stateIdx=-1;
function setStatus(avg){
const idx = avg<0.10?0 : avg<0.32?1 : avg<0.6?2 : 3;
if(idx!==stateIdx){ stateIdx=idx; stText.textContent=['the water is calm','the fish are curious','the shoal stirs','the shoal scatters!'][idx]; dotEl.style.background=['#7ee0a3','#7fd4ff','#ffd166','#ff8a7a'][idx]; }
}
```
**Legend HTML:**
```html
<div id="legend">
<div><i style="background:#ff8a1e"></i>ocellaris clownfish · 12</div>
<div><i style="background:#35d5ff"></i>neon tetra · 40</div>
<div><i style="background:#ffd21c"></i>yellow tang · 9</div>
<div><i style="background:#dfe6ec"></i>veil angelfish · 6</div>
</div>
```
**Resize:**
```js
addEventListener('resize',()=>{ camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth,innerHeight); });
```
**wrapPI helper.**
Now — one BIG remaining piece: `makeFishGeo` with fins & colors. Let me write it fully and carefully.
```js
function sstepJ(a,b,x){ return sstep(a,b,x); } // just use sstep
function makeFishGeo(def){
const parts=[];
const H=def.height, W=def.width;
const prof=t=>Math.sin(Math.PI*Math.pow(clamp(t,0,1),1.15));
const topAt=t=>prof(t)*H*1.16;
const botAt=t=>-prof(t)*H*0.95;
// body
const body=new THREE.SphereGeometry(1,24,16);
const bp=body.attributes.position;
const bcol=new Float32Array(bp.count*3);
const cc=new THREE.Color();
for(let i=0;i<bp.count;i++){
const x=bp.getX(i),y=bp.getY(i),z=bp.getZ(i);
const t=(z+1)*0.5;
const pr=prof(t);
const sy=0.95+0.21*sstep(0,0.8,y);
bp.setXYZ(i, x*pr*W, y*pr*H*sy, t-0.42);
def.bodyColor(cc,t,(y+1)*0.5);
bcol[i*3]=cc.r;bcol[i*3+1]=cc.g;bcol[i*3+2]=cc.b;
}
body.computeVertexNormals();
body.setAttribute('color',new THREE.BufferAttribute(bcol,3));
parts.push(body.index?body.toNonIndexed():body);
```
Wait: toNonIndexed after setting color ✓ attributes copied ✓ normals already computed smooth ✓.
Hmm one subtlety: toNonIndexed() keeps morph... fine.
```js
// generic fin builder: pts in [x(rearward), y(abs world)]
function finGeo(pts, curve){
const sh=new THREE.Shape();
sh.moveTo(pts[0][0],pts[0][1]);
for(let i=1;i<pts.length;i++) sh.lineTo(pts[i][0],pts[i][1]);
sh.closePath();
const g=new THREE.ShapeGeometry(sh, 5);
g.rotateY(Math.PI/2);
return g;
}
function paint(g, fn){
const uv=g.attributes.uv; const n=uv.count; const arr=new Float32Array(n*3); const c=new THREE.Color();
for(let i=0;i<n;i++){ fn(c,uv.getX(i),uv.getY(i)); arr[i*3]=c.r;arr[i*3+1]=c.g;arr[i*3+2]=c.b; }
g.setAttribute('color',new THREE.BufferAttribute(arr,3));
return g;
}
```
Note: ShapeGeometry uv — does ShapeGeometry generate uv? ShapeGeometry generates uv from XY positions ✓ (uv = position x,y as generated by ShapeGeometry? It sets uv via ... let me recall: ShapeGeometry builds vertices with `uvs.push(x, y)`? In three source, ShapeGeometry: vertices from shape points; uv generated as (x, y)? Looking at three's ShapeGeometry code: it calls Earcut triangulation; uv: `uvs.push( x, y )`? Hmm — I believe ShapeGeometry uv = raw x,y coords (yes: `addShape` pushes uvs equal to vertex x,y — actually let me recall ShapeGeometry source:
```js
function buildShape(...) { ... for each vertex: vertices.push(x,y,z); uvs.push(x, -y?) }
```
Hmm risk: uv might not exist or be (x, -y)? Let me check three r170 ShapeGeometry: In `ShapeGeometry`, `addShape(shape, ...)`:
```js
for ( let i = 0; i < ...; i ++ ) {
vertices.push( x, y, 0 );
uvs.push( x, y ); // ?
}
```
Hmm, actually I recall ShapeGeometry UVs are the raw 2D coordinates: source:
```js
const uv = [ x, y ]?
```
Not 100% sure of sign. To be safe, don't rely on uv for fins — instead compute fin vertex colors from position (after rotation, position.z gives rearward coordinate, position.y gives height ✓). Position is reliable! Use position-based painting for fins: fn(c, rearwardDist = -(z - baseZ?)...) — after rotateY(π/2) and translate, position.z = baseZ - shapeX. Rearward extent = shapeX = (baseZ - z). I can pass baseZ to compute u = baseZ - z ✓ robust. And v = position.y ✓ absolute.
So paint(g, fn) iterates position: fn(c, u, v) with u = rearward progress (0 at front/base), v = absolute y.
For fins built via finGeo(pts) then translated: translate after rotate: g.translate(0, 0, z0) sets base at z0; then u = z0 - g.position.z. I'll make finGeo take z0 and do translate inside, and paint with knowledge of z0.
Redefine:
```js
function fin(pts, z0, colorFn){
const g = shape from pts (pts in [rearward u, y] local, y relative)
```
Better: define pts in (u rearward, y RELATIVE to attachment y0), then bake translate(x? no) → world y = y0 + relY, z = z0 - u. Build shape with abs coords: pts.map(([u,dy])=>[u, y0+dy]). Then rotateY(π/2) → shape x maps to -z: point (u, y) → (0, y, -u); translate z by z0: z = z0 - u ✓ rearward ✓ (since nose +z, rear is -z; base z0, extends to z0-u ✓).
Then paint: u = z0 - pos.z ✓.
Tail fin: base z0 = -0.40 (slightly overlapping body end -0.42 → base inside body ✓ good).
Dorsal fin: base z along body — but its bottom edge follows body curve: I'll construct with bottom edge points computed: for k=0..3: u_k, bottomY_k = topAt(tAt(z0-u_k)) - 0.02 where tAt(z) = z+0.42. Shape coords (u_k, bottomY_k) ✓ absolute y baked ✓ no y translate.
Ventra similar with botAt + 0.02.
Pectorals: small shape in (u,dy) relative y0=... simpler: build with relative pts then handle translate manually:
```js
function finRel(pts, pos, rotYextra, colorFn){
const sh = new Shape(); ... pts in (u, y) relative
g=ShapeGeometry; g.rotateY(Math.PI/2); // (0,y,-u)
if(rotYextra) g.rotateY(rotYextra);
g.translate(pos.x, pos.y, pos.z);
paint by position...
}
```
For painting pectoral: rearward measure = length from base: use u = -(z - pos.z) before extra rotation... with extra rotation the mapping skews; but painting just needs approximate gradient: use distance from attach point: u = p.distanceTo(attach). Fine: paint(c, u, v) with u = distance from attach — for pectoral color edges darkening at tips works ✓.
To keep it simple: paintFins(g, attachVec, maxD, colorFn): u = clamp(dist(p, attach)/maxD, 0,1).
For tail/dorsal/ventral I use dedicated painting with u = z0 - z and v = y. Hmm — unify: paint with signature fn(c, u01, v) where u01 = normalized progress toward tip. For tail: maxD = tail len → u01 = (z0 - z)/len. For dorsal: progress toward top tip: use maxH: u01 = (y - bottomY0)/h? Fin tip is up; darkening at upper tip: use relative height: (y - yBaseMin)/hMax. I'll just pass appropriate progress per fin call.
Simplify by giving each fin a colorFn(c, prog) where prog = normalized 0(base)→1(tip), computed per fin type:
- tail: prog = (z0 - z)/tailLen... tip is rearward ✓.
- dorsal: prog = (y - yb)/h where yb = min bottom edge y.
- ventral: prog = (yb - y)/h.
- pectoral: prog = dist/maxD.
- streamer: prog = dist/maxD.
Implement fin builders:
```js
// caudal
const tf=def.tail;
{
const z0=-0.40;
const pts=[[0,0.05],[tf.len*0.8, tf.spread],[tf.len*0.55, tf.fork*tf.spread+0.015],[tf.len*0.55,-tf.fork*tf.spread-0.015],[tf.len*0.8,-tf.spread],[0,-0.05]];
const g=finAbs(pts,z0);
paintProg(g, p=>(z0-p.z)/tf.len, def.finColor('tail'));
parts.push(g);
}
```
finAbs(pts, z0): shape in absolute (u→x coordinate? I need shape x = u (rearward param) then rotated maps to -z with translate z0: shape x = u; after rotateY(π/2): (u,y,0)→(0,y,-u); translate(0,0,z0) → z = z0-u ✓. So shape coordinates: (u, yAbs) ✓.
```js
function finAbs(pts, z0){
const sh=new THREE.Shape();
sh.moveTo(pts[0][0],pts[0][1]);
for(let i=1;i<pts.length;i++)sh.lineTo(pts[i][0],pts[i][1]);
const g=new THREE.ShapeGeometry(sh,4);
g.rotateY(Math.PI/2);
g.translate(0,0,z0);
return g;
}
```
Wait rotateY(π/2) maps (x,y,0)→? earlier computed (0,y,-x) ✓ so shape x=u → z=-u ✓ then translate +z0 ✓.
Dorsal:
```js
const df=def.dorsal;
{
const z0=df.t0-0.42;
const yb0=topAt(df.t0)-0.02;
const h=df.h;
const uEnd=df.len;
// bottom edge sample points
const ns=4; const bottom=[];
for(let k=0;k<=ns;k++){ const u=uEnd*k/ns; bottom.push([u, topAt(clamp(df.t0 - u,0,1))-0.02]); }
// outline: up leading edge, tip, down to rear bottom, then bottom edge reversed
const pts=[];
pts.push([0, bottom[0][1]]);
pts.push([uEnd*0.30, bottom[0][1]+h*0.92]);
pts.push([uEnd*0.72, bottom[Math.round(ns*0.7)][1]+h*0.78]);
pts.push([uEnd, bottom[ns][1]+h*0.28]);
for(let k=ns;k>=0;k--) pts.push(bottom[k]);
close;
const g=finAbs(pts, z0);
const yMin=bottom[ns][1]; // approx lowest? bottom varies; use min
paintProg(g, p=>(p.y - yb0*0? ...
```
prog for dorsal = (y - baseYAtThatPoint)/h... simpler: prog = (y - yLowest)/h where yLowest = min bottom y (bottom edges near front are highest...). Whatever — edge darkening toward upper tip: prog = (y - (yb0 - 0.06))/ (h+0.06) clamp. Eh, visually fine.
Actually simpler & robust: prog = clamp((y - topAt(df.t0) + 0.05)/h, 0, 1)?? topAt varies little across fin. y ranges from ~bodyTop-0.02 to bodyTop-0.02+h. prog = (y - (bodyTopAtBase-0.03))/h ✓.
I'll compute in the closure: const yb = topAt(df.t0)-0.03; paintProg(g, p=>clamp((p.y-yb)/h,0,1), def.finColor('dorsal')).
Ventral mirrored:
```js
const vf=def.ventral;
similar with botAt, tip downward: pts with -h.
prog = clamp(((vb) - p.y)/h,0,1).
```
Pectorals:
```js
{
const tp=0.66, z0=tp-0.42; const pr=prof(tp);
const y0=-botAt? place: y0 = -pr*H*0.15? Let me: attach y = -H*0.05 - ... just y0 = -H*0.1*? Use y0 = -0.02 - H*0.1; x0 = pr*W*0.85;
const pts=[[0,0.03],[0.16,-0.04],[0.12,0.07]];
for(const side of [1,-1]){
const g=finRel(pts, new THREE.Vector3(side*x0, y0, z0), side*0.5);
paintProg(g, p=> clamp(Math.hypot(p.x-side*x0,p.y-y0,p.z-z0)/0.16,0,1), def.finColor('pect'));
parts.push(g);
}
}
```
finRel: shape pts (u,y rel) → shape coords (u, y0+dy)... build shape with y = y0+pts.y; rotate; then extra rotateY(side*0.5): rotates around Y axis through origin — but geometry coords currently near origin? After finAbs-style building, geometry near origin (z from 0 to -u). Extra rotateY around origin maps x↔z mixing: point (0, y, -u) rotateY(θ): x' = z sinθ = -u sinθ; z' = z cosθ = -u cosθ. For side=1 (right, +x): want tip to +x: x' = -u sinθ positive needs θ<0 → rotateY(-0.5) for right. So pass rot = -side*0.5. Then translate to (side*x0, y0, z0).
```js
function finRel(pts, pos, rot){
const sh=new THREE.Shape();
pts.forEach(([u,dy],i)=>{ i? sh.lineTo(u, pos.y+dy) : sh.moveTo(u,pos.y+dy); });
const g=new THREE.ShapeGeometry(sh,4);
g.rotateY(Math.PI/2);
if(rot) g.rotateY(rot);
g.translate(pos.x,pos.y,pos.z); // pos.y already in shape; translate y 0!
```
Careful: shape y already absolute (pos.y+dy); translate should add pos.y=0 → pass translate( pos.x, 0, pos.z ). ✓
Streamers (angel):
```js
if(def.streamer){
const z0=0.10;
const y0=botAt(0.52)+0.03;
const pts=[[0,0],[0.07,-0.05],[0.42,-0.40],[0.47,-0.36],[0.10,-0.015]];
for(side of [1,-1]){ g=finRel(pts, vec(side*0.02? width thin: x offset ±0.018, y0, z0), 0); paint prog dist/0.45, finColor('streamer'); }
}
```
Eyes:
```js
{
const te=0.88, pr=prof(te);
const eyeG=new THREE.SphereGeometry(0.033,8,6);
const black=new THREE.Color(0x0b0e12);
for(side of [1,-1]){
const g=eyeG.clone();
g.translate(side*pr*W*0.72, H*0.14, te-0.42);
paintSolid(g, black);
parts.push(g);
}
}
```
paintSolid sets color attr constant. Sphere indexed → toNonIndexed before merge ✓ (handle in merge step uniformly).
Merging:
```js
const merged=mergeGeometries(parts.map(g=> g.index? g.toNonIndexed(): g));
merged.computeBoundingSphere();
return merged;
}
```
All parts must have color attr — ensure paint called on every part including eyes ✓.
Attributes check: ShapeGeometry has position/normal/uv ✓ + our color ✓. Sphere same ✓.
**finColor functions per species** — signature: def.finColor(part) returns fn(c, prog). Implement in species def as `finColor:(part)=>(c,pr)=>{...}`.
clown:
```js
finColor:(part)=>{ const base=new THREE.Color(0xffa53c), dark=new THREE.Color(0x1f2126), white=new THREE.Color(0xf6f4ea);
return (c,pr)=>{ c.copy(part==='tail'? white : base); if(part==='tail'){ c.lerp(dark, sstep(0.6,1,pr)*0.8); } else { c.lerp(dark, sstep(0.55,1,pr)*0.65); } }; }
```
Hmm clown tail white with black edge ✓ nice.
tetra: fins pale: (c,pr)=>{ c.set(0xbfcdd2); c.lerp(new Color(0x7f95a0), sstep(0.5,1,pr)*0.6); } — precreate colors.
tang: (c,pr)=>{ c.set(0xffdf5e).lerp(C(0xfff3b8), sstep(0.5,1,pr)*0.5); }
angel: (c,pr)=>{ c.set(0x4d5865).lerp(C(0x10141a), sstep(0.45,1,pr)*0.85); }
To avoid allocating colors in closures each call, define with const outside. Fine at build time anyway (few hundred verts).
**bodyColor implementations** as planned (write as (c,t,y)=>{...} using precreated Colors).
**Anemone build:**
```js
function buildAnemone(x,z,scale=1){
const g=new THREE.Group();
const baseH=sandHeight(x,z);
g.position.set(x,baseH,z);
// base blob
const bGeo=new THREE.SphereGeometry(0.5,14,10);
displace slightly? keep smooth squash via scale.
vertex colors gradient by y: bottom 0x5c1f3f → top 0xc94f82.
const bMat=new THREE.MeshStandardMaterial({vertexColors:true, roughness:0.7});
const base=new THREE.Mesh(bGeo,bMat); base.scale.set(1.15*scale,0.62*scale,1.15*scale); base.position.y=0.1*scale; base.castShadow=true;
g.add(base);
// column
const cGeo=new THREE.CylinderGeometry(0.42*scale,0.5*scale,0.3*scale,10); colors solid-ish gradient...
maybe skip column; blob enough.
// tentacles
const n=Math.round(26*scale);
const tGeo=getTentacleGeo(); // shared with colors baked
const tMat=tentacleMaterial (shared across anemones, sway injected, aPhase per instance)
const tm=new THREE.InstancedMesh(tGeo,tMat,n);
...
}
```
Wait — aPhase attribute is on the shared geometry; two anemones → two InstancedMeshes sharing geometry & attribute arrays — phases random anyway ✓ but same array shared → same phases across both anemones — visually fine (random per tentacle index).
Set tentacle matrices as planned; setColorAt per tentacle jitter.
tentacle geo colors: gradient along y: base 0xa63a6e → mid 0xe0679c → tip 0xffd3e6: mix by y. Vertex colors baked ✓ then instanceColor jitters overall.
Tentacle sway injection:
```js
function injectSwayAnemone(mat){
mat.onBeforeCompile=(sh)=>{
sh.uniforms.uTime=timeU;
sh.vertexShader=sh.vertexShader
.replace('#include <common>','#include <common>\nattribute float aPhase;\nuniform float uTime;')
.replace('#include <begin_vertex>',`#include <begin_vertex>
{
float hh = clamp(transformed.y, 0.0, 1.0);
float sw = pow(hh, 1.5);
transformed.x += sin(uTime*1.7 + aPhase) * sw * 0.13;
transformed.z += cos(uTime*1.4 + aPhase*1.31) * sw * 0.13;
}`);
};
}
```
Tentacle local y: cylinder translated +0.5 → y∈[0,1] ✓ (height 1 scaled by instance scale y).
Kelp sway injection similar with position.y (0..1 blade) ✓.
**Anemone cast shadows:** base castShadow true; tentacles no.
**Flee also from anemone? no.**
**Clown homes:** after building anemones at A(-8.3,-5.5) scale 1.15, B(6.8,-7.5) scale 0.9: homes = [{x:-8.3,y:sandH+1.5,z:-5.5},{x:6.8,y:...,z:-7.5}]. Assign clowns round-robin.
**Kelp aPhase:** random per instance ✓.
**Now the caustic GLSL function string (shared):**
```js
const CAUSTIC_GLSL = `
float causticField(vec2 p, float t){
vec2 q = p;
float v = 0.0;
float w = 0.62;
for(int k=0;k<3;k++){
float fk = float(k);
q += vec2(sin(q.y*1.65 + t*(0.9+0.13*fk) + fk*2.4), cos(q.x*1.42 - t*(0.8+0.11*fk) + fk*1.7))*0.45;
v += w * abs(sin(q.x*1.5 + t*0.7) * sin(q.y*1.62 - t*0.85));
w *= 0.58;
}
v = v/1.02;
float c = clamp(1.0 - v*1.7, 0.0, 1.0);
return pow(c, 5.0);
}`;
```
Check normalization: w sum = 0.62+0.36+0.209=1.189; v/1.02 hmm — divide by 1.19: v avg = 0.42*1.189/1.19≈0.42 (avg |sin sin| ~0.4). c = 1-0.71=0.29^5=0.002 dark; ridges v→0 → 1 bright ✓. Let me set v/1.19 properly: `v /= 1.19;`.
Hmm, wait — is the ridged look right? abs(sin·sin)=0 along lines where either sin=0 → grid of nodal lines moving with warping → v small along web lines → c high on web lines ✓ caustic web ✓ moves & warps over time ✓.
**injectCaustics full:**
```js
function injectCaustics(mat, strength, scale){
mat.onBeforeCompile=(sh)=>{
sh.uniforms.uTime=timeU;
sh.uniforms.uCau={value:strength};
sh.uniforms.uCauS={value:scale};
sh.vertexShader=sh.vertexShader
.replace('#include <common>','#include <common>\nvarying vec3 vCauP;')
.replace('#include <begin_vertex>','#include <begin_vertex>\nvCauP=(modelMatrix*vec4(transformed,1.0)).xyz;');
sh.fragmentShader=sh.fragmentShader
.replace('#include <common>','#include <common>\nuniform float uTime;uniform float uCau;uniform float uCauS;varying vec3 vCauP;\n'+CAUSTIC_GLSL)
.replace('#include <map_fragment>',`#include <map_fragment>
{
float cau = causticField(vCauP.xz*uCauS, uTime*0.55)*0.75 + causticField(vCauP.xz*uCauS*2.1+13.7, uTime*0.8)*0.35;
diffuseColor.rgb *= (1.0 + cau*uCau);
}`);
};
}
```
Applied to sand (strength 0.7, scale 0.5) & rocks (0.35, 0.55). Note rocks non-instanced ✓ vCauP fine (rock meshes have modelMatrix ✓).
**Sand caustics scale 0.5:** features ~ (2π/1.5)/0.5 ≈ 8.4 units — hmm large. Use scale 0.8 → ~5 units; second octave 1.7 → 2.5 units ✓ rich. Let me pass scale 0.75.
Wait, actually the internal warp makes effective features smaller. Whatever — pick and it'll look fine-ish: sand (0.85, strength 0.65), rocks (0.9, 0.3).
**Water surface uses causticField too** with uniforms uTime ✓ include same GLSL string in its fragment ✓.
**Kelp receiveShadow with instancing:** works.
**Now assemble species defs concretely:**
```js
const fishDefs=[
{
key:'clown', count:12, len:0.85, height:0.20, width:0.135,
cruise:1.25, maxS:3.4, coh:0.6, ali:1.0, neighR:3.4, wander:0.55, prefY:3.1,
tail:{len:0.26,spread:0.21,fork:0.18},
dorsal:{t0:0.50,len:0.20,h:0.13},
ventral:{t0:0.46,len:0.15,h:0.10},
bodyColor:(c,t,y)=>{...},
finColor:(part)=>...
},
...
];
```
Write bodyColor fns:
```js
const COL={
orange:new THREE.Color(0xff8a1e), orangeD:new THREE.Color(0xd45500), clownW:new THREE.Color(0xf4f2e9), black:new THREE.Color(0x232629), belly:new THREE.Color(0xffc98a),
silver:new THREE.Color(0xd9e2e6), neon:new THREE.Color(0x35d5ff), red:new THREE.Color(0xff2e46), white:new THREE.Color(0xf2f7f9), steel:new THREE.Color(0x9fb4bd),
yellow:new THREE.Color(0xffd21c), yellowD:new THREE.Color(0xdf9c08), yellowL:new THREE.Color(0xffe664),
angelS:new THREE.Color(0xdae2e8), angelT:new THREE.Color(0x8fa0ad), angelD:new THREE.Color(0x2b343d),
};
```
clown bodyColor:
```js
(c,t,y)=>{
c.copy(COL.orange).lerp(COL.orangeD, sstep(0.55,1,y)*0.55);
c.lerp(COL.belly,(1-sstep(0.28,0.06,y))*0.45);
let bd=0,bw=0;
const bands=[[0.26,0.05],[0.55,0.055],[0.88,0.045]];
for(const [bc,bw2] of bands){
bd=Math.max(bd,bandf(t,bc,bw2+0.028,0.03));
bw=Math.max(bw,bandf(t,bc,bw2-0.02,0.03));
}
c.lerp(COL.black,Math.min(1,bd)*0.85);
c.lerp(COL.clownW,Math.min(1,bw));
}
```
Check band widths: bw2-0.02 could be negative for 0.045-0.02=0.025 ok positive ✓.
tetra:
```js
(c,t,y)=>{
c.copy(COL.silver);
c.lerp(COL.red, sstep(0.62,0.30,t)*(1-sstep(0.42,0.6,y)));
c.lerp(COL.neon, sstep(0.48,0.7,y)*sstep(0.10,0.30,t));
c.lerp(COL.white,(1-sstep(0.34,0.12,y))*0.5);
c.lerp(COL.steel, sstep(0.55,1,y)*0.45);
}
```
Neon stripe strong ✓.
tang:
```js
(c,t,y)=>{
c.copy(COL.yellow).lerp(COL.yellowD, sstep(0.45,0.95,y)*0.6);
c.lerp(COL.yellowL, sstep(0.72,0.97,t)*0.6);
c.lerp(COL.white? no.
}
```
angel:
```js
(c,t,y)=>{
c.copy(COL.angelS).lerp(COL.angelT,sstep(0.45,0.95,y)*0.5);
let b=bandf(t,0.36,0.035,0.02)+bandf(t,0.60,0.03,0.02)+bandf(t,0.84,0.04,0.015);
c.lerp(COL.angelD,Math.min(1,b));
}
```
finColor per species as above.
**Species full definitions:**
tetra: {count:40, len:0.52, height:0.155, width:0.10, cruise:1.9, maxS:4.6, coh:1.4, ali:2.2, neighR:2.5, wander:0.5, prefY:5.6, tail:{len:0.17,spread:0.13,fork:0.5}, dorsal:{t0:0.55,len:0.09,h:0.06}, ventral:{t0:0.42,len:0.08,h:0.05}}
tang: {count:9, len:1.05, height:0.33, width:0.13, cruise:1.05, maxS:3.0, coh:0.25, ali:0.5, neighR:4.0, wander:0.9, prefY:2.5, tail:{len:0.16,spread:0.30,fork:0.06}, dorsal:{t0:0.42,len:0.34,h:0.30}, ventral:{t0:0.42,len:0.30,h:0.26}}
angel: {count:6, len:1.15, height:0.40, width:0.075, cruise:0.85, maxS:2.4, coh:0.35, ali:0.7, neighR:4.4, wander:0.45, prefY:6.2, tail:{len:0.30,spread:0.22,fork:0.10}, dorsal:{t0:0.56,len:0.30,h:0.40}, ventral:{t0:0.50,len:0.26,h:0.44}, streamer:true}
Angel height 0.40 & dorsal 0.40 above body top (~0.40*1.16=0.46) → total ~0.9 tall vs 1.15 long ✓ angelfish proportions.
Ventral h 0.44 below → hangs to -0.46-0.44 ≈ -0.9 ✓ symmetrical ✓.
**Kelp instance building code:**
```js
const kelpClusters=[[-13.5,-7,9],[13.6,-8,8],[-9.5,-9.6,6],[8.8,-1.0,5],[2.5,-10.2,6],[13.8,1.8,4]];
const kelpData=[];
for(const [cx,cz,n] of kelpClusters){
for(i<n){ const a=rand(TAU), r=rand(0.3,1.3); kelpData.push([cx+Math.cos(a)*r, cz+Math.sin(a)*r, rand(2.2,5.2)]); }
}
for(i<24){ kelpData.push([rand(-14,14), rand(-9.5,9.5), rand(0.7,1.7)]); }
```
Hmm scattered short blades may pop inside rocks — fine.
Total = 9+8+6+5+6+4=38 +24=62.
Instances:
```js
const kelp=new THREE.InstancedMesh(bladeGeo,kelpMat,kelpData.length);
kelp.frustumCulled=false; kelp.receiveShadow=true;
const phases=new Float32Array(kelpData.length);
const kc=new THREE.Color(); const KPAL=[0x2c6b4f,0x3f8f6a,0x57a06a,0x2f8f7a,0x6fae6a].map(h=>new THREE.Color(h));
kelpData.forEach(([x,z,h],i)=>{
phases[i]=rand(TAU);
const gy=sandHeight(x,z);
mtx.makeRotationY(rand(TAU));
mtx.setPosition(x,gy-0.05,z);
// scale: non-uniform via compose:
v1.set(h*rand(0.55,0.9), h, h*rand(0.55,0.9)); // width factor
qt.identity();
mtx2.compose(new Vector3(x,gy-0.05,z), qtFromYaw, v1)
...
});
```
Use compose directly: euler y random → quat; scl vector; pos. ✓
kelp.setColorAt(i, KPAL[i%5] jittered: kc.copy(pal).multiplyScalar(rand(0.85,1.15)));
kelp.instanceColor.needsUpdate ✓ (after loop).
bladeGeo.setAttribute('aPhase', new InstancedBufferAttribute(phases,1)).
Kelp shadows: receive only ✓.
**Anemone tentacle geo colors:**
```js
const tGeo=new THREE.CylinderGeometry(0.018,0.06,1,5,4);
tGeo.translate(0,0.5,0);
const tp=tGeo.attributes.position; colors: for i: y=tp.getY(i); mix: base 0x9c2f63 (0.15) → 0xe26b9e (0.6) → 0xffd9ec (1.0)
tGeo.setAttribute('color',...)
```
Gradient stops: c = lerp3. Implement:
```js
const c1=new Color(0x8e2657), c2=new Color(0xe26b9e), c3=new Color(0xffd9ec);
c = y<0.55? lerp(c1,c2,y/0.55) : lerp(c2,c3,(y-0.55)/0.45)
```
Anemone base geo colors similar dark.
**Now, dome/surface/ray/bubble/snow/food/cursor materials — write out fully.**
I need to be careful: ShaderMaterial vertex shaders must define things used by fog chunks: `#include <fog_vertex>` expects `mvPosition`. ✓ define `vec4 mvPosition = ...` before include.
Also `#include <fog_pars_vertex>` requires nothing else ✓.
For Points ShaderMaterials, also there's `#include <common>` maybe needed by fog chunks? fog chunks use only fogColor/fogDensity + vFogDepth — `fog_fragment`:
```glsl
#ifdef USE_FOG
float fogFactor = 1.0 - exp( - fogDensity * fogDensity * vFogDepth * vFogDepth ); (for EXP2)
gl_FragColor.rgb = mix( gl_FragColor.rgb, fogColor, fogFactor );
#endif
```
needs nothing from common ✓. But defines USE_FOG/FOG_EXP2 set by renderer only if material.fog === true AND scene.fog ✓ (WebGLProgram parameters: fog: material.fog && !!fog). ✓
`colorspace_fragment`: `gl_FragColor = linearToOutputTexel( gl_FragColor );` — function defined in... the colorspace_pars? In r170, `linearToOutputTexel` is injected into all fragment shaders automatically (it's part of the common prolog added by WebGLProgram — yes, getTexelEncodingFunction is appended to every shader including ShaderMaterial). ✓ safe.
**Bubble shader complete:**
```js
const bubGeo=new THREE.BufferGeometry();
const NB=90;
const bBase=new Float32Array(NB*3), bSeed=new Float32Array(NB), bRate=new Float32Array(NB), bSize=new Float32Array(NB);
emitters: E1(-8.4,-5.2), E2(9.3,-6.6);
for i: e = i%2; x=ex+rand(-0.5,0.5), z=ez+rand(-0.5,0.5), y=sandHeight(x,z)+0.1;
bSeed=rand(100); bRate=rand(1.0,2.0); bSize=rand(0.35,0.8);
attributes: aBase(3), aSeed, aRate, aSize.
const bubMat=new THREE.ShaderMaterial({
uniforms:{uTime:timeU,uPR:{value:PR}},
transparent:true, depthWrite:false, fog:true,
vertexShader:`
uniform float uTime; uniform float uPR;
attribute vec3 aBase; attribute float aSeed; attribute float aRate; attribute float aSize;
varying float vA;
#include <fog_pars_vertex>
void main(){
float h = mod(aSeed*13.7 + uTime*aRate, 11.0);
float wob = 0.05 + h*0.05;
vec3 p = vec3(aBase.x + sin(uTime*1.6+aSeed)*wob, aBase.y + h, aBase.z + cos(uTime*1.3+aSeed*1.7)*wob);
vA = smoothstep(0.0,0.5,h) * (1.0 - smoothstep(10.2,11.0,h));
vec4 mvPosition = modelViewMatrix * vec4(p,1.0);
gl_PointSize = max(aSize*(1.0+h*0.10)*uPR*(150.0/ max(1.0,-mvPosition.z)), 2.0);
gl_Position = projectionMatrix*mvPosition;
#include <fog_vertex>
}`,
fragmentShader:`
varying float vA;
#include <fog_pars_fragment>
void main(){
vec2 q = gl_PointCoord-0.5; float d=length(q)*2.0;
float edge = 1.0 - smoothstep(0.82,1.0,d);
float hole = smoothstep(0.5,0.85,d);
float rim = edge*(0.12+0.88*hole);
float hi = smoothstep(0.4,0.0,length(q-vec2(-0.14,0.16)));
float a = clamp(rim + hi*0.7, 0.0, 1.0) * vA * 0.8;
vec3 col = mix(vec3(0.72,0.88,1.0), vec3(1.0), hi*0.8);
gl_FragColor = vec4(col, a);
#include <fog_fragment>
#include <colorspace_fragment>
}`
});
```
Hmm `1.0 - smoothstep(10.2,11.0,h)` fine ascending ✓.
`#include <fog_pars_vertex>` in ShaderMaterial — chunk content:
```glsl
#ifdef USE_FOG
varying float vFogDepth;
#endif
```
✓.
Wait — mod(aSeed*13.7 + uTime*aRate, 11.0): all bubbles pass y range aBase.y..+11 — base y ~0.3 → top 11.3 ✓ surface 11.8.
**Snow:**
```js
NS=220; aBase rand box: x±15, y rand(0.8,11.3), z±10; aSeed, aSize rand(0.5,1.4);
vertex:
float yy = 0.7 + mod(aBase.y - uTime*(0.06+aSeed*0.05), 10.5);
vec3 p = vec3(aBase.x + sin(uTime*0.18+aSeed*3.1)*0.6, yy, aBase.z + cos(uTime*0.15+aSeed*2.3)*0.6);
gl_PointSize = max(aSize*uPR*(60.0/max(1.0,-mv.z)), 1.0);
vA=0.5?
frag: soft dot alpha 0.16*shape: a = smoothstep(0.5,0.15,d)*0.2
```
Add fog ✓.
**God rays shader:**
```js
const rayVert=`
uniform float uTime; uniform float uSeed;
varying vec2 vUv;
void main(){
vUv=uv;
vec3 p=position;
p.x += sin(uTime*0.22+uSeed*7.0)*0.55;
gl_Position = projectionMatrix*modelViewMatrix*vec4(p,1.0);
}`;
const rayFrag=`
uniform float uTime; uniform float uSeed; uniform float uOp;
varying vec2 vUv;
void main(){
float ex = smoothstep(0.0,0.38,vUv.x)*(1.0-smoothstep(0.62,1.0,vUv.x));
float ey = pow(vUv.y, 2.8);
float fl = 0.55+0.45*sin(uTime*0.5+uSeed*9.0);
float a = ex*ey*fl*uOp;
gl_FragColor = vec4(vec3(0.55,0.82,1.0), a);
#include <colorspace_fragment>
}`;
```
Plane uv.y: PlaneGeometry uv (0,0) bottom-left? uv y=0 at bottom ✓ ey bright at top ✓ (plane y up, top near surface ✓).
Rays: 7 meshes:
```js
for(let i=0;i<7;i++){
const m=new THREE.Mesh(rayGeo, new THREE.ShaderMaterial({uniforms:{uTime:timeU,uSeed:{value:rand(10)},uOp:{value:rand(0.05,0.10)}}, vertexShader:rayVert,fragmentShader:rayFrag, transparent:true, depthWrite:false, blending:THREE.AdditiveBlending, side:THREE.DoubleSide}));
m.scale.set(rand(0.9,2.6), 11.2, 1);
m.position.set(rand(-10,10), 5.8, rand(-8,1));
m.rotation.y=rand(-0.5,0.5);
m.rotation.z=rand(-0.09,0.09);
m.renderOrder=5;
scene.add(m);
}
```
Ray plane height 1 scaled 11.2 → from y=0.2 to 11.4 ✓.
**Water surface mesh:**
```js
const surfGeo=new THREE.PlaneGeometry(130,90,50,36);
surfGeo.rotateX(Math.PI/2); // normal -Y
const surfMat=new THREE.ShaderMaterial({
uniforms:{uTime:timeU,uDeep:{value:new THREE.Color(0x0a3450)},uLite:{value:new THREE.Color(0xaee8ff)}},
fog:true, side:THREE.FrontSide?
```
Hmm normal after rotateX(+π/2) is (0,-1,0) ✓ FrontSide faces down ✓ camera below sees it ✓.
```js
vertexShader:`
uniform float uTime; varying vec2 vP;
#include <fog_pars_vertex>
void main(){
vec3 p=position;
p.y += sin(p.x*0.32+uTime*0.7)*0.10 + sin(p.z*0.45-uTime*0.5)*0.08;
vP=p.xz;
vec4 mvPosition=modelViewMatrix*vec4(p,1.0);
gl_Position=projectionMatrix*mvPosition;
#include <fog_vertex>
}`,
fragmentShader:`
uniform float uTime; uniform vec3 uDeep; uniform vec3 uLite;
varying vec2 vP;
#include <fog_pars_fragment>
${CAUSTIC_GLSL}
void main(){
float c = causticField(vP*0.06, uTime*0.5)*0.8 + causticField(vP*0.16+7.3, uTime*0.75)*0.5;
vec3 col = mix(uDeep, uLite, clamp(c*1.15,0.0,1.0));
gl_FragColor=vec4(col,1.0);
#include <fog_fragment>
#include <colorspace_fragment>
}`
});
const surf=new THREE.Mesh(surfGeo,surfMat); surf.position.y=11.8; surf.frustumCulled=false; scene.add(surf);
```
Wait CAUSTIC_GLSL uses nothing outside ✓ (pure function). Note in the fragment, `causticField` defined before main ✓.
Plane rotation and position: geometry local after rotateX: plane in XZ ✓ position y set on mesh ✓ waves displace local y ✓.
Surface size 130×90 covers view up to horizon w/ fog ✓.
**Dome:**
```js
const domeMat=new THREE.ShaderMaterial({
uniforms:{uSun:{value:new THREE.Vector3(0.32,0.9,0.28).normalize()}},
side:THREE.BackSide, depthWrite:false, fog:false,
vertexShader:`
varying vec3 vD;
void main(){
vec4 wp=modelMatrix*vec4(position,1.0);
vD = wp.xyz - cameraPosition;
gl_Position = projectionMatrix*viewMatrix*wp;
}`,
fragmentShader:`
uniform vec3 uSun; varying vec3 vD;
void main(){
vec3 d=normalize(vD);
vec3 bot=vec3(0.012,0.055,0.085), mid=vec3(0.030,0.145,0.20), top=vec3(0.10,0.32,0.42);
vec3 col=mix(bot,mid,smoothstep(-0.45,0.15,d.y));
col=mix(col,top,smoothstep(0.15,0.75,d.y));
float g=pow(max(dot(d,uSun),0.0),20.0);
col += vec3(0.45,0.65,0.75)*g*0.4;
gl_FragColor=vec4(col,1.0);
#include <colorspace_fragment>
}`
});
```
These constants are linear-space values (since colorspace_fragment converts) — authored darker to compensate sRGB curve: linear 0.145 → sRGB ~0.42 ✓ mid tone teal. bottom 0.055 → sRGB ~0.26 ✓ dark deep. top 0.32 → sRGB 0.6 ✓ light aqua. Looks right.
renderOrder: dome drawn... it's opaque (transparent false) — drawn with depthWrite false among opaque queue sorted by renderOrder: set dome.renderOrder=-2 → drawn first ✓ then everything overwrites ✓. But depthTest: dome fragments at depth far — other objects pass depth test (closer) ✓. But dome drawn first with depthWrite false doesn't block ✓. Also surf is opaque too (ShaderMaterial transparent default false) — surface at top; fine.
Hmm wait: dome drawn first, then opaque objects — but objects BEHIND dome? none (dome radius 120 encloses all) ✓.
**Fog color vs dome bottom:** dome bottom linear 0.012,0.055,0.085 ≈ #1e2e38? sRGB(0.055*?) hmm converting: linear 0.055 → sRGB = 1.055*0.055^(1/2.4)-0.055 = 1.055*e^(ln0.055/2.4)=0.055^(0.4167): ln0.055=-2.9*0.4167=-1.208 → e^-1.208=0.299*1.055-0.055=0.26 → RGB(3,66,88)-ish. Fog color 0x0b4157 → linear (0.0043, 0.055, 0.086)? 0x41=65/255=0.255→linear 0.053; 0x57=87/255=0.341→linear 0.096. So fog linear ≈ (0.0043,0.053,0.096) ≈ dome bottom (0.012,0.055,0.085) ✓ great match.
**Sun position & dome sun glow:** sun light at (7,19,5) direction normalized ≈ (0.34,0.91,0.24) ✓ matches uSun ✓.
**Fish shadows visible?** Sand receives ✓.
**Check sun shadow camera setup syntax:**
```js
sun.shadow.camera.left=-24; sun.shadow.camera.right=24; sun.shadow.camera.top=20; sun.shadow.camera.bottom=-20; sun.shadow.camera.near=4; sun.shadow.camera.far=50;
```
Light at (7,19,5), target (0,0,-3): distance ~ 21.6; shadow cam near 4 far 50 covers scene depth along light dir ✓. Ortho box 48×40 covers tank from light's POV ✓ (rotated view). 1024 map over 48 units → ~21px/unit ✓ soft.
**Kelp receiveShadow:** InstancedMesh receiveShadow works ✓.
**Also add slight blue point light in front? fill dir handles.**
**Status: avg excite over fish → setStatus.**
**Auto rotate re-enable & pointer interplay fine.**
**Initial pointer state:** pointerActive=false until first pointermove → ring hidden initially. On touch devices, no hover until tap ✓ fine. But screenshot: ring hidden ✓ fine.
Wait — the autoRotate drift + fish swimming is the alive motion ✓.
**Let me reconsider tetra school center start:** spawn tetras in tight ball for instant schooling look: spawn around (2,5.6,0) radius 3 → first paint shows a formed school ✓ nice. Others random.
Clowns around homes ✓. Tangs random lower. Angels random upper.
**Food initial sprinkle at 1.3s at (1.5,?,0):** school nearby converges ✓.
**Edge: foods MAXF 90 with sprinkle 8 → fine.**
**Performance of per-frame instanceColor?** static ✓.
**Now — potential issue: `#include <begin_vertex>` replacement inside materials that also got `vCauP` injected — rocks & sand only caustics ✓ fish wag ✓ kelp sway ✓ no conflicts.**
**The ring/cursor & ripples use raw ShaderMaterial with own uniform uTime (shared timeU).**
**Also add subtle DOM "grain"? skip.**
**Write CSS nicely:**
```css
*{box-sizing:border-box}
html,body{margin:0;height:100%;overflow:hidden;background:#03151f}
#c{position:fixed;inset:0;width:100vw;height:100vh;display:block;cursor:none;touch-action:none}
```
touch-action none needed for OrbitControls touch ✓ (three sets it itself too, but fine).
Wait — `touch-action:none` on canvas prevents scrolling — page has no scroll anyway ✓.
```css
#vig{position:fixed;inset:0;pointer-events:none;
background:
radial-gradient(130% 100% at 50% 38%, rgba(0,0,0,0) 52%, rgba(2,14,24,.62) 100%),
linear-gradient(rgba(4,30,44,.30), rgba(0,0,0,0) 22%);}
#ui{position:fixed;inset:0;pointer-events:none;color:#e7f4f6;font-family:ui-sans-serif,system-ui,'Segoe UI',Helvetica,Arial,sans-serif;animation:fadein 1.8s ease .25s both}
@keyframes fadein{from{opacity:0}to{opacity:1}}
.brand{position:absolute;top:26px;left:32px}
.over{font-size:10px;letter-spacing:.42em;text-transform:uppercase;opacity:.55;margin-bottom:10px}
h1{margin:0;font-family:Georgia,'Times New Roman',serif;font-style:italic;font-weight:400;font-size:clamp(30px,4vw,44px);letter-spacing:.04em;text-shadow:0 3px 24px rgba(0,20,35,.9)}
.rule{width:52px;height:1px;background:linear-gradient(90deg,rgba(231,244,246,.9),rgba(231,244,246,0));margin:12px 0 10px}
#status{display:flex;align-items:center;gap:8px;font-size:11px;letter-spacing:.16em;text-transform:uppercase;opacity:.85}
#dot{width:7px;height:7px;border-radius:50%;background:#7ee0a3;box-shadow:0 0 10px currentColor? no...}
```
dot glow: `box-shadow:0 0 8px rgba(126,224,163,.8)` fixed per state — update via inline style background only; glow color mismatch acceptable, or set box-shadow to transparent-white glow constant: rgba(255,255,255,.35).
```css
#hint{position:absolute;bottom:24px;left:50%;transform:translateX(-50%);font-size:10.5px;letter-spacing:.24em;text-transform:uppercase;opacity:.55;white-space:nowrap}
#hint em{font-style:normal;opacity:.5;padding:0 .6em}
#legend{position:absolute;right:30px;bottom:22px;text-align:right;font-size:10px;letter-spacing:.18em;text-transform:uppercase;line-height:2.05;opacity:.8}
#legend i{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:9px;vertical-align:1px}
@media (max-width:680px){
#legend{display:none}
.brand{top:18px;left:20px}
#hint{bottom:18px;font-size:9px;letter-spacing:.14em;white-space:normal;width:86%;text-align:center}
}
```
Small screens hint text "move to startle the shoal · click to feed · drag to look around" wraps ✓.
**Legend counts match defs ✓ (12, 40, 9, 6).**
**Total fish 67 — legend says tetra 40 ✓.**
Hmm — is 40 tiny tetras too many draw...? InstancedMesh single draw ✓ fine.
**Check `controls.autoRotateSpeed` sign: positive rotates left? whatever, 0.22 gentle.**
**Also on pointerdown we set `lastInteract` — and also when pointermove? Dragging counts as interaction: set lastInteract on move too? Then autoRotate never re-enables while user moves — desired ✓: lastInteract=elapsed on pointermove & down.
**Sim order in tick:** updateFish uses foods; updateFood prunes; order: updateFood first then updateFish? Fish read foods — fine either; do updateFood(dt) before updateFish so dead foods removed before targeting... fish mark dead; updateFood prunes at start ✓ order: updateFood then updateFish (fish eat marks; pruned next frame ✓).
**Ripples update in updateFX.**
**Now also ensure no `console` usage.**
**Double-check `InstancedMesh.setColorAt` exists r170 ✓, `instanceColor` property created. After all setColorAt calls, `mesh.instanceColor.needsUpdate=true`.**
**Check kelp material injection uses attribute aPhase — declared via replace; geometry has aPhase instanced attribute ✓. But careful: the SAME bladeGeo with aPhase shared by one mesh only ✓.**
**Anemone tentacle material shared by 2 InstancedMeshes with shared geometry & aPhase attr ✓ same as kelp reasoning.**
**One more possible gotcha:** `#include <begin_vertex>` also exists in depth material? No — onBeforeCompile applies to the material's own shaders only (shadow uses separate depth material without my injection, but that depth material doesn't have attribute aTail declared... irrelevant — it doesn't reference it). But WAIT: the depth material for InstancedMesh shadow — three's depth material handles instancing via USE_INSTANCING ✓ attributes aTail simply unused ✓.
BUT: does WebGL warn about program attribute `aTail` not being found in depth program? No — attributes are per-program ✓.
**Merged fish geometry & aTail InstancedBufferAttribute:** geometry has both regular attributes and instanced attribute ✓ standard pattern.
**toNonIndexed on already-colored geoms before adding aTail?** aTail added after merge ✓.
**Now camera clamp function:**
```js
function clampCamera(){
camera.position.y=clamp(camera.position.y,1.5,10.6);
// also keep within dome: trivial
}
```
**Check polar angle limits with clamp y:** consistent.
**Prevent OrbitControls from zooming through fish? fine.**
**wheel zoom: controls.enableZoom true default — page scroll locked anyway ✓.**
**Init resize call once ✓.**
**Let me also double check `THREE.ShapeGeometry(shape, curveSegments)` — second arg ✓.
**ShapeGeometry with only lineTo (no curves): triangulated by Earcut ✓ (three uses ShapeUtils.triangulateShape).
**Potential problem: shape winding order** — if clockwise, Earcut still triangulates; normals may flip (0,0,-1)? ShapeGeometry computes normals as (0,0,1) always? ShapeGeometry sets normals constant (0,0,1) regardless ✓ then rotated. If shape winding clockwise, front face becomes back — with DoubleSide material ✓ visible regardless. OK.
**Fish material DoubleSide + shadows:** shadow depth material with DoubleSide? castShadow fine.
**Check fins z-fighting with body:** fins intersect body interior — no z-fight (different surfaces crossing at angle) ✓.
**Angel streamers:** two thin fins close together (x=±0.018) with body width 0.075 — inside body silhouette, hanging below ✓.
**Pectoral attach:** x0 = prof(0.66)*W*0.85: prof(0.66)= sin(π*0.66^1.15)= 0.66^1.15: ln0.66=-0.415*1.15=-0.477→0.62; sin(π*0.62)=sin(1.948)=0.93 → x0=0.79W ✓ inside surface slightly.
**Eye placement t=0.88:** z=0.46; prof(0.88): 0.88^1.15: ln0.88=-0.128*1.15=-0.147→0.863; sin(π*0.863)=sin(2.712)=0.41 → x=±0.41*W*0.72=0.30W; body half width at that z is 0.41W → eye slightly inside surface ✓ visible? Eye radius 0.033 vs embed 0.11W... W~0.135 → x=0.029+? halfwidth=0.055; eye at 0.030 with r 0.033 → sticks out to 0.063 ✓ protrudes slightly ✓ good.
y = H*0.14 ✓ above midline.
**Tetra tiny (len 0.52) — eyes radius 0.033 relative to unit geometry, scaled 0.52 → 0.017 world ✓ ok.
**Now the "effort" for wag: aTail.y = f.effort (0..~1.3). Shader amp (0.016+0.045*effort)*2.4*env.
Cruise: effort ~ cruise/maxS ≈ 0.4 → amp = (0.016+0.018)*2.4=0.082 → tail ±0.08 body lengths — visible wag ✓. Fleeing: effort→1.3: (0.016+0.0585)*2.4=0.18 ✓ frantic ✓.
**Slerp init:** f.quat starts identity facing +Z; fish vel random — fine converges.
**prevYaw init 0 vs actual yaw → first-frame turn big → roll spike clamped ✓ fine.**
**Status average:** compute in updateFish: exciteSum.
**Let me also make sure `pointer` Vector2 initial (0,0) → raycaster with origin center — pointerActive false so no flee ✓.
**Escape key? none.**
**One more polish: fish slight scale-flip? no.**
**Write `injectSwayKelp`:**
```js
function injectSwayKelp(mat){
mat.onBeforeCompile=(sh)=>{
sh.uniforms.uTime=timeU;
sh.vertexShader=sh.vertexShader
.replace('#include <common>','#include <common>\nattribute float aPhase;\nuniform float uTime;')
.replace('#include <begin_vertex>',`#include <begin_vertex>
{
float h=clamp(transformed.y,0.0,1.0);
float b=h*h;
transformed.x += sin(uTime*0.85+aPhase+h*2.4)*b*0.16;
transformed.z += cos(uTime*0.62+aPhase*1.7+h*1.9)*b*0.11;
}`);
};
}
```
**Anemone tentacle material similar (amp 0.13, freq higher).**
**Check: kelp blade geometry has y 0..1 after translate ✓ (PlaneGeometry(0.16,1) centered → y -0.5..0.5 → translate +0.5 → 0..1 ✓).**
**Sand receiveShadow + caustics ✓.**
**Fill light no shadow ✓.**
**renderer.setClearColor irrelevant (background set).**
**iOS pixel ratio 3 capped 1.75 ✓.**
**One concern — `hemi` intensity with r170 physical lights: HemisphereLight intensity 0.85 reasonable ✓.**
**InstancedMesh with count 0 species? none.**
**Food shader — Points with dynamic draw range ✓ but `position` attribute with DynamicDrawUsage ✓.**
**Food color attr: f.c Color — write rgb each frame (static per food) ✓ could precompute but fine.**
**Ripple meshes:**
```js
const rippleMat base = ShaderMaterial with uniforms {uTime, uK}
const ripples=[]; for(i<4){ m=new THREE.Mesh(ringGeo2? PlaneGeometry(3,3), mat clone); m.visible=false; scene.add(m); ripples.push({m,age:1}); }
function spawnRipple(x,y,z){ const r=ripples.find(r=>r.age>=1)||ripples[0]; r.age=0; r.m.position.set(x,y,z); r.m.visible=true; }
update: for r: if(r.age<1){ r.age+=dt/0.9; r.m.material.uniforms.uK.value=r.age; r.m.quaternion.copy(camera.quaternion); if(r.age>=1) r.m.visible=false; }
frag: float k=uK; float rr=0.10+0.85*k; float ring=1.0-smoothstep(0.0,0.10,abs(r-rr)); a=ring*(1.0-k)*0.55; color cyan-white.
```
Where r=length(q)*2 as before ✓ plane 3.4.
Also scale plane slightly? radius grows via rr in shader ✓ constant mesh size ✓.
**eatPulse(p):** spawnRipple at food pos with smaller? reuse same ✓ nice feedback when fish eats ✓.
**Hmm — ripple at eat point might spam with many eats — pool cycles, fine.**
**Now, write the species-specific finColor with precreated colors:**
```js
const FINCOL={
clownBase:new THREE.Color(0xffa53c), clownDark:new THREE.Color(0x1f2126), clownWhite:new THREE.Color(0xf6f4ea),
tetraA:new THREE.Color(0xbfcdd2), tetraB:new THREE.Color(0x7f95a0),
tangA:new THREE.Color(0xffe25e), tangB:new THREE.Color(0xfff3b8),
angelA:new THREE.Color(0x525f6e), angelB:new THREE.Color(0x10141a),
};
```
finColor fns:
```js
clown: (part)=> (c,pr)=>{
if(part==='tail'){ c.copy(FINCOL.clownWhite).lerp(FINCOL.clownDark, sstep(0.55,1,pr)*0.85); }
else if(part==='pect'){ c.copy(FINCOL.clownBase).lerp(FINCOL.clownDark, sstep(0.5,1,pr)*0.5); }
else { c.copy(FINCOL.clownBase).lerp(FINCOL.clownDark, sstep(0.5,1,pr)*0.7); }
}
```
**Streamers for angel part 'streamer' → use default else-branch ✓.**
**Now compile risks review — GLSL numeric literals:** all floats with decimals ✓ (write carefully: `0.13*fk` etc).
In CAUSTIC_GLSL: `t*(0.9+0.13*fk)` ✓ floats.
In fish wag injection: `(0.016 + 0.045 * aTail.y) * 2.4` ✓.
**In injectCaustics fragment, uses vCauP declared ✓, CAUSTIC_GLSL inserted after common ✓ — but `#include <common>` appears once in fragment? Standard material fragment has one `#include <common>` ✓.**
**Kelp/anemone injection on vertex — `#include <common>` in vertex exists ✓.**
**Wait — injectCaustics replaces `#include <begin_vertex>` in vertex — but sand material also standard: begin_vertex exists ✓.**
**Now: mergeGeometries import name check** — `BufferGeometryUtils.js` exports `mergeGeometries` (r170 ✓).
**OrbitControls constructor(camera, domElement) ✓.**
**Also `controls.update()` each frame with damping ✓. autoRotate needs update ✓.**
**controls.listenToKeyEvents? no.**
**Check: `THREE.DynamicDrawUsage` constant ✓.**
**InstancedBufferAttribute import from three ✓.**
**Food: `foodGeo.setDrawRange(0,n)` — with indexed? Points non-indexed ✓ drawRange counts vertices ✓.**
**Points materials & `sizeAttenuation` N/A (custom).**
**gl_PointSize with perspective divide — using -mvPosition.z ✓.**
**Now, the vertical bounds for fish y: soft 2.0..9.6 — but anemone at y~1.5: clowns pulled to home.y = sandH+1.5 ~ 1.6..2.0 — soft lower bound 2.0 fights home pull near bottom... home y set = baseH+1.6 ≈ 2.1 ✓ above soft bound ✓ OK.**
Actually sandHeight at (-8.3,-5.5): compute roughly: 0.55*sin(-1.245+1.7)*cos(-1.265-0.6)=0.55*sin(0.455)*cos(-1.865)=0.55*0.439*0.29? cos(-1.865)=-0.29 → -0.07; +0.28*sin(-8.3*0.43 + -5.5*0.31+0.5)=sin(-3.569-1.705+0.5)=sin(-4.774)=0.998? sin(-4.774)=sin(-4.774+2π=1.509)=0.998 → 0.28; +0.22*sin(-5.5*0.55 - (-8.3)*(-0.13)+2.1)=sin(-3.025-1.079+2.1)=sin(-2.004)=-0.909→-0.2; +back term 0.4*sstep(-4,-13,-5.5)=0.4*0.26? (x+4)/9 → (-5.5+4)/(-9)=0.167→sstep 0.0655*0.4=0.026. Total ≈ -0.07+0.28-0.2+0.03 ≈ 0.04. Around 0 ✓ plausible. home y = 0.04+1.7 ≈ 1.74. Clowns hover ~1.7-2.5 ✓ above soft bound 2.0?? Conflict — lower soft bound pushes up to 2.0, home pulls to 1.74 → equilibrium ~2 ✓ fine hovering above anemone ✓ good.
Set soft lower y bound 1.7 to be safe (anemone tips ~1.6+): fish y≥1.7 ✓.
**obstacles include anemones?** fish could swim through anemone — add obstacles at anemone centers r 0.9 ✓ prevents clipping through tentacles mostly.
**Tang prefY 2.5 → they swim mid-low; kelp tops at 5 → tangs may clip kelp blades — acceptable (kelp at edges).
**Alright, the legend/brand/hint text, meta, done.**
**Let me also think whether `mergeGeometries` needs `useGroups=false` param: default false ✓ single material.**
**One more check — `paintProg` on ShapeGeometry: iterates position attr ✓ exists.
Define:
```js
function paintFin(g, progFn, colorFn){
const p=g.attributes.position; const arr=new Float32Array(p.count*3); const c=new THREE.Color();
for(let i=0;i<p.count;i++){
const pr=progFn(p.getX(i),p.getY(i),p.getZ(i));
colorFn(c, clamp(pr,0,1));
arr[i*3]=c.r;arr[i*3+1]=c.g;arr[i*3+2]=c.b;
}
g.setAttribute('color', new THREE.BufferAttribute(arr,3));
}
```
**Now — assemble ALL of it.** Also I must double check the fin `finAbs` shapes produce sane polygons (non-self-intersecting):
Caudal points: (0,0.05) → (0.8len, spread) → (0.55len, fork*spread+0.015) → (0.55len, -fork*spread-0.015) → (0.8len, -spread) → (0,-0.05), close. For fork 0.18, spread 0.21: pts: (0,0.05),(0.21,0.21),(0.14,0.053),(0.14,-0.053),(0.21,-0.21),(0,-0.05). Polygon: from top base to top tip, in to notch (0.053), jump across to -0.053 (vertical line at x=0.14 — that's the notch ✓), out to bottom tip, back to bottom base ✓ simple polygon ✓.
For fork 0.5 tetra: notch at ±(0.5*0.13+0.015)=±0.08 vs spread 0.13 tips at x 0.136 ✓ notch depth ok ✓.
Dorsal polygon: pts: (0,b0), (0.3u, b0+0.92h)... using bottom[k] values: bottom edge y values decrease toward rear (body tapers) ✓ shape valid (top edge above bottom edge) ✓.
For angel dorsal: t0=0.56, len=0.30 → bottom rear at t=0.26: topAt(0.26): prof(0.26)=sin(π*0.26^1.15): 0.26^1.15≈0.218; sin(0.685)=0.633*H(0.40)*1.16=0.294. Front bottom: topAt(0.56)=prof(0.56): 0.56^1.15=0.513; sin(1.612)=0.999 → 0.464. So bottom from 0.444 to 0.274, tip height 0.92*0.40=0.368 above → up to 0.81 ✓ tall sail ✓.
Ventral mirror ✓.
**Angel width 0.075 — fins in x=0 plane ✓; pectorals at x0=0.93*0.075*0.85=0.06 tiny ✓ ok.
**Now think about draw order issue: god rays additive + fog:false ✓; but they render before/after water surface (opaque) — depthTest true → occluded correctly ✓.
**Cursor ring renders among transparent sorted by distance ✓ depthTest true ✓.**
**Ripples additive ✓.**
**Bubble/snow/food depthTest true, depthWrite false ✓.**
**Now write the food shader with fog:**
vertex:
```glsl
uniform float uPR;
attribute float aSize; attribute float aFade; attribute vec3 aColor;
varying vec3 vC; varying float vF;
#include <fog_pars_vertex>
void main(){
vC=aColor; vF=aFade;
vec4 mvPosition = modelViewMatrix*vec4(position,1.0);
gl_PointSize = max(aSize*uPR*(150.0/max(1.0,-mvPosition.z)), 2.0);
gl_Position = projectionMatrix*mvPosition;
#include <fog_vertex>
}
```
fragment:
```glsl
varying vec3 vC; varying float vF;
#include <fog_pars_fragment>
void main(){
vec2 q=gl_PointCoord-0.5;
float d=length(q)*2.0;
float a=(1.0-smoothstep(0.55,0.95,d))*vF;
gl_FragColor=vec4(vC,a);
#include <fog_fragment>
#include <colorspace_fragment>
}
```
Note: `#include <fog_pars_vertex>` — in ShaderMaterial, must the include be on its own line? The parser regex: `/^[ \t]*#include +<([\w\d/]+)>/gm` — requires the include at line start (with optional whitespace) — my includes are on their own lines ✓. But careful in template literals with leading spaces — fine (whitespace allowed).
**Important: chunk include parsing of `#include <...>` in ShaderMaterial — supported for any material ✓ (resolveIncludes applied to all).**
**Now the autoRotate initial: enabled — but pointermove constantly fires when user moves → lastInteract updated → autoRotate stays on until first interaction... my logic: autoRotate = (elapsed - lastInteract > 16)? At load lastInteract = -16 → autoRotate true immediately? Set lastInteract = -20 so initially autoRotate true; when user moves pointer → lastInteract=elapsed → autoRotate=false (stops as soon as they engage ✓). Then after 16s idle resumes ✓. Initial value: lastInteract=-999 → true ✓.
Hmm wait — at page load, user's mouse might already be over canvas without moving — autoRotate keeps drifting until first move ✓ good.
**Screenshot ~3s: camera drifted slightly (autoRotate 0.22 → 0.22*? OrbitControls autoRotate rotates 2π*speed*delta/... autoRotateSpeed 0.22 → 0.22*2π/60 per second? Speed 2.0 = 30s/orbit... 0.22 → ~273s/orbit — barely moves ✓ subtle. Maybe a bit more: 0.35 → 171s/orbit ✓ gentle drift visible over 10s. OK 0.3.
**Fish spawn positions recap:**
```js
if(def.key==='tetra'){ center (2.5,5.6,0.5) + rand sphere 2.5 }
clown: home + rand sphere 2.2 (y 0.5..2.5)
tang: rand x[-12,12], y[2,4], z[-8,8]
angel: rand x[-10,10], y[5,8.5], z[-7,7]
```
**vel init:** random horizontal-ish direction * cruise*0.8.
**OK — also handle speciesList structure:**
```js
const speciesList=[];
function addSpecies(def){
... build ...
speciesList.push({def, mesh, tailAttr});
for i<count: fishes.push(f)
}
```
**Status & counts: after build, legend counts hardcoded match.**
**Potential perf: pair loop 67*66/2=2211 with sqrt — fine.**
**Fish mesh `castShadow` — 4 instanced meshes ✓.**
**Let me now also verify the flee works when camera orbits: raycaster from camera ✓.**
**And "the fish swim away when I move the mouse near them" — the core ask — implemented robustly ✓ plus discoverable via cursor ring.**
**Edge: pointer over UI text — pointer-events none ✓ canvas receives ✓.
**Write `sstep` used in JS bodyColor — defined ✓.
**One thing: `bandf` uses sstep ✓.**
**angelD color band at t 0.84 — near eye t 0.88 → band behind eye ✓ angelfish look ✓.
**Tetra blue stripe: y (0..1) — v y param passed as (y+1)/2 ✓ where y is sphere coord — but after deformation the mapping distorts; roughly vertical gradient ✓ ok.
**Now write everything into the final file.** I'll aim for clean, organized code with section comments. Let me write it now, carefully, keeping all identifiers consistent.
Also — sandbox: the iframe might not allow pointer? It does. OK.
One more consideration: the task says "Load Three.js only through this import map" — I must include exactly that import map script tag ✓ and use module imports ✓ no other CDN ✓.
Let me also double check that I don't accidentally use `three/addons/` paths beyond the two imports (OrbitControls, BufferGeometryUtils) ✓ both under the addons mapping ✓.
**Potential runtime error candidates to triple-check while writing:**
1. `sandGeo.attributes.position` after rotateX — setY on XZ-plane: local coordinate y is the height ✓ (after rotation, attribute positions already rotated: plane originally XY; after rotateX(-π/2) positions become (x, 0, -y')... hmm: original plane vertex (x, y, 0) → rotated (x, y·cos−z·sin, y·sin+z·cos) θ=-π/2 → (x, y·0 - 0·(-1), y·(-1)+0) = (x, 0, -y) ✓ so positions have y=0, z=-origY. So after rotation: setY(i, sandHeight(getX, getZ)) ✓ correct.
2. `mergeGeometries(parts...)` — parts include geometries with `color` attr ✓ and uv ✓ normals ✓; ShapeGeometry has position, normal, uv ✓; Sphere ✓; all toNonIndexed'd consistently? Mixed indexed/non → I convert per-part: `g = g.index ? g.toNonIndexed() : g` — ShapeGeometry non-indexed ✓ Sphere indexed → converted ✓.
3. `paintFin` before toNonIndexed → color carried ✓ (toNonIndexed copies all attributes ✓).
4. Kelp instance matrix compose with quaternion from euler ✓.
5. Anemone tentacle quaternion: `qt.setFromUnitVectors(up, dir)` ✓.
6. `mesh.setColorAt` before add? anytime before render ✓.
7. ring material uniform uOp update ✓.
8. `renderer.render` in loop ✓.
9. No use of removed APIs (e.g., `outputEncoding`) ✓.
10. `THREE.SRGBColorSpace` default ✓ not touched.
11. `PlaneGeometry` attribute `uv` exists ✓ used by rays (vUv) ✓ and ring: PlaneGeometry uv ✓.
12. Ring shader `#include <colorspace_fragment>` at end — for ShaderMaterial, `linearToOutputTexel` defined automatically? WebGLProgram adds it via getTexelEncodingFunction... In recent three versions, `linearToOutputTexel` is included in the fragment shader prolog for ALL materials (defined in `colorspace_pars_fragment`?) Hmm — actually in r152+: the function `linearToOutputTexel` is generated in WebGLProgram `_getTexelEncodingFunction` and prepended to fragment shader for every program including ShaderMaterial? Let me recall WebGLProgram code: for non-RawShaderMaterial, three prepends a standard prolog: includes `getTexelEncodingFunction('linearToOutputTexel', parameters.outputColorSpace)` — yes, this is part of the standard prefix added to ALL non-raw shader materials, including ShaderMaterial ✓. So `#include <colorspace_fragment>` works in ShaderMaterial ✓ (commonly done in three examples ✓).
13. `#include <fog_fragment>` in ShaderMaterial with fog:true ✓ standard example-based pattern ✓.
14. InstancedMesh + vertexColors + instanceColor: shader `#ifdef USE_INSTANCING_COLOR vColor.xyz *= instanceColor.xyz;` — requires USE_COLOR... vColor varying declared when USE_COLOR or USE_INSTANCING_COLOR? chunk `color_pars_vertex`: `#if defined( USE_COLOR_ALPHA ) ... #elif defined( USE_COLOR ) varying vec3 vColor;` — hmm! If material.vertexColors=true → USE_COLOR ✓ vColor declared ✓ and instancing color chunk `#ifdef USE_INSTANCING_COLOR vColor.xyz *= instanceColor.xyz;` inside color_vertex ✓ works when both ✓. Good — my fish/kelp/anemone materials all have vertexColors:true ✓.
15. `instanceColor` needsUpdate after setColorAt in loop ✓.
16. Shadow map with `sun.target` added to scene ✓ (target must be in scene for matrix update ✓).
17. Points: `aColor` attribute name — I use custom 'aColor' to avoid colliding with built-in 'color' ✓.
18. In food vertex shader I declared `attribute vec3 aColor;` — ShaderMaterial auto-declares built-ins (position, normal, uv) but custom attributes must be declared ✓ done.
19. In bubbles: attributes declared ✓.
20. Ring/ripple ShaderMaterials: no custom attributes ✓.
21. GLSL: no `#version` lines ✓ WebGL1/2 compatible? three compiles GLSL3 for WebGL2 with automatic conversions — ShaderMaterial default GLSL1 syntax gets converted? three uses `#version 300 es` and defines to map attribute/varying → in/out ✓ handled ✓.
22. `mod()` with negative input: GLSL mod(x,y) = x - y*floor(x/y) → always positive for positive y ✓ (bubble h positive ✓; snow uses mod of negative base — result positive ✓).
23. JS `Math.hypot` ✓.
24. The wag shader: `transformed.x += sin(aTail.x + transformed.z * 4.3) * env * (0.016 + 0.045 * aTail.y) * 2.4;` — aTail.y up to ~1.3 ✓.
25. `injectWag` — attribute `aTail` vec2 declared ✓ geometry has it ✓.
26. Fish `frustumCulled=false` ✓ else disappearing.
27. Kelp/anemone/tentacle `frustumCulled=false` ✓.
28. Points frustumCulled=false ✓.
29. **Water surface & sand overlap at horizon?** sand extends 120×84 (z ±42) — surface 130×90 — camera sees sand to z=-42 where fog 0.028*58≈ factor 1-exp(-(1.62)^2)=0.99... wait distance from camera z16 to -42 = 58 units: (58*0.028)=1.62²=2.63 → 93% fogged ✓ blends into dome bottom which matches fog color ✓ seamless ✓.
30. Kelp at z=-10.2 with fog: distance ~26 → (0.73)²=0.53 → 41% fogged — kelp visible but hazy ✓ moody.
Maybe reduce fog density to 0.024 for clearer fish: at 26 → (0.62)²=0.39→32% ✓. Set 0.024.
Then dome bottom match still fine.
31. Caustic brightness on sand with strength 0.65: sand luminance ~0.5 → bright ridges up to 1.65× under light 2.4/π·cos... could clip white — visually "sunlight webbing" ✓ fine.
32. Anemone & kelp Standard material with DoubleSide for kelp ✓; anemone tentacles FrontSide ok (cylinders closed) ✓.
33. **Controls target y 4.4** and camera min distance 7 — zoom in close: near plane 0.1 ✓ fish near camera could clip through near plane — fine.
34. PixelRatio uniform uPR for points: gl_PointSize in device px ✓ multiply by PR ✓.
35. **Ensure the canvas isn't taller than viewport (1280×800):** full viewport ✓ no scrollbars (overflow hidden) ✓.
36. `#hint` might overlap legend on ~900px widths: hint centered bottom, legend right bottom — 1280 wide fine; at 800 wide could collide: add media query at ≤900px: move legend? Simply hide legend below 900px? Legend adds charm at 1280 ✓ hidden on small ✓. Use 900px threshold.
37. Status text: fine.
38. The brand may overlay surface visuals — fine.
Now, also the **initial "wow"**: fish + caustics + rays + bubbles + swaying kelp + drifting snow + title — ✓ rich.
**Auto camera drift + fish = alive at 3s ✓.**
I think the plan is solid. Let me also decide: include one more subtle behavior — occasional fish "idle hover" — no, speed min ensures motion.
**Write final code now.** I'll write ~850 lines carefully.
Additional detail — the `wrapPI` function:
```js
const wrapPI=(a)=>{a=a%(Math.PI*2); if(a>Math.PI)a-=Math.PI*2; if(a<-Math.PI)a+=Math.PI*2; return a;};
```
**Sprinkle function bounds:** foods spawn y 10.9..11.4 (below surface 11.8 ✓).
**Fish eat radius:** `0.30 + f.def.len*0.35` → tetra: 0.30+0.18=0.48 ✓ angel: 0.30+0.40=0.70 ✓.
**Fish-food attraction radius 9.5 — big; when food resting, distant fish come from across tank ✓ dramatic.**
Also cap: attract only if foods exist ✓ loop.
**Excite from food:** +0.3 → brief wag burst ✓.
**Ripple on eat ✓.**
Let me also add slight **fish depth-based color fade?** fog handles ✓.
**Now writing the vertex shader for ring:**
```glsl
varying vec2 vUv;
void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }
```
**Ripple shader vertex same.**
**Ripple frag:**
```glsl
uniform float uK; varying vec2 vUv;
void main(){
vec2 q=vUv-0.5; float r=length(q)*2.0;
float rr=0.12+0.88*uK;
float ring=1.0-smoothstep(0.0,0.12,abs(r-rr));
float a=ring*(1.0-uK)*0.5;
gl_FragColor=vec4(vec3(0.62,0.94,1.0),a);
#include <colorspace_fragment>
}
```
Additive blending ✓.
**Cursor ring frag as planned with uTime pulse.**
Alright — also **prevent OrbitControls from hijacking click? OrbitControls doesn't preventDefault pointerup clicks; our feed logic on pointerup ✓.**
**Touch: tap → pointerdown+pointerup quickly ✓ feed ✓.**
**Let me now also double check `mergeGeometries` returns geometry with groups? useGroups false → no groups ✓ single material ✓.**
**Bounding sphere compute ✓ (needed for shadow culling? frustumCulled false on fish; shadow camera includes regardless ✓).**
Hmm — actually for the SHADOW pass, three culls objects against shadow camera using frustumCulled too — I set false → always rendered in shadow pass ✓ good.
**Sand boundingSphere huge fine.**
**One more possible visual issue: fish DoubleSide + shadow → shadowSide default: material.shadowSide null → uses side... For DoubleSide materials three renders shadows with... default shadowSide: back faces? When side=DoubleSide, shadow rendered double-sided ✓ fine.
**Fish emissive slight?** Give fish material a touch of emissive to keep colors readable in shade: emissive 0x0a1420 intensity? MeshStandardMaterial emissive default black ✓ skip; hemi light handles.
**I realize I should double check `SphereGeometry(1,24,16)` deformation: vertices at poles (y=±1, x=z=0): t computed from z — pole at z any? Poles are at y=±1 with x,z=0 → t=(0+1)/2=0.5 ✓ fine ring.
**Tail region: sphere z=-1 (t=0) → prof 0 → collapsed to a point at z=-0.42 ✓ nose point at t=1 z=0.58 ✓.**
**Check topAt(t)=prof*H*1.16: but actual max y = y*prof*H*sy where sy=1.16 at y=1 ✓ matches ✓.**
**Ventral fin bottom edge uses botAt(t)+0.02 (embed): botAt = -prof*H*0.95 ✓ (sy at y=-1 is 0.95 ✓ matches formula 0.95+0.21*sstep(0,0.8,-1)=0.95 ✓).
**Pectoral y0 = -0.02 - H*0.25?** place pectoral at lower-mid body: y0 = -(prof(0.66)*H*0.3) ≈ small negative ✓ e.g., H=0.2: -0.056. Use y0=-prof*H*0.35.
**Eye y = H*0.14: for tang H 0.33 → 0.046 ✓.
Everything set. Also add **fish slight yaw offset noise per fish** so school isn't perfectly parallel? Alignment handles.
**Let me also reconsider `sepR=(lenA+lenB)*0.85`: tetra+tetra: (1.04)*0.85=0.88 — tetra length 0.52 — separation distance ~0.9 units — a bit sparse for a school; use *0.62: 0.64 ✓ tighter. For tang pairs (2.1*0.62=1.3) ✓. OK 0.62.
Also school cohesion min(1,dC*0.5)*coh where dC distance to centroid — tetra coh 1.4 ✓.
**neighR tetra 2.5 with 40 fish spawned in radius 2.5 ball → dense interactions ✓ schooling emerges.
Hmm, 40 fish × 40 pair = 780 pair checks + cross species... total pairs 2211 ✓ fine.
**Speed check tetra: cruise 1.9, maxS 4.6 → effort at cruise 0.41 ✓.
**Now — a nagging concern about `f.mesh.userData.tail` — simpler: store tailAttr on fish: f.tailAttr. ✓
Also `speciesList` upload loops ✓.
**One more: initial `controls.update()` requires camera looking at target ✓ set.
**devicePixelRatio in module — `devicePixelRatio` global ✓.
**Let me write the HTML now, top to bottom, and be meticulous.**
Also remember rule: "Reply with exactly ONE fenced code block" — everything inside one ```html block, no text outside.
Let me carefully now also design **fin shapes for tang** — tall sail dorsal from t0 0.42 len 0.34 h 0.30: body top at 0.42: prof(0.42): 0.42^1.15=0.378; sin(1.187)=0.927 → top 0.927*0.33*1.16=0.355; fin tip 0.355-0.03+0.92*0.30=0.60 ✓ plus body height → total ~0.95 vs len 1.05 ✓ tang silhouette ✓.
**And clownfish dorsal small ✓.**
**Time to write.** I'll include concise comments. Watch out: total file large (~35-40KB) — fine.
Also let me predefine `up` vector for anemone: `const UP=new THREE.Vector3(0,1,0);`
Anemone tentacle placement:
```js
function buildAnemone(x,z,s){
const baseY=sandHeight(x,z);
const grp=new THREE.Group(); grp.position.set(x,baseY,z); scene.add(grp);
// base
const bm=new THREE.Mesh(anemBaseGeo, anemBaseMat);
bm.scale.set(1.15*s,0.62*s,1.15*s); bm.position.y=0.12*s; bm.castShadow=true; grp.add(bm);
const n=Math.round(26*s);
const im=new THREE.InstancedMesh(tentGeo,tentMat,n); im.frustumCulled=false;
for(i<n){
const th=rand(TAU); const yy=rand(0.2,0.95);
const rr=Math.sqrt(1-yy*yy);
dir.set(Math.cos(th)*rr, yy, Math.sin(th)*rr);
qt.setFromUnitVectors(UP,dir);
v1.copy(dir).multiply(vec(0.55*s, 0.34*s, 0.55*s)); // approx surface (x,z scaled 1.15*0.5, y 0.62*0.5) plus base offset y 0.12*s
v1.y+=0.12*s;
v2.set(1, rand(0.5,0.95)*s, 1);
mtx.compose(v1,qt,v2);
im.setMatrixAt(i,mtx);
im.setColorAt(i, kc.setRGB(rand(0.85,1.15),rand(0.85,1.15),rand(0.85,1.15)));
}
im.instanceColor && (im.instanceColor.needsUpdate=true);
scene.add(im); // add to scene, not group (positions already world? compose used world coords? v1 computed from grp origin — need world: add absolute x,z,y).
```
Hmm — compose positions must be world-space if im added to scene. v1 currently relative to group. Compute world: v1.add(grp.position)? But quaternion orientation independent ✓. Simpler: add im as child of grp — then local coords ✓ matrices local ✓. InstancedMesh as child works ✓. Do that.
obstacles.push({x, y: baseY+0.6*s, z, r: 0.9*s});
Homes.push({x, y: baseY+1.7*s, z}).
anemBaseGeo: SphereGeometry(0.5,14,10) with vertex colors gradient (y -0.5..0.5 → t 0..1): base color 0x7a2b52 → 0xd16a9c.
**tentGeo colors gradient as planned.**
**tentMat:** MeshStandardMaterial({vertexColors:true, roughness:0.55}) + injectSwayAnemone ✓ DoubleSide? cylinder closed → FrontSide fine.
**anemBaseMat:** MeshStandardMaterial({vertexColors:true, roughness:0.8}).
**Legend maybe also add anemone line? keep fish only.**
Now **spawn foods auto at (rand)**: sprinkle(clamp(rand(-6,6)), clamp(rand(-6,6))... choose (rand(-5,5), rand(-5,3)) center-visible ✓.
**Ripples pool:**
```js
const ripplePool=[];
for(let i=0;i<4;i++){
const m=new THREE.Mesh(new THREE.PlaneGeometry(3.4,3.4), new THREE.ShaderMaterial({uniforms:{uK:{value:1}}, vertexShader:RIP_VERT, fragmentShader:RIP_FRAG, transparent:true, depthWrite:false, blending:THREE.AdditiveBlending}));
m.visible=false; m.frustumCulled=false; scene.add(m);
ripplePool.push({m, age:1});
}
```
Each has own material instance (uniform uK per ripple) ✓.
spawnRipple(x,y,z): find age>=1 else oldest; set.
update: quaternion copy camera each frame while visible ✓.
**Cursor ring geometry PlaneGeometry(3.2,3.2).**
**Final check on imports usage** — OrbitControls used ✓, mergeGeometries ✓.
**Now write everything.** During writing, maintain consistent variable names. Let me produce the final file.
Ah — one more: **`hint` wording** mentions drag; on touch "drag to look around" ✓ fine.
**Meta theme-color?** minor, add.
Also `user-select:none` on body to avoid text selection flashes; pointer-events none on UI anyway.
**Kelp `receiveShadow=true` requires material lights ✓ standard ✓.
**Let me finalize the caustic strength on sand: 0.6, scale 0.85.**
Also apply caustics FAINTLY to kelp? skip.
**Density of everything vs 1280×800 screenshot — good.**
Potential last bug: `injectCaustics` on sand — sand material also receives shadows; diffuseColor multiplication happens before lighting ✓ caustics unaffected by shadow (they'll show in shadowed zones too — physically wrong but subtle; acceptable... could multiply by light... skip).
Also — the caustics on sand near camera front (z 5-9): visible ✓ nice.
Writing the code now. I'll write `const V3=THREE.Vector3` shortcuts? Keep explicit but use short temp names.
One more decision: `species tail len 0.52 tetra` — school of 40 tiny fish: at distance they're small (~0.5 units vs 20 view distance) — maybe slightly bigger: len 0.6. And count 36? Keep 40 & len 0.58.
Angel len 1.15, tang 1.05, clown 0.85 ✓ variety.
Also **maxAzimuth ±1.15** might feel restrictive but keeps composition sane ✓ plus autoRotate would hit the limit and stick... autoRotate rotates azimuth until clamp → camera pushed against 1.15 rad and stays — bad! autoRotate + azimuth limits: OrbitControls autoRotate keeps adding → clamped → sticks at limit. Fix: disable autoRotate once near limit? Or use a custom gentle drift: instead of OrbitControls.autoRotate, I'll implement my own idle drift: slowly oscillate azimuth via sin: each frame if idle: `controls.setAutoZoom? ` simplest: set controls.autoRotate=true with small speed, and when |azimuth| > 0.9 flip sign of autoRotateSpeed ✓ ping-pong:
```js
if(Math.abs(controls.getAzimuthalAngle())>0.95) rotDir*=-1, controls.autoRotateSpeed=0.28*rotDir;
```
✓ elegant.
getAzimuthalAngle() exists ✓.
Also user might leave it at extreme — drift resumes ping-pong ✓.
**Let me also double-check `controls.getAzimuthalAngle` in r170 ✓ (OrbitControls has getAzimuthalAngle/getPolarAngle).
Now, finally — WRITE IT.
Let me carefully draft the entire script in my head as final text... I'll write it directly in the answer, being careful. A few more mini-decisions while writing:
- fish geometry `def.bodyColor` signature (c,t,y).
- Use function declarations hoisted.
For the `finRel` shape function — pectoral pts relative: [[0,0.03],[0.17,-0.05],[0.13,0.07]] hmm y rel small. Attach y0 computed. Shape coords absolute y = y0+dy ✓.
Pectoral extra rotate: for side=1 (right): rot=-0.55; side=-1: rot=+0.55. Then translate (side*x0, 0, z0). Wait translate y must be 0 since abs y baked — pass pos with y component used only for shape; translate(pos.x, 0, pos.z) ✓ implement carefully:
```js
function finRel(pts, px, py, pz, rot, colorFn, maxD){
const sh=new THREE.Shape();
sh.moveTo(pts[0][0], py+pts[0][1]);
for(let i=1;i<pts.length;i++) sh.lineTo(pts[i][0], py+pts[i][1]);
const g=new THREE.ShapeGeometry(sh,4);
g.rotateY(Math.PI/2);
if(rot) g.rotateY(rot);
g.translate(px,0,pz);
// paint by distance from attach
const p=g.attributes.position, arr=new Float32Array(p.count*3), c=new THREE.Color();
for(let i=0;i<p.count;i++){
const dx=p.getX(i)-px, dy=p.getY(i)-py, dz=p.getZ(i)-pz;
colorFn(c, clamp(Math.sqrt(dx*dx+dy*dy+dz*dz)/maxD,0,1));
arr[i*3]=c.r;arr[i*3+1]=c.g;arr[i*3+2]=c.b;
}
g.setAttribute('color',new THREE.BufferAttribute(arr,3));
return g;
}
```
And `finAbs(pts, z0)`:
```js
function finAbs(pts, z0){
const sh=new THREE.Shape();
sh.moveTo(pts[0][0], pts[0][1]);
for(let i=1;i<pts.length;i++) sh.lineTo(pts[i][0], pts[i][1]);
const g=new THREE.ShapeGeometry(sh,4);
g.rotateY(Math.PI/2);
g.translate(0,0,z0);
return g;
}
```
Then paint separately per part with custom prog functions.
Tail paint: prog = (z0 - z)/tf.len → but after translate z = z0-u ✓ prog = (z0 - p.z)/len... wait z0 - (z0-u) = u ✓ prog=u/len ✓.
Dorsal paint: prog = (p.y - yb)/h where yb = topAt(df.t0)-0.05.
OK.
**Angel streamers via finRel with pts [[0,0],[0.08,-0.05],[0.46,-0.42],[0.50,-0.37],[0.10,-0.02]], py = botAt(0.52)+0.05, pz = 0.52-0.42=0.10, px=±0.02, rot 0, maxD 0.5.**
Hmm botAt(0.52) = -prof(0.52)*H*0.95 = -0.44*0.4=-0.176*... prof(0.52): 0.52^1.15=0.476; sin(1.495)=0.997 → -0.997*0.40*0.95 = -0.379. py = -0.379+0.05 = -0.33 ✓ streamer hangs from belly to -0.33-0.42 = -0.75 ✓ below ventral fin bottom? ventral t0 0.50 h 0.44: bottom tip = botAt(0.5)+? ventral extends down to botAt - h*? For ventral: tips at botAt(mid) - 0.92*h ≈ -0.38-0.40=-0.78 — similar ✓ visually merged, fine.
Streamers slightly forward z 0.10 vs ventral base z 0.08 — overlapping ✓ fine.
**Ventral fin shape (mirror of dorsal):**
```js
{
const z0=vf.t0-0.42;
const h=vf.h;
const ns=4, bottom=[];
for(k=0..ns){ u=vf.len*k/ns; bottom.push([u, botAt(clamp(vf.t0-u,0.02,0.98))+0.02]); }
pts=[ [0,bottom[0][1]],
[vf.len*0.30, bottom[0][1]-h*0.92],
[vf.len*0.72, bottom[Math.round(ns*0.72)][1]-h*0.80],
[vf.len, bottom[ns][1]-h*0.30],
...bottom reversed ];
g=finAbs(pts,z0);
prog: (vb - p.y)/h with vb = botAt(vf.t0)+0.05
}
```
**Dorsal/ventral paint colorFn part 'dorsal'/'ventral' ✓.**
**Check `bottom[Math.round(ns*0.72)]` index: ns=4 → 2.88→3 ✓ within 0..4 ✓.**
Now species build loop uses these; all good.
Let me also ensure fish don't intersect the sand visibly at bounds y≥1.7 — sand max ~0.9 + fish half height ~0.3 ✓ fine.
Also **tang prefY 2.5 vs rocks at y~1-2 — tangs may brush rocks; obstacle force handles ✓.
**Compose final answer now.** Also include a brief header comment in HTML? Not needed but a title comment fine.
While writing the big template literals, avoid the sequence `</script>` — none.
Let me also handle **`window.addEventListener('blur')` → pointerActive=false** to avoid stuck cursor ✓ minor.
Also **`document.addEventListener('visibilitychange')`** — clock delta clamp handles ✓ skip.
Alright — final code. I'll write methodically:
Section order in script:
1. imports, helpers, constants
2. renderer, scene, camera, controls, lights
3. GLSL strings (CAUSTIC)
4. inject helpers
5. dome, sand, rocks, kelp, anemones(+obstacles/homes)
6. god rays, bubbles, snow
7. fish species defs + geometry builder + spawn
8. food system
9. cursor ring + ripples
10. input handlers
11. main loop
12. resize
13. UI status
Let me write the loop's per-frame sequence:
```js
function tick(){
requestAnimationFrame(tick);
const dt=Math.min(clock.getDelta(),0.033);
elapsed+=dt; timeU.value=elapsed;
// idle drift
if(elapsed-lastInteract>16){ controls.autoRotate=true; if(Math.abs(controls.getAzimuthalAngle())>0.95){rotDir*=-1;} controls.autoRotateSpeed=0.26*rotDir; }
else controls.autoRotate=false;
controls.update();
camera.position.y=clamp(camera.position.y,1.5,10.6);
updateFood(dt);
updateFish(dt);
updateFX(dt);
renderer.render(scene,camera);
}
```
Wait — autoRotate sign flip each frame when beyond 0.95 → flips repeatedly (only when beyond, sign flips → comes back; once back inside no flip ✓ stable-ish: at >0.95 flip → speed negative → recedes ✓ fine).
updateFX: cursor ring, ripples, status, auto-feed.
Auto-feed: `if(elapsed>nextAuto){ sprinkle(rand(-5,5), rand(-4,3), 6+((Math.random()*4)|0)); nextAuto=elapsed+rand(26,38); }` — nextAuto initial 1.4.
**Status:**
```js
let exciteSum computed in updateFish → window var avgE.
in updateFX: setStatus(avgE).
```
**updateFish writes instance matrices; also cursor flee needs raycaster — do inside updateFish ✓.**
Also compute `avgE` there.
Let me now also handle: **`pointerActive` true even when pointer over UI (fine) and after leaving canvas region → pointerout sets false ✓.
Also when the mouse hasn't moved for a while but stays still — fish calm again ✓ (excite decays; no lasting flee since force depends on distance, still cursor still repels nearby fish — that's actually desired: fish keep distance from resting cursor ✓ realistic "hand in tank").
OK writing now. Also — minor: initial `nextAuto=1.4` sprinkle happens at 1.4s ✓ before screenshot ~3s: fish converging toward food — dynamic look ✓. Food falls from 11 to lower — at 3s it's mid-fall ~ y 5-8 with fish rising to it ✓ great screenshot moment!
Let me make the first sprinkle a bit generous: 9 flakes at (1.5, 0.5) area — visible center. `sprinkle(1.5, 0.5, 9)` — wait signature sprinkle(px,pz,n).
Set nextAuto=1.35, position fixed (1.5,0.5) for the first, later random.
Actually randomize slightly: (rand(0,3), rand(-1,2)) around center-right.
Alright — writing the final file. (Also double-check: `controls.getAzimuthalAngle()` — method name ✓.)
Also one more guard: WebGL support — if `renderer` creation throws... skip, assume modern Chrome.
Let me write the code.
Note on `paintSolid` for eyes:
```js
function paintSolid(g, col){
const p=g.attributes.position, arr=new Float32Array(p.count*3);
for(let i=0;i<p.count;i++){ arr[i*3]=col.r;arr[i*3+1]=col.g;arr[i*3+2]=col.b; }
g.setAttribute('color',new THREE.BufferAttribute(arr,3));
}
```
Eyes: SphereGeometry(0.033,8,6).clone() per side, translate — note clone of geometry then translate ✓ (translate mutates that clone ✓, original intact for second side — clone again from eyeG ✓).
Also add a **tiny white glint**? skip.
Also I realize — **fins colors for 'pect' part exist in all species ✓.
**Tang bodyColor also add faint dark edge on fins via finColor ✓.**
Double-check the `finColor` for clown 'tail' uses white ✓ pretty.
Time to write out. Also final species list consistency with legend counts (12/40/9/6) ✓.
Let me also remember **`mesh.instanceColor.needsUpdate=true`** after setColorAt loops for fish, kelp, tentacles.
For fish I use setColorAt with subtle jitter Color(0.92..1.05) — with vertexColors the multiply darkens species pattern ✓ nice variation.
**Species `key` for behavior branches (tetra ball spawn etc.)** ✓.
Also **angel/tang slow: turn rate — slerp handles smoothing ✓.
**One potential visual artifact: fish pitch near vertical when food falls — pitch clamp ±0.6 ✓.
**After eating, fish velocity unchanged — they'll swim through eat point ✓ fine.
Also — small thing: `f.vel.multiplyScalar(Math.exp(-dt*0.15))` drag reduces speed slightly below cruise — min clamp compensates ✓ fine.
Also **acc clamp:** limit |acc| to 60:
```js
const al=acc.length(); if(al>60) acc.multiplyScalar(60/al);
```
Prevent explosive forces ✓ (flee max 60 anyway; sum could exceed).
**Write now.**
Actually — one more consideration on **`sstep(a,b,x)` with a>b** used in bodyColor `(1-sstep(0.28,0.06,y))`: sstep(0.28,0.06,y): (y-0.28)/(0.06-0.28) = (y-0.28)/(-0.22) → at y=0.06: (−0.22)/(−0.22)=1 ✓ at y=0.28 → 0 ✓ works (clamp handles) ✓.
Similarly `(1-sstep(0.34,0.12,y))` ✓.
**GLSL smoothstep ascending only ✓ I'll double check each GLSL usage while writing.**
In caustic GLSL: none reversed ✓.
Ring: `smoothstep(0.10,0.02,r)` — REVERSED! Fix: `1.0-smoothstep(0.02,0.10,r)` ✓ careful.
I'll write all smoothsteps ascending:
- ring: ring=1.0-smoothstep(0.03,0.11,abs(r-0.72)); dot0=1.0-smoothstep(0.02,0.10,r); halo=(1.0-smoothstep(0.0,0.35,abs(r-0.72)))*0.35;
- ripple: ring=1.0-smoothstep(0.0,0.12,abs(r-rr)) ✓.
- bubbles: edge=1.0-smoothstep(0.82,1.0,d) ✓ ascending ✓; hole=smoothstep(0.5,0.85,d) ✓; hi=1.0-smoothstep(0.0,0.4,length(...)) ✓.
- rays: ex=smoothstep(0.0,0.38,x)*(1.0-smoothstep(0.62,1.0,x)) ✓.
- fish wag env: 1.0-smoothstep(-0.60,0.18,z) ✓ ascending edges (-0.60<0.18 ✓).
- food: 1.0-smoothstep(0.55,0.95,d) ✓.
- surface fog ✓ chunks.
Good. Also JS sstep handles reversed fine as shown.
**Ring pulse: fine.**
Now, **DOM ids**: c, vig, ui, dot, statusText, hint, legend.
Final: write the code. I'll number nothing, just write. Careful with backticks inside template literals — GLSL strings contain no backticks ✓. JS template literals inside HTML script ✓.
Also the importmap must be exactly as given ✓.
Let me estimate fish body sphere segments 24×16 = 384 quads → 768 tris → toNonIndexed 2304 verts per fish + fins ~ 600 → ~3k verts × 4 species ≈ 12k verts total instanced ✓ trivial.
Write!
One more thought about `THREE.PlaneGeometry` for kelp: has uv ✓ unused by sway (uses position.y) ✓.
And kelp blade taper: modify before normals: after taper set z curve then computeVertexNormals ✓.
Blade colors: gradient with slight horizontal variation:
```js
const c1=new Color(0x0e2f22), c2=new Color(0x3f8f5c), c3=new Color(0x79b46a);
y01: c = y<0.6? lerp(c1,c2,y/0.6) : lerp(c2,c3,(y-0.6)/0.4)
```
instanceColor multiplies → instance tints ✓.
**Kelp sway: b=h*h → tip sway 0.16 relative (blade local height 1, scaled 3-5 → tip ±0.5-0.8 world) ✓ organic.
**Anemone tentacle sway amp 0.13 * local y (scaled 0.5-0.95) ✓.
Now truly write.
Also: `renderer.setAnimationLoop` vs rAF — use rAF fine.
Also for **iOS/perf**: fine.
After writing code, quickly re-scan for: undefined vars, mismatched braces, GLSL syntax, reversed smoothsteps, `needsUpdate` flags, and the one-output-block rule.
Let me also prepare the exact GLSL strings.
CAUSTIC_GLSL:
```glsl
float causticField(vec2 p, float t){
vec2 q = p;
float v = 0.0;
float w = 0.62;
for (int k = 0; k < 3; k++){
float fk = float(k);
q += vec2(sin(q.y*1.65 + t*(0.9+0.13*fk) + fk*2.4), cos(q.x*1.42 - t*(0.8+0.11*fk) + fk*1.7))*0.45;
v += w * abs(sin(q.x*1.5 + t*0.7) * sin(q.y*1.62 - t*0.85));
w *= 0.58;
}
v /= 1.19;
return pow(clamp(1.0 - v*1.7, 0.0, 1.0), 5.0);
}
```
injectCaustics fragment replacement string — watch that `${CAUSTIC_GLSL}` interpolation ✓.
Fish wag inject string:
```js
`#include <begin_vertex>
{
float env = 0.22 + 0.78 * (1.0 - smoothstep(-0.60, 0.18, transformed.z));
transformed.x += sin(aTail.x + transformed.z * 4.3) * env * (0.016 + 0.045 * aTail.y) * 2.4;
}`
```
Kelp:
```js
`#include <begin_vertex>
{
float h = clamp(transformed.y, 0.0, 1.0);
float b = h * h;
transformed.x += sin(uTime * 0.85 + aPhase + h * 2.4) * b * 0.16;
transformed.z += cos(uTime * 0.62 + aPhase * 1.7 + h * 1.9) * b * 0.11;
}`
```
Anemone:
```js
`#include <begin_vertex>
{
float h = clamp(transformed.y, 0.0, 1.0);
float s = pow(h, 1.5);
transformed.x += sin(uTime * 1.7 + aPhase) * s * 0.13;
transformed.z += cos(uTime * 1.35 + aPhase * 1.31) * s * 0.13;
}`
```
All good. Write the final file now — carefully and completely. I'll keep code tight but readable.
Also — subtle: **`#include <common>` replacement adds attribute declarations — but three also validates? Declaring `attribute vec2 aTail;` in vertex shader for GLSL1-style — three converts to `in` for GLSL3 ✓ handled by prefix mapping? The conversion of `attribute` keyword: WebGLProgram does NOT auto-convert custom-written `attribute` keywords... wait — for WebGL2, three uses `#version 300 es` and the prefix defines `#define attribute in` — YES: in the GLSL3 prefix three adds:
```
#define attribute in
#define varying out
```
for vertex shaders. So writing `attribute` works ✓ standard practice in examples ✓.
Same for `varying` ✓.
`texture2D` define also handled ✓ (unused).
OK. Also **precision**: prefix sets default highp float ✓.
**One more check — `uv` attribute in ShaderMaterial vertex for rays/ring: three declares `attribute vec2 uv;` automatically in prefix? Yes — for non-raw ShaderMaterial, three's prefix declares position/normal/uv attributes ALWAYS ✓.
Writing now for real. Structure the fish update as one function; ensure closures over module vars.
Count check on my GLSL uses of `t` name collisions: causticField(p,t) param named t vs uniform uTime passed in ✓ no conflict.
In injectCaustics fragment: `causticField(vCauP.xz*uCauS, uTime*0.55)` ✓.
Also in the water surface fragment I named varying vP (vec2) ✓ and use `causticField(vP*0.06, uTime*0.5)` ✓.
Final code writing... Also ensure the fish InstancedMesh gets `mesh.instanceMatrix.needsUpdate = true` initially after setting matrices (first frame sets anyway ✓).
For initial pose before first updateFish (first tick runs before first render? I call updateFood/updateFish before render in tick ✓ first frame positions set ✓).
Also initial fish matrices: set at spawn to avoid identity flash? First tick sets them before any render ✓ (tick runs immediately via rAF ~16ms — a flash of identity-pose fish could occur at frame 0? No — nothing renders before first tick ✓ we render only inside tick ✓).
Edge: rAF first fires after ~16ms — fine.
Let me also set `renderer.info` — no.
WRITE.
Actually, also double-check `updateFood` prune:
```js
for(let i=foods.length-1;i>=0;i--) if(foods[i].dead) foods.splice(i,1);
```
place at start.
And buffer writing:
```js
let n=0;
for(const fo of foods){ if(n>=MAXF) break; fPos[n*3]=fo.p.x; fPos[n*3+1]=fo.p.y; fPos[n*3+2]=fo.p.z; const c=fo.c; fCol[n*3]=c.r*fo.fade?
```
Wait fade should affect alpha not color; aFade attr carries fade ✓ color static ✓.
sizes: fSize[n]=fo.size; fFade[n]=fo.fade.
After loop: setDrawRange(0,n); mark attrs needsUpdate (position, aSize, aFade, aColor) — aColor static per slot but slots shift when compaction... foods array order shifts as early ones die → rewrite every frame ✓ mark all needsUpdate every frame ✓ cheap.
OK. Writing the answer now. Length will be big; that's fine.
let me also do a final sanity on **eye z position: te-0.42 = 0.46; body nose extends to z=0.58; prof at t=1 is 0 → nose point at 0.58; eye at 0.46 with prof 0.41 ✓ on the head ✓.
And **caudal fin z0=-0.40 body ends -0.42** → base overlaps body by 0.02 ✓.
**Dorsal base z0 = df.t0-0.42** e.g., clown 0.50-0.42=0.08 → fin from z 0.08 to 0.08-0.20=-0.12 ✓ mid-back ✓.
Tetra dorsal t0 0.55 → z 0.13 len 0.09 ✓ tiny.
**Pectoral z0 = 0.66-0.42=0.24** ✓ behind head ✓.
Great. Write.
def order in fishDefs array: build tang, angel, clown, tetra — order irrelevant.
Legend ordering: match legend list.
Also — **clownfish count 12 but only ~26 tentacle anemones... fine.
Also add anemone count 2 ✓ homes 2 ✓ assign clowns alternate.
FINAL WRITE.
(Also: never call `alert` etc ✓; no localStorage ✓.)
After code, no trailing text. One fenced block. Language: html.
Let me write the complete file text mentally-clean:
Also I want to double check one GLSL subtlety in fish wag: I reference `aTail.x` as accumulated phase which grows unbounded (e.g., after 10 minutes phase += dt*14*600s = 8400 rad). sin of large float32 loses precision → wag jitter after long time. Mitigate: phase wrapped on CPU: f.tailPhase = (f.tailPhase + ...) % (Math.PI*2)... but the shader adds transformed.z*4.3 to phase — wrapping phase by 2π keeps continuity since sin period 2π ✓ (z-term unaffected). So wrap: `f.tailPhase=(f.tailPhase + dt*(...))%TAU;` ✓ safe. But careful: the z*4.3 term — wrapping the time-phase by 2π is fine ✓.
Great — wrap phases.
Also aTail precision: Float32 fine for wrapped values ✓.
Similarly kelp/anemone phases use uTime directly — uTime grows: after 20 min = 1200s × freq ~1.7 → phase 2000 rad — float32 sin precision: sin(x) with x~2000: float32 has ~7 digits; 2000.123 representable with ~0.0001 error → fine. After hours could degrade — acceptable. Could wrap uTime at 2π*large... uTime used everywhere with different multipliers; wrap at 3600? Break continuity in mod-based bubbles (uTime*aRate). Leave it.
Now write!
Also quick thought on `updateFish` reading `pointer` — when pointerActive false skip flee ✓.
And excite also slightly raised when fish is near food (anticipation): f.excite=max(f.excite, 0.25) when food within 3? adds wag liveliness during feeding ✓ cheap:
```js
if(best && bd<3.0) f.excite=Math.max(f.excite,0.35);
```
OK writing final.
Also don't forget `document.body` overflow hidden via CSS ✓.
Also add `<noscript>`? skip.
Let me make sure the hint says "click to feed" which matches behavior (click anywhere sprinkles food above that spot) ✓.
Final answer below.
One more consideration on layout: brand top-left over dark-ish water; ensure text readable: text-shadow ✓ plus vig darkening corners helps top-left ✓.
Also `#vig` should be UNDER #ui visually: order in DOM: canvas, vig, ui ✓.
Write it.
For safety in the fish update: `dt` could be 0 on first frame (clock delta ~0) — division by dt in roll calc: turn/dt → Infinity → clamped to 0.5? clamp(±0.5) handles Infinity (clamp works) ✓ NaN if dt=0 AND turn=0 → 0/0=NaN! Guard: `const turnRate = dt>1e-4 ? wrapPI(yaw-f.prevYaw)/dt : 0;` ✓.
Also `1-Math.exp(-dt*7)` fine at dt=0 → 0 → slerp 0 ✓.
Also tailPhase update uses dt ✓.
Also `updateFood` at dt=0 harmless ✓.
Now writing for real. Final file:
Also I should double-check `THREE.Shape` closePath — `sh.closePath()` exists ✓.
And `new THREE.ShapeGeometry(shape, 4)` ✓.
For `mergeGeometries` — import { mergeGeometries } ✓.
Also careful: in makeFishGeo I `parts.push(g.index ? g.toNonIndexed() : g)` for EVERY part including fin geos (non-indexed ✓ stay as-is) — but toNonIndexed on geometry WITHOUT index returns... toNonIndexed on non-indexed logs warning "already non-indexed" and returns itself? It console.warns! Warning = console noise (not error, but let's avoid): check `g.index ? g.toNonIndexed() : g` — ShapeGeometry has no index → no call ✓ Sphere has index ✓. Warning avoided ✓.
SphereGeometry indexed ✓; CylinderGeometry indexed ✓ (not used in fish merge); eyes Sphere ✓ converted.
OK — also in anemone base: not merged (single mesh) ✓.
FINAL WRITE NOW. I'll be thorough and consistent.
Let me pre-count the pieces in the species def object so the builder doesn't branch on missing params: every def needs tail, dorsal, ventral, bodyColor, finColor. Streamer optional (angel only) → check `def.streamer`.
Writing:
```js
function makeFishGeo(def){
const parts=[];
const H=def.height, W=def.width;
const prof=(t)=>Math.sin(Math.PI*Math.pow(clamp(t,0,1),1.15));
const topAt=(t)=>prof(t)*H*1.16;
const botAt=(t)=>-prof(t)*H*0.95;
...
}
```
bodyColor uses `sstep`, `bandf` module helpers ✓.
Also `def.finColor(part)` returns fn — call inside paint.
Everything consistent.
Now — sizes: legend says counts — I'll compute counts from defs to inject? Hardcode HTML to match: 12/40/9/6 ✓ keep synced.
Also status initial "the water is calm" ✓.
Done deliberating — produce final answer.
Also — one last look at requirement: "Games must start in a self-playing demo mode behind a 'Click or press a key to start' message" — this is NOT a game (no objective/score); it's an interactive scene that's alive from load and fully operable via mouse without any click-to-start gate. Adding a blocking "click to start" overlay would HURT this demo (mouse hover works immediately). The keyboard-specific rule doesn't apply (no keyboard use). I'll keep a non-blocking hint. The demo works without interaction ✓. I think that's the right interpretation.
Write the final file.
Let me write out the whole thing.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#03151f">
<title>Tideglass · a pocket aquarium</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden;background:#03151f}
body{font-family:ui-sans-serif,system-ui,"Segoe UI",Helvetica,Arial,sans-serif;-webkit-user-select:none;user-select:none}
#c{position:fixed;inset:0;width:100%;height:100%;display:block;cursor:none;touch-action:none}
#vig{position:fixed;inset:0;pointer-events:none;background:radial-gradient(130% 105% at 50% 38%,rgba(0,0,0,0) 50%,rgba(2,12,22,.6) 100%),linear-gradient(rgba(6,34,50,.32),rgba(0,0,0,0) 24%)}
#ui{position:fixed;inset:0;pointer-events:none;color:#e7f4f6;animation:fadein 2s ease .25s both}
@keyframes fadein{from{opacity:0}to{opacity:1}}
.brand{position:absolute;top:26px;left:32px}
.over{font-size:10px;letter-spacing:.42em;text-transform:uppercase;opacity:.55;margin-bottom:10px}
h1{font-family:Georgia,"Times New Roman",serif;font-style:italic;font-weight:400;font-size:clamp(30px,4vw,44px);letter-spacing:.04em;text-shadow:0 3px 26px rgba(0,18,32,.95)}
.rule{width:54px;height:1px;margin:13px 0 11px;background:linear-gradient(90deg,rgba(231,244,246,.95),rgba(231,244,246,0))}
#status{display:flex;align-items:center;gap:9px;font-size:11px;letter-spacing:.18em;text-transform:uppercase;opacity:.85}
#dot{width:7px;height:7px;border-radius:50%;background:#7ee0a3;box-shadow:0 0 9px rgba(255,255,255,.35)}
#hint{position:absolute;bottom:24px;left:50%;transform:translateX(-50%);font-size:10.5px;letter-spacing:.24em;text-transform:uppercase;opacity:.55;white-space:nowrap}
#hint em{font-style:normal;opacity:.5;padding:0 .7em}
#legend{position:absolute;right:30px;bottom:22px;text-align:right;font-size:10px;letter-spacing:.18em;text-transform:uppercase;line-height:2.1;opacity:.8}
#legend i{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:9px;vertical-align:1px}
@media (max-width:900px){#legend{display:none}}
@media (max-width:640px){
.brand{top:18px;left:20px}
#hint{bottom:16px;font-size:9px;letter-spacing:.14em;white-space:normal;width:88%;text-align:center}
}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="vig"></div>
<div id="ui">
<div class="brand">
<div class="over">Pocket aquarium · nº 07</div>
<h1>Tideglass</h1>
<div class="rule"></div>
<div id="status"><i id="dot"></i><span id="statusText">the water is calm</span></div>
</div>
<div id="hint">move to startle the shoal<em>·</em>click to feed<em>·</em>drag to look around</div>
<div id="legend">
<div><i style="background:#ff8a1e"></i>ocellaris clownfish — 12</div>
<div><i style="background:#35d5ff"></i>neon tetra — 40</div>
<div><i style="background:#ffd21c"></i>yellow tang — 9</div>
<div><i style="background:#dfe6ec"></i>veil angelfish — 6</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">
... (big code)
</script>
</body>
</html>
```
Now the big module. Let me write it fully with care. I'll go section by section. I need to make sure that all referenced variables exist and are defined in the right order (module top-level; function declarations hoisted; const/let in order of execution).
Execution order plan:
1. imports
2. helpers (clamp, sstep, rand, bandf, wrapPI, TAU)
3. DOM refs (statusText, dot)
4. renderer/canvas/scene/camera/controls/lights
5. timeU, CAUSTIC_GLSL, inject helpers
6. environment builds (dome, sand, rocks/obstacles, kelp, anemones/homes, rays, surface, bubbles, snow)
7. fish defs & geometry & spawn (uses homes/obstacles)
8. food system
9. cursor ring & ripples
10. input
11. loop functions & start
Careful: `obstacles` and `homes` arrays defined before fish spawn uses homes; anemone builder pushes to them — define arrays before anemone build (step 6 before 7 ✓).
`foods` array before updateFish uses it ✓ (food system step 8 defines; updateFish runs in tick ✓).
`pointer` before input ✓.
Let me also define `let pointerActive=false, lastInteract=-999, nextAuto=1.35, rotDir=1;`
I'm confident. Final answer time.
While writing `updateFish`, remember it's large — take care with braces.
Also remember in tick: `updateFX(dt)` includes ring, ripples, status text update, camera-follow ring needs pointer & raycaster.
Let me handle ring target computed in updateFX using its own raycaster call (or reuse the one from updateFish — recompute cheap ✓).
Also — snow & bubble shaders need `uPR` uniforms = PR.
Write it!
def final numbers:
- FLEE_R=3.4, FLEE_F=60.
Also — clamp acc 60.
Let me also give tangs & angels slight individual wander timers — current sin-based wander is continuous; fine.
I also want **tetras to make tighter turns** — slerp 7/s fine.
DONE — write answer.
Wait, one more: **`controls.getAzimuthalAngle()`** — with min/max azimuth set (±1.15), initial camera azimuth 0.23 ✓ inside.
Also `controls.maxDistance 19` vs initial 18.3 ✓.
And **`camera.position.y=clamp(...)` after controls.update()** ✓.
**Also, when user zooms in to 7 units, camera might sit inside fish school — flee still works ✓ fun.
Final: also `renderer.setPixelRatio` before setSize ✓ order fine either way.
Now truly write the final response.
Also — double-check that I define `PR` before materials that embed it ✓ top.
In `injectCaustics`, I reference `CAUSTIC_GLSL` — defined before use at call time (function called during material creation) ✓.
`timeU` shared ✓.
Let me also confirm **`InstancedBufferAttribute` needsUpdate each frame for aTail** — set in updateFish via speciesList loop ✓.
Also **initial aTail values** (phase random, amp 0.4) set at spawn to avoid zero wag on first frame ✓ set at spawn too.
Alright — writing.
Also `mtx` reused across anemone/kelp/fish loops — declare one module-level `const _m=new THREE.Matrix4()` and temp vectors `_v1,_v2,_v3,_q,_e,_c`. Careful not to clobber mid-use — usage is sequential ✓.
Also `updstatus` uses avgE global let.
FINAL.
Let me write the fish spawn code:
```js
const fishes=[];
const speciesList=[];
function addSpecies(def){
const geo=makeFishGeo(def);
const mat=new THREE.MeshStandardMaterial({vertexColors:true,roughness:0.42,metalness:0.05,side:THREE.DoubleSide});
injectWag(mat);
const mesh=new THREE.InstancedMesh(geo,mat,def.count);
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
mesh.castShadow=true;
mesh.frustumCulled=false;
const tailAttr=new THREE.InstancedBufferAttribute(new Float32Array(def.count*2),2);
tailAttr.setUsage(THREE.DynamicDrawUsage);
geo.setAttribute('aTail',tailAttr);
scene.add(mesh);
speciesList.push({mesh,tailAttr});
const tint=new THREE.Color();
for(let i=0;i<def.count;i++){
const f={def,mesh,tailAttr,idx:i,seed:rand(100),
pos:new THREE.Vector3(),vel:new THREE.Vector3(),
quat:new THREE.Quaternion(),scl:new THREE.Vector3(),
tailPhase:rand(TAU),effort:0.45,excite:0,prevYaw:0,roll:0,nCount:0,
sep:new THREE.Vector3(),coh:new THREE.Vector3(),ali:new THREE.Vector3(),home:null};
// spawn
if(def.key==='tetra'){ f.pos.set(2.5+rand(-2.4,2.4),5.6+rand(-1.6,1.6),0.5+rand(-2,2)); }
else if(def.key==='clown'){ const h=homes[i%homes.length]; f.home=h; f.pos.set(h.x+rand(-2,2),clamp(h.y+rand(-0.6,1.4),1.8,6),h.z+rand(-2,2)); }
else if(def.key==='tang'){ f.pos.set(rand(-12,12),rand(2.2,4),rand(-8,8)); }
else { f.pos.set(rand(-10,10),rand(5,8.4),rand(-7,7)); }
f.pos.y=clamp(f.pos.y,1.9,10.2);
const a=rand(TAU);
f.vel.set(Math.sin(a),rand(-0.2,0.2),Math.cos(a)).multiplyScalar(def.cruise*0.8);
const s=def.len*rand(0.86,1.14);
f.scl.set(s,s,s);
f.prevYaw=Math.atan2(f.vel.x,f.vel.z);
tailAttr.setXY(i,f.tailPhase,f.effort);
mesh.setColorAt(i,tint.setRGB(rand(0.9,1.06),rand(0.9,1.06),rand(0.9,1.06)));
fishes.push(f);
}
mesh.instanceColor.needsUpdate=true;
_m.compose(f? — write initial matrices:
```
Initial matrices: compose per fish quickly:
```js
for(const f of fishes) if(f.mesh===mesh){ _m.compose(f.pos,f.quat,f.scl); mesh.setMatrixAt(f.idx,_m); }
```
Or simpler — do it inside spawn loop before push: compose and set ✓.
Wait `mesh.setColorAt` allocates instanceColor ✓ then needsUpdate ✓.
Order of addSpecies calls: clown (needs homes — anemones built earlier ✓).
Now updateFish full code (writing carefully):
```js
let avgE=0;
const _acc=new THREE.Vector3();
function updateFish(dt){
const t=timeU.value;
raycaster.setFromCamera(pointer,camera);
const ro=raycaster.ray.origin, rd=raycaster.ray.direction;
const n=fishes.length;
for(let i=0;i<n;i++){ const f=fishes[i]; f.sep.set(0,0,0); f.coh.set(0,0,0); f.ali.set(0,0,0); f.nCount=0; }
for(let i=0;i<n;i++){
const a=fishes[i];
for(let j=i+1;j<n;j++){
const b=fishes[j];
const dx=a.pos.x-b.pos.x, dy=a.pos.y-b.pos.y, dz=a.pos.z-b.pos.z;
const d2=dx*dx+dy*dy+dz*dz;
const sepR=(a.def.len+b.def.len)*0.62;
if(d2<sepR*sepR){
const d=Math.sqrt(d2)||1e-4;
const push=(1-d/sepR)*7/d;
a.sep.x+=dx*push; a.sep.y+=dy*push; a.sep.z+=dz*push;
b.sep.x-=dx*push; b.sep.y-=dy*push; b.sep.z-=dz*push;
}
if(a.def===b.def && d2<a.def.neighR*a.def.neighR){
a.coh.add(b.pos); a.ali.add(b.vel); a.nCount++;
b.coh.add(a.pos); b.ali.add(a.vel); b.nCount++;
}
}
}
let exciteSum=0;
for(let i=0;i<n;i++){
const f=fishes[i], def=f.def;
_acc.set(0,0,0);
if(f.nCount>0){
_v1.copy(f.coh).divideScalar(f.nCount).sub(f.pos);
const dC=_v1.length();
if(dC>1e-4) _acc.addScaledVector(_v1.divideScalar(dC), def.coh*Math.min(1,dC*0.45));
_v1.copy(f.ali).divideScalar(f.nCount).sub(f.vel);
_acc.addScaledVector(_v1,def.ali*0.5);
}
_acc.add(f.sep);
_acc.x+=Math.sin(t*0.37+f.seed)*def.wander;
_acc.y+=Math.sin(t*0.45+f.seed*1.71)*def.wander*0.5;
_acc.z+=Math.cos(t*0.31+f.seed*2.33)*def.wander;
_acc.y+=(def.prefY-f.pos.y)*0.22;
if(f.home){
const hx=f.home.x-f.pos.x, hz=f.home.z-f.pos.z;
const hd=Math.hypot(hx,hz)||1e-4;
if(hd>4.5){ _acc.x+=hx/hd*0.8; _acc.z+=hz/hd*0.8; }
const near=1-sstep(3,6,hd);
_acc.y+=(f.home.y-f.pos.y)*0.55*near;
}
if(f.pos.x>13.5) _acc.x-=(f.pos.x-13.5)*2.6;
else if(f.pos.x<-13.5) _acc.x+=(-13.5-f.pos.x)*2.6;
if(f.pos.z>9) _acc.z-=(f.pos.z-9)*2.6;
else if(f.pos.z<-9) _acc.z+=(-9-f.pos.z)*2.6;
if(f.pos.y>9.6) _acc.y-=(f.pos.y-9.6)*3.2;
else if(f.pos.y<1.7) _acc.y+=(1.7-f.pos.y)*3.2;
for(let k=0;k<obstacles.length;k++){
const r=obstacles[k];
const dx=f.pos.x-r.x, dy=f.pos.y-r.y, dz=f.pos.z-r.z;
const rr=r.r+0.55;
const d2=dx*dx+dy*dy+dz*dz;
if(d2<rr*rr){
const d=Math.sqrt(d2)||1e-4;
const kk=(rr-d)*6/d;
_acc.x+=dx*kk; _acc.y+=dy*kk; _acc.z+=dz*kk;
}
}
let best=null, bd=9.5;
for(let k=0;k<foods.length;k++){
const fo=foods[k];
if(fo.dead) continue;
const d=f.pos.distanceTo(fo.p);
if(d<bd){ bd=d; best=fo; }
}
if(best){
_v1.copy(best.p).sub(f.pos);
const dl=_v1.length();
if(dl>1e-4) _acc.addScaledVector(_v1.divideScalar(dl), 3.0+(9.5-dl)*1.2);
if(bd<3) f.excite=Math.max(f.excite,0.35);
if(dl<0.30+def.len*0.35){ best.dead=true; f.excite=Math.min(1.3,f.excite+0.3); spawnRipple(best.p.x,best.p.y,best.p.z); }
}
if(pointerActive){
_v1.copy(f.pos).sub(ro);
const tt=clamp(_v1.dot(rd),3,60);
_v2.copy(rd).multiplyScalar(tt).add(ro);
_v3.copy(f.pos).sub(_v2);
const d=_v3.length();
if(d<3.4){
const k=1-d/3.4;
if(d>1e-3) _acc.addScaledVector(_v3.divideScalar(d),60*k*k);
f.excite=Math.max(f.excite,k*1.25);
}
}
const al=_acc.length();
if(al>60) _acc.multiplyScalar(60/al);
f.excite*=Math.exp(-dt*1.6);
const maxS=def.maxS*(1+f.excite*1.3);
f.vel.addScaledVector(_acc,dt);
f.vel.multiplyScalar(Math.exp(-dt*0.15));
let sp=f.vel.length();
const minS=def.cruise*0.55;
if(sp>maxS){ f.vel.multiplyScalar(maxS/sp); sp=maxS; }
else if(sp<minS){ if(sp>1e-5) f.vel.multiplyScalar(minS/sp); else f.vel.set(minS,0,0); sp=minS; }
f.pos.addScaledVector(f.vel,dt);
f.pos.x=clamp(f.pos.x,-15.5,15.5); f.pos.y=clamp(f.pos.y,1.4,10.6); f.pos.z=clamp(f.pos.z,-10.5,10.5);
f.effort+=((sp/def.maxS)+f.excite*0.5-f.effort)*Math.min(1,dt*5);
f.tailPhase=(f.tailPhase+dt*(5+f.effort*9+f.excite*5))%TAU;
const yaw=Math.atan2(f.vel.x,f.vel.z);
const hs=Math.hypot(f.vel.x,f.vel.z);
const pitch=clamp(-Math.atan2(f.vel.y,hs||1e-4),-0.6,0.6);
const turnRate=dt>1e-4?wrapPI(yaw-f.prevYaw)/dt:0;
const tRoll=clamp(-turnRate*0.22,-0.5,0.5);
f.roll+=(tRoll-f.roll)*Math.min(1,dt*3.5);
f.prevYaw=yaw;
_e.set(pitch,yaw,f.roll,'YXZ');
_q.setFromEuler(_e);
f.quat.slerp(_q,1-Math.exp(-dt*7));
_m.compose(f.pos,f.quat,f.scl);
f.mesh.setMatrixAt(f.idx,_m);
f.tailAttr.setXY(f.idx,f.tailPhase,f.effort);
exciteSum+=f.excite;
}
avgE=exciteSum/n;
for(const s of speciesList){ s.mesh.instanceMatrix.needsUpdate=true; s.tailAttr.needsUpdate=true; }
}
```
Check `_v1.divideScalar(dC)` after `.sub(f.pos)` copy — I used _v1 twice (coh then ali) — careful sequence: first block uses _v1 for cohesion and divides it — then reassigns _v1 for alignment ✓ sequential fine.
Note `_acc.addScaledVector(_v1.divideScalar(dC), ...)` — dividing _v1 in place then scaling — but I already normalized? I wrote: `_v1.copy(...).divideScalar(nCount).sub(f.pos)` → vector from pos to centroid, length dC. Then `if(dC>1e-4) _acc.addScaledVector(_v1.divideScalar(dC), def.coh*Math.min(1,dC*0.45));` ✓ normalizes and adds scaled ✓.
Food targeting `_v1` reused after — fine sequential.
`spawnRipple` referenced — defined later in module (function declaration hoisted ✓ if declared as function). I'll declare all as function declarations for hoisting safety ✓.
Note: `avgE` let at module scope ✓.
updateFood:
```js
function updateFood(dt){
for(let i=foods.length-1;i>=0;i--) if(foods[i].dead) foods.splice(i,1);
const t=timeU.value;
for(const fo of foods){
if(!fo.rest){
fo.v.y=Math.max(fo.v.y-1.6*dt,-0.55);
fo.p.x+=(fo.v.x+Math.sin(t*2.1+fo.seed)*0.12)*dt;
fo.p.z+=(fo.v.z+Math.cos(t*1.7+fo.seed*1.3)*0.12)*dt;
fo.p.y+=fo.v.y*dt;
const gy=sandHeight(fo.p.x,fo.p.z)+0.07;
if(fo.p.y<=gy){ fo.p.y=gy; fo.rest=true; }
fo.fade=1;
} else {
fo.life-=dt;
fo.fade=clamp(fo.life/2.5,0,1);
if(fo.life<=0) fo.dead=true;
}
}
let n=0;
for(const fo of foods){
if(n>=MAXF) break;
fPos[n*3]=fo.p.x; fPos[n*3+1]=fo.p.y; fPos[n*3+2]=fo.p.z;
fCol[n*3]=fo.c.r; fCol[n*3+1]=fo.c.g; fCol[n*3+2]=fo.c.b;
fSize[n]=fo.size; fFade[n]=fo.fade;
n++;
}
foodGeo.setDrawRange(0,n);
foodGeo.attributes.position.needsUpdate=true;
foodGeo.attributes.aColor.needsUpdate=true;
foodGeo.attributes.aSize.needsUpdate=true;
foodGeo.attributes.aFade.needsUpdate=true;
}
```
sprinkle:
```js
function sprinkle(px,pz,count){
for(let i=0;i<count;i++){
if(foods.length>=MAXF) foods.shift();
foods.push({
p:new THREE.Vector3(px+rand(-0.9,0.9),10.9+rand(0,0.5),pz+rand(-0.9,0.9)),
v:new THREE.Vector3(rand(-0.15,0.15),0,rand(-0.15,0.15)),
seed:rand(50),size:rand(0.5,0.85),life:rand(16,26),fade:1,rest:false,dead:false,
c:new THREE.Color([0xff9b4a,0xe8703a,0xffd07a][i%3])
});
}
}
```
updateFX:
```js
function updateFX(dt){
// cursor ring
if(pointerActive){
raycaster.setFromCamera(pointer,camera);
const ro=raycaster.ray.origin, rd=raycaster.ray.direction;
let tt=rd.z<-1e-4?(0-ro.z)/rd.z:12;
tt=clamp(tt,2,40);
_v1.copy(rd).multiplyScalar(tt).add(ro);
_v1.x=clamp(_v1.x,-14.5,14.5); _v1.y=clamp(_v1.y,1.4,10.4); _v1.z=clamp(_v1.z,-10,10);
ringTarget.copy(_v1);
}
ringPos.lerp(ringTarget,1-Math.exp(-dt*14));
ring.position.copy(ringPos);
ring.quaternion.copy(camera.quaternion);
const want=pointerActive?0.5:0;
ringMat.uniforms.uOp.value+=(want-ringMat.uniforms.uOp.value)*Math.min(1,dt*6);
// ripples
for(const r of ripplePool){
if(r.age<1){
r.age=Math.min(1,r.age+dt/0.9);
r.m.material.uniforms.uK.value=r.age;
r.m.quaternion.copy(camera.quaternion);
if(r.age>=1) r.m.visible=false;
}
}
// auto feeding
if(elapsed>nextAuto){
if(nextAuto<5) sprinkle(1.5+rand(-1,1),0.5+rand(-1,1),9);
else sprinkle(rand(-6,6),rand(-5,4),7);
nextAuto=elapsed+rand(28,40);
}
// status
setStatus(avgE);
}
```
setStatus:
```js
const statusEl=document.getElementById('statusText');
const dotEl=document.getElementById('dot');
const STATES=[['the water is calm','#7ee0a3'],['the fish are curious','#7fd4ff'],['the shoal stirs','#ffd166'],['the shoal scatters!','#ff8a7a']];
let stateIdx=0;
function setStatus(e){
const idx=e<0.10?0:e<0.32?1:e<0.6?2:3;
if(idx!==stateIdx){
stateIdx=idx;
statusEl.textContent=STATES[idx][0];
dotEl.style.background=STATES[idx][1];
}
}
```
DOM refs — script at end of body ✓ elements exist ✓.
Input:
```js
const pointer=new THREE.Vector2(0,0);
let pointerActive=false, lastInteract=-999;
function setPointer(e){
pointer.x=(e.clientX/innerWidth)*2-1;
pointer.y=-(e.clientY/innerHeight)*2+1;
}
addEventListener('pointermove',(e)=>{ setPointer(e); pointerActive=true; lastInteract=elapsed; },{passive:true});
addEventListener('blur',()=>pointerActive=false);
document.addEventListener('mouseleave',()=>pointerActive=false);
let downX=0,downY=0,downT=0;
canvas.addEventListener('pointerdown',(e)=>{
downX=e.clientX; downY=e.clientY; downT=performance.now();
setPointer(e); pointerActive=true; lastInteract=elapsed;
});
canvas.addEventListener('pointerup',(e)=>{
const moved=Math.hypot(e.clientX-downX,e.clientY-downY);
if(performance.now()-downT<300 && moved<9){
setPointer(e);
raycaster.setFromCamera(pointer,camera);
const ro=raycaster.ray.origin, rd=raycaster.ray.direction;
let tt=rd.y<-1e-4?(10.9-ro.y)/rd.y:12;
if(!(tt>2&&tt<60)) tt=12;
_v1.copy(rd).multiplyScalar(tt).add(ro);
const px=clamp(_v1.x,-13.5,13.5), pz=clamp(_v1.z,-9,9);
sprinkle(px,pz,8);
spawnRipple(px,clamp(_v1.y,1.6,10.6),pz);
nextAuto=elapsed+34;
}
});
```
Hmm — rd.y<-1e-4: camera looks slightly down usually → tt = (10.9-ro.y)/rd.y: ro.y≈5, rd.y≈-0.2 → tt=(5.9)/(-0.2)= -29?? Negative! Wait: to reach y=10.9 ABOVE camera, need rd.y>0. Camera at y 5 pointing slightly DOWN (rd.y<0) → ray never reaches 10.9 → tt negative → fallback 12 → food at ray t=12 → y = 5 - 0.2*12 ≈ 2.6 mid-low — that's fine actually! If user points up (rd.y>0): tt=(10.9-5)/0.4≈15 → food near surface ✓. Condition: use tt = rd.y>1e-3 ? (10.9-ro.y)/rd.y : 12; validate range else 12. Let me fix:
```js
let tt=12;
if(rd.y>1e-3){ const tc=(10.9-ro.y)/rd.y; if(tc>2&&tc<60) tt=tc; }
```
Then food column: p = ro+rd*tt; x,z from that; spawn y 10.9 near surface ✓ for downward clicks, p at t=12 depth mid-tank; food spawns at surface y=10.9 above that x,z — fish look up and rise ✓ good.
Similarly the ring t calc uses z-plane (rd.z always negative-ish from front camera ✓ camera always front: rd.z<0 ✓ ok; if rd.z≥-1e-4 fallback 12 ✓).
spawnRipple:
```js
function spawnRipple(x,y,z){
let r=ripplePool[0];
for(const q of ripplePool) if(q.age>=r.age) r=q; // pick most finished
r.age=0; r.m.visible=true; r.m.position.set(x,y,z);
}
```
Ring objects:
```js
const ringMat=new THREE.ShaderMaterial({...});
const ring=new THREE.Mesh(new THREE.PlaneGeometry(3.3,3.3),ringMat);
ring.frustumCulled=false; ring.renderOrder=8; scene.add(ring);
const ringPos=new THREE.Vector3(0,4.5,0), ringTarget=new THREE.Vector3(0,4.5,0);
```
OK — also **make sure `ring` doesn't intersect near camera**: it's at flee point ✓.
Now environment code writing — kelp instance creation with `_e`/`_q`/`_m`/`_v1` etc. Careful: `_e` is Euler — reuse ✓.
Rocks:
```js
const rockMat=new THREE.MeshStandardMaterial({vertexColors:true,roughness:0.95,flatShading:true});
injectCaustics(rockMat,0.32,0.9);
const rockGeos=[makeRockGeo(1),makeRockGeo(2),makeRockGeo(3)];
const ROCKS=[[-11.2,-6.5,2.3],[-8.6,-8.3,1.5],[9.5,-7.8,2.0],[12.2,-4.6,1.25],[-0.6,-10.2,1.55],[3.4,-9.2,1.0],[-14.2,-1.2,1.7],[6.4,0.6,0.7],[-4.6,1.8,0.6],[12.6,1.6,0.95]];
ROCKS.forEach((r,i)=>{
const [x,z,s]=r;
const m=new THREE.Mesh(rockGeos[i%3],rockMat);
const y=sandHeight(x,z)+s*0.18;
m.position.set(x,y,z);
m.scale.set(s,s*rand(0.72,0.95),s);
m.rotation.y=rand(TAU);
m.castShadow=true; m.receiveShadow=true;
scene.add(m);
obstacles.push({x,y:y+s*0.25,z,r:s*0.95});
});
```
makeRockGeo:
```js
function makeRockGeo(seed){
const g=new THREE.IcosahedronGeometry(1,2);
const p=g.attributes.position;
const cols=new Float32Array(p.count*3);
const cA=new THREE.Color(0x45444b), cB=new THREE.Color(0x74726a), cM=new THREE.Color(0x3f6b52), cW=new THREE.Color(0x6b5f50);
const c=new THREE.Color();
for(let i=0;i<p.count;i++){
const x=p.getX(i),y=p.getY(i),z=p.getZ(i);
const h=Math.abs(Math.sin(x*12.9898+y*78.233+z*37.719+seed*94.673)*43758.5453)%1;
const s=0.72+h*0.55;
const ny=y*s*0.8;
p.setXYZ(i,x*s,ny,z*s);
const yt=clamp((ny+0.75)/1.5,0,1);
c.copy(cA).lerp(cB,yt);
c.lerp(cW,h*0.35);
c.lerp(cM,sstep(0.55,0.95,yt)*0.55);
cols[i*3]=c.r;cols[i*3+1]=c.g;cols[i*3+2]=c.b;
}
g.setAttribute('color',new THREE.BufferAttribute(cols,3));
g.computeVertexNormals();
return g;
}
```
IcosahedronGeometry non-indexed ✓ no toNonIndexed needed ✓ (flatShading anyway).
Note vertex displacement per duplicated vertex consistent (hash by position ✓ same displacement ✓ no cracks ✓).
Kelp code:
```js
const bladeGeo=new THREE.PlaneGeometry(0.16,1,1,12);
bladeGeo.translate(0,0.5,0);
{
const p=bladeGeo.attributes.position;
const cols=new Float32Array(p.count*3);
const c1=new THREE.Color(0x0e2f22), c2=new THREE.Color(0x3f8f5c), c3=new THREE.Color(0x83b96f);
const c=new THREE.Color();
for(let i=0;i<p.count;i++){
const y=p.getY(i);
const w=1-Math.pow(y,1.35)*0.75;
p.setX(i,p.getX(i)*w);
p.setZ(i,Math.sin(y*2.6)*0.055);
if(y<0.6) c.copy(c1).lerp(c2,y/0.6); else c.copy(c2).lerp(c3,(y-0.6)/0.4);
cols[i*3]=c.r;cols[i*3+1]=c.g;cols[i*3+2]=c.b;
}
bladeGeo.setAttribute('color',new THREE.BufferAttribute(cols,3));
bladeGeo.computeVertexNormals();
}
```
Wait — PlaneGeometry indexed ✓; computeVertexNormals on indexed → smooth ✓ good. Kelp instanced with aPhase attr:
```js
const kelpMat=new THREE.MeshStandardMaterial({vertexColors:true,roughness:0.8,side:THREE.DoubleSide});
injectSwayKelp(kelpMat);
const kelpSpots=[];
[[-13.4,-7.2,9],[13.6,-8.2,8],[-9.6,-9.8,6],[8.8,-1.2,5],[2.4,-10.3,6],[13.9,1.6,4]].forEach(([cx,cz,cn])=>{
for(let i=0;i<cn;i++){
const a=rand(TAU), r=rand(0.25,1.35);
kelpSpots.push([cx+Math.cos(a)*r,cz+Math.sin(a)*r,rand(2.4,5.4)]);
}
});
for(let i=0;i<22;i++) kelpSpots.push([rand(-14.5,14.5),rand(-9.5,9.5),rand(0.7,1.8)]);
const kelp=new THREE.InstancedMesh(bladeGeo,kelpMat,kelpSpots.length);
kelp.frustumCulled=false; kelp.receiveShadow=true;
const kelpPhases=new Float32Array(kelpSpots.length);
const kpal=[0x2c6b4f,0x3f8f6a,0x57a06a,0x2f8f7a,0x6fae6a].map((h)=>new THREE.Color(h));
const kt=new THREE.Color();
kelpSpots.forEach((s,i)=>{
kelpPhases[i]=rand(TAU);
_e.set(0,rand(TAU),rand(-0.06,0.06));
_q.setFromEuler(_e);
const h=s[2];
_v1.set(h*rand(0.6,0.95),h,h*rand(0.6,0.95));
_v2.set(s[0],sandHeight(s[0],s[1])-0.06,s[1]);
_m.compose(_v2,_q,_v1);
kelp.setMatrixAt(i,_m);
kt.copy(kpal[i%kpal.length]).multiplyScalar(rand(0.8,1.15));
kelp.setColorAt(i,kt);
});
bladeGeo.setAttribute('aPhase',new THREE.InstancedBufferAttribute(kelpPhases,1));
kelp.instanceColor.needsUpdate=true;
scene.add(kelp);
```
Anemones:
```js
const tentGeo=new THREE.CylinderGeometry(0.018,0.06,1,5,4);
tentGeo.translate(0,0.5,0);
{
const p=tentGeo.attributes.position;
const cols=new Float32Array(p.count*3);
const c1=new THREE.Color(0x8e2657), c2=new THREE.Color(0xe26b9e), c3=new THREE.Color(0xffd9ec);
const c=new THREE.Color();
for(let i=0;i<p.count;i++){
const y=p.getY(i);
if(y<0.55) c.copy(c1).lerp(c2,y/0.55); else c.copy(c2).lerp(c3,(y-0.55)/0.45);
cols[i*3]=c.r;cols[i*3+1]=c.g;cols[i*3+2]=c.b;
}
tentGeo.setAttribute('color',new THREE.BufferAttribute(cols,3));
}
const tentMat=new THREE.MeshStandardMaterial({vertexColors:true,roughness:0.55});
injectSwayAnemone(tentMat);
const tentPhases=new Float32Array(60);
for(let i=0;i<60;i++) tentPhases[i]=rand(TAU);
tentGeo.setAttribute('aPhase',new THREE.InstancedBufferAttribute(tentPhases,1));
const anemBaseGeo=new THREE.SphereGeometry(0.5,14,10);
{
const p=anemBaseGeo.attributes.position;
const cols=new Float32Array(p.count*3);
const c1=new THREE.Color(0x6d2246), c2=new THREE.Color(0xc25585);
const c=new THREE.Color();
for(let i=0;i<p.count;i++){
const yt=clamp((p.getY(i)+0.5),0,1);
c.copy(c1).lerp(c2,yt);
cols[i*3]=c.r;cols[i*3+1]=c.g;cols[i*3+2]=c.b;
}
anemBaseGeo.setAttribute('color',new THREE.BufferAttribute(cols,3));
}
const anemBaseMat=new THREE.MeshStandardMaterial({vertexColors:true,roughness:0.75});
const homes=[];
function buildAnemone(x,z,s){
const baseY=sandHeight(x,z);
const grp=new THREE.Group();
grp.position.set(x,baseY,z);
scene.add(grp);
const base=new THREE.Mesh(anemBaseGeo,anemBaseMat);
base.scale.set(1.15*s,0.62*s,1.15*s);
base.position.y=0.14*s;
base.castShadow=true;
grp.add(base);
const n=Math.round(26*s);
const im=new THREE.InstancedMesh(tentGeo,tentMat,n);
im.frustumCulled=false;
const tj=new THREE.Color();
for(let i=0;i<n;i++){
const th=rand(TAU), yy=rand(0.18,0.95);
const rr=Math.sqrt(Math.max(0,1-yy*yy));
_v1.set(Math.cos(th)*rr,yy,Math.sin(th)*rr);
_q.setFromUnitVectors(UP,_v1);
_v2.copy(_v1).multiply(new THREE.Vector3? — allocate! use _v3.set(_v1.x*0.55*s,_v1.y*0.33*s,_v1.z*0.55*s); _v3.y+=0.14*s;
_v3.set(_v1.x*0.5*1.15*s,_v1.y*0.5*0.62*s+0.14*s,_v1.z*0.5*1.15*s);
_v2.set(1,rand(0.5,0.95)*s,1);
_m.compose(_v3,_q,_v2);
im.setMatrixAt(i,_m);
im.setColorAt(i,tj.setRGB(rand(0.85,1.15),rand(0.85,1.15),rand(0.85,1.15)));
}
im.instanceColor.needsUpdate=true;
grp.add(im);
obstacles.push({x,y:baseY+0.5*s,z,r:0.85*s});
homes.push({x,y:baseY+1.7*s,z});
}
buildAnemone(-8.3,-5.5,1.15);
buildAnemone(6.9,-7.4,0.9);
```
Hmm — `UP` const Vector3(0,1,0) define before ✓.
Wait — obstacles order: obstacles const array must be defined BEFORE buildAnemone calls and before rocks ✓ define `const obstacles=[]` early.
Also note tentPhases array length 60 but two anemones each use ≤26*... count max 26+? n=round(26*1.15)=30! exceeds 60? two anemones: 30 + 23 = 53 ≤ 60 ✓ but each InstancedMesh indexes aPhase from 0..n-1 — shared attr of 60, each mesh uses first n — phases repeat between anemones (index 0..29 and 0..22) — same phase values for both anemones at same index — visually fine (tentacles oriented differently) ✓. But InstancedBufferAttribute length must be ≥ instance count of the LARGEST mesh using it: 30 ≤ 60 ✓ ok. But does three validate attribute count vs instance count? It draws min; extra fine ✓. Hmm, actually the attribute count 60 with mesh count 30 — three uses mesh.count for instancing ✓ fine.
Let me set tentPhases length 30 exactly and both meshes use ≤30 ✓ (round(26*1.15)=30 ✓). Make it 64 to be safe.
**Kelp count check:** clusters 9+8+6+5+6+4=38 + 22 = 60 ✓.
**instanced attribute aPhase count = kelpSpots.length ✓.**
Now fish species definitions & builder — write:
```js
const fishDefs=[
{
key:'tetra', count:40, len:0.58, height:0.155, width:0.10,
cruise:1.9, maxS:4.6, coh:1.4, ali:2.2, neighR:2.5, wander:0.5, prefY:5.6,
tail:{len:0.17,spread:0.13,fork:0.5},
dorsal:{t0:0.55,len:0.09,h:0.06},
ventral:{t0:0.42,len:0.08,h:0.05},
bodyColor:(c,t,y)=>{ ... },
finColor:(part)=>{...}
},
{ key:'clown', count:12, len:0.85, height:0.20, width:0.135, cruise:1.25, maxS:3.4, coh:0.6, ali:1.0, neighR:3.4, wander:0.55, prefY:3.1, tail:{len:0.26,spread:0.21,fork:0.18}, dorsal:{t0:0.50,len:0.20,h:0.13}, ventral:{t0:0.46,len:0.15,h:0.10}, bodyColor, finColor },
{ key:'tang', count:9, len:1.05, height:0.33, width:0.13, cruise:1.05, maxS:3.0, coh:0.25, ali:0.5, neighR:4.0, wander:0.9, prefY:2.5, tail:{len:0.16,spread:0.30,fork:0.06}, dorsal:{t0:0.42,len:0.34,h:0.30}, ventral:{t0:0.42,len:0.30,h:0.26}, ... },
{ key:'angel', count:6, len:1.15, height:0.40, width:0.075, cruise:0.85, maxS:2.4, coh:0.35, ali:0.7, neighR:4.4, wander:0.45, prefY:6.2, tail:{len:0.30,spread:0.22,fork:0.10}, dorsal:{t0:0.56,len:0.30,h:0.40}, ventral:{t0:0.50,len:0.26,h:0.44}, streamer:true, ... },
];
```
Write makeFishGeo fully (with helpers inside referencing module fns).
Also remember tetra prefY 5.6 spawn ball at (2.5,5.6,0.5) ✓.
Everything else per plan. Also — the ring initial position (0,4.5,0).
Let me also double check god ray positions y: scale.y 11.2 with plane centered → position.y 5.8 → spans 0.2..11.4 ✓.
Water surface at 11.8 ✓ rays just below ✓.
**Bubble rise height 11.0 from base ~0.4 → top 11.4 ✓.**
Also **snow y 0.7..11.2 ✓.
Final assembly & write. Also the module code should avoid referencing `elapsed` in event listeners before init — declared with let at top ✓.
Let me also not forget: `const canvas=document.getElementById('c')`.
And renderer creation with `{canvas, antialias:true}` ✓.
Writing the final response NOW. I'll be careful about every brace. Also final double check of GLSL uniform declarations in each custom shader — enumerated:
bubMat uniforms: uTime, uPR ✓ used ✓ declared in vertex; fragment uses none besides fog ✓.
snowMat: uTime, uPR ✓.
foodMat: uPR (+fog auto) — wait food fragment uses vC/vF only ✓; but I need uTime in food? No — wobble done CPU ✓. Food vertex doesn't use uTime ✓. OK.
ringMat: uTime, uOp ✓.
ripple materials: uK ✓ (uTime unused — drop from shader) ✓.
rayMat: uTime, uSeed, uOp ✓.
surfMat: uTime, uDeep, uLite ✓.
domeMat: uSun ✓.
Check each shader compiles: all varyings matched.
Snow vertex:
```glsl
uniform float uTime; uniform float uPR;
attribute vec3 aBase; attribute float aSeed; attribute float aSize;
varying float vA;
#include <fog_pars_vertex>
void main(){
vec3 p=aBase;
p.x+=sin(uTime*0.17+aSeed*3.1)*0.7;
p.z+=cos(uTime*0.13+aSeed*2.3)*0.7;
p.y=0.7+mod(aBase.y-uTime*(0.05+aSeed*0.04),10.4);
vA=0.5+0.5*sin(aSeed*7.0);
vec4 mvPosition=modelViewMatrix*vec4(p,1.0);
gl_PointSize=max(aSize*uPR*(70.0/max(1.0,-mvPosition.z)),1.0);
gl_Position=projectionMatrix*mvPosition;
#include <fog_vertex>
}
```
vA used as brightness variation 0.5..1 ✓.
snow frag:
```glsl
varying float vA;
#include <fog_pars_fragment>
void main(){
vec2 q=gl_PointCoord-0.5;
float d=length(q)*2.0;
float a=(1.0-smoothstep(0.3,0.9,d))*0.16*vA;
gl_FragColor=vec4(vec3(0.78,0.9,0.95),a);
#include <fog_fragment>
#include <colorspace_fragment>
}
```
Note: with fog, alpha stays; fog affects rgb → distant dots fade toward fog color but alpha remains — they'd become fog-colored dots — acceptable (alpha low) ✓. Actually better fade alpha with fog too — minor; skip.
Bubble attribute setup: NB=90:
```js
const NB=90;
const bBase=new Float32Array(NB*3), bSeed=new Float32Array(NB), bRate=new Float32Array(NB), bSize=new Float32Array(NB);
const bEm=[[-8.4,-5.2],[9.3,-6.6]];
for(let i=0;i<NB;i++){
const e=bEm[i%2];
const x=e[0]+rand(-0.55,0.55), z=e[1]+rand(-0.55,0.55);
bBase[i*3]=x; bBase[i*3+1]=sandHeight(x,z)+0.15; bBase[i*3+2]=z;
bSeed[i]=rand(100); bRate[i]=rand(1.0,2.1); bSize[i]=rand(0.35,0.85);
}
const bubGeo=new THREE.BufferGeometry();
bubGeo.setAttribute('position',new THREE.BufferAttribute(bBase,3)); // actual per-vertex pos unused but needed?
```
Hmm — ShaderMaterial must have `position` attribute? My vertex shader doesn't use `position`... but three REQUIRES position attribute for draw count! The draw range/draw count derives from position attribute. Without it, nothing draws. So set position = bBase (unused in shader but defines count) — but then I also read aBase — redundant. Simpler: name the base attribute 'position' and use it directly in shader (three auto-declares position) ✓! So: position attr = base positions; shader uses `position` as base ✓. Same for snow & food (food uses real position ✓).
So bubbles:
```glsl
// position attribute auto-declared
vec3 p=position;
float h=mod(aSeed*13.7+uTime*aRate,11.0);
...
p.y=position.y+h;
p.x=position.x+sin(...)*wob; p.z=position.z+cos(...)*wob;
```
✓. Same for snow: use position as base ✓.
Good — simpler.
For food: positions updated per frame in `position` attr ✓ shader uses position ✓.
**Points bounding sphere:** frustumCulled=false ✓.
Also — set `bubGeo.setDrawRange`? default all ✓.
Snow NS=220 similar.
Food MAXF=90, but drawRange controls ✓ with full-size attrs ✓.
Now — write final code. Also remember `renderer.getPixelRatio()` equals PR — used in uniforms as PR constant ✓.
**One more subtle thing:** ShaderMaterial + `#include <fog_pars_vertex>` requires USE_FOG define — set automatically only when material.fog=true ✓ set in constructor params `fog:true` ✓.
Ring/ripple/dome: fog:false default ✓ they don't include fog chunks ✓.
Ray material: no fog chunks ✓.
Let me now also double-check the **injectWag replacement strings match r170 chunks**: `#include <common>` ✓ appears in vertex. `#include <begin_vertex>` ✓ appears. OK.
For MeshStandardMaterial fragment `#include <map_fragment>` ✓ appears.
All set — writing final answer!
Ah — one more: OrbitControls minDistance 7 — initial distance 18.3 ✓.
Also **prevent context menu** on canvas to avoid right-click menu during drags: `canvas.addEventListener('contextmenu',e=>e.preventDefault())` ✓ polite.
Also OrbitControls rotate speed default fine; set `controls.rotateSpeed=0.6` gentle ✓; zoomSpeed 0.6.
Done. Write.
Let me also compute dom heights: brand top 26 + content ~ 130px — fine at 800 height.
FINAL ANSWER — write everything.
Actually one more check on the fish wag: `aTail` is InstancedBufferAttribute with itemSize 2 → shader `attribute vec2 aTail;` ✓ three maps buffer to attribute ✓.
And **fish material needs `defines` nothing** ✓.
Also `mesh.setColorAt` BEFORE first render ✓ done at spawn.
Writing now — final.
During final write, I must keep `sstep` name (JS) and use it in bodyColor closures — those run at build ✓.
The `STATES`/`setStatus` defined before tick ✓ order in file.
Also `let elapsed=0` before listeners referencing it ✓ (they fire after load anyway).
Write final HTML file now. Also make sure to define `const UP` before anemone. And `_e`,`_q`,`_m`,`_v1.._v3` before first use in builds. Define right after helpers.
Also `raycaster` before use ✓.
Let me finalize species bodyColor/finColor code exactly:
```js
// clown
bodyColor:(c,t,y)=>{
c.copy(COL.orange).lerp(COL.orangeD,sstep(0.55,1,y)*0.55);
c.lerp(COL.belly,(1-sstep(0.30,0.08,y))*0.4);
let bd=0,bw=0;
const bands=[[0.26,0.05],[0.55,0.055],[0.88,0.045]];
for(const bn of bands){
bd=Math.max(bd,bandf(t,bn[0],bn[1]+0.028,0.03));
bw=Math.max(bw,bandf(t,bn[0],Math.max(0.012,bn[1]-0.02),0.03));
}
c.lerp(COL.black,Math.min(1,bd)*0.85);
c.lerp(COL.clownW,Math.min(1,bw));
},
finColor:(part)=>{
if(part==='tail') return (c,pr)=>{c.copy(FIN.clownW).lerp(FIN.clownDark,sstep(0.5,1,pr)*0.85);};
if(part==='pect') return (c,pr)=>{c.copy(FIN.clownBase).lerp(FIN.clownDark,sstep(0.45,1,pr)*0.5);};
return (c,pr)=>{c.copy(FIN.clownBase).lerp(FIN.clownDark,sstep(0.5,1,pr)*0.7);};
}
```
tetra bodyColor:
```js
(c,t,y)=>{
c.copy(COL.silver);
c.lerp(COL.red,sstep(0.62,0.30,t)*(1-sstep(0.42,0.60,y)));
c.lerp(COL.neon,sstep(0.48,0.70,y)*sstep(0.10,0.30,t));
c.lerp(COL.white,(1-sstep(0.34,0.12,y))*0.5);
c.lerp(COL.steel,sstep(0.55,1,y)*0.45);
},
finColor:()=>(c,pr)=>{c.copy(FIN.tetraA).lerp(FIN.tetraB,sstep(0.45,1,pr)*0.7);}
```
tang:
```js
(c,t,y)=>{
c.copy(COL.yellow).lerp(COL.yellowD,sstep(0.45,0.95,y)*0.6);
c.lerp(COL.yellowL,sstep(0.70,0.97,t)*0.6);
},
finColor:()=>(c,pr)=>{c.copy(FIN.tangA).lerp(FIN.tangB,sstep(0.45,1,pr)*0.55);}
```
angel:
```js
(c,t,y)=>{
c.copy(COL.angelS).lerp(COL.angelT,sstep(0.45,0.95,y)*0.5);
const b=bandf(t,0.36,0.035,0.02)+bandf(t,0.60,0.03,0.02)+bandf(t,0.84,0.04,0.015);
c.lerp(COL.angelD,Math.min(1,b));
},
finColor:()=>(c,pr)=>{c.copy(FIN.angelA).lerp(FIN.angelB,sstep(0.4,1,pr)*0.85);}
```
COL & FIN objects precreated ✓.
Now makeFishGeo complete code:
```js
function makeFishGeo(def){
const H=def.height,W=def.width;
const prof=(t)=>Math.sin(Math.PI*Math.pow(clamp(t,0,1),1.15));
const topAt=(t)=>prof(t)*H*1.16;
const botAt=(t)=>-prof(t)*H*0.95;
const parts=[];
// body
const body=new THREE.SphereGeometry(1,24,16);
const bp=body.attributes.position;
const bcol=new Float32Array(bp.count*3);
const cc=new THREE.Color();
for(let i=0;i<bp.count;i++){
const x=bp.getX(i),y=bp.getY(i),z=bp.getZ(i);
const t=(z+1)*0.5;
const pr=prof(t);
const sy=0.95+0.21*sstep(0,0.8,y);
bp.setXYZ(i,x*pr*W,y*pr*H*sy,t-0.42);
def.bodyColor(cc,t,(y+1)*0.5);
bcol[i*3]=cc.r;bcol[i*3+1]=cc.g;bcol[i*3+2]=cc.b;
}
body.computeVertexNormals();
body.setAttribute('color',new THREE.BufferAttribute(bcol,3));
parts.push(body.toNonIndexed());
function shapeGeo(pts){
const sh=new THREE.Shape();
sh.moveTo(pts[0][0],pts[0][1]);
for(let i=1;i<pts.length;i++) sh.lineTo(pts[i][0],pts[i][1]);
sh.closePath();
const g=new THREE.ShapeGeometry(sh,4);
g.rotateY(Math.PI/2);
return g;
}
function paintFin(g,progFn,colorFn){
const p=g.attributes.position;
const arr=new Float32Array(p.count*3);
const c=new THREE.Color();
for(let i=0;i<p.count;i++){
colorFn(c,clamp(progFn(p.getX(i),p.getY(i),p.getZ(i)),0,1));
arr[i*3]=c.r;arr[i*3+1]=c.g;arr[i*3+2]=c.b;
}
g.setAttribute('color',new THREE.BufferAttribute(arr,3));
}
// caudal fin
{
const tf=def.tail, z0=-0.40;
const pts=[[0,0.05],[tf.len*0.8,tf.spread],[tf.len*0.55,tf.fork*tf.spread+0.015],[tf.len*0.55,-tf.fork*tf.spread-0.015],[tf.len*0.8,-tf.spread],[0,-0.05]];
const g=shapeGeo(pts);
g.translate(0,0,z0);
paintFin(g,(x,y,z)=>(z0-z)/tf.len,def.finColor('tail'));
parts.push(g);
}
```
Wait shapeGeo does rotateY(π/2): shape x → -z; then translate z0 → z = z0 - u ✓ prog=(z0 - z)/len ✓ since z0-z = u ✓.
Hmm z0=-0.40; z = -0.40 - u ≤ -0.40 ✓ prog positive ✓.
```js
// dorsal fin
{
const df=def.dorsal, z0=df.t0-0.42, h=df.h, ns=4;
const bottom=[];
for(let k=0;k<=ns;k++){
const u=df.len*k/ns;
bottom.push([u,topAt(clamp(df.t0-u,0.02,0.98))-0.02]);
}
const pts=[[0,bottom[0][1]],[df.len*0.30,bottom[0][1]+h*0.92],[df.len*0.72,bottom[3][1]+h*0.78],[df.len,bottom[ns][1]+h*0.28]];
for(let k=ns;k>=0;k--) pts.push(bottom[k]);
const g=shapeGeo(pts);
g.translate(0,0,z0);
const yb=topAt(df.t0)-0.05;
paintFin(g,(x,y,z)=>(y-yb)/h,def.finColor('dorsal'));
parts.push(g);
}
// ventral fin
{
const vf=def.ventral, z0=vf.t0-0.42, h=vf.h, ns=4;
const bottom=[];
for(let k=0;k<=ns;k++){
const u=vf.len*k/ns;
bottom.push([u,botAt(clamp(vf.t0-u,0.02,0.98))+0.02]);
}
const pts=[[0,bottom[0][1]],[vf.len*0.30,bottom[0][1]-h*0.92],[vf.len*0.72,bottom[3][1]-h*0.78],[vf.len,bottom[ns][1]-h*0.28]];
for(let k=ns;k>=0;k--) pts.push(bottom[k]);
const g=shapeGeo(pts);
g.translate(0,0,z0);
const yb=botAt(vf.t0)+0.05;
paintFin(g,(x,y,z)=>(yb-y)/h,def.finColor('ventral'));
parts.push(g);
}
```
bottom[3] exists (ns=4 → indices 0..4 ✓).
Note dorsal polygon: leading point sequence [0,b0] → [0.3L, b0+0.92h] → [0.72L, b3+0.78h] → [L, b4+0.28h] → bottom reversed b4..b0. Is polygon simple? The top edge from (0.3L, high) to (0.72L, b3+0.78h): b3 < b0 (body tapering) — heights: b0+0.92h vs b3+0.78h: if h small (tetra 0.06): b0≈? tang: b0=topAt(0.42)=0.355, b3=topAt(0.42-0.72*0.34=0.175): prof(0.175): 0.175^1.15≈0.143; sin(0.449)=0.434*0.33*1.16=0.166. tip1 = 0.355+0.276=0.63; tip2=0.166+0.234*... 0.78*0.30=0.234 → 0.40. Then [L=0.34, b4+0.084]: b4=topAt(0.08): prof(0.08): 0.08^1.15=0.0544; sin(0.171)=0.17*0.383=0.065+0.084=0.149. So top edge descends 0.63→0.40→0.15 while bottom edge b0=0.335→b4=0.046. At u=0.34: top 0.149 vs bottom 0.046 → top above bottom ✓ simple polygon ✓.
For angel dorsal h=0.40: tip1 = b0+0.368: b0=topAt(0.56)=0.464→0.83; mid: b3+0.312: b3=topAt(0.56-0.216=0.344): prof(0.344): 0.344^1.15=0.296; sin(0.93)=0.80*0.464=0.371+0.312=0.683; end b4=topAt(0.26)=0.294+0.112=0.406 vs bottom b4=0.274 ✓ above ✓ simple ✓.
Pectorals:
```js
{
const tp=0.66, z0=tp-0.42;
const x0=prof(tp)*W*0.85;
const y0=-prof(tp)*H*0.32;
const pts=[[0,0.03],[0.17,-0.055],[0.13,0.075]];
for(const side of [1,-1]){
const sh=new THREE.Shape();
sh.moveTo(pts[0][0],y0+pts[0][1]);
sh.lineTo(pts[1][0],y0+pts[1][1]);
sh.lineTo(pts[2][0],y0+pts[2][1]);
sh.closePath();
const g=new THREE.ShapeGeometry(sh,4);
g.rotateY(Math.PI/2);
g.rotateY(-side*0.55);
g.translate(side*x0,0,z0);
paintFin(g,(x,y,z)=>Math.hypot(x-side*x0,y-y0,z-z0)/0.17,def.finColor('pect'));
parts.push(g);
}
}
```
Streamers:
```js
if(def.streamer){
const py=botAt(0.52)+0.05, pz=0.10;
const pts=[[0,0],[0.08,-0.05],[0.46,-0.42],[0.50,-0.37],[0.10,-0.02]];
for(const side of [1,-1]){
const sh=new THREE.Shape();
sh.moveTo(pts[0][0],py+pts[0][1]);
for(let i=1;i<pts.length;i++) sh.lineTo(pts[i][0],py+pts[i][1]);
sh.closePath();
const g=new THREE.ShapeGeometry(sh,4);
g.rotateY(Math.PI/2);
g.translate(side*0.02,0,pz);
paintFin(g,(x,y,z)=>Math.hypot(x-side*0.02,y-py,z-pz)/0.46,def.finColor('streamer'));
parts.push(g);
}
}
```
Eyes:
```js
{
const te=0.88;
const ex=prof(te)*W*0.72, ey=H*0.14, ez=te-0.42;
const eyeCol=new THREE.Color(0x0b0e12);
for(const side of [1,-1]){
const g=new THREE.SphereGeometry(0.035,8,6);
g.translate(side*ex,ey,ez);
paintFin(g,()=>0,(c)=>c.copy(eyeCol));
parts.push(g.toNonIndexed());
}
}
const merged=mergeGeometries(parts);
merged.computeBoundingSphere();
return merged;
}
```
paintFin with progFn returning 0 and colorFn ignoring pr ✓ works.
Sphere geometry translate then toNonIndexed ✓ (color attr set on indexed before conversion ✓ set by paintFin ✓).
Note — SphereGeometry(1,24,16).toNonIndexed() — normals smooth ✓ (computed before). Wait — I computeVertexNormals BEFORE setting color & before toNonIndexed ✓ order in code: deform → computeVertexNormals → set color → push toNonIndexed ✓.
Eyes: default sphere normals fine (no deform) ✓.
Fins: ShapeGeometry normals: all (0,0,1) rotated by rotateY(π/2) → (1,0,0)?? rotateY(π/2) maps +z→+x ✓ then second rotateY(-side*0.55) rotates normals too (applied to whole geometry incl normals ✓ rotateY transforms normals ✓ since uniform rotation ✓).
Fins unlit from behind? DoubleSide handles ✓.
**mergeGeometries(parts)** — parts: body non-indexed, fins non-indexed, eyes non-indexed ✓ all have position/normal/uv/color ✓.
Wait — does SphereGeometry have uv ✓; ShapeGeometry uv ✓ ✓.
One issue — **ShapeGeometry UV values**: irrelevant (unused) ✓ just present.
Now — will the fin normals being (1,0,0)-ish make fins lighting flat-dark from some angles? They're double-sided thin fins; with hemi light they'll get some light ✓ plus fill light ✓ acceptable stylized.
**Fish eye z: ez=0.46, body half-width at te: prof=0.41*W... ex=0.41*W*0.72=0.295W. For angel W=0.075: ex=0.022; sphere radius 0.035 > half width 0.031 → eye sticks out both sides slightly — bulgy eyes on angelfish — charming-ish? Eye radius 0.035 relative to length 1.15*... acceptable.
**Now the loop:**
```js
const clock=new THREE.Clock();
let elapsed=0;
function tick(){
requestAnimationFrame(tick);
const dt=Math.min(clock.getDelta(),0.033);
elapsed+=dt;
timeU.value=elapsed;
if(elapsed-lastInteract>16){
controls.autoRotate=true;
if(Math.abs(controls.getAzimuthalAngle())>0.95) rotDir=-rotDir;
controls.autoRotateSpeed=0.26*rotDir;
} else controls.autoRotate=false;
controls.update();
camera.position.y=clamp(camera.position.y,1.5,10.6);
updateFood(dt);
updateFish(dt);
updateFX(dt);
renderer.render(scene,camera);
}
tick();
```
resize:
```js
addEventListener('resize',()=>{
camera.aspect=innerWidth/innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth,innerHeight);
});
```
Everything else per plan. Let me also make sure **`nextAuto` declared** with let at module top: `let nextAuto=1.35;` ✓ and rotDir=1 ✓.
**DOM refs for status defined before tick ✓.**
Now assemble the full file. Also define `const raycaster=new THREE.Raycaster();` before input handlers ✓.
Also `spawnRipple` uses ripplePool — define before pointerup handler registered? Handler executes on event, by then all initialized ✓ regardless of order — but tick references all ✓. Function declarations hoisted; const ripplePool defined at module top-level before any event ✓ order in file: define ripples before input section ✓.
Write it all out now in the answer. Also count that every GLSL string is syntactically valid. I'll write carefully.
One more GLSL check — food vertex uses `#include <fog_pars_vertex>` and `#include <fog_vertex>` — mvPosition defined ✓.
And bubbles/snow same pattern ✓.
For dome vertex: uses cameraPosition (auto uniform ✓ available in ShaderMaterial ✓).
OK — final answer.
Also: `const PR=Math.min(window.devicePixelRatio||1,1.75);` — window.devicePixelRatio ✓.
Let me also double-check `new THREE.InstancedBufferAttribute(new Float32Array(count*2),2)` and `setXY` ✓.
And `tailAttr.setUsage(THREE.DynamicDrawUsage)` ✓.
FINAL. Writing the response with the complete HTML file. Given the size, stay focused and consistent.
Also — the first `pointermove` immediately sets lastInteract=elapsed (elapsed still 0 before first tick? elapsed initialized 0 then ticks) → autoRotate initially ON until first mouse move over iframe — the judge's automated screenshot environment likely has no mouse over the iframe → autoRotate drifts gently ✓ good.
Also `#hint` mention of cursor… fine.
Deliver.