Interactive 3D aquariumOne-line
Qwen3.8 Flash
by QwenReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 9 min 8 s
- Cost
- $0.017
- Output tokens
- 36,635
- Spent thinking
- 28,498 tokens
- Speed
- 66.9 tok/s
- Console errors
- 0
- First token after
- 578 ms
- First visible text after
- 7 min 52 s
- Prompt tokens
- 483
- Code size
- 21.3 KB
- Finish reason
- stop
- Served by
- Alibaba
- Model id
qwen/qwen3.8-flash- Reasoning sent
model default- Max output
- 64,000 tokens
- Generated
- Nov 11, 2026
What it was asked
The prompt
“a 3D aquarium with Three.js where the fish swim away when I move the mouse near them (Load Three.js only through this import map: <script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}</script>)”
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task. Output rules: - Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block. - Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task. - Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw. - The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar. - Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction. - Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session. - Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing. - The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message. - The code must run with zero errors in the console of the latest Chrome.
Code run-1.html · 706 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>3D Aquarium — Fish Flee the Mouse</title>
<style>
html, body { height: 100%; }
body {
margin: 0;
overflow: hidden;
background: #041018;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
color: #dff5ff;
}
canvas { display: block; }
#ui {
position: fixed;
inset: 0;
pointer-events: none;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: clamp(12px, 2vw, 22px);
}
.hud-top {
display: flex;
justify-content: space-between;
gap: 14px;
align-items: flex-start;
}
.title {
font-weight: 700;
font-size: clamp(15px, 2.2vw, 24px);
letter-spacing: .08em;
text-shadow: 0 0 18px rgba(0, 220, 255, .45);
opacity: .92;
}
.info {
font-size: clamp(11px, 1.3vw, 13px);
opacity: .62;
text-align: right;
max-width: 48ch;
line-height: 1.35;
}
.pill {
position: absolute;
left: 50%;
bottom: clamp(14px, 4vh, 34px);
transform: translateX(-50%);
padding: 10px 18px;
border: 1px solid rgba(120, 230, 255, .35);
background: rgba(4, 20, 30, .5);
border-radius: 999px;
font-size: clamp(12px, 1.6vw, 15px);
box-shadow: 0 0 30px rgba(0, 180, 255, .12);
backdrop-filter: blur(6px);
transition: opacity .8s ease, transform .8s ease;
}
.pill.hidden {
opacity: 0;
transform: translate(-50%, 10px);
}
@media (max-width: 640px) {
.hud-top { flex-direction: column; }
.info { text-align: left; font-size: 11px; max-width: none; }
.pill { bottom: 12px; }
}
</style>
<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}
</script>
</head>
<body>
<div id="ui">
<div class="hud-top">
<div class="title">Deep Reef Aquarium</div>
<div class="info">Fish swim away from your pointer in true 3D space.<br>Self-playing demo — move your mouse or click.</div>
</div>
<div id="start" class="pill">Click or press a key to start</div>
</div>
<script type="module">
import * as THREE from 'three';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x061723);
scene.fog = new THREE.FogExp2(0x071b28, 0.024);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.18;
document.body.appendChild(renderer.domElement);
const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 140);
const baseCam = new THREE.Vector3(0, 1.35, 20.5);
camera.position.copy(baseCam);
camera.lookAt(0, 0.15, 0);
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// Lights
scene.add(new THREE.AmbientLight(0x366a91, 0.68));
const hemi = new THREE.HemisphereLight(0x74e4ff, 0x071924, 0.78);
scene.add(hemi);
const keyLight = new THREE.DirectionalLight(0xffffff, 1.35);
keyLight.position.set(7, 12, 10);
scene.add(keyLight);
const backLight = new THREE.PointLight(0x1b7db3, 26, 42, 1.7);
backLight.position.set(0, 2, -8);
scene.add(backLight);
const caustic1 = new THREE.PointLight(0x76efff, 34, 34, 1.75);
const caustic2 = new THREE.PointLight(0xff5ec6, 20, 30, 1.8);
scene.add(caustic1, caustic2);
// Tank dimensions
const outerW = 17.5;
const outerH = 10.5;
const outerD = 10.5;
const tankW = 16.5;
const tankH = 9.8;
const tankD = 9.8;
const waterBounds = new THREE.Box3(
new THREE.Vector3(-tankW / 2 + 1.0, -tankH / 2 + 1.15, -tankD / 2 + 1.0),
new THREE.Vector3( tankW / 2 - 1.0, tankH / 2 - 1.45, tankD / 2 - 1.0)
);
const haloBox = waterBounds.clone().expandByScalar(1.5);
// Glass
const glassMat = new THREE.MeshPhysicalMaterial({
color: 0xcfeeff,
metalness: 0,
roughness: 0.06,
transparent: true,
opacity: 0.17,
clearcoat: 0.55,
clearcoatRoughness: 0.1,
side: THREE.DoubleSide,
depthWrite: false
});
const glass = new THREE.Mesh(new THREE.BoxGeometry(outerW, outerH, outerD), glassMat);
glass.renderOrder = 12;
scene.add(glass);
const edgeLines = new THREE.LineSegments(
new THREE.EdgesGeometry(glass.geometry),
new THREE.LineBasicMaterial({ color: 0x78f0ff, transparent: true, opacity: 0.55 })
);
edgeLines.renderOrder = 13;
scene.add(edgeLines);
// Frame edges
function addEdge(a, b, radius, material) {
const dir = new THREE.Vector3().subVectors(b, a);
const length = dir.length();
if (length < 0.001) return;
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, length, 8, 1), material);
mesh.position.copy(a).add(b).multiplyScalar(0.5);
mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.normalize());
scene.add(mesh);
}
function addBoxEdges(cx, cy, cz, w, h, d, radius, material) {
const xs = [cx - w / 2, cx + w / 2];
const ys = [cy - h / 2, cy + h / 2];
const zs = [cz - d / 2, cz + d / 2];
for (const y of ys) for (const z of zs)
addEdge(new THREE.Vector3(xs[0], y, z), new THREE.Vector3(xs[1], y, z), radius, material);
for (const x of xs) for (const z of zs)
addEdge(new THREE.Vector3(x, ys[0], z), new THREE.Vector3(x, ys[1], z), radius, material);
for (const x of xs) for (const y of ys)
addEdge(new THREE.Vector3(x, y, zs[0]), new THREE.Vector3(x, y, zs[1]), radius, material);
}
const frameMat = new THREE.MeshStandardMaterial({
color: 0x0d2230,
metalness: 0.58,
roughness: 0.34
});
addBoxEdges(0, 0, 0, outerW, outerH, outerD, 0.11, frameMat);
// Water volume tint
const waterBox = new THREE.Mesh(
new THREE.BoxGeometry(tankW - 0.25, tankH - 0.35, tankD - 0.25),
new THREE.MeshStandardMaterial({
color: 0x167f9c,
transparent: true,
opacity: 0.09,
roughness: 0.1,
metalness: 0,
side: THREE.BackSide,
depthWrite: false
})
);
waterBox.renderOrder = 1;
scene.add(waterBox);
// Sand
const sandGeo = new THREE.PlaneGeometry(tankW - 1.0, tankD - 1.0, 28, 20);
const sandPos = sandGeo.attributes.position;
for (let i = 0; i < sandPos.count; i++) {
const x = sandPos.getX(i);
const y = sandPos.getY(i);
sandPos.setZ(i, Math.sin(x * 0.7) * 0.08 + Math.cos(y * 0.55) * 0.07 + Math.random() * 0.03);
}
sandGeo.computeVertexNormals();
const sand = new THREE.Mesh(sandGeo, new THREE.MeshStandardMaterial({
color: 0x8e7148,
roughness: 0.92
}));
sand.rotation.x = -Math.PI / 2;
sand.position.y = -tankH / 2 + 0.06;
scene.add(sand);
// Rocks
const rockMat = new THREE.MeshStandardMaterial({ color: 0x556470, roughness: 0.85, metalness: 0.06 });
for (let i = 0; i < 16; i++) {
const rock = new THREE.Mesh(new THREE.DodecahedronGeometry(0.35 + Math.random() * 0.45, 0), rockMat);
rock.position.set(
THREE.MathUtils.randFloat(-tankW / 2 + 1.2, tankW / 2 - 1.2),
-tankH / 2 + 0.1 + Math.random() * 0.18,
THREE.MathUtils.randFloat(-tankD / 2 + 1.0, tankD / 2 - 1.0)
);
rock.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
rock.scale.set(1 + Math.random(), 0.6 + Math.random() * 0.8, 1 + Math.random());
scene.add(rock);
}
// Kelp/plants
const plants = [];
for (let i = 0; i < 14; i++) {
const h = 1.8 + Math.random() * 3.1;
const color = new THREE.Color().setHSL(0.33 + Math.random() * 0.08, 0.62, 0.24 + Math.random() * 0.16);
const plant = new THREE.Mesh(
new THREE.ConeGeometry(0.18, h, 6, 4),
new THREE.MeshStandardMaterial({ color, roughness: 0.75, side: THREE.DoubleSide })
);
plant.position.set(
THREE.MathUtils.randFloat(-tankW / 2 + 1.0, tankW / 2 - 1.0),
-tankH / 2 + 0.1 + h / 2,
THREE.MathUtils.randFloat(-tankD / 2 + 0.8, tankD / 2 - 0.8)
);
plant.scale.x = 0.25 + Math.random() * 0.22;
plant.rotation.y = Math.random() * Math.PI * 2;
plant.userData.phase = Math.random() * 99;
scene.add(plant);
plants.push(plant);
}
// Water surface
const surfaceGeo = new THREE.PlaneGeometry(tankW - 1.2, tankD - 1.2, 34, 24);
const surfaceBase = new Float32Array(surfaceGeo.attributes.position.array);
const surfaceMat = new THREE.MeshStandardMaterial({
color: 0x8cf6ff,
transparent: true,
opacity: 0.32,
roughness: 0.08,
metalness: 0.1,
side: THREE.DoubleSide,
depthWrite: false
});
const surface = new THREE.Mesh(surfaceGeo, surfaceMat);
surface.rotation.x = -Math.PI / 2;
surface.position.y = tankH / 2 - 0.35;
surface.renderOrder = 2;
scene.add(surface);
// Stand/floor
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(90, 90),
new THREE.MeshStandardMaterial({ color: 0x071520, roughness: 0.96 })
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -7.25;
scene.add(floor);
const stand = new THREE.Mesh(
new THREE.BoxGeometry(outerW + 2.4, 1.4, outerD + 2.4),
new THREE.MeshStandardMaterial({ color: 0x0c1a24, roughness: 0.65, metalness: 0.25 })
);
stand.position.y = -outerH / 2 - 0.7;
scene.add(stand);
// Bubbles
function makeCircleTexture() {
const c = document.createElement('canvas');
c.width = c.height = 64;
const ctx = c.getContext('2d');
const g = ctx.createRadialGradient(32, 32, 2, 32, 32, 30);
g.addColorStop(0, 'rgba(255,255,255,0.95)');
g.addColorStop(0.32, 'rgba(190,245,255,0.55)');
g.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, 64, 64);
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
const bubbleTex = makeCircleTexture();
const bubbleCount = 340;
const bubbleGeo = new THREE.BufferGeometry();
const bubbleArr = new Float32Array(bubbleCount * 3);
const bubbleSpeed = new Float32Array(bubbleCount);
for (let i = 0; i < bubbleCount; i++) {
bubbleArr[i * 3 + 0] = THREE.MathUtils.randFloat(waterBounds.min.x, waterBounds.max.x);
bubbleArr[i * 3 + 1] = THREE.MathUtils.randFloat(waterBounds.min.y, waterBounds.max.y);
bubbleArr[i * 3 + 2] = THREE.MathUtils.randFloat(waterBounds.min.z, waterBounds.max.z);
bubbleSpeed[i] = 0.45 + Math.random() * 1.35;
}
bubbleGeo.setAttribute('position', new THREE.BufferAttribute(bubbleArr, 3));
const bubbles = new THREE.Points(
bubbleGeo,
new THREE.PointsMaterial({
size: 0.085,
map: bubbleTex,
transparent: true,
opacity: 0.82,
depthWrite: false,
blending: THREE.AdditiveBlending,
sizeAttenuation: true
})
);
bubbles.renderOrder = 4;
scene.add(bubbles);
// Pointer halo
const haloGroup = new THREE.Group();
const halo = new THREE.Mesh(
new THREE.SphereGeometry(1, 24, 16),
new THREE.MeshBasicMaterial({
color: 0x8ef6ff,
transparent: true,
opacity: 0.08,
depthWrite: false,
blending: THREE.AdditiveBlending
})
);
halo.scale.setScalar(3.1);
const core = new THREE.Mesh(
new THREE.SphereGeometry(0.3, 18, 12),
new THREE.MeshBasicMaterial({
color: 0xeaffff,
transparent: true,
opacity: 0.7,
depthWrite: false,
blending: THREE.AdditiveBlending
})
);
core.scale.set(1, 0.55, 1);
haloGroup.add(halo, core);
haloGroup.visible = false;
haloGroup.renderOrder = 9;
scene.add(haloGroup);
// Fish
const palette = [0xff7a18, 0xffd166, 0xef476f, 0x06d6a0, 0x4cc9f0, 0xffa5a5, 0x9b5de5, 0xf15bb5, 0x61e3ff, 0xff9b44];
function randomPoint(margin = 1.0) {
return new THREE.Vector3(
THREE.MathUtils.randFloat(waterBounds.min.x + margin, waterBounds.max.x - margin),
THREE.MathUtils.randFloat(waterBounds.min.y + margin, waterBounds.max.y - margin),
THREE.MathUtils.randFloat(waterBounds.min.z + margin, waterBounds.max.z - margin)
);
}
function createFish(hexColor, scale) {
const base = new THREE.Color(hexColor);
const bodyMat = new THREE.MeshStandardMaterial({
color: base,
roughness: 0.32,
metalness: 0.05,
emissive: base.clone().multiplyScalar(0.07)
});
const finMat = new THREE.MeshStandardMaterial({
color: base.clone().lerp(new THREE.Color(0xffffff), 0.45),
roughness: 0.45,
metalness: 0.02,
side: THREE.DoubleSide
});
const eyeMat = new THREE.MeshStandardMaterial({ color: 0xf5faff, roughness: 0.12, metalness: 0.1 });
const pupilMat = new THREE.MeshStandardMaterial({ color: 0x09131c, roughness: 0.25 });
const group = new THREE.Group();
group.scale.setScalar(scale);
const body = new THREE.Mesh(new THREE.SphereGeometry(0.68, 24, 16), bodyMat);
body.scale.set(0.62, 0.95, 1.5);
group.add(body);
const dorsal = new THREE.Mesh(new THREE.ConeGeometry(0.23, 0.75, 10), finMat);
dorsal.scale.set(0.07, 0.85, 0.85);
dorsal.position.set(0, 0.56, -0.08);
dorsal.rotation.x = -0.2;
group.add(dorsal);
const tailGroup = new THREE.Group();
tailGroup.position.set(0, 0, -1.02);
group.add(tailGroup);
const tail = new THREE.Mesh(new THREE.ConeGeometry(0.33, 0.85, 10), finMat);
tail.rotation.x = -Math.PI / 2;
tail.scale.set(0.07, 0.6, 1.0);
tail.position.set(0, 0, -0.42);
tailGroup.add(tail);
const pectoral = [];
for (let s = -1; s <= 1; s += 2) {
const fin = new THREE.Mesh(new THREE.ConeGeometry(0.14, 0.36, 7), finMat);
fin.scale.set(0.07, 1, 0.75);
fin.rotation.z = s * -1.1;
fin.rotation.y = s * 0.45;
fin.position.set(s * 0.3, -0.07, 0.26);
group.add(fin);
pectoral.push({ mesh: fin, side: s });
}
for (let s = -1; s <= 1; s += 2) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.086, 10, 10), eyeMat);
eye.position.set(s * 0.25, 0.16, 0.68);
group.add(eye);
const pupil = new THREE.Mesh(new THREE.SphereGeometry(0.045, 8, 8), pupilMat);
pupil.position.set(s * 0.28, 0.17, 0.73);
group.add(pupil);
}
return { group, tailGroup, pectoral, dorsal };
}
function makeFish(i) {
const scale = i < 5 ? 1.15 + Math.random() * 0.55 : 0.55 + Math.random() * 0.72;
const fish = createFish(palette[i % palette.length], scale);
fish.phase = Math.random() * Math.PI * 2;
fish.wander = 0.55 + Math.random() * 1.15;
fish.tailFreq = 3.5 + Math.random() * 4.5;
fish.maxSpeed = Math.max(1.25, (3.35 - scale * 0.85) + Math.random() * 1.05);
fish.fleeRadius = 2.0 + scale * 1.9;
fish.radius = scale * 0.72;
fish.velocity = new THREE.Vector3(
(Math.random() - 0.5) * 2.0,
(Math.random() - 0.5) * 1.0,
(Math.random() - 0.5) * 2.0
);
fish.accel = new THREE.Vector3();
fish.target = randomPoint(1.25);
fish.nextTargetTime = Math.random() * 7;
fish.group.position.copy(randomPoint(1.6));
scene.add(fish.group);
return fish;
}
const fishes = [];
for (let i = 0; i < 18; i++) fishes.push(makeFish(i));
const dummy = new THREE.Object3D();
dummy.matrixAutoUpdate = false;
const v1 = new THREE.Vector3();
const v2 = new THREE.Vector3();
const v3 = new THREE.Vector3();
const center = new THREE.Vector3(0, 0, 0);
function confine(pos, accel, axis, margin) {
const min = waterBounds.min[axis];
const max = waterBounds.max[axis];
if (pos[axis] < min + margin) accel[axis] += (min + margin - pos[axis]) * 3.6;
if (pos[axis] > max - margin) accel[axis] -= (pos[axis] - (max - margin)) * 3.6;
}
function updateFish(f, dt, time, mouseRay) {
const pos = f.group.position;
const accel = f.accel;
if (time > f.nextTargetTime) {
const m = 1.3;
f.target.set(
THREE.MathUtils.randFloat(waterBounds.min.x + m, waterBounds.max.x - m),
THREE.MathUtils.randFloat(waterBounds.min.y + m, waterBounds.max.y - m),
THREE.MathUtils.randFloat(waterBounds.min.z + m, waterBounds.max.z - m)
);
f.nextTargetTime = time + 5.0 + Math.random() * 7.0;
}
v1.copy(f.target).sub(pos).normalize().multiplyScalar(f.maxSpeed).sub(f.velocity);
accel.addScaledVector(v1, 0.72);
accel.x += Math.sin(time * f.wander + f.phase) * 0.8;
accel.y += Math.sin(time * 1.7 * f.wander + f.phase * 1.4) * 1.05;
accel.z += Math.cos(time * f.wander * 0.9 + f.phase) * 0.8;
if (mouseRay) {
v1.copy(pos).sub(mouseRay.origin);
const t = v1.dot(mouseRay.direction);
if (t > -0.5) {
v2.copy(mouseRay.origin).addScaledVector(mouseRay.direction, Math.max(0, t));
v3.copy(pos).sub(v2);
const d = v3.length();
if (d < f.fleeRadius) {
if (d < 0.02) {
v3.set(Math.cos(f.phase), Math.sin(f.phase * 1.3), Math.sin(f.phase * 0.7));
} else {
v3.divideScalar(d);
}
const falloff = 1 - d / f.fleeRadius;
const strength = 78 * falloff * falloff;
accel.addScaledVector(v3, strength);
}
}
}
confine(pos, accel, 'x', 1.1);
confine(pos, accel, 'y', 0.85);
confine(pos, accel, 'z', 1.1);
f.velocity.addScaledVector(accel, dt);
const speed = f.velocity.length();
const limit = f.maxSpeed * 1.65;
if (speed > limit) f.velocity.multiplyScalar(limit / speed);
pos.addScaledVector(f.velocity, dt);
if (speed > 0.035) {
v1.copy(pos).addScaledVector(f.velocity, 2.5);
dummy.position.copy(pos);
dummy.up.set(0, 1, 0);
dummy.lookAt(v1);
f.group.quaternion.slerp(dummy.quaternion, 1 - Math.exp(-dt * 5.0));
}
const tailAmp = 0.55 + Math.min(speed, 4.0) * 0.07;
f.tailGroup.rotation.y = Math.sin(time * f.tailFreq + f.phase) * tailAmp;
for (let i = 0; i < f.pectoral.length; i++) {
const p = f.pectoral[i];
p.mesh.rotation.x = Math.sin(time * 7.0 + f.phase + i * 1.7) * 0.24;
}
}
// Pointer state
const pointer = new THREE.Vector2(0, 0);
let pointerActive = false;
let started = false;
const startEl = document.getElementById('start');
function hideStart() {
if (!started) {
started = true;
startEl.classList.add('hidden');
}
}
window.addEventListener('pointermove', (e) => {
pointer.x = (e.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(e.clientY / window.innerHeight) * 2 + 1;
pointerActive = true;
hideStart();
});
window.addEventListener('pointerdown', (e) => {
pointer.x = (e.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(e.clientY / window.innerHeight) * 2 + 1;
pointerActive = true;
hideStart();
});
window.addEventListener('keydown', () => hideStart());
// Auto-fade message a bit later even if untouched, but still keep demo running.
setTimeout(() => hideStart(), 6500);
const raycaster = new THREE.Raycaster();
const clock = new THREE.Clock();
function animate() {
const dt = Math.min(clock.getDelta(), 0.05);
const time = clock.elapsedTime;
// Subtle parallax camera, still centered on tank.
const px = pointerActive ? pointer.x : 0;
const py = pointerActive ? pointer.y : 0;
camera.position.x = baseCam.x + Math.sin(time * 0.16) * 0.55 + px * 0.75;
camera.position.y = baseCam.y + Math.sin(time * 0.21) * 0.22 + py * 0.45;
camera.position.z = baseCam.z + Math.sin(time * 0.11) * 0.28;
camera.lookAt(0, 0.15, 0);
caustic1.position.set(Math.sin(time * 0.34) * 6.0, 3.9, Math.cos(time * 0.28) * 5.0);
caustic1.intensity = 27 + Math.sin(time * 1.7) * 4;
caustic2.position.set(Math.cos(time * 0.22) * 7.0, 2.5, Math.sin(time * 0.33) * 5.5);
caustic2.intensity = 17 + Math.cos(time * 1.2) * 3;
let mouseRay = null;
if (pointerActive) {
raycaster.setFromCamera(pointer, camera);
mouseRay = raycaster.ray;
v1.copy(center).sub(mouseRay.origin);
const t = Math.max(0, v1.dot(mouseRay.direction));
v2.copy(mouseRay.origin).addScaledVector(mouseRay.direction, t);
if (haloBox.containsPoint(v2)) {
haloGroup.visible = true;
haloGroup.position.copy(v2);
haloGroup.scale.setScalar(1 + Math.sin(time * 3.0) * 0.08);
} else {
haloGroup.visible = false;
}
} else {
haloGroup.visible = false;
}
// Reset fish acceleration and apply simple separation.
for (const f of fishes) f.accel.set(0, 0, 0);
for (let i = 0; i < fishes.length; i++) {
const a = fishes[i];
for (let j = i + 1; j < fishes.length; j++) {
const b = fishes[j];
v1.copy(a.group.position).sub(b.group.position);
const d = v1.length();
const minD = a.radius + b.radius + 0.35;
if (d < minD) {
if (d > 0.01) v1.divideScalar(d);
else v1.set(Math.sin(a.phase + b.phase), Math.cos(a.phase * 1.2), Math.sin(a.phase * 0.8)).normalize();
const force = (minD - d) * 2.9;
a.accel.addScaledVector(v1, force);
b.accel.addScaledVector(v1, -force);
}
}
}
for (const f of fishes) updateFish(f, dt, time, mouseRay);
// Plants sway.
for (const p of plants) {
p.rotation.z = Math.sin(time * 1.2 + p.userData.phase) * 0.09;
}
// Surface waves.
const posAttr = surfaceGeo.attributes.position;
for (let i = 0; i < posAttr.count; i++) {
const x = surfaceBase[i * 3 + 0];
const y = surfaceBase[i * 3 + 1];
const z =
Math.sin(x * 0.55 + time * 1.45) * 0.07 +
Math.cos(y * 0.65 - time * 1.1) * 0.06 +
Math.sin((x + y) * 0.3 + time * 0.8) * 0.045;
posAttr.setZ(i, z);
}
posAttr.needsUpdate = true;
surfaceGeo.computeVertexNormals();
// Bubbles.
const bp = bubbleGeo.attributes.position;
for (let i = 0; i < bubbleCount; i++) {
let x = bp.getX(i);
let y = bp.getY(i);
let z = bp.getZ(i);
y += bubbleSpeed[i] * dt;
x += Math.sin(time * 1.3 + i) * 0.0016;
z += Math.cos(time * 1.7 + i * 0.7) * 0.0016;
if (y > waterBounds.max.y + 0.7) {
y = waterBounds.min.y - 0.3;
x = THREE.MathUtils.randFloat(waterBounds.min.x, waterBounds.max.x);
z = THREE.MathUtils.randFloat(waterBounds.min.z, waterBounds.max.z);
}
bp.setXYZ(i, x, y, z);
}
bp.needsUpdate = true;
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
</script>
</body>
</html>Reasoning trace (28,498 tokens)
We need answer with one HTML code block. Need produce self-contained Three.js aquarium. Need satisfy: import map only three. Fish swim away when mouse near. 3D aquarium. First paint meaningful, self-playing demo behind click? For 3D non-game maybe still need interactive demo. Need games self-play? Not a game, but still can be interactive. But likely should have message "Click or press a key to start"? The user asks aquarium. We can have automatic animation visible immediately; but mouse interaction may require click for pointer lock? Not necessary, can track mouse even before click? Iframe receives keyboard events after click, but mouse events might work? Safer include start message and also autonomous demo with wandering fish, and allow mouse after click. But screenshot 3s after load: should show something. Message can be overlay. Need avoid external images. Use Three.js. Build scene: glass tank with water, fish procedurally modeled maybe group body, tail, fins, eye. Multiple fish. Mouse projected to a 3D target inside tank via raycaster or simple plane at z=0? Since perspective camera, can use NDC raycast to intersect a central plane or use vector unproject. Need fish flee from mouse: calculate distance in tank space from fish to mouse target; steering force away. Fish orientation to velocity. Tail animation. Caustic lighting maybe. Bubbles. Plants? Could be simple. Use MeshStandardMaterial/Phong, environment? no HDR. Use color. For glass use MeshPhysicalMaterial transmission? Might be expensive, transparency. Simpler use MeshPhysicalMaterial roughness 0 transparent opacity. Water inside box volume with slight fog and color. Add background. Resize adapt. Use OrbitControls? Need import from three/addons. But external network only allowed cdn import map. Could use OrbitControls but if user moves mouse it controls camera and also fish flee. It might be okay. But simpler fixed camera with slight mouse parallax; fish flee. Need ensure import map in HTML. Use type module. No errors.
Let's design robust.
HTML:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" ...>
<style> body margin 0 overflow hidden background etc. canvas display block. #ui overlay pointer-events none. #start message pointer-events auto? If click start dismiss. But can also start automatically demo behind message. Use CSS backdrop.
</style>
<script type="importmap">...</script>
</head>
<body>
<div id="ui">
<div id="title">...</div>
<div id="hint">Click to interact with fish</div>
<div id="start">Click or press a key to start</div>
</div>
<script type="module"> import * as THREE from 'three'; // maybe no addons to avoid network? If no OrbitControls no addons. Use pure Three core.
</script>
</body>
</html>
Need self-playing demo: fish already swim. Start overlay maybe visible but if it blocks pointer? It might block mousemove? If overlay covers full with pointer-events:none except click? But if start message covers, user may not see fish? Could set overlay small bottom center pointer-events:none, fish visible. But requirement says games must start behind a message, but this isn't game. Could just show message for first seconds then fade. If it overlays too much may hide scene. Use bottom banner with text, fades after click/any key or after 4 sec. But screenshot 3 sec maybe visible scene plus text. Fine.
Need mouse move near fish: Use raycaster from camera to a transparent sphere/box water. Better: define water volume box centered. Use NDC mouse and raycast against invisible plane? Need 3D position inside tank. Tank maybe centered at origin, width 14, height 8, depth 8. Camera at (0,4,18). Mouse projected onto plane z=0? Then fish flee in 3D. To have depth interaction, use raycast against an invisible sphere/plane? We can use a 3D attractor by projecting mouse ray onto a sphere volume: find closest point to ray inside tank center? Better use raycaster with a large invisible plane at z=0. But fish are throughout depth; if plane at z=0 only, fish in front/back might flee from target plane coordinate, okay maybe enough. Alternatively compute mouse ray and for each fish find closest point along ray to fish within tank? For steering, compute a virtual repulsor point: for each fish, closest point on mouse ray to fish, if within radius then repel direction fish - closestPoint. This gives depth-independent 3D influence, more realistic (fish flee from pointer ray). Nice. For bubbles maybe simple.
Fish class:
class Fish {
constructor(scene, color, scale)
group. Create body using SphereGeometry squashed, tail cone, fins triangles/plane, eyes. Animation tail by group tail.rotation.y? Need local tail joint. Use body geometry: Mesh(Sphere(0.6,16,12), material), scale.set(1.6,1,1). Tail group position x=1.0? Fish forward maybe +x. Body length. Tail mesh Cone(0.35,0.7,4) rotated -90 around y? For fish oriented local +x forward, tail at x=-1? Use tail group at -1.0; tail mesh rotation z=-pi/2? Cone base toward group? Let's build simple. Or use geometry: body = new THREE.Mesh(new THREE.SphereGeometry(0.7, 24, 16), material); body.scale.set(1.6, 1.0, 0.7). Tail: group at x=-1.2; tail = new THREE.Mesh(new THREE.ConeGeometry(0.35, 0.9, 8), material); tail.rotation.z = Math.PI/2? Cone default height along y, apex up. Rotate z=90 makes apex along -x? Need tail fin extending backward from group. If group at back, tail mesh position x=-0.4? Cone apex at -x, base at +x maybe rotation.z = -Math.PI/2? Cone default +y apex; rotate z=+90 => apex +x? Actually rotate around z: x' = x cos - y sin. (0,1) -> (-1,0) for +90. Apex at -x. We want tail fin maybe two triangles vertical. Let's not overcomplicate; use small cone with apex away from body. group position at body back, tail mesh rotation.z = -Math.PI/2 => apex +x? Wait -90: (0,1)->(1,0). Apex +x, towards body? group behind body, apex toward front? Tail should extend backward away from front. If front +x, body center 0, tail group at x=-1.3; fin should extend to -x, apex at -x maybe rotate +90. But a cone fin with apex at back is plausible. We'll set rotation.z=Math.PI/2. scale maybe flat.
Top fin: triangle via ShapeGeometry? Could use ConeGeometry flattened. Side fins: small planes or cones. Eyes: two spheres at front.
Animation: update position, velocity. Boids-like? They should wander. Flee mouse ray. Boundaries: if outside box, steer back. Use max speed. Velocity += acceleration; position += velocity*dt. Orientation: use group.lookAt(position + velocity) if speed>0. But default mesh geometry may need model facing. If group local forward is +x. Object.lookAt makes local +z point toward target? Three.js lookAt makes positive z axis point to target? Actually Object3D.lookAt makes rotation such that local +z faces target? In world, camera looks -z but object? For non-camera, lookAt aligns local +z to target. So if our fish body is +x forward, lookAt wrong. We can build fish facing +z instead: elongate along z? Easier: set group.lookAt(new pos), then rotate mesh children? Or use quaternion from forward vector with custom forward = +x? We can make fish local +z forward: body scale(0.7,1,1.6)? Tail at z=-. eyes at z+. Then lookAt works. Let's do forward +z. Create fish in XY plane? Body Sphere scale(0.8,1,1.5) elongate z, tail group at z=-1.3, tail cone rotate x=+90? Cone height along y, rotate x=-90? Need tail apex maybe -z? For cone default apex +y; rotate x=-90 maps y to z? (0,1,0) x=-90: z =? Rotation matrix around x: y'=y cos - z sin. For (0,1,0): y'=0, z'= -? cos0? sin(-90)=-1, z' = - y? Formula z' = y sin? Actually for right-hand rotation: y' = y cosθ - z sinθ, z' = y sinθ + z cosθ. θ=-π/2: y'=0*0 -0=0, z'=1*(-1)+0= -1. apex -z. We want tail extends -z? Tail group at z=-1.2, tail apex -z, okay. But group lookAt forward +z means tail -z. Good.
Tail animation: rotate tail group around y for sway, or x? Tail moves side to side. Use tailGroup.rotation.y = sin(t)*0.6. Top fin maybe rotation.z. Body pitch roll maybe.
Material: fish color random vivid. Use MeshStandardMaterial. Add slight emissive? maybe.
Tank structure:
- Inner water volume: BoxGeometry(width,height,depth) MeshPhysicalMaterial? To make water visible, create a large box as volume with MeshStandardMaterial color teal, transparent opacity 0.12, side? It might render weird. Use a water tank: glass outer shell with MeshPhysicalMaterial({ transmission: 1, roughness 0.05, thickness: 1, ior 1.33, transparent true? }) maybe needs renderer alpha? Transmission expensive but maybe okay. Simpler glass panels: planes with MeshPhongMaterial transparent opacity .15, side DoubleSide. Add edges frame (cylinders/boxes) for aquarium. Inner water volume box with transparent blue. Need avoid too much rendering cost. Use 5 panels (front/back/left/right/top maybe open, bottom). Frame edges as cylinders black/white. Could use a BoxGeometry with MeshPhysicalMaterial transparent opacity 0.08 and side BackSide? Hmm.
Alternative aquarium: A glass tank: use THREE.Mesh(new THREE.BoxGeometry(16,10,10), MeshPhysicalMaterial({transmission:.9, roughness:.1, metalness:0, thickness:2, transparent:true, color:0xffffff, side:THREE.DoubleSide?}) ). It may render transparent box. But transmission requires WebGL2, works. Could be heavy. Use opacity simple.
Water inside: mesh box slightly smaller with MeshStandardMaterial({color:0x1e88a5, transparent:true, opacity:0.18, side:THREE.BackSide, roughness:.3}) to make volume. But BackSide only renders inner faces, fish inside visible through front faces? If inner box BackSide, only back faces (inner) visible; from camera front wall not render, okay. Need outer glass separate.
Could create tank with:
const W=16,H=10,D=10
Glass: BoxGeometry W,H,D; material MeshPhysicalMaterial roughness .05 metalness .2 transparent true opacity .15 side DoubleSide envMap? depthWrite false. Edges: BoxGeometry wireframe? Use LineSegments EdgesGeometry with cyan opacity .8. Plus frame cylinders around edges maybe. Simpler wireframe box. Add floor with sand? Tank bottom: PlaneGeometry 18x18? outside? Could have stand? Let's focus.
Scene background: dark gradient via scene.background? Can't network image. Use canvas texture? Could generate gradient CanvasTexture. Or set fog and solid color. Use renderer clear color dark blue.
Lighting: ambient, directional, point blue/pink caustic, hemisphere. Use renderer.toneMapping? Maybe not.
Fog inside? scene.fog = FogExp2(0x0a2a44, 0.018) gives depth.
Caustics: animated spotlights moving? Add 2 lights. Also maybe particles (bubbles, plankton). Bubbles: Points geometry with positions, animate upward and wrap. Need sizeAttenuation. Use PointsMaterial color white transparent opacity .7. Generate random within tank.
Plants: simple cones/cylinders swaying? Could use bent cylinders? Need not. Could add seaweed: group of plane? Simpler use tapered cone geometry green, animate slight rotation. Add rocks: dodecahedron scaled.
Fish behavior:
World bounds: tank water internal: W=15,H=9,D=9, with margin 1. Fish cannot cross. Each frame compute target. Use mouse ray. Raycaster from camera with NDC. Need raycaster.setFromCamera(mouseNDC, camera). Then compute closest point on ray to fish. Ray origin O, direction D (normalized). Vector from origin to fish: v = fish.pos - O. proj = v.dot(D). Clamp to maybe [0,100]? Then point = O + D*proj. Distance = fish.pos - point. If dist < fleeRadius (maybe 3.5), force = normalized(distance)*strength*(1 - dist/radius)^2. If distance length < 0.05 use direction random. This repels along surface normal around ray. Since fish can move out of ray path. Good.
Also mouse position projected onto water central plane to spawn ripple? Maybe not.
If pointer active: after click set active true. But mousemove should still work? We can use pointer events always. The message says click to interact; but fish already wander. Need self-play: fish wander no interaction. When user moves mouse before click maybe ignore or active false? To satisfy click maybe active false until start. But the screenshot no mouse. We can set pointerActive after first pointerdown or pointermove? If only after click, moving mouse without click no effect. But requirement click or press key. However if user just moves mouse they expect fish swim away, task says when I move mouse near them. Better to let mousemove interact immediately even without click? But requirement says iframe keyboard only after click; mouse can work? Maybe pointermove may not fire until click? In sandbox, mousemove likely works without click. But we can include start overlay to not hinder. We can allow mousemove always; key/click starts full interactivity? Hmm. To be safe: pointerActive true initially? But message says click or press to start. Could set started true after 2 seconds? Or fish demo visible. Let's allow mouse interaction after first pointermove or click? The message could say "Move your mouse; click to add bubbles" etc. But instruction games must start self-playing behind a click message. Not necessarily games. Could just have an overlay that disappears after first pointermove. Fish are moving. That satisfies visible action. But maybe evaluator wants click message. Add #start "Click or press a key to start" and set started = true on click/key, but also set started = true on pointermove? No, then message disappears immediately if mouse moves. But self-playing demo behind message: fish moving visible. If not started, still show fish moving. Good.
Potential issue if pointer events on overlay: set overlay pointer-events none except maybe not blocking. #start pointer-events none to allow canvas mousemove? If overlay text only. Click on body? document event can start. So no blocking.
Camera: slight parallax based on mouse and time to feel 3D. Or orbit-like auto rotate. If camera moves, mouse ray still works. Fish flee from ray. Add slight camera sway. Maybe allow drag rotate? Not needed. To avoid user confusion, auto orbit? But mouse move should be for fish, not camera. Keep camera fixed but subtle.
Renderer: antialias true, pixelRatio min(devicePixelRatio, 2). Need size. Resize handler. Use clock.
Zero errors: Need import THREE. No addons. Use THREE.ConeGeometry etc.
Let's detail code.
HTML/CSS:
:root. body { margin:0; height:100vh; overflow:hidden; background:#041018; font-family: system-ui, sans-serif; color:#dff6ff;}
canvas { display:block }
#ui { position:fixed; inset:0; pointer-events:none; display:flex; flex-direction:column; justify-content:space-between; padding:18px; }
#title { font-size clamp(18px,2vw,28px); text-shadow...; opacity .9}
#controls { font-size 14; opacity .75}
#start { position fixed? maybe top center? Use bottom center. pointer-events:none; transition opacity .5; padding 12 20; border 1px; border-radius; background rgba; }
.hidden {opacity:0; transform:translateY(8px)}
Also vignette overlay? Maybe.
JS:
const canvas? Append renderer.domElement.
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x061926);
scene.fog = new THREE.FogExp2(0x082433, 0.028);
camera = PerspectiveCamera(55, aspect, .1, 100); camera.position.set(0,1.5,18); camera.lookAt(0,0,0);
renderer = WebGLRenderer({antialias:true, alpha:false}); renderer.setSize(innerWidth, innerHeight); renderer.setPixelRatio(Math.min(devicePixelRatio,2)); renderer.outputColorSpace? default SRGB; renderer.toneMapping = ACESFilmic? Could set but no issue.
Lights:
hemisphere = HemisphereLight(0x88ddff, 0x0a2030, 0.7);
dir = DirectionalLight(0xffffff, 1.1); dir.position.set(5,10,8);
blue point = PointLight(0x00d9ff, 18, 30) position inside; move.
pink rim = PointLight(0xff4fd8, 10, 25) position back; maybe.
ambient.
Tank:
const T = {w:15.5,h:9.5,d:9.5}; // internal? Use waterBox dimensions 14,8.5,8 maybe.
Outer glass dimensions W=16,H=10,D=10.
const waterBounds = new THREE.Box3(new Vector3(-7.25,-4.25,-4.25), new Vector3(7.25,4.25,4.25)); // fish bounds.
Glass:
const glassMat = new THREE.MeshPhysicalMaterial({color:0xcfeeff, metalness:0, roughness:.05, transparent:true, opacity:.12, transmission:0? MeshPhysicalMaterial supports transmission; set transmission:0.85, thickness:1, ior:1.33, clearcoat:.3, clearcoatRoughness:.1, side:THREE.DoubleSide, depthWrite:false}); But if transmission maybe expensive. Could use opacity .12. MeshStandardMaterial easier. Let's use MeshPhysicalMaterial with transparent, opacity .15, roughness .08, metalness .0, envMap? none. Maybe transmission not needed. For glass box side DoubleSide depthWrite false. It might cause sorting weird. We can render glass after fish? Use renderOrder.
Glass mesh renderOrder=10, material depthWrite false.
Edges line: EdgesGeometry of box; LineBasicMaterial({color:0x6ef0ff, transparent:true, opacity:0.55}); LineSegments renderOrder=11.
Frame: Could use 12 thin boxes? Maybe overkill. Add vertical/horizontal edge cylinders at 4 vertical corners: CylinderGeometry(0.22,0.22,H) at x +/- W/2, z +/- D/2. Material MeshStandard color 0x0f2535, metalness .6, roughness .3. Plus top/bottom rims? Use BoxGeometry? To look aquarium, add dark stand? Could add rim frames: 12 thin BoxGeometry for each edge. Simpler use CylinderGeometry for vertical edges and BoxGeometry for horizontal edges. But many geometry. We can create a frame group:
function addEdge(start,end,radius,mat){ length=... direction; cylinder oriented along direction midpoint }
Edges from 8 vertices. Use 12 edges. Radius 0.13. Good.
Need BoxGeometry for each? Or cylinder. For horizontal edges, rotate. Implement edgePoints list.
Material dark. renderOrder low.
Water volume:
Box(W-0.5,H-0.5,D-0.5) MeshStandardMaterial({color:0x0e84a8, transparent:true, opacity:0.12, side:THREE.BackSide, roughness:0.2, metalness:0}); renderOrder=1. This renders inner back sides (bottom/back/side) maybe water tint. Add a top surface plane? Inside, a plane at y=H/2 with MeshStandardMaterial({color:0x76e1ff, transparent:true, opacity:.22, side:THREE.DoubleSide}); animate wave via vertices? Could use simple plane with vertex y sin in update? Nice. Plane 15x9, 32 segments, y=H/2-0.1. Update geometry position attribute with sine, normal? Could not recompute normals each frame? Use MeshStandardMaterial flat? Maybe skip normals update? For visual, can just rotate? But water surface waves. Use PlaneGeometry, set x/z, y = sin(x*0.8+t)*0.05 + cos(z*0.6+t*1.2)*0.05. Then geometry.attributes.position.needsUpdate=true. Material flatShading? It will look okay. Need plane rotate x=-Math.PI/2, so vertices have x,z? After rotation, local y becomes world? Simpler use PlaneGeometry in XY and rotate X -PI/2; vertices z before? For updated positions in local coordinates: x, y, z=0; after rotation local y = -world z? Better create custom horizontal plane by generating BufferGeometry? Or set plane.rotation.x=-PI/2 and update position attribute y? After rotation, local x -> world x, local y -> world -z? But if updating z? Hmm. Use PlaneGeometry and rotate x=-PI/2; local x corresponds world x, local y corresponds world z, local z (before) corresponds world y. So update attribute z for height: positions[i*3+2] = wave. But if z initially 0, okay. Need maybe material side double.
Sand bottom: inside tank, PlaneGeometry W-1,D-1 at y=-H/2+0.05 rotate x=-PI/2; MeshStandardMaterial color 0x9c8256 roughness .9. Add rocks.
Rocks: group of DodecahedronGeometry scaled randomly, grey/brown, placed bottom.
Plants/seaweed: several groups, each has thin cylinder stem and leaves? Use ConeGeometry tall. Sway rotation.
Bubbles: Points 400. Positions array within water bounds. Update y += speed; if > top reset bottom. Also x/z slight sin. PointsMaterial size .08, transparent, opacity .85, depthWrite false. Maybe use bubble sprite canvas texture? Could generate circle texture via canvas, use PointsMaterial map = texture. Good. But no external. Generate 64x64 radial gradient on canvas. Use THREE.CanvasTexture. It's allowed.
Particles: small points plankton.
Fish:
const fishes=[];
function makeFish(color, scale) { group = new THREE.Group(); bodyMat = MeshStandardMaterial({color, roughness:.45, metalness:.05, emissive: color*0.04?});
body = Mesh(Sphere(0.65,24,16), bodyMat); body.scale.set(0.65,0.95,1.5); group.add(body);
// tail
tailGroup = new THREE.Group(); tailGroup.position.set(0,0,-0.95); group.add(tailGroup);
tail = Mesh(Cone(0.34,0.8,12), bodyMat); tail.position.set(0,0,-0.32); tail.rotation.x = Math.PI/2? Let's check. Need tail fin along horizontal plane, broad? Maybe two triangular tails. Simpler: tail = Mesh(PlaneGeometry(0.8,0.5), material) rotate? But double. Use ConeGeometry radius .4 height .8; rotate x= Math.PI/2? Cone default height along y, apex +y. If rotate x=90: y -> z? Using matrix, (0,1,0) x 90 -> (0,0,1). Apex +z (front), base -z. For tail at back z=-0.95, want apex at -z (back), base near body +z? Rotate x=-90: apex -z. Use tail.rotation.x=-Math.PI/2. Scale maybe (0.08, 0.35,1?) Hmm.
Tail flat: tail.scale.set(0.08,0.45,0.9)? If cone after rotation, x=thickness, y? Maybe not.
Let's create a custom tail with two triangular fins using Shape? Could use ConeGeometry with radius .32 height .7, scale.set(0.08,1,1) to flatten in x? After rotation x=-90, cone height along z, radius in x,y; flattening x gives thin in horizontal? Need tail fin vertical (thin in x? fish width x? Actually fish body elongate z, width x small, height y). Tail fin should vertical thin x? Fish tail moves left/right? If body local x horizontal width, y vertical height. Tail fin extends in y vertical and -z. Flat thin x. Cone after rotation: radius in x,y, height in z. scale x 0.08 for thin, y 0.5, z 1.0. Good. tail.position.z=-0.35.
tailGroup.rotation.y? Sway side? Tail moves in horizontal plane? If fish facing +z, left-right is x. TailGroup should rotate around y to move tail side to side in xz? But tail is in y vertical plane, rotation around y will move it sideways yes.
// dorsal fin: small cone at top: dorsal = Cone(0.18,0.5,8), position (0,0.42,0), rotation.x=0? Apex up. scale (0.05,0.8,0.8)
// pectoral fins: two cones/plane at sides: finL = Mesh(PlaneGeometry(0.25,0.5)?), but plane thin; rotate? Could use cone flattened. Add small cones.
// eyes: eyeGeo Sphere(0.09,8,8); white material; pupil 0.05; position x +/-0.22, y 0.14, z 0.75? Body scale front z max .65*1.5=0.975. eyes at z=0.8. Need white/pupil.
// tail animation property.
return {group, body, tail, tailGroup, phase, speed, color etc.}
But orientation: group lookAt uses local +z. If front +z, good.
Need scale fish by scale.
Spawn: create 14-18 fish with colors. Some small, large. Random position within bounds margin. velocity random. Each has wander offset.
Update:
const tmpA = new Vector3(), tmpB, tmpRay, tmpMouseRay.
Raycaster raycaster; raycaster.setFromCamera(pointer, camera). ray = raycaster.ray.
pointer = {x:-10,y:-10} offscreen to ignore.
mouseActive false? But pointermove sets active true? For ray influence, if pointerActive? Let's have pointerInside boolean true after any pointermove. But start overlay not blocking. It says click or key to start; maybe set started true on pointerdown, keydown. But fish interaction can happen before start? For screenshot no. I think we can use pointerActive true if started or maybe always. Task specifically: fish swim away when I move mouse near them. If they must click to start, maybe okay. But could frustrate if moving mouse not. We can set started true after pointermove too? The start message is for games not this. But instructions say Games must start...; aquarium not game, but perhaps still interactive. To be safe: start message visible; clicking or pressing key dismisses, but mouse movement after page load is used for fish regardless. It won't affect demo. The message can be non-interactive, so mousemove works. It says "Click or press a key to start" if keyboard. We can have it fade after first pointermove or click. Good.
For each fish:
fish.phase += dt * fish.speed * 6;
tailGroup.rotation.y = Math.sin(fish.phase) * 0.7;
body.rotation.y maybe slight sin? Could also pitch.
Dorsal? fins animate.
Steering:
accel = new Vector3()
// wander: use noise-like: change direction over time. fish.wanderAngle += dt*(random?); Add force = vector from sin/cos scaled by bounds? Simpler each fish target position updated periodically? Let's implement boids-ish.
Each fish has:
velocity Vector3, targetPos Vector3, speedMax, wanderTime.
Update: if time > nextChange: choose random target within bounds, nextChange = time + rand(5,10); Also maybe avoid mouse. Then direction to target = target - pos normalized; desired velocity = dir*speedMax; steer = (desired - vel)*0.4; add.
Mouse repulsion as above: if mouseActive (pointer inside) and distance < fleeRadius, fleeStrength.
Boundaries: if pos outside waterBounds expanded, push toward center. For each axis, if pos < min+margin accel -= sign*(pos-min)/margin; if > max+margin. Use spring.
Clamp velocity magnitude to maxSpeed (3 for small maybe). Add vertical bob: accel.y += sin(time+phase)*0.15.
Update velocity += accel*dt; pos += velocity*dt.
Orientation: if speed > 0.001: desiredForward = velocity.clone(); group.lookAt(pos + desiredForward). However group.up default (0,1,0). Fish may roll? Use group.quaternion? Object.lookAt makes front +z to target, up global. For moving down, fish pitches. Good. But when velocity near zero not.
Need preserve orientation smoothly: group.quaternion.slerp(targetQuat, 1 - exp(-dt*5)). Compute target quat by temporary object? Simpler use group.lookAt but smooth by lerping position? Could have a dummy = new Object3D(); dummy.position.copy(group.position); dummy.lookAt(pos + vel); group.quaternion.slerp(dummy.quaternion, min(1, dt*3)). To avoid per fish object allocation, use global dummy and set position.
Tail orientation due to rotation? If group rotates, tailGroup local rotates side sway.
Flee from ray: If fish near ray, it should swim away in lateral direction and along ray? For fish moving toward camera, ray might pass through them. Distance computed shortest to ray. Direction = fish.pos - closestPoint. If distance small, add force. Also add along ray away from origin? Maybe. Use radial from ray.
closest point:
v.copy(fish.pos).sub(ray.origin); t = v.dot(ray.direction); if t > 0 then point.copy(ray.origin).addScaledVector(ray.direction, t); else point=ray.origin? Since ray direction from camera to tank. For t negative, closest at origin but fish not near. Use t = clamp? If ray origin behind camera, fish always t positive. Good.
dir = fish.pos - point; dist = len. If dist < fleeRadius: strength = (1 - dist/fleeRadius)^2 * 90; dir.normalize; accel += dir * strength. But dir perpendicular to ray; if dist near zero, add random. To avoid fish getting stuck on ray line, add small velocity along ray: if moving with ray? Could add away from camera: point direction? Not necessary.
Could also have a virtual 3D point from ray-plane at center to visualize pointer? Maybe small glowing sphere representing hand/shadow. Add a mesh pointerSphere at closest point on central plane? Could compute intersection with a plane at camera focus? Not needed but visual: raycast to invisible water volume box: get first intersection? Could put a small semi-transparent sphere at pointer target. But ray might intersect box; use raycaster.intersectObject(glass?) not? Simpler compute t to plane z=0? Or intersect a bounding sphere? A sphere follows mouse ray within tank: project to center plane. Use raycaster intersect with invisible Box? If intersect waterBoundsBox (Box3) ray? Use Mesh of water volume? We can raycast against an invisible mesh inside box? Better: raycaster with box mesh; but we already have waterBox. Could make pointerTarget by intersecting a sphere centered origin radius 8? Maybe use raycaster.ray.intersectSphere(new Sphere(center, 7), target). If not, no. Then place a translucent ripple disk? Could be nice. Let's add a "mouse halo" group: sphere mesh (or ring) inside tank. Compute target = closest point on ray to center? Actually to show where pointer influence is, find point on ray with depth where distance to center minimal. Compute t = dot(center - origin, direction), pos = origin + dir*t. If inside bounds, show halo at pos, else hide. Then fish flee from actual ray (not just halo). Halo radius can match fleeRadius. Use SphereGeometry 0.6 additive material opacity .25. Or TorusGeometry. But if halo on fish, okay. We'll add pointerTargetPos. If pointerActive, halo.visible.
Water surface: if camera slightly above, plane top visible.
Fish bounds: waterBounds. Use box with min/max.
Need ensure fish not behind glass? okay.
Camera movement: const cameraTarget = new Vector3(0,0.3,0); camera.position.x = Math.sin(time*0.18)*1.5 + pointer.x*0.6? But pointer ray depends camera. If camera moves with pointer, fish behavior influenced? okay. Could do subtle only: camera.position.x = base.x + Math.sin(time*0.2)*0.8 + pointer.x*1.2; camera.position.y = base.y + Math.sin(time*0.3)*0.3 + pointer.y*0.8; camera.lookAt(0,0.2,0). This creates parallax but mouse ray changes. Might be fine. However if camera moves, pointer target ray may swing. Could affect fish more. But user moving mouse near fish should still repel. Maybe keep camera mostly fixed to avoid weird. Use time sway small. Pointer influence maybe 0.2. camera.lookAt.
Performance: 16 fish with spheres 24 segments okay. Points 600. Plane vertices update maybe. No shadows. Good.
Potential issue MeshPhysicalMaterial transparent with depthWrite false may see fish through? Good. If fish render order after glass? Transparent materials sorted by renderOrder and distance. We want glass over fish? If glass renderOrder high and depthWrite false, fish drawn first (opaque). But water volume transparent maybe depthWrite false too. Points etc. Let's set:
- sand/rocks/plants opaque renderOrder default.
- fish opaque.
- waterBox transparent depthWrite false renderOrder 1.
- surface transparent depthWrite false renderOrder 2.
- bubbles points depthWrite false renderOrder 3.
- glass transparent depthWrite false renderOrder 10.
- edges renderOrder 11.
This ensures fish visible, then glass tint.
But if glass box transparent with depthWrite false, rendering order 10, it will draw after all, good. If doubleSide, front/back glass both. Might create ghosting but okay.
Need import THREE. Avoid optional addons.
Fish colors: array vivid: orange, clownfish white/black? Use color 0xff7b00 etc. Some stripes? Could add stripe geometry? Maybe no. Could create fish with two colors: body, tail? For clownfish, body color orange and white bands: add torus rings? Might be nice: add ring mesh around body. But too much. Maybe use varied colors.
Tail group: If tailGroup.position set before group scale? Group.scale uniform.
Let's write code carefully.
Detailed fish creation:
function createFish(options) {
const color = new THREE.Color(options.color);
const bodyMat = new THREE.MeshStandardMaterial({ color, roughness:0.38, metalness:0.08, emissive: color.clone().multiplyScalar(0.07)});
const finMat = new THREE.MeshStandardMaterial({ color: color.clone().lerp(new THREE.Color(0xffffff), 0.35), transparent: true, opacity:0.85, roughness:0.45, side:THREE.DoubleSide });
const group = new THREE.Group();
group.scale.setScalar(options.scale);
const body = new THREE.Mesh(new THREE.SphereGeometry(0.68, 24, 16), bodyMat);
body.scale.set(0.66, 0.98, 1.55);
group.add(body);
// dorsal fin (top) along z? A cone: height along y, apex +y. Flatten x, length z maybe.
const dorsal = new THREE.Mesh(new THREE.ConeGeometry(0.22, 0.75, 10), finMat);
dorsal.scale.set(0.07,0.9,0.8);
dorsal.position.set(0, 0.6, -0.05);
dorsal.rotation.x = -0.15;
group.add(dorsal);
// tail group behind
const tailGroup = new THREE.Group();
tailGroup.position.set(0,0,-1.05);
group.add(tailGroup);
const tail = new THREE.Mesh(new THREE.ConeGeometry(0.32, 0.85, 10), finMat);
tail.rotation.x = -Math.PI/2; // apex -z? Check: rotation x=-pi/2 gives y -> -z. Apex -z, base +z (near group center? group tailGroup at -1.05, cone center at 0, apex -z -> tail extends further back, base at +z toward front. Good.
tail.scale.set(0.07,0.55,1.0);
tail.position.set(0,0,-0.42); // center shifted? If cone height 0.85, center at 0, apex at -0.425. With rotation and position -0.42, apex at tailGroup -0.845, base at +0.005. Good. tailGroup position -1.05, body front z +? tail behind.
tailGroup.add(tail);
// maybe second tail fork? use two small cones? no.
// pectoral fins: two small flattened cones at sides near front, animation maybe.
const finGeo = new THREE.ConeGeometry(0.12, 0.35, 8);
for s of [-1,1] {
const fin = new THREE.Mesh(finGeo, finMat);
fin.rotation.z = s * -1.1;
fin.rotation.x = 0.5;
fin.position.set(s*0.28, -0.1, 0.35);
fin.scale.set(0.07,1,0.8);
group.add(fin); fin.userData? We can store fins array.
}
// eyes
const eyeGeo = new THREE.SphereGeometry(0.085, 10, 10);
const eyeMat = new THREE.MeshStandardMaterial({ color:0xffffff, roughness:0.15});
const pupilMat = new THREE.MeshStandardMaterial({ color:0x08121a, roughness:0.2});
for s of [-1,1] { const eye = new THREE.Mesh(eyeGeo, eyeMat); eye.position.set(s*0.28,0.18,0.72); group.add(eye); const pupil = new THREE.Mesh(new THREE.SphereGeometry(0.045,8,8), pupilMat); pupil.position.set(s*0.315,0.19,0.775); group.add(pupil); }
return { group, tailGroup, dorsal, fins: group.children? Better store pectoral fins. phase: random, velocity, etc. }
}
Need access pectoral fins to animate. Store fins = []; when create side fins push.
Maybe body also.
Spawn fish:
const palette = [0xff7a18,0xffd166,0xef476f,0x06d6a0,0x4cc9f0,0xffa5a5,0x9b5de5,0xf15bb5];
for i 0..15: color = palette[i%...], scale = 0.7 + Math.random()*0.9, maxSpeed = (2.2 - scale*0.4) + rand. Actually large slower. maxSpeed = 1.8 + Math.random()*1.6; fleeRadius = 2.2 + scale*1.5?
fish = {
group, pos, vel, target, nextTarget=0, wander = rand, maxSpeed, fleeRadius, phase = rand*Math.PI*2, tailFreq = 4+rand*3, pectoral fins?
};
scene.add(group); fishes.push.
Initialize pos random within bounds. target pos random.
Update function updateFish(f, dt, time):
const pos = f.group.position;
const accel = f.accel; accel.set(0,0,0);
// choose target every few seconds
if (time > f.nextTargetTime) { f.target.copy(randPointInBounds(1.2)); f.nextTargetTime = time + 6 + Math.random()*8; }
tmp.copy(f.target).sub(pos).normalize().multiplyScalar(f.maxSpeed);
tmp.sub(f.vel); accel.addScaledVector(tmp, 0.8); // steer
// wander wiggle vertical and horizontal
accel.x += Math.sin(time*f.wander + f.phase)*0.5;
accel.y += Math.sin(time*1.7*f.wander + f.phase*1.3)*0.7;
accel.z += Math.cos(time*f.wander*0.8 + f.phase)*0.5;
// mouse ray repulsion
if (pointerActive && pointer.x !== -10) { raycaster.setFromCamera(pointer, camera); const ray = raycaster.ray; tmp2.copy(pos).sub(ray.origin); const distOnRay = tmp2.dot(ray.direction); if (distOnRay > 0) { tmp.copy(ray.origin).addScaledVector(ray.direction, distOnRay); tmp3.copy(pos).sub(tmp); const d = tmp3.length(); if (d < f.fleeRadius) { if (d<0.01) tmp3.set(Math.sin(time),0,Math.cos(time)); tmp3.normalize(); const strength = 45 * (1 - d/f.fleeRadius)*(1 - d/f.fleeRadius); accel.addScaledVector(tmp3, strength); // maybe move along ray away: accel.addScaledVector(ray.direction, strength*0.2) ??? ray.direction from camera into tank. Fish near ray, should swim off ray, not necessarily away. } } }
But raycaster should only update once per frame outside for all fish. pointerActive maybe true if pointer moved and pointer in [-1,1]. Use mouseActive.
// boundaries
bounds = f.bounds or waterBounds, margin=1.2;
if pos.x < min.x+margin) accel.x += (min.x+margin - pos.x)*3;
if pos.x > max.x-margin) accel.x -= (pos.x-(max.x-margin))*3;
... same y,z.
// update vel
f.vel.addScaledVector(accel, dt);
// clamp
const speed = f.vel.length(); if speed > f.maxSpeed*1.25 f.vel.multiplyScalar(...); if speed < 0.2? maybe not.
// update pos
pos.addScaledVector(f.vel, dt);
// orientation smoothing
if (speed>0.02) { dummy.position.copy(pos); dummy.lookAt(pos.x+f.vel.x*3? Actually lookAt target: dummy.position + velocity normalized * 2); Use dummy.lookAt(tmp.set(pos.x + vel.x, pos.y + vel.y, pos.z + vel.z)); group.quaternion.slerp(dummy.quaternion, 1 - Math.exp(-dt*4)); }
// tail animation
const tailSwing = Math.sin(time*f.tailFreq + f.phase)*(0.65 + speed*0.08); f.tailGroup.rotation.y = tailSwing;
f.body? Could roll: group.rotation? Don't override group.rotation from quaternion. tailGroup local rotation okay.
fins animate: f.pectoral.rotation? We stored. Maybe f.pectoralL.rotation.z = base - Math.sin(...)*0.3. But rotation of children. We can store base positions. Simpler animate f.tailGroup only. Could animate dorsal? skip.
Need bounds constant:
const waterBounds = {min: new Vector3(-7, -4.2, -4), max: new Vector3(7, 4.2, 4)};
randPointInBounds(margin) => random between min+margin, max-margin.
Pointer halo:
const haloGroup = new THREE.Group();
halo = Mesh(SphereGeometry(0.7, 24, 16), MeshBasicMaterial({color:0x8ef6ff, transparent:true, opacity:0.15, blending:Additive, depthWrite:false})); scale maybe flee radius 3? Actually radius = fleeRadius? We want visualize influence. Use ring torus oriented to ray? Maybe sphere enough. Add small inner sphere opacity .4. Or circular disk billboard. Simpler sphere radius 0.6, and halo.scale.set(fleeRadius? maybe 2.5). Use two: pointerCore sphere .4 and pointerHalo sphere 2.5 additive. But sphere may block? transparent. Add if pointerActive.
Set halo.position to closest point on ray to center? Or intersection with z=0. Use t = dot(center-origin, dir) with center=0. pos = origin+dir*t. Then if within bounds, visible. But fish flee from ray, not halo point. Fine.
Could instead use intersection with plane y=0? Not important.
Maybe use mouse target from raycast against a sphere inside tank? Use invisible Mesh Sphere (radius 7) with raycaster.intersectObject? That gives entry point where pointer "hits" front sphere. But not central. For visible target, ray-plane at z=0: setFromCamera, plane normal? Could create plane at water center vertical? For camera at z, ray intersects z=0 plane easily: denominator dir.z; if dir.z !=0 t=(0-origin.z)/dir.z. If t>0. But fish are in depth, central target okay. Use that for halo; if outside bounds maybe clamp? Simpler closest to center.
Bubble update:
const bubbleCount=320;
const bPositions = geometry.attributes.position.array;
Need store speeds maybe Float32Array.
In update:
for i:
y += speed[i]*dt; if y > max.y + rand? reset y=min.y; x,z random. x += sin(time + i)*dt*0.1.
positionsNeedUpdate.
Need use bounds min/max.
Water surface waves:
planeGeo = PlaneGeometry(W-0.6,D-0.6,30,20);
material maybe MeshPhysicalMaterial({color:0x9defff, transparent:true, opacity:0.28, roughness:0.1, metalness:0.2, side:DoubleSide, depthWrite:false});
surfaceMesh.rotation.x = -Math.PI/2; surfaceMesh.position.y = waterBounds.max.y - 0.15; scene.add;
Update: position = planeGeo.attributes.position; for i: x=pos.getX(i), y=pos.getY(i); // local y is world z? Actually after rotate -PI/2, local y -> -world z. Use z=pos.getZ(i) initially 0; set z = sin(x*0.7 + time*1.8)*0.06 + cos(y*0.8 - time*1.3)*0.05 + sin((x+y)*0.4+time)*0.04. Then geometry.attributes.position.needsUpdate=true. Need not compute normals? MeshStandardMaterial may need normal updates; skip computeVertexNormals every few frames? Could call geometry.computeVertexNormals() each frame maybe 600 vertices, okay? Might cost but acceptable. Could use MeshBasicMaterial with emissive? But standard without normals update may look flat wrong. Could use MeshStandardMaterial and compute normals every 3 frames? Simpler use MeshBasicMaterial? For water surface, MeshStandard with flatShading maybe weird. Use MeshBasicMaterial color with opacity? It won't lit, but okay. Use MeshPhongMaterial? Let's use MeshStandardMaterial and compute normals after updating; 30x20 = 600, not too many. computeVertexNormals every frame okay. But maybe performance? fine.
Caustics lights:
const caustic1 = PointLight(0x66f7ff, 25, 18); scene.add; Update pos x = sin(time*0.3)*4, y = top-1, z= cos(time*0.2)*3; etc. caustic2 magenta.
Maybe god rays? Not.
Fish schooling? They swim away individually. Could have some school small tetra with aligned velocity? But enough.
Potential issue: group.lookAt uses up (0,1,0), if velocity points straight up/down, roll undefined; slerp okay. Could add small horizontal velocity to avoid.
No text outside code block. Need final only code.
Let's consider if transmission glass may cause fish behind glass blurred? Use standard transparent to avoid performance. MeshPhysicalMaterial with transparent is okay.
Let's build full code.
Pseudo:
<script type="module">
import * as THREE from 'three';
const scene = new THREE.Scene(); ...
const renderer = new THREE.WebGLRenderer({antialias:true});
document.body.appendChild(renderer.domElement);
const camera = new THREE.PerspectiveCamera(55, innerWidth/innerHeight, 0.1, 120);
const baseCam = new THREE.Vector3(0,1.2,19);
camera.position.copy(baseCam);
// Resize
function onResize(){camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix(); renderer.setSize(...)}
addEventListener('resize', onResize);
// Lights
scene.add(new THREE.HemisphereLight(...));
const dir=... scene.add(dir);
// Tank geometry
const outerW=17, outerH=10.5, outerD=10.5;
const innerW=15.5, innerH=9.5, innerD=9.5;
waterBounds = new Box3(new Vector3(-innerW/2+0.5? maybe), ...)
Actually fish should stay within water, not glass. waterBounds min (-innerW/2+0.8, -innerH/2+0.7, -innerD/2+0.8), max...
const W=innerW, H=innerH, D=innerD;
// Glass frame edges: define half extents outer. Use addEdge for all cube edges.
function addCylinderEdge(a,b,radius,mat){ dir=b-a; length=dir.length(); geo=Cylinder(radius,radius,length,8,1); mesh = Mesh(geo,mat); mesh.position.copy(a).add(b).multiplyScalar(.5); const zAxis? Use mesh.quaternion.setFromUnitVectors(new Vector3(0,1,0), dir.clone().normalize()); }
Edge vertices:
const ox=outerW/2, oy=outerH/2, oz=outerD/2;
vertices array corners 8. Edges pairs: [0,1], [2,3], [4,5], [6,7] x direction; [0,2], [1,3], [4,6], [5,7] y? etc. Need define.
Simpler loop:
for y in [-1,1] for z in [-1,1] add edge x from (-ox,y*oy,z*oz) to (ox,...)
for x,z add y edges
for x,y add z edges.
Material frameMat = MeshStandardMaterial({color:0x0b1f2b, metalness:.65, roughness:.35});
Add. Glass mesh renderOrder 12; LineSegments renderOrder 13; but frame is opaque, should render before glass? Frame is at outer surface. Opaque by default; okay. If glass drawn after frame, transparent over frame maybe fine. But frame depthWrite true. Good.
Could use line edges too but maybe not needed if frame. Add edges to make crisp.
// Sand
const sandGeo = PlaneGeometry(innerW-0.4, innerD-0.4, 20,20); // maybe displace for uneven
sandMesh.position.y = waterBounds.min.y+0.02; rotate. material color 0x8a6f48. Displace z? Use vertices to make dunes. Update once.
// Rocks and plants
function addRock(sx) { mesh = Dodecahedron(0.5,0); scale random; color gray; pos bottom; }
function addPlant(height) { ConeGeometry(0.12,height,6); material green standard transparent? }
Maybe add multiple seaweed: use CylinderGeometry(0.03,0.08,height,5), bend? Cylinder straight, animate slight. For leaves maybe. But can add 8 plants. Use MeshStandardMaterial({color:0x2fa564, side:DoubleSide, roughness:.7}). Cone. Animate by plant.rotation.z = sin(time+i)*0.15. Place at bottom.
// Water volume
const waterMesh = new THREE.Mesh(new THREE.BoxGeometry(innerW, innerH, innerD), new THREE.MeshStandardMaterial({color:0x187a94, transparent:true, opacity:0.09, side:THREE.BackSide, roughness:0.1, metalness:0}));
waterMesh.position.y=0; waterMesh.renderOrder=1; scene.add(waterMesh);
// Surface
const surfaceGeo = new THREE.PlaneGeometry(innerW-0.8, innerD-0.8, 34, 24);
const surfaceMat = new THREE.MeshStandardMaterial({color:0x8ef6ff, transparent:true, opacity:0.35, roughness:0.1, metalness:0.1, side:THREE.DoubleSide});
surfaceGeo.attributes.position original? Need store base positions.
const surfaceBase = new Float32Array(surfaceGeo.attributes.position.array);
surfaceMesh.rotation.x=-Math.PI/2; surfaceMesh.position.y=innerH/2 - 0.2; surfaceMesh.renderOrder=2; scene.add(surfaceMesh);
// Bubbles
function makeCircleTexture(){ canvas 64; ctx; radial gradient white center; ... return new THREE.CanvasTexture(canvas); }
bubbleTexture.
const bubbleGeo = new THREE.BufferGeometry(); positions Float32Array(count*3); speeds Float32Array(count); random.
material PointsMaterial({size:0.09, map:bubbleTexture, transparent:true, opacity:.85, depthWrite:false, sizeAttenuation:true, blending:THREE.AdditiveBlending});
bubbles = Points; renderOrder=4.
// Plankton points similarly, tiny, slow, opacity .35.
// Fish halo
pointerHalo = Mesh(SphereGeometry(1,24,16), MeshBasicMaterial({color:0x8cf6ff, transparent:true, opacity:0.12, blending:Additive, depthWrite:false}));
pointerCore = Mesh(SphereGeometry(0.28,16,16), MeshBasicMaterial({color:0xeaffff, transparent:true, opacity:.7, blending:Additive, depthWrite:false}));
haloGroup.add. visible=false.
// Fish creation
// Store fish.
Pointer:
const pointer = new THREE.Vector2(0,0); let pointerActive=false; let started=false;
function setPointer(e){ pointer.x=(e.clientX/innerWidth)*2-1; pointer.y=-(e.clientY/innerHeight)*2+1; pointerActive=true; if(!started) hideStart(); }
window.addEventListener('pointermove', setPointer);
window.addEventListener('pointerdown', e=>{ started=true; hideStart(); });
window.addEventListener('keydown', e=>{ if(e.key===' ' || e.key==='Enter'||e.key==='Escape'? maybe start) ... });
But key could conflict? no.
If mouse leaves, keep active. Fine.
Start message: function start(){started=true; message.classList.add('hidden');}
Could fade after 6 sec anyway.
Raycaster: const raycaster = new THREE.Raycaster();
Animate:
const clock = new THREE.Clock();
function animate(){ requestAnimationFrame(animate); dt = Math.min(clock.getDelta(), 0.05); time = clock.elapsedTime;
// camera parallax
camera.position.x = baseCam.x + Math.sin(time*0.16)*0.6 + (pointerActive? pointer.x*0.8:0);
camera.position.y = baseCam.y + Math.sin(time*0.21)*0.22 + (pointerActive? pointer.y*0.5:0);
camera.position.z = baseCam.z + Math.sin(time*0.11)*0.3;
camera.lookAt(0,0.2,0);
// lights update
caustic1.position.set(Math.sin(time*0.33)*5, 3.8, Math.cos(time*0.28)*4); ... intensity maybe.
// update pointer halo
if(pointerActive){ raycaster.setFromCamera(pointer,camera); tmp.copy(camera.position).sub? Actually raycaster.ray. Use center=0,0,0. t=dot(center-ray.origin, ray.direction). closest point. halo.position.copy(ray.origin).addScaledVector(ray.direction, t); // if within bounds? Use if inside; else clamp maybe.
// halo scale pulse
halo.scale.setScalar(3.5+Math.sin(time*3)*0.2);
haloGroup.visible=true;
} else haloGroup.visible=false;
// update surface waves
updateSurface(time)
updateBubbles(dt,time)
// update ray once: raycaster.setFromCamera(pointer,camera) if active. Need if camera changed after set? yes call after camera update.
// fish
for f updateFish
// render
renderer.render(scene,camera);
}
animate();
Need updateSurface: const posAttr = surfaceGeo.attributes.position; for i, x=base[i*3], y=base[i*3+1]; z = sin(x*0.55+time*1.4)*0.08 + cos(y*0.65-time*1.1)*0.07 + sin((x+y)*0.3+time*0.8)*0.05; posAttr.setZ(i,z); posAttr.needsUpdate=true; surfaceGeo.computeVertexNormals();
Note PlaneGeometry has z 0 in base; we set z.
Update bubbles:
const bPos = bubbleGeo.attributes.position; for i: y = bPos.getY(i); y += speeds[i]*dt; if y > waterBounds.max.y+0.5 { y = waterBounds.min.y - Math.random()*1; x = random x; z = random; } x += sin(time*2+i)*0.0005? Use bPos.setX(i, x + Math.sin(time+i)*0.001) maybe drift. bPos.setY, etc. bPos.needsUpdate=true.
Need store initial positions? We can read.
For plankton similar.
Fish creation details. Let's write helper:
function randomPointInBounds(margin=1){ return new THREE.Vector3(THREE.MathUtils.randFloat(min.x+margin, max.x-margin), ...)}
But THREE.MathUtils available.
fishes=[];
for i=0; i<17; i++:
const color = palette[i%palette.length]; maybe add some color jitter.
const scale = i<6 ? 1.2 + Math.random()*0.6 : 0.55 + Math.random()*0.55;
const fish = createFish(color, scale);
fish.maxSpeed = (3.1 - scale*0.8) + Math.random()*1.2; // small faster? For large slow.
if small maybe maxSpeed 2.5-4.
fish.fleeRadius = 2.2 + scale*1.8;
fish.pos = randomPointInBounds(1.3); fish.group.position.copy(pos); fish.vel = random direction* speed*0.5; fish.target=randomPointInBounds(1.0); fish.nextTargetTime = time+ rand; fish.phase = rand*6.28; fish.wander= rand 0.5..1.5; fish.tailFreq=4+rand*4; fish.accel=new Vector3();
scene.add(fish.group); fishes.push.
Potential: fish group scale, but fleeRadius in world units unaffected. Good.
createFish returns group and parts.
Need body? Not needed. pectoral fins? Could animate but maybe not. Let's store fins.
Implementation of pectoral fins:
const pectoral = [];
for (let s=-1;s<=1;s+=2){ const fin = new THREE.Mesh(new THREE.ConeGeometry(0.14,0.35,7), finMat); fin.scale.set(0.08,1,0.75); fin.rotation.z = s * -1.05; fin.rotation.y = s * 0.35; fin.position.set(s*0.32,-0.08,0.3); group.add(fin); pectoral.push({mesh:fin, side:s, baseZ:fin.rotation.z}); }
In update: f.pectoral.forEach((p, idx)=> { p.mesh.rotation.x = Math.sin(time*6+phase+idx)*0.3; }); But initial rotation.z fixed. okay.
Maybe add stripes? For clownfish: add two torus rings:
function addStripe(fish, mat, zPos) ring = TorusGeometry(radius, tube, 8,20); rotate around x? Fish body z length, stripes vertical ring around body. Use torus radius scaled to body cross-section; rotate x=PI/2? Torus default XY plane, z tube around. To wrap around fish along z, rotate x=90? The torus ring lies in XZ? Let's skip to save.
Materials: Need maybe transparent fins; ensure side DoubleSide. Cone with open? no.
Potential issue: using same geometry for fins but material transparent and depthWrite maybe false? For fish opaque? finMat transparent could cause fish sorting. But small, okay. Could make fins opaque to avoid depth issues. Use MeshStandardMaterial transparent false for finMat but color lighter. Use side DoubleSide. Let's not use transparent. fins solid. Good.
Fish body emissive maybe low. If color dark, emissive? okay.
Add background gradient? Since renderer.setClearColor maybe. Could make scene.background CanvasTexture gradient. Let's add a simple sky? In browser behind dark. Could add background via large sphere? Maybe not. scene.background Color and fog enough. But 3D aquarium looks better with dark gradient behind. We can generate canvas gradient texture for background.
function makeBgTexture(){ canvas width 16 height 256? vertical gradient from #0b3450 to #04070f; texture; scene.background = texture; But texture may stretch, okay. Use THREE.CanvasTexture. Set colorSpace? default srgb? renderer.outputColorSpace? For background, maybe no need. But use scene.background = texture; }
But if background texture aspect? scene.background is equirect? If regular texture, it maps screen? In Three r150, background texture treated as cube/equirect if mapping? Actually Texture as background can be screen-space? I think scene.background can be Color or Texture or CubeTexture. Texture maps to screen? Could be okay. But safer set scene.background = new Color.
Can add distant back wall plane? Could have aquarium on stand with wall? Not needed.
Could add spotlight cone? skip.
Glass material: MeshPhysicalMaterial({color:0xbfefff, metalness:0, roughness:0.08, transparent:true, opacity:0.14, side:THREE.DoubleSide}); depthWrite false. If no env, roughness not reflection. Could add metalness .1 for highlight. Directional lights create specular. Good.
Frame edges: Use radius 0.09? If outer dims 17x10.5x10.5, frame visible. Use MeshStandardMaterial({color:0x102634, metalness:0.5, roughness:0.4}). Add vertical corner posts thicker radius 0.16? Maybe. Use generic radius 0.08.
Also water top inner plane might intersect glass. fine.
Bounds: Use waterBounds min = -W/2+0.75, W/2-0.75 etc. For tank W=innerW? Need fish margin. Let's define:
const tankW = 16.5, tankH = 9.5, tankD = 9.5;
const outerW = tankW+1, outerH = tankH+0.7, outerD = tankD+1;
const waterBounds = new THREE.Box3(
new THREE.Vector3(-tankW/2+0.55, -tankH/2+0.45, -tankD/2+0.55),
new THREE.Vector3(tankW/2-0.55, tankH/2-0.75, tankD/2-0.55)
);
Fish max y less than surface. Water volume dimensions tankW-0.1, tankH, tankD-0.1 maybe.
Surface y = waterBounds.max.y + 0.3? Actually tank top inner. Use surface at tankH/2 - 0.3. Fish max below surface by 0.8.
Glass outer box: BoxGeometry(outerW, outerH, outerD). Position 0? Center at y maybe 0. Tank top at 5.4, bottom -5.4. Camera y 1.2.
Water volume box: BoxGeometry(tankW-0.4, tankH-0.4, tankD-0.4) renderOrder 1.
Sand bottom: at y=-tankH/2+0.15; PlaneGeometry(tankW-1, tankD-1).
Plants bottom same.
Bounds for fish: within water volume with margin 1.0:
waterBounds = new Box3(new Vector3(-(tankW-1)/2, -(tankH-1)/2, -(tankD-1)/2), new Vector3(...))? Let's compute manually.
const boundsHalf = new Vector3((tankW/2)-1.3, (tankH/2)-1.2, (tankD/2)-1.3);
min=-boundsHalf; max=boundsHalf; surface y = tankH/2 - 0.4; fish max y = boundsHalf.y.
Good.
Ray closest to center: if pointerActive, raycaster.setFromCamera(pointer,camera); const ray=raycaster.ray; const tmp = new Vector3(0,0,0).sub(ray.origin); const t = tmp.dot(ray.direction); haloPoint.copy(ray.origin).addScaledVector(ray.direction, Math.max(0,t)); But if camera at z=18, origin.z=18, center 0, dir negative z; t positive ~? okay. If user moves pointer far, point on ray near center depth but x,y maybe large; halo may outside bounds. Check inside: clamp maybe to bounds: if not inside bounds expand? Use Box3.containsPoint(haloPoint); else hide halo but still fish flee ray. Or project to a sphere around bounds? Could clamp. Not important.
Pointer influence: Use raycaster from camera to fish positions. If pointer not active, skip.
Raycaster performance: call each frame. For each fish, use same ray. Good.
Need avoid memory allocation in update. Use shared THREE.Vector3 temporaries.
Let's define:
const v1=new Vector3(), v2=new Vector3(), v3=new Vector3(), q1? dummy = new THREE.Object3D(); dummy.matrixAutoUpdate=false. For lookAt, dummy.position.copy(pos), dummy.up.set(0,1,0), dummy.lookAt(target). Then group.quaternion.slerp(dummy.quaternion, alpha). Since dummy.up default.
Need dummy.position set because lookAt uses object's position and target. Good.
Potential fish orientation: group.quaternion.slerp to dummy.quaternion. But group.position also changing. Good.
When speed very low, use previous. But if velocity negative? okay.
Boundary force: For each axis, helper:
function confineAxis(pos, accel, key) { const min = waterBounds.min[key], max = waterBounds.max[key]; const margin = 0.8; if (pos < min+margin) accel[key]+= (min+margin-pos)*3.5; if (pos > max-margin) accel[key]-=(pos-(max-margin))*3.5; }
But waterBounds is Box3, property access maybe not dynamic? Box3 has min Vector3, max Vector3. Use if pos.x.
Fish target update: use randomPoint function that allocates? Could avoid. But okay every few sec. In randomPoint, v.copy. Need not allocate each call? Use temp. For spawn, okay.
Maybe fish should stay away from camera? Bound z max -0.5? They can be near front glass; okay.
Interactivity: if user clicks, maybe spawn bubbles burst at pointer target. Could be fun but optional. Since start message click, can add a bubble burst. Implement simple: on pointerdown or click? Need not block. If click on canvas, generate 10 bubbles at haloPoint. But if halo visible. Could add to bubble pool. However if bubble pool fixed, we can set nearest bubbles? Simpler no.
But requirement "fish swim away when I move the mouse near them". Good.
Let's ensure code runs. Use MathUtils. Check geometry names in r170: THREE.ConeGeometry yes, Torus? no.
Potential import map: Must include exactly. Use <script type="importmap">...</script> before module.
Potential no horizontal scrollbar: overflow hidden.
Need final answer just code block. Let's maybe produce polished.
Let's review possible errors.
- scene.background = new THREE.Color.
- renderer.outputColorSpace = THREE.SRGBColorSpace? In r170 default. Could set. But if not present? exists. Use optional? no, fine.
- CanvasTexture colorSpace? For point material map, set texture.colorSpace = THREE.SRGBColorSpace if needed. Points map alpha? Use additive. okay.
- Edges: addEdge function uses CylinderGeometry, quaternion. Need dir.normalize. If dir length zero? no.
- Box3: waterBounds.min.x etc.
- Bubble texture: create circle. PointsMaterial with map, transparent, blending. Points render as sprites. Good.
- Surface plane: PlaneGeometry creates vertices with x, y, z=0. We update z. After rotation, local z becomes world y? Let's verify: rotate x=-π/2: local z axis maps to world y? Right hand: point (0,0,z) after rotation about x: y' = y cos - z sin; z'= y sin + z cos. y=0,z => y'=-z, z'=0? Wait rotation x=-90: cos0? sin=-1. For (0,0,z): y' = 0 - z*(-1)=z; z' = 0 + z*0=0. local z -> world y. Good. local y (0,1,0): y'=cos*(-?)? y'=1*0 - 0*(-1)=0; z'=1*(-1)+0= -1, so local y -> world -z. So updating local z works as height, local x -> world x, local y -> world -z. Good. Base geometry has z=0. We set z to waves.
ComputeVertexNormals uses updated z. Good.
- Water volume BackSide: It draws inside of box. With camera outside, front faces back side (inner surfaces) visible? Box3 centered at origin. For front wall, front side is outward? BackSide renders inside face, visible if looking through glass. It may render back side surfaces including bottom, top? Top inside face visible from above? camera slightly above maybe. It gives tint. Good.
- Glass box side DoubleSide transparent depthWrite false. If drawn after fish and water, okay. But fish may be inside glass and transparent water. Render order: waterMesh renderOrder 1, surface 2, bubbles 4, glass 10. Opaque fish renderOrder default 0? Transparent sorted after opaque? Three renderOrder applies regardless; transparent objects drawn after opaque if depth? I think renderOrder sorts all objects; lower first. Fish are opaque, renderOrder 0. Good.
- Frame edges opaque renderOrder maybe 0; glass after. Good.
- Points bubbles renderOrder 4 but transparent depthWrite false; may render before glass. okay.
- HaloGroup transparent additive renderOrder 9, before glass. Good.
Potential fish body scale with group.lookAt: group.scale uniform doesn't affect orientation. Good.
- createFish: group.scale.setScalar(scale). TailGroup position local -1.05 scaled. Good.
- ConeGeometry tail rotation: Need tailGroup rotation.y side sway. Tail is child with apex -z. Good.
Check tail.rotation.x = -Math.PI/2. Cone default centered at origin, apex (0,h/2,0), base (0,-h/2,0). Rotate x -90: apex -> (0,0,-h/2), base -> (0,0,+h/2). Then tail.position.z=-0.42, so apex at -0.845 relative tailGroup, base at 0.005 relative tailGroup. tailGroup at -1.05. Good, tail extends -1.895, base near -1.045. Body front z ~ 1.05, back z ~ -1.05, tail further back. Good.
- Dorsal cone: rotation.x=-0.15 maybe apex up/back? default apex +y; fine.
- Pectoral fins cone geometry radius .12 height .35; rotation z side. Good.
Could add tail fork maybe no.
- Fish update pectoral: We need store f.pectoral array. In animate fish, loop pectoral.
Let's write createFish return with pectoral.
- Palette colors: Some dark? okay.
- Bounds: fish initial group.position maybe copied. Need create fish after waterBounds defined.
- Time and clock: clock.getElapsedTime? We call clock.getDelta() and time=clock.elapsedTime? After getDelta, clock.elapsedTime updated? In THREE Clock, getDelta updates oldTime and elapsedTime? Yes getDelta sets elapsedTime += diff. Use const time = clock.getElapsedTime(); maybe if call getDelta after, elapsedTime current? Better:
const dt = Math.min(clock.getDelta(), .05); const time = clock.elapsedTime; (or getElapsedTime after delta). Use time = clock.getElapsedTime() which returns total, but if getDelta then getElapsedTime no double? It does not reset. Fine: const dt = Math.min(clock.getDelta(), .05); const time = clock.elapsedTime;
- Start message: const startEl = document.getElementById('start'); function reveal(){ startEl.classList.add('hidden'); started=true; } Add timeout 5000 maybe. But message not blocking. If keydown, ignore if typing? no inputs. Use any key? Requirement key to start; if pressing arrow no issue. But any key? okay. Prevent default? no.
- If user clicks, pointerdown may also ray. Good.
Potential issue pointerActive starts false; camera parallax uses pointer if false? use (pointerActive ? pointer.x:0). But if pointer never moved, fine. For screenshot fish wander visible.
- If pointer moves, hide start? set started? Maybe if pointerActive true, set started and hide after 0.2? If user moves mouse, task says fish swim away, so no need to click. But message says click or press. Could not hide on move? The demo is running anyway. If message remains after move, no harm. But instructions: games start behind message; interactive maybe. We'll hide on click/key or after pointermove? If pointermove before click might hide, which might be okay but message not required. I think hide on pointermove too to show full UI? But then click not needed. The start message could still be displayed until interaction; moving is interaction. But requirement says click/press to start if keyboard. Hmm. To avoid blocking mouse interaction, set pointerActive true on pointermove and hide message after a small delay? Or keep message but transparent. Let's make #start text not block pointer and fade out after first pointermove/click/key or after 6 sec. So first screenshot shows message. If user moves mouse, message hides and fish flee. Good.
- Use event listener on window pointermove; if pointer coordinates outside? still.
- Maybe need touch: pointermove works touch? For mobile, touch drag. Good.
Let's consider alternative: Use OrbitControls from addons? Not necessary.
Testing mentally: On load, canvas appended. UI overlay. animate starts. fish wander. At 3 sec screenshot: 17 fish moving, tank with frame and bubbles, message bottom. Good.
Potential issue fish orientation dummy lookAt target uses position + velocity vector but velocity can be >? fine. But dummy.matrixAutoUpdate=false doesn't matter for lookAt? lookAt updates quaternion, but if matrixAutoUpdate false, still ok. Need dummy.up default. Good.
Potential issue group.quaternion.slerp with target from dummy; if fish moves backward? target pos+vel normalized? Velocity vector maybe small; dummy.lookAt(pos + vel) if vel zero? guarded speed>0.02.
Potential issue fish group local +z forward but body scale elongate z, tail behind -z, eyes front +z. Good.
Potential issue fish scale: group.scale.setScalar(scale); then pectoral positions scale; tail group local positions scaled. Good.
Potential issue raycaster ray direction: camera update changes but projection matrix updated on resize; no need update camera projection each frame. Good.
Could use renderer.setClearColor? scene.background color enough.
Let's add a slight vignette via CSS? okay.
Need output only code block. Ensure HTML valid.
Now, let's draft full code.
Double-check imports:
<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';
...
</script>
No use addons.
Could there be CORS? jsdelivr okay.
Let's consider THREE version 0.170.0 API:
- Box3.set? okay.
- THREE.MathUtils.randFloat.
- PointsMaterial map and alphaTest. CanvasTexture.
- BufferGeometry.attributes.position.setZ exists? Yes BufferAttribute has setZ. Use setXYZ? For PlaneGeometry attributes, position is Float32BufferAttribute, has setZ. Could use setZ(i, z). For bubble positions Float32BufferAttribute, has setY etc.
- material.blending = THREE.AdditiveBlending.
- THREE.SRGBColorSpace exists.
- renderer.outputColorSpace = THREE.SRGBColorSpace (not required).
Potential issue computeVertexNormals on BufferGeometry with indexed PlaneGeometry: works.
Potential issue water volume material side BackSide with transparent and depthWrite false. It may cause fish to appear behind water volume? Since water volume is transparent but renderOrder after fish. It tints everything inside. okay.
Let's tune visual:
- glass opacity 0.1 too invisible? Maybe use 0.18 with specular. Add edge lines bright. Frame thick.
- water color opacity .18 maybe too much. Let's use .09.
- lights: directional intensity maybe 1.2, hemisphere .6, point lights intensity .? Three r155 lighting units changed? Physically correct lights? WebGLRenderer.useLegacyLights deprecated; default false. PointLight intensity needs higher for distance. We set 25 maybe okay. Directional light intensity 1.5. Hemisphere 0.6. MeshStandard. If too dark, increase. Use ambient .35. Maybe scene.fog dark.
Let's set:
scene.add(new THREE.AmbientLight(0x3366aa, 0.8));
Hemisphere(0x74e4ff, 0x08203a, 0.7);
Directional(0xffffff, 1.6);
Point caustic(0x74e4ff, 35, 22, 2); Point magenta(0xff5ec6, 22, 20, 2);
Back fill point(0x1d6ea8, 30, 25, 2).
Point distance decays; intensity in candela? For physical, intensity high okay. If too bright, maybe. We can set toneMapping ACES and exposure 1.0.
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
This helps.
Potential performance compute normals every frame plus 17 fish okay.
Add background gradient via large sphere? Could use scene.background color only.
Let's make tank frame more aquarium: Add a base/stand? Simple dark box below tank? Could add a stand box under tank to ground. But camera looks at origin; maybe water tank floating. Add floor? Could be nice. Add a dark plane at y=-5.8 for floor, grid? Without textures. Plane large color 0x021018. But fog hides. Add stand: BoxGeometry(outerW+2, 0.6, outerD+2) at y=-outerH/2-0.3. Then legs? maybe not. Could have aquarium on stand. But fish bounds unaffected. Add stand to ground.
Floor: PlaneGeometry(100,100) at y=-6.2 rotate; MeshStandardMaterial color 0x07141f roughness .9. Might receive lights? no shadows. Add stand box at bottom. But glass bottom may overlap.
Let's add:
const floor = Mesh(PlaneGeometry(80,80), MeshStandardMaterial({color:0x071824, roughness:.95})); floor.rotation.x=-PI/2; floor.position.y=-7.2; scene.add(floor);
const stand = Mesh(BoxGeometry(outerW+1.6, 1.1, outerD+1.6), MeshStandardMaterial({color:0x0d1a24, roughness:.7, metalness:.2})); stand.position.y=-outerH/2-0.55; scene.add(stand);
But floor and stand opaque renderOrder default; fish inside unaffected. Could make scene less dark. Add fog color same.
Need ensure camera doesn't see bottom? y=1.2, z=19, tank height top ~5.6, stand bottom maybe -5.6; floor -7. okay.
Maybe back wall? floor enough.
Add caustic light moving inside tank; if stand/floor no shadows, okay.
Now, let's write code in final.
One more thought: Fish flee from ray: If fish is exactly on ray (near camera direction), closest point to fish is fish itself => distance zero. We add random perpendicular. Good. But if many fish near, random might vary. We can use fish phase for random: dir.set(Math.cos(f.phase), Math.sin(f.phase*1.3), Math.sin(f.phase)).normalize.
The mouse ray extends from camera through screen. If user points outside tank, ray may still pass through tank? Maybe fish flee from offscreen pointer if ray intersects tank due to wide FOV. Good.
If pointerActive false but message says start, fish self-play. Good.
Potential issue pointerActive becomes true after first pointermove, which may hide start. If no mouse move, message stays. Fine.
Let's implement hideStart on pointermove? Maybe to avoid lingering over demo? But first screenshot 3s could have message. If user moves immediately, hide. Good.
CSS #start fixed bottom center, pointer-events:none. #ui pointer-events:none overall. So clicks pass to canvas? We listen window anyway.
Let's ensure no horizontal scroll. body overflow hidden.
Now final code block. Need no explanatory text.
Double-check variables not undefined.
Detailed code skeleton:
import * as THREE from 'three';
const scene = ...
const renderer = ...
const camera = ...
const clock = ...
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2(0,0);
let pointerActive=false, started=false;
const waterBounds = new THREE.Box3();
...
function addEdge(a,b,radius,mat){...}
function addBoxEdges(cx,cy,cz,w,h,d,radius,mat){ const x0=cx-w/2, x1=cx+w/2, y0=cy-h/2,...; // edges
for (let x=0; x<2; x++) for y in 2? Actually:
addEdge(new Vector3(x0,y0,z0), new Vector3(x1,y0,z0), radius, mat); // bottom x
...
}
Let's implement loop simpler:
const x = [cx - w/2, cx + w/2];
const y = [cy - h/2, cy + h/2];
const z = [cz - d/2, cz + d/2];
for i in 0..1 for j in 0..1 { addEdge(x[i],y[j],z[0], x[1-i]...) maybe duplicate? We need 12 unique edges. Could manual 12:
for i=0,1 for j=0,1: add edge along x between (x[i? wait if along x, y=z fixed) Actually loop over y,z:
for (const yy of y) for (const zz of z) addEdge({x[0],yy,zz},{x[1],yy,zz}); // 4 edges
for (const xx of x) for (const zz of z) addEdge({xx,y[0],zz},{xx,y[1],zz}); // 4
for (const xx of x) for (const yy of y) addEdge({xx,yy,z[0]},{xx,yy,z[1]}); //4
Total 12.
Use Vector3 objects. Good.
Frame edges radius maybe 0.12. For glass maybe no line edges.
Glass mesh:
const glassGeo = new THREE.BoxGeometry(outerW, outerH, outerD);
const glassMat = new THREE.MeshPhysicalMaterial({ color: 0xcfeeff, roughness: 0.07, metalness: 0, transparent: true, opacity: 0.16, side: THREE.DoubleSide, depthWrite: false, clearcoat:0.5 }); glassMesh = new Mesh(glassGeo, glassMat); glassMesh.renderOrder=10; scene.add.
MeshPhysicalMaterial clearcoat uses env? okay.
WaterBox renderOrder 1, depthWrite false.
Halo renderOrder 9.
Bubbles: Need set bubble points material map; canvas texture. PointsMaterial blending Additive, depthWrite false, size .12, sizeAttenuation true. Generate texture.
Plankton: smaller points color maybe 0xbff8ff opacity .4.
Fish creation function uses shared fin material? Could use per fish finMat. Good.
Potential issue transparent materials with depthWrite false but opaque? We use opaque.
Let's craft createFish carefully:
function createFish(hexColor, scale) {
const base = new THREE.Color(hexColor);
const bodyMat = new THREE.MeshStandardMaterial({ color: base, roughness:0.32, metalness:0.05, emissive: base.clone().multiplyScalar(0.08) });
const light = base.clone().lerp(new THREE.Color(0xffffff), 0.45);
const finMat = new THREE.MeshStandardMaterial({ color: light, roughness:0.45, metalness:0.02, side:THREE.DoubleSide });
const eyeMat = new THREE.MeshStandardMaterial({ color: 0xf5faff, roughness:0.12, metalness:0.1 });
const pupilMat = new THREE.MeshStandardMaterial({ color: 0x09131c, roughness:0.25 });
const group = new THREE.Group(); group.scale.setScalar(scale);
const body = new THREE.Mesh(new THREE.SphereGeometry(0.68,24,16), bodyMat); body.scale.set(0.62,0.95,1.5); group.add(body);
const dorsal = new THREE.Mesh(new THREE.ConeGeometry(0.23,0.75,10), finMat); dorsal.scale.set(0.07,0.85,0.85); dorsal.position.set(0,0.56,-0.08); dorsal.rotation.x=-0.2; group.add(dorsal);
const tailGroup = new THREE.Group(); tailGroup.position.set(0,0,-1.0); group.add(tailGroup);
const tail = new THREE.Mesh(new THREE.ConeGeometry(0.33,0.85,10), finMat); tail.rotation.x = -Math.PI/2; tail.scale.set(0.07,0.6,1.0); tail.position.set(0,0,-0.42); tailGroup.add(tail);
// caudal fork? Add a second thin cone angled maybe? Could not.
const pectoral = [];
for (let s=-1; s<=1; s+=2){ const fin = new THREE.Mesh(new THREE.ConeGeometry(0.14,0.36,7), finMat); fin.scale.set(0.07,1,0.75); fin.rotation.z = s * -1.1; fin.rotation.y = s * 0.45; fin.position.set(s*0.3,-0.07,0.26); group.add(fin); pectoral.push({mesh:fin, side:s}); }
// eyes and maybe brow
for (let s=-1; s<=1; s+=2){ const eye = new THREE.Mesh(new THREE.SphereGeometry(0.086,10,10), eyeMat); eye.position.set(s*0.25,0.16,0.68); group.add(eye); const pupil = new THREE.Mesh(new THREE.SphereGeometry(0.045,8,8), pupilMat); pupil.position.set(s*0.28,0.17,0.73); group.add(pupil); }
// pelvic fin? optional
return {group, tailGroup, pectoral, dorsal};
}
Need body z max: sphere radius .68 scale z 1.5 =1.02, eyes z .68 inside. Good. TailGroup at -1.0 body back -1.02; tail extends.
Fish object creation after createFish:
function makeFish(i) {
const scale = i < 5 ? 1.2 + Math.random()*0.5 : 0.55 + Math.random()*0.7;
const fish = createFish(palette[i%palette.length], scale);
fish.phase = Math.random()*Math.PI*2;
fish.wander = 0.6 + Math.random()*1.1;
fish.tailFreq = 3.5 + Math.random()*4.5;
fish.maxSpeed = Math.max(1.3, (3.4 - scale*0.9) + Math.random()*1.1);
fish.fleeRadius = 2.0 + scale*1.9;
fish.velocity = new THREE.Vector3((Math.random()-0.5)*2, (Math.random()-0.5)*1, (Math.random()-0.5)*2);
fish.target = randomPoint(1.5);
fish.nextTargetTime = Math.random()*6;
fish.accel = new THREE.Vector3();
fish.group.position.copy(randomPoint(1.5));
scene.add(fish.group);
return fish;
}
randomPoint(margin) uses waterBounds.
const p = new Vector3; function randomPoint(margin){ p.set(...); return p.clone? For initial need clone. But fish.target should be separate. Use return new Vector3(...). For spawn okay. For update reuse? We can allocate when target changes. Fine.
updateFish:
function updateFish(f, dt, time, mouseRayActive) {
const pos = f.group.position; const accel = f.accel;
accel.set(0,0,0);
if (time > f.nextTargetTime) { const m = 1.2; f.target.set(randRange(waterBounds.min.x+m, waterBounds.max.x-m), ...); f.nextTargetTime = time + 5 + Math.random()*7; }
v1.copy(f.target).sub(pos).normalize().multiplyScalar(f.maxSpeed).sub(f.velocity); accel.addScaledVector(v1, 0.7);
accel.x += Math.sin(time*f.wander + f.phase)*0.8;
accel.y += Math.sin(time*1.7*f.wander + f.phase*1.4)*1.1;
accel.z += Math.cos(time*f.wander*0.9 + f.phase)*0.8;
// boundary
confine(pos, accel, 'x', 1.1); etc? Box3 min/max keys. But property access waterBounds.min[key] with key string okay.
// mouse
if (mouseRayActive) { v1.copy(pos).sub(mouseRay.origin); const t=v1.dot(mouseRay.direction); if (t > -1) { v2.copy(mouseRay.origin).addScaledVector(mouseRay.direction, Math.max(0,t)); v3.copy(pos).sub(v2); const d=v3.length(); if (d < f.fleeRadius) { if (d < 0.02) v3.set(Math.cos(f.phase), Math.sin(f.phase*1.3), Math.sin(f.phase*0.7)); else v3.divideScalar(d); const falloff = 1 - d/f.fleeRadius; const strength = 75 * falloff * falloff; accel.addScaledVector(v3, strength); // add a little perpendicular along ray? maybe not. } } }
// vertical no floor? Already.
f.velocity.addScaledVector(accel, dt);
const speed = f.velocity.length();
const limit = f.maxSpeed * 1.6; if (speed > limit) f.velocity.multiplyScalar(limit/speed);
pos.addScaledVector(f.velocity, dt);
if (speed > 0.05) { v1.copy(pos).addScaledVector(f.velocity, 2.5); dummy.position.copy(pos); dummy.lookAt(v1); f.group.quaternion.slerp(dummy.quaternion, 1 - Math.exp(-dt*5)); }
const sw = Math.sin(time*f.tailFreq + f.phase)*(0.55 + Math.min(speed,4)*0.07); f.tailGroup.rotation.y = sw;
// fin animation
for (let i=0;i<f.pectoral.length;i++) { const p=f.pectoral[i]; p.mesh.rotation.x = Math.sin(time*7 + f.phase + i*1.7)*0.25; }
}
But f.pectoral mesh already has rotation.z; updating rotation.x independent okay? Euler order maybe default XYZ, changing x while z? okay. It will flap.
confine:
function confine(pos, accel, axis, margin) {
const min = waterBounds.min[axis]; const max = waterBounds.max[axis];
if (pos[axis] < min + margin) accel[axis] += (min + margin - pos[axis]) * 3.5;
if (pos[axis] > max - margin) accel[axis] -= (pos[axis] - (max - margin)) * 3.5;
}
But axis string 'x' works with Vector3? In JS, v['x'] works, assignment v[axis] +=? Yes Vector3 properties are normal properties. Good.
For min waterBounds.min[axis], returns number. Good.
mouseRay variable: if pointerActive: raycaster.setFromCamera(pointer,camera); const mouseRay = raycaster.ray; else null.
Need use same ray for all fish, update once in animate:
let mouseRay=null;
if (pointerActive) { raycaster.setFromCamera(pointer,camera); mouseRay = raycaster.ray; }
updateFish(..., !!mouseRay, mouseRay) but if no, don't pass.
Halo point: use mouseRay? If pointerActive, raycaster set. v1.copy(center).sub(mouseRay.origin); t=v1.dot(mouseRay.direction); v2.copy(mouseRay.origin).addScaledVector(mouseRay.direction,t); haloGroup.position.copy(v2); if waterBounds expanded? Check inside. Let's create expandBox? Not necessary; halo visible outside tank maybe behind glass. But if pointer at far edge, ray may pass outside bounds. Could clamp to box: v2.x = clamp(v2.x,min.x,max.x), etc. But clamped point not on ray; halo weird. Hide if outside. Use containsPoint on waterBounds expanded 0.5.
const haloBox = waterBounds.clone(); haloBox.expandByScalar(0.8); if (haloBox.containsPoint(v2)) visible else false. Need Box3.expandByScalar. Good.
But pointer influence on fish uses ray not haloBox; fish can flee even if pointer outside tank if ray passes through tank. Good.
Maybe user moves mouse near fish visually: The ray from camera through mouse; if a fish is in that screen region, distance to ray small, repels. Good.
Add camera parallax: Should use pointer after maybe started? If pointerActive false but pointer (0,0), no. Use pointerActive.
baseCam.z maybe 19. With FOV 55 and tank width 17.5, visible? At z=19, tan(27.5)=.52*19=9.9 half height? width 19* aspect maybe. Tank fits? Height 10.5 visible vertical 19.8, okay. width at aspect 1.6 => 31, okay.
Resize: camera.aspect=innerWidth/innerHeight; update.
Renderer setSize after append. Good.
CSS message: maybe title top left, controls bottom right. #start top? The screenshot should show something. Place message at bottom center. Title maybe small.
UI:
#ui { ... justify-content: space-between; }
.top {display:flex; justify-content:space-between;}
.bottom {display:flex; justify-content:space-between; align-items:flex-end;}
#start {margin:0 auto; ... transform? }
Could put start in bottom center using absolute.
Let's set:
#ui{position:fixed; inset:0; pointer-events:none; display:flex; flex-direction:column; justify-content:space-between; padding:18px 22px; }
.hud-top{display:flex; justify-content:space-between; gap:14px;}
.title{font-size:clamp(16px,2vw,24px); ...}
#start{position:absolute; left:50%; bottom:22px; transform:translateX(-50%); ...}
But inside flex absolute okay. pointer-events none. .pill.
#info{...}
CSS hidden {opacity:0; transform:translate(-50%,8px);}
Maybe add FPS? no.
Potential issue #start absolute in #ui pointer-events none, but click window still. Good.
Now, check final for zero errors. Need no unused variables? unused okay. But maybe const dummy = new THREE.Object3D(); dummy.matrixAutoUpdate = false; dummy.lookAt? lookAt uses matrixWorld? It sets quaternion based on position. It may require updateMatrix? No, Object3D.lookAt uses this.position and target; if matrixAutoUpdate false still position available? Yes lookAt uses this.position and object's world position? It computes this.getWorldPosition? Wait Object3D.lookAt implementation: it gets position from object (or world?) In recent Three, lookAt uses this.position? It uses quaternion based on target and position. If matrixWorld not updated? It does not need matrixWorld for local? But if parent none, okay. With matrixAutoUpdate false, lookAt might update quaternion? yes. However it may set rotation order? okay.
Need dummy not added scene. Good.
For f.group.quaternion.slerp, if group.scale changes, quaternion independent.
Potential issue with pectoral fins: Cone default height along y; rotation.z side, updating rotation.x with Euler order XYZ: x rotation applied then y then z? Might alter orientation, but okay.
Potential issue with tailGroup.rotation.y: If tail group rotated, tail cone still vertical flat? Good.
Potential issue fish initial velocities could point backward? Orientation looks to velocity. Good.
Let's maybe add a small "swimming" bob to group position? Already via accel.
Could add fish school? Not required.
Let's think of if fish swim away from mouse: They flee from 3D ray. If user places cursor over fish on screen, distance to ray small, large force. Good.
Let's consider if camera parallax shifts pointer target: If pointer stationary, camera moves slightly, ray changes, fish flee. But subtle.
Maybe use renderer.setAnimationLoop rather than requestAnimationFrame? Either. Use renderer.setAnimationLoop(animate). Good.
In animate, dt if tab hidden? no. Use clock.
Potential issue waterBounds.min[axis] when axis is string; Box3 min is Vector3 object. good.
Potential issue THREE.Box3.expandByScalar exists? Yes. clone? yes.
Potential issue PointsMaterial size with map alpha. okay.
Let's add a second point light for bottom sand? Maybe.
Add caustic moving spotlight? Could be expensive but simple. Use SpotLight? Point enough.
Let's code final with comments perhaps? Comments okay. Need only code block. Oververbosity doesn't matter.
Double-check all braces.
Pseudo full:
<!DOCTYPE html>
<html lang=en>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>3D Aquarium</title>
<style> ... </style>
<script type="importmap"> ... </script>
</head>
<body>
<div id="ui">
<div class="hud-top"><div class="title">Deep Reef Aquarium</div><div class="info">Fish flee from your pointer in 3D · self-playing demo</div></div>
<div id="start" class="pill">Click or press a key to start</div>
</div>
<script type="module">
import * as THREE from 'three';
...
</script>
</body>
</html>
CSS:
html,body {height:100%;}
body{margin:0;overflow:hidden;background:#041018;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#dff5ff;}
canvas{display:block;}
#ui{position:fixed;inset:0;pointer-events:none;display:flex;flex-direction:column;justify-content:space-between;padding:clamp(12px,2vw,22px);}
.hud-top{display:flex;justify-content:space-between;gap:14px;align-items:flex-start;}
.title{font-weight:700;font-size:clamp(15px,2.2vw,24px);text-shadow:0 0 16px rgba(0,220,255,.5);letter-spacing:.08em;opacity:.92}
.info{font-size:clamp(11px,1.3vw,13px);opacity:.6;text-align:right;max-width:45ch;line-height:1.35}
.pill{position:absolute;left:50%;bottom:clamp(14px,4vh,34px);transform:translateX(-50%);padding:10px 18px;border:1px solid rgba(120,230,255,.35);background:rgba(4,20,30,.48);backdrop-filter:blur(6px);border-radius:999px;font-size:clamp(12px,1.6vw,15px);box-shadow:0 0 30px rgba(0,180,255,.12);opacity:1;transition:opacity .7s ease, transform .7s ease;}
.pill.hidden{opacity:0;transform:translate(-50%,10px);pointer-events:none;}
@media (max-width:600px){.hud-top{flex-direction:column}.info{font-size:11px;max-width:none}.pill{bottom:12px}}
</style>
No backdrop-filter maybe not supported but fine.
JS specifics:
function makeCircleTexture(){
const canvas = document.createElement('canvas'); canvas.width=canvas.height=64; const ctx=canvas.getContext('2d');
const grad=ctx.createRadialGradient(32,32,2,32,32,30); grad.addColorStop(0,'rgba(255,255,255,0.95)'); grad.addColorStop(0.35,'rgba(190,245,255,0.55)'); grad.addColorStop(1,'rgba(255,255,255,0)'); ctx.fillStyle=grad; ctx.fillRect(0,0,64,64);
const texture=new THREE.CanvasTexture(canvas); texture.colorSpace=THREE.SRGBColorSpace; return texture;
}
const bubbleTex = makeCircleTexture();
Add edge function:
function addEdge(a,b,r,mat){ const dir=new THREE.Vector3().subVectors(b,a); const length=dir.length(); if(length<0.001) return; const geo=new THREE.CylinderGeometry(r,r,length,8,1); const mesh=new THREE.Mesh(geo,mat); mesh.position.copy(a).addScaledVector(dir,0.5); dir.normalize(); const axis=new THREE.Vector3(0,1,0); mesh.quaternion.setFromUnitVectors(axis,dir); scene.add(mesh); }
But mesh.position.copy(a).addScaledVector(dir,0.5) uses normalized? Need before normalize? We use dir raw; if after normalize? Better:
const dir = new THREE.Vector3().subVectors(b,a); const length=dir.length(); const mid = new THREE.Vector3().addVectors(a,b).multiplyScalar(0.5); const norm=dir.clone().normalize(); mesh.position.copy(mid); mesh.quaternion.setFromUnitVectors(up,norm);
Good.
Add edges function:
function addBoxEdges(cx,cy,cz,w,h,d,r,mat){ const xs=[cx-w/2,cx+w/2]; ... const up? For x edges: for yy in y for zz in z addEdge(new Vector3(xs[0],yy,zz), new Vector3(xs[1],yy,zz), r,mat); etc. }
But this adds duplicate if loop over xs[0] to xs[1] only once. We do for each fixed y,z one edge; 2x2=4. Good. For y edges fixed x,z one edge; 4. For z edges fixed x,y one edge;4. Good.
Add frame:
const outerW=17.5, outerH=10.5, outerD=10.5;
const tankW=16.5, tankH=9.8, tankD=9.8;
frameMat ...
addBoxEdges(0,0,0,outerW,outerH,outerD,0.11,frameMat);
Could add vertical posts thicker:
const postMat = MeshStandard({color:0x0e2432,metalness:.7,roughness:.25});
for xx [-outerW/2,outerW/2] for zz [-outerD/2,outerD/2] { cylinder post radius .16 height outerH position; } But edges already. Add maybe for corners? Could skip.
Add glass box. Edges line? Maybe use EdgesGeometry with cyan lines at outer box. Could make glass less plain. Add:
const edgeLines = new THREE.LineSegments(new THREE.EdgesGeometry(glassGeo), new THREE.LineBasicMaterial({color:0x77f0ff, transparent:true, opacity:.55})); edgeLines.renderOrder=11; scene.add. But frame already. Lines fine.
Water bounds:
const waterBounds = new THREE.Box3(new THREE.Vector3(-tankW/2+1.0, -tankH/2+1.0, -tankD/2+1.0), new THREE.Vector3(tankW/2-1.0, tankH/2-1.3, tankD/2-1.0));
If tankW=16.5, half=8.25, x half=7.25, z=7.25, y min -3.9 max 3.6. Surface at tankH/2-0.4 = 4.5, fish below. Good.
Water volume mesh dims tankW-0.2,tankH-0.2,tankD-0.2.
Sand dims tankW-1,tankD-1 at y=-tankH/2+0.05? Actually bottom inner at -tankH/2+0.1. Fish min -3.9, water bottom -4.8? Wait tankH=9.8, half=4.9, water mesh height 9.6. Sand y=-4.8. Fish min -3.9 margin 0.9. okay.
Add floor/stand:
const floorY=-7.5; floor plane y=floorY.
stand box dims outerW+2,1.4,outerD+2 at y=-outerH/2-0.7? outerH/2=5.25, stand top -4.55, glass bottom -5.25 gap? Maybe stand top should be at bottom outer: y=-outerH/2-0.7 => top -4.55, bottom glass -5.25? gap? Let's set stand center y = -outerH/2 - 0.7 = -5.95, height 1.4, top = -5.25, aligns bottom. Good.
floor y=-6.7.
Add stand renderOrder 0.
Need camera not see stand? okay.
Add lights:
caustic1 = PointLight(...); scene.add(caustic1);
caustic2 = PointLight(...); scene.add(caustic2);
Update in animate:
caustic1.position.set(Math.sin(time*0.35)*6, 3.8, Math.cos(time*0.27)*5);
caustic1.intensity = 28 + Math.sin(time*1.7)*4;
caustic2.position.set(Math.cos(time*0.22)*7, 2.4, Math.sin(time*0.31)*5);
caustic2.intensity = 18 + Math.cos(time*1.2)*3;
PointLight distance and decay: In Three r155+ physical decay default 2. Set decay=2? Use intensity high. Maybe point light intensity 40 too bright. Use exposure 1.1. Let's choose:
Ambient 0.7, Hemi 0.8, Dir 1.4.
Caustic1 25 distance 35 decay 1.8? Decay parameter not in constructor? PointLight(color,intensity,distance,decay). Set decay=1.6 to reduce. Maybe intensity 30. We'll use 35, 24.
If too bright? fine.
Fish materials with emissive small.
Animate update surface:
for i < posAttr.count: x = surfaceBase[i*3]; y = surfaceBase[i*3+1]; z = ...; posAttr.setZ(i,z); posAttr.needsUpdate=true; surfaceGeo.computeVertexNormals();
Potential issue base array is Float32Array of positions, we don't update x/y, only z. Good.
Update bubbles:
const bubblePos = bubbleGeo.attributes.position;
for i:
let x=bubblePos.getX(i), y=bubblePos.getY(i), z=bubblePos.getZ(i);
y += bubbleSpeed[i]*dt;
x += Math.sin(time*1.3 + i)*0.0015;
z += Math.cos(time*1.7 + i*0.7)*0.0015;
if (y > waterBounds.max.y + 0.7) { y = waterBounds.min.y - 0.4; x=THREE.MathUtils.randFloat(waterBounds.min.x,waterBounds.max.x); z=THREE.MathUtils.randFloat(waterBounds.min.z,waterBounds.max.z); }
bubblePos.setXYZ(i,x,y,z);
bubblePos.needsUpdate=true.
Speeds: 0.6+rand*1.2.
Plankton: move very slow, maybe orbit:
for i: angle etc. Could skip. Add tiny points static? Maybe use one Points cloud with positions, animate slight y. Simpler static? Could add but maybe unnecessary. Let's add plankton similar to bubbles but downward, size .05. But performance okay.
Maybe fish are opaque; if renderOrder 0, okay.
Pointer events:
function hideStart(){ if(!started){started=true; document.getElementById('start').classList.add('hidden'); } }
window.addEventListener('pointermove', e=>{ pointer.x=(e.clientX/window.innerWidth)*2-1; pointer.y=-(e.clientY/window.innerHeight)*2+1; pointerActive=true; hideStart(); });
window.addEventListener('pointerdown', e=>{ hideStart(); pointer.x... set pointerActive true; });
window.addEventListener('keydown', e=>{ if(e.key==='Escape') return; hideStart(); });
But moving mouse hides start before screenshot? If no mouse, okay. If automated screenshot no mouse, message remains. Good.
If pointermove on touch, okay.
Should we prevent scroll on touch? body overflow hidden.
Click adds a ripple? Maybe no.
Could set halo color pulsing:
haloGroup.scale.setScalar(1 + Math.sin(time*3)*0.07);
pointerHalo.scale? haloGroup scale. The halo sphere geometry radius 1; we want radius fleeRadius? Could set pointerHalo scale 3? Instead haloGroup contains core radius .28 and halo radius 2.8? Let's create:
pointerHalo = Mesh(Sphere(1,24,16), basic opacity .08); pointerHalo.scale.setScalar(3.2);
pointerCore = Mesh(Sphere(.32,16,16), basic opacity .7); pointerCore.scale.set(1,0.4,1)? maybe disk. Add.
But haloGroup.scale pulse. If pointerHalo radius 3.2, matches. Good.
Need transparent depthWrite false. renderOrder 8.
If haloBox visible. Set haloGroup.visible = pointerActive && haloBox.containsPoint(v2). If hidden, still mouse ray repels. Good.
Potential issue halo sphere too large and visible in all positions; okay.
Let's ensure no external network except import. Good.
Let's think if fish flee from ray: They can swim away from pointer line, but if pointer exactly behind fish (ray passes), they move perpendicular. If mouse moves near, good. If fish near front glass, may push out? Boundary confines.
Maybe fish should also be attracted to center to avoid clumping? Boundary and wander enough. Target random prevents clumping. Good.
Could add separation fish-fish to avoid overlap? Not necessary, but fish might bunch. We can add a simple repulsion between fish for first few? 17 fish pairwise 136, okay. Implement maybe to avoid intersections. But could alter. Simpler skip. But if fish overlap, looks less good. Pairwise separation with radius 0.9*scale? Since group scale. Add in update? Need loop over fish before update or in updateFish with all fish; O(n^2) okay. We can add in animate after computing ray? For each i, j>i if distance < 1.2 + ... push opposite. But velocities? We can add acceleration. Need store all fish, positions. Could do:
for i: for j=i+1: v1.copy(fish[i].group.position).sub(fish[j].group.position); d=length; sep = 1.0 + (scale avg?) Use fish.minSize? We don't store. Could use maxSpeed? Not. Maybe skip for simplicity.
Maybe add slight flocking? Not needed.
But overlapping fish may look weird. Let's add separation with constant. Each fish stores radius = scale*0.9. In update, pairwise before update:
for i=0;i<fishes.length;i++){ const a=fishes[i]; for j=i+1... { const b; v1.copy(a.group.position).sub(b.group.position); const d=v1.length(); const minD = a.radius + b.radius + 0.2; if (d<minD && d>0.001) { v1.divideScalar(d); const force=(minD-d)*2.2; a.accel? accel currently zero but if updateFish later uses accel. We can pre-add to a.accel/b.accel. Need initialize accel zero before pairwise. Or updateFish computes its own. Could do in animate: for each f f.accel.set(0,0,0); then pairwise add; then call updateFish which assumes accel zero? It sets accel.set. Instead we need integrate. Simpler: in updateFish, after other forces? Could do pairwise after target, before boundaries: for f in fishes, for others? That duplicates. But 17 small. Could implement:
// reset all accel first
for(f) f.accel.set(0,0,0);
// pairwise separation add to accel
for...
// call updateFish with dt, time, mouseRay, skip accel reset
Modify updateFish to not reset accel if we pass? Better do in animate:
for (const f of fishes) f.accel.set(0,0,0);
// pair sep
...
for (const f of fishes) updateFish(f, dt, time, mouseRay);
updateFish does not reset accel. But target/wander/boundary add to accel. Good. Let's implement.
Pair separation:
const minD = a.radius + b.radius + 0.35;
const sep = (minD - d) * 2.8;
a.accel.addScaledVector(v1, sep);
b.accel.addScaledVector(v1, -sep);
Need v1 normalized. If d very small, use random. We can use if d>0.01 else set vector from phases.
Store radius = scale*0.8.
Then updateFish:
// no accel set
...
But f.accel used. Fine.
Need call reset before ray? yes.
updateFish order: target/wander then mouse then boundaries then integration.
If f.accel has separation, don't overwrite. Good.
Let's code.
Potential issue: target randomPoint allocates; pair separation uses temp. okay.
Now, let's produce final code. Ensure all functions defined before use. Use top-level code.
Let's draft final code fully:
<script type="module">
import * as THREE from 'three';
// renderer scene camera...
const waterBounds = new THREE.Box3(...);
const haloBox = waterBounds.clone().expandByScalar(1.2);
function makeCircleTexture(){...}
// tank...
// fish...
function randomPoint(margin){ return new THREE.Vector3(...); }
function createFish(hex, scale){...}
function makeFish(i){...}
// pointer halo...
const dummy = new THREE.Object3D(); dummy.matrixAutoUpdate=false;
const v1=new THREE.Vector3(), v2=new THREE.Vector3(), v3=new THREE.Vector3();
function confine(...){}
function updateFish(...){}
// animate
const startEl=document.getElementById('start'); function hideStart...
...
</script>
Need order of const waterBounds: if randomPoint uses. define after tank dims.
Potential error: In makeFish i use palette before. define palette.
Potential error: In updateFish, if mouseRay null. use if (mouseRay) and raycaster.ray? pass ray object. Good.
Potential error: Pair separation resets accel after pointer halo? Need animate order:
function animate(){
dt/time
// camera
raycaster update and mouseRay
// reset accel and pair
for f f.accel.set(0,0,0)
for pairs separation using group.position
// update fish (with mouseRay)
for f updateFish
// surface, bubbles
renderer.render
}
In updateFish, target time uses time. Good.
Potential issue: pair separation uses f.radius; make sure stored.
Potential issue: updateFish target: f.target.copy? We set f.target.set. f.target vector already exists. randomPoint allocates only first. Good.
In makeFish: fish.target = randomPoint(1.2); fish.group.position.copy(randomPoint(1.5)).
In updateFish:
if (time > f.nextTargetTime) {
const m=1.3;
f.target.set(THREE.MathUtils.randFloat(waterBounds.min.x+m, waterBounds.max.x-m), ...);
f.nextTargetTime = time + 5+...;
}
v1.copy(f.target).sub(pos).normalize().multiplyScalar(f.maxSpeed).sub(f.velocity); // if target and pos same? normalize with zero? If d=0 maybe zero vector. target random distinct. okay.
Boundary uses confine after mouse. It modifies accel.
Integration:
f.velocity.addScaledVector(accel, dt);
const speed = f.velocity.length();
const limit = f.maxSpeed * 1.65;
if (speed > limit) f.velocity.multiplyScalar(limit/speed);
// prevent zero? if speed <0.2 add? Could add small forward if too slow? Maybe not.
pos.addScaledVector(f.velocity, dt);
Orientation: if speed > 0.03. v1.copy(pos).addScaledVector(f.velocity,2.5); dummy.position.copy(pos); dummy.up.set(0,1,0); dummy.lookAt(v1); f.group.quaternion.slerp(dummy.quaternion, 1 - Math.exp(-dt*5)); But dummy.up set okay. Could default.
Tail: sw uses speed.
Potential issue group.position updated before dummy.up? yes.
Let's check tailGroup rotation.y: Since group quaternion from lookAt, tailGroup local rotation.y side sway. Good.
Now, maybe need water surface visible from top. camera y=1.2, top y=4.5, looking slightly down. okay.
Glass and water bounds: glass top at 5.25, water top maybe 4.9, surface 4.5. good.
Let's think of the import map: Must include exactly as provided? It says Load Three.js only through this import map: <script ...>. We can include exactly that line. Good.
Final response should only code block. Need no trailing text.
Let's also ensure no use window.alert etc. Good.
Potential issue "Click or press a key to start" with pointer-events none. If the iframe only receives keyboard after user clicks it, but message instructs. Our event listener on window keydown works after click. Good.
Potential issue screenshot at 3 sec: message visible? If no pointermove, yes. But our pointerActive false. Fish moving. Good.
Potential issue renderer clear and scene.fog: If fog color dark, distant floor may vanish. good.
Let's finalize.
Wait: In Three.js, PointsMaterial with map and blending Additive but transparent, opacity. Points may render as squares if alpha from map. okay.
Bubble positions initial: use waterBounds min/max. Good.
Add plankton maybe not necessary but we can. If add, update. Let's include small plankton for ambience:
const planktonCount=500; positions; update similar with slow drift. But more code. Could omit to reduce. Bubbles enough.
Add some seaweed. Need plants animate. Let's implement simple plants:
const plantGroup = new THREE.Group();
const plantMat = new THREE.MeshStandardMaterial({color:0x2bb373, roughness:.75, side:THREE.DoubleSide, emissive:0x03291a?});
for i=0..9: height=2+rand*2.5; geo = ConeGeometry(0.12,height,5); plant=Mesh; plant.position.set(random x, bottom, random z); plant.rotation.z small; plantGroup.add.
But cone straight, no branches. Maybe add leaves planes? Could use a tapered leaf via ShapeGeometry? Maybe too much. Simple cone looks like kelp. Add slight scale x .1 to flatten? For kelp ribbon: use CylinderGeometry? Actually a flat ribbon: PlaneGeometry(0.3,height,1,6) and bend vertices; material double. Could use ConeGeometry radius 0.15 height. We can animate plant rotation.z. Place in corners. Good.
Need not too many.
Add rocks.
Maybe fish swimming through plants. Good.
Implement plants:
const plants=[];
for i<12: const h = 2+Math.random()*2.8; const g=new THREE.ConeGeometry(0.16,h,6,3); const m=new THREE.Mesh(g, new THREE.MeshStandardMaterial({color:new THREE.Color().setHSL(0.33+Math.random()*0.08,0.6,0.28+Math.random()*0.15), roughness:0.75})); m.position.set(rand, bottomY+h/2, rand); m.rotation.y=rand*PI; m.scale.x=0.25 + rand*0.25; plants.push(m); scene.add; In animate: m.rotation.z = Math.sin(time*0.8 + i)*0.1; maybe plant.rotation.z? But plant group local rotation z with base. Use base rotation stored. m.userData.base = ...
Could simply add plants and animate rotation.z = Math.sin(time*1.2+i)*0.08. They are upright with rotation z; base 0.
Add rocks: Dodecahedron random.
This improves visuals.
Need ensure plants not too close center? Can random entire bottom. Good.
Potential issue plant cone intersects fish? okay.
Now, code.
Double-check water surface plane dimensions: innerW=16.5-? If surface at top and fish bounds max y -3.6, no interaction. Good.
Potential issue camera background: no skybox. Could have glass box transparent show fog color and floor. okay.
Let's final.