Interactive 3D aquariumOne-line
MiMo-V2.6-Pro
by Xiaomireasoning defaultClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 4 min 25 s
- Cost
- $0.025
- Output tokens
- 32,267
- Spent thinking
- 18,495 tokens
- Speed
- 122.1 tok/s
- Console errors
- 0
- First token after
- 531 ms
- First visible text after
- 3 min 10 s
- Prompt tokens
- 431
- Code size
- 38.8 KB
- Finish reason
- stop
- Served by
- DeepInfra
- Model id
xiaomi/mimo-v2.6-pro- Reasoning sent
model default- Max output
- 131,072 tokens
- Generated
- Nov 13, 2026
What it was asked
The prompt
“a 3D aquarium with Three.js where the fish swim away when I move the mouse near them (Load Three.js only through this import map: <script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}</script>)”
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task. Output rules: - Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block. - Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task. - Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw. - The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar. - Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction. - Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session. - Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing. - The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message. - The code must run with zero errors in the console of the latest Chrome.
Code run-1.html · 1097 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Glass Tide — Interactive 3D Aquarium</title>
<style>
:root{
--ink:#eaf8ff;
--muted:#8fb8cb;
--cyan:#6fe6f7;
--deep:#04121d;
--glass:rgba(9,38,58,.42);
--line:rgba(140,222,246,.18);
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
background:var(--deep);
color:var(--ink);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,Helvetica,Arial,sans-serif;
overflow:hidden;
-webkit-font-smoothing:antialiased;
}
#scene{
position:fixed; inset:0;
width:100%; height:100%;
display:block;
touch-action:none;
}
.sheen{
position:fixed; inset:0; pointer-events:none;
background:
linear-gradient(112deg, rgba(190,240,255,.10) 0%, rgba(190,240,255,0) 26%, rgba(190,240,255,0) 62%, rgba(190,240,255,.055) 78%, rgba(190,240,255,0) 100%);
mix-blend-mode:screen;
}
.vignette{
position:fixed; inset:0; pointer-events:none;
background:radial-gradient(128% 96% at 50% 26%, rgba(0,0,0,0) 38%, rgba(2,12,20,.42) 78%, rgba(1,8,14,.78) 100%);
}
.ui{
position:fixed; inset:0;
display:flex; flex-direction:column; justify-content:space-between;
gap:18px;
padding:clamp(18px,3vw,40px);
pointer-events:none;
}
.top{display:flex; justify-content:space-between; align-items:flex-start; gap:24px}
.brand{max-width:min(620px,58vw)}
.kicker{
display:flex; align-items:center; gap:9px;
font-size:clamp(10px,1.1vw,12px);
letter-spacing:.22em; text-transform:uppercase;
color:#9ed9ee; font-weight:600;
}
.dot{
width:8px;height:8px;border-radius:50%;
background:#5ef0d6;
box-shadow:0 0 0 4px rgba(94,240,214,.16), 0 0 14px rgba(94,240,214,.85);
animation:pulse 2.6s ease-in-out infinite;
}
@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.55;transform:scale(.82)}}
h1{
margin:.18em 0 .18em;
font-size:clamp(38px,7.2vw,86px);
line-height:.92;
letter-spacing:-.035em;
font-weight:750;
}
h1 em{
font-style:normal;
background:linear-gradient(100deg,#8ff2ff 0%,#6fe6f7 38%,#b6ffe9 72%,#79d8ff 100%);
-webkit-background-clip:text; background-clip:text; color:transparent;
filter:drop-shadow(0 6px 26px rgba(70,215,255,.30));
}
.sub{
margin:0;
max-width:44ch;
font-size:clamp(13px,1.5vw,17px);
line-height:1.55;
color:#b9d7e4;
text-shadow:0 2px 18px rgba(2,12,20,.7);
}
.panel{
pointer-events:none;
width:clamp(220px,23vw,282px);
padding:18px 18px 16px;
border-radius:18px;
background:var(--glass);
border:1px solid var(--line);
backdrop-filter:blur(16px) saturate(140%);
-webkit-backdrop-filter:blur(16px) saturate(140%);
box-shadow:0 26px 60px rgba(2,14,24,.42), inset 0 1px 0 rgba(190,240,255,.14);
}
.panel-head{
display:flex; justify-content:space-between; align-items:center;
font-size:10.5px; letter-spacing:.2em; text-transform:uppercase;
color:#a8d5e6; font-weight:700;
padding-bottom:12px; margin-bottom:12px;
border-bottom:1px solid rgba(150,225,245,.14);
}
.live{
font-size:9px; letter-spacing:.16em; color:#06202c;
background:linear-gradient(180deg,#7ff0dd,#4fd6e6);
padding:3px 7px; border-radius:20px; font-weight:800;
}
.row{
display:flex; justify-content:space-between; align-items:baseline;
padding:6px 0;
font-size:12px; color:#9dc4d5; letter-spacing:.055em;
}
.row b{
color:#eafaff; font-size:14px; font-weight:650; letter-spacing:.01em;
font-variant-numeric:tabular-nums;
}
.meter-label{
display:flex; justify-content:space-between; align-items:baseline;
margin-top:14px; font-size:10px; letter-spacing:.18em;
text-transform:uppercase; color:#8fb8cb;
}
.meter-label b{color:#8ef0e2; letter-spacing:.12em; font-size:10px}
.meter{
margin-top:8px; height:6px; border-radius:20px;
background:rgba(150,225,245,.14); overflow:hidden;
}
.meter i{
display:block; height:100%; width:8%;
border-radius:20px;
background:linear-gradient(90deg,#59e2f2,#8ff7d8,#ffe08a);
box-shadow:0 0 16px rgba(110,235,240,.65);
transition:width .22s ease;
}
.bottom{
display:flex; align-items:flex-end; justify-content:space-between; gap:18px;
}
.hint{
display:flex; align-items:center; gap:14px;
padding:12px 20px 12px 14px;
border-radius:100px;
background:rgba(8,34,52,.46);
border:1px solid var(--line);
backdrop-filter:blur(14px) saturate(140%);
-webkit-backdrop-filter:blur(14px) saturate(140%);
box-shadow:0 20px 46px rgba(2,14,24,.42), inset 0 1px 0 rgba(190,240,255,.12);
transition:opacity .5s ease, transform .5s ease;
}
.hint.dim{opacity:.5; transform:translateY(4px)}
.hint b{display:block; font-size:13.5px; letter-spacing:.012em; font-weight:650}
.hint span{display:block; font-size:11.5px; color:#a3c8d8; letter-spacing:.04em; margin-top:2px}
.mouse{
position:relative; flex:0 0 auto;
width:32px; height:44px; border-radius:16px;
border:1.5px solid rgba(150,228,246,.5);
background:rgba(120,220,245,.10);
}
.mouse i{
position:absolute; left:50%; top:8px; width:3px; height:9px;
border-radius:3px; background:#7fe9f7; transform:translateX(-50%);
animation:scroll 1.9s ease-in-out infinite;
box-shadow:0 0 10px rgba(127,233,247,.9);
}
@keyframes scroll{0%{opacity:0;transform:translate(-50%,-4px)}35%{opacity:1}100%{opacity:0;transform:translate(-50%,12px)}}
.credit{
font-size:10.5px; letter-spacing:.22em; text-transform:uppercase;
color:rgba(150,200,218,.6); text-align:right;
}
.fallback{
position:fixed; inset:0; display:none; place-items:center;
text-align:center; padding:30px; color:#bfe6f4;
}
@media (max-width: 860px){
.panel{width:210px; padding:14px}
.row{font-size:11px}
.row b{font-size:12.5px}
.brand{max-width:56vw}
}
@media (max-width: 680px){
.panel{display:none}
.brand{max-width:100%}
.sub{display:none}
.credit{display:none}
.bottom{justify-content:center}
.hint{width:100%; border-radius:22px; padding:12px 16px}
.hint b{font-size:12.5px}
.hint span{font-size:10.5px}
}
@media (max-height: 520px){
.sub{display:none}
h1{font-size:clamp(30px,6vw,52px)}
}
</style>
</head>
<body>
<canvas id="scene"></canvas>
<div class="sheen"></div>
<div class="vignette"></div>
<div class="ui">
<div class="top">
<div class="brand">
<div class="kicker"><span class="dot"></span> Live exhibit · Tank 03</div>
<h1>Glass <em>Tide</em></h1>
<p class="sub">A procedural reef rendered in real time. Bring your cursor close to the water — every fish feels the disturbance and darts away.</p>
</div>
<aside class="panel">
<div class="panel-head">Tank telemetry <span class="live">Live</span></div>
<div class="row"><span>Specimens</span><b id="stat-fish">18</b></div>
<div class="row"><span>Water temp</span><b>24.6 °C</b></div>
<div class="row"><span>Current</span><b>0.31 m/s</b></div>
<div class="row"><span>Control</span><b id="stat-mode">Auto tour</b></div>
<div class="meter-label"><span>Proximity response</span><b id="status-text">Calm</b></div>
<div class="meter"><i id="meter-fill"></i></div>
</aside>
</div>
<div class="bottom">
<div class="hint" id="hint">
<div class="mouse"><i></i></div>
<div>
<b>Move your cursor through the water</b>
<span>Click anywhere, or press a key for keyboard control · WASD / arrow keys</span>
</div>
</div>
<div class="credit">Three.js · procedural geometry · no textures</div>
</div>
</div>
<div class="fallback" id="fallback">
<div>
<h2>WebGL unavailable</h2>
<p>This exhibit needs a WebGL-capable browser to render the aquarium.</p>
</div>
</div>
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}</script>
<script type="module">
import * as THREE from 'three';
try {
init();
} catch (err) {
document.getElementById('fallback').style.display = 'grid';
console.warn('Aquarium could not start:', err && err.message ? err.message : err);
}
function init() {
// ---------------------------------------------------------------- renderer
const canvas = document.getElementById('scene');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(window.innerWidth, window.innerHeight, false);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.08;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 220);
camera.position.set(0, 5.5, 15.5);
const clock = new THREE.Clock();
let time = 0;
// ---------------------------------------------------------------- helpers
const clamp = THREE.MathUtils.clamp;
const rand = (a, b) => a + Math.random() * (b - a);
// ---------------------------------------------------------------- backdrop
const bg = new THREE.Mesh(
new THREE.SphereGeometry(90, 32, 20),
new THREE.ShaderMaterial({
side: THREE.BackSide,
depthWrite: false,
uniforms: {
uTop: { value: new THREE.Color('#0d4f74') },
uMid: { value: new THREE.Color('#072c44') },
uBottom: { value: new THREE.Color('#02101c') }
},
vertexShader: `
varying vec3 vP;
void main(){
vP = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0);
}`,
fragmentShader: `
varying vec3 vP;
uniform vec3 uTop; uniform vec3 uMid; uniform vec3 uBottom;
void main(){
float h = normalize(vP).y * 0.5 + 0.5;
vec3 c = mix(uBottom, uMid, smoothstep(0.18, 0.52, h));
c = mix(c, uTop, smoothstep(0.5, 0.92, h));
gl_FragColor = vec4(c, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`
})
);
bg.frustumCulled = false;
scene.add(bg);
// ---------------------------------------------------------------- lights
scene.add(new THREE.HemisphereLight(0x9fe6ff, 0x08202f, 1.15));
const keyLight = new THREE.DirectionalLight(0xdcf7ff, 2.35);
keyLight.position.set(7, 17, 10);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(1024, 1024);
keyLight.shadow.camera.left = -13;
keyLight.shadow.camera.right = 13;
keyLight.shadow.camera.top = 13;
keyLight.shadow.camera.bottom = -13;
keyLight.shadow.camera.near = 1;
keyLight.shadow.camera.far = 52;
keyLight.shadow.bias = -0.0012;
keyLight.shadow.normalBias = 0.035;
keyLight.target.position.set(0, 2.5, 0);
scene.add(keyLight, keyLight.target);
const fillLight = new THREE.DirectionalLight(0x2f9fd2, 1.15);
fillLight.position.set(-10, 7, -8);
scene.add(fillLight);
const rimLight = new THREE.PointLight(0x39d9ff, 46, 34, 2);
rimLight.position.set(0, 8.2, -3.4);
scene.add(rimLight);
// ---------------------------------------------------------------- sand
const sandGeo = new THREE.PlaneGeometry(30, 18, 110, 70);
sandGeo.rotateX(-Math.PI / 2);
{
const p = sandGeo.attributes.position;
for (let i = 0; i < p.count; i++) {
const x = p.getX(i), z = p.getZ(i);
const y = 0.22 * Math.sin(x * 0.36) * Math.cos(z * 0.5)
+ 0.11 * Math.sin(x * 0.95 + z * 0.6)
+ 0.06 * Math.cos(z * 1.25 - x * 0.3);
p.setY(i, y);
}
sandGeo.computeVertexNormals();
}
const sand = new THREE.Mesh(sandGeo, new THREE.MeshStandardMaterial({
color: 0x9a8b6c, roughness: 1, metalness: 0
}));
sand.receiveShadow = true;
scene.add(sand);
// caustics overlay
const causticUniforms = {
uTime: { value: 0 },
uColor: { value: new THREE.Color('#a8f2ff') },
uStrength: { value: 0.62 }
};
const caustics = new THREE.Mesh(
new THREE.PlaneGeometry(30, 18, 1, 1).rotateX(-Math.PI / 2),
new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
uniforms: causticUniforms,
vertexShader: `
varying vec3 vW;
void main(){
vec4 w = modelMatrix * vec4(position,1.0);
vW = w.xyz;
gl_Position = projectionMatrix * viewMatrix * w;
}`,
fragmentShader: `
varying vec3 vW;
uniform float uTime; uniform vec3 uColor; uniform float uStrength;
void main(){
vec2 p = vW.xz * 0.52;
float t = uTime * 0.55;
float a = sin(p.x * 1.7 + sin(p.y * 1.35 + t)) * sin(p.y * 1.95 + sin(p.x * 1.12 - t * 0.85));
float b = sin(p.x * 3.15 - t * 0.72 + sin(p.y * 2.25 + t * 0.62)) * sin(p.y * 2.75 + t * 0.48);
float c = pow(max(0.0, a * 0.66 + b * 0.5), 3.1);
float fade = smoothstep(15.0, 3.0, length(vW.xz));
gl_FragColor = vec4(uColor, c * uStrength * fade);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`
})
);
caustics.position.y = 0.07;
caustics.renderOrder = 2;
scene.add(caustics);
// ---------------------------------------------------------------- tank glass
const TW = 16, TH = 8.4, TD = 7.6;
const glassMat = new THREE.MeshPhysicalMaterial({
color: 0x9fe4ff,
transparent: true,
opacity: 0.075,
roughness: 0.06,
metalness: 0,
transmission: 0,
side: THREE.DoubleSide,
depthWrite: false
});
const glassParts = [
[new THREE.Vector3(0, TH / 2, -TD / 2), new THREE.Euler(0, 0, 0), TW, TH],
[new THREE.Vector3(-TW / 2, TH / 2, 0), new THREE.Euler(0, Math.PI / 2, 0), TD, TH],
[new THREE.Vector3(TW / 2, TH / 2, 0), new THREE.Euler(0, Math.PI / 2, 0), TD, TH]
];
glassParts.forEach(([pos, rot, w, h]) => {
const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), glassMat);
m.position.copy(pos); m.rotation.copy(rot); m.renderOrder = 6;
scene.add(m);
});
const frameMat = new THREE.MeshStandardMaterial({ color: 0x14394c, roughness: 0.32, metalness: 0.72 });
const frameGroup = new THREE.Group();
const addBeam = (x, y, z, sx, sy, sz) => {
const b = new THREE.Mesh(new THREE.BoxGeometry(sx, sy, sz), frameMat);
b.position.set(x, y, z);
frameGroup.add(b);
};
const t = 0.17;
[-1, 1].forEach(sy => {
[-1, 1].forEach(sz => addBeam(0, sy * (TH / 2), sz * (TD / 2), TW + t, t, t));
[-1, 1].forEach(sx => addBeam(sx * (TW / 2), sy * (TH / 2), 0, t, t, TD + t));
});
[-1, 1].forEach(sx => {
[-1, 1].forEach(sz => addBeam(sx * (TW / 2), 0, sz * (TD / 2), t, TH + t, t));
});
frameGroup.position.y = TH / 2;
scene.add(frameGroup);
// ---------------------------------------------------------------- water surface
const waterUniforms = { uTime: { value: 0 } };
const water = new THREE.Mesh(
new THREE.PlaneGeometry(TW, TD, 60, 30).rotateX(-Math.PI / 2),
new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
side: THREE.DoubleSide,
uniforms: waterUniforms,
vertexShader: `
varying vec3 vW;
uniform float uTime;
void main(){
vec4 w = modelMatrix * vec4(position,1.0);
w.y += sin(w.x * 0.62 + uTime * 0.9) * 0.075 + cos(w.z * 0.85 - uTime * 0.7) * 0.065;
vW = w.xyz;
gl_Position = projectionMatrix * viewMatrix * w;
}`,
fragmentShader: `
varying vec3 vW;
uniform float uTime;
void main(){
float w1 = sin(vW.x * 1.15 + uTime * 1.05) * cos(vW.z * 1.35 - uTime * 0.8);
float w2 = sin(vW.x * 2.6 - uTime * 0.62 + vW.z * 1.85);
float h = smoothstep(0.15, 1.0, w1 * 0.5 + 0.5) * 0.34 + pow(max(0.0, w2), 6.0) * 0.55;
vec3 col = mix(vec3(0.07,0.36,0.52), vec3(0.62,0.94,1.0), h);
gl_FragColor = vec4(col, 0.13 + h * 0.22);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`
})
);
water.position.y = TH - 0.08;
water.renderOrder = 7;
scene.add(water);
// ---------------------------------------------------------------- light shafts
const shaftUniformsList = [];
for (let i = 0; i < 6; i++) {
const u = { uTime: { value: 0 }, uSeed: { value: i * 1.37 } };
shaftUniformsList.push(u);
const shaft = new THREE.Mesh(
new THREE.PlaneGeometry(rand(2.2, 4.4), 12.5),
new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
side: THREE.DoubleSide,
uniforms: u,
vertexShader: `
varying vec2 vUv;
void main(){
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0);
}`,
fragmentShader: `
varying vec2 vUv;
uniform float uTime; uniform float uSeed;
void main(){
float edge = smoothstep(0.0, 0.46, vUv.x) * smoothstep(1.0, 0.54, vUv.x);
float vfade = smoothstep(0.02, 0.5, vUv.y) * (1.0 - smoothstep(0.72, 1.0, vUv.y));
float s = 0.55 + 0.45 * sin(vUv.x * 8.5 + uSeed * 2.7 + uTime * 0.22);
float a = edge * vfade * s * 0.14;
gl_FragColor = vec4(vec3(0.42,0.86,1.0), a);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`
})
);
shaft.position.set(rand(-7.5, 7.5), 5.6, rand(-3.4, 1.6));
shaft.rotation.set(rand(-0.16, 0.16), rand(-0.7, 0.7), rand(-0.3, 0.3));
shaft.renderOrder = 8;
scene.add(shaft);
}
// ---------------------------------------------------------------- rocks
const rockMat = new THREE.MeshStandardMaterial({ color: 0x4d5c63, roughness: 0.92, metalness: 0.06, flatShading: true });
const rockPositions = [
[-6.2, -2.4, 1.15], [-5.1, -2.9, 0.75], [6.3, -2.2, 1.35], [5.2, -2.9, 0.7],
[0.4, -3.1, 0.95], [-1.9, -2.6, 0.6], [2.9, -3.2, 0.8], [-7.1, 1.6, 0.85], [7.2, 1.2, 1.0]
];
rockPositions.forEach(([x, z, s], i) => {
const r = new THREE.Mesh(new THREE.DodecahedronGeometry(1, 0), rockMat);
r.position.set(x, s * 0.42, z);
r.scale.set(s * rand(1.0, 1.5), s * rand(0.55, 0.95), s * rand(0.9, 1.3));
r.rotation.set(rand(0, 3), rand(0, 3), rand(0, 3));
r.castShadow = true;
r.receiveShadow = true;
scene.add(r);
});
// ---------------------------------------------------------------- plants
const plants = [];
const plantColors = [0x2c7a62, 0x2f8c72, 0x1f6b58, 0x3e8d63, 0x276f74];
function makePlant(x, z, height, color) {
const group = new THREE.Group();
const blades = 5 + Math.floor(Math.random() * 4);
const mat = new THREE.MeshStandardMaterial({
color, roughness: 0.72, metalness: 0.06, side: THREE.DoubleSide
});
for (let i = 0; i < blades; i++) {
const h = height * rand(0.55, 1.15);
const sx = rand(-0.45, 0.45), sz = rand(-0.45, 0.45);
const bend = rand(-1.5, 1.5);
const pts = [];
for (let j = 0; j <= 6; j++) {
const k = j / 6;
pts.push(new THREE.Vector3(sx + bend * k * k, h * k, sz + bend * 0.35 * k * k));
}
const curve = new THREE.CatmullRomCurve3(pts);
const geo = new THREE.TubeGeometry(curve, 14, rand(0.045, 0.085), 5, false);
const mesh = new THREE.Mesh(geo, mat);
mesh.castShadow = true;
group.add(mesh);
}
group.position.set(x, 0.05, z);
scene.add(group);
plants.push({ group, phase: Math.random() * Math.PI * 2, amp: rand(0.03, 0.07) });
}
[
[-7.0, -2.8, 2.5], [-6.2, -3.1, 1.8], [-5.4, -3.2, 2.2], [6.9, -2.8, 2.7],
[5.9, -3.2, 1.9], [4.8, -3.1, 1.4], [0.2, -3.3, 2.1], [-1.4, -3.2, 1.5],
[2.6, -3.3, 1.7], [-7.4, 1.4, 1.6], [7.4, 0.9, 1.8], [-3.4, -2.9, 1.2]
].forEach(([x, z, h], i) => makePlant(x, z, h, plantColors[i % plantColors.length]));
// ---------------------------------------------------------------- bubbles
const BUBBLE_COUNT = 140;
const bubbleGeo = new THREE.SphereGeometry(1, 8, 6);
const bubbleMat = new THREE.MeshStandardMaterial({
color: 0xcdf1ff, transparent: true, opacity: 0.3, roughness: 0.05,
metalness: 0.05, emissive: 0x2c7f9c, emissiveIntensity: 0.35
});
const bubbles = new THREE.InstancedMesh(bubbleGeo, bubbleMat, BUBBLE_COUNT);
bubbles.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
bubbles.frustumCulled = false;
scene.add(bubbles);
const bubbleData = [];
for (let i = 0; i < BUBBLE_COUNT; i++) {
bubbleData.push({
pos: new THREE.Vector3(rand(-7.6, 7.6), rand(0.2, TH - 0.3), rand(-3.5, 3.4)),
speed: rand(0.22, 0.85),
radius: rand(0.028, 0.115),
phase: Math.random() * Math.PI * 2,
sway: rand(0.06, 0.22)
});
}
const dummy = new THREE.Object3D();
// ---------------------------------------------------------------- fish
function makeBodyGeo(len, width, height, sharpness) {
const g = new THREE.SphereGeometry(0.5, 30, 20);
const p = g.attributes.position;
for (let i = 0; i < p.count; i++) {
const x = p.getX(i), y = p.getY(i), z = p.getZ(i);
const k = clamp((z / 0.5 + 1) * 0.5, 0.0001, 0.9999);
const profile = Math.pow(Math.sin(Math.PI * k), sharpness);
p.setXYZ(i, x * profile, y * profile, z);
}
g.computeVertexNormals();
g.scale(width, height, len);
return g;
}
const GEO = {
round: makeBodyGeo(1.55, 0.62, 0.92, 0.62),
long: makeBodyGeo(1.85, 0.5, 0.72, 0.72),
tall: makeBodyGeo(1.35, 0.66, 1.12, 0.58),
tail: new THREE.ConeGeometry(0.44, 0.95, 4, 1).rotateX(-Math.PI / 2),
dorsal: new THREE.ConeGeometry(0.34, 0.66, 4),
pect: new THREE.ConeGeometry(0.22, 0.52, 4),
eye: new THREE.SphereGeometry(0.1, 12, 10),
pupil: new THREE.SphereGeometry(0.055, 10, 8),
stripe: new THREE.TorusGeometry(0.31, 0.052, 8, 22)
};
const fishList = [];
function makeFish(opts) {
const group = new THREE.Group();
const bodyGroup = new THREE.Group();
group.add(bodyGroup);
const bodyMat = new THREE.MeshStandardMaterial({
color: opts.color,
roughness: 0.36,
metalness: 0.18,
emissive: opts.emissive || 0x061522,
emissiveIntensity: 0.55
});
const finMat = new THREE.MeshStandardMaterial({
color: opts.fin || opts.color,
roughness: 0.52,
metalness: 0.08,
side: THREE.DoubleSide,
transparent: true,
opacity: opts.finOpacity || 0.95
});
const body = new THREE.Mesh(opts.geo, bodyMat);
body.castShadow = true;
bodyGroup.add(body);
if (opts.stripes) {
[-0.16, 0.18].forEach((z, idx) => {
const ring = new THREE.Mesh(GEO.stripe, new THREE.MeshStandardMaterial({
color: opts.stripeColor || 0xf3fbff, roughness: 0.42, metalness: 0.1
}));
ring.position.z = z;
ring.scale.set(1.02, idx ? 1.35 : 1.55, 1);
bodyGroup.add(ring);
});
}
const tailPivot = new THREE.Group();
tailPivot.position.set(0, 0, -0.56);
const tail = new THREE.Mesh(GEO.tail, finMat);
tail.position.set(0, 0, -0.36);
tail.scale.set(0.13, 1, 1);
tail.castShadow = true;
tailPivot.add(tail);
bodyGroup.add(tailPivot);
const dorsal = new THREE.Mesh(GEO.dorsal, finMat);
dorsal.position.set(0, 0.42, -0.12);
dorsal.scale.set(0.12, 1, 1);
dorsal.rotation.x = -0.18;
bodyGroup.add(dorsal);
const ventral = new THREE.Mesh(GEO.dorsal, finMat);
ventral.position.set(0, -0.34, -0.05);
ventral.scale.set(0.1, 0.55, 1);
ventral.rotation.x = Math.PI + 0.25;
bodyGroup.add(ventral);
[-1, 1].forEach(side => {
const pect = new THREE.Mesh(GEO.pect, finMat);
pect.position.set(0.26 * side, -0.02, 0.12);
pect.scale.set(0.12, 1, 1);
pect.rotation.set(-0.75, 0, -1.05 * side);
bodyGroup.add(pect);
});
const eyeWhite = new THREE.MeshStandardMaterial({ color: 0xf6fdff, roughness: 0.22, metalness: 0.05 });
const pupilMat = new THREE.MeshStandardMaterial({ color: 0x08131c, roughness: 0.18, metalness: 0.15 });
[-1, 1].forEach(side => {
const eye = new THREE.Mesh(GEO.eye, eyeWhite);
eye.position.set(0.235 * side, 0.135, 0.52);
bodyGroup.add(eye);
const pupil = new THREE.Mesh(GEO.pupil, pupilMat);
pupil.position.set(0.292 * side, 0.14, 0.552);
bodyGroup.add(pupil);
});
group.scale.setScalar(opts.size);
group.position.copy(opts.position);
scene.add(group);
return {
group,
bodyGroup,
tailPivot,
position: group.position,
vel: opts.vel.clone(),
cruise: opts.cruise,
agility: opts.agility,
phase: Math.random() * Math.PI * 2,
wanderTarget: opts.position.clone().add(new THREE.Vector3(rand(-3, 3), rand(-1, 1), rand(-1.5, 1.5))),
wanderTimer: rand(0.4, 2.4),
boost: 0,
flock: opts.flock || null,
size: opts.size
};
}
// reef fish (independent wanderers)
const reefColors = [
{ color: 0xff9a3c, fin: 0xffd08a, geo: GEO.round },
{ color: 0x35c9e8, fin: 0x9ef1ff, geo: GEO.long },
{ color: 0xff5f7e, fin: 0xffb1c1, geo: GEO.tall },
{ color: 0xffc857, fin: 0xfff0b8, geo: GEO.round },
{ color: 0x8e7bff, fin: 0xc6bcff, geo: GEO.long },
{ color: 0x4ee0a7, fin: 0xb6ffe2, geo: GEO.tall }
];
reefColors.forEach((c, i) => {
makeFish({
geo: c.geo,
color: c.color,
fin: c.fin,
emissive: new THREE.Color(c.color).multiplyScalar(0.16).getHex(),
stripes: i === 0 || i === 3,
stripeColor: i === 3 ? 0x2a2f57 : 0xfff4e2,
size: rand(0.95, 1.35),
position: new THREE.Vector3(rand(-6.2, 6.2), rand(1.8, 6.9), rand(-2.8, 1.9)),
vel: new THREE.Vector3(rand(-1, 1), rand(-0.2, 0.2), rand(-0.5, 0.5)),
cruise: rand(1.25, 1.85),
agility: rand(1.1, 1.7)
});
});
// schooling tetras
for (let i = 0; i < 12; i++) {
makeFish({
geo: GEO.long,
color: i % 3 === 0 ? 0x9be8ff : (i % 3 === 1 ? 0x66d6f2 : 0xc7f3ff),
fin: 0xd9f7ff,
emissive: 0x0b2c3c,
size: rand(0.48, 0.66),
position: new THREE.Vector3(rand(-4.2, 4.2), rand(3.1, 6.6), rand(-2.2, 1.2)),
vel: new THREE.Vector3(rand(-1, 1), rand(-0.2, 0.2), rand(-0.4, 0.4)),
cruise: rand(1.7, 2.15),
agility: rand(1.5, 2.1),
flock: 'tetra'
});
}
document.getElementById('stat-fish').textContent = String(fishList.length || 18);
// ---------------------------------------------------------------- lure / cursor
const lure = new THREE.Group();
const core = new THREE.Mesh(
new THREE.SphereGeometry(0.17, 18, 14),
new THREE.MeshBasicMaterial({ color: 0xd9fbff, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false })
);
const halo = new THREE.Mesh(
new THREE.SphereGeometry(0.62, 18, 14),
new THREE.MeshBasicMaterial({ color: 0x62d9ff, transparent: true, opacity: 0.17, blending: THREE.AdditiveBlending, depthWrite: false })
);
const halo2 = new THREE.Mesh(
new THREE.SphereGeometry(1.25, 18, 14),
new THREE.MeshBasicMaterial({ color: 0x2fb6e8, transparent: true, opacity: 0.07, blending: THREE.AdditiveBlending, depthWrite: false })
);
lure.add(core, halo, halo2);
const lureLight = new THREE.PointLight(0x74e2ff, 26, 11, 2);
lure.add(lureLight);
lure.position.set(0, 4.2, 0);
scene.add(lure);
const lureTarget = new THREE.Vector3(0, 4.2, 0);
// ---------------------------------------------------------------- interaction
const raycaster = new THREE.Raycaster();
const planeZ = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
const pointerNDC = new THREE.Vector2(0, 0);
let mode = 'auto'; // 'auto' | 'cursor' | 'key'
let lastInput = -100;
const keys = new Set();
const modeLabel = document.getElementById('stat-mode');
const hintEl = document.getElementById('hint');
function setMode(next) {
if (mode === next) return;
mode = next;
lastInput = time;
modeLabel.textContent = next === 'auto' ? 'Auto tour' : (next === 'cursor' ? 'Cursor' : 'Keyboard');
hintEl.classList.toggle('dim', next !== 'auto');
}
window.addEventListener('pointermove', (e) => {
const r = canvas.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return;
pointerNDC.x = ((e.clientX - r.left) / r.width) * 2 - 1;
pointerNDC.y = -((e.clientY - r.top) / r.height) * 2 + 1;
setMode('cursor');
lastInput = time;
}, { passive: true });
function splash() {
for (let i = 0; i < fishList.length; i++) {
const f = fishList[i];
const d = f.position.distanceTo(lure.position);
if (d < 5.2) {
_tmp.copy(f.position).sub(lure.position).normalize();
f.vel.addScaledVector(_tmp, (1 - d / 5.2) * 9.5);
f.boost = Math.max(f.boost, 1 - d / 5.2);
}
}
}
canvas.addEventListener('pointerdown', () => {
setMode('cursor');
lastInput = time;
splash();
pulse = 1;
});
const moveKeys = ['w', 'a', 's', 'd', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'];
window.addEventListener('keydown', (e) => {
const k = e.key.toLowerCase();
if (moveKeys.includes(k)) {
e.preventDefault();
keys.add(k);
setMode('key');
lastInput = time;
}
});
window.addEventListener('keyup', (e) => keys.delete(e.key.toLowerCase()));
window.addEventListener('blur', () => keys.clear());
// ---------------------------------------------------------------- temps
const _tmp = new THREE.Vector3();
const _tmp2 = new THREE.Vector3();
const _desired = new THREE.Vector3();
const _steer = new THREE.Vector3();
const _threat = new THREE.Vector3();
const _flee = new THREE.Vector3();
const _look = new THREE.Vector3();
const _acc = new THREE.Vector3();
const _q = new THREE.Quaternion();
const _m = new THREE.Matrix4();
const _camForward = new THREE.Vector3();
const _rayPoint = new THREE.Vector3();
const _up = new THREE.Vector3(0, 1, 0);
let pulse = 0;
// ---------------------------------------------------------------- camera
let camBaseZ = 15.5;
const camTarget = new THREE.Vector3(0, 3.95, 0);
function resize() {
const w = window.innerWidth, h = window.innerHeight;
renderer.setSize(w, h, false);
const aspect = w / h;
camera.aspect = aspect;
camera.fov = aspect < 1 ? 62 : 50;
const vFov = THREE.MathUtils.degToRad(camera.fov);
const needW = aspect < 1 ? 15.8 : 19.2;
const needH = 11.2;
const distH = (needH / 2) / Math.tan(vFov / 2);
const distW = (needW / 2) / (Math.tan(vFov / 2) * aspect);
camBaseZ = Math.max(11.6, Math.max(distH, distW) * 0.94);
camera.updateProjectionMatrix();
}
window.addEventListener('resize', resize);
resize();
// ---------------------------------------------------------------- update: fish
function updateFish(dt) {
camera.updateMatrixWorld();
camera.getWorldDirection(_camForward);
let maxBoost = 0;
for (let i = 0; i < fishList.length; i++) {
const f = fishList[i];
// --- threat point at the fish's own depth (screen-accurate proximity)
_tmp.copy(lure.position).project(camera);
_tmp2.set(_tmp.x, _tmp.y, 0.5).unproject(camera);
_tmp2.sub(camera.position).normalize();
const depth = Math.max(1.0, _tmp.copy(f.position).sub(camera.position).dot(_camForward));
_threat.copy(camera.position).addScaledVector(_tmp2, depth / Math.max(0.05, _tmp2.dot(_camForward)));
const dist = f.position.distanceTo(_threat);
const fleeRadius = 3.35 + f.size * 0.9;
if (dist < fleeRadius) {
const urgency = 1 - dist / fleeRadius;
_flee.copy(f.position).sub(_threat);
_flee.y *= 0.52;
if (_flee.lengthSq() < 1e-6) _flee.set(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5);
_flee.normalize();
f.vel.addScaledVector(_flee, urgency * urgency * 52 * dt);
f.boost = Math.max(f.boost, urgency);
}
maxBoost = Math.max(maxBoost, f.boost);
// --- wander
f.wanderTimer -= dt;
if (f.wanderTimer <= 0) {
f.wanderTimer = rand(1.6, 4.2);
f.wanderTarget.set(rand(-6.6, 6.6), rand(1.35, 7.15), rand(-3.1, 2.35));
}
_desired.copy(f.wanderTarget).sub(f.position);
if (_desired.lengthSq() > 0.0001) {
_desired.normalize().multiplyScalar(f.cruise);
_steer.copy(_desired).sub(f.vel);
f.vel.addScaledVector(_steer, dt * f.agility);
}
// --- soft tank bounds
const B = { x: 7.05, yMin: 1.05, yMax: 7.55, zMin: -3.25, zMax: 2.55 };
const edge = 1.5;
if (f.position.x > B.x - edge) f.vel.x -= dt * 13 * (f.position.x - (B.x - edge));
if (f.position.x < -B.x + edge) f.vel.x += dt * 13 * ((-B.x + edge) - f.position.x);
if (f.position.y > B.yMax - edge) f.vel.y -= dt * 11 * (f.position.y - (B.yMax - edge));
if (f.position.y < B.yMin + edge) f.vel.y += dt * 12 * ((B.yMin + edge) - f.position.y);
if (f.position.z > B.zMax - edge) f.vel.z -= dt * 12 * (f.position.z - (B.zMax - edge));
if (f.position.z < B.zMin + edge) f.vel.z += dt * 12 * ((B.zMin + edge) - f.position.z);
// --- speed
const maxSpeed = f.cruise * (1 + f.boost * 3.2) * 1.25;
const sp = f.vel.length();
if (sp > maxSpeed) f.vel.multiplyScalar(maxSpeed / sp);
f.vel.multiplyScalar(1 - dt * 0.55);
f.position.addScaledVector(f.vel, dt);
// --- orientation
if (f.vel.lengthSq() > 0.0005) {
_look.copy(f.position).addScaledVector(f.vel, 0.9);
_m.lookAt(_look, f.position, _up);
_q.setFromRotationMatrix(_m);
f.group.quaternion.slerp(_q, 1 - Math.exp(-dt * 5.2));
}
// --- animation
const wag = Math.sin(time * (5.2 + f.boost * 13) + f.phase) * (0.3 + f.boost * 0.5);
f.tailPivot.rotation.y = wag;
f.bodyGroup.rotation.y = Math.sin(time * (2.6 + f.boost * 6) + f.phase) * 0.045;
f.bodyGroup.rotation.z = Math.sin(time * 1.6 + f.phase * 1.7) * 0.035;
f.boost = Math.max(0, f.boost - dt * 1.35);
}
// --- light flocking for the tetra school
const school = fishList.filter(f => f.flock === 'tetra');
for (let i = 0; i < school.length; i++) {
const a = school[i];
_acc.set(0, 0, 0);
let n = 0;
for (let j = 0; j < school.length; j++) {
if (i === j) continue;
const b = school[j];
const d = a.position.distanceTo(b.position);
if (d < 3.1 && d > 0.0001) {
_acc.addScaledVector(b.vel, 0.36 / d);
_tmp.copy(b.position).sub(a.position);
_acc.addScaledVector(_tmp, 0.13 / d);
if (d < 1.3) {
_tmp.copy(a.position).sub(b.position);
_acc.addScaledVector(_tmp, ((1.3 - d) * 1.7) / d);
}
n++;
}
}
if (n) a.vel.addScaledVector(_acc, dt * 0.62);
}
return maxBoost;
}
// ---------------------------------------------------------------- update: bubbles
function updateBubbles(dt) {
for (let i = 0; i < BUBBLE_COUNT; i++) {
const b = bubbleData[i];
b.pos.y += b.speed * dt * (1 + 0.25 * Math.sin(time * 0.8 + b.phase));
b.pos.x += Math.sin(time * 1.15 + b.phase) * b.sway * dt;
b.pos.z += Math.cos(time * 0.9 + b.phase * 1.3) * b.sway * 0.6 * dt;
if (b.pos.y > TH - 0.25) {
b.pos.y = rand(0.15, 1.1);
b.pos.x = rand(-7.7, 7.7);
b.pos.z = rand(-3.5, 3.3);
}
dummy.position.copy(b.pos);
const s = b.radius * (1 + 0.16 * Math.sin(time * 2.2 + b.phase));
dummy.scale.setScalar(s);
dummy.updateMatrix();
bubbles.setMatrixAt(i, dummy.matrix);
}
bubbles.instanceMatrix.needsUpdate = true;
}
// ---------------------------------------------------------------- HUD
const meterFill = document.getElementById('meter-fill');
const statusText = document.getElementById('status-text');
let lastMeter = -1, lastStatus = '';
function updateHUD(boost) {
const v = clamp(boost, 0, 1);
const pct = Math.round(6 + v * 94);
if (pct !== lastMeter) {
meterFill.style.width = pct + '%';
lastMeter = pct;
}
const label = v > 0.62 ? 'Startled' : (v > 0.24 ? 'Alert' : 'Calm');
if (label !== lastStatus) {
statusText.textContent = label;
statusText.style.color = label === 'Calm' ? '#8ef0e2' : (label === 'Alert' ? '#ffd98a' : '#ff9c9c');
lastStatus = label;
}
}
// ---------------------------------------------------------------- loop
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
time += dt;
// ---- control mode
if (mode !== 'auto' && time - lastInput > 9) setMode('auto');
if (mode === 'auto') {
lureTarget.set(
Math.sin(time * 0.29) * 6.1,
4.15 + Math.sin(time * 0.215 + 1.25) * 2.55,
Math.cos(time * 0.175) * 1.9
);
} else if (mode === 'cursor') {
raycaster.setFromCamera(pointerNDC, camera);
const hit = raycaster.ray.intersectPlane(planeZ, _rayPoint);
if (hit) {
lureTarget.set(
clamp(hit.x, -7.6, 7.6),
clamp(hit.y, 0.7, 7.9),
clamp(hit.z, -2.6, 2.2)
);
}
} else {
let dx = 0, dy = 0;
if (keys.has('w') || keys.has('arrowup')) dy += 1;
if (keys.has('s') || keys.has('arrowdown')) dy -= 1;
if (keys.has('a') || keys.has('arrowleft')) dx -= 1;
if (keys.has('d') || keys.has('arrowright')) dx += 1;
if (dx || dy) {
const l = Math.hypot(dx, dy) || 1;
lureTarget.x = clamp(lureTarget.x + (dx / l) * 8.2 * dt, -7.6, 7.6);
lureTarget.y = clamp(lureTarget.y + (dy / l) * 6.4 * dt, 0.7, 7.9);
lastInput = time;
}
}
const follow = mode === 'cursor' ? 1 - Math.exp(-dt * 13) : 1 - Math.exp(-dt * 2.1);
lure.position.lerp(lureTarget, follow);
pulse = Math.max(0, pulse - dt * 2.2);
const breathe = 1 + Math.sin(time * 2.1) * 0.06 + pulse * 1.25;
halo.scale.setScalar(breathe);
halo2.scale.setScalar(1 + Math.sin(time * 1.4 + 1) * 0.12 + pulse * 1.6);
core.scale.setScalar(1 + pulse * 0.7);
lureLight.intensity = 22 + Math.sin(time * 2.4) * 3 + pulse * 34;
// ---- camera
const drift = time * 0.072;
const camX = Math.sin(drift) * 1.5 + pointerNDC.x * 1.35;
const camY = 5.45 + Math.sin(drift * 1.31) * 0.42 + pointerNDC.y * 0.85;
const camZ = camBaseZ + Math.cos(drift * 0.83) * 0.55;
camera.position.lerp(_tmp2.set(camX, camY, camZ), 1 - Math.exp(-dt * 1.5));
camera.lookAt(camTarget);
// ---- world
const boost = updateFish(dt);
updateBubbles(dt);
for (let i = 0; i < plants.length; i++) {
const p = plants[i];
p.group.rotation.z = Math.sin(time * 0.62 + p.phase) * p.amp;
p.group.rotation.x = Math.cos(time * 0.48 + p.phase * 1.3) * p.amp * 0.75;
}
causticUniforms.uTime.value = time;
waterUniforms.uTime.value = time;
for (let i = 0; i < shaftUniformsList.length; i++) shaftUniformsList[i].uTime.value = time;
renderer.render(scene, camera);
updateHUD(boost);
}
animate();
}
</script>
</body>
</html>Reasoning trace (18,495 tokens)
We need to produce a complete HTML file with a 3D aquarium using Three.js via the import map. Must be self-contained, no external assets. Fish swim away from mouse pointer (projected into 3D via raycast onto a plane). Need visually rich: water, glass tank, caustics-ish lighting, bubbles, plants, sand, fish made from procedural geometry, maybe multiple species. Screenshot at 3s must be meaningful.
Let me design:
- Scene: dark blue gradient background (canvas texture or scene fog + big backdrop plane / gradient shader).
- Tank: glass box with transparent panels (slightly visible), sand floor, rocks, seaweed (curved tube geometry or simple planes with shader sway), bubbles rising.
- Fish: procedural: body = scaled sphere (or lathe), tail = cone/plane, fins. Each fish has a group with userData: position, velocity, wander target, phase.
- Mouse interaction: raycast onto a plane at z=0 (or the tank mid-plane), get world point; fish flee from that point within radius, with speed boost, then return to normal wandering.
- Camera: slight orbit / parallax with mouse too, but keep simple. Maybe camera gently auto-drifts and follows mouse subtly.
- Lighting: hemisphere + directional + point lights, animated caustics via a custom shader on the sand (procedural voronoi-ish noise in GLSL) — could be nice but keep to shader material with animated noise. That's fine, self-contained.
Actually let's do caustics on the sand with a ShaderMaterial: fragment shader computing layered sin/cos noise producing bright patterns. Nice.
Water surface: a plane at top with shader for wavy highlights, semi-transparent.
Bubbles: instanced or many small spheres with transparent material, moving up and reset.
Plants: use TubeGeometry along a curve, with slight sway? Sway on tube is harder; instead use plane fronds with vertex shader sway. Or just static curved tubes — fine.
Fish design: Use a body built from SphereGeometry scaled, plus tail = flattened cone (rotated), dorsal fin = flattened cone, eyes = small spheres. Different colors per fish. Maybe ~18 fish, 3 species sizes.
Movement: boids-lite: wander with sin-based heading, stay inside tank bounds (soft steering), flee from pointer.
Pointer: raycast onto plane z = 0 (tank center plane) in world. Also compute for touch. Use a glowing "cursor lure" sphere? Maybe a small light orb at pointer position in the tank — that reads well visually and shows interaction. Yes: a soft glowing sphere + point light at the pointer position, moved with mouse.
Camera: position around (0, 2.2, 11) looking at (0,1.5,0), with subtle parallax based on mouse, and slow orbit drift. But if camera moves with mouse, the raycast must handle it — yes raycaster handles camera.
Note: "fish swim away when I move the mouse near them" — must work. The pointer in 3D space: use raycaster with a plane at z=0. Fish at various z. Compute distance in 3D from the pointer world point — fine, but consider x,y mainly. Better: compute distance from fish to the ray line? Actually "move mouse near them" visually means screen-space proximity. Best approach: project fish to screen space and compare to mouse NDC distance. That's accurate visually. Combine: compute screen-space distance, if < threshold, flee away in screen-projected direction (world direction computed via camera right/up vectors). That's robust and looks right.
Let's do: for each fish, project position to NDC; dist in NDC with aspect correction; if dist < fleeRadiusNDC, flee direction = normalize(fishPos - pointerWorldPoint) in world but also use camera right/up from NDC delta. Simpler: compute a world "threat point" by unprojecting mouse NDC at fish's depth (distance along camera forward). That gives accurate per-fish threat position. Then flee = normalize(fishPos - threatPoint). Distance check in world. That is clean and physically sensible.
Implement: for each fish, take mouse NDC, unproject at the fish's camera depth:
```
const ndc = new THREE.Vector3(mouseNDC.x, mouseNDC.y, 0.5).unproject(camera);
const dir = ndc.sub(camera.position).normalize();
const dist = fish.position.clone().sub(camera.position).dot(camera.getWorldDirection(...)); // depth along forward
const threat = camera.position.clone().add(dir.multiplyScalar(dist));
```
Then flee distance = fish.position.distanceTo(threat). Great.
Also show a "cursor lure" ring at the nearest fish? Maybe simpler: a soft glowing sphere at the plane z=0 raycast point, which is close enough visually.
Add UI overlay: title "GLASS TIDE — Interactive Aquarium", instructions, stat chips (fish count, temp, "mouse proximity: calm/alert"). Keep stylish with CSS gradients, glass panels. Must adapt to 375px.
Loading: Three.js from CDN — module import; must not have console errors. Add a fallback message if import fails? Not necessary but could catch errors gracefully. Careful: any error in console fails. Use try/catch around init maybe.
Need everything visible on first paint: overlay + canvas rendering within 3s. Three.js module loads from CDN, fine.
Let's write the code.
Structure:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>...</title>
<style>...</style>
</head>
<body>
<div id="app">
<canvas id="scene"></canvas>
<div class="vignette"></div>
<header>...</header>
<div class="hint">Move your cursor through the water ...</div>
<footer class="readouts">...</footer>
</div>
<script type="importmap">...</script>
<script type="module">...</script>
</body>
</html>
```
Now the JS details.
Renderer: `new THREE.WebGLRenderer({canvas, antialias:true, alpha:false})`, setPixelRatio(min(devicePixelRatio,2)), setSize, outputColorSpace SRGB, toneMapping ACESFilmic, toneMappingExposure ~1.1.
Scene background: gradient via a big BackSide sphere with ShaderMaterial (vertical gradient deep blue → teal). Plus fog `THREE.FogExp2(0x072b45, 0.028)` — careful with shader material ignoring fog (fine).
Camera: PerspectiveCamera(52, aspect, 0.1, 200), position (0, 2.6, 13).
Tank dims: width 16, height 9, depth 8. Center y = 4? Let's set floor y=0, water top y=8.6, tank x from -8 to 8, z from -4 to 4.
Camera at (0, 4, 15) looking at (0, 4, 0)? For a nice view maybe slightly above: camera (0, 5.2, 15.5), lookAt (0,4,0). Hmm, with 52 fov and distance 15, vertical visible ~ 2*15*tan(26°)=14.6 — tank height 8.6 fits, width visible = 14.6*aspect(1.6)=23 > 16 fits. Good.
Add slight downward angle so floor is visible. camera.position.set(0, 5.6, 16), lookAt(0, 3.8, 0).
Sand floor: PlaneGeometry(20, 12, 64, 64) rotated -PI/2 at y=0, with vertex displacement for gentle dunes and the caustics shader material.
Caustics shader for sand:
```glsl
uniform float uTime;
varying vec2 vUv; varying vec3 vWorldPos;
vertex: displace y with sin waves, pass uv & world pos.
fragment: base sand color mixed with depth; caustic pattern:
float c = 0.0;
vec2 p = vWorldPos.xz * 0.35;
c += pow(abs(sin(p.x*1.3 + sin(p.y*1.7 + uTime*0.6)) * sin(p.y*1.1 + sin(p.x*1.9 - uTime*0.5))), 6.0);
```
Multiply brightness by light and add. Also add grid-ish? Keep it simple and pretty.
Glass tank: 4 side panels + maybe not the front panel (to avoid haze over the view) — actually a subtle front glass adds realism with a faint reflection/edge. I'll add edge frames: thin boxes (dark teal metal) at the 12 edges, plus subtle side/back glass panels with transparent material opacity 0.06, and skip the front panel (or add very subtle with opacity 0.035 and depthWrite false). Let's add front glass with opacity 0.05 and blending normal, rendered last (renderOrder) — could cause artifacts. Simpler: side & back glass only, plus edges. Plus a subtle "glass sheen" overlay via CSS gradient on top of canvas — nice trick: a CSS diagonal sheen band with low opacity. Good.
Water surface: plane at y=8.6, size 16x8, shader: transparent, animated normals-ish highlights using sin waves, viewed from below/above. Use DoubleSide, opacity ~0.35, color light cyan, with fresnel-ish highlight. Since camera is slightly above water top? Camera y=5.6 < 8.6 so we look from below the surface. Fine — we see the underside.
Plants: create ~10 seaweed clusters using `THREE.TubeGeometry` along CatmullRomCurve3 curves, dark green/teal material, at back edges and corners. Sway: animate by rotating the whole plant group slightly with sin(time) — cheap and looks fine.
Rocks: icosahedron/dodecahedron geometry scaled randomly, gray-blue material, on the floor.
Bubbles: 120 spheres via InstancedMesh with per-instance matrix update each frame; radius 0.04-0.12; material MeshPhysicalMaterial transparent opacity 0.35, roughness 0, transmission? transmission is expensive-ish but fine. Use MeshStandardMaterial with transparent + emissive light blue. Update matrices each frame — 120 instances fine.
Fish: function createFish(config). Geometry:
- body: SphereGeometry(0.5, 20, 14) scaled (1.5, 0.85, 0.6) — better use a single geometry and scale the group.
- tail: ConeGeometry(0.42, 0.85, 3?) Actually a flattened cone rotated so axis along x, positioned behind. Use ConeGeometry(0.5, 1.0, 4) rotated Z by -PI/2 so it points along +x... Let's think: cone default points +y. Rotate Z by -PI/2 → points +x. Tail should point backward (-x) and open outward. Use rotateZ(Math.PI/2) → points -x. Position x = -0.95. Scale y 0.12 (flat in z?), so scale (1, 1, 0.12)? Rotating then scaling: apply scale on the mesh after rotation: mesh.scale.set(1, 1, 0.12) in local coords of the rotated geometry... Actually geometry rotation bakes rotation, then mesh.scale applies in local space which is now aligned with world axes of the fish group. So scale.set(0.9, 1, 0.12) makes it flat in z (thin laterally)? Fish tail is vertically oriented, so it should be thin in z and tall in y. Yes scale.set(1, 1, 0.12) works — tall in y, thin in z. Good.
- dorsal fin: cone rotated to point +y-ish at top, scaled thin in z.
- pectoral fins: two small cones on sides.
- eyes: two small spheres white with dark pupil at front sides.
Color: body material MeshStandardMaterial with color, roughness 0.35, metalness 0.05, flatShading false. Add emissive slight.
Fish group orientation: use `fish.lookAt(pos + velocity)` since default forward for the model is... The body is elongated along x with head at +x. lookAt makes -z? No: lookAt orients the object's +z axis toward the target. So model forward should be +z. Let's build the fish with head at +z instead: body scaled (0.6, 0.85, 1.5), tail at z = -0.95, cone rotated X by +PI/2 → points... cone +y rotated about X by +90° maps +y → +z? Rotation about X by +90°: y→z, z→-y. So +y becomes +z. We want tail pointing -z: rotate X by -PI/2. Then scale to flatten in x: scale.set(0.12, 1, 1). Good.
Dorsal fin: cone pointing +y, positioned at y=0.55, z=-0.1, scaled x thin: scale.set(0.12,1,1). Good.
Eyes at z=+0.75, x=±0.35, y=0.12.
Tail wag: rotate tail mesh around y with sin(time*speed + phase) — the tail mesh pivot at its base. Since geometry is baked, offset the tail mesh so that its group pivot is at the base: create a Group at z=-0.75 containing the cone positioned at z=-0.25. Then rotate group.y = sin(...)*0.5. Similarly body wiggle: rotate whole fish slightly.
Fish movement:
```
state: pos, vel (Vector3), maxSpeed, wanderTarget, phase
update(dt):
// wander: steering toward a slowly moving target
target = wanderTarget; if dist< 1.2 or timer: pick new random point in tank bounds
desired = (target - pos).normalize() * cruiseSpeed
steer = desired - vel; vel += steer * dt * 1.2
// flee
threatPoint computed; d = pos.distanceTo(threat)
if d < fleeRadius (e.g., 3.2 world units):
urgency = 1 - d/fleeRadius
fleeDir = (pos - threat).normalize() (with y damping and wall push)
vel += fleeDir * urgency * 22 * dt
speed cap increases
// bounds: soft repulsion
// integrate pos += vel*dt
```
Then orient fish: lookAt(pos + vel) but smooth with quaternion slerp to avoid jitter. Use a target quaternion via `m.lookAt(eye, target, up)` and `q.setFromRotationMatrix(m)`, then `group.quaternion.slerp(q, 1 - Math.exp(-dt*4))`.
Also fish should bank: roll based on turn rate — optional, skip or slight.
Speed: cruise ~ 1.6 units/s, flee up to 7.
Flee radius should scale with camera distance — just use world units, ~3.5.
Also make fish avoid the very front (z>2.5) to stay visible? Allow z in [-3.5, 2.5].
Bubbles near pointer? Skip.
Interaction feedback UI: a status chip that shows "CALM" / "STARTLED" when fish flee. Update text with DOM (cheap, only when changed).
Also a "cursor lure" glow: a small sphere with additive material + a THREE.PointLight at the pointer position in the tank, moving with the mouse. Position: raycast to a plane z=0... but fish are at different z. Use the point at fixed depth: unproject mouse NDC at the depth of tank center. Compute: `const p = new THREE.Vector3(mouse.x, mouse.y, 0.5).unproject(camera); dir = p - camera.position; t = (targetPlaneDistance)/...`. Simpler: raycaster.ray.intersectPlane(planeZ0) where planeZ0 = new THREE.Plane(new THREE.Vector3(0,0,1), 0). That gives a world point on z=0. Good for the lure.
Camera parallax: camera.position.x = mouseX * 1.2, y = baseY + mouseY * 0.8, lookAt(0,3.9,0) — smooth. Plus slow auto drift with sin(time*0.08) for a living feel. Careful: the raycast then uses the actual camera matrix — must call camera.updateMatrixWorld before raycasting; do it after setting position in the render loop before computing threat points. Fine.
But careful: if camera parallax uses mouse AND fish flee uses mouse ray through camera — consistent since we compute after camera update.
Resize handling: update camera aspect, renderer size, and CSS.
Reduced motion? not necessary.
Now caustics on fish? Skip.
Let me also add light shafts: a few large transparent planes with additive gradient material angled from the top, slowly moving — adds a lot of visual richness. Use a shader with vertical gradient & soft stripes. Let's do 4 planes rotated around Y, with a ShaderMaterial (additive, depthWrite false, side double) with noise-based streaks. Keep opacity low.
Implementation of light shaft shader:
```glsl
varying vec2 vUv;
uniform float uTime;
void main(){
float x = vUv.x;
float band = smoothstep(0.0,0.35,vUv.y) * (1.0-smoothstep(0.6,1.0,vUv.y)); // fade top?
float streak = 0.5 + 0.5*sin(x*22.0 + uTime*0.25) * sin(x*7.0 - uTime*0.15);
float a = band * pow(streak, 2.0) * 0.16;
gl_FragColor = vec4(vec3(0.35,0.85,1.0), a);
}
```
Actually vertical fade: vUv.y=0 bottom, 1 top; want brighter at top fading down: `fade = smoothstep(0.05, 0.95, vUv.y)`. And the band plane is tall. Fine.
Now the DOM overlay design:
- top-left: small kicker "LIVE EXHIBIT · TANK 03", big title "GLASS TIDE", subtitle.
- top-right: glass panel with readouts: SPECIMENS 18, WATER 24.6°C, FLOW 0.3 m/s, and a "PROXIMITY" meter bar that fills when the cursor is near fish.
- bottom-center: hint pill: "Move your cursor through the water — the school darts away from your touch." with a mouse icon (CSS).
- bottom-left: small caption "Three.js · procedural geometry · no textures".
Make sure it's readable on 375px: use clamp() for font sizes, hide some readouts on small screens.
Also "Click or press a key to start" — this isn't a game; it's an interactive scene. Requirement says games must start in demo mode. This isn't a game, so not strictly needed. But the interaction requires mouse; the iframe receives keyboard after click. Mouse move should work without click presumably. I'll add an "autoplay" behavior: if no pointer interaction within... Actually the fish should swim on their own (they do). Fine. Maybe add: clicking anywhere toggles "feed mode" that attracts fish? That could conflict with "swim away". Keep: click = gentle "ripple" pulse that scatters fish — nice extra. And keyboard: arrow keys move the lure? Might be nice for accessibility. Let's add: click/press any key sets an "active" state and the hint changes; keyboard arrows/WASD move the lure so it works without a mouse. That satisfies the "click or press a key" spirit. But careful: no errors.
Let's include: hint text "Move your cursor (or click, then use WASD / arrow keys)". On first keydown/click, hint fades to a compact version.
Keep code robust. Let's write it.
Now write the actual full code carefully.
```js
import * as THREE from 'three';
```
No addons needed (good — fewer failure points).
Renderer setup, scene.
```js
const canvas = document.getElementById('scene');
const renderer = new THREE.WebGLRenderer({canvas, antialias:true, powerPreference:'high-performance'});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight, false);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
```
Background gradient sphere:
```js
const bgGeo = new THREE.SphereGeometry(80, 32, 16);
const bgMat = new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite:false,
uniforms:{ top:{value:new THREE.Color('#0a3b5c')}, bottom:{value:new THREE.Color('#02121f')} },
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, smoothstep(0.15,0.95,h)); gl_FragColor = vec4(c,1.0);}`
});
```
Note: with ACES tone mapping and output color space, ShaderMaterial output is treated as linear and converted... In three r152+, ShaderMaterial fragment output goes through tone mapping/color space only if `#include <tonemapping_fragment>` etc. Actually raw ShaderMaterial does NOT include the tonemapping/colorspace chunks — output is written directly to the framebuffer... Hmm. In three.js, ShaderMaterial shaders get `#include <colorspace_fragment>`? No — ShaderMaterial does not automatically include those chunks. The renderer's output color space conversion happens in the shader via the chunk, so raw ShaderMaterial writes raw values which then appear as-is in the sRGB framebuffer... Actually the renderer renders to the default framebuffer; if outputColorSpace is SRGB, three renders linear→sRGB in shader for materials built from ShaderLib. Custom ShaderMaterial without the chunk outputs values directly (treated as sRGB values visually). So my colors defined with `new THREE.Color('#0a3b5c')` — Color with hex converts to linear when `THREE.ColorManagement.enabled` (default true) via setStyle→ SRGBToLinear. So the uniform value would be linear but written without conversion → looks darker than intended. To avoid confusion, for custom shaders I'll do the sRGB→linear→output manually or just tune colors by eye... Better: include the chunks in fragment shaders:
```glsl
gl_FragColor = vec4(c, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
```
These chunks exist in r170 (tonemapping_fragment, colorspace_fragment). Yes, `#include <colorspace_fragment>` is the new name (was encodings_fragment). In r170 it's `colorspace_fragment`. Good. And uniforms colors: `new THREE.Color('#...')` will be in linear working space → correct after the conversion.
Let's verify chunk name in r170: `ShaderChunk.colorspace_fragment` exists (`gl_FragColor = linearToOutputTexel( gl_FragColor );`). Yes. And `tonemapping_fragment`: `gl_FragColor.rgb = toneMapping( gl_FragColor.rgb );` guarded by `#if defined( TONE_MAPPING )`. Good.
For sand shader, also include those.
Now, `fog`: custom shader won't apply fog unless included; I'll compute fog manually in the sand shader using camera distance? Simpler: no scene fog, use the background gradient + distance-based darkening in shaders manually. Or set scene.fog and include `#include <fog_fragment>` — needs `#include <fog_pars_fragment>` and varying `vFogDepth`... it requires `fog_vertex` chunk which needs `mvPosition`. Doable but let's just do manual distance fade in the sand shader: mix toward the fog color based on distance to camera. I'll pass cameraPosition (built-in uniform in ShaderMaterial? `cameraPosition` is a built-in uniform available in ShaderMaterial — yes, three injects `uniform vec3 cameraPosition;` for all shaders). Good.
Lights:
- HemisphereLight(0x9fe8ff, 0x0a2233, 1.0)
- DirectionalLight(0xcdf2ff, 2.0) from (6, 14, 8), no shadows (or shadows? shadows would add realism but cost; let's enable shadows for the directional light onto the sand with fish casting shadows — that's nice! PCFSoft shadows, shadow map 1024, camera bounds tank. Fish + rocks cast shadows. Sand receives. Might be a nice touch. Let's do it — but ensure perf is ok with 18 fish + bubbles. Bubbles don't cast shadows. OK.
Actually shadows on a ShaderMaterial sand: `receiveShadow` requires the shader to include shadowmap chunks — custom ShaderMaterial won't support shadows automatically. Hmm. Use MeshStandardMaterial for sand and put caustics as a separate additive plane slightly above the sand (y=0.02) with the caustic shader — that works nicely and keeps shadows on the sand.
So: sand = MeshStandardMaterial({color:0x8a9a72? }) — sandy: 0x9a8f72 with roughness 1. Dune displacement via geometry position manipulation. Then a caustic overlay plane with additive blending and a transparent shader (alpha = caustic intensity), size same as floor, y=0.03, depthWrite false.
Caustic shader fragment: compute pattern, output `vec4(color, pattern*strength)` with AdditiveBlending. Since additive with alpha... For additive blending, `gl_FragColor.rgb * alpha`? THREE.AdditiveBlending uses blendSrc SrcAlpha, blendDst One — so rgb multiplied by alpha then added. Fine.
Colors: caustic light cyan `vec3(0.55,0.95,1.0)`.
Ok.
Water surface shader at top: MeshPhysicalMaterial? Use a custom shader with animated normal-ish highlights, transparent, DoubleSide, depthWrite false. Fine.
Let me now think about fish geometry building more concretely, and species variation:
Species A "Tang": body ellipse, blue/indigo with yellow tail.
Species B "Clown-ish": orange with white bands — bands need texture; instead use two-tone via vertex colors? Simpler: use a body material with `onBeforeCompile` stripes? Might be over-engineering. Alternative: add thin torus rings around the body for stripes — a TorusGeometry scaled to hug the body, slightly larger radius, colored white. That reads as stripes! Nice and cheap. Let's do it for one species.
Species C: small silver tetras in a school (10 small fish, uniform color, moving loosely together).
Boids-lite schooling for tetras: add a cohesion/alignment force toward nearby tetras — with 10 fish, O(n²)=100 checks, trivial. Let's implement a light flocking for the school group so they move as a group, but flee individually. That looks great.
Actually apply flocking to all fish with a `school` tag. Let's have:
- 6 "reef fish" (larger, colorful, independent wanderers)
- 12 "tetras" (small, schooling)
Total 18 fish. Readout says SPECIMENS 18.
Flocking:
```
for each pair in same flock: if d < 2.5: alignment += other.vel; cohesion += other.pos; separation if d<0.9: push away
```
Scale forces.
Alright, let's write.
```js
function createFish(opts) {
const g = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({color: opts.color, roughness:.42, metalness:.12, emissive: opts.emissive??0x000000, flatShading:false});
const body = new THREE.Mesh(bodyGeo, mat);
...
}
```
Body geometry: `new THREE.SphereGeometry(0.5, 24, 16)` then scale mesh (0.62, 0.82, 1.45)*size. Hmm but scaling a sphere gives an ellipsoid — fine, fish-ish. Maybe better: use a sphere with modified vertices to taper toward the tail. Let's do vertex manipulation on a SphereGeometry: for each vertex, scale x,y by a profile based on z: `t = (z/0.5 + 1)/2` (0 at tail end -z, 1 at head +z)... Actually a fish body: widest around 40% from head, tapering to tail and to mouth. profile = sin(pi * t)^0.7 roughly. Let's implement:
```js
const geo = new THREE.SphereGeometry(0.5, 28, 18);
const pos = geo.attributes.position;
for (i...){
const v = new THREE.Vector3().fromBufferAttribute(pos, i);
const t = (v.z / 0.5 + 1) * 0.5; // 0 tail, 1 head
const profile = Math.pow(Math.sin(Math.PI * Math.min(1, Math.max(0.001,t))), 0.62);
// slight head flatten
v.x *= profile; v.y *= profile;
// taper head toward nose
pos.setXYZ(i, v.x, v.y, v.z);
}
geo.computeVertexNormals();
```
With sphere radius 0.5 and z range ±0.5, then scale the mesh to (0.62, 0.9, 1.6) → length 1.6. Good.
Tail: cone. For the wag pivot, wrap in group.
Let's define helper `finCone(radius, height, color)`.
Also add `eyes`: SphereGeometry(0.075, 10, 8) white-ish, and pupil 0.042 dark, positioned at (±0.3, 0.12, 0.62) * size. Note fish group scaled? Better: build fish at unit scale and then set `group.scale.setScalar(size)`. Then positions in local units.
Movement code operates on world positions (group.position), so the scale is fine.
Now let's handle orientation with `lookAt`. The fish model's forward is +z, and `Object3D.lookAt` points +z at the target.
Now the "lure" glow: `THREE.Mesh(new THREE.SphereGeometry(0.16,16,12), new THREE.MeshBasicMaterial({color:0x8ff0ff, transparent:true, opacity:0.55, blending:AdditiveBlending, depthWrite:false}))` plus a bigger halo sprite? Use two spheres (inner + outer with lower opacity). Plus PointLight(0x66ddff, 12, 9, 2). Nice.
When no pointer yet (mobile / no mouse), place the lure offscreen or at center? Show it at center and gently drift with a circle pattern until the user moves the mouse — that also makes the screenshot interesting (fish scattering around the lure). Actually if the lure is at center and fish flee, the demo shows the interaction on first paint! Great idea: an "auto demo mode" where the lure drifts in a Lissajous path through the tank, scattering fish, until the user moves the mouse. That satisfies "self-playing demo mode" spirit and makes the screenshot show the interaction. Yes!
So: `autoMode = true` initially; lure position follows `lissajous(t)`; on first `pointermove`, switch to mouse control. Keyboard: on keydown, switch to keyboard control with WASD/arrows moving the lure; and idle timeout of 6s without input returns to auto drift. Hmm, careful with `pointermove` events firing immediately from synthetic mouse positions? Fine.
Hint overlay: "Move your cursor through the water" + "Click or press a key for keyboard control". Show a "AUTO TOUR" chip while in auto mode, switching to "CURSOR CONTROL" / "KEYBOARD CONTROL". Nice UI state.
Now, performance: fine.
Time step: use `THREE.Clock` with delta clamped to 0.05.
Now write the CSS. Design language: deep ocean blues, glass panels with backdrop-filter blur (works in Chrome), thin cyan borders, uppercase letter-spaced labels, big display title with gradient text.
Let me write the HTML/CSS now mentally, then assemble.
CSS:
```css
:root{ --ink:#eaf7ff; --muted:#8fb6c9; --cyan:#63e2f2; --glass: rgba(10,38,58,.42); --line: rgba(140,220,245,.18);}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#04121d;color:var(--ink);font-family:...;overflow:hidden}
#scene{position:fixed;inset:0;width:100%;height:100%;display:block;touch-action:none}
.overlay{position:fixed;inset:0;pointer-events:none;display:flex;flex-direction:column;justify-content:space-between;padding:clamp(16px,3vw,38px);}
```
Top row: `display:flex; justify-content:space-between; gap:20px; align-items:flex-start;`.
Title: `font-size:clamp(30px,5.2vw,68px); font-weight:700; letter-spacing:-0.02em;` with a gradient background-clip.
Bottom: hint pill centered.
Readout panel: width ~clamp(210px, 22vw, 280px); grid of rows: label / value.
Proximity meter: a bar `<div class="meter"><i style="width:42%"></i></div>`.
Small screens: `.readouts{display:none}` under 720px? Better: show a condensed version — keep the panel but narrower and hide the meter row? Let's hide the panel at max-width 640px and show a compact chip instead. Fine.
Vignette + glass sheen: `.vignette{position:fixed;inset:0;pointer-events:none;background:radial-gradient(120% 90% at 50% 30%, transparent 40%, rgba(2,12,20,.55) 100%);}` and `.sheen` with a diagonal linear-gradient at low opacity.
Careful with `backdrop-filter` — supported in Chrome; fine.
Now writing the JS fully. Let me draft it in final form.
```js
import * as THREE from 'three';
const canvas = document.getElementById('scene');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
...
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 200);
camera.position.set(0, 5.4, 15.8);
const camTarget = new THREE.Vector3(0, 3.9, 0);
```
Tank constants:
```js
const TANK = { w: 16, h: 8.4, d: 7.6 };
const BOUNDS = { minX: -7.2, maxX: 7.2, minY: 0.9, maxY: 7.7, minZ: -3.2, maxZ: 2.8 };
```
Lights:
```js
scene.add(new THREE.HemisphereLight(0x86d9f2, 0x0b2b3c, 1.25));
const key = new THREE.DirectionalLight(0xd9f6ff, 2.4);
key.position.set(7, 16, 9);
key.castShadow = true;
key.shadow.mapSize.set(1024,1024);
key.shadow.camera.left=-12; ... etc; key.shadow.camera.far = 45; key.shadow.bias=-0.0012;
scene.add(key);
const fill = new THREE.DirectionalLight(0x2c9fd0, 1.1); fill.position.set(-9, 6, -6); scene.add(fill);
const rim = new THREE.PointLight(0x36e0ff, 60, 30, 2); rim.position.set(0, 8, -3); scene.add(rim);
```
PointLight intensity units in r155+ physically correct lighting: intensity for point lights is candela; values like 60 with decay 2 at distance 10 gives 0.6 — reasonable. Keep moderate.
Sand:
```js
const sandGeo = new THREE.PlaneGeometry(26, 15, 96, 64);
sandGeo.rotateX(-Math.PI/2);
const p = sandGeo.attributes.position;
for(...){ const x=p.getX(i), z=p.getZ(i); const y = 0.18*Math.sin(x*0.42)*Math.cos(z*0.55) + 0.09*Math.sin(x*1.1 + z*0.7) + 0.06*Math.cos(z*1.4); p.setY(i, y); }
sandGeo.computeVertexNormals();
const sand = new THREE.Mesh(sandGeo, new THREE.MeshStandardMaterial({color:0x7d8a72...}))
```
Hmm, sand color should read as pale warm sand under blue light: use 0x9b8e70 with roughness 1. Under blue lighting it'll look teal-ish sand. Good. `sand.receiveShadow = true; sand.position.y = 0;`
Caustic overlay:
```js
const causticMat = new THREE.ShaderMaterial({
transparent:true, depthWrite:false, blending:THREE.AdditiveBlending,
uniforms:{ uTime:{value:0}, uColor:{value:new THREE.Color('#9df0ff')}, uStrength:{value:0.55} },
vertexShader: `varying vec3 vW; void main(){ vec4 w = modelMatrix*vec4(position,1.0); vW=w.xyz; gl_Position = projectionMatrix*viewMatrix*w; }`,
fragmentShader: `...`
});
const caustics = new THREE.Mesh(new THREE.PlaneGeometry(26,15,1,1).rotateX(-Math.PI/2), causticMat);
caustics.position.y = 0.06;
```
Wait: PlaneGeometry.rotateX returns the geometry (it returns this) — yes, `BufferGeometry.rotateX` returns this. Good.
Caustic fragment:
```glsl
varying vec3 vW;
uniform float uTime; uniform vec3 uColor; uniform float uStrength;
void main(){
vec2 p = vW.xz * 0.55;
float t = uTime * 0.55;
float a = sin(p.x*1.7 + sin(p.y*1.3 + t)) * sin(p.y*1.9 + sin(p.x*1.1 - t*0.8));
float b = sin(p.x*3.1 - t*0.7 + sin(p.y*2.2 + t*0.6)) * sin(p.y*2.7 + t*0.5);
float c = pow(max(0.0, a*0.65 + b*0.45), 3.0);
float fade = smoothstep(13.0, 4.0, length(vW.xz)) ; // fade near edges
gl_FragColor = vec4(uColor, c * uStrength * fade);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
```
Careful: with additive blending and these chunks — tonemapping/colorspace applied to the color then multiplied by alpha in blending. Slight double conversion but visually fine. Actually for alpha-blended additive, the alpha is used as src factor, and the rgb gets converted to sRGB output space — that makes the added color brighter/odd. It'll look fine as a glow. Alternatively skip the chunks and use `uColor` in sRGB-ish values directly. Let me just skip the chunks in the additive shaders (caustics, shafts, glow) and use plain colors (0x9df0ff converted to linear by ColorManagement... hmm). To keep it predictable, I'll pass colors as `new THREE.Color().setHex(0x9df0ff, THREE.SRGBColorSpace)`... default `setHex` uses SRGBColorSpace by default and converts to linear working space. Then output without conversion appears darker.
Simplest: in additive/glow shaders, don't include chunks and multiply colors explicitly: use `vec3 uColor` from `new THREE.Color('#9df0ff').convertLinearToSRGB()`? That's hacky.
Honestly, just include `#include <colorspace_fragment>` in all custom fragment shaders so the pipeline is consistent. For additive, brightness will be consistent with other materials. I'll do that, including tonemapping. Fine.
Water surface:
```js
const waterMat = new THREE.ShaderMaterial({
transparent:true, depthWrite:false, side:THREE.DoubleSide,
uniforms:{uTime:{value:0}},
vertexShader: `
varying vec2 vUv; varying vec3 vW; uniform float uTime;
void main(){
vUv=uv; vec3 p=position;
p.z += 0.0; // plane rotated so displacement in local z maps to world y
vec4 w = modelMatrix*vec4(p,1.0);
w.y += sin(w.x*0.6 + uTime*0.9)*0.07 + cos(w.z*0.8 - uTime*0.7)*0.06;
vW = w.xyz;
gl_Position = projectionMatrix*viewMatrix*w;
}`,
fragmentShader: `
varying vec2 vUv; varying vec3 vW; uniform float uTime;
void main(){
float w1 = sin(vW.x*1.1 + uTime*1.1) * cos(vW.z*1.3 - uTime*0.8);
float w2 = sin(vW.x*2.7 - uTime*0.6 + vW.z*1.9);
float h = smoothstep(0.2, 1.0, w1*0.5+0.5) * 0.35 + pow(max(0.0,w2),6.0)*0.5;
vec3 col = mix(vec3(0.10,0.45,0.62), vec3(0.65,0.95,1.0), h);
gl_FragColor = vec4(col, 0.16 + h*0.22);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`
});
const water = new THREE.Mesh(new THREE.PlaneGeometry(16,7.6,48,24).rotateX(-Math.PI/2), waterMat);
water.position.y = 8.35; water.renderOrder = 5;
```
Wait, in vertex shader I displace w.y — good since plane is rotated.
Light shafts: 5 planes of size (3.2, 11) positioned near the top, rotated around Y at various angles, additive.
```js
const shaftMat = new THREE.ShaderMaterial({ transparent:true, depthWrite:false, blending:THREE.AdditiveBlending, side:THREE.DoubleSide, uniforms:{uTime:{value:0}, uSeed:{value:0}}, vertexShader:`varying vec2 vUv; void main(){ vUv=uv; gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0);}`, fragmentShader:`...`});
```
Fragment:
```glsl
varying vec2 vUv; uniform float uTime; uniform float uSeed;
void main(){
float x = vUv.x;
float edge = smoothstep(0.0,0.45,x) * smoothstep(1.0,0.55,x);
float vfade = smoothstep(0.0,0.45,vUv.y) * (1.0 - smoothstep(0.72,1.0,vUv.y)); // brighter mid-top
float s = 0.55 + 0.45*sin(x*9.0 + uSeed*3.0 + uTime*0.22);
float a = edge*vfade*s*0.13;
gl_FragColor = vec4(vec3(0.45,0.88,1.0), a);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
```
Hmm, `#include <tonemapping_fragment>` after setting gl_FragColor with alpha — the chunk only modifies `.rgb`, fine.
Plants: create with TubeGeometry:
```js
function makePlant(x, z, height, color, seed) {
const group = new THREE.Group();
const blades = 5 + (seed % 4);
for (let i=0;i<blades;i++){
const h = height * (0.6 + Math.random()*0.7);
const pts = [];
const sx = (Math.random()-0.5)*0.5, sz=(Math.random()-0.5)*0.5;
const bend = (Math.random()-0.5)*1.6;
for (let j=0;j<=6;j++){
const t = j/6;
pts.push(new THREE.Vector3(sx + bend*t*t, h*t, sz + bend*0.4*t*t));
}
const curve = new THREE.CatmullRomCurve3(pts);
const geo = new THREE.TubeGeometry(curve, 14, 0.055 + Math.random()*0.05, 5, false);
const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({color, roughness:.75, metalness:.05, side:THREE.DoubleSide}));
mesh.castShadow = true;
group.add(mesh);
}
group.position.set(x, 0, z);
scene.add(group);
plants.push({group, phase: Math.random()*Math.PI*2});
}
```
Animate: `group.rotation.z = Math.sin(t*0.55 + phase)*0.045; group.rotation.x = Math.cos(t*0.42+phase)*0.035;`
Rocks: `new THREE.DodecahedronGeometry(1, 0)` scaled non-uniformly, random rotation, MeshStandardMaterial({color:0x4b5a63, roughness:.9, flatShading:true}).
Bubbles: InstancedMesh with `new THREE.SphereGeometry(1, 8, 6)` and MeshStandardMaterial({color:0xbfefff, transparent:true, opacity:0.32, roughness:0.05, metalness:0, emissive:0x2f7f96, emissiveIntensity:0.35}). Wait, transparent instanced mesh sorting issues — fine with depthWrite false? If depthWrite false, bubbles will blend over everything even behind fish. Better keep depthWrite true with opacity 0.32 — sorting artifacts minimal. I'll keep depthWrite: true.
Bubble data: {pos, speed, radius, wobble, phase}. Each frame: y += speed*dt; x += sin(t*1.2+phase)*0.004; if y > 8.2 reset to y=0.2 with random x,z. Set matrix via dummy Object3D.
Fish update and threat computation. Let me write the main loop:
```js
function animate(){
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
time += dt;
updateCamera(dt);
updatePointer(dt);
updateFish(dt);
updateBubbles(dt);
updatePlants();
uniforms time updates;
renderer.render(scene, camera);
updateHUD();
}
```
Camera update:
```js
const targetCamPos = new THREE.Vector3();
function updateCamera(dt){
const drift = time*0.075;
targetCamPos.set(
Math.sin(drift)*1.6 + pointerNDC.x*1.5,
5.5 + Math.sin(drift*1.31)*0.45 + pointerNDC.y*0.9,
15.8 + Math.cos(drift*0.8)*0.5
);
camera.position.lerp(targetCamPos, 1 - Math.exp(-dt*1.6));
camera.lookAt(camTarget);
}
```
Hmm, with camera.lookAt each frame, `pointerNDC` also drives the lure — the parallax amplifies the flee effect slightly; that's fine and looks alive. But careful: the auto-demo lure path should be computed in world coords (independent of camera) — yes.
Pointer handling:
```js
const pointerNDC = new THREE.Vector2(0,0); // for camera parallax (0 when auto? use lure ndc)
```
In auto mode, the parallax should use the lure's screen position... simpler: parallax always uses `pointerNDC` which in auto mode is derived from the lure projected to NDC. Eh — just use pointerNDC updated from mouse when in mouse mode, and in auto mode use `Math.sin(time*0.3)` synthetic values. Fine.
Lure position:
- mouse mode: raycast to plane z=0 → but if the mouse is far off the tank, the point could be way outside; clamp to tank bounds.
- Actually, maybe better to place the lure at the ray's closest point to the tank center region... just clamp x,y,z to bounds.
- keyboard mode: move lure with keys in x/y plane at z=0.
- auto mode: `x = Math.sin(t*0.31)*6.2, y = 4.2 + Math.sin(t*0.23+1.2)*2.6, z = Math.cos(t*0.19)*1.6`.
Fish flee uses per-fish threat point via the ray (mouse) or the lure world position (auto/keyboard). To unify: in mouse mode, compute per-fish threat via unprojection at fish depth; in auto/keyboard mode, the threat is the lure world position. Simpler & consistent: always use `lure.position` as the threat point, and in mouse mode set lure.position = raycast point on a plane... but then fish at z=-3 would see the threat at z=0, distance inflated — acceptable, radius 3.2 world units. Actually visual proximity matters: a fish at z=-3 with the lure at z=0 has distance ≥3 even when directly under the cursor on screen. That would break the "swim away when near" feel for back fish.
Better: per-fish unprojection approach for the mouse mode; and for auto/keyboard, use the lure position but also do the per-fish depth trick: project the lure to NDC then unproject at the fish's depth. That's uniform! Implement:
```js
function threatPointFor(fishPos, out){
// screen position of the lure
_v.copy(lure.position).project(camera); // ndc
_ndc.set(_v.x, _v.y);
_v2.set(_ndc.x, _ndc.y, 0.5).unproject(camera);
_dir.copy(_v2).sub(camera.position).normalize();
const depth = _fish.clone().sub(camera.position).dot(_camDir);
out.copy(camera.position).addScaledVector(_dir, depth);
}
```
Wait, `depth` should be the distance along the camera forward direction: `fishPos - camPos` dot camForward. Then the point along the ray at that distance is `camPos + rayDir * (depth / dot(rayDir, camForward))` for correctness (rayDir is not exactly camForward). Use `const k = depth / _dir.dot(camForward)`. Fine.
Then `dist = fishPos.distanceTo(threat)`. Good — this is essentially screen-space proximity converted to world at the fish's depth.
But the lure mesh itself is placed on z=0 plane; fish flee from the projected point. Fine.
Flee force:
```js
const fleeRadius = 3.4;
if (dist < fleeRadius) {
const urgency = 1 - dist / fleeRadius;
_flee.copy(fish.position).sub(threat);
_flee.y *= 0.55; // less vertical escape
if (_flee.lengthSq() < 1e-5) _flee.set(Math.random()-0.5, Math.random()-0.5, Math.random()-0.5);
_flee.normalize();
fish.vel.addScaledVector(_flee, urgency * urgency * 46 * dt);
fish.boost = Math.max(fish.boost, urgency);
}
```
Boost decays: `fish.boost = THREE.MathUtils.lerp(fish.boost, 0, 1-Math.exp(-dt*1.2))` — but set after adding; use `fish.boost = Math.max(fish.boost, urgency)` before decay and decay at the end. Fine.
Speed clamp: `const maxSpeed = fish.cruise * (1 + fish.boost*2.8); if (vel.length() > maxSpeed) vel.setLength(maxSpeed)`. Also apply mild drag toward cruise speed: `vel.multiplyScalar(1 - dt*0.35)`? Actually steering handles it.
Wander:
```js
fish.wanderTimer -= dt;
if (fish.wanderTimer <= 0) {
fish.wanderTimer = 1.6 + Math.random()*2.6;
fish.target.set(rand(BOUNDS.minX, maxX), rand(1.2, maxY), rand(minZ, maxZ));
}
_desired.copy(fish.target).sub(fish.position).normalize().multiplyScalar(fish.cruise);
_steer.copy(_desired).sub(fish.vel);
fish.vel.addScaledVector(_steer, dt * fish.agility);
```
Bounds repulsion:
```js
const m = 1.4;
if (fish.position.x > BOUNDS.maxX - m) fish.vel.x -= dt * 12 * (fish.position.x - (BOUNDS.maxX-m));
... etc for each axis
```
Also floor: y > 0.9.
Flocking for tetras:
```js
for (i) for (j>i) if same flock && dist<3.2: alignment, cohesion, separation
```
Implement as accumulating into a temp per-fish vector. Simpler O(n²) with 12 tetras.
Let me write flock update:
```js
function updateFlock(fishes, dt){
for (const a of fishes){
_acc.set(0,0,0);
let n=0;
for (const b of fishes){
if (a===b) continue;
const d = a.position.distanceTo(b.position);
if (d < 3.0 && d > 0.0001){
_acc.addScaledVector(b.vel, 0.32/d); // alignment-ish
_acc.addScaledVector(_tmp.copy(b.position).sub(a.position), 0.12/d); // cohesion
n++;
if (d < 1.25) _acc.addScaledVector(_tmp.copy(a.position).sub(b.position), (1.25-d)*1.4/d);
}
}
if (n) a.vel.addScaledVector(_acc, dt * 0.55);
}
}
```
Fine.
Orientation:
```js
if (fish.vel.lengthSq() > 0.0004){
_look.copy(fish.position).addScaledVector(fish.vel, 1.0);
_m.lookAt(_look, fish.position, UP); // Matrix4.lookAt(eye, target, up) builds a matrix looking from eye to target; then quaternion -> object's +z points from target to eye??
}
```
Careful: `Matrix4.lookAt(eye, target, up)` constructs a rotation matrix where the object's +z axis points from target toward eye (i.e., the camera convention: object looks down -z). `Object3D.lookAt` uses this and for non-camera objects it... Let me recall three's `Object3D.lookAt`:
```js
lookAt(x,y,z){
...
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 regular objects, it does `lookAt(target, position, up)` — swapping so that +z points toward the target. So I should use `_m.lookAt(_target, _position, UP)` where target = pos + vel. Or just call `group.lookAt(pos + vel)` which handles it. But then smoothing: `group.lookAt()` sets quaternion directly. To smooth, compute the desired quaternion with the matrix then slerp. Let me implement:
```js
_look.copy(fish.position).addScaledVector(fish.vel, 0.5);
_m.lookAt(_look, fish.position, UP);
_q.setFromRotationMatrix(_m);
fish.group.quaternion.slerp(_q, 1 - Math.exp(-dt * 5.5));
```
Using `_m.lookAt(target, position, up)` per the non-camera convention. Good.
Tail wag: `fish.tail.rotation.y = Math.sin(time * (6 + fish.boost*10) + fish.phase) * (0.32 + fish.boost*0.45);`
Body sway: `fish.bodyGroup.rotation.y = Math.sin(time*3 + phase)*0.05*boost`? Skip.
Now HUD update: proximity = max boost across fish → set meter width and status text. Throttle DOM writes to ~10/s or only when changed by >2%.
Interaction events:
```js
window.addEventListener('pointermove', e => {
const r = canvas.getBoundingClientRect();
pointerNDC.x = ((e.clientX - r.left)/r.width)*2 - 1;
pointerNDC.y = -((e.clientY - r.top)/r.height)*2 + 1;
setMode('cursor');
lastInput = time;
});
canvas.addEventListener('pointerdown', e => { setMode('cursor'); pulse(); });
window.addEventListener('keydown', e => { ... setMode('key'); keys.add(e.code); });
keyup -> delete
```
Careful with `pointermove` on `window` — the overlay has pointer-events none, so it's fine.
Note: pointer coordinates relative to the canvas which fills the viewport — fine.
Keyboard movement: `if (mode==='key') { lure.position.x += (right-left)*speed*dt; ... }` and the lure's NDC then drives the flee via threatPointFor — which projects lure.position to NDC.
Also clamp the lure to bounds.
Auto mode when idle > 8s: `if (time - lastInput > 8) mode = 'auto'` — and then the lure follows the Lissajous path. Smoothly blend? Just set lure position each frame with lerp toward the path target to avoid jumps. Use `lure.position.lerp(targetPos, 1-exp(-dt*1.5))` in auto mode. In cursor mode, lerp faster (1-exp(-dt*12)).
Hmm, but for the flee effect to feel snappy on mouse move, the lure should track quickly. 12/s is decent (0.08s lag). Fine.
Pulse on click: `lurePulse = 1` and scale the glow; plus instantly add an outward impulse to nearby fish (like a splash). Implement: on pointerdown, for each fish within 5 units of the lure, add impulse away. Nice.
Now let's be careful about `THREE.ColorManagement` and `new THREE.Color('#hex')` in uniforms — consistent with chunks.
One more consideration: `#include <tonemapping_fragment>` requires the `TONE_MAPPING` define which is added when material.toneMapped is true and the renderer has tone mapping... For ShaderMaterial, `toneMapped` defaults true, and the renderer adds `#define TONE_MAPPING` plus `toneMapping` function definition when the material is toneMapped... Actually the `WebGLProgram` includes `<tonemapping_pars_fragment>` in the prefix only for non-raw shader materials — yes, `getToneMappingFunction` is inserted into the fragment prefix for all materials when `parameters.toneMapping !== NoToneMapping`. And `parameters.toneMapping = material.toneMapped ? renderer.toneMapping : NoToneMapping`. ShaderMaterial included. Good, so `#include <tonemapping_fragment>` works. Similarly `colorspace_fragment` is always includable (chunk exists and `linearToOutputTexel` is defined in the prefix via `getTexelEncodingFunction`... In r152+, the output color space conversion function `linearToOutputTexel` is generated in the fragment prefix. Yes.
But: I must ensure the chunks are on their own line after `gl_FragColor = ...`. Yes.
Alright. Also potential issue: in r170, `gl_FragColor` is still supported in GLSL1 shaders (default glslVersion for ShaderMaterial is GLSL1). Yes.
Now writing everything out. I'll be careful about syntax.
Let me write the final code.
```js
// ---------- setup ----------
const canvas = document.getElementById('scene');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(window.innerWidth, window.innerHeight, false);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.08;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
```
Resize function with `window.addEventListener('resize', onResize)`.
For mobile: camera fov increase when aspect < 1: `camera.fov = aspect < 1 ? 68 : 50; camera.updateProjectionMatrix();` and maybe move the camera back. Let's handle: `camera.position.z` base = aspect < 1 ? 20 : 15.8. Store `camBaseZ`. Since the camera position is animated via targetCamPos using base values, I'll include `baseZ` variable.
Now, the tank: 16 wide at z=0 with the camera at z=15.8 and fov 50 → half-height at distance 15.8 = 15.8*tan(25°)=7.37 → full 14.7 vertical; tank 8.4 tall fits with room. Half-width = 7.37*1.6=11.8 → 23.6 wide; tank 16 fits with margins. Good. For 375px width portrait: aspect 0.47, fov 68 → half-height = 20*tan(34°)=13.5 → 27 vertical; half-width = 13.5*0.47 = 6.35 → 12.7 wide < 16 — tank would be cropped. Need camera farther: half-width needed 8.5+margin=10 → half-height = 10/0.47 = 21 → distance = 21/tan(34°) = 31. So baseZ = 31 for portrait. Let's compute dynamically in onResize:
```js
function fitCamera(){
const aspect = w/h;
camera.aspect = aspect;
const vFov = THREE.MathUtils.degToRad(camera.fov);
const needW = 19, needH = 11.5;
const distH = (needH/2) / Math.tan(vFov/2);
const distW = (needW/2) / (Math.tan(vFov/2)*aspect);
camBaseZ = Math.max(distH, distW) * 0.92;
camera.updateProjectionMatrix();
}
```
With fov 50 and aspect 1.6: distH = 5.75/0.466 = 12.3; distW = 9.5/(0.466*1.6)=12.7 → baseZ ≈ 11.7. Hmm that's closer than my 15.8, making the tank fill the frame more. That's fine and looks good — but the camera y should be ~5.4 and looking at 3.9. With z≈12, the tank fills the frame nicely. Let's use `* 0.92` and clamp min 12. For portrait 375x800: aspect 0.469, fov maybe 62: tan(31°)=0.6 → distH = 5.75/0.6=9.6; distW = 9.5/(0.6*0.469)=33.7 → baseZ=31. OK.
Actually, for a nicer portrait view, maybe reduce needW on portrait (crop the sides a bit is acceptable). Let's set needW = aspect < 1 ? 15.5 : 19. Then distW = 7.75/(0.6*0.469)=27.5 → baseZ 25.3. The tank 16 wide vs visible 15.5 — slight crop of the extreme edges, acceptable and looks better. Fine.
Camera y should also adapt? Keep 5.4.
Now, the fish bounds must stay within the visible tank; with a distant camera, fine.
Let me now write out the whole file. I'll aim for clean, complete code.
One more thing: `SphereGeometry` vertex profile modification — must handle the poles: at t=0 and t=1, sin=0 → profile 0 → all pole vertices collapse to the axis. That's fine (a pointed tail & nose).
Actually for the nose, t=1 gives profile 0 → pointed snout. Nice fish shape.
Let me define body geometry once and reuse (all fish of the same size class share geometry, scaled per fish). Different species → different geometry variants (short-tall vs long).
I'll create `makeBodyGeo()` fresh per species (3 variants).
Now the tail cone: `new THREE.ConeGeometry(0.42, 0.95, 4, 1)` with 4 radial segments → a diamond-ish flattened fin. Rotate: `geo.rotateX(-Math.PI/2)` makes the cone point along -z? Let's check: cone axis along +y (apex at +y). rotateX(-90°): rotation about X by -90°, y → -z. So apex goes to -z. Yes, points backward. Then `mesh.scale.set(1, 1, 0.12)` — but the geometry after rotation has its "height" along z and radius in x/y. Scaling z by 0.12 squashes the length rather than flattening! Careful: scale axes are applied in the mesh's local frame to the geometry's current coordinates. After rotateX, the cone's apex direction is along z, its radial extent is x and y. To flatten laterally, scale x by 0.12: `mesh.scale.set(0.12, 1, 1)`. Yes — flatten x (lateral), keep y (vertical) and z (length). Good.
Position the tail group at z=-0.62 with the cone at z=-0.42 within the group.
Dorsal fin: `ConeGeometry(0.34, 0.62, 4)` pointing +y (no rotation), scaled x 0.1, positioned at (0, 0.42, -0.12) and rotated slightly.
Pectoral fins: cone rotated to point backward-outward: `geo.rotateZ(Math.PI/2)`? Let's do: `new THREE.ConeGeometry(0.22,0.5,4).rotateX(Math.PI/2)` → points +z... Let's just make small flattened cones at (±0.32, -0.05, 0.15) with `mesh.rotation.set(-0.6, ±0.7, ±0.9)`. It'll look like fins; precision isn't critical.
Eyes: `SphereGeometry(0.1, 10, 8)` at (±0.26, 0.12, 0.52) with white material and a pupil `SphereGeometry(0.055)` slightly outboard at (±0.335, 0.125, 0.545).
Since the body profile narrows at z=0.52 (t = 0.52/0.5... wait body geometry is a unit sphere radius 0.5, so z ∈ [-0.5, 0.5]). But I place the eye at z=0.52 which is outside the body! Let me fix the scale: build the body sphere radius 0.5 then scale mesh (0.62, 0.92, 1.55) → z range ±0.775. But eye/tail positions are in local group coords (unscaled), so I should apply the same scale to the sub-part positions. Cleaner: bake the scale into the body geometry via `geo.scale(0.62, 0.92, 1.55)` and keep the mesh scale = 1. Then the body spans z ∈ [-0.775, 0.775], x ∈ [-0.31,0.31], y ∈ [-0.46,0.46]. Then eye at z=0.55, x=±0.22, y=0.13. Hmm, at z=0.55 (t=0.85), profile = sin(π*0.85)^0.62 = sin(153°)=0.454^0.62 ≈ 0.61 → x extent 0.19. So the eye at x=0.22 is slightly outside — good (eyes bulge). OK.
Tail at z=-0.62 group, cone spans z ∈ [-0.62-0.95, -0.62] roughly (cone height 0.95 centered → the cone center at z=-0.42 within the group spans -0.895..0.055 in group local). Let me just set: tailGroup.position.z = -0.55; cone.position.z = -0.38; cone height 0.9 → spans -0.83 to 0.07 in group → world z from -1.38 to -0.48. Body ends at -0.775. Good overlap.
Fine. Species variations: scale the whole group (size), and adjust color.
Stripes for species B: torus rings around the body: `new THREE.TorusGeometry(0.34, 0.045, 8, 20).rotateY(Math.PI/2)` — torus lies in the XY plane by default (axis along z). We want rings around the body with the axis along z (fish length) — that's already the case! Torus in XY plane, ring around the z-axis. So no rotation needed. Scale it to match the body cross-section at that z. Position at z = 0.15 and z = -0.2 with scale (1, 1.5, 1) to match the body's y/x ratio. Use a white material with roughness 0.4. Two rings.
Now let's write out the final code text. I need to be careful and complete.
Also HUD elements ids: `#stat-fish`, `#stat-mode`, `#meter-fill`, `#status-text`, `#hint`.
Let me write the HTML body:
```html
<div class="stage">
<canvas id="scene"></canvas>
<div class="sheen"></div>
<div class="vignette"></div>
</div>
<div class="ui">
<div class="top">
<div class="brand">
<div class="kicker"><span class="dot"></span> Live exhibit · Tank 03</div>
<h1>Glass <em>Tide</em></h1>
<p class="sub">A procedural reef in real time. Bring your cursor close to the water — every fish feels it coming and darts away.</p>
</div>
<aside class="panel">
<div class="panel-head">Tank telemetry <span class="live">LIVE</span></div>
<div class="row"><span>Specimens</span><b id="stat-fish">18</b></div>
<div class="row"><span>Water</span><b>24.6 °C</b></div>
<div class="row"><span>Current</span><b>0.31 m/s</b></div>
<div class="row"><span>Control</span><b id="stat-mode">Auto tour</b></div>
<div class="meter-label"><span>Proximity response</span><b id="status-text">Calm</b></div>
<div class="meter"><i id="meter-fill"></i></div>
</aside>
</div>
<div class="bottom">
<div class="hint" id="hint">
<span class="mouse"><i></i></span>
<div>
<b>Move your cursor through the water</b>
<span>Click anywhere or press a key for keyboard control (WASD / arrows).</span>
</div>
</div>
<div class="credit">Three.js · procedural geometry · no textures</div>
</div>
</div>
```
CSS `.ui{position:fixed;inset:0;display:flex;flex-direction:column;justify-content:space-between;pointer-events:none;padding:clamp(18px,3vw,40px);gap:20px}` and `.top{display:flex;justify-content:space-between;align-items:flex-start;gap:24px}`.
Mobile: `.panel{display:none}` under 720px? Let's instead make the panel `width:min(300px, 42vw)` and at <=680px hide `.panel .row:nth...`. Simpler: `@media (max-width: 720px){ .panel{display:none} .sub{display:none} }` and keep the hint. Hmm, but the readouts are nice. Keep the panel but shrink it and hide the credit. I'll do: at <=720px, `.panel{width:170px; padding:12px}` and hide rows 2 and 3. That keeps something. Actually simpler and safer: hide the panel at <=620px.
Font: use system fonts (`-apple-system, "Segoe UI", Inter, Helvetica Neue, Arial`) — no external fonts allowed.
Let me finalize numbers for HUD: fish count from `fish.length`.
Now writing it all out for real. I'll produce the final answer code.
Potential console errors to avoid:
- `THREE.WebGLRenderer` failing if WebGL unavailable — wrap in try/catch and show a fallback message? If it throws, the console has an error. But the sandbox has WebGL presumably. I'll wrap the whole init in try/catch and set a fallback background — but an uncaught error inside a module would still log. With try/catch, no console error. Good.
Let me write the code now in full detail, then review.
```js
import * as THREE from 'three';
const canvas = document.getElementById('scene');
const $ = (id) => document.getElementById(id);
let renderer, scene, camera;
const clock = new THREE.Clock();
let time = 0;
```
I'll write everything inside one big function `init()` and call it in try/catch.
Now, the full fish class:
```js
const fishList = [];
function makeFish(opts) {
const group = new THREE.Group();
const bodyGroup = new THREE.Group();
group.add(bodyGroup);
const bodyMat = new THREE.MeshStandardMaterial({
color: opts.color, roughness: 0.38, metalness: 0.16,
emissive: opts.emissive || 0x06121c, emissiveIntensity: 0.6
});
const finMat = new THREE.MeshStandardMaterial({ color: opts.fin, roughness: 0.5, metalness: 0.08, side: THREE.DoubleSide });
const body = new THREE.Mesh(opts.geo, bodyMat);
body.castShadow = true;
bodyGroup.add(body);
// tail
const tailPivot = new THREE.Group();
tailPivot.position.set(0, 0, -0.52);
const tail = new THREE.Mesh(TAIL_GEO, finMat);
tail.position.set(0, 0, -0.38);
tail.castShadow = true;
tailPivot.add(tail);
bodyGroup.add(tailPivot);
...
}
```
Shared geometries: `BODY_GEO`, `TAIL_GEO`, `DORSAL_GEO`, `PECT_GEO`, `EYE_GEO`, `PUPIL_GEO`, `STRIPE_GEO`. Create once.
Since I use `opts.geo` for species variants, define `makeBodyGeo(lengthScale, widthScale, heightScale)`.
OK, writing the final answer now. I'll be meticulous about matching braces and quotes.
For the HUD meter: `meterFill.style.width = (proximity*100).toFixed(0)+'%'` and status text: proximity > 0.6 ? 'Startled' : > 0.25 ? 'Alert' : 'Calm'. Only update when the text changes.
Let me write the final code.
Regarding `camera.getWorldDirection(_camForward)` — allocates? `getWorldDirection(target)` requires a target vector; pass a preallocated one. Must call `camera.updateMatrixWorld()` first — `lookAt` updates rotation but `matrixWorld` is updated in render. Since I call the fish update before render after moving the camera, call `camera.updateMatrixWorld(true)` after `camera.lookAt(...)`. Good.
Writing now.
Also: `renderer.setSize(w,h,false)` with `false` means don't set canvas style — but the canvas is styled with CSS 100%/100%, so that's fine (drawing buffer set correctly).
Let me define `onResize` to also set `renderer.setSize`.
Final check on the instanced bubbles: `bubbleMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage)`.
Now produce the answer.
One more detail — the `.hint` should fade after the user interacts (opacity .45) but remain visible-ish. And on small screens the hint text wraps.
Code for keys:
```js
const keys = new Set();
window.addEventListener('keydown', (e) => {
const k = e.key.toLowerCase();
if (['w','a','s','d','arrowup','arrowdown','arrowleft','arrowright'].includes(k)) e.preventDefault();
keys.add(k);
setMode('key'); lastInput = time;
});
window.addEventListener('keyup', (e)=> keys.delete(e.key.toLowerCase()));
```
Careful: `e.preventDefault()` on arrow keys prevents page scroll — the page can't scroll anyway (overflow hidden). Fine.
In the update:
```js
let dx=0, dy=0;
if (keys.has('w')||keys.has('arrowup')) dy += 1;
...
lureTarget.x += dx * 7 * dt; lureTarget.y += dy * 5 * dt; clamp.
```
Where in key mode `lureTarget` is persistent state. Let me keep `lureTarget` as a Vector3 used by all modes:
- auto: computed from time
- cursor: from raycast
- key: persists, updated by keys
Then `lure.position.lerp(lureTarget, ...)`.
Good. Let me write it.
For the raycast in cursor mode:
```js
raycaster.setFromCamera(pointerNDC, camera);
if (raycaster.ray.intersectPlane(planeZ, lureTarget)) {
lureTarget.x = clamp(...); lureTarget.y = clamp(...); lureTarget.z = 0;
}
```
Note `intersectPlane` returns null if parallel — fine.
`planeZ = new THREE.Plane(new THREE.Vector3(0,0,1), 0)` → plane z=0. Yes.
Now write the answer. I'll produce approximately 700 lines. Let's go.
Also add `document.addEventListener('visibilitychange')` — not needed.
Let me make sure `updateFish` uses preallocated temp vectors declared at module scope.
Final code writing (careful, complete):
I also want a "floor ripple" effect where the lure is: a ring mesh on the sand? Skip — the lure glow + point light suffices.
Point light at lure: `new THREE.PointLight(0x77e8ff, 22, 10, 2)` — intensity 22 with decay 2 at distance 3 → 22/9 ≈ 2.4. Reasonable.
OK writing.
For `updateHUD`, use a throttle counter.
I'll now write out the entire file text.
Regarding the shadow camera for the directional light covering the tank: left/right ±12, top/bottom ±12, near 1 far 50, with the light at (7,16,9) targeting (0,2,0) — need `key.target.position.set(0,2,0); scene.add(key.target)`.
Ok. Writing the answer now — final.
Let me double-check the `#include` chunk names in r170 one more time: `tonemapping_fragment`, `colorspace_fragment`. In r152 the encoding chunk was renamed `encodings_fragment` → `colorspace_fragment`. r170 definitely has `colorspace_fragment`. Good.
Now, one concern: `#include <tonemapping_fragment>` in a shader where `gl_FragColor` has alpha — the chunk does `gl_FragColor.rgb = toneMapping( gl_FragColor.rgb );`. When TONE_MAPPING isn't defined, the chunk body is `#if defined( TONE_MAPPING ) ... #endif`? Let me recall the chunk source:
```js
export default /* glsl */`
#if defined( TONE_MAPPING )
gl_FragColor.rgb = toneMapping( gl_FragColor.rgb );
#endif
`
```
Yes. Good.
And `colorspace_fragment`:
```js
gl_FragColor = linearToOutputTexel( gl_FragColor );
```
Good.
Write it.