Interactive 3D aquariumOne-line
DeepSeek V4.1 Flash 1017
by DeepSeekReasoning1 console error
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 3 min 33 s
- Cost
- $0.019
- Output tokens
- 31,560
- Spent thinking
- 23,544 tokens
- Speed
- 148.4 tok/s
- Console errors
- 1
- First token after
- 533 ms
- First visible text after
- 2 min 52 s
- Prompt tokens
- 435
- Code size
- 22.5 KB
- Finish reason
- stop
- Served by
- InferenceNet
- Model id
deepseek/deepseek-v4.1-flash-1017- Reasoning sent
{"effort":"high"}- Max output
- 64,000 tokens
- Generated
- Nov 11, 2026
Console errors
Caught in the first 3 seconds after load, in a sandboxed iframe like the one above.
Cannot access 'bubblePosRef' before initialization
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 · 728 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>3D Aquarium — Shy Fish</title>
<style>
html, body {
margin: 0; padding: 0; height: 100%; width: 100%;
overflow: hidden; background: #05131f;
-webkit-tap-highlight-color: transparent;
}
#app { position: fixed; inset: 0; }
canvas { display: block; }
#vignette {
position: fixed; inset: 0; pointer-events: none;
background: radial-gradient(ellipse at 50% 42%, rgba(0,0,0,0) 42%, rgba(0,8,18,0.62) 100%);
}
#hint {
position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 13px; letter-spacing: 0.12em; text-transform: uppercase;
color: rgba(198, 238, 255, 0.82);
background: rgba(6, 30, 46, 0.42);
border: 1px solid rgba(140, 220, 255, 0.22);
padding: 10px 20px; border-radius: 999px;
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
pointer-events: none; user-select: none;
white-space: nowrap;
animation: pulse 3.2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
@media (max-width: 560px) {
#hint { font-size: 10px; letter-spacing: 0.08em; padding: 8px 12px; bottom: 16px; }
}
</style>
</head>
<body>
<div id="app"></div>
<div id="vignette"></div>
<div id="hint">Move your cursor through the water — the fish dart away</div>
<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}
</script>
<script type="module">
import * as THREE from 'three';
/* ------------------------------------------------------------------ *
* RENDERER / SCENE / CAMERA
* ------------------------------------------------------------------ */
const container = document.getElementById('app');
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.06;
container.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x0b435f, 0.021);
const camera = new THREE.PerspectiveCamera(52, window.innerWidth / window.innerHeight, 0.1, 400);
camera.position.set(0, 1.4, 17);
camera.lookAt(0, 0.4, 0);
/* ------------------------------------------------------------------ *
* WATER BACKGROUND (gradient dome)
* ------------------------------------------------------------------ */
{
const bgMat = new THREE.ShaderMaterial({
side: THREE.BackSide,
depthWrite: false,
uniforms: {
cTop: { value: new THREE.Vector3(0.16, 0.55, 0.74) },
cBot: { value: new THREE.Vector3(0.008, 0.045, 0.085) },
cGlow: { value: new THREE.Vector3(0.42, 0.82, 1.0) }
},
vertexShader: /* glsl */`
varying vec3 vDir;
void main() {
vDir = normalize(position);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: /* glsl */`
varying vec3 vDir;
uniform vec3 cTop, cBot, cGlow;
void main() {
float h = clamp(vDir.y * 0.5 + 0.5, 0.0, 1.0);
vec3 col = mix(cBot, cTop, pow(h, 0.8));
float glow = smoothstep(0.62, 1.0, h);
col += cGlow * glow * 0.35;
gl_FragColor = vec4(col, 1.0);
}`
});
const bg = new THREE.Mesh(new THREE.SphereGeometry(140, 32, 20), bgMat);
bg.frustumCulled = false;
scene.add(bg);
}
/* ------------------------------------------------------------------ *
* LIGHTS
* ------------------------------------------------------------------ */
scene.add(new THREE.HemisphereLight(0x9fe8ff, 0x0d2a38, 1.25));
const sun = new THREE.DirectionalLight(0xcdf6ff, 1.6);
sun.position.set(6, 26, 10);
scene.add(sun);
const fill = new THREE.DirectionalLight(0x2f7ba8, 0.75);
fill.position.set(-12, 3, -10);
scene.add(fill);
/* ------------------------------------------------------------------ *
* SAND FLOOR
* ------------------------------------------------------------------ */
{
const geo = new THREE.PlaneGeometry(170, 170, 90, 90);
geo.rotateX(-Math.PI / 2);
const pos = geo.attributes.position;
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i);
const z = pos.getZ(i);
const h = Math.sin(x * 0.09) * Math.cos(z * 0.07) * 0.45
+ Math.sin(x * 0.23 + z * 0.19) * 0.16
+ Math.cos(z * 0.31 - x * 0.12) * 0.10;
pos.setY(i, h);
}
geo.computeVertexNormals();
const sand = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({
color: 0xb6a684, roughness: 0.98, metalness: 0.0
}));
sand.position.y = -6;
scene.add(sand);
}
/* ------------------------------------------------------------------ *
* CAUSTICS ON THE FLOOR
* ------------------------------------------------------------------ */
const causticTime = { value: 0 };
{
const geo = new THREE.PlaneGeometry(95, 95, 1, 1);
geo.rotateX(-Math.PI / 2);
const mat = new THREE.ShaderMaterial({
uniforms: { uTime: causticTime },
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
vertexShader: /* glsl */`
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: /* glsl */`
uniform float uTime;
varying vec2 vUv;
void main() {
vec2 uv = vUv * 26.0;
float t = uTime * 0.55;
float a = sin(uv.x * 0.9 + t * 1.3) * sin(uv.y * 1.1 - t);
float b = sin((uv.x + uv.y) * 0.65 - t * 1.1) * sin((uv.x - uv.y) * 0.75 + t * 0.7);
float v = clamp(abs(a + b) * 0.7, 0.0, 1.0);
float c = pow(1.0 - v, 7.0);
float fade = smoothstep(0.5, 0.13, distance(vUv, vec2(0.5)));
vec3 col = vec3(0.36, 0.80, 1.0) * c * fade;
gl_FragColor = vec4(col, 1.0);
}`
});
const plane = new THREE.Mesh(geo, mat);
plane.position.y = -5.25;
scene.add(plane);
}
/* ------------------------------------------------------------------ *
* ROCKS
* ------------------------------------------------------------------ */
{
const rockMat = new THREE.MeshStandardMaterial({
color: 0x37505f, roughness: 0.95, metalness: 0.05, flatShading: true
});
for (let i = 0; i < 16; i++) {
const r = 0.7 + Math.random() * 2.3;
const geo = new THREE.IcosahedronGeometry(r, 0);
const p = geo.attributes.position;
for (let v = 0; v < p.count; v++) {
p.setXYZ(v,
p.getX(v) * (0.75 + Math.random() * 0.55),
p.getY(v) * (0.75 + Math.random() * 0.55),
p.getZ(v) * (0.75 + Math.random() * 0.55)
);
}
geo.computeVertexNormals();
const rock = new THREE.Mesh(geo, rockMat);
rock.position.set((Math.random() - 0.5) * 46, -6 + r * 0.35, (Math.random() - 0.5) * 40 - 5);
rock.scale.set(1, 0.55 + Math.random() * 0.5, 1);
rock.rotation.set(Math.random() * 3, Math.random() * 6, Math.random() * 3);
scene.add(rock);
}
}
/* ------------------------------------------------------------------ *
* SEAWEED (vertex-shader sway)
* ------------------------------------------------------------------ */
const plantTime = { value: 0 };
function createPlant(cx, cz) {
const blades = 3 + ((Math.random() * 3) | 0);
for (let i = 0; i < blades; i++) {
const h = 2.4 + Math.random() * 4.6;
const w = 0.16 + Math.random() * 0.24;
const geo = new THREE.PlaneGeometry(w, h, 1, 12);
geo.translate(0, h / 2, 0);
const mat = new THREE.ShaderMaterial({
uniforms: {
uTime: plantTime,
uPhase: { value: Math.random() * 24.0 }
},
side: THREE.DoubleSide,
vertexShader: /* glsl */`
uniform float uTime;
uniform float uPhase;
varying float vK;
void main() {
vK = uv.y;
vec3 p = position;
float k = uv.y * uv.y;
p.x += sin(uTime * 1.05 + uPhase + p.y * 0.55) * 1.35 * k;
p.z += cos(uTime * 0.85 + uPhase * 1.4 + p.y * 0.45) * 0.95 * k;
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}`,
fragmentShader: /* glsl */`
varying float vK;
void main() {
vec3 col = mix(vec3(0.025, 0.15, 0.095), vec3(0.16, 0.62, 0.34), vK);
gl_FragColor = vec4(col, 1.0);
}`
});
const blade = new THREE.Mesh(geo, mat);
blade.position.set(
cx + (Math.random() - 0.5) * 1.6,
-6.25,
cz + (Math.random() - 0.5) * 1.6
);
blade.rotation.y = Math.random() * Math.PI;
blade.rotation.z = (Math.random() - 0.5) * 0.22;
scene.add(blade);
}
}
for (let i = 0; i < 14; i++) {
createPlant((Math.random() - 0.5) * 38, -14 + Math.random() * 16);
}
/* ------------------------------------------------------------------ *
* LIGHT SHAFTS
* ------------------------------------------------------------------ */
{
const c = document.createElement('canvas');
c.width = 128; c.height = 256;
const ctx = c.getContext('2d');
const g = ctx.createLinearGradient(0, 0, 0, 256);
g.addColorStop(0.00, 'rgba(190,245,255,0.85)');
g.addColorStop(0.35, 'rgba(150,225,255,0.32)');
g.addColorStop(1.00, 'rgba(120,200,255,0.0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, 128, 256);
const hg = ctx.createLinearGradient(0, 0, 128, 0);
hg.addColorStop(0.0, 'rgba(0,0,0,0)');
hg.addColorStop(0.5, 'rgba(0,0,0,1)');
hg.addColorStop(1.0, 'rgba(0,0,0,0)');
ctx.globalCompositeOperation = 'destination-in';
ctx.fillStyle = hg;
ctx.fillRect(0, 0, 128, 256);
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
const shaftMat = new THREE.MeshBasicMaterial({
map: tex,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
side: THREE.DoubleSide,
opacity: 0.26,
fog: false
});
for (let i = 0; i < 7; i++) {
const w = 7 + Math.random() * 9;
const hgt = 42 + Math.random() * 18;
const m = new THREE.Mesh(new THREE.PlaneGeometry(w, hgt), shaftMat);
m.position.set(-32 + Math.random() * 64, hgt * 0.32, -28 + Math.random() * 26);
m.rotation.set(
(Math.random() - 0.5) * 0.25,
Math.random() * 0.5,
0.22 + (Math.random() - 0.5) * 0.28
);
scene.add(m);
}
}
/* ------------------------------------------------------------------ *
* BUBBLES
* ------------------------------------------------------------------ */
let bubbles, bubblePos, bubbleSpeed, bubbleDrift;
{
const BUBBLES = 200;
const c = document.createElement('canvas');
c.width = c.height = 64;
const ctx = c.getContext('2d');
const g = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
g.addColorStop(0.0, 'rgba(255,255,255,0.95)');
g.addColorStop(0.35, 'rgba(205,242,255,0.45)');
g.addColorStop(0.75, 'rgba(160,220,255,0.13)');
g.addColorStop(1.0, 'rgba(160,220,255,0.0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(32, 32, 32, 0, Math.PI * 2);
ctx.fill();
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
bubblePos = new Float32Array(BUBBLES * 3);
bubbleSpeed = new Float32Array(BUBBLES);
bubbleDrift = new Float32Array(BUBBLES);
for (let i = 0; i < BUBBLES; i++) {
bubblePos[i * 3 + 0] = (Math.random() - 0.5) * 36;
bubblePos[i * 3 + 1] = -6 + Math.random() * 16;
bubblePos[i * 3 + 2] = (Math.random() - 0.5) * 26;
bubbleSpeed[i] = 0.5 + Math.random() * 1.4;
bubbleDrift[i] = Math.random() * Math.PI * 2;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(bubblePos, 3));
bubbles = new THREE.Points(geo, new THREE.PointsMaterial({
map: tex,
size: 0.42,
sizeAttenuation: true,
transparent: true,
depthWrite: false,
opacity: 0.85,
blending: THREE.AdditiveBlending
}));
bubblePosRef = geo.attributes.position;
scene.add(bubbles);
}
let bubblePosRef;
/* ------------------------------------------------------------------ *
* FISH
* ------------------------------------------------------------------ */
const PALETTES = [
[0xff8a3d, 0xffd166],
[0xffc94d, 0xfff3b0],
[0x54c8e8, 0x9ef0ff],
[0xff6f91, 0xffc2d1],
[0x9b7bff, 0xd6c8ff],
[0x6ee7a8, 0xc9ffdd],
[0xe9eef7, 0xffffff],
[0xff5c5c, 0xffb3b3],
[0x4f9bff, 0xa8d4ff]
];
const bodyGeo = new THREE.SphereGeometry(1, 18, 12);
const eyeGeo = new THREE.SphereGeometry(1, 8, 8);
const tailShape = new THREE.Shape();
tailShape.moveTo(0, 0);
tailShape.lineTo(-0.62, 0.78);
tailShape.quadraticCurveTo(-0.20, 0, -0.62, -0.78);
tailShape.lineTo(0, 0);
const tailGeo = new THREE.ShapeGeometry(tailShape);
const dorsalShape = new THREE.Shape();
dorsalShape.moveTo(0.52, 0);
dorsalShape.quadraticCurveTo(0.05, 0.62, -0.45, 0.02);
dorsalShape.lineTo(0.52, 0);
const dorsalGeo = new THREE.ShapeGeometry(dorsalShape);
const pelvicShape = new THREE.Shape();
pelvicShape.moveTo(0.14, 0);
pelvicShape.quadraticCurveTo(-0.12, -0.42, -0.40, -0.02);
pelvicShape.lineTo(0.14, 0);
const pelvicGeo = new THREE.ShapeGeometry(pelvicShape);
function createFish() {
const pal = PALETTES[(Math.random() * PALETTES.length) | 0];
const group = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({
color: pal[0],
roughness: 0.33,
metalness: 0.5,
flatShading: true,
emissive: new THREE.Color(pal[0]).multiplyScalar(0.07)
});
const finMat = new THREE.MeshStandardMaterial({
color: pal[1],
roughness: 0.65,
metalness: 0.12,
flatShading: true,
side: THREE.DoubleSide,
emissive: new THREE.Color(pal[1]).multiplyScalar(0.05)
});
const eyeMat = new THREE.MeshStandardMaterial({
color: 0x0a0d14, roughness: 0.15, metalness: 0.7
});
const body = new THREE.Mesh(bodyGeo, bodyMat);
body.scale.set(0.95, 0.60, 0.34);
group.add(body);
const tail = new THREE.Group();
tail.position.set(-0.86, 0, 0);
tail.add(new THREE.Mesh(tailGeo, finMat));
group.add(tail);
const dorsal = new THREE.Mesh(dorsalGeo, finMat);
dorsal.position.set(0.0, 0.50, 0);
group.add(dorsal);
const pelvic = new THREE.Mesh(pelvicGeo, finMat);
pelvic.position.set(0.0, -0.50, 0);
group.add(pelvic);
const eyeL = new THREE.Mesh(eyeGeo, eyeMat);
eyeL.scale.setScalar(0.10);
eyeL.position.set(0.60, 0.12, 0.22);
group.add(eyeL);
const eyeR = eyeL.clone();
eyeR.position.z = -0.22;
group.add(eyeR);
const size = 0.45 + Math.random() * 0.52;
group.scale.setScalar(size);
scene.add(group);
return { group, body, tail, size };
}
const FISH = [];
const BX = 15, BY0 = -4.4, BY1 = 7.0, BZ = 9.5;
for (let i = 0; i < 28; i++) {
const parts = createFish();
parts.group.position.set(
(Math.random() - 0.5) * 26,
BY0 + 0.8 + Math.random() * (BY1 - BY0 - 1.6),
(Math.random() - 0.5) * 17
);
const f = {
group: parts.group,
body: parts.body,
tail: parts.tail,
size: parts.size,
pos: parts.group.position,
vel: new THREE.Vector3(
Math.random() - 0.5,
(Math.random() - 0.5) * 0.25,
Math.random() - 0.5
).normalize().multiplyScalar(1.4),
cruise: 1.05 + Math.random() * 1.15,
panic: 0,
phase: Math.random() * 20,
w1: 0.35 + Math.random() * 0.55,
w2: 0.30 + Math.random() * 0.60,
w3: 0.35 + Math.random() * 0.55,
p1: Math.random() * 20,
p2: Math.random() * 20,
p3: Math.random() * 20
};
FISH.push(f);
}
/* orientation helpers */
const _fwd = new THREE.Vector3();
const _up = new THREE.Vector3();
const _right = new THREE.Vector3();
const _mat = new THREE.Matrix4();
const _quat = new THREE.Quaternion();
function orient(f, dt, instant) {
_fwd.copy(f.vel);
if (_fwd.lengthSq() < 1e-8) _fwd.set(1, 0, 0);
_fwd.normalize();
_up.set(0, 1, 0);
if (Math.abs(_fwd.y) > 0.96) _up.set(0, 0, 1);
_right.crossVectors(_fwd, _up).normalize();
_up.crossVectors(_right, _fwd).normalize();
_mat.makeBasis(_fwd, _up, _right);
_quat.setFromRotationMatrix(_mat);
if (instant) {
f.group.quaternion.copy(_quat);
} else {
f.group.quaternion.slerp(_quat, 1 - Math.exp(-dt * 9));
}
}
// initial orientation
for (const f of FISH) orient(f, 0, true);
/* ------------------------------------------------------------------ *
* POINTER -> 3D RAY
* ------------------------------------------------------------------ */
const pointer = new THREE.Vector2(0, 0);
let pointerActive = false;
const raycaster = new THREE.Raycaster();
const FLEE_R = 3.2;
function setPointer(clientX, clientY) {
pointer.x = (clientX / window.innerWidth) * 2 - 1;
pointer.y = -(clientY / window.innerHeight) * 2 + 1;
pointerActive = true;
}
window.addEventListener('pointermove', (e) => setPointer(e.clientX, e.clientY), { passive: true });
window.addEventListener('pointerdown', (e) => setPointer(e.clientX, e.clientY), { passive: true });
window.addEventListener('touchmove', (e) => {
if (e.touches.length > 0) setPointer(e.touches[0].clientX, e.touches[0].clientY);
}, { passive: true });
window.addEventListener('pointerleave', () => { pointerActive = false; });
/* ------------------------------------------------------------------ *
* FISH SIMULATION
* ------------------------------------------------------------------ */
const _acc = new THREE.Vector3();
const _away = new THREE.Vector3();
function updateFish(dt, t) {
const ro = raycaster.ray.origin;
const rd = raycaster.ray.direction;
for (let i = 0; i < FISH.length; i++) {
const f = FISH[i];
const p = f.pos;
const v = f.vel;
_acc.set(0, 0, 0);
/* --- lazy wander --- */
_acc.x += Math.sin(t * f.w1 + f.p1) * 2.4;
_acc.y += Math.sin(t * f.w2 + f.p2) * 1.5;
_acc.z += Math.cos(t * f.w3 + f.p3) * 2.4;
/* --- separation --- */
for (let j = 0; j < FISH.length; j++) {
if (j === i) continue;
const o = FISH[j];
const dx = p.x - o.pos.x;
const dy = p.y - o.pos.y;
const dz = p.z - o.pos.z;
const d2 = dx * dx + dy * dy + dz * dz;
const minD = (f.size + o.size) * 1.7;
if (d2 < minD * minD && d2 > 1e-5) {
const d = Math.sqrt(d2);
const s = ((1 - d / minD) * 20) / d;
_acc.x += dx * s;
_acc.y += dy * s;
_acc.z += dz * s;
}
}
/* --- soft tank walls --- */
const m = 2.6;
if (p.x > BX - m) _acc.x -= (p.x - (BX - m)) * 6;
if (p.x < -BX + m) _acc.x += (-BX + m - p.x) * 6;
if (p.y > BY1 - m) _acc.y -= (p.y - (BY1 - m)) * 6;
if (p.y < BY0 + m) _acc.y += (BY0 + m - p.y) * 6;
if (p.z > BZ - m) _acc.z -= (p.z - (BZ - m)) * 6;
if (p.z < -BZ + m) _acc.z += (-BZ + m - p.z) * 6;
/* --- flee from the cursor ray --- */
if (pointerActive) {
const rx = p.x - ro.x;
const ry = p.y - ro.y;
const rz = p.z - ro.z;
const proj = rx * rd.x + ry * rd.y + rz * rd.z;
if (proj > 0.4) {
const cx = ro.x + rd.x * proj;
const cy = ro.y + rd.y * proj;
const cz = ro.z + rd.z * proj;
const dx = p.x - cx;
const dy = p.y - cy;
const dz = p.z - cz;
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (dist < FLEE_R) {
const threat = 1 - dist / FLEE_R;
if (dist > 1e-3) {
_away.set(dx / dist, dy / dist, dz / dist);
} else {
_away.set(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5);
if (_away.lengthSq() < 1e-6) _away.set(0, 1, 0);
_away.normalize();
}
// a little extra "away from the viewer" push
_away.x += rd.x * 0.25;
_away.y += rd.y * 0.25 + 0.10;
_away.z += rd.z * 0.25;
_away.normalize();
const force = 150 * threat * threat;
_acc.x += _away.x * force;
_acc.y += _away.y * force * 0.75;
_acc.z += _away.z * force;
f.panic = Math.min(1, f.panic + dt * 6.5 * threat);
}
}
}
f.panic = Math.max(0, f.panic - dt * 0.85);
/* --- integrate --- */
v.addScaledVector(_acc, dt);
const maxS = f.cruise * (1 + f.panic * 4.5);
const minS = f.cruise * 0.55;
let sp = v.length();
if (sp > maxS) {
v.multiplyScalar(maxS / sp);
sp = maxS;
} else if (sp < minS && sp > 1e-6) {
v.multiplyScalar(minS / sp);
sp = minS;
}
p.addScaledVector(v, dt);
/* hard clamp */
p.x = THREE.MathUtils.clamp(p.x, -BX - 1.5, BX + 1.5);
p.y = THREE.MathUtils.clamp(p.y, BY0 - 0.4, BY1 + 1.5);
p.z = THREE.MathUtils.clamp(p.z, -BZ - 1.5, BZ + 1.5);
/* --- visuals --- */
orient(f, dt, false);
const wagSpeed = 5.5 + f.panic * 16;
const wag = Math.sin(t * wagSpeed + f.phase) * (0.34 + f.panic * 0.30);
f.tail.rotation.y = wag;
f.body.rotation.y = wag * 0.16;
}
}
/* ------------------------------------------------------------------ *
* RESIZE
* ------------------------------------------------------------------ */
function onResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
}
window.addEventListener('resize', onResize);
/* ------------------------------------------------------------------ *
* MAIN LOOP
* ------------------------------------------------------------------ */
const clock = new THREE.Clock();
const lookTarget = new THREE.Vector3(0, 0.4, 0);
function animate() {
const dt = Math.min(clock.getDelta(), 0.05);
const t = clock.elapsedTime;
/* camera drift + parallax */
const targetX = Math.sin(t * 0.13) * 1.3 + pointer.x * 1.7;
const targetY = 1.4 + Math.cos(t * 0.17) * 0.35 + pointer.y * 1.0;
const k = 1 - Math.exp(-dt * 2.2);
camera.position.x += (targetX - camera.position.x) * k;
camera.position.y += (targetY - camera.position.y) * k;
camera.position.z = 17;
camera.lookAt(lookTarget);
camera.updateMatrixWorld();
raycaster.setFromCamera(pointer, camera);
/* fish */
updateFish(dt, t);
/* bubbles */
for (let i = 0; i < bubbleSpeed.length; i++) {
const iy = i * 3 + 1;
bubblePos[iy] += bubbleSpeed[i] * dt;
bubblePos[i * 3] += Math.sin(t * 0.9 + bubbleDrift[i]) * 0.35 * dt;
bubblePos[i * 3 + 2] += Math.cos(t * 0.7 + bubbleDrift[i]) * 0.30 * dt;
if (bubblePos[iy] > 9) {
bubblePos[iy] = -6;
bubblePos[i * 3] = (Math.random() - 0.5) * 36;
bubblePos[i * 3 + 2] = (Math.random() - 0.5) * 26;
}
}
bubblePosRef.needsUpdate = true;
/* shader time */
causticTime.value = t;
plantTime.value = t;
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
</script>
</body>
</html>Reasoning trace (23,544 tokens)
Let me build a 3D aquarium with Three.js. Fish are procedurally generated (body + tail), swimming with boids-like behavior, fleeing from mouse raycast point.
Key features:
- Underwater scene: fog, blue background, caustic-ish light, sand floor, rocks, plants (seaweed swaying), bubbles.
- Fish with instanced or individual meshes. Use simple geometry: a sphere scaled for body, cone for tail. Better: build fish from a custom shape — body = ConvexGeometry? Simpler: use a scaled sphere + tail fin plane triangle. Group per fish.
- Fish AI: wander with steering, flee from the mouse's projected 3D point (ray-plane intersection at fish depth or nearest point). Use raycast to a plane? Simpler: unproject mouse into world at some depth, then fish within radius flee.
- Camera: slow orbit / slight parallax with mouse.
- Click or press key to start message overlay (needed per rules, since keyboard isn't used but demo is interactive with mouse — mouse works without click actually. But the requirement says games need demo mode; this isn't a game. Still, showing a "Click or press a key to start" could be fine but maybe unnecessary. The instructions say "Games must start in a self-playing demo mode". This is not a game, it's an interactive aquarium. I'll skip the start overlay but maybe add a subtle hint "Move your mouse to scare the fish". Actually the iframe receives keyboard only after click; we don't need keys. Mouse events should work over iframe? Mouse events typically work in iframe without clicking first for hover/move. Yes, mousemove works.
Let me be careful: something meaningful visible on first paint — fish swimming immediately. Good.
Implementation details:
Scene:
- renderer with antialias, tone mapping ACESFilmic.
- background gradient: use a large sphere with shader or just scene.fog + background color. Let's do a vertical gradient background via a big sphere with BackSide and shader material.
- Fog: THREE.FogExp2(0x062a4a, 0.03).
- Lights: hemisphere + directional from above (with a slight wobble), plus point lights for color.
- God rays: use a few cone meshes with additive transparent material to fake light shafts. Could look nice.
- Sand floor: plane with vertex displacement (noise) and MeshStandardMaterial sandy color. Add fog.
- Rocks: icosahedron geometry with random scaling, dark.
- Seaweed: several thin planes/cylinders that sway in the vertex shader or via per-frame rotation. Simpler: use TubeGeometry or cone strips. Use a custom shader? Simpler approach: create a set of "blade" meshes (PlaneGeometry with segments), animate vertices in JS each frame — maybe 20 blades × 8 segments is fine. Or use shader material with wind uniform. I'll do a shader-based sway using onBeforeCompile? Simplest: animate in JS: each blade has geometry with segments, update positions.
Actually easier: use a small number of tall tapered cylinders per plant with per-frame bending via rotation of segments — nah. Let's use planes with a custom ShaderMaterial: transparent green, sway based on time and y position. That's clean.
- Bubbles: Points with a circular sprite texture made from canvas, moving upward, resetting.
- Fish: create ~25 fish of a few types.
Fish geometry construction:
Body: SphereGeometry(1, 16, 12) scaled (1, 0.55, 0.45)? Fish pointing along +X? Let's build fish facing +Z or +X. Common: build facing +X (length along X). I'll build facing +X.
Body: sphere scaled to (0.5, 0.3, 0.18) → length 1 in X. Tail: cone or a custom triangle geometry at x=-0.5, in the XY plane (vertical fin). Add a dorsal fin. Add small eye spheres (black) at ±0.12 on Z.
Actually a nicer fish silhouette: use a LatheGeometry? Let's just use sphere scaled + tail fins; with flat shading and nice colors it reads well.
Fish material: MeshStandardMaterial with color, roughness 0.4, metalness 0.2, flatShading maybe.
Tail animation: rotate tail group around Y axis with sin(time*speed + phase).
Fish AI:
Each fish has position, velocity (Vector3), maxSpeed, size, wander phase.
Behavior:
- Boundary steering: keep inside a box (aquarium bounds) with soft push.
- Flee from mouse point: the mouse maps to a 3D point. Compute mouseWorld by raycasting against a plane at z = 0? But fish are distributed in depth. Better: compute the mouse world point at the camera-facing plane through the aquarium center. Then fish flee if distance < radius, but also weight by depth proximity? Simpler and effective: compute flee force inversely proportional to distance to the mouse ray (distance from fish to the ray line), which handles depth nicely: fish near the cursor's line of sight flee. That's great — no need to project.
So: ray = camera unproject. Distance from fish position to the ray = |(p - o) - ((p-o)·d)d|. If < R, flee away from closest point on ray, in direction perpendicular with some forward component away from camera.
That's elegant. Use R ~ 2.5.
Also fish avoid each other slightly (separation), and align/cohere lightly for schooling. Keep cheap.
Steering: desired velocity accumulation → clamp to maxSpeed, lerp.
Also fish should tilt/bank based on turning. Set quaternion from lookAt direction with up, plus roll.
Camera: OrbitControls? Addons available. Maybe gentle auto-rotation with mouse parallax. But OrbitControls would let users rotate; however mouse move should scare fish. Let's use a fixed camera with slight parallax based on mouse position (offsets), no OrbitControls. That keeps focus on interaction.
Actually a slow orbit could be nice: camera slowly moves left-right. Let's do subtle camera drift + parallax (damped).
Mouse ray: create Raycaster from camera and mouse NDC. camera.updateMatrixWorld each frame.
Wireframe? no.
Let's write the code.
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>3D Aquarium</title>
<style>...</style>
</head>
<body>
<div id="app"></div>
<div id="hint">Move your mouse — the fish will swim away</div>
<script type="importmap">...</script>
<script type="module">
...
</script>
</body>
</html>
```
Now the JS.
```js
import * as THREE from 'three';
const container = document.getElementById('app');
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(55, innerWidth/innerHeight, 0.1, 200);
camera.position.set(0, 1.5, 16);
const renderer = new THREE.WebGLRenderer({antialias:true});
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
container.appendChild(renderer.domElement);
```
Background gradient: big sphere with ShaderMaterial side BackSide, depthWrite false. Radius 120.
Colors: deep teal top #0a3d5c → bottom darker #01121f? Actually underwater: top brighter, bottom dark.
```js
const bgGeo = new THREE.SphereGeometry(100, 32, 32);
const bgMat = new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite: false,
uniforms: { top: {value: new THREE.Color(0x2a90b8)}, bottom: {value:new THREE.Color(0x03141f)} },
vertexShader: `varying vec3 vP; void main(){ vP = position; gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
fragmentShader: `varying vec3 vP; uniform vec3 top; uniform vec3 bottom; void main(){ float h = normalize(vP).y*0.5+0.5; vec3 c = mix(bottom, top, pow(h, 0.9)); gl_FragColor = vec4(c,1.0);} `
});
```
Hmm with ACES tonemapping shader material output is in linear? ShaderMaterial isn't tone mapped by default (toneMapped default true for ShaderMaterial? Actually Material.toneMapped default true; for ShaderMaterial, tone mapping isn't applied unless you include the chunks). Let's not worry; set colors to look decent. Or use renderer.setClearColor and a fog. Simpler and safer: plain gradient sphere with basic shader. Fine.
Alternatively use scene.background = new THREE.Color(0x073b55) and fog. The gradient sphere adds depth. I'll keep the sphere but make sure colors chosen look right after sRGB conversion. With renderer.outputColorSpace = SRGB (default), colors in shader are output linear→sRGB conversion is applied automatically? For ShaderMaterial output conversion happens only if the material includes the colorspace_fragment chunk. Since it doesn't, the raw value goes to the framebuffer... Actually in WebGLRenderer, the output color space conversion is done in shader via includes; ShaderMaterial without includes outputs raw values which are treated as final. So writing vec3(0.05,0.2,0.3) gives that value directly → looks like sRGB. Good, simpler: I'll just pick sRGB-ish colors directly.
Hmm but ACES tonemapping doesn't apply either. Fine.
Let's use simpler: gl_FragColor = vec4(mix(bottom, top, h), 1.0) with nice hex colors converted via THREE.Color with SRGBToLinear? No — just use raw floats from hex/255.
Let me pick top: (0.13, 0.45, 0.62) and bottom (0.01, 0.06, 0.11). And a soft glow near top center.
Add depth fog: FogExp2(0x073b55, 0.022). Fog affects the sand and fish, blending them into the background color. Good, but background color differs from gradient... it's fine if close.
Actually with a gradient background sphere and fog, distant objects fade to fog color which should roughly match mid gradient. Choose fog color 0x0a4a68.
Lights:
- HemisphereLight(0x88ddff, 0x224466, 0.8)
- DirectionalLight(0xaaffff, 1.2) from (5, 20, 5)
- PointLight warm? maybe not.
Shadow: skip shadows for perf/simplicity, but a subtle shadow on the sand would help grounding. Skip.
Sand floor: PlaneGeometry(80, 80, 64, 64) rotated -PI/2 at y=-6, with vertex noise displacement, MeshStandardMaterial color 0xd8c9a3 with roughness 1. Then fog will fade it.
Wait, camera at z=16 looking at 0,0,0 with floor at y=-6: fine.
Rocks: 12 icosahedrons randomly scattered, dark grey-blue, low poly.
Seaweed: shader-based blades.
Let me write a plant as a set of blades: For each plant, create a group of 3-6 blades, each a PlaneGeometry(0.25, height, 1, 8) positioned with slight offsets and rotations, with ShaderMaterial:
```glsl
uniform float uTime; uniform float uPhase;
varying float vY;
void main(){
vec3 p = position;
float k = uv.y; // 0 bottom, 1 top
p.x += sin(uTime*1.5 + uPhase + p.y*1.2) * 0.35 * k*k;
p.z += cos(uTime*1.1 + uPhase + p.y*0.9) * 0.2 * k*k;
...
}
```
Since geometry is a plane with height along Y and we position it so bottom at 0 (translate geometry up by height/2). Use uv.y for k.
Material: side DoubleSide, transparent, color green, blending normal, depthWrite true. Add slight gradient via vY.
Bubbles: Points with sizeAttenuation. Positions array; each frame y += speed*dt, wrap.
Fish creation function:
```js
function makeFish(color, size) {
const g = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({color, roughness:0.35, metalness:0.25, flatShading:true});
const body = new THREE.Mesh(new THREE.SphereGeometry(1, 14, 10), bodyMat);
body.scale.set(1.0, 0.55, 0.32); // length along X
g.add(body);
...
}
```
Hmm, sphere radius 1 with scale (1, .55, .32) gives body length 2. For a fish of length ~1.4, scale group down.
Tail: a cone? Let's use a custom shape: BufferGeometry triangle fan for a crescent tail. Simpler: use ConeGeometry(0.5, 0.9, 4) rotated so it points along -X and flattened in Z: scale.z = 0.08. Position at x=-1.0. Rotate cone so axis along X: cone default axis +Y, so rotate z by -PI/2 → axis along +X... rotateZ(-Math.PI/2) maps +Y to +X. We want tail extending in -X, so rotateZ(Math.PI/2) maps +Y to -X. Good: cone tip at -X, base at x=0. Position at x = -1.0 so base at body's rear.
Actually a cone tail looks like a spike. Better a crescent fin: use a Shape with two arcs? Let's make a simple triangle-plane fin:
```js
const tailShape = new THREE.Shape();
tailShape.moveTo(0,0);
tailShape.lineTo(-0.55, 0.55);
tailShape.lineTo(-0.35, 0);
tailShape.lineTo(-0.55,-0.55);
tailShape.closePath();
const tailGeo = new THREE.ExtrudeGeometry?
```
Use ShapeGeometry (flat in XY plane) — fish fins are vertical planes (XY plane since fish faces +X, up is Y). Yes! ShapeGeometry gives a flat shape in XY.
Tail fin shape as above: a forked tail. Position at x=-1.0 (rear of body). The shape extends to -0.55 → total. Scale accordingly.
Dorsal fin: another shape above body: triangle from (0.1, 0.4) to (-0.3, 0.9) etc. Use ShapeGeometry in XY plane, same.
Pectoral fins: small shapes rotated.
Eye: small sphere black at (0.55, 0.15, ±0.22).
Group everything; then animate tail rotation? The tail is a flat ShapeGeometry positioned; rotating a Group containing it around its base point (x=-1.0) works if the shape geometry starts at (0,0). Yes: create tailGroup at position (-1.0, 0, 0), add mesh with shape geometry as defined (extends to -X). Then rotate tailGroup.rotation.y by sin → tail wags side to side.
Body wiggle: rotate body slightly too? Keep simple.
Fish scale: group.scale.setScalar(size).
Fish orientation: fish faces +X locally. To orient along velocity direction, use quaternion from (1,0,0) to normalized velocity: `q.setFromUnitVectors(new THREE.Vector3(1,0,0), dir)`. But that gives arbitrary roll. Better: build a matrix lookAt. Use `const m = new THREE.Matrix4().lookAt(new Vector3(0,0,0), dir, up)` — that gives -Z facing? THREE.Matrix4.lookAt(eye, target, up) creates a rotation where -Z looks from eye toward target... Actually Object3D.lookAt makes the object's +Z axis point... no: for cameras, -Z; for objects, +Z points toward the target? Let's recall: Object3D.lookAt uses m1.lookAt(target, position, up) for cameras and m1.lookAt(position, target, up) for others; Matrix4.lookAt(eye, target, up) creates basis with z = normalize(eye - target) (pointing from target to eye). For non-camera objects, lookAt(position, target, up) → z = normalize(position - target)?? Hmm, that yields +Z pointing away from target... Actually no: for objects, it's `m1.lookAt(_position, _target, this.up)` where _position = object world position and _target = target world pos. Matrix4.lookAt(eye,target,up): z = eye - target normalized. So z axis points from target to eye, i.e., away from target. But then object's +Z faces away from target... that's wrong? No wait — Object3D's default forward is -Z (like camera) for lookAt purposes? Hmm, actually for Mesh.lookAt(target), the mesh's +Z axis points toward the target. Let me just verify mentally with the code:
```js
lookAt( x, y, z ) {
...
const parent = this.parent;
this.updateWorldMatrix( true, false );
_position.setFromMatrixPosition( this.matrixWorld );
if ( this.isCamera || this.isLight ) {
_m1.lookAt( _position, _target, this.up );
} else {
_m1.lookAt( _target, _position, this.up );
}
this.quaternion.setFromRotationMatrix( _m1 );
```
So for objects: _m1.lookAt(_target, _position, up) → eye=_target, target=_position → z = normalize(target - position) = direction toward target. So +Z points toward target. Good.
So if I build the fish facing +Z instead of +X, I can use fish.lookAt(pos + vel). Simpler! Let's build the fish facing +Z: body scaled (0.32, 0.55, 1.0) — length along Z. Tail at z=-1.0, in the... fins vertical planes: for a fish facing +Z, the tail fin lies in the YZ plane. ShapeGeometry is in XY, so rotate Y by 90° to put it in ZY plane. Or just build shapes in XY and rotate the group by PI/2 about Y. Hmm.
Alternative: keep building geometry in "X forward" space, then wrap in an inner group rotated by -PI/2 about Y so that local +Z is forward. Hmm, confusing.
Let's just build with X forward and set rotation manually using quaternion from unit vectors plus keep up. Rolling isn't a big deal if we compute:
```js
const dir = vel.clone().normalize();
const quat = new THREE.Quaternion().setFromUnitVectors(FORWARD, dir); // FORWARD = (1,0,0)
```
This gives a valid rotation, but the roll around the forward axis is whatever the shortest-arc gives — as the fish turns, the roll may twist, and with a fish that's roughly axially symmetric-ish (flat body) it could look odd. Fish body is flattened in Z, so roll matters.
Better approach: build a basis matrix.
```js
const fwd = dir;
const up = new THREE.Vector3(0,1,0);
const right = new THREE.Vector3().crossVectors(up, fwd).normalize(); // since fish's local Z is "side"
// careful with degenerate
const up2 = new THREE.Vector3().crossVectors(fwd, right).normalize();
m.makeBasis(fwd, up2, right); // columns: local X→fwd, local Y→up2, local Z→right
```
Wait makeBasis(xAxis, yAxis, zAxis) sets matrix columns. If local X is forward, local Y is up, local Z is the side. Need right-handed: X × Y = Z. fwd × up2 should equal right. Let's check: if fwd = (1,0,0), up2 = (0,1,0), then fwd×up2 = (0,0,1) = right. So compute right = fwd × up2. And up2 = up - (up·fwd)fwd normalized. Let's do:
```js
const up = new THREE.Vector3(0,1,0);
const up2 = up.clone().addScaledVector(fwd, -up.dot(fwd)).normalize();
const right = new THREE.Vector3().crossVectors(fwd, up2);
m.makeBasis(fwd, up2, right);
q.setFromRotationMatrix(m);
```
If fwd nearly vertical, fallback. Fish won't be vertical.
Then apply smoothing: fish.quaternion.slerp(targetQuat, 0.15).
Add banking: rotate around fwd axis proportional to turn rate. Optional — skip or add small.
OK. Fish facing +X locally: body sphere scaled (1, 0.55, 0.32)*bodyScale. Tail shape in XY plane at x = -1.
Let's define fish length ~2 units at scale 1, then group.scale.setScalar(size) with size ~0.5-0.9 → fish 1-1.8 units long. Aquarium bounds: x ∈ [-12,12], y ∈ [-4.5, 5], z ∈ [-8, 8]. Camera at z=16 fov 55 → visible height at z=0 is 2*16*tan(27.5°)=16.6, width ~26 at 1280x800 aspect 1.6. So x±12 fine, y ±5 fine. Fish at z from -8 to 8.
Hmm, with fog 0.022, at distance ~25 the fog factor is 1-exp(-(0.022*25)^2)= 1-exp(-0.3)=0.26. Mild. Good.
Let me set fog density 0.03 → at 25: (0.75)^2=0.56, 1-e^-0.56=0.43. Nice atmospheric.
Now fish behavior update:
```js
const FISH = [];
function updateFish(dt, t) {
for (const f of FISH) {
const p = f.pos, v = f.vel;
const acc = new THREE.Vector3();
// wander
f.phase += dt * f.wanderSpeed;
const wanderAngle = ...
// use noise-ish: perlin via sin sums
```
Simple wander: each fish has a slowly varying target direction using sin/cos of time with different frequencies:
```js
const t2 = t * f.wf + f.phase;
acc.x += Math.sin(t2*1.1)*0.5;
acc.y += Math.sin(t2*0.7+1.3)*0.3;
acc.z += Math.cos(t2*0.9+2.1)*0.5;
```
Scaled by ~1.2.
Boundary: if p.x > bx, acc.x -= (p.x-bx)*k; etc. Use soft margins.
Flee from mouse ray:
```js
const toP = p.clone().sub(rayOrigin);
const proj = toP.dot(rayDir);
const closest = rayOrigin.clone().addScaledVector(rayDir, proj);
const d = p.distanceTo(closest);
```
But closest could be behind camera for fish behind... whatever. Also we should limit to points in front of the camera (proj > 0). If proj < 0, skip.
If d < FLEE_RADIUS (2.5):
```js
const away = p.clone().sub(closest).normalize();
// add component away from camera for depth-appropriate flee
acc.addScaledVector(away, FLEE_FORCE * (1 - d/FLEE_RADIUS));
```
Also if d is tiny, use random.
Wait, fish near the ray line flee perpendicular to the ray — good. If the fish is exactly on the ray, d→0, and `away` is undefined → fallback.
Then: v += acc*dt; clamp speed between minSpeed and maxSpeed; p += v*dt. Also add drag.
Actually steering typical: desired = v.normalize().multiplyScalar(maxSpeed) + acc*dt... Let's do simpler physics:
```js
v.addScaledVector(acc, dt * f.agility);
// clamp
const sp = v.length();
const maxS = f.maxSpeed * (fleeing ? 2.2 : 1);
if (sp > maxS) v.multiplyScalar(maxS/sp);
if (sp < 0.6) v.multiplyScalar(1 + dt*2); // keep moving
p.addScaledVector(v, dt);
```
Hmm, keep speed roughly constant with steering. Let's do:
acc includes a "seek forward" force? With wander, speed will vary. I'll maintain a cruise speed: after applying acc and clamping, if speed < cruise, scale up.
Simplest robust:
```js
v.addScaledVector(acc, dt);
const speed = v.length();
if (speed > 0.0001) v.multiplyScalar(THREE.MathUtils.clamp(speed, f.minSpeed, f.maxSpeed) / speed);
```
Where minSpeed is cruise (e.g., 1.2) and maxSpeed 4.5. But when not fleeing, clamping to min 1.2 keeps them moving. Fleeing boosts maxSpeed briefly. Actually the flee force will accelerate them beyond, so set maxSpeed higher, like 9 for a burst. Use `f.burst` that decays.
Let me define per fish: cruise = 1.0–1.8, maxSpeed = 3.5 non-flee. When fleeing, we want a burst: add a `panic` value per fish that increases when threat nearby, decays. Then maxSpeed = cruise * (1 + panic*3).
Fine.
Separation: for schooling fish, do a cheap separation using only nearby fish — O(n²) with n=30 is 900 checks/frame, fine.
```js
for (j) {
const d = p.distanceToSquared(q.pos);
if (d < 1.5*1.5 && d > 0) sep += (p-q.pos)/d ...
}
```
Keep it light: separation force only.
Fish also need to not go through the sand: y > -4.
Bubbles: 200 points.
Now the mouse ray. Compute:
```js
const ndc = new THREE.Vector2();
onPointerMove: ndc.set((e.clientX/innerWidth)*2-1, -(e.clientY/innerHeight)*2+1);
```
Then each frame: raycaster.setFromCamera(ndc, camera) → ray.origin, ray.direction. Only if pointer has moved at least once (otherwise default center → fish flee from center at start; maybe fine but let's initialize ndc to (0,0)? That'd make fish flee the middle. Set a flag `pointerActive = false` initially and set true on first mousemove. Also add a slow virtual "hand" wandering so the demo shows fleeing even without mouse? The screenshot at 3s would show fish fleeing from an invisible point — could look like random scattering. Better: only flee on actual pointer.
Hmm, but the "demo mode" idea: for evaluation, an automatic screenshot at 3s. Fish swimming normally is good. Maybe add a gentle auto-"gaze" that pushes fish away slowly so there's motion. I'll leave it off; fish wander is enough motion.
Also add: when the pointer is active, a subtle glow/cursor light near the mouse ray? Could add a small point of light or ripple ring. A nice touch: a "hand" sphere at the closest point near the camera? Might be confusing. Instead, show a subtle radial ripple at the mouse in 2D? Skip. Maybe a soft point light attached at the point on the ray 12 units from camera — makes fish illuminated as they flee. Nice touch but adds cost; fine with one point light.
Camera parallax: camera.position.x = sin(t*0.15)*1.5 + ndc.x*1.2, y = 1.5 + cos(t*0.2)*0.4 - ndc.y*0.8, lookAt(0, 0.5, 0). Damped with lerp. But careful: moving the camera also moves the ray; fine.
Hmm, camera parallax + mouse ray: if camera moves, ray changes but consistent.
Let's do camera base position (0, 1.2, 16) and add small offsets.
Light shafts: create 4-6 large planes with additive gradient texture (canvas radial gradient) angled from top, slowly drifting. Could look mushy. I'll do cone meshes? Let's do simple: elongated PlaneGeometry with a canvas texture (vertical gradient, soft edges), additive blending, depthWrite false, rotated and tilted, positioned high. Might look decent.
Actually simpler and reliable: skip god rays. Add instead a caustic-like effect on the floor via an animated shader? That's nice but more code. Let's add a simple animated caustic pattern on the sand using a custom ShaderMaterial? Or modify MeshStandardMaterial with onBeforeCompile adding caustic to emissive. Might be risky.
Alternative: add a large semi-transparent plane just above the sand with an animated procedural caustic shader in additive blending. That's contained and safe.
Caustic shader:
```glsl
float caustic(vec2 uv, float t){
vec2 p = uv*6.0;
float v = 0.0;
for(int i=0;i<3;i++){ ... }
}
```
Simple approach: layered sin waves:
```glsl
float f = sin(p.x*1.7 + t) * sin(p.y*2.1 - t*0.8) + sin((p.x+p.y)*1.3 - t*1.2);
float c = pow(max(f*0.5+0.5, 0.0), 6.0);
```
Gives blob patterns. Additive with low opacity, color light cyan. Fine.
Let me now write the code carefully.
```js
// ---- Scene ----
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x0a4a68, 0.028);
```
Wait — fog affects the background sphere? The bg sphere has a ShaderMaterial without fog → not affected. Good.
Sand plane at y=-6 spanning 100x100 — with fog it fades to fog color.
Camera at y=1.2 looking at (0,0,0) — sees the sand in the lower part. Good.
Let's write it.
```html
<script type="module">
import * as THREE from 'three';
const container = document.getElementById('app');
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
container.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x0b4a68, 0.026);
const camera = new THREE.PerspectiveCamera(52, window.innerWidth/window.innerHeight, 0.1, 300);
camera.position.set(0, 1.2, 17);
```
Background sphere with gradient + a subtle bright spot near the top.
```js
const bgMat = new THREE.ShaderMaterial({
side: THREE.BackSide,
depthWrite: false,
uniforms: {
cTop: { value: new THREE.Color(0x4fb3d9) }, // careful: THREE.Color converts sRGB→linear on construction?
```
In three r152+, `new THREE.Color(0x...)` with color management enabled converts hex to linear working space. So passing to shader gives linear values, and if the shader doesn't do colorspace conversion the output will be darker/desaturated... Actually since the shader lacks the output conversion chunk, the value written is the linear value, then the framebuffer is sRGB-ish... Hmm, the canvas output is sRGB encoded automatically by the browser? No — WebGL canvas with outputColorSpace sRGB: three sets the drawing buffer to be treated as sRGB? In WebGL2, three uses `SRGB8_ALPHA8`? No — three renders to the default framebuffer and does the conversion in shader (colorspace_fragment). For raw ShaderMaterial without conversion, the output is written as-is to the default framebuffer which is displayed as sRGB (values interpreted directly). So if I write linear values, it will look dark.
To be safe I'll pass colors as plain Vector3 with sRGB values 0..1 and write them directly, so the result looks like the intended hex.
So:
```js
const bgMat = new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite: false,
uniforms: {
cTop: { value: new THREE.Vector3(0.18, 0.60, 0.78) },
cBot: { value: new THREE.Vector3(0.01, 0.05, 0.09) },
cGlow: { value: new THREE.Vector3(0.45, 0.85, 1.0) }
},
vertexShader: `
varying vec3 vDir;
void main(){
vDir = normalize(position);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `
varying vec3 vDir;
uniform vec3 cTop, cBot, cGlow;
void main(){
float h = clamp(vDir.y * 0.5 + 0.5, 0.0, 1.0);
vec3 col = mix(cBot, cTop, pow(h, 0.75));
float glow = smoothstep(0.55, 1.0, h) * 0.55;
col += cGlow * glow;
gl_FragColor = vec4(col, 1.0);
}`
});
const bg = new THREE.Mesh(new THREE.SphereGeometry(120, 32, 16), bgMat);
scene.add(bg);
```
Note: with ACES tone mapping, ShaderMaterial output isn't tonemapped (no chunk). Fine.
Wait, but scene.background is null → the clear color shows where nothing drawn; the sphere covers everything. Good.
Lights:
```js
scene.add(new THREE.HemisphereLight(0x9fe8ff, 0x1a3a4a, 1.4));
const dir = new THREE.DirectionalLight(0xcdf6ff, 1.6);
dir.position.set(6, 24, 8);
scene.add(dir);
const fill = new THREE.DirectionalLight(0x2a6f9a, 0.7);
fill.position.set(-10, 4, -8);
scene.add(fill);
```
Since materials are MeshStandardMaterial, they'll respond. Also intensity with ACES + exposure ~1.1. Might be bright. We'll tune: hemisphere 1.0, dir 1.4.
Sand:
```js
const sandGeo = new THREE.PlaneGeometry(140, 140, 90, 90);
sandGeo.rotateX(-Math.PI/2);
const pos = sandGeo.attributes.position;
for (let i=0;i<pos.count;i++){
const x = pos.getX(i), z = pos.getZ(i);
const h = Math.sin(x*0.22)*Math.cos(z*0.18)*0.6 + Math.sin(x*0.07+z*0.11)*1.1 + Math.sin(z*0.5+x*0.3)*0.15;
pos.setY(i, h);
}
sandGeo.computeVertexNormals();
const sand = new THREE.Mesh(sandGeo, new THREE.MeshStandardMaterial({ color: 0x9f8f6d, roughness: 1, metalness: 0, flatShading: false }));
sand.position.y = -6;
scene.add(sand);
```
Wait, sandy color underwater with blue fog → will look muted; fine.
Caustic plane above sand:
```js
const causticMat = new THREE.ShaderMaterial({
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
uniforms: { uTime: { value: 0 } },
vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0);} `,
fragmentShader: `...`
});
```
Plane 140x140 at y=-5.4, rotated -PI/2. The pattern with world coords... use uv*40.
Careful with additive + fog: not fogged, but it's near the ground far away → visible bright over distant floor. Multiply by fade based on distance? Use alpha decreasing with distance from center in the shader using vUv (uv centered 0.5): `float fade = smoothstep(0.5, 0.15, distance(vUv, vec2(0.5)))`. Good, kills the far part.
Rocks:
```js
const rockMat = new THREE.MeshStandardMaterial({color:0x35485a, roughness:0.95, flatShading:true});
for (let i=0;i<18;i++){
const r = 0.8 + Math.random()*2.2;
const geo = new THREE.IcosahedronGeometry(r, 0);
// jitter vertices
const mesh = new THREE.Mesh(geo, rockMat);
mesh.position.set((Math.random()-0.5)*40, -6 + r*0.4, (Math.random()-0.5)*30 - 6);
mesh.scale.set(1, 0.6+Math.random()*0.5, 1);
mesh.rotation.set(Math.random(), Math.random()*6, Math.random());
scene.add(mesh);
}
```
Note rocks placed with y around -6 which is the sand level; sand height varies though. Fine.
Seaweed/plants: create at random positions on the floor, y = -6.
```js
const plantMatUniforms = { uTime: {value:0} };
const plantMat = new THREE.ShaderMaterial({
uniforms: { uTime: plantMatUniforms.uTime },
side: THREE.DoubleSide,
transparent: true,
vertexShader: ..., fragmentShader: ...
});
```
Blades with per-blade phase: use an attribute? For a merged plant, use instanced? Simpler: each blade gets its own material clone with uPhase. Material count ~ 60 — acceptable but shader compiles per material? No, three caches programs by shader code, so it's fine.
Actually easier: add `uPhase` as a uniform per blade material (clone). Let's do 8 plants × 5 blades = 40 materials. Meh, that's fine but let's reduce: 7 plants × 4 blades = 28.
Alternatively use one geometry per plant with a phase attribute... Let's keep clones; simplicity over micro-optimization.
Blade geometry: PlaneGeometry(0.35, h, 1, 10) translated up by h/2 along Y. Vertex shader bends:
```glsl
uniform float uTime; uniform float uPhase;
varying float vK;
void main(){
vK = uv.y;
vec3 p = position;
float k = uv.y;
float bend = k*k;
p.x += sin(uTime*1.2 + uPhase) * 1.2 * bend;
p.z += cos(uTime*0.9 + uPhase*1.3) * 0.8 * bend;
p.x += sin(uTime*2.3 + uPhase + p.y*0.8) * 0.15 * bend;
vec4 mv = modelViewMatrix * vec4(p, 1.0);
gl_Position = projectionMatrix * mv;
}
```
Fragment: color gradient from dark green at bottom to brighter at top, with `gl_FragColor = vec4(col, 0.95)`. With transparent + depthWrite true? Transparent with additive? Use normal blending, transparent true, depthWrite false could cause sorting issues among blades, but they're thin. Let's set transparent: true, depthWrite: true (alpha ~1 mostly, edges hard) — no alpha edges → use opaque material, no transparency. Simpler: opaque, side DoubleSide. Blade color dark green with lighter tip.
Since it's a custom ShaderMaterial with lighting ignored — fine, they'll be flat colored. Choose color that fits the scene.
Now fish.
```js
const fishGeoCache = {};
function makeTailGeo(){...}
```
Let me create the fish body group:
```js
function createFish(colorHex, accentHex) {
const group = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.35, metalness: 0.35, flatShading: true });
const finMat = new THREE.MeshStandardMaterial({ color: accentHex, roughness: 0.6, metalness: 0.1, side: THREE.DoubleSide, flatShading: true });
const body = new THREE.Mesh(new THREE.SphereGeometry(1, 16, 12), bodyMat);
body.scale.set(1.0, 0.62, 0.36);
group.add(body);
// snout? skip
// tail
const tailShape = new THREE.Shape();
tailShape.moveTo(0, 0);
tailShape.lineTo(-0.62, 0.62);
tailShape.quadraticCurveTo(-0.3, 0, -0.62, -0.62); // hmm quadratic through?
tailShape.lineTo(0,0);
```
Using bezier: from (-0.62,0.62) curve to (-0.62,-0.62) with control (-0.25, 0) → gives forked shape with notch at x=-0.25. Good.
```js
const tailGeo = new THREE.ShapeGeometry(tailShape);
const tail = new THREE.Mesh(tailGeo, finMat);
```
But ShapeGeometry is in the XY plane with z=0, and the fish faces +X, so a fin in the XY plane is vertical and aligned with the body axis — correct for a tail fin (fish's tail is vertical, perpendicular to the left-right axis Z). Yes! Correct.
Place tail pivot at x = -0.92 (rear of body since body spans -1..1 in x, but scaled... body scale x=1 gives -1 to 1. Let's make body.scale.set(0.95, 0.6, 0.34)) so rear is at x=-0.95.
tailGroup.position.set(-0.85, 0, 0); tail mesh added at origin.
Dorsal fin: shape triangle above body:
```js
const dorsalShape = new THREE.Shape();
dorsalShape.moveTo(0.35, 0);
dorsalShape.lineTo(-0.1, 0.5);
dorsalShape.lineTo(-0.55, 0.0);
dorsalShape.lineTo(0.35, 0);
```
Positioned at y=0.45, x=0. Slight rotation.
Pectoral fins: small shapes near the head, rotated ±30° about X, on both sides. Optional; skip for perf simplicity, or add as small planes.
Eye: sphere radius 0.13 black with a white specular dot? Just black sphere: MeshStandardMaterial({color: 0x08080c, roughness:0.2, metalness:0.0}). Position (0.62, 0.14, ±0.26). But body at x=0.62 has z radius ~ 0.34*sqrt(1-0.62²/0.95²)*... whatever, place at z = ±0.2 and it should poke out. Let's compute: body ellipsoid semi-axes (0.95, 0.6, 0.34). At x=0.62: (0.62/0.95)² = 0.426, remaining 0.574, so y semi = 0.6*0.758=0.455, z semi = 0.34*0.758=0.258. Eye at y=0.12, z=±0.24 with radius 0.11 → sticks out. Good.
Fish color palettes: orange (0xff8c42), yellow (0xffd166), cyan (0x5fd0e0), pink (0xff7a9c), purple (0x9b7bff), silver/blue (0x88aaff).
Now the fish object:
```js
const BOUNDS = { x: 15, yMin: -4.2, yMax: 6.5, z: 9 };
```
Camera at z=17 sees x range at z=0 about ±13. Fish at x ±15 near edges but they'd be off-screen partly; fine, gives depth.
Actually let's make bounds x: ±14, y: -4..6, z: -9..9.
Fish spawn: random inside.
Fish state:
```js
{
obj: group,
pos, vel,
cruise, maxSpeed,
size,
wanderPhase, wanderFreq,
panic: 0,
tailPhase, tailSpeed
}
```
Update loop per fish:
```js
const acc = new THREE.Vector3();
// wander
const t = time;
acc.x += Math.sin(t * f.f1 + f.p1) * 1.6;
acc.y += Math.sin(t * f.f2 + f.p2) * 1.1;
acc.z += Math.cos(t * f.f3 + f.p3) * 1.6;
```
Hmm, this gives acceleration, which combined with velocity gives smooth motion. Good. Scale ~2.
Boundary:
```js
const m = 2.0;
if (p.x > B.x - m) acc.x -= (p.x - (B.x - m)) * 2.5;
if (p.x < -B.x + m) acc.x += (-B.x + m - p.x) * 2.5;
// same for y, z
```
Also clamp position hard to bounds.
Flee:
```js
let threat = 0;
if (pointerActive) {
const rel = tmp1.copy(p).sub(rayOrigin);
const proj = rel.dot(rayDir);
if (proj > 0) {
const closest = tmp2.copy(rayOrigin).addScaledVector(rayDir, proj);
const dist = p.distanceTo(closest);
if (dist < FLEE_R) {
threat = 1 - dist / FLEE_R;
const away = tmp3.copy(p).sub(closest);
if (away.lengthSq() < 1e-4) away.set(Math.random()-0.5, Math.random()-0.5, Math.random()-0.5);
away.normalize();
// also push away from camera a bit for depth
acc.addScaledVector(away, 60 * threat);
f.panic = Math.min(1, f.panic + dt * 4 * threat);
}
}
}
f.panic = Math.max(0, f.panic - dt * 0.6);
```
Hmm `threat` per frame; panic increases fast and decays slowly.
acc *= similar. Let's cap acc length.
Velocity:
```js
f.vel.addScaledVector(acc, dt);
const maxS = f.cruise * (1 + f.panic * 5);
const sp = f.vel.length();
if (sp > maxS) f.vel.multiplyScalar(maxS / sp);
else if (sp < f.cruise * 0.5 && sp > 1e-4) f.vel.multiplyScalar(1 + (1 - sp/(f.cruise*0.5)) * dt * 2);
p.addScaledVector(f.vel, dt);
```
Hmm, when fleeing, the acc is huge (60*threat) and dt ~0.016 → dv = ~1 per frame, and maxS = cruise*6 ≈ 9. Good — they dash.
Note also `acc` includes wander of magnitude ~2, small.
Separation: I'll do simple loop with a spatial check (just O(n²) with n≈28 = 784 distance checks; fine).
```js
for (let j=0;j<FISH.length;j++){
if (j===i) continue;
const o = FISH[j];
const dx = p.x-o.pos.x, dy=p.y-o.pos.y, dz=p.z-o.pos.z;
const d2 = dx*dx+dy*dy+dz*dz;
const minD = (f.size + o.size) * 2.2;
if (d2 < minD*minD && d2 > 1e-4) {
const d = Math.sqrt(d2);
const s = (1 - d/minD) * 6;
acc.x += dx/d*s; ...
}
}
```
Also keep fish away from the sand: if p.y < -3.5, push up.
Orientation:
```js
const fwd = tmp.copy(f.vel).normalize();
const up = new THREE.Vector3(0,1,0);
if (Math.abs(fwd.y) > 0.98) up.set(0,0,1); // near-vertical fallback
const right = new THREE.Vector3().crossVectors(fwd, up).normalize();
const up2 = new THREE.Vector3().crossVectors(right, fwd).normalize();
m.makeBasis(fwd, up2, right);
q.setFromRotationMatrix(m);
```
Check handedness: right = fwd × up. With fwd=(1,0,0), up=(0,1,0): right = (0,0,1)... wait cross((1,0,0),(0,1,0)) = (0*0-0*1, 0*0-1*0, 1*1-0*0) = (0,0,1). Yes right = +Z. Then up2 = right × fwd = (0,0,1)×(1,0,0) = (0*0-1*0, 1*1-0*0, 0*0-0*1) = (0,1,0). Good. makeBasis(fwd, up2, right) = makeBasis(X=(1,0,0), Y=(0,1,0), Z=(0,0,1)) identity.
Then apply a bank roll around the forward axis based on turn: skip or small.
Smooth: `f.obj.quaternion.slerp(q, 1 - Math.exp(-dt*8))`.
Tail wag: `tailGroup.rotation.y = Math.sin(t * (4 + panic*12) + phase) * (0.35 + panic*0.3)`.
Body slight yaw wiggle: `body.rotation.y = sin(...)*0.08`.
Let's add that as part of the group's animation.
Bubbles:
```js
const bubbleCount = 220;
const bPos = new Float32Array(bubbleCount*3);
const bSpeed = new Float32Array(bubbleCount);
const bPhase = new Float32Array(bubbleCount);
for(...) { x=rand(-16,16), y=rand(-6,8), z=rand(-10,12); }
```
Points material with a round sprite texture from canvas, size 0.35, sizeAttenuation, transparent, depthWrite false, opacity 0.7, blending additive? Normal with white color is fine.
Update: y += speed*dt; if y > 8 → y = -6, randomize x,z; add a small sinusoidal x drift.
Wait, bubbles rising and resetting is fine.
Also add some tiny "plankton"/dust particles for depth.
God rays: let's add 5 slanted planes with additive gradient. I'll do it with a canvas texture:
Actually, let's use a shader plane with a soft vertical streak:
```glsl
varying vec2 vUv;
uniform float uTime; uniform float uSeed;
void main(){
float x = vUv.x;
float y = vUv.y;
float streak = smoothstep(0.5, 0.0, abs(x-0.5)) * ...
```
Meh, I'll skip god rays to reduce risk and code size. The gradient bg + fog + caustics is enough.
Actually, light shafts really sell underwater. Let me do a cheap version: a few large planes with a canvas-generated soft vertical gradient texture, additive, tilted. Cheap and safe:
```js
function makeShaftTexture(){
const c = document.createElement('canvas'); c.width = 64; c.height = 256;
const ctx = c.getContext('2d');
const g = ctx.createLinearGradient(0,0,0,256);
g.addColorStop(0,'rgba(160,230,255,0.55)');
g.addColorStop(0.5,'rgba(120,210,255,0.18)');
g.addColorStop(1,'rgba(100,200,255,0)');
ctx.fillStyle=g; ctx.fillRect(0,0,64,256);
// horizontal falloff
const g2 = ctx.createLinearGradient(0,0,64,0);
...
```
Better: use a radial gradient horizontally via composite. Simpler: draw the vertical gradient, then multiply with a horizontal alpha via `globalCompositeOperation='destination-in'` with a horizontal gradient from transparent → opaque → transparent.
```js
const h = ctx.createLinearGradient(0,0,64,0);
h.addColorStop(0,'rgba(0,0,0,0)'); h.addColorStop(0.5,'rgba(0,0,0,1)'); h.addColorStop(1,'rgba(0,0,0,0)');
ctx.globalCompositeOperation = 'destination-in';
ctx.fillStyle = h; ctx.fillRect(0,0,64,256);
```
Then use as a texture on planes of size ~ (4, 40), rotated to tilt, additive, depthWrite false, opacity ~0.5. Place several near the top going down.
Since these planes are vertical-ish and the camera looks forward, they'll be visible as soft beams. Set renderOrder maybe. Let's include it but keep opacity subtle.
Hmm, additive planes with fog disabled look okay.
I'll include 6 shafts.
Now the mouse ray.
```js
const pointer = new THREE.Vector2(0,0);
let pointerActive = false;
const raycaster = new THREE.Raycaster();
```
On pointermove: update pointer, set pointerActive = true. Also on touch move.
In the loop:
```js
raycaster.setFromCamera(pointer, camera);
const rayOrigin = raycaster.ray.origin;
const rayDir = raycaster.ray.direction;
```
Where pointer defaults to (0,0) → center of screen → fish would flee the center; but pointerActive false prevents it.
Maybe also fade in a "hand" indicator? Skip.
Camera parallax:
```js
const targetX = Math.sin(t*0.13)*1.2 + pointer.x*1.5;
const targetY = 1.2 + Math.cos(t*0.17)*0.35 + pointer.y*0.9;
camera.position.x += (targetX - camera.position.x) * Math.min(1, dt*2);
camera.position.y += (targetY - camera.position.y) * ...
camera.lookAt(0, 0.3, 0);
```
Careful: pointer.y positive = up in NDC. Moving mouse up should move camera up? Sure.
Now let's double check the ray usage for fleeing: ray from camera through the mouse. Fish near the line flee. Good.
Resize handler.
Let me also add a subtle vignette / overlay via CSS? Maybe a CSS radial gradient overlay with pointer-events none for polish. Light.
Also HUD text hint at bottom: "Move your cursor through the water — the fish will dart away". Fades after a while? Keep persistent, subtle.
Let me now write the shaders carefully.
Plant shader:
```glsl
uniform float uTime;
uniform float uPhase;
varying float vK;
void main() {
vK = uv.y;
vec3 p = position;
float k = uv.y;
float b = k * k;
p.x += sin(uTime * 1.1 + uPhase + p.y * 0.5) * 1.4 * b;
p.z += cos(uTime * 0.8 + uPhase * 1.4 + p.y * 0.4) * 0.9 * b;
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}
```
Note: `position` is in local space; plane translated so bottom at y=0. The bend amplitude 1.4 at the top for a blade of height ~5 is a lot; use 0.9.
Fragment:
```glsl
varying float vK;
void main(){
vec3 c = mix(vec3(0.04,0.20,0.12), vec3(0.20,0.65,0.38), vK);
gl_FragColor = vec4(c, 1.0);
}
```
Colors as sRGB-ish raw values. Good.
Caustic shader:
```glsl
uniform float uTime;
varying vec2 vUv;
void main(){
vec2 uv = vUv * 30.0;
float t = uTime * 0.6;
float a = sin(uv.x * 0.9 + t) * sin(uv.y * 1.1 - t * 0.8);
float b = sin((uv.x + uv.y) * 0.7 - t * 1.3) * sin((uv.x - uv.y) * 0.8 + t);
float v = abs(a + b) ;
float c = pow(1.0 - clamp(v, 0.0, 1.0), 6.0);
float fade = smoothstep(0.5, 0.12, distance(vUv, vec2(0.5)));
gl_FragColor = vec4(vec3(0.45, 0.85, 1.0) * c * fade * 0.9, 1.0);
}
```
With AdditiveBlending, alpha is irrelevant (well, with additive, src*srcAlpha + dst; if srcAlpha=1 then it's full add). Set transparent:true and blending additive.
Hmm `1.0 - clamp(v)` where v in [0,2] → clamped to 1, then 1-1=0 for most. Let's use v = abs(a+b)*0.5 in [0,1], then c = pow(1-v, 8). Fine, produces thin bright lines where a+b≈0. Actually caustics are bright where the function has sharp minimum. OK, it'll look like a network of bright lines. Good enough.
Position the caustic plane at y = -5.5 (sand at -6 with displacements of ±1.7 — could poke through). Set caustic plane y = -5.2 and sand displacement amplitude ~1.2 max. Hmm sand dunes: `Math.sin(x*0.07+z*0.11)*1.1` up to 1.1, plus 0.6 + 0.15 → up to ~1.85 above -6 → -4.15. That would poke through the caustic plane at -5.2. Let me reduce sand amplitude: h = sin(x*0.06)*cos(z*0.08)*0.9 + sin(x*0.15+z*0.21)*0.35. Max ~1.25. Then sand peak at -4.75. Caustic plane at -4.4? That's high above the sand in some places. Alternative: skip the caustic plane and instead apply caustics in the sand material via onBeforeCompile — more code.
Simplest: make the sand mostly flat (amplitude 0.5) and put the caustic plane at -5.5. Let's do that: h = sin(x*0.09)*cos(z*0.07)*0.45 + sin(x*0.21 + z*0.17)*0.18 → max 0.63. Sand top ~ -5.37 and caustic at -5.45... too close. Put caustic at -5.6? Then sand peaks at -5.37 poke through caustic plane. Use sand max 0.5 → top -5.5, caustic at -5.35. Hmm, close but fine — small pokes aren't terrible with additive blending (they'd just cut off). Let's make sand amplitude small (0.35) and caustic at -5.4.
Actually, since the caustic plane is additive and depthWrite:false but depthTest:true, sand poking above it will occlude the caustic there — acceptable.
Alternatively apply caustics as an emissive overlay in the sand shader. Too complex. Move on.
Now the fish count and variety: 26 fish.
Let's write the whole thing.
One more: to make it clear the fish flee the mouse, the flee radius should be generous (3.0) and force strong.
Consider also making the fish "look" at the mouse and scatter only when close.
Let me write final code.
```js
import * as THREE from 'three';
const container = document.getElementById('app');
const renderer = new THREE.WebGLRenderer({ antialias: true });
...
```
Full code:
```js
// ---------- Renderer / Scene ----------
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
document.getElementById('app').appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x0c4a66, 0.024);
const camera = new THREE.PerspectiveCamera(52, window.innerWidth / window.innerHeight, 0.1, 400);
camera.position.set(0, 1.4, 17);
camera.lookAt(0, 0.4, 0);
```
Let me now think about the fish facing. ShapeGeometry with a Shape: shape path must be counterclockwise? ShapeGeometry handles both via triangulation (earcut). Fine.
Tail shape:
```js
const s = new THREE.Shape();
s.moveTo(0, 0);
s.lineTo(-0.55, 0.7);
s.quadraticCurveTo(-0.2, 0, -0.55, -0.7);
s.lineTo(0, 0);
```
Hmm, the order: moveTo(0,0) → lineTo(-0.55, 0.7) [upper tip] → quadratic to (-0.55,-0.7) with control (-0.2, 0) [notch curving inward] → lineTo(0,0) closing. This creates a forked tail.
The tail fin group is at x=-0.9, so the fins extend to -1.45.
Dorsal fin shape:
```js
const d = new THREE.Shape();
d.moveTo(0.45, 0);
d.quadraticCurveTo(0.0, 0.55, -0.35, 0.05);
d.lineTo(0.45, 0);
```
Positioned at y = 0.5 (top of body at y=0.6).
Pelvic fin similar below, smaller.
Pectoral fin: small ellipse near head, rotated. `new THREE.CircleGeometry(0.22, 8)` scaled. Skip.
Eyes: as described.
Fish will be viewed from the side mostly, so this is fine.
Colors: use `new THREE.Color(hex)` for the material — with color management this yields correct sRGB rendering.
Now, let me write out everything concretely.
```js
// ---------- Fish ----------
const FISH = [];
const FORWARD = new THREE.Vector3(1, 0, 0);
function buildFishGeometryParts() { ... } // shared geometry
const bodyGeo = new THREE.SphereGeometry(1, 18, 12);
const tailGeo = ...;
const dorsalGeo = ...;
const pelvicGeo = ...;
const eyeGeo = new THREE.SphereGeometry(1, 8, 6);
const PALETTES = [
[0xff8a3d, 0xffd166], // orange
[0xffc94d, 0xfff3b0],
[0x54c8e8, 0x9ef0ff],
[0xff6f91, 0xffc2d1],
[0x9b7bff, 0xd6c8ff],
[0x6ee7a8, 0xc9ffdd],
[0xe8eef7, 0xffffff], // silver
[0xff5c5c, 0xffb3b3],
];
```
createFish(index):
```js
function createFish() {
const [c1, c2] = PALETTES[Math.floor(Math.random()*PALETTES.length)];
const g = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({ color: c1, roughness: 0.3, metalness: 0.45, flatShading: true });
const finMat = new THREE.MeshStandardMaterial({ color: c2, roughness: 0.6, metalness: 0.15, side: THREE.DoubleSide, flatShading: true, transparent: true, opacity: 0.9 });
...
}
```
Transparent fins might cause sorting issues but ok. Let's keep fins opaque for safety (transparent:false).
Add a slight emissive? `emissive: new THREE.Color(c1).multiplyScalar(0.05)`.
OK writing it now for real. I'll be careful with variable names.
Also `renderer.setAnimationLoop`.
Time management: `const clock = new THREE.Clock();` dt = Math.min(clock.getDelta(), 0.05).
Let me write the final HTML.
Also add a small "ripple" indicator at the mouse position? A subtle circle could help users understand the interaction. I'll add a soft glowing sphere that follows the ray at a fixed distance? It might confuse the depth. Skip.
One more consideration: the pointer ray uses NDC; on the first frame before any pointer move, pointer = (0,0) → center; guarded by pointerActive.
Edge: `pointerActive` should also be set on touchstart.
Let me finalize the shaft planes:
```js
function makeShaftTexture() {
const c = document.createElement('canvas');
c.width = 128; c.height = 256;
const ctx = c.getContext('2d');
const g = ctx.createLinearGradient(0, 0, 0, 256);
g.addColorStop(0.0, 'rgba(180,240,255,0.85)');
g.addColorStop(0.35, 'rgba(150,225,255,0.35)');
g.addColorStop(1.0, 'rgba(120,200,255,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, 128, 256);
const h = ctx.createLinearGradient(0, 0, 128, 0);
h.addColorStop(0, 'rgba(0,0,0,0)');
h.addColorStop(0.5, 'rgba(0,0,0,1)');
h.addColorStop(1, 'rgba(0,0,0,0)');
ctx.globalCompositeOperation = 'destination-in';
ctx.fillStyle = h;
ctx.fillRect(0, 0, 128, 256);
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
```
Then:
```js
const shaftTex = makeShaftTexture();
const shaftMat = new THREE.MeshBasicMaterial({ map: shaftTex, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, opacity: 0.35, side: THREE.DoubleSide, fog: false });
for (let i = 0; i < 7; i++) {
const w = 6 + Math.random() * 8;
const hgt = 40 + Math.random() * 20;
const m = new THREE.Mesh(new THREE.PlaneGeometry(w, hgt), shaftMat);
m.position.set(-30 + Math.random() * 60, hgt * 0.35, -25 + Math.random() * 25);
m.rotation.set(0.15 * (Math.random()-0.5), Math.random() * 0.4, 0.25 + (Math.random() - 0.5) * 0.3);
scene.add(m);
}
```
Hmm, the shafts should be visible in front of the background but behind fish. With additive + depthWrite false and depthTest true they'll be occluded by fish properly. Fine.
They're near z -25 to 0; camera at z 17 → visible. Their top is at y = 0.35*40 + 20 = 34, bottom at 34-40 = -6. Good, from the floor up. Slight tilt.
Might be too bright; opacity 0.35 with additive. I'll use 0.22.
Let's now assemble everything.
For the bubbles texture:
```js
function makeBubbleTexture(){
const c = document.createElement('canvas'); c.width = c.height = 64;
const ctx = c.getContext('2d');
const g = ctx.createRadialGradient(32,32,0,32,32,32);
g.addColorStop(0,'rgba(255,255,255,0.9)');
g.addColorStop(0.35,'rgba(200,240,255,0.45)');
g.addColorStop(0.75,'rgba(160,220,255,0.12)');
g.addColorStop(1,'rgba(160,220,255,0)');
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(32,32,32,0,Math.PI*2); ctx.fill();
const t = new THREE.CanvasTexture(c);
t.colorSpace = THREE.SRGBColorSpace;
return t;
}
```
Points material: `new THREE.PointsMaterial({ map, size: 0.5, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, sizeAttenuation: true, opacity: 0.8 })`.
Points with additive blending and fog... fog default true for PointsMaterial; the material gets fogged. Fine.
Bubble update: store positions in a Float32Array and update the attribute each frame.
Let's write final code now. I'll be meticulous.
```js
// ---------- Sand ----------
{
const geo = new THREE.PlaneGeometry(160, 160, 80, 80);
geo.rotateX(-Math.PI / 2);
const p = geo.attributes.position;
for (let i = 0; i < p.count; i++) {
const x = p.getX(i), z = p.getZ(i);
const h = Math.sin(x * 0.09) * Math.cos(z * 0.07) * 0.45
+ Math.sin(x * 0.23 + z * 0.19) * 0.16
+ Math.cos(z * 0.31 - x * 0.12) * 0.1;
p.setY(i, h);
}
geo.computeVertexNormals();
const mat = new THREE.MeshStandardMaterial({ color: 0xb8a882, roughness: 0.95, metalness: 0.0 });
const sand = new THREE.Mesh(geo, mat);
sand.position.y = -6;
scene.add(sand);
}
```
Max height 0.71 → top at -5.29. Caustic plane at y = -5.2 then. Hmm, then caustics appear to float slightly above the sand, which is fine visually since it's additive and the sand is dark.
Hmm, but with the camera at y=1.4 looking slightly down, the sand fills the bottom. Fine.
Now writing fish update and everything. Let me produce the final code.
I also want to double-check `THREE.ShapeGeometry` exists — yes, ShapeGeometry(shape, curveSegments).
Let me write the whole file.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>3D Aquarium — Shy Fish</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #05131f; }
#app { position: fixed; inset: 0; }
canvas { display: block; }
#vignette {
position: fixed; inset: 0; pointer-events: none;
background: radial-gradient(ellipse at 50% 45%, rgba(0,0,0,0) 45%, rgba(0,10,20,0.55) 100%);
}
#hint {
position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 14px; letter-spacing: 0.06em; text-transform: uppercase;
color: rgba(200, 240, 255, 0.75);
background: rgba(6, 30, 46, 0.45);
border: 1px solid rgba(140, 220, 255, 0.25);
padding: 9px 18px; border-radius: 999px;
backdrop-filter: blur(6px);
pointer-events: none; user-select: none;
white-space: nowrap;
transition: opacity 0.6s;
}
@media (max-width: 520px) { #hint { font-size: 11px; padding: 7px 12px; bottom: 16px; } }
</style>
</head>
<body>
<div id="app"></div>
<div id="vignette"></div>
<div id="hint">Move your cursor through the water — the fish dart away</div>
<script type="importmap">...</script>
<script type="module"> ... </script>
</body>
</html>
```
Now the module code.
I'll write it in order: renderer, scene, camera, background, lights, sand, caustics, rocks, plants, shafts, bubbles, fish, interaction, loop.
Let me write the fish data structure and loop carefully.
```js
const clock = new THREE.Clock();
let elapsed = 0;
function animate() {
const dt = Math.min(clock.getDelta(), 0.05);
elapsed += dt;
...
}
renderer.setAnimationLoop(animate);
```
Mouse ray update inside animate after camera update.
Camera update first, then raycaster.setFromCamera(pointer, camera).
Then fish.
Then bubbles, caustics uniform, plant uniforms (shared uTime object across all plant materials → update once).
For plants, I'll share a single `uTime` uniform object reference:
```js
const plantTime = { value: 0 };
```
and each material uses `uniforms: { uTime: plantTime, uPhase: { value: phase } }`. Sharing the same uniform object across materials works — three reads `.value` at render.
Good.
Now writing the fish creation function fully:
```js
const bodyGeo = new THREE.SphereGeometry(1, 18, 12);
const eyeGeo = new THREE.SphereGeometry(1, 8, 8);
const tailShape = new THREE.Shape();
tailShape.moveTo(0, 0);
tailShape.lineTo(-0.62, 0.75);
tailShape.quadraticCurveTo(-0.22, 0, -0.62, -0.75);
tailShape.lineTo(0, 0);
const tailGeo = new THREE.ShapeGeometry(tailShape);
const dorsalShape = new THREE.Shape();
dorsalShape.moveTo(0.5, 0);
dorsalShape.quadraticCurveTo(0.05, 0.62, -0.45, 0.02);
dorsalShape.lineTo(0.5, 0);
const dorsalGeo = new THREE.ShapeGeometry(dorsalShape);
const pelvicShape = new THREE.Shape();
pelvicShape.moveTo(0.1, 0);
pelvicShape.quadraticCurveTo(-0.15, -0.4, -0.4, -0.02);
pelvicShape.lineTo(0.1, 0);
const pelvicGeo = new THREE.ShapeGeometry(pelvicShape);
```
createFish:
```js
function createFish() {
const pal = PALETTES[(Math.random() * PALETTES.length) | 0];
const group = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({
color: pal[0], roughness: 0.32, metalness: 0.5, flatShading: true,
emissive: new THREE.Color(pal[0]).multiplyScalar(0.06)
});
const finMat = new THREE.MeshStandardMaterial({
color: pal[1], roughness: 0.65, metalness: 0.1, side: THREE.DoubleSide, flatShading: true,
emissive: new THREE.Color(pal[1]).multiplyScalar(0.05)
});
const eyeMat = new THREE.MeshStandardMaterial({ color: 0x0a0d14, roughness: 0.15, metalness: 0.6 });
const body = new THREE.Mesh(bodyGeo, bodyMat);
body.scale.set(0.95, 0.6, 0.34);
group.add(body);
const tail = new THREE.Group();
tail.position.set(-0.86, 0, 0);
const tailMesh = new THREE.Mesh(tailGeo, finMat);
tail.add(tailMesh);
group.add(tail);
const dorsal = new THREE.Mesh(dorsalGeo, finMat);
dorsal.position.set(0.0, 0.52, 0);
group.add(dorsal);
const pelvic = new THREE.Mesh(pelvicGeo, finMat);
pelvic.position.set(0.0, -0.5, 0);
group.add(pelvic);
const eyeL = new THREE.Mesh(eyeGeo, eyeMat);
eyeL.scale.setScalar(0.1);
eyeL.position.set(0.6, 0.12, 0.22);
group.add(eyeL);
const eyeR = eyeL.clone();
eyeR.position.z = -0.22;
group.add(eyeR);
```
Wait `clone()` on a Mesh shares geometry and material — fine.
Hmm, eyes at z ±0.22: body z semi-axis 0.34 at x=0... at x=0.6, (0.6/0.95)²=0.399, factor sqrt(0.601)=0.775 → z semi = 0.264. So z=0.22 with eye radius 0.1 → sticks out to 0.32. Good.
```js
const size = 0.45 + Math.random() * 0.5;
group.scale.setScalar(size);
scene.add(group);
return { group, body, tail, size };
}
```
Then the state object.
Fish speed should scale inversely with size a bit (bigger fish slower). Fine.
Position bounds: I want fish distributed in a box.
Let me write the loop update.
```js
const tmpA = new THREE.Vector3(), tmpB = new THREE.Vector3(), tmpC = new THREE.Vector3();
const tmpQ = new THREE.Quaternion();
const basis = new THREE.Matrix4();
const upVec = new THREE.Vector3(0, 1, 0);
```
```js
function updateFish(dt, t) {
const ro = raycaster.ray.origin, rd = raycaster.ray.direction;
for (let i = 0; i < FISH.length; i++) {
const f = FISH[i];
const p = f.pos, v = f.vel;
const acc = tmpA.set(0, 0, 0);
// wander
acc.x += Math.sin(t * f.w1 + f.p1) * 2.2;
acc.y += Math.sin(t * f.w2 + f.p2) * 1.4;
acc.z += Math.cos(t * f.w3 + f.p3) * 2.2;
// separation
for (let j = 0; j < FISH.length; j++) {
if (j === i) continue;
const o = FISH[j];
const dx = p.x - o.pos.x, dy = p.y - o.pos.y, dz = p.z - o.pos.z;
const d2 = dx*dx + dy*dy + dz*dz;
const minD = (f.size + o.size) * 1.6;
if (d2 < minD*minD && d2 > 1e-5) {
const d = Math.sqrt(d2);
const s = (1 - d / minD) * 18 / d;
acc.x += dx * s; acc.y += dy * s; acc.z += dz * s;
}
}
// bounds
...
// flee
...
f.panic = Math.max(0, f.panic - dt * 0.9);
...
}
}
```
Careful: `acc` is tmpA, but I use tmpB/tmpC inside for flee — ok as long as I don't overwrite tmpA.
Bounds:
```js
const BX = 15, BY0 = -4.4, BY1 = 7, BZ = 9.5;
const m = 2.5;
if (p.x > BX - m) acc.x -= (p.x - (BX - m)) * 6;
if (p.x < -BX + m) acc.x += (-BX + m - p.x) * 6;
if (p.y > BY1 - m) acc.y -= (p.y - (BY1 - m)) * 6;
if (p.y < BY0 + m) acc.y += (BY0 + m - p.y) * 6;
if (p.z > BZ - m) acc.z -= (p.z - (BZ - m)) * 6;
if (p.z < -BZ + m) acc.z += (-BZ + m - p.z) * 6;
```
With m=2.5, the margin region starts at 12.5. Steering strength 6 * 2.5 = 15 at the edge — strong enough. Fine.
Flee:
```js
if (pointerActive) {
const rx = p.x - ro.x, ry = p.y - ro.y, rz = p.z - ro.z;
const proj = rx * rd.x + ry * rd.y + rz * rd.z;
if (proj > 0.5) {
const cx = ro.x + rd.x * proj, cy = ..., cz = ...;
const dx = p.x - cx, dy = p.y - cy, dz = p.z - cz;
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz);
if (dist < FLEE_R) {
const threat = 1 - dist / FLEE_R;
let nx, ny, nz;
if (dist > 0.001) { nx = dx/dist; ny = dy/dist; nz = dz/dist; }
else { nx = rd.x; ny = rd.y; nz = rd.z; }
const F = 140 * threat * threat;
acc.x += nx * F; acc.y += ny * F * 0.8; acc.z += nz * F;
f.panic = Math.min(1, f.panic + dt * 6 * threat);
}
}
}
```
Hmm — should the fish flee directly away from the ray (perpendicular), or should they also get pushed away from the camera? Perpendicular is what makes "swim away from the cursor" natural. But if the fish is directly in front, perpendicular is nearly random. Adding a component away from the camera when the fish is far along the ray would help. Let's add: also push away from the camera along rd... Actually the mouse ray direction goes INTO the screen. Pushing along -rd would move fish toward the camera. Hmm, that's not it either.
Better: the ideal is that the fish flees from the closest point on the ray in a direction perpendicular to the ray, plus a bias away from the camera (so the fish moves deeper/away from the viewer) — no wait, perpendicular is best, and the "away from camera" bias only when the ray is near the fish: flee direction should include "away from camera" so the fish doesn't just slide sideways in front of the camera. Let's add a small component of +rd?? rd points into the screen. Adding rd pushes fish deeper into the scene (away from the camera). That's reasonable: move away from the viewer.
Actually the pure perpendicular direction already points away from the ray line, and since the ray line goes through the mouse which is at the camera, perpendiculars point... it's fine. Let's add a small along-rd component (0.35) to push them slightly deeper. But then fish would drift backward over time when you hover. It's fine; they'll return via bounds.
Hmm, careful: if the ray is nearly parallel to the fish's flee direction... fine.
Let's keep: away = normalize(p - closest) + rd*0.25, normalized.
Speed control:
```js
f.vel.addScaledVector(acc, dt);
const maxS = f.cruise * (1 + f.panic * 4.5);
const minS = f.cruise * 0.55;
let sp = f.vel.length();
if (sp > maxS) { f.vel.multiplyScalar(maxS / sp); sp = maxS; }
else if (sp < minS && sp > 1e-6) { f.vel.multiplyScalar(minS / sp); sp = minS; }
p.addScaledVector(f.vel, dt);
```
Hmm, this min-speed clamp forces fish to always move at ≥0.55*cruise. That's ok — fish rarely stop.
But when acc is huge (flee), v grows fast and clamps at maxS = cruise*5.5, ~= 7. Good.
Wait: `f.vel.addScaledVector(acc, dt)` with acc ~140*dist → dv per frame at dt=0.016 is ~2.2 per frame. So over 3 frames the fish hits max speed. Good responsiveness.
Also clamp position to the absolute bounds:
```js
p.x = THREE.MathUtils.clamp(p.x, -BX - 1, BX + 1); ...
```
Orientation:
```js
tmpB.copy(f.vel).normalize();
if (tmpB.lengthSq() < 1e-8) tmpB.set(Math.random()-0.5, 0, Math.random()-0.5).normalize();
const up = Math.abs(tmpB.y) > 0.97 ? tmpC.set(0,0,1) : upVec;
const right = tmpC.crossVectors(tmpB, up).normalize(); // careful: tmpC might be used for up
```
Careful with tmp reuse. Let me use dedicated temporaries:
```js
const _fwd = new THREE.Vector3(), _up = new THREE.Vector3(), _right = new THREE.Vector3(), _m = new THREE.Matrix4(), _q = new THREE.Quaternion();
```
Use module-level scratch vectors, and be careful not to alias.
```js
_fwd.copy(f.vel).normalize();
_up.set(0,1,0);
if (Math.abs(_fwd.y) > 0.97) _up.set(0,0,1);
_right.crossVectors(_fwd, _up).normalize();
_up.crossVectors(_right, _fwd).normalize();
_m.makeBasis(_fwd, _up, _right);
_q.setFromRotationMatrix(_m);
f.group.quaternion.slerp(_q, 1 - Math.exp(-dt * 10));
```
Hmm — slerping on the group. But I also want to animate the tail (child). Fine.
But careful: the group also has scale. quaternion is separate. OK.
Wait, there's an issue: `f.group.quaternion.slerp` — the fish model faces +X locally, and my basis maps local X to _fwd. Good.
Tail wag:
```js
const wag = Math.sin(t * (6 + f.panic * 14) + f.phase) * (0.35 + f.panic * 0.25);
f.tail.rotation.y = wag;
f.body.rotation.y = wag * 0.15;
```
Wait, the tail rotates about Y — the fin is in the XY plane at x<0 pivot; rotating about Y swings it left/right in Z. Correct.
Also add a slight roll based on angular velocity? Skip.
Now positions: fish.pos is a Vector3 separate from group.position? Just use group.position as pos. Simpler: `f.pos = f.group.position`. Yes.
Fish init:
```js
for (let i = 0; i < 26; i++) {
const { group, body, tail, size } = createFish();
group.position.set((Math.random()-0.5)*26, -3 + Math.random()*8, (Math.random()-0.5)*16);
const f = {
group, body, tail, size,
pos: group.position,
vel: new THREE.Vector3((Math.random()-0.5), (Math.random()-0.5)*0.3, (Math.random()-0.5)).normalize().multiplyScalar(1.2),
cruise: 1.1 + Math.random() * 1.2,
panic: 0,
phase: Math.random() * 10,
w1: 0.4 + Math.random()*0.6, w2: ..., w3: ...,
p1: Math.random()*10, ...
};
FISH.push(f);
}
```
Set the initial quaternion to face the velocity. I'll just call the orientation code once.
Now the initial camera: at (0, 1.4, 17) looking at (0, 0.4, 0). Fish spread x ±13, y -4..6.
Check the visible area: at z=0, distance 17, fov 52 → half-height = 17*tan(26°)=8.3, half-width = 8.3*1.6=13.3. Good: fish at x±13 at z=0 are near the edges. Fish at z=-9 → distance 26 → half-width 20. Fine.
Sand at y=-6, camera y=1.4 looking at y=0.4 → the horizon is above; the sand fills the lower ~40%. Good.
Now bubbles: 180 bubbles.
The whole update loop:
```js
function animate() {
const dt = Math.min(clock.getDelta(), 0.05);
const t = clock.elapsedTime; // hmm, getDelta updates elapsedTime; use accumulated
...
}
```
`clock.getDelta()` updates oldTime and elapsedTime. So `clock.elapsedTime` is valid after. Use that as t.
Let's write it.
Also, handle window resize.
Potential issue: `renderer.setAnimationLoop(animate)` — fine.
Now writing the final code in full.
For the caustic plane rotation: PlaneGeometry is in XY; rotate -PI/2 about X to lay flat, then the uv mapping stays. I'll create geometry and call `geo.rotateX(-Math.PI/2)`.
Position y = -5.2, size 160x160 — but wait, the plane's uv maps 0..1 across, and my fade uses distance from center — with a 160-unit plane, the caustics will be visible over a huge area. That's fine since it fades at the edges. But the pattern scale: uv*30 → 30 repeats over 160 units → ~5 units per repeat. Fine.
Hmm, actually the caustic plane at y=-5.2 and 160 units wide would be visible way off in the distance where the fog covers everything anyway (additive isn't fogged!). The additive caustics far away would appear as a bright band on the horizon. The fade factor handles it: fade = smoothstep(0.5, 0.12, dist(uv, 0.5)) → visible only in the central 24% of the plane = 38 units. That's ok, fading out at ~19 units from center. Good.
Let me set the plane size to 90x90 with the same fade.
Also, the caustic plane should be dark-ish additive so it doesn't blow out. Multiply by 0.6.
Let me write the shader:
```glsl
uniform float uTime;
varying vec2 vUv;
void main() {
vec2 uv = vUv * 26.0;
float t = uTime * 0.55;
float a = sin(uv.x * 0.9 + t * 1.3) * sin(uv.y * 1.1 - t);
float b = sin((uv.x + uv.y) * 0.65 - t * 1.1) * sin((uv.x - uv.y) * 0.75 + t * 0.7);
float v = clamp(abs(a + b) * 0.7, 0.0, 1.0);
float c = pow(1.0 - v, 7.0);
float fade = smoothstep(0.5, 0.14, distance(vUv, vec2(0.5)));
vec3 col = vec3(0.35, 0.8, 1.0) * c * fade;
gl_FragColor = vec4(col, 1.0);
}
```
With additive blending and no alpha handling, fine.
Now — is `vUv` correct? Need `varying vec2 vUv; vUv = uv;` in the vertex shader. Yes.
Time uniform shared.
Let me write the plants.
```js
const plantTime = { value: 0 };
function createPlant(x, z, baseY) {
const blades = 3 + ((Math.random() * 3) | 0);
for (let i = 0; i < blades; i++) {
const h = 2.5 + Math.random() * 4.5;
const w = 0.18 + Math.random() * 0.22;
const geo = new THREE.PlaneGeometry(w, h, 1, 12);
geo.translate(0, h / 2, 0);
const mat = new THREE.ShaderMaterial({
uniforms: { uTime: plantTime, uPhase: { value: Math.random() * 20 } },
side: THREE.DoubleSide,
vertexShader: `
uniform float uTime; uniform float uPhase;
varying float vK;
void main() {
vK = uv.y;
vec3 p = position;
float k = uv.y * uv.y;
p.x += sin(uTime * 1.1 + uPhase + p.y * 0.55) * 1.3 * k;
p.z += cos(uTime * 0.85 + uPhase * 1.4 + p.y * 0.45) * 0.9 * k;
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}`,
fragmentShader: `
varying float vK;
void main() {
vec3 col = mix(vec3(0.03, 0.16, 0.10), vec3(0.16, 0.62, 0.34), vK);
gl_FragColor = vec4(col, 1.0);
}`
});
const blade = new THREE.Mesh(geo, mat);
blade.position.set(x + (Math.random() - 0.5) * 1.2, baseY, z + (Math.random() - 0.5) * 1.2);
blade.rotation.y = Math.random() * Math.PI;
blade.rotation.z = (Math.random() - 0.5) * 0.25;
scene.add(blade);
}
}
```
Wait, rotating the blade about y after the vertex shader displacement — the displacement is in local space, fine.
Position y: the sand at that x,z has height variation up to 0.7 above -6. Set baseY = -6.2 to bury the base.
Plants placed at various x,z, mostly in the background: z between -12 and 2.
Hmm, if plants are placed with x up to ±20 they'd be off-screen. Range x ±18, z -14..2.
Also the number of plants: 14 plants × ~4 blades = 56 meshes with 56 ShaderMaterials. Each unique ShaderMaterial = unique program? No — the program is cached by shader source + defines, so only one program. Materials themselves are lightweight. But 56 draw calls. Fine.
Actually, I could reduce: 10 plants × 3-5 blades. OK.
Now the rocks: 14 rocks.
Let's write the final code.
One more detail: the fish group needs `group.rotation` set initially to face the velocity; I'll run the orientation code once per fish on the first frame — it's already handled by the slerp starting from identity... the slow slerp from identity would look like the fish rotates over ~0.5s at start. Acceptable. But to be safe, set the initial quaternion immediately: compute and set in the init loop. I'll add a small helper `orient(f, dt, instant)`.
Simpler: during init, after creating the fish, call `setOrientation(f, f.vel, 1)` where the slerp factor is 1.
Let me structure:
```js
function orient(f, dt, instant) {
_fwd.copy(f.vel);
if (_fwd.lengthSq() < 1e-8) _fwd.set(1, 0, 0);
_fwd.normalize();
_up.set(0, 1, 0);
if (Math.abs(_fwd.y) > 0.96) _up.set(0, 0, 1);
_right.crossVectors(_fwd, _up).normalize();
_up.crossVectors(_right, _fwd).normalize();
_m.makeBasis(_fwd, _up, _right);
_q.setFromRotationMatrix(_m);
if (instant) f.group.quaternion.copy(_q);
else f.group.quaternion.slerp(_q, 1 - Math.exp(-dt * 9));
}
```
Careful: `_up.crossVectors(_right, _fwd)` overwrites _up which is used in `_right.crossVectors(_fwd, _up)`—that's already computed. Fine.
Now, let me write everything out.
Also need to make sure `pointerActive` starts false and the hint mentions moving the mouse.
Potential visual issue: fish emissive makes them glow slightly; ok.
Let me also add a subtle point light following the pointer ray? It would illuminate fish as they flee, nice but could blow out. Let's add a PointLight with intensity 30, distance 20, color 0x88ddff, positioned at the point on the ray 14 units from camera, only when the pointer is active. Actually let's skip to keep the scene stable. Hmm, it adds "hand in water" feel. I'll add a very subtle one: intensity 12, distance 14, color 0x9fe8ff, position = camera.position + rd*12. Since ACES + physically correct lights in r155+, intensity units for point lights are candela; a value like 40 gives a modest glow. Let's use 30. Eh — risk of looking bad. Skip it.
I'll instead make the fish's emissive bump when panicking? No.
Keep it clean.
Final code writing.
One thing: `new THREE.Color(pal[0]).multiplyScalar(0.06)` for emissive — fine.
Let me write out the complete file now.
Also: `renderer.outputColorSpace` defaults to SRGB in r152+. Good.
Double-check the import map usage:
```html
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}</script>
```
I don't use addons. Fine.
Write it.
Also handle the case where the pointer leaves the window — keep pointerActive true, fish keep fleeing from the last position. Better to set pointerActive = false on pointerleave? Then fish calm down. Let's keep it true; the mouse position stays at the edge. Fine either way. I'll set pointerActive=false on 'pointerleave' of the document... Actually with the mouse outside the iframe, the cursor's ray stays at the last position, and the fish would keep fleeing. Setting it false on leave is nicer.
Let me finalize.
Code:
```js
import * as THREE from 'three';
// ---------- renderer ----------
const container = document.getElementById('app');
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
container.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x0b435f, 0.022);
const camera = new THREE.PerspectiveCamera(52, window.innerWidth / window.innerHeight, 0.1, 400);
camera.position.set(0, 1.4, 17);
camera.lookAt(0, 0.4, 0);
```
...
I'm going to write the final answer now. I'll be careful about every API.
For the bubbles' PointsMaterial `map` with `transparent: true` and additive blending — ok.
Note: PointsMaterial with fog: the fog applies. Fine.
Writing final.
const BX = 15; etc. — need these accessible in updateFish.
Alright, write it.
One more thought on the "3-second screenshot": fish should be visible and there should be motion. Since the demo doesn't depend on input, at 3s the fish will be swimming around. Good.
Let me make sure the initial distribution places many fish near the camera view: x ±13, y -3.5..5.5, z -8..6.
Final code:
Also, the hint says "Move your cursor..." — pointer events in a sandboxed iframe work fine (no click needed for mousemove).
Writing now.
Regarding the tail: since ShapeGeometry faces +Z (normal +Z) and it's a vertical fin, with DoubleSide material it's visible from both sides. Good.
The body: SphereGeometry with flatShading and scale — the flat shading gives a faceted look, nice stylized look.
Now write.
For the fish count, use 28.
Final check on performance: 28 fish × (body 18×12 = ~400 tris + fins) ≈ fine. 28 fish × ~6 meshes = 170 draw calls, plus plants 50, rocks 14, bubbles 1, shafts 7. ~250 draw calls. Should run fine.
Let me write out.