Interactive 3D aquariumOne-line
DeepSeek V4.1 Flash 1017
by DeepSeekReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 2 min 50 s
- Cost
- $0.040
- Output tokens
- 55,271
- Spent thinking
- 44,601 tokens
- Speed
- 325.7 tok/s
- Console errors
- 0
- First token after
- 605 ms
- First visible text after
- 2 min 22 s
- Prompt tokens
- 460
- Code size
- 30.7 KB
- Finish reason
- stop
- Served by
- DigitalOcean
- Model id
deepseek/deepseek-v4.1-flash-1017- Reasoning sent
{"effort":"max"}- 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 · 889 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Deep Reef — a three.js aquarium</title>
<style>
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; height: 100%; overflow: hidden;
background: #03101c;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: #d8f2ff;
-webkit-font-smoothing: antialiased;
user-select: none;
cursor: crosshair;
}
canvas { position: fixed; inset: 0; display: block; z-index: 1; }
#vignette {
position: fixed; inset: 0; z-index: 2; pointer-events: none;
background:
radial-gradient(125% 95% at 50% 42%, rgba(0,0,0,0) 32%, rgba(1,9,18,.78) 100%),
linear-gradient(180deg, rgba(20,110,160,.18), rgba(0,0,0,0) 35%);
}
#hud {
position: fixed; inset: 0; z-index: 3; pointer-events: none;
display: flex; flex-direction: column; justify-content: space-between;
padding: clamp(14px, 2.2vw, 30px);
animation: fadein 1.2s ease both;
}
header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.brand {
font-size: clamp(12px, 1.35vw, 17px);
letter-spacing: .44em; font-weight: 600; text-transform: uppercase;
color: #eafaff;
text-shadow: 0 0 26px rgba(90,200,255,.55);
}
.sub {
margin-top: 7px; font-size: 10px; letter-spacing: .26em;
text-transform: uppercase; color: #7fc3e4; opacity: .85;
}
.badge {
display: flex; align-items: center; gap: 8px;
font-size: 10px; letter-spacing: .22em; text-transform: uppercase;
color: #a9e2ff;
border: 1px solid rgba(130,215,255,.28);
background: rgba(6,32,52,.42);
-webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
padding: 7px 12px; border-radius: 999px; white-space: nowrap;
}
.dot {
width: 6px; height: 6px; border-radius: 50%;
background: #5fe0ff; box-shadow: 0 0 10px #5fe0ff;
animation: pulse 1.8s ease-in-out infinite;
}
footer { display: flex; justify-content: center; padding-bottom: 4px; }
.pill {
display: flex; align-items: center; gap: 10px;
font-size: 12.5px; letter-spacing: .05em;
color: #cfeeff;
background: rgba(6,30,50,.42);
border: 1px solid rgba(140,220,255,.22);
-webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
padding: 10px 18px; border-radius: 999px;
box-shadow: 0 10px 40px rgba(0,0,0,.28), inset 0 1px 0 rgba(255,255,255,.07);
}
.pin {
width: 7px; height: 7px; border-radius: 50%;
background: #8affd0; box-shadow: 0 0 12px #8affd0;
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse { 0%,100% { opacity: .35; transform: scale(.8); } 50% { opacity: 1; transform: scale(1.2); } }
@keyframes fadein { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
@media (max-width: 640px) {
.sub { display: none; }
.brand { letter-spacing: .32em; }
.pill { font-size: 11px; padding: 9px 13px; letter-spacing: .02em; }
.badge { font-size: 9px; padding: 6px 9px; letter-spacing: .16em; }
}
</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="vignette"></div>
<div id="hud">
<header>
<div>
<div class="brand">Deep Reef</div>
<div class="sub">an interactive three.js aquarium</div>
</div>
<div class="badge"><span class="dot" id="dot"></span><span id="badgeText">auto demo</span></div>
</header>
<footer>
<div class="pill"><span class="pin"></span><span id="hintText">Move your cursor through the water — the fish will scatter</span></div>
</footer>
</div>
<script type="module">
import * as THREE from 'three';
/* ------------------------------------------------------------------ */
/* helpers */
/* ------------------------------------------------------------------ */
const clamp = (v, a, b) => Math.min(Math.max(v, a), b);
const rand = (a, b) => a + Math.random() * (b - a);
/* ------------------------------------------------------------------ */
/* renderer */
/* ------------------------------------------------------------------ */
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);
/* ------------------------------------------------------------------ */
/* scene + camera */
/* ------------------------------------------------------------------ */
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x07314c, 0.019);
const camera = new THREE.PerspectiveCamera(52, window.innerWidth / window.innerHeight, 0.1, 600);
camera.position.set(0, 2.4, 15);
const uTime = { value: 0 };
let time = 0;
let TANK_X = 10;
const decor = [];
function layoutDecor() {
const r = clamp(TANK_X / 10, 0.42, 1.05);
for (let i = 0; i < decor.length; i++) {
decor[i].m.position.x = decor[i].bx * r;
}
}
function layoutCamera() {
const w = window.innerWidth, h = window.innerHeight;
const aspect = w / h;
camera.aspect = aspect;
camera.position.z = clamp(14.5 * Math.sqrt(1.6 / aspect), 12, 22);
camera.updateProjectionMatrix();
const halfW = camera.position.z * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * aspect;
TANK_X = clamp(halfW * 0.88, 3.0, 10.5);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(w, h);
layoutDecor();
}
layoutCamera();
/* ------------------------------------------------------------------ */
/* lights */
/* ------------------------------------------------------------------ */
scene.add(new THREE.HemisphereLight(0x9fe8ff, 0x08202e, 2.1));
const sun = new THREE.DirectionalLight(0xd8f7ff, 2.6);
sun.position.set(6, 22, 10);
scene.add(sun);
const rimA = new THREE.PointLight(0x2f9dff, 300, 80, 2);
rimA.position.set(-15, 6, 10);
scene.add(rimA);
const rimB = new THREE.PointLight(0x22e0b0, 200, 60, 2);
rimB.position.set(14, 1, -6);
scene.add(rimB);
/* ------------------------------------------------------------------ */
/* background gradient dome */
/* ------------------------------------------------------------------ */
const backdrop = new THREE.Mesh(
new THREE.SphereGeometry(200, 32, 24),
new THREE.ShaderMaterial({
side: THREE.BackSide,
depthWrite: false,
uniforms: {
uTop: { value: new THREE.Color(0x0d5f86) },
uMid: { value: new THREE.Color(0x063853) },
uBot: { value: new THREE.Color(0x01101c) }
},
vertexShader: `
varying vec3 vP;
void main(){
vP = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `
varying vec3 vP;
uniform vec3 uTop; uniform vec3 uMid; uniform vec3 uBot;
void main(){
vec3 d = normalize(vP);
vec3 c = mix(uBot, uMid, smoothstep(-0.75, 0.05, d.y));
c = mix(c, uTop, smoothstep(0.0, 0.85, d.y));
float glow = pow(max(dot(d, normalize(vec3(0.10, 0.90, -0.25))), 0.0), 6.0);
c += vec3(0.16, 0.40, 0.48) * glow;
gl_FragColor = vec4(c, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`
})
);
scene.add(backdrop);
/* ------------------------------------------------------------------ */
/* caustics shader chunk (shared) */
/* ------------------------------------------------------------------ */
const CAUSTIC_GLSL = `
float causticPattern(vec2 p, float t){
vec2 q = p;
float v = 0.0;
for (int i = 0; i < 3; i++){
float fi = float(i);
q = q * 1.63 + vec2(1.7, 2.3);
v += sin(q.x + t * (0.9 + fi * 0.21)) * cos(q.y - t * (0.7 + fi * 0.17));
}
v /= 3.0;
return pow(max(1.0 - abs(v), 0.0), 8.0);
}
`;
/* ------------------------------------------------------------------ */
/* sand floor */
/* ------------------------------------------------------------------ */
const floorMat = new THREE.MeshStandardMaterial({ color: 0x1b3f53, roughness: 1.0, metalness: 0.0 });
floorMat.onBeforeCompile = (shader) => {
shader.uniforms.uTime = uTime;
shader.vertexShader = shader.vertexShader
.replace('#include <common>', '#include <common>\nvarying vec3 vWorldPos;')
.replace('#include <begin_vertex>', '#include <begin_vertex>\n vWorldPos = (modelMatrix * vec4(transformed, 1.0)).xyz;');
shader.fragmentShader = shader.fragmentShader
.replace('#include <common>', '#include <common>\nvarying vec3 vWorldPos;\nuniform float uTime;\n' + CAUSTIC_GLSL)
.replace('#include <color_fragment>', `#include <color_fragment>
float cs = causticPattern(vWorldPos.xz * 0.35, uTime);
float fade = 1.0 - smoothstep(16.0, 50.0, length(vWorldPos.xz));
diffuseColor.rgb += vec3(0.34, 0.70, 0.84) * cs * 1.5 * fade;
float sand = sin(vWorldPos.x * 0.23 + sin(vWorldPos.z * 0.19) * 2.4) * 0.5 + 0.5;
diffuseColor.rgb *= 0.84 + sand * 0.32;
`);
};
floorMat.customProgramCacheKey = () => 'floor-caustics';
const floorGeo = new THREE.PlaneGeometry(220, 220, 1, 1);
floorGeo.rotateX(-Math.PI / 2);
const floor = new THREE.Mesh(floorGeo, floorMat);
floor.position.y = -3;
scene.add(floor);
/* ------------------------------------------------------------------ */
/* rocks */
/* ------------------------------------------------------------------ */
const rockGeo = new THREE.IcosahedronGeometry(1, 1);
{
const p = rockGeo.attributes.position;
const v = new THREE.Vector3();
for (let i = 0; i < p.count; i++) {
v.fromBufferAttribute(p, i);
const n = v.clone().normalize();
const k = 0.80 + Math.sin(v.x * 3.1) * 0.11 + Math.cos(v.y * 2.7) * 0.10 + Math.sin(v.z * 4.3) * 0.08;
v.copy(n).multiplyScalar(k);
p.setXYZ(i, v.x, v.y, v.z);
}
rockGeo.computeVertexNormals();
}
const rockMat = new THREE.MeshStandardMaterial({ color: 0x123443, roughness: 0.95, metalness: 0.05, flatShading: true });
const rockSpots = [
[-9.8, -4.2, 1.9], [-11.6, 1.4, 2.4], [-6.4, -6.6, 1.2], [-3.2, -8.0, 1.7],
[9.2, -3.4, 2.0], [11.6, 2.6, 1.6], [6.8, -7.2, 1.1], [2.2, -8.6, 2.5],
[0.4, -7.6, 1.0], [-1.2, -6.2, 0.8], [4.4, 5.8, 1.3]
];
for (const [x, z, s] of rockSpots) {
const m = new THREE.Mesh(rockGeo, rockMat);
m.position.set(x, -3 + s * 0.32, z);
m.scale.set(s * 1.25, s * 0.78, s * 1.1);
m.rotation.set(rand(-0.2, 0.2), rand(0, Math.PI * 2), rand(-0.2, 0.2));
scene.add(m);
decor.push({ m, bx: x });
}
/* ------------------------------------------------------------------ */
/* kelp */
/* ------------------------------------------------------------------ */
const kelpGeo = new THREE.PlaneGeometry(1, 1, 1, 10);
kelpGeo.translate(0, 0.5, 0);
const kelpMats = [];
for (let i = 0; i < 4; i++) {
const mat = new THREE.MeshStandardMaterial({
color: new THREE.Color().setHSL(0.37 + Math.random() * 0.07, 0.5, 0.20 + i * 0.02),
roughness: 0.92,
metalness: 0.0,
side: THREE.DoubleSide,
emissive: new THREE.Color(0x081f16),
emissiveIntensity: 0.8
});
mat.onBeforeCompile = (shader) => {
shader.uniforms.uTime = uTime;
shader.uniforms.uPhase = { value: Math.random() * 12.0 };
shader.vertexShader = 'uniform float uTime;\nuniform float uPhase;\n' + shader.vertexShader.replace(
'#include <begin_vertex>',
`#include <begin_vertex>
float bb = clamp(position.y, 0.0, 1.0);
float amp = bb * bb;
transformed.x += (sin(uTime * 1.05 + uPhase + bb * 2.0) * 0.55
+ sin(uTime * 2.35 + uPhase * 1.7 + bb * 4.5) * 0.16) * amp * 0.75;
`
);
};
mat.customProgramCacheKey = () => 'kelp-sway';
kelpMats.push(mat);
}
const kelpClusters = [
{ x: -10.2, z: -2.0, n: 10 }, { x: -8.4, z: 4.2, n: 7 },
{ x: 9.6, z: -1.6, n: 10 }, { x: 11.0, z: 4.6, n: 7 },
{ x: -1.4, z: -8.4, n: 6 }, { x: 5.2, z: -5.4, n: 5 }
];
for (const c of kelpClusters) {
for (let i = 0; i < c.n; i++) {
const m = new THREE.Mesh(kelpGeo, kelpMats[(Math.random() * kelpMats.length) | 0]);
const h = rand(2.6, 6.6);
const w = rand(0.35, 0.85);
m.scale.set(w, h, 1);
const px = c.x + rand(-1.8, 1.8);
m.position.set(px, -3, c.z + rand(-1.6, 1.6));
m.rotation.y = rand(0, Math.PI * 2);
m.rotation.z = rand(-0.12, 0.12);
scene.add(m);
decor.push({ m, bx: px });
}
}
/* ------------------------------------------------------------------ */
/* water surface (seen from below) */
/* ------------------------------------------------------------------ */
const surfaceMat = new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
side: THREE.DoubleSide,
blending: THREE.AdditiveBlending,
uniforms: { uTime },
vertexShader: `
varying vec2 vUv;
void main(){
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `
varying vec2 vUv;
uniform float uTime;
${CAUSTIC_GLSL}
void main(){
vec2 p = vUv * 30.0;
float c = causticPattern(p, uTime * 0.85);
float e = smoothstep(0.0, 0.18, vUv.x) * smoothstep(1.0, 0.82, vUv.x)
* smoothstep(0.0, 0.18, vUv.y) * smoothstep(1.0, 0.82, vUv.y);
vec3 col = mix(vec3(0.02, 0.14, 0.24), vec3(0.62, 0.95, 1.0), c);
gl_FragColor = vec4(col * (0.25 + c * 1.6), (0.18 + c * 0.8) * e);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`
});
const surface = new THREE.Mesh(new THREE.PlaneGeometry(130, 130, 1, 1), surfaceMat);
surface.rotation.x = Math.PI / 2;
surface.position.y = 7.2;
scene.add(surface);
/* ------------------------------------------------------------------ */
/* fish geometry */
/* ------------------------------------------------------------------ */
function makeFishGeometry() {
const positions = [];
const indices = [];
const RINGS = 20, SEG = 10;
const L = 2.0, R0 = 0.44;
const ringStart = [];
for (let i = 0; i <= RINGS; i++) {
const t = i / RINGS;
const x = (0.5 - t) * L;
const prof = Math.pow(Math.sin(Math.PI * Math.pow(t, 0.62)), 0.85);
const r = Math.max(R0 * prof, 0.012);
const hScale = 1.0 + 0.22 * Math.sin(Math.PI * t);
ringStart.push(positions.length / 3);
for (let j = 0; j < SEG; j++) {
const a = (j / SEG) * Math.PI * 2;
positions.push(x, Math.cos(a) * r * 1.30 * hScale, Math.sin(a) * r * 0.68);
}
}
for (let i = 0; i < RINGS; i++) {
const a0 = ringStart[i], b0 = ringStart[i + 1];
for (let j = 0; j < SEG; j++) {
const j2 = (j + 1) % SEG;
indices.push(a0 + j, b0 + j, a0 + j2);
indices.push(a0 + j2, b0 + j, b0 + j2);
}
}
const tri = (ax, ay, az, bx, by, bz, cx, cy, cz) => {
const base = positions.length / 3;
positions.push(ax, ay, az, bx, by, bz, cx, cy, cz);
indices.push(base, base + 1, base + 2);
};
// forked tail fin
tri(-0.88, 0.00, 0.00, -1.64, 0.68, 0.00, -1.20, 0.02, 0.00);
tri(-0.88, 0.00, 0.00, -1.20, 0.02, 0.00, -1.64, -0.68, 0.00);
// dorsal fin
tri(0.34, 0.22, 0.00, -0.06, 1.04, 0.00, -0.56, 0.22, 0.00);
// anal fin
tri(-0.22, -0.22, 0.00, -0.60, -0.74, 0.00, -0.80, -0.16, 0.00);
// little pectoral fin
tri(0.36, -0.04, 0.14, 0.02, -0.34, 0.44, 0.16, 0.06, 0.20);
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
g.setIndex(indices);
g.computeVertexNormals();
return g;
}
const fishGeo = makeFishGeometry();
function makeFishMaterial(color) {
const mat = new THREE.MeshStandardMaterial({
color,
roughness: 0.42,
metalness: 0.28,
side: THREE.DoubleSide,
emissive: color.clone().multiplyScalar(0.14),
emissiveIntensity: 1.0
});
mat.onBeforeCompile = (shader) => {
shader.uniforms.uTime = uTime;
shader.uniforms.uPhase = { value: Math.random() * Math.PI * 2 };
shader.uniforms.uFreq = { value: 6.0 };
shader.uniforms.uAmp = { value: 0.24 };
shader.vertexShader = `
uniform float uTime;
uniform float uPhase;
uniform float uFreq;
uniform float uAmp;
` + shader.vertexShader.replace(
'#include <begin_vertex>',
`#include <begin_vertex>
float backAmt = 1.0 - smoothstep(-1.7, 0.45, transformed.x);
float w = sin(uTime * uFreq + uPhase - transformed.x * 2.2);
transformed.z += w * backAmt * uAmp;
`
);
mat.userData.shader = shader;
};
mat.customProgramCacheKey = () => 'fish-sway';
return mat;
}
/* ------------------------------------------------------------------ */
/* fish school */
/* ------------------------------------------------------------------ */
const PALETTE = [0xff7b3d, 0xffc247, 0x4fe3ff, 0xff6fc6, 0x9be36a, 0x7f9cff, 0xff5a5a, 0x63ffd4];
const fishes = [];
const FISH_COUNT = 46;
const FLEE_R = 4.6;
for (let i = 0; i < FISH_COUNT; i++) {
const color = new THREE.Color(PALETTE[i % PALETTE.length]);
color.offsetHSL((Math.random() - 0.5) * 0.06, (Math.random() - 0.5) * 0.16, (Math.random() - 0.5) * 0.09);
const mat = makeFishMaterial(color);
const mesh = new THREE.Mesh(fishGeo, mat);
const s = rand(0.55, 1.18);
mesh.scale.setScalar(s);
scene.add(mesh);
const pos = new THREE.Vector3(
rand(-TANK_X * 0.85, TANK_X * 0.85),
rand(-1.4, 5.2),
rand(-5.2, 5.2)
);
mesh.position.copy(pos);
const vel = new THREE.Vector3(rand(-1, 1), rand(-0.2, 0.2), rand(-1, 1)).normalize().multiplyScalar(1.4);
fishes.push({
mesh, mat, pos, vel,
target: pos.clone(),
timer: Math.random() * 4,
speed: rand(1.1, 2.2) * (1.15 - s * 0.25)
});
}
/* ------------------------------------------------------------------ */
/* particles (bubbles + marine snow) */
/* ------------------------------------------------------------------ */
function makeDotTexture(hard) {
const s = 64;
const c = document.createElement('canvas');
c.width = c.height = s;
const ctx = c.getContext('2d');
const g = ctx.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
if (hard) {
g.addColorStop(0.0, 'rgba(255,255,255,1)');
g.addColorStop(0.42, 'rgba(220,245,255,0.85)');
g.addColorStop(0.78, 'rgba(170,225,255,0.22)');
g.addColorStop(1.0, 'rgba(170,225,255,0)');
} else {
g.addColorStop(0.0, 'rgba(255,255,255,1)');
g.addColorStop(0.6, 'rgba(190,230,255,0.5)');
g.addColorStop(1.0, 'rgba(190,230,255,0)');
}
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(s / 2, s / 2, s / 2, 0, Math.PI * 2);
ctx.fill();
const t = new THREE.CanvasTexture(c);
t.colorSpace = THREE.SRGBColorSpace;
return t;
}
function makeGlowTexture() {
const s = 128;
const c = document.createElement('canvas');
c.width = c.height = s;
const ctx = c.getContext('2d');
const g = ctx.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
g.addColorStop(0.0, 'rgba(230,252,255,0.95)');
g.addColorStop(0.22, 'rgba(130,220,255,0.5)');
g.addColorStop(0.55, 'rgba(60,160,235,0.14)');
g.addColorStop(1.0, 'rgba(30,120,200,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, s, s);
const t = new THREE.CanvasTexture(c);
t.colorSpace = THREE.SRGBColorSpace;
return t;
}
function makeRingTexture() {
const s = 128;
const c = document.createElement('canvas');
c.width = c.height = s;
const ctx = c.getContext('2d');
ctx.shadowBlur = 14;
ctx.shadowColor = 'rgba(150,230,255,0.95)';
ctx.strokeStyle = 'rgba(190,240,255,0.95)';
ctx.lineWidth = 5;
ctx.beginPath();
ctx.arc(s / 2, s / 2, s / 2 - 12, 0, Math.PI * 2);
ctx.stroke();
const t = new THREE.CanvasTexture(c);
t.colorSpace = THREE.SRGBColorSpace;
return t;
}
const dotTex = makeDotTexture(true);
const softTex = makeDotTexture(false);
// bubbles
const BUBBLES = 170;
const bubbleArr = new Float32Array(BUBBLES * 3);
const bubbleMeta = [];
for (let i = 0; i < BUBBLES; i++) {
bubbleArr[i * 3 + 0] = rand(-13, 13);
bubbleArr[i * 3 + 1] = rand(-3, 7.2);
bubbleArr[i * 3 + 2] = rand(-8, 6);
bubbleMeta.push({ sp: rand(0.35, 1.15), ph: rand(0, 6.28), amp: rand(0.15, 0.5) });
}
const bubbleGeo = new THREE.BufferGeometry();
bubbleGeo.setAttribute('position', new THREE.BufferAttribute(bubbleArr, 3));
const bubbleMat = new THREE.PointsMaterial({
size: 0.26, map: dotTex, transparent: true, opacity: 0.62,
blending: THREE.AdditiveBlending, depthWrite: false,
color: 0xbfefff, sizeAttenuation: true, toneMapped: false
});
scene.add(new THREE.Points(bubbleGeo, bubbleMat));
const bubbleAttr = bubbleGeo.attributes.position;
// marine snow
const SNOW = 520;
const snowArr = new Float32Array(SNOW * 3);
const snowMeta = [];
for (let i = 0; i < SNOW; i++) {
snowArr[i * 3 + 0] = rand(-16, 16);
snowArr[i * 3 + 1] = rand(-3, 8);
snowArr[i * 3 + 2] = rand(-10, 8);
snowMeta.push({ sp: rand(0.05, 0.22), ph: rand(0, 6.28) });
}
const snowGeo = new THREE.BufferGeometry();
snowGeo.setAttribute('position', new THREE.BufferAttribute(snowArr, 3));
const snowMat = new THREE.PointsMaterial({
size: 0.075, map: softTex, transparent: true, opacity: 0.4,
blending: THREE.AdditiveBlending, depthWrite: false,
color: 0xa8d8ee, sizeAttenuation: true, toneMapped: false
});
scene.add(new THREE.Points(snowGeo, snowMat));
const snowAttr = snowGeo.attributes.position;
/* ------------------------------------------------------------------ */
/* cursor "hand" */
/* ------------------------------------------------------------------ */
const glowSprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: makeGlowTexture(), color: 0xcaf3ff, transparent: true,
blending: THREE.AdditiveBlending, depthWrite: false, depthTest: false,
opacity: 0.9, toneMapped: false
}));
glowSprite.scale.set(2.6, 2.6, 1);
glowSprite.renderOrder = 20;
scene.add(glowSprite);
const ringSprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: makeRingTexture(), color: 0x9fe6ff, transparent: true,
blending: THREE.AdditiveBlending, depthWrite: false, depthTest: false,
opacity: 0.4, toneMapped: false
}));
ringSprite.renderOrder = 21;
scene.add(ringSprite);
/* ------------------------------------------------------------------ */
/* pointer */
/* ------------------------------------------------------------------ */
const pointerNDC = new THREE.Vector2(0, 0);
const raycaster = new THREE.Raycaster();
const handPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
const handTarget = new THREE.Vector3(0, 2, 0);
const handPos = new THREE.Vector3(0, 2, 0);
const tmpRay = new THREE.Vector3();
let realPointer = false;
function setRealPointer() {
if (realPointer) return;
realPointer = true;
const bt = document.getElementById('badgeText');
const dt = document.getElementById('dot');
if (bt) bt.textContent = 'live';
if (dt) {
dt.style.background = '#8affd0';
dt.style.boxShadow = '0 0 10px #8affd0';
}
}
window.addEventListener('pointermove', (e) => {
setRealPointer();
pointerNDC.x = (e.clientX / window.innerWidth) * 2 - 1;
pointerNDC.y = -(e.clientY / window.innerHeight) * 2 + 1;
}, { passive: true });
window.addEventListener('pointerdown', (e) => {
setRealPointer();
pointerNDC.x = (e.clientX / window.innerWidth) * 2 - 1;
pointerNDC.y = -(e.clientY / window.innerHeight) * 2 + 1;
}, { passive: true });
function updatePointer(dt) {
if (!realPointer) {
// gentle auto-demo path — fish are chased around before you touch anything
pointerNDC.x = Math.sin(time * 0.55) * 0.74 + Math.sin(time * 1.31 + 1.0) * 0.14;
pointerNDC.y = Math.cos(time * 0.37) * 0.42 + Math.sin(time * 0.93) * 0.13;
}
raycaster.setFromCamera(pointerNDC, camera);
if (raycaster.ray.intersectPlane(handPlane, tmpRay)) {
handTarget.copy(tmpRay);
}
handTarget.x = clamp(handTarget.x, -(TANK_X + 2.5), TANK_X + 2.5);
handTarget.y = clamp(handTarget.y, -2.6, 7.4);
handTarget.z = 0;
handPos.lerp(handTarget, 1 - Math.exp(-dt * 11));
}
/* ------------------------------------------------------------------ */
/* fish update */
/* ------------------------------------------------------------------ */
const _v1 = new THREE.Vector3();
const _v2 = new THREE.Vector3();
const _q = new THREE.Quaternion();
const _m = new THREE.Matrix4();
const _ax = new THREE.Vector3();
const _ay = new THREE.Vector3();
const _az = new THREE.Vector3();
const _up = new THREE.Vector3();
const BOUND_Y0 = -2.3;
const BOUND_Y1 = 6.0;
const BOUND_Z = 5.8;
function updateFish(dt) {
const BX = TANK_X;
const margin = 1.7;
for (let i = 0; i < fishes.length; i++) {
const f = fishes[i];
/* wander target */
f.timer -= dt;
if (f.timer <= 0) {
f.timer = rand(2.0, 6.5);
f.target.set(
rand(-BX * 0.92, BX * 0.92),
rand(BOUND_Y0 + 0.4, BOUND_Y1 - 0.4),
rand(-BOUND_Z * 0.9, BOUND_Z * 0.9)
);
}
const desired = _v1.set(0, 0, 0);
/* seek wander target */
_v2.subVectors(f.target, f.pos);
const dT = _v2.length();
if (dT > 0.01) {
const sp = f.speed * Math.min(1, dT / 3.0);
desired.addScaledVector(_v2, sp / dT);
}
/* flee from the cursor */
let fleeing = false;
_v2.subVectors(f.pos, handPos);
const dHand = Math.sqrt(_v2.x * _v2.x + _v2.y * _v2.y + (_v2.z * 0.45) * (_v2.z * 0.45));
if (dHand < FLEE_R) {
const k = 1 - dHand / FLEE_R;
if (_v2.lengthSq() < 0.0001) _v2.set(0, 1, 0);
_v2.normalize();
desired.addScaledVector(_v2, f.speed * 5.5 * k * k);
fleeing = true;
}
/* tank boundaries */
const lx = -BX + margin, hx = BX - margin;
if (f.pos.x < lx) desired.x += (lx - f.pos.x) * 3.2;
if (f.pos.x > hx) desired.x -= (f.pos.x - hx) * 3.2;
if (f.pos.y < BOUND_Y0 + margin * 0.6) desired.y += (BOUND_Y0 + margin * 0.6 - f.pos.y) * 3.2;
if (f.pos.y > BOUND_Y1 - margin * 0.6) desired.y -= (f.pos.y - (BOUND_Y1 - margin * 0.6)) * 3.2;
if (f.pos.z < -BOUND_Z + margin) desired.z += (-BOUND_Z + margin - f.pos.z) * 3.2;
if (f.pos.z > BOUND_Z - margin) desired.z -= (f.pos.z - (BOUND_Z - margin)) * 3.2;
/* separation */
for (let j = 0; j < fishes.length; j++) {
if (j === i) continue;
const o = fishes[j];
const dx = f.pos.x - o.pos.x;
const dy = f.pos.y - o.pos.y;
const dz = f.pos.z - o.pos.z;
const d2 = dx * dx + dy * dy + dz * dz;
if (d2 < 2.4 && d2 > 0.0004) {
const d = Math.sqrt(d2);
const push = (1.55 - d) / 1.55;
const inv = push * 2.0 / d;
desired.x += dx * inv;
desired.y += dy * inv * 0.7;
desired.z += dz * inv;
}
}
/* clamp desired speed */
const maxD = f.speed * (fleeing ? 4.6 : 1.9);
const dl = desired.length();
if (dl > maxD) desired.multiplyScalar(maxD / dl);
/* integrate */
f.vel.lerp(desired, 1 - Math.exp(-dt * (fleeing ? 10.0 : 2.6)));
f.pos.addScaledVector(f.vel, dt);
f.pos.x = clamp(f.pos.x, -BX, BX);
f.pos.y = clamp(f.pos.y, BOUND_Y0, BOUND_Y1);
f.pos.z = clamp(f.pos.z, -BOUND_Z, BOUND_Z);
f.mesh.position.copy(f.pos);
/* orient toward velocity */
const vlen = f.vel.length();
if (vlen > 0.05) {
_ax.copy(f.vel).multiplyScalar(1 / vlen);
_up.set(0, 1, 0);
if (Math.abs(_ax.y) > 0.94) _up.set(0, 0, 1);
_az.crossVectors(_ax, _up).normalize();
if (_az.lengthSq() < 0.0001) _az.set(0, 0, 1);
_ay.crossVectors(_az, _ax).normalize();
_m.makeBasis(_ax, _ay, _az);
_q.setFromRotationMatrix(_m);
f.mesh.quaternion.slerp(_q, 1 - Math.exp(-dt * 7.5));
}
/* tail beat follows speed */
const sh = f.mat.userData.shader;
if (sh) sh.uniforms.uFreq.value = 4.5 + vlen * 2.4;
}
}
/* ------------------------------------------------------------------ */
/* particles update */
/* ------------------------------------------------------------------ */
function updateParticles(dt) {
const b = bubbleAttr.array;
for (let i = 0; i < BUBBLES; i++) {
const m = bubbleMeta[i];
let y = b[i * 3 + 1] + m.sp * dt;
let x = b[i * 3 + 0] + Math.sin(time * 1.6 + m.ph) * m.amp * dt;
if (y > 7.3) {
y = -3;
x = rand(-13, 13);
b[i * 3 + 2] = rand(-8, 6);
}
b[i * 3 + 0] = x;
b[i * 3 + 1] = y;
}
bubbleAttr.needsUpdate = true;
const s = snowAttr.array;
for (let i = 0; i < SNOW; i++) {
const m = snowMeta[i];
let y = s[i * 3 + 1] + m.sp * dt;
let x = s[i * 3 + 0] + Math.sin(time * 0.5 + m.ph) * 0.06 * dt;
if (y > 8.2) {
y = -3;
x = rand(-16, 16);
s[i * 3 + 2] = rand(-10, 8);
}
s[i * 3 + 0] = x;
s[i * 3 + 1] = y;
}
snowAttr.needsUpdate = true;
}
/* ------------------------------------------------------------------ */
/* decoration layout after camera fit */
/* ------------------------------------------------------------------ */
layoutDecor();
/* ------------------------------------------------------------------ */
/* loop */
/* ------------------------------------------------------------------ */
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
time += dt;
uTime.value = time;
/* slow drifting camera */
camera.position.x = Math.sin(time * 0.07) * 1.1;
camera.position.y = 2.4 + Math.sin(time * 0.11) * 0.28;
camera.lookAt(0, 1.7, 0);
updatePointer(dt);
updateFish(dt);
updateParticles(dt);
/* cursor orb */
const pulse = 1 + Math.sin(time * 3.2) * 0.07;
glowSprite.position.copy(handPos);
glowSprite.scale.set(2.6 * pulse, 2.6 * pulse, 1);
const rp = (time * 0.55) % 1;
ringSprite.position.copy(handPos);
ringSprite.scale.setScalar(1.0 + rp * 3.6);
ringSprite.material.opacity = (1 - rp) * 0.38;
renderer.render(scene, camera);
}
animate();
/* ------------------------------------------------------------------ */
/* resize */
/* ------------------------------------------------------------------ */
window.addEventListener('resize', () => {
layoutCamera();
for (const f of fishes) {
f.pos.x = clamp(f.pos.x, -TANK_X, TANK_X);
f.target.x = clamp(f.target.x, -TANK_X, TANK_X);
}
});
</script>
</body>
</html>Reasoning trace (44,601 tokens)
We need to build a 3D aquarium with Three.js where fish swim away when the mouse moves near them. Let's design something visually rich.
Key elements:
- Three.js scene with underwater fog, blue gradient background.
- A water surface plane at top with caustics shader.
- Sand floor with procedural texture (canvas-generated) or shader.
- Fish: instanced or individual meshes with procedural geometry (body + tail). Use vertex shader for swimming motion deformation? Simpler: build fish from lathe/cone geometry, animate tail rotation.
- Mouse interaction: project mouse onto a plane / raycast into the 3D scene, fish within radius flee.
- Boids-like behavior: wander, flee from cursor, avoid walls, avoid each other mildly.
- Bubbles rising.
- Caustic light patterns on the floor via shader.
- Maybe some plants/seaweed swaying.
- UI hint: "Move your mouse near the fish — they'll swim away".
- Self-playing demo: since mouse-driven, we can have a virtual cursor that moves around automatically, showing fish fleeing. That satisfies "demo mode".
Let's implement carefully.
Technical plan:
Scene setup:
- Renderer with antialias, ACESFilmic tone mapping, sRGB output.
- Camera perspective 50deg, position (0, 3, 14) looking at (0,2,0).
- Fog: FogExp2 with color 0x0a2a45, density 0.03.
- Background: gradient via a large sphere with shader material, or just scene.background = color + fog. Better: a big inverted sphere with vertical gradient shader.
Water surface: plane at y=9, rotated to face down, with shader that animates caustic-ish sine patterns, additive blending, transparent. Visible from below.
Floor: plane at y=-3 with a shader material doing sand color + caustic light patterns (animated voronoi-ish / sine waves). Plus some rocks (icosahedron geometry with noise displacement) and seaweed (planes or tube geometry swaying via vertex shader).
Fish: Create a fish mesh procedurally. Use a LatheGeometry for body? A fish body shape: use a sphere scaled, tapered via modifying vertices. Let's generate a custom geometry: parametrize along x from -1 to 1, radius profile r(t) = sin(...), create rings and triangles. Add tail fin as a flat shape (two triangles) and dorsal fin.
Simplest robust approach:
- Body: THREE.SphereGeometry(1, 16, 12) then modify positions: scale x by 1.8, and taper: for x>0 (front) narrow, for x<0 taper to tail. Actually let's build from scratch with a profile.
Let me write a function makeFishGeometry():
```
const N = 24, RING = 12;
positions, normals, indices
for i in 0..N: t = i/N (0=tail,1=head)
// radius profile
const r = Math.sin(Math.PI * Math.pow(t,0.75)) * 0.55 * (1 - 0.5*Math.pow(1-t,3)) ...
```
Hmm. Let's just use a simple approach: radius = 0.5 * sin(pi * t^0.8) with a bit of shaping, and length from -1 to 1 along z? Let's use along X: x = (t-0.5)*2.2.
Actually let's define t from 0 (nose) to 1 (tail). x = (0.5 - t) * L where L=2.2 => nose at x=+1.1, tail at x=-1.1.
radius(t) = R * sin(pi * t^0.7) maybe. At t=0, r=0 (nose point - a bit sharp but fine); at t=1, r=0 (tail point). Use r = R * Math.pow(Math.sin(Math.PI * Math.pow(t, 0.65)), 0.9). Fine. Also flatten vertically? Fish are usually taller than wide: scale y by 1.4, z by 0.8.
Add tail fin: a plane in XY at x=-1.1 with a triangular/forked shape. And dorsal fin on top. Use simple triangle geometry with DoubleSide material.
Fish material: MeshStandardMaterial with color, roughness 0.35, metalness 0.2, flatShading? Maybe use a gradient along body using vertexColors. Simpler: single color with slight emissive. Add a stripe pattern via... skip.
For performance: about 40-60 fish. Each fish is a Mesh (body) plus fins — grouping them means 3 meshes per fish, 180 meshes. That's OK. Or merge fins into body geometry using BufferGeometryUtils.mergeGeometries from addons. Import 'three/addons/utils/BufferGeometryUtils.js'. That would reduce to one mesh per fish — nice. But merging requires same attributes. We can build body and fins separately and merge. Let's do it: create body geometry (with normals), tail geometry, dorsal geometry, merge. But then we can't animate the tail separately. Alternative: animate swimming via vertex shader that bends the whole mesh based on x position — a nice touch. With a merged geometry, we can add a custom shader that sways the tail. Use onBeforeCompile to inject sway.
That's a bit complex but doable and looks great: in vertex shader, offset z by sin(time*speed + x*k) * amplitude * smoothstep(...). Actually let's do a simpler approach: each fish has body mesh + tail mesh as separate meshes; tail rotates around its attach point. Fins merged into body.
Hmm, per-fish: body mesh (merged with dorsal/pectoral fins) + tail mesh (rotating). 2 meshes * 45 fish = 90 meshes. Fine.
Actually simpler and still nice: use onBeforeCompile-based bending on a single merged mesh including the tail. Sway amplitude proportional to how far back along the body (using position.x relative to fish center). Let's do that — gives a lovely organic swim.
Let me write the shader injection:
```js
mat.onBeforeCompile = (shader) => {
shader.uniforms.uTime = uniforms.time; // shared
shader.uniforms.uPhase = { value: Math.random()*Math.PI*2 };
shader.uniforms.uSpeed = { value: 1 };
shader.vertexShader = `uniform float uTime; uniform float uPhase; uniform float uSpeed;\n` + shader.vertexShader;
shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>', `
#include <begin_vertex>
float bx = transformed.x; // -1.1..1.1 with nose at +
float back = smoothstep(0.3, -1.15, bx); // 0 at front, 1 at tail
float wave = sin(uTime * uSpeed * 6.0 + uPhase + bx * 2.2);
transformed.z += wave * back * 0.22;
// slight body yaw
transformed.z += 0.0;
`);
};
```
But careful: normals won't be transformed, lighting slightly off — acceptable.
Hmm, but each fish needs its own material (since uniforms differ per fish) — 45 materials = 45 shader programs? Actually Three compiles per-material program with different onBeforeCompile results... programs are cached by shader code, so if uniforms only differ, programs might still be shared? Three's program cache key includes the material's shader source... With onBeforeCompile, customProgramCacheKey defaults to onBeforeCompile.toString() I think. So all fish with the same function source share program, and uniforms are per-material. That's fine — uniform updates per material are cheap.
Simpler alternative: use a single shared material and encode phase via an instanced attribute... overkill. Let's use per-fish materials but keep count around 40. Fine.
Actually, we could use InstancedMesh with instance attributes for phase/color — very efficient, but then bending uses instance-local x which is fine. 45 fish, InstancedMesh with per-instance attributes and color. That's elegant but more code complexity for raycasting/flee logic (which is fine, we store data in JS arrays anyway). Let me consider: InstancedMesh requires setting matrix per instance each frame — fine. Per-instance attributes (aPhase, aSpeed, aTint) via InstancedBufferAttribute on the geometry. Then vertex shader uses `instanceMatrix` (already applied by three's instancing chunk). We need to inject code after begin_vertex where `transformed` is in object space. Good, since scale/orientation applied by instanceMatrix later in project_vertex.
But colors: with MeshStandardMaterial + vertexColors + instanceColor, we can use instanceColor supported by three (setColorAt). That works with standard material.
Hmm, mixing instanceColor and custom attributes — instanceColor is handled via `#include <color_vertex>` with USE_INSTANCING_COLOR. Fine.
Risk: complexity. Let's just do individual meshes; 45 fish is nothing for the GPU. Each fish: 2 meshes max. It's simpler to reason about and raycasting not needed (we use distance to cursor in 3D).
Interaction: The mouse position → we want the fish to flee from a point in the aquarium. Project the mouse ray onto a plane at z = 0 (or better, find a point on a sphere/capsule). Simple approach: raycast against an invisible large plane at z=0 (the "interaction plane"), get 3D point. Fish flee if within radius (~4 units) with strength falling off. Also add a small vertical component from the mouse.
But aquarium is a box of depth; fish at various z. Using a plane at z=0 means fish near the front or back also react to the cursor's x,y — that's intuitive.
Also show a subtle cursor indicator: a small glowing ring/sphere at the interaction point in 3D, so users see the "hand of god" pushing fish. Nice touch: a soft glowing sphere that follows the mouse point with some lerp, plus ripple rings maybe.
Cursor glow: sprite with radial gradient canvas texture, additive blending. Add to scene at the mouse3D point. That looks good.
Fish behavior:
Each fish has: position (Vector3), velocity (Vector3), maxSpeed, wanderAngle, etc.
Update each frame:
- Wander: steer toward a slowly changing target point.
- Flee: if dist to cursor < fleeRadius, add force away scaled by (1 - dist/radius)^2 * strength.
- Boundary: soft force toward the interior of the tank box (x in [-10,10], y in [-2, 7], z in [-6,6]).
- Separation: sample a few other fish? With 45 fish, O(n^2) = 2025 distance checks per frame — trivial. Include separation and alignment/cohesion lightly for a schooling feel.
- Speed clamp; steering clamp for smooth turning.
Fish orientation: use lookAt with the velocity direction, plus roll/banking. We need model forward axis: our fish nose is at +x. So we build a quaternion that maps +X to the velocity direction. Use `quaternion.setFromUnitVectors(new Vector3(1,0,0), dir)`. But that introduces arbitrary roll. Better: build a matrix with forward = dir, up = world up-ish. Let's compute:
```
const forward = dir.clone().normalize();
const up = new Vector3(0,1,0);
const right = new Vector3().crossVectors(up, forward).normalize(); // if forward ~ up, degenerate
const up2 = new Vector3().crossVectors(forward, right).normalize();
matrix.makeBasis(forward, up2, right); // x=forward, y=up2, z=right
```
Wait, makeBasis(xAxis, yAxis, zAxis) sets columns. Our fish's local +X is forward, local +Y is up, local +Z is... right-handed: z = x cross y = forward × up. Let's compute zAxis = forward × up2. Hmm, need consistency: right-handed basis needs x × y = z. So z = forward × up2. Then setFromRotationMatrix. Let's do that and add banking roll by rotating around forward axis based on turn rate. Keep simple: bank = -turn * k applied as quaternion multiply around local X.
Actually simpler: use a matrix and then apply a roll about local X before building. Let's just do it.
Also add slight pitch based on vy naturally from forward direction.
Let's use `Object3D` per fish: `fish.mesh` group with quaternion set.
Bubbles: Points with additive sprite texture, rising, resetting. ~300 bubbles.
Caustics on the floor: shader material. Let's write a floor shader:
```glsl
varying vec2 vUv;
uniform float uTime;
void main(){
vec2 p = vUv * 20.0;
float c = 0.0;
// layered sine waves
c += sin(p.x*1.5 + uTime*0.7) * sin(p.y*1.3 - uTime*0.5);
...
}
```
Better caustic: use the classic "voronoi-ish" caustic from sine interference:
```
float caustic(vec2 uv, float t){
vec2 p = uv;
float v = 0.0;
for(int i=0;i<3;i++){
p = p * 1.7 + 1.3;
v += sin(p.x + t) * sin(p.y + t*0.8);
}
return v;
}
```
Something that yields bright lines. Let's compute `float d = abs(sin(...))` etc.
A decent cheap caustic:
```
vec2 uv2 = uv * 1.6;
float a = sin(uv2.x*2.0 + t*0.9) * cos(uv2.y*1.7 - t*0.7);
float b = sin(uv2.y*2.4 - t*1.1) * cos(uv2.x*1.9 + t*0.6);
float c = pow(abs(a+b)*0.5, 4.0) ...
```
Hmm. Alternative: use texture-free "water caustics" via layered trig then apply pow to sharpen.
I'll write:
```
float caustics(vec2 p, float t){
float v = 0.0;
for(int i = 0; i < 4; i++){
float fi = float(i);
vec2 q = p * (1.0 + fi*0.6) + vec2(t*0.3*(1.0+fi*0.4), -t*0.22*(1.0+fi*0.3));
v += sin(q.x + sin(q.y*1.3)) * cos(q.y + cos(q.x*0.9));
}
v = v * 0.25;
v = pow(abs(v), 3.0) * 2.0;
return v;
}
```
Hmm the sin(x + sin(y)) pattern gives nice organic blobs. Let's just go with something and visually it will look like moving light. Since we can't preview, need to be careful that it looks decent. Keep colors: deep blue sand #1b3a4a with light streaks in #7fd8ff at low intensity.
Actually, maybe use a simpler and reliable approach: caustics via `sin` interference, output added to base sand color:
```
float c = 0.0;
c += sin((p.x)*3.0 + t) * sin((p.y)*2.7 - t*0.8);
c += sin((p.x)*5.3 - t*1.1) * sin((p.y)*4.9 + t*0.9);
c = c*0.25+0.5; // 0..1
c = pow(c, 6.0) * 3.0; // sharp bright lines
```
That should give moving bright lines. Good enough.
Floor also gets a radial vignette toward the fog.
Also the water surface from below: a plane at y=8 with similar caustic shader in reverse, semi-transparent, additive. Plus a "god ray" effect maybe via a few large transparent planes? Could be cheesy. Maybe skip god rays, or add subtle vertical beams using additive planes. Let's consider adding a few "light shafts": planes with a gradient texture (canvas) additive, slowly rotating. Might look nice. Optional; keep if easy.
Seaweed: use `THREE.PlaneGeometry(0.5, 4, 1, 8)` with vertex shader sway in onBeforeCompile, or just animate with a custom ShaderMaterial. Let's create kelp using a custom shader material with vertex bending + a simple gradient color, double side, transparent. Actually a standard material with onBeforeCompile bend is easy:
Actually the simplest: use `MeshStandardMaterial` with `onBeforeCompile` injecting sway based on `position.y`. Kelp geometry as a tapered ribbon. Let's do a few clusters of kelp: 30 ribbons.
Hmm, budget: we want zero errors and good visuals. Let's keep the scope manageable but rich:
1. Background gradient sphere (custom shader).
2. Fog.
3. Sand floor with caustic shader + a few rocks (dodecahedrons with flat shading, dark).
4. Some coral/kelp: simple swaying ribbons.
5. Water surface plane at top with animated caustics (additive, semi transparent).
6. Fish: 45, varied colors/sizes, procedural geometry, tail sway shader.
7. Bubbles: Points rising.
8. Floating particles (marine snow): Points drifting.
9. Cursor glow sphere + ripples.
10. UI overlay: title, hint text, small "demo" indicator. Auto-demo mode moves a virtual cursor in a Lissajous pattern so fish visibly flee at start.
Wait — the instructions say games must start in a self-playing demo mode. This isn't a game exactly, but it's an interactive demo. Since interaction uses the mouse, and a screenshot is taken at ~3s, the fish should already look alive. I'll add an auto cursor that wanders around, making fish flee, until the user moves their actual mouse over the canvas. Plus a "Move your mouse" hint overlay that fades after real interaction. That fits nicely.
Let's make sure mouse events work in the iframe: pointermove on window/canvas. The iframe receives mouse events without clicking (mouse doesn't need click). Keyboard needs click, but we don't need keyboard. Still, I could show a "move your mouse" hint. Good.
Now, careful implementation details.
**Setup**
```js
import * as THREE from 'three';
```
Use import map. Since it's a module script, fine.
Renderer:
```js
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);
```
Camera:
```js
const camera = new THREE.PerspectiveCamera(48, innerWidth/innerHeight, 0.1, 200);
camera.position.set(0, 2.5, 16);
camera.lookAt(0, 2.5, 0);
```
Maybe add subtle camera drift/orbit based on mouse parallax for immersion.
Scene:
```js
scene.fog = new THREE.FogExp2(0x062a44, 0.028);
```
Background: a big sphere with ShaderMaterial side: BackSide:
```glsl
varying vec3 vPos;
void main(){ vPos = position; gl_Position = ... }
frag:
float h = normalize(vPos).y;
vec3 top = vec3(0.05, 0.45, 0.65);
vec3 bot = vec3(0.01, 0.08, 0.18);
gl_FragColor = vec4(mix(bot, top, smoothstep(-0.6, 0.9, h)), 1.0);
```
Note: with tone mapping, need `#include <tonemapping_fragment>` etc. Simpler: set the background sphere material with `toneMapped` default true for ShaderMaterial? ShaderMaterial doesn't auto-apply tone mapping unless you include the chunks. Since I output colors in linear-ish space, it's fine either way. Let's just make the shader output colors and set `material.toneMapped = false`? For ShaderMaterial, the tonemapping chunk is not included automatically. So colors will be output raw and then the renderer applies output color space conversion? Actually no — for ShaderMaterial, three adds `#include <colorspace_fragment>`? Let me recall: WebGLProgram prefixes fragment shader with defines, and the `#include <colorspace_fragment>` must be in your shader. Hmm. For raw ShaderMaterial in r170, the output is written directly to the framebuffer without sRGB conversion unless you include the chunk. The renderer's output color space conversion happens via that chunk. So a custom ShaderMaterial writing vec3(0.05,0.45,0.65) would display as that value directly on screen (in sRGB terms), making it a bit brighter than linear. That's OK for a background; I'll just tune values by eye... but I can't preview. I'll include `#include <tonemapping_fragment>` and `#include <colorspace_fragment>` in the fragment shaders for consistency where it matters, using `gl_FragColor`. That's the standard approach:
```glsl
gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
```
These chunks operate on `gl_FragColor`. In r170 that's still valid. Yes, `tonemapping_fragment` uses `gl_FragColor.rgb = toneMapping(gl_FragColor.rgb)`. Good.
Hmm, but `#include <colorspace_fragment>` requires the `linearToOutputTexel` function which is defined in the fragment prefix (encodings_pars_fragment?). In r170, `colorspace_fragment` is `gl_FragColor = linearToOutputTexel( gl_FragColor );` and linearToOutputTexel is defined in the prefix for all fragment shaders. I believe so. There's a risk. To be safe, maybe use simpler approach: use `THREE.Color` values and standard materials for most things, and for the background sphere use a MeshBasicMaterial with a canvas-generated vertical gradient texture. That avoids custom shader issues entirely, and gives crisper control. Yes! Use a canvas texture for the background gradient.
Similarly, caustics on the floor: I could generate a caustics texture on a canvas and scroll two layers with additive blending over a base floor mesh. That's much safer than custom shaders and looks good. Two overlapping caustic textures (canvas-generated with random blobs / voronoi-ish lines) scrolling in different directions, additive, on top of a sand-colored plane. That's a solid, robust approach.
Actually generating a good caustic texture procedurally on canvas: draw many soft radial gradient circles? Or draw random curves with soft strokes. A classic trick: draw a bunch of random wavy lines with white strokes and blur, then overlay with 'lighter'. Let me do: for a 512x512 canvas, fill black; then draw ~40 random quadratic curves with white stroke and shadowBlur to make them soft, then repeat wrapping. Tileable issues — just make texture repeat wrapping and accept seams (hidden by blur? seams visible). Alternatively make the texture a "voronoi cell" pattern which tiles if wrapped properly... complexity.
Alternative: generate caustics via a shader — which gives perfect tiling and animation. Let me just write a custom ShaderMaterial with the standard chunks. I think the risk is low. Actually, I'm fairly confident that `#include <colorspace_fragment>` works in custom ShaderMaterial fragment shaders in r170. Many examples do this. Let me double check the chunk names: In three r152+, chunk names: `tonemapping_fragment`, `colorspace_fragment`. Yes. And `linearToOutputTexel` is defined in the shader prefix (getTexelEncodingFunction is injected into fragment prefix). Yes — `WebGLProgram` adds `getTexelEncodingFunction('linearToOutputTexel', parameters.outputColorSpace)` to the fragment prefix for all materials. Good.
But for a ShaderMaterial with `toneMapped` true and renderer.outputColorSpace = SRGB, there's also the toneMapping function definition — `getToneMappingFunction` is added only if `parameters.toneMapping !== NoToneMapping`. And `#include <tonemapping_fragment>` becomes:
```
#if defined( TONE_MAPPING )
gl_FragColor.rgb = toneMapping( gl_FragColor.rgb );
#endif
```
The `TONE_MAPPING` define is added when toneMapping is on. Fine.
OK, I'll use shaders for the floor caustics, water surface, and background. Actually for the background I'll use a gradient shader too, with the standard includes. Fine.
Hmm, but wait: does ShaderMaterial receive the tone mapping define? In WebGLProgram, `parameters.toneMapping` is `material.toneMapped ? renderer.toneMapping : NoToneMapping`. And the prefix includes `getToneMappingFunction( 'toneMapping', parameters.toneMapping )` — actually it's `parameters.toneMapping !== NoToneMapping ? '#define TONE_MAPPING\n' + ShaderChunk['tonemapping_pars_fragment'] + getToneMappingFunction(...) : ''`. Right. So it works for ShaderMaterial too.
Let's now write the code.
### Structure
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Aquarium</title>
<style> ... </style>
<script type="importmap">...</script>
</head>
<body>
<div id="ui"> ... </div>
<script type="module"> ... </script>
</body>
</html>
```
UI:
- Title top-left: "AQUARIUM" small caps letterspaced, subtitle "move your cursor through the water".
- A hint pill at bottom center: "Move your mouse — fish will flee". Fades out after first real pointer move? Better keep it always but change text after interaction. Let's have a bottom hint that says "Move your cursor to disturb the fish" and a small "auto demo" tag that disappears once real mouse control begins.
Let's design the overlay minimal and elegant:
- top-left: "THE AQUARIUM" with a thin line, plus "three.js" maybe not.
- bottom-center small text with a dot pulsing.
Now the code.
```js
import * as THREE from 'three';
```
#### Constants
```js
const TANK = { x: 11, yMin: -2.6, yMax: 6.5, zMin: -7, zMax: 7 };
```
#### Renderer/camera/scene
Camera at (0, 2.2, 15), fov 50, looking at (0, 2.4, 0).
Hmm, the tank extends x ±11 and the camera is at z=15 with fov 50 → at z=0, visible height = 2*15*tan(25°) = 14, width = 14*aspect(1.6)=22.4. So x ±11 visible. Good. On a 375px wide screen, aspect ~0.5 (in portrait), width visible = 14*0.5 = 7 → too narrow. Handle resize: adjust camera position z based on aspect so the tank fits:
```js
const fitDist = 12 / (Math.tan(fov/2) * aspect) ...
```
Let's compute: we want horizontal half-extent of ~13 at z=0 → needed distance = 13 / (tan(fov/2)*aspect). For aspect 1.6: 13/(0.466*1.6)=17.4. For aspect 0.5: 55 — too far. Instead, for narrow screens we accept cropping horizontally and just make sure the main content is visible. Alternatively increase fov or move camera back a bit and reduce tank width. Let's set camera z = clamp(16 / aspect^0.5 ...). Simply:
```js
const aspect = w/h;
const dist = THREE.MathUtils.clamp(14 / Math.sqrt(aspect), 12, 26);
camera.position.z = dist; camera.position.y = 2.5;
```
At aspect 1.6 → 14/1.26 = 11.1 → clamp 12. At aspect 0.5 → 14/0.707=19.8. Then vertical visible height = 2*19.8*tan25 = 18.5, width = 9.3. Hmm, at aspect 0.5 with distance 19.8, width visible ~ 9.2 — that shows the middle portion, fine.
Actually camera fov is vertical. For narrow screens we want to see more horizontally → move back. The formula makes visible width = 2*d*tan(fov/2)*aspect = 2*(14/sqrt(a))*0.466*a = 13.05*sqrt(a). At a=0.5: 9.2. At a=1.6: 16.5. OK reasonable. Good enough. Also the vertical extent grows as we move back: height = 2*d*0.466 = 13.05/sqrt(a): at a=0.5 → 18.4 tall, so we'd see way beyond the tank vertically — fog will cover it. Fine. Maybe also raise the camera y a bit. OK.
Let's just do:
```js
function updateCamera(){
const aspect = innerWidth/innerHeight;
camera.aspect = aspect;
camera.position.z = THREE.MathUtils.clamp(13.5 / Math.sqrt(Math.min(aspect, 1.6)), 11, 22);
camera.updateProjectionMatrix();
}
```
#### Lighting
- Ambient: 0x4466aa intensity 1.2? With ACES tone mapping and standard materials, need decent intensity.
- HemisphereLight(0x88ddff, 0x0a2338, 1.2)
- DirectionalLight from above (0xbfefff, 2.0), position (4, 18, 6), no shadows (or shadows? too expensive with many objects; skip).
- A couple of colored point lights for accent (0x1e6f9f, 0x2e8b57). Maybe a spotlight from top creating the god ray feel. Point lights add nice color variation.
Since MeshStandardMaterial with tone mapping: fine.
#### Background gradient sphere
Radius 90, BackSide, ShaderMaterial with vertical gradient + subtle noise. Must not be affected by fog? Fog affects it if `fog: true` on material — ShaderMaterial has fog false by default. Good. But the sphere is at radius 90 while fog is exp2 density 0.028 → e^{-(0.028*90)^2} ≈ 0 — objects fade to fog color. The background sphere is not fogged so it stays visible: we want the deep blue gradient. Fog color should match the mid gradient color for coherence. Set fog color 0x0b3550 maybe, and gradient from 0x02121f bottom to 0x0e4a68 top... The distant objects will blend to fog color which is a mid-blue. That works if the gradient near the horizon is similar.
Let's set fog = 0x0a3350, density 0.022.
Background gradient: top (y=1): #0a4a70, middle (y=0): #072a44, bottom: #02101c. Hmm, underwater usually brighter at top. Yes.
Also add a subtle bright spot near the top for the "sun through water". Could add in shader: `col += vec3(0.15,0.3,0.35) * smoothstep(0.5,1.0,dot(dir, sunDir))`.
#### Floor
PlaneGeometry(80, 80) rotated -PI/2, positioned y = -3. Material: ShaderMaterial with sand color + caustics + fog? Custom ShaderMaterial needs fog handling manually. Alternatively use MeshStandardMaterial with an onBeforeCompile caustic addition — that handles fog and lighting automatically. That's nicer! Let's do onBeforeCompile on a MeshStandardMaterial:
```js
const mat = new THREE.MeshStandardMaterial({ color: 0x2a4a5a, roughness: 1, metalness: 0 });
mat.onBeforeCompile = (shader) => {
shader.uniforms.uTime = timeUniform;
shader.vertexShader = shader.vertexShader
.replace('#include <common>', '#include <common>\nvarying vec3 vWorldPos;')
.replace('#include <worldpos_vertex>', '#include <worldpos_vertex>\n vWorldPos = (modelMatrix * vec4(transformed,1.0)).xyz;');
shader.fragmentShader = shader.fragmentShader
.replace('#include <common>', '#include <common>\nvarying vec3 vWorldPos;\nuniform float uTime;\n' + causticGLSL)
.replace('#include <map_fragment>', '#include <map_fragment>\n ... add caustics to diffuseColor');
};
```
Careful: `worldpos_vertex` is only included when there are shadows or envmap etc.? In the standard vertex shader, `#include <worldpos_vertex>` is present always, but the chunk body is guarded by `#if defined( USE_ENVMAP ) || defined( DISTANCE ) || defined ( USE_SHADOWMAP ) || defined ( USE_TRANSMISSION ) || NUM_SPOT_LIGHT_COORDS > 0`. If none of those are defined, `worldPosition` is not computed. So better to compute my own world position in the vertex shader after `#include <begin_vertex>`:
```
vWorldPos = (modelMatrix * vec4(transformed, 1.0)).xyz;
```
That's safe.
Then in the fragment, add caustics to diffuseColor.rgb before lighting: use `#include <color_fragment>`? Actually the order in meshphysical fragment:
```
vec4 diffuseColor = vec4( diffuse, opacity );
#include <map_fragment>
#include <color_fragment>
#include <alphamap_fragment>
...
```
So replacing `#include <color_fragment>` with `#include <color_fragment>\n<caustic code>` works.
The caustic function:
```glsl
float caustic(vec2 p, float t){
vec2 q = p;
float v = 0.0;
for(int i=0;i<4;i++){
float fi = float(i);
q = q * 1.9 + vec2(1.7, 0.9);
v += sin(q.x*0.7 + t*(0.6+fi*0.13)) * sin(q.y*0.6 - t*(0.5+fi*0.11));
}
v *= 0.25;
return pow(max(v,0.0), 4.0) ...
```
Hmm, v ranges -1..1. Let's map: `float c = smoothstep(0.35, 0.95, abs(v));` then `c = pow(c, 2.0);`. Use abs to get symmetric bright lines? Let's use `pow(clamp(v*0.5+0.5, 0.0, 1.0), 5.0) * 2.0`. That gives bright spots where v near 1.
I think the safest visually-pleasing option:
```glsl
float caustics(vec2 p, float t){
float v = 0.0;
vec2 q = p;
for (int i = 0; i < 3; i++){
float fi = float(i);
q = q * 1.61 + vec2(2.3, 1.7);
v += sin(q.x + t * (0.8 + fi * 0.2)) * cos(q.y - t * (0.6 + fi * 0.17));
}
v = v / 3.0; // -1..1
v = abs(v);
return pow(1.0 - v, 8.0); // bright thin lines where v ~ 0
}
```
Using `pow(1 - |v|, 8)` gives thin bright filaments — that's exactly caustic-like. Multiply by strength ~1.5 and a blue-white color. That should look good. Scale p ~ 0.35 so features are ~ few units.
I'll test mentally: v is a sum of sines → smoothly varying. |v| near zero along curves → nice webbing. Yes, classic.
Floor also needs some variation: add a big-slow noise for sand color variation. Could use a simple trig noise. Fine.
Also add "distance falloff" so far floor fades into fog — fog handles that automatically since fog is applied to the standard material.
#### Rocks
A few IcosahedronGeometry with vertex jitter and flatShading, dark desaturated color, positioned on the floor around the edges, scaled non-uniformly. 8 of them.
#### Kelp / seaweed
Create ribbon geometry: PlaneGeometry(0.4, 4, 1, 12) with bottom at y=0 (translate up by 2). Sway in vertex shader via onBeforeCompile with uniform time. 20-30 blades in 4 clusters near the sides. Use MeshStandardMaterial double-sided, color 0x1f6b4a, with slight emissive. Animate:
```
float sway = sin(uTime * 1.2 + position.y * 0.6 + uPhase) * 0.35 * (position.y * 0.25);
```
Since geometry translated so y from 0..4, use uv.y or position.y.
Note: onBeforeCompile per material — I'll make one material per cluster (share phase per cluster) or use ~6 materials. Fine, use 5 clusters with shared material per cluster (phase uniform).
Actually simpler: give each kelp its own mesh but share geometry and use a per-mesh material? Too many materials. Use 4 materials with different phases, reuse across blades with random rotation/scale. Good.
Alternatively animate kelp via JS by rotating the mesh slightly with a sine — cheap and simple: just set `mesh.rotation.z = sin(t*1.1 + phase) * 0.08` and scale... That looks less organic but is simple. But bending is nicer. Let's do the shader bend — it's a small amount of code.
Hmm, actually if I'm doing onBeforeCompile for fish too, I might as well.
Let's simplify: kelp sway via rotating whole mesh around its base (mesh pivots at base) + slight second-order via a child? Rotating the base only moves the tip rigidly, which for a tall thin blade looks fine actually. Add two segments? Keep it simple: rotate the whole blade about its base with a small angle. That reads as swaying. Good enough, cheaper risk.
Actually the vertex bend is quite easy:
```js
mat.onBeforeCompile = (shader) => {
shader.uniforms.uTime = uTime;
shader.uniforms.uPhase = { value: phase };
shader.vertexShader = 'uniform float uTime; uniform float uPhase;\n' + shader.vertexShader.replace(
'#include <begin_vertex>',
`#include <begin_vertex>
float h = uv.y;
float s = sin(uTime * 1.1 + uPhase + h * 2.2) * 0.16 + sin(uTime*2.3 + uPhase*1.7 + h*4.0)*0.05;
transformed.x += s * h * h * 6.0;`
);
};
```
Wait, with a PlaneGeometry of width 0.4 and height 4, `uv.y` goes 0 at bottom to 1 at top. h*h*6 gives 6 units of offset at top with s~0.2 → 1.2 units sway. Reasonable for a 4-unit tall blade. Good.
Since it's a plane, we see it from both sides — use `side: THREE.DoubleSide`.
#### Water surface
Plane at y = 7, rotated PI/2 so it faces... we're below looking up, so it should face down: rotation.x = Math.PI/2 gives normal pointing -y? PlaneGeometry default normal +z. rotation.x = -PI/2 → normal +y (floor). rotation.x = +PI/2 → normal -y (ceiling, visible from below). Use DoubleSide anyway, with a shader.
I'll make a ShaderMaterial with additive blending, transparent, depthWrite false:
```glsl
varying vec2 vUv;
uniform float uTime;
void main(){
vec2 p = vUv * 8.0;
float v = 0.0;
... same caustic function ...
vec3 col = mix(vec3(0.05,0.25,0.4), vec3(0.55,0.9,1.0), c) ;
gl_FragColor = vec4(col * c * 0.8, c * 0.55);
}
```
Additive blending makes it glow. Plus fade near the edges? With additive, black areas add nothing. Good.
Also, a bright "sun disc" glow near the top? Maybe skip.
#### Fish
Geometry builder:
```js
function makeFishGeometry(){
const seg = 20, ring = 10;
const len = 2.0, radius = 0.42;
const pos = [], idx = [], nor = [];
for(let i=0;i<=seg;i++){
const t = i/seg; // 0 nose .. 1 tail
const x = (0.5 - t) * len;
// profile
let r = Math.sin(Math.PI * Math.pow(t, 0.62)) ; // 0 at both ends
r = Math.pow(r, 0.85) * radius;
// add a belly/height factor
const hy = 1.0 + 0.35 * Math.sin(Math.PI * t); // taller in middle
for(let j=0;j<ring;j++){
const a = j/ring * Math.PI*2;
const y = Math.cos(a) * r * 1.25 * (0.8 + 0.4*Math.sin(Math.PI*t));
const z = Math.sin(a) * r * 0.72;
pos.push(x, y, z);
}
}
// caps: nose point and tail point
...
}
```
Hmm, sin(pi * t^0.62) at t=0 is 0 and at t=1 is sin(pi)=0. Good; max around t^0.62 = 0.5 → t = 0.5^(1/0.62) = 0.5^1.61 = 0.327. So max girth is at 1/3 from nose — correct for fish.
For the caps: the rings at t=0 and t=1 all have r=0 → degenerate vertices at the same point. That's fine — they form the nose/tail points, and the triangles will be degenerate slivers, which might cause NaN normals. Better: make the first ring r=0 collapse but compute normals via computeVertexNormals which handles degenerate triangles by producing zero-length normals → could cause artifacts. Safer: set the radius at the ends to a small value like 0.02 instead of 0, then add a cap fan to a center point. Or simply keep the ends slightly open — you'd see a hole if viewed from the tip. With a small radius (0.03) and the fish mostly seen from the side, it's fine. Let's use `r = Math.max(r, 0.02)`.
Actually let's do: `const r = radius * Math.pow(Math.sin(Math.PI * Math.pow(t, 0.62)), 0.8) + 0.015;`. Then compute normals automatically with computeVertexNormals. Good.
Then flatten top/bottom? Fish are taller than wide: y scale 1.25, z scale 0.72 as above. Good.
Tail fin: a separate geometry — a flat "V" shape in the XY plane at the tail end, extending backward. Make it part of the body geometry via merging (need BufferGeometryUtils). Or add as a second mesh child with the same material. Since tail sway is done in the shader based on x, including the fin in the same geometry means the fin sways too.
To merge without importing BufferGeometryUtils, I can build the fin vertices directly into the same position array as the body. Let's do that: write a custom function that builds an indexed BufferGeometry with the body, then append fin triangles manually.
Fin geometry (in local XY plane, z=0, thin, double-sided? if it's flat with zero thickness, we need DoubleSide material). Hmm, backface culling on a flat fin: viewed from one side it disappears. Options: make the material `side: THREE.DoubleSide` for the whole fish. That's fine (slight perf cost, negligible). With DoubleSide and computeVertexNormals, lighting on the back side flips normals automatically in the shader? Three handles double-sided lighting by flipping normals for back faces (`gl_FrontFacing`). Yes, in the standard material there's `#include <normal_fragment_begin>` with `float faceDirection = gl_FrontFacing ? 1.0 : -1.0;` and `normal *= faceDirection;` when DOUBLE_SIDED. Good.
So: build body vertices + fin vertices into one geometry, use DoubleSide.
Fins to add:
- Caudal (tail) fin: from tail point (-1.0, 0, 0), a fan: points (-1.0±...). Let's define in body space: tail base at x = -len/2 = -1.0 (nose at +1.0). Fin extends from x=-0.75 to x=-1.55, with top at y=+0.55 and bottom at y=-0.55, and a fork. Simple triangle fan from tail base (-0.95,0) to: (-1.6, 0.6), (-1.25, 0), (-1.6, -0.6). Actually a forked tail: outline points: (-1.0, 0.1) → (-1.65, 0.7) → (-1.35, 0) → (-1.65,-0.7) → (-1.0,-0.1). Triangulate as a fan around (-1.0, 0). That yields a nice forked tail. z = 0, maybe add a slight thickness by duplicating at z=±0.02. Let's keep flat.
- Dorsal fin: on the top, from x=0.2 to -0.6, y from body surface up to 0.75. A triangle: (0.25, 0.15) → (0.0, 0.8) → (-0.55, 0.15)? Roughly. It should sit on the back. Just place at z=0 in the XY plane.
- Pectoral fins: small triangles on the sides at the shoulder, at z=±. Could skip or add small ones angled. Let's add small ones in the YZ-ish plane for detail. Maybe skip for simplicity; the silhouette is what matters.
Add fin vertices with addTri(a,b,c) helper that pushes positions and indices.
The fins are flat in the z=0 plane, but the body is 3D, so at y=±0.5 the body surface is at z=0 only at the extreme top/bottom. The dorsal fin sticking up from y=0.15 at x=0.25... at x=0.25 the body radius is around 0.35, so the fin's lower vertices should be inside the body. Fine, overlapping geometry is not visible if the fin base is inside the body. Let's ensure the fin base y is around 0.1-0.2 (inside the body) so no gap.
Body at x=0.25: t where x=0.5-t... wait, x = (0.5 - t)*len with len=2 → t = 0.5 - x/2. For x=0.25 → t=0.375. r = 0.42 * sin(pi*0.375^0.62)^0.8 = 0.375^0.62 = e^{0.62*ln0.375} = e^{0.62*(-0.981)} = e^{-0.608}=0.544 → sin(pi*0.544)=sin(97.9°)=0.99 → ^0.8 ≈ 0.992 → r≈0.417. times 1.25 → 0.52 height. So the body's top at x=0.25 is y≈0.52. So a dorsal fin from y=0.2 to y=0.85 spanning x from 0.35 to -0.5 would be visible above the body. Good.
Now, fish material:
```js
new THREE.MeshStandardMaterial({ color, roughness: 0.45, metalness: 0.25, side: DoubleSide, flatShading: false, emissive: color.clone().multiplyScalar(0.15) })
```
Colors: orange (0xff8a3d), yellow (0xffd166), cyan (0x5ee7ff), pink (0xff7bd5), silver-blue (0x9fd8ff), purple (0xa78bfa), green (0x7ef0a8), red-orange.
Each fish gets a color from a palette with variation, plus a size scale.
Fish data:
```js
class Fish {
constructor(){ this.pos = new Vector3(); this.vel = ...; this.mesh; ... }
}
```
I'll use plain objects.
Fish behavior update (dt):
```js
const steer = new THREE.Vector3();
// wander
fish.wanderPhase += dt * fish.wanderSpeed;
target = fish.home + spherical offset
```
Simpler approach that yields good results: use a "desired velocity" model.
```js
desired.set(0,0,0);
// wander: a slowly rotating direction
const wob = ...
desired.add(wanderDir)
// flee
const d = pos.distanceTo(cursor3D)
if (d < fleeR) {
const push = 1 - d/fleeR;
away.subVectors(pos, cursor3D).normalize().multiplyScalar(push*push*6)
desired.add(away)
}
// bounds
if (pos.x > 9) desired.x -= (pos.x-9)*2; etc.
// separation
for other fish: if dist<1.2 add away*(...)
// school: slight attraction to the average of nearby fish
vel.lerp toward desired normalized * speed
```
Let's implement with steering forces:
```js
const acc = new THREE.Vector3();
// wander
fish.angle += (Math.random()-0.5) * dt * 2;
acc.add(new THREE.Vector3(Math.cos(fish.angle), Math.sin(fish.angle*1.3)*0.3, Math.sin(fish.angle*0.7)*0.8).multiplyScalar(1.2));
```
Hmm, random walk per frame is jittery. Use smooth noise: each fish has a wander target point that changes slowly.
I'll do this:
- Each fish has `wanderTarget` Vector3. Every `wanderTimer` seconds (2-6s), pick a new target within the tank. Steer toward it with force proportional to distance (capped).
- That gives calm swimming with direction changes.
```js
fish.timer -= dt;
if (fish.timer <= 0) { fish.timer = 2 + Math.random()*5; fish.target.set(rand(-9,9), rand(-1.8,5.5), rand(-5,5)); }
const toTarget = fish.target.clone().sub(fish.pos);
acc.add(toTarget.normalize().multiplyScalar(0.9 * fish.speed)); // desired velocity
```
Then vel += (desired - vel) * k * dt, with clamping. Then flee adds a strong impulse.
Let's do the classic "seek/flee = desired velocity" formulation:
```js
const desired = new Vector3();
if (toTarget.length() > 0.5) desired.add(toTarget.normalize().multiplyScalar(maxSpeed));
// flee
if (d < R) {
const away = pos - cursor; away.normalize();
desired.addScaledVector(away, maxSpeed * 3.5 * (1 - d/R));
}
// bounds
// separation
// then:
const steerVec = desired.sub(vel); // steering
steerVec.clampLength(0, maxForce)
vel.addScaledVector(steerVec, dt) // hmm units
```
Actually the standard: `vel += steer * dt * maxForce`, and `vel.clampLength(0, maxSpeed)`. Then `pos += vel*dt`.
Since we're mixing dt-based accumulations, let's use a simple exponential smoothing which is stable:
```js
desiredVel.clampLength(0, maxSpeed)
vel.lerp(desiredVel, 1 - Math.exp(-dt * 4.0)) // smooth
pos.addScaledVector(vel, dt)
```
This is stable and simple. Flee adds to desiredVel with a large magnitude so the fish accelerates away fast. Good.
Fish orientation: compute quaternion from vel direction. Use a smooth slerp toward the target quaternion so turning doesn't snap.
```js
const dir = vel.clone().normalize();
const up = new Vector3(0,1,0);
if (Math.abs(dir.y) > 0.98) up.set(0,0,1);
const right = new Vector3().crossVectors(up, dir).normalize(); // wait
```
Let's define basis: local +X = forward (dir). local +Y = up'. local +Z = forward × up'? Let's check right-handedness: for a basis matrix with columns (xAxis, yAxis, zAxis), we need xAxis × yAxis = zAxis. So zAxis = dir × up'.
Compute: right = up × dir (this points to the fish's left or right?). Let's just do:
```
const zAxis = new THREE.Vector3().crossVectors(dir, up).normalize(); // = dir × up
const yAxis = new THREE.Vector3().crossVectors(zAxis, dir).normalize(); // z × x = y
```
Check: z = x × y → y = z × x. Yes since x,y,z orthonormal right-handed: y = z × x. Good.
So:
```js
const xAxis = dir;
const zAxis = new Vector3().crossVectors(xAxis, worldUp).normalize(); // if parallel, fallback
const yAxis = new Vector3().crossVectors(zAxis, xAxis).normalize();
m.makeBasis(xAxis, yAxis, zAxis);
quat.setFromRotationMatrix(m);
```
If dir is nearly parallel to worldUp, cross is near zero → NaN. Guard: if |dir.y| > 0.99, use worldUp = (0,0,1).
Then add banking: rotate around local X by an angle proportional to the horizontal turn rate. Skip for simplicity? A little banking looks great. Let's compute `bank = clamp(-turnRate * 0.5, -0.6, 0.6)` where turnRate is the angular change. Simpler: bank based on the cross product of the previous dir and new dir's y component. Let's just do a small roll proportional to the lateral acceleration: `const bank = THREE.MathUtils.clamp(-vel.dot(rightVec)*0.15, -0.6, 0.6)`. Meh. I'll skip banking to reduce risk, or apply a gentle constant roll? No roll.
Actually let's do a simple bank: after computing the quaternion, multiply by a quaternion around (1,0,0) by angle = clamp(cross(prevDir, dir).y * 8, -0.5, 0.5). Hmm, the sign might be wrong but it's subtle. Let's skip.
#### Cursor interaction
```js
const pointer = new THREE.Vector2(0,0);
let pointerActive = false;
let lastPointerMove = 0;
window.addEventListener('pointermove', (e) => {
pointer.x = (e.clientX / innerWidth) * 2 - 1;
pointer.y = -(e.clientY / innerHeight) * 2 + 1;
pointerActive = true;
...
});
```
But the demo mode: before any real pointer movement, auto-drive the pointer in a Lissajous curve. Once the user moves the mouse, switch over. Also, if the user stops moving for, say, 8 seconds, should we go back to the demo? That might be annoying. Keep it: real pointer control takes over permanently after first move. Hmm, but the auto demo should continue if the user doesn't interact (screenshot at 3s → the demo is running). Good.
Raycast to a plane at z = 0:
```js
const raycaster = new THREE.Raycaster();
const plane = new THREE.Plane(new THREE.Vector3(0,0,1), 0); // z = 0
raycaster.setFromCamera(pointer, camera);
raycaster.ray.intersectPlane(plane, cursorTarget);
```
But if the ray is nearly parallel to the plane... The camera looks along -z, so the ray is mostly along -z; intersection with z=0 plane is fine.
However, the intersection point spreads widely: at the top of the screen, the ray hits z=0 at a high y. Fine.
The cursor3D should be clamped into the tank: y between -2 and 6.5, x within ±12.
Then smooth the actual "hand" position toward the target:
```js
handPos.lerp(cursorTarget, 1 - Math.exp(-dt*14));
```
Wait — should the fish flee from the smoothed hand or the raw cursor? Smoothed is fine and shows a nice trail.
Visual: a glowing sprite at handPos + expanding ring? Let's do:
- A sprite with a radial gradient texture, additive blending, scale ~2.2, color 0x9fe8ff, opacity 0.55.
- Maybe 2 concentric ripple rings (RingGeometry) that expand and fade repeatedly at the hand position. That adds motion. But rings oriented to face the camera — use a Sprite for the glow and a Mesh ring that faces the camera each frame (billboard). Actually just use 3 sprites with varying sizes/pulsing. Simple and safe.
I'll make the sprite pulse: scale = 1.6 + sin(t*3)*0.2, plus the movement speed affecting scale.
Actually — is a visible "hand" cursor good? It communicates the interaction. Yes, a soft glowing orb that trails the mouse is nice and hints at "this is what pushes the fish". Keep it subtle.
#### Bubbles
Points geometry with ~250 particles, custom sprite texture (radial gradient canvas). Animate in JS: y += speed*dt; wobble x by sin; reset when above water surface. Points material with sizeAttenuation, size ~0.35, additive, transparent, opacity 0.7, depthWrite false. Update the position attribute each frame.
#### Marine snow (floating particles)
Similar, ~600 tiny particles drifting slowly. Cheap and adds depth. Maybe combine into the bubbles system? Separate is fine.
#### Post-processing
Skipping EffectComposer keeps it simple and safe; add a vignette-ish radial gradient overlay in CSS instead. Good idea: a CSS overlay with a radial gradient (transparent center → dark blue edges) plus a subtle color tint. Cheap and looks polished.
Also add a slight blue tint overlay with mix-blend-mode: screen? Nah, keep the CSS vignette only.
#### Animation loop
```js
const clock = new THREE.Clock();
function animate(){
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
time += dt;
...
renderer.render(scene, camera);
}
```
Handle resize.
#### Fish count / performance
50 fish * (geometry ~ 20*10 = 200 verts + fins) = trivial. Materials 50 → 50 draw calls. Fine.
Now let's think about whether fish actually visibly flee. fleeRadius ~ 4.5 units in world space. The tank is ~20 wide. On a 1280-wide screen, 4.5 units ≈ 4.5/22 * 1280 ≈ 260px radius. Good, noticeable.
Let's make the fish accelerate strongly: desired velocity from flee = maxSpeed * 4 * (1-d/R)^1.5, and also keep the flee for a short "alarm" duration so they keep darting after the cursor leaves. Add `fish.alarm` timer set to 0.6s when fleeing; while alarm > 0, keep adding flee in the last known direction. Simple: just apply flee only when within radius, but the velocity smoothing keeps them moving away. With lerp factor 1-exp(-dt*4) ≈ 0.06 per frame at 60fps... that's slow to accelerate. Use different smoothing when fleeing: lerp faster (8-10). Let's use `const k = 1 - Math.exp(-dt * (fleeing ? 8 : 2.5))`. Then the fish reacts quickly.
Hmm, with maxSpeed ~ 2.5 units/s normally, and fleeing up to 9 units/s. Tank is 20 wide, so a fish crosses it in 2s while fleeing. Good.
Let's define per-fish: maxSpeed 1.4–2.6, fleeBoost 3.5x.
#### Demo cursor
```js
let t = 0;
function autoPointer(){
const x = Math.sin(t*0.6)*0.75 + Math.sin(t*1.7)*0.15;
const y = Math.cos(t*0.43)*0.5 + Math.sin(t*1.1)*0.12;
pointer.set(x, y);
}
```
This creates a sweeping motion across the screen so the fish get chased around.
Should the auto cursor show as the glowing orb? Yes — it demonstrates the interaction. Maybe make the orb a slightly different tint in demo mode. Not necessary.
Also add a text hint: "Move your mouse — the fish will swim away" that fades when real input happens; before that, "Auto-demo" tag. Let's do:
```html
<div class="hud">
<div class="brand">DEEP <span>·</span> REEF</div>
<div class="hint" id="hint">Move your cursor through the water — the fish will scatter</div>
</div>
```
And a small demo badge that hides on first pointer move.
Hmm, in the sandboxed iframe, pointermove events on the window should fire when the user moves the mouse over the iframe. Yes.
Also account for touch: pointermove with touch works if not prevented. Use `touch-action: none` on body? That might block scrolling but the page shouldn't scroll anyway (100vh, overflow hidden). Fine.
#### Now write the code in detail.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>Deep Reef — 3D Aquarium</title>
<style>
:root { --ink: #cfefff; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #03101c; }
body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; color: var(--ink); }
canvas { display: block; }
#vignette {
position: fixed; inset: 0; pointer-events: none;
background: radial-gradient(ellipse at 50% 45%, rgba(0,0,0,0) 40%, rgba(1,10,20,0.55) 100%);
z-index: 2;
}
...
</style>
```
Also add a subtle scanline/noise? Not needed.
HUD styles: top-left brand with letterspacing, bottom-center hint with a pulsing dot.
Let's write:
```html
<div id="hud">
<header>
<div class="brand">DEEP REEF</div>
<div class="sub">a three.js aquarium</div>
</header>
<footer>
<div class="pill" id="hint"><span class="dot"></span><span id="hintText">Move your cursor through the water — the fish will flee</span></div>
</footer>
</div>
```
With `pointer-events: none` on #hud.
CSS for the pill: backdrop blur, border 1px rgba(160,230,255,.25), background rgba(6,30,50,.35), rounded full, padding 10px 18px, font-size 13px, letter-spacing .04em.
On first real pointer move, change hintText to "Nice — keep chasing them" and animate a fade-out of a "demo" badge.
Maybe add a small badge top-right: "AUTO DEMO" that becomes "LIVE" on interaction. Cute. Let's do it simply:
```html
<div class="badge" id="badge"><span class="live"></span>AUTO-DEMO</div>
```
On pointer move: `badge.innerHTML = '<span class="live on"></span>YOU ARE HERE'`. Hmm, maybe "INTERACTIVE". Fine.
Now the JS.
```js
import * as THREE from 'three';
// ---------- renderer ----------
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);
```
Wait — the canvas should be behind the HUD: set canvas position fixed inset 0, z-index 1, and hud z-index 3.
Scene:
```js
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x07314c, 0.021);
```
Camera:
```js
const camera = new THREE.PerspectiveCamera(52, 1, 0.1, 400);
camera.position.set(0, 2.6, 15);
```
The camera looks at (0, 2.6, 0). Let's add a slight downward tilt? Keep it level.
Lights:
```js
const hemi = new THREE.HemisphereLight(0x9fe6ff, 0x0b2b3e, 2.0);
scene.add(hemi);
const dir = new THREE.DirectionalLight(0xd6f6ff, 2.4);
dir.position.set(6, 22, 8);
scene.add(dir);
const fill = new THREE.PointLight(0x2aa8ff, 120, 60, 2);
fill.position.set(-12, 6, 8);
scene.add(fill);
```
With physically correct lighting (default in r155+), PointLight intensity is in candela. intensity 120 at distance ~15 gives 120/225 ≈ 0.53 — reasonable. Let's use `decay: 2` default. Fine.
Backdrop sphere:
```js
const bgGeo = new THREE.SphereGeometry(160, 32, 24);
const bgMat = new THREE.ShaderMaterial({
side: THREE.BackSide,
depthWrite: false,
uniforms: { uTop: {value: new THREE.Color(0x0f5a7d)}, uMid: {value: new THREE.Color(0x062f4a)}, uBot: {value: new THREE.Color(0x01101d)} },
vertexShader: `varying vec3 vP; void main(){ vP = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
fragmentShader: `
varying vec3 vP;
uniform vec3 uTop, uMid, uBot;
void main(){
float h = normalize(vP).y;
vec3 c = mix(uBot, uMid, smoothstep(-0.7, 0.1, h));
c = mix(c, uTop, smoothstep(0.05, 0.9, h));
// sun glow
vec3 d = normalize(vP);
float s = pow(max(dot(d, normalize(vec3(0.15, 0.85, -0.2))), 0.0), 8.0);
c += vec3(0.25, 0.45, 0.5) * s;
gl_FragColor = vec4(c, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`
});
```
Careful: `#include <tonemapping_fragment>` in a ShaderMaterial requires `toneMapping` to be defined... it's guarded by `#if defined( TONE_MAPPING )`, and TONE_MAPPING is defined if material.toneMapped && renderer.toneMapping !== NoToneMapping. Since we set ACES, fine. And `colorspace_fragment` requires `linearToOutputTexel`. I'm fairly confident it's in the fragment prefix for all materials.
Hmm, let me double-check three r170 WebGLProgram:
```js
prefixFragment = [
...
getToneMappingFunction( 'toneMapping', parameters.toneMapping ), // only if not NoToneMapping?
...
getTexelEncodingFunction( 'linearToOutputTexel', parameters.outputColorSpace ),
...
].join('\n')
```
Actually in r170:
```js
parameters.toneMapping !== NoToneMapping ? '#define TONE_MAPPING' : '',
parameters.toneMapping !== NoToneMapping ? ShaderChunk['tonemapping_pars_fragment'] : '',
parameters.toneMapping !== NoToneMapping ? getToneMappingFunction('toneMapping', parameters.toneMapping) : '',
...
getTexelEncodingFunction( 'linearToOutputTexel', parameters.outputColorSpace ),
```
Yes, `linearToOutputTexel` is always defined. Good. So both includes work.
But wait: `parameters.toneMapping` for a ShaderMaterial — in WebGLRenderer.getParameters: `toneMapping: material.toneMapped ? renderer.toneMapping : NoToneMapping`. ShaderMaterial's default `toneMapped` is true. Good.
Alternatively I could avoid all risk by not using includes and instead applying the sRGB conversion manually. But then colors will be off vs. other materials. Let's keep includes. Actually... a subtle bug: the scene background color. We're using a sphere mesh so scene.background isn't set. Fine.
Also make sure the sphere is inside the camera far plane (400 > 160). Good.
Fog affects the backdrop? ShaderMaterial fog is false by default, so no. Good — we want the gradient visible.
Floor:
```js
const floorGeo = new THREE.PlaneGeometry(160, 160, 1, 1);
floorGeo.rotateX(-Math.PI/2);
const floorMat = new THREE.MeshStandardMaterial({ color: 0x18384a, roughness: 1.0, metalness: 0.0 });
floorMat.onBeforeCompile = ...
```
Hmm, fog with exp2 density 0.021 → at distance 60, e^{-(1.26)^2} = e^{-1.588}=0.20, so the floor's far parts are 80% fog. Good, it fades into the haze.
Floor caustics via onBeforeCompile. Let me write:
```js
const uTime = { value: 0 };
const causticChunk = /* glsl */`
float causticPattern(vec2 p, float t){
float v = 0.0;
vec2 q = p;
for(int i = 0; i < 3; i++){
float fi = float(i);
q = q * 1.63 + vec2(1.7, 2.3);
v += sin(q.x + t * (0.9 + fi * 0.23)) * cos(q.y - t * (0.7 + fi * 0.19));
}
v /= 3.0;
float d = 1.0 - abs(v);
return pow(max(d, 0.0), 7.0);
}
`;
```
Wait, with v/3 in [-1,1], 1-|v| in [0,1], pow 7 → thin lines. Good. Scale factor for the world pos: p = vWorldPos.xz * 0.55.
Fragment injection:
```js
.replace('#include <color_fragment>', `#include <color_fragment>
float cs = causticPattern(vWorldPos.xz * 0.55, uTime);
diffuseColor.rgb += vec3(0.28, 0.72, 0.9) * cs * 0.85;
float sandNoise = sin(vWorldPos.x*0.31 + sin(vWorldPos.z*0.27)*2.0) * 0.5 + 0.5;
diffuseColor.rgb *= 0.82 + 0.32 * sandNoise;
`)
```
And declare uniforms in both shaders.
Careful with `#include <color_fragment>` — is it present in meshphysical fragment? Yes:
```
vec4 diffuseColor = vec4( diffuse, opacity );
#include <map_fragment>
#include <color_fragment>
...
```
Actually I recall the order is: `<clipping_planes_fragment>`, `vec4 diffuseColor = vec4( diffuse, opacity );`, `<map_fragment>`, `<color_fragment>`, `<alphamap_fragment>`... Yes.
Vertex injection:
```js
.replace('#include <common>', `#include <common>
varying vec3 vWorldPos;`)
.replace('#include <begin_vertex>', `#include <begin_vertex>
vWorldPos = (modelMatrix * vec4(transformed, 1.0)).xyz;`)
```
Good. Note modelMatrix is available in the vertex shader. Yes.
Now, does the floor material need `flatShading`? No.
Rocks:
```js
const rockGeo = new THREE.IcosahedronGeometry(1, 2); // then jitter vertices
const pos = rockGeo.attributes.position;
for (let i = 0; i < pos.count; i++){
const v = new THREE.Vector3().fromBufferAttribute(pos, i);
const n = v.clone().normalize();
const k = 0.78 + Math.sin(v.x*3.1)*0.12 + Math.cos(v.y*2.7)*0.1 + Math.sin(v.z*4.3)*0.08;
v.copy(n).multiplyScalar(k);
pos.setXYZ(i, v.x, v.y, v.z);
}
rockGeo.computeVertexNormals();
```
Note: IcosahedronGeometry is non-indexed, so each face has its own vertices → jittering by position produces cracks between faces? Actually non-indexed vertices at the same position get the same deterministic jitter (since it's a function of position), so no cracks. Good. Then use flatShading for a faceted look.
Rock material: MeshStandardMaterial({ color: 0x0f2b3a, roughness: 0.95, flatShading: true }).
Place ~10 rocks along the back and sides, scaled 1-3, sitting at y=-3 so they poke up.
Kelp: 40 blades in clusters at x ∈ [-11, -7] and [7, 11], z ∈ [-6, 4], base y = -2.9. Random height 3-7, width 0.3-0.6, random rotation, hue variation. Use 5 shared materials with different phases.
Hmm, 40 blades = 40 draw calls, fine.
Fish geometry function. Let me write it fully:
```js
function makeFishGeometry(){
const positions = [];
const indices = [];
const RINGS = 22, SEG = 10;
const L = 2.0, R0 = 0.42;
const ringStart = [];
for (let i = 0; i <= RINGS; i++){
const t = i / RINGS;
const x = (0.5 - t) * L;
const prof = Math.pow(Math.sin(Math.PI * Math.pow(t, 0.62)), 0.85);
const r = Math.max(R0 * prof, 0.012);
const hScale = 1.0 + 0.25 * Math.sin(Math.PI * t);
ringStart.push(positions.length / 3);
for (let j = 0; j < SEG; j++){
const a = (j / SEG) * Math.PI * 2;
const y = Math.cos(a) * r * 1.28 * hScale;
const z = Math.sin(a) * r * 0.70;
positions.push(x, y, z);
}
}
for (let i = 0; i < RINGS; i++){
const a0 = ringStart[i], b0 = ringStart[i+1];
for (let j = 0; j < SEG; j++){
const j2 = (j + 1) % SEG;
const a1 = a0 + j, a2 = a0 + j2, b1 = b0 + j, b2 = b0 + j2;
indices.push(a1, b1, a2, a2, b1, b2);
}
}
...
}
```
Winding order: need outward normals. Let's think: at angle a=0, the point is at +y (cos0=1), z=0. As j increases, a increases, so z = sin(a)*... increases. x increases with i? No: x = (0.5 - t)*L decreases as i increases. So the rings go from nose (x=+1) to tail (x=-1).
Triangle (a1, b1, a2): a1 = ring i, angle j; b1 = ring i+1 (further back, smaller x), same angle; a2 = ring i, angle j+1 (rotating toward +z).
Let's take a point at the top of the body, angle j≈0 (y+, z≈0). a1 = (x1, y1, 0), b1 = (x2 < x1, y2, 0), a2 = (x1, y1, +z small), i.e., toward +z.
Normal = (b1-a1) × (a2-a1) = (-dx, 0, 0) × (0, 0, +dz) = (0*dz - 0*0, 0*0 - (-dx)(dz), 0) = (0, dx*dz, 0) with dx>0, dz>0 → normal +y. Outward at the top.
Wait, cross product formula: (a × b) = (a_y b_z - a_z b_y, a_z b_x - a_x b_z, a_x b_y - a_y b_x).
a = b1-a1 = (-dx, dy, 0) with dx>0 (x decreases), dy small.
b = a2-a1 = (0, ~0, +dz).
a × b = (dy*dz - 0*0, 0*0 - (-dx)*dz, (-dx)*0 - dy*0) = (dy*dz, dx*dz, 0). The y component is positive → outward. Good.
Then `computeVertexNormals()` will produce smooth normals. The ends: ring 0 has tiny radius, forming a near-point. Fine.
Fins: helper
```js
function addTri(a, b, c){
const base = positions.length/3;
positions.push(...a, ...b, ...c);
indices.push(base, base+1, base+2);
}
```
Winding for fins doesn't matter much with DoubleSide.
Tail fin:
```js
const tailBaseX = -0.86, tailTipX = -1.62;
addTri([tailBaseX, 0.02, 0], [tailTipX, 0.62, 0], [tailBaseX + 0.06, -0.02, 0]); // upper lobe
addTri([tailBaseX, -0.02, 0], [tailBaseX + 0.06, 0.02, 0], [tailTipX, -0.62, 0]); // lower lobe
```
Hmm, let me instead define a fan from a base point B = (-0.9, 0, 0) with outline points around:
P0 = (-1.55, 0.68), P1 = (-1.2, 0.05), P2 = (-1.55, -0.68).
Triangles: (B, P0, P1), (B, P1, P2). Plus maybe a small triangle for the top of the base. That gives a forked tail shape. Good. z = 0 for all. Also give the fin a slight thickness? Not needed with DoubleSide.
But wait — the body's tail end has radius ~0.012 at x=-1.0. The base point at x=-0.9 has a body radius around: t = 0.5+0.45 = 0.95, prof = sin(pi*0.95^0.62)^0.85. 0.95^0.62 = e^{0.62*ln0.95} = e^{-0.0318} = 0.9687. sin(pi*0.9687)=sin(174.4°)=0.0977. ^0.85 = e^{0.85*ln0.0977}= e^{0.85*(-2.326)} = e^{-1.977} = 0.1385. r = 0.42*0.1385 = 0.058. So at x=-0.9 the tail is thin (0.058*1.28 = 0.075 tall). The fin from x=-0.9 extends out to -1.55 with a big shape — visually it starts at a thin part. Good, looks like a tail.
Dorsal fin:
```js
addTri([0.30, 0.28, 0], [-0.02, 0.88, 0], [-0.55, 0.30, 0]);
```
Check body top at x=0.30: t = 0.5-0.15 = 0.35, prof = sin(pi*0.35^0.62)^0.85: 0.35^0.62 = e^{0.62*(-1.0498)} = e^{-0.651} = 0.5216; sin(pi*0.5216) = sin(93.9°) = 0.9977; ^0.85 ≈ 0.998; r = 0.419 * (1+0.25*sin(pi*0.35)=1.216) → 0.419*1.28*1.216 ≈ 0.652. So the body top at x=0.30 is y≈0.65. My fin base at y=0.28 is inside the body — fine, but the fin's outer edge at y=0.88 only sticks 0.23 above the body. Let me put the dorsal peak higher: 1.05. And base points at y=0.3 at x=0.3 and x=-0.5. At x=-0.5: t=0.75, prof: 0.75^0.62 = e^{-0.1785}=0.8365; sin(pi*0.8365)=sin(150.6°)=0.4909; ^0.85=0.5395 → r=0.2266; *1.28*(1+0.25*sin(pi*0.75)=1.177) = 0.2266*1.28*1.177 = 0.341. So body top at x=-0.5 is 0.34. Putting the fin base at 0.25 is inside. Good.
Dorsal triangle: (0.35, 0.25), (-0.05, 1.02), (-0.55, 0.25). That will show nicely. Maybe make it 2 triangles for a curved shape. Fine as a triangle.
Anal fin (bottom, near tail): (-0.15, -0.3), (-0.45, -0.55)... let's skip or add a small one:
addTri([-0.15,-0.28,0], [-0.55,-0.5,0], [-0.7,-0.2,0]) — a small triangle at the bottom rear. Meh, optional. I'll add it for character.
Pectoral fins: small triangles on the sides. Skip.
Now the tail-sway vertex shader. The geometry x range is about [-1.62, 1.0]. The sway should be 0 at the head and max at the tail:
```glsl
float back = smoothstep(0.35, -1.6, transformed.x);
float w = sin(uTime * uFreq + uPhase - transformed.x * 2.0);
transformed.z += w * back * 0.28;
```
Hmm, careful: `smoothstep(edge0, edge1, x)` requires edge0 < edge1 for the standard behavior; if edge0 > edge1, results are undefined (though in practice it's `clamp((x-edge0)/(edge1-edge0))` which works with reversed edges giving an inverted ramp — GLSL spec says results are undefined if edge0 >= edge1). Better to use `1.0 - smoothstep(-1.6, 0.35, transformed.x)`.
Also, the fish is oriented with +X forward. The lateral sway should be in Z (the fish's local side-to-side). Yes.
Since the body is scaled/rotated by the mesh's own transform, the local geometry is in the fish's local space. Good.
Amplitude 0.28 at the tail. The tail tip at x=-1.6 gets a full 0.28 offset, plus the fins sway. That's a good visible swim.
Frequency: uFreq = 7 + speed-dependent. We can update uFreq per fish each frame based on its speed. That's a per-fish uniform we can update if we keep the shader ref. `mat.userData.shader = shader` inside onBeforeCompile. Then in the update loop, `if (fish.mat.userData.shader) fish.mat.userData.shader.uniforms.uFreq.value = ...`. Nice touch. Or just bake a constant. Let's do the update — it's easy and adds life.
But careful: onBeforeCompile is called at first render, so shader is undefined before the first render — guard with `if (shader)`.
Also `uTime` should be a shared uniform object so updating one value updates all: `shader.uniforms.uTime = uTime` where uTime is the shared object `{value: 0}`. Three stores the reference in the material's uniform map? For onBeforeCompile-modified materials, three creates the program's uniform list from the shader object at compile time and reads `shader.uniforms[name].value` each frame — since it's the same object reference, updating uTime.value works. Yes, this is a common pattern.
Alright.
Now the fish orientation matrix. Let me write a helper:
```js
const _m = new THREE.Matrix4();
const _up = new THREE.Vector3();
const _x = new THREE.Vector3();
const _y = new THREE.Vector3();
const _z = new THREE.Vector3();
const _q = new THREE.Quaternion();
function orientToward(mesh, dir, dt, lerpAmt){
_x.copy(dir).normalize();
_up.set(0,1,0);
if (Math.abs(_x.y) > 0.96) _up.set(0,0,1);
_z.crossVectors(_x, _up).normalize(); // hmm: is this right-handed?
...
}
```
Let's verify with an example: fish swimming toward +X (dir = (1,0,0)), up=(0,1,0). z = x × up = (1,0,0)×(0,1,0) = (0*0-0*1, 0*0-1*0, 1*1-0*0) = (0,0,1). Then y = z × x = (0,0,1)×(1,0,0) = (0*0-1*0, 1*1-0*0, 0-0) = (0,1,0). Great: basis x=(1,0,0), y=(0,1,0), z=(0,0,1) = identity. Fish nose points +X = direction.
Now if the fish swims toward -X (dir=(-1,0,0)): z = (-1,0,0)×(0,1,0) = (0*0-0*1, 0*0-(-1)*0, -1*1-0) = (0,0,-1). y = z × x = (0,0,-1)×(-1,0,0) = (0*0 - (-1)*0, (-1)(-1) - 0*0, 0 - 0) = (0,1,0). Basis: x=(-1,0,0), y=(0,1,0), z=(0,0,-1). Determinant: this is a rotation by 180° about Y. Correct.
Good.
Then:
```js
_m.makeBasis(_x, _y, _z);
_q.setFromRotationMatrix(_m);
mesh.quaternion.slerp(_q, 1 - Math.exp(-dt * 6));
```
But careful: scaling. The mesh's scale should be set once at creation, and quaternion rotation applied separately. Since scale is applied in the matrix as S then R then T, using quaternion + scale works fine. Setting mesh.scale once and using mesh.quaternion.slerp works.
However, `makeBasis` with non-normalized scale is not an issue since we normalized.
Now fish creation:
```js
const PALETTE = [0xff7b3d, 0xffc247, 0x4fe3ff, 0xff6fc6, 0x9be36a, 0x7f9cff, 0xff5a5a, 0x66ffd4];
```
Create 46 fish:
```js
for (let i = 0; i < 46; i++){
const geo = fishGeo; // shared geometry, each fish can scale
const color = new THREE.Color(PALETTE[i % PALETTE.length]).offsetHSL((Math.random()-0.5)*0.05, 0, (Math.random()-0.5)*0.1);
const mat = new THREE.MeshStandardMaterial({
color, roughness: 0.42, metalness: 0.28, side: THREE.DoubleSide,
emissive: color.clone().multiplyScalar(0.12)
});
...
}
```
Hmm, `new THREE.Color().offsetHSL` works. Fine.
Each fish's material with onBeforeCompile. Also add a subtle "rim" via emissive.
Material `side: DoubleSide` — with a closed body, double-side is a bit wasteful but needed for the fins. Alternatively make fins thick. Keep DoubleSide.
Set mesh.frustumCulled = true (default). Since the geometry is shared, bounding sphere is computed from the geometry — fine.
Fish init position: random within the tank, avoiding the floor and surface.
Fish scale: 0.6–1.4 times base, and vary by palette index? Random. Note the base fish is 2 units long, so scaled 0.6–1.4 → 1.2–2.8 units long, in a 22-wide tank. That's a reasonable "big fish" look. Maybe make them smaller: 0.5–1.1 → 1–2.2 units. Good.
Speeds: 1.2–2.4 units/s, fleeSpeed multiplier ~3.
Wander target selection within the tank bounds.
Now the update:
```js
const fleeR = 4.2;
function updateFish(dt){
for (const f of fishes){
f.timer -= dt;
if (f.timer <= 0){
f.timer = 2.5 + Math.random()*4;
f.target.set(rand(-9.5,9.5), rand(-1.5, 5.2), rand(-5.5, 5.5));
}
const desired = _v1.set(0,0,0);
// seek
_v2.subVectors(f.target, f.pos);
const distT = _v2.length();
if (distT > 0.001) desired.addScaledVector(_v2.divideScalar(distT), f.speed);
// flee
_v2.subVectors(f.pos, handPos);
const dp = _v2.length();
let fleeing = false;
if (dp < fleeR){
const k = 1 - dp / fleeR;
fleeing = true;
desired.addScaledVector(_v2.divideScalar(Math.max(dp, 0.001)), f.speed * 4.5 * k * k);
f.alarm = 0.9;
}
// bounds
...
// separation
...
// integrate
desired.clampLength(0, f.speed * (fleeing ? 5 : 1.6));
f.vel.lerp(desired, 1 - Math.exp(-dt * (fleeing ? 9 : 2.2)));
f.pos.addScaledVector(f.vel, dt);
...
}
}
```
Hmm, `desired.clampLength(0, max)` mutates in place — fine.
But there's a subtlety: when the fish is fleeing and then the cursor moves away, the desired becomes just the seek direction (small), and the lerp with k=2.2 is slow, so it coasts — good, it looks like it's calming down.
Both `desired` and the `_v2.divideScalar` mutate `_v2` — careful with reuse. Let me use separate temp vectors.
Separation: O(n²)=2116 iterations. For each pair within 1.4 units, add a push. Let's implement with a simple double loop computing distance squared. Fine.
Also, a mild cohesion/alignment would make them school. Let's skip to keep it simpler; instead, spawn fish in loose groups of similar color? Not needed.
Actually a nice touch: fish "flocking" makes the aquarium look more alive. But random wander is fine and less likely to glitch.
Bounds:
```js
const BX = 10.5, BYMIN = -2.0, BYMAX = 5.8, BZ = 6.2;
if (f.pos.x < -BX) desired.x += (-BX - f.pos.x) * 3;
if (f.pos.x > BX) desired.x += (BX - f.pos.x) * 3;
... same for y, z
```
Plus hard clamp after integration with a velocity bounce:
```js
if (f.pos.y < BYMIN) { f.pos.y = BYMIN; f.vel.y = Math.abs(f.vel.y)*0.5; }
```
Simple clamps are fine.
Also, for the visual: fish should not swim through rocks. Ignore.
Now `handPos` — the smoothed cursor in 3D. Also for the "ripple" visuals.
Let me think about how the seek+flee behaves when the hand is near the fish's target — fish may get stuck near bounds. Fine.
Fish mesh position update: `f.mesh.position.copy(f.pos)`. But note: for larger fish, the vertical center offset. Fine.
Sway speed uniform per fish: `uFreq = 5 + speed*1.5`.
Also add a subtle "swim faster" tail when fleeing — handled by uFreq.
Water surface plane at y = 7.0. The tank top is around y=6. The camera at y=2.6 looking level — the surface would be above the view. With fov 52 at distance 15, the visible half-height at z=0 is 15*tan(26°) = 7.3, so y from 2.6-7.3 to 2.6+7.3 = -4.7 to 9.9. The water surface at y=7 is visible. Good. And the floor at y=-3 is visible. Nice — we see the whole tank.
Hmm, but with fish up to y=5.8 and the water at 7, they're near the surface. Fine.
Actually, wait: the camera distance changes with aspect. At a wide 16:10 aspect, z=12 → half-height at z=0 = 12*tan26 = 5.85, y range -3.25..8.45. The floor at -3 is just visible at the bottom edge. Good but tight. Let me set the camera distance a bit larger: base 14 → half-height 6.8 → -4.2..9.4. Good. And the floor extends into the distance anyway.
Let's use `camera.position.set(0, 2.4, 14.5)` and look at (0, 2.0, 0). Slight downward tilt so the floor is more visible: lookAt(0, 1.6, 0).
Hmm, then the top water surface at y=7 might be near the top edge. Let's compute: camera at y=2.4 tilted down slightly toward (0,1.6,0): direction (0,-0.8,-14.5) normalized → pitch ~ -3.2°. Half-height at z=0 plane: distance ~14.5 → 7.07. Center of view at z=0 is y≈1.6. So the visible y range at z=0 is about -5.5 to 8.7. Water at 7 visible near the top.
At smaller aspect (mobile portrait), the camera moves back → more vertical range visible. Fine.
Let's now consider the visual composition: we see the sand floor filling the bottom third, water surface at the top, dark blue haze, fish swimming in between, kelp on the sides, rocks, bubbles. Sounds good.
Maybe add some distant "silhouette" shapes for depth? The fog handles it.
#### Bubbles detail
```js
const bubbleCount = 220;
const bpos = new Float32Array(bubbleCount * 3);
const bdata = [];
for (...){ x: rand(-11,11), y: rand(-3,7), z: rand(-6,6), speed: rand(0.4,1.4), phase, amp }
```
Each frame: y += speed*dt; x += sin(t*1.5+phase)*0.2*dt; if y > 7.2 → y = -3, new random x,z.
Set `positions.needsUpdate = true`.
Points material with a circular sprite texture, additive blending, size 0.22, sizeAttenuation true, depthWrite false, transparent, opacity 0.55, color 0xbfefff.
Marine snow: 500 points, size 0.06, slowly drifting, opacity 0.35.
Actually, maybe use one Points for snow and one for bubbles.
#### Cursor glow sprite
Canvas radial gradient texture:
```js
function makeGlowTexture(){
const s = 128;
const c = document.createElement('canvas'); c.width = c.height = s;
const ctx = c.getContext('2d');
const g = ctx.createRadialGradient(s/2,s/2,0,s/2,s/2,s/2);
g.addColorStop(0, 'rgba(190,245,255,1)');
g.addColorStop(0.25, 'rgba(110,210,255,0.55)');
g.addColorStop(1, 'rgba(40,120,200,0)');
ctx.fillStyle = g; ctx.fillRect(0,0,s,s);
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
```
Use it for the sprite and for the bubbles (a harder-edged circle).
Sprite:
```js
const cursorSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: glowTex, color: 0x9fe8ff, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, depthTest: false, opacity: 0.7 }));
cursorSprite.scale.set(2.6,2.6,1);
scene.add(cursorSprite);
```
depthTest false means it draws over the fish — it's a "god hand" indicator, that's fine and makes it always visible. Hmm, maybe keep depthTest true so it feels in the water. But then fish occlude it. I'll keep depthTest false with a low opacity so it reads as a UI element.
Add a second, larger, thinner sprite for the halo. Or animate the scale.
Ripple rings: create 3 ring sprites that scale up and fade on a loop, positioned at handPos. A ring texture would be needed. Let's generate a ring texture on canvas: stroke a circle with a radial gradient. Then use sprites that pulse: scale from 1 to 3 and opacity 0.5 → 0.
That's a nice "ping" effect showing where you are.
Actually — the movement itself moves the glow. Ripples might be too much. Let's add one ring sprite that pulses based on time: `scale = 1.2 + (t*0.8 % 1)*2.2`, `opacity = 0.45*(1 - (t*0.8%1))`. Simple.
Hmm, sprites with additive blending and a ring texture: good.
#### Also: the fish could have a "flee" visual cue — e.g., they flash brighter. Skip.
#### Resize handler
```js
function onResize(){
const w = window.innerWidth, h = window.innerHeight;
renderer.setSize(w,h);
renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2));
camera.aspect = w/h;
camera.position.z = THREE.MathUtils.clamp(14.5 / Math.sqrt(Math.min(camera.aspect, 1.7)) * 1.0, 12, 24);
camera.updateProjectionMatrix();
}
```
Hmm, the formula: at aspect 1.7 → 14.5/1.30 = 11.1 → clamped to 12. At aspect 1.0 → 14.5. At 0.5 → 20.5. OK. But the camera y/lookAt should stay.
Let me define:
```js
const baseDist = 14.5;
camera.position.z = THREE.MathUtils.clamp(baseDist * Math.pow(1.6/aspect, 0.55), 12, 26);
```
At aspect 1.6 → 14.5. At 1.0 → 14.5*1.6^0.55 = 14.5*1.29 = 18.7. At 0.5 → 14.5*(3.2)^0.55 = 14.5*1.88 = 27.3 → clamp 26. Hmm, that's quite far back for mobile. The vertical view will be huge. Let's just use clamp to [12, 22] and accept.
Actually, an alternative for mobile: increase the fov instead of moving back, so vertical exaggeration isn't so bad... same effect. Moving back is fine; the fog will hide the emptiness.
Let me just use `camera.position.z = THREE.MathUtils.clamp(14 * Math.pow(1.55 / aspect, 0.45), 11, 21);`
aspect 1.55 → 14. aspect 0.5 → 14*(3.1)^0.45 = 14*1.66 = 23.2 → 21. OK.
Fine.
#### Let's write the fish shader injection
```js
const fishUniforms = { uTime: { value: 0 } };
function makeFishMaterial(color){
const mat = new THREE.MeshStandardMaterial({
color,
roughness: 0.45,
metalness: 0.25,
side: THREE.DoubleSide,
emissive: color.clone().multiplyScalar(0.15),
});
mat.onBeforeCompile = (shader) => {
shader.uniforms.uTime = uTime;
shader.uniforms.uPhase = { value: Math.random() * Math.PI * 2 };
shader.uniforms.uFreq = { value: 6.0 };
shader.uniforms.uAmp = { value: 0.22 };
shader.vertexShader = `
uniform float uTime;
uniform float uPhase;
uniform float uFreq;
uniform float uAmp;
` + shader.vertexShader.replace(
'#include <begin_vertex>',
`
#include <begin_vertex>
float backAmt = 1.0 - smoothstep(-1.7, 0.4, transformed.x);
float w = sin(uTime * uFreq + uPhase - transformed.x * 2.2);
transformed.z += w * backAmt * uAmp;
`
);
mat.userData.shader = shader;
};
mat.customProgramCacheKey = () => 'fish-sway';
return mat;
}
```
`customProgramCacheKey` ensures all fish share one program. Good practice.
Hmm: the material's uniforms aren't tracked by three for `onBeforeCompile` materials unless... Actually, three's WebGLRenderer, for materials with `onBeforeCompile`, uses `materialProperties.uniforms = shader.uniforms` and updates them each frame via `WebGLUniforms.upload(gl, materialProperties.uniforms, ...)`? Let me recall `setProgram`:
```js
if ( material.isShaderMaterial || material.isRawShaderMaterial || material.onBeforeCompile !== undefined ... )
```
Hmm. In `WebGLRenderer.setProgram`:
```js
if ( materialProperties.needsLights ) { ... }
...
if ( refreshProgram || currentCamera !== camera ) { ... }
if ( material.isShaderMaterial && material.uniformsNeedUpdate === true ) { ... }
...
```
And uniforms for ShaderMaterial are uploaded via `WebGLUniforms.upload(gl, materialProperties.uniforms, uniformsList, textures)`; for built-in materials, `materialProperties.uniforms` is also used. Let me check: In `WebGLRenderer`, after `setProgram`:
```js
if ( material.isShaderMaterial || material.isRawShaderMaterial ) {
...
} else {
// common
}
```
Hmm. Actually, in `setProgram`, there's:
```js
if ( materialProperties.needsLights ) { ...markUniformsLightsNeedsUpdate... }
...
// refresh uniforms common to several materials
if ( refreshMaterial ) {
...
if ( material.isMeshStandardMaterial ) { ... }
...
// refresh uniforms
if ( material.isShaderMaterial && material.uniformsNeedUpdate === true ) {
WebGLUniforms.upload( _gl, materialProperties.uniforms, m_uniforms, textures );
material.uniformsNeedUpdate = false;
}
}
```
Hmm, that's only for ShaderMaterial. But then how do onBeforeCompile custom uniforms get updated for standard materials? In `WebGLRenderer.setProgram`, near the end:
```js
// common matrices
p_uniforms.setValue( _gl, 'modelViewMatrix', object.modelViewMatrix );
...
```
and inside `setProgram`:
```js
if ( materialProperties.uniforms !== undefined ) { ... }
```
Hmm. Let me recall from `WebGLRenderer.js` (r150ish):
```js
function setProgram( camera, scene, geometry, material, object ) {
...
if ( materialProperties.needsLights ) { ... }
...
if ( refreshProgram || _currentCamera !== camera ) { ... }
if ( material.isShaderMaterial && material.uniformsNeedUpdate === true ) { ... }
...
}
```
And in the render loop, there's:
```js
// in WebGLRenderer.renderBufferDirect or setProgram
```
Actually I distinctly remember that in three, custom uniforms added via onBeforeCompile DO update automatically every frame, because `materialProperties.uniforms` is used in `WebGLUniforms.upload` inside the `setProgram` function? Hmm.
Let me think about `WebGLMaterials.refreshMaterialUniforms`, which handles built-in uniforms. Custom uniforms added by onBeforeCompile are in `materialProperties.uniforms` (the shader.uniforms object)... and there is this in setProgram:
```js
if ( materialProperties.uniformsNeedUpdate === true ) ...
```
Hmm. I'm quite sure the common pattern used by many examples (e.g., the official "materials / modified" example, or countless Codepen shaders) is:
```js
material.onBeforeCompile = shader => {
shader.uniforms.uTime = { value: 0 };
material.userData.shader = shader;
};
// in animation loop
if (material.userData.shader) material.userData.shader.uniforms.uTime.value = t;
```
And this works — it's the standard pattern (used in the three.js docs example for onBeforeCompile). So yes, uniform values are uploaded each frame. I'm confident because `WebGLRenderer.setProgram` has:
```js
if ( material.isShaderMaterial || material.isRawShaderMaterial ) {
...
} else {
...
}
```
Hmm, but the docs example "How to update a shader" states exactly this pattern. Also many demos share a single `uTime` uniform object between materials. It works because `WebGLUniforms.upload` is called with `materialProperties.uniforms` somewhere each frame. In `setProgram`:
```js
if ( refreshProgram || _currentCamera !== camera ) {
...
// refresh uniforms common to several materials
...
}
```
Hmm, `refreshProgram` is true when the program changes; if the same program is used for all fish but they're different materials, the program changes once and then stays. Then the custom uniforms would only upload on material switch...
OK let me look at this more carefully from memory of three's source (r160 `WebGLRenderer.setProgram`):
```js
if ( material.isShaderMaterial && material.uniformsNeedUpdate === true ) { ... }
```
No wait — I recall this in `WebGLRenderer.renderBufferDirect`:
```js
if ( material.isShaderMaterial ) { ... }
```
Honestly, the safest approach: update the uniform in the render loop AND also ensure that the uniform is uploaded. Actually, I recall clearly that in the newer three versions, `setProgram` includes:
```js
if ( refreshMaterial ) {
p_uniforms.setValue( _gl, 'toneMappingExposure', _this.toneMappingExposure );
if ( materialProperties.needsLights ) { markUniformsLightsNeedsUpdate( m_uniforms, refreshLights ); }
if ( fog && material.fog === true ) { materials.refreshFogUniforms( m_uniforms, fog ); }
materials.refreshMaterialUniforms( m_uniforms, material, _pixelRatio, _height, _transmissionRenderTarget );
WebGLUniforms.upload( _gl, getUniformList( materialProperties ), m_uniforms, textures );
}
```
and `getUniformList(materialProperties)` returns `materialProperties.uniformsList` which is built in `WebGLProgram`/`WebGLProperties` from the program's active uniforms, and `m_uniforms = materialProperties.uniforms` which is `shader.uniforms` (from onBeforeCompile). So yes — `WebGLUniforms.upload(gl, uniformsList, m_uniforms, textures)` uploads ALL active uniforms from the material's uniform object each time `refreshMaterial` is true. `refreshMaterial` is true when the material changes or `materialProperties.receiveShadow !== object.receiveShadow` etc. Hmm, `refreshMaterial = materialProperties.version !== material.version` or the program changed.
Hmm, hold on. In r155+:
```js
const materialProperties = properties.get( material );
...
let refreshProgram = false;
let refreshMaterial = false;
let refreshLights = false;
...
if ( _currentProgram !== program ) { refreshProgram = true; refreshMaterial = true; ... }
if ( material.id !== _currentMaterialId ) { _currentMaterialId = material.id; refreshMaterial = true; }
...
if ( refreshMaterial ) { ... WebGLUniforms.upload(...) }
```
So with different materials for each fish, `material.id !== _currentMaterialId` → refreshMaterial = true → all uniforms uploaded.
So the per-fish uniforms (uPhase, uFreq, uAmp) will be uploaded each time the material changes. Good, and uTime too. Since each fish has its own material, everything updates.
OK, but the kelp uses a shared material with an onBeforeCompile uniform — same story: when the material is drawn, refreshMaterial is true for the first draw but subsequent draws of the same material in a row won't refresh... but the uniform value doesn't change within a frame anyway. Across frames: `_currentMaterialId` is reset? Hmm, `_currentMaterialId` persists across frames until another material is used. But between frames many materials get drawn, so by the time we come back to the kelp material it will be different from the current one → refresh. In practice this pattern works everywhere. Good.
To be extra safe with uTime for shared materials, I could update `uTime.value` before rendering — which we do.
Fine.
Now let's double check the `begin_vertex` injection: `#include <begin_vertex>` expands to `vec3 transformed = vec3( position );`. Our replacement refers to `transformed.x`. Good. And `transformed.z` modification happens before `project_vertex` (which applies modelViewMatrix * instanceMatrix). Good.
But note: `#include <beginnormal_vertex>` comes before, so normals aren't affected — fine.
One issue: `<displacementmap_vertex>` and `<morphtarget_vertex>` come after `begin_vertex`? The order in meshphysical_vert is:
```
#include <begin_vertex>
#include <morphinstance_vertex>
#include <morphtarget_vertex>
#include <skinning_vertex>
#include <displacementmap_vertex>
#include <project_vertex>
```
Our injection right after begin_vertex is fine.
#### Fish creation loop details
```js
const fishes = [];
const FISH_COUNT = 44;
for (let i = 0; i < FISH_COUNT; i++){
const color = new THREE.Color(PALETTE[i % PALETTE.length]);
color.offsetHSL((Math.random()-0.5)*0.06, (Math.random()-0.5)*0.15, (Math.random()-0.5)*0.08);
const mat = makeFishMaterial(color);
const mesh = new THREE.Mesh(fishGeometry, mat);
const s = 0.55 + Math.random()*0.6;
mesh.scale.setScalar(s);
scene.add(mesh);
const f = {
mesh, mat,
pos: new THREE.Vector3(rand(-9,9), rand(-1.5,5), rand(-5,5)),
vel: new THREE.Vector3(rand(-1,1), rand(-0.2,0.2), rand(-1,1)).normalize().multiplyScalar(1.5),
target: new THREE.Vector3(),
timer: Math.random()*4,
speed: 1.0 + Math.random()*1.4,
size: s,
phase: Math.random()*6.28,
};
f.target.copy(f.pos);
fishes.push(f);
}
```
Note: the fish's `pos` is its center. The mesh origin is at the fish's center (geometry spans x -1.6..1). Good.
Hmm, one issue: the fish scale affects the perceived speed. Bigger fish should be a bit slower. Fine.
#### Kelp
Let me write:
```js
const kelpGeo = new THREE.PlaneGeometry(0.42, 1, 1, 10);
kelpGeo.translate(0, 0.5, 0); // base at y=0, top at y=1
```
Then scale each blade by (1, height, 1).
Wait, with PlaneGeometry(w, h, 1, 10) and then scaling by height, the uv.y stays 0..1. In the shader I'll use `uv.y * uv.y` for the bend amount, so use `position.y` after translate (0..1). But the geometry is later scaled by the mesh transform, so the bend in the local space (before scaling) will also be scaled by the mesh scale — x-offset scaled by width, which is small. Hmm — if the mesh scale is (1, 5, 1), the local x offset gets scaled by 1 in x. So a bend of 0.3 local x becomes 0.3 world. Fine, since the width scale is ~1. Actually let me set the mesh scale to (1, height, 1) with the base width 0.42 → width 0.42. To vary width, use scale.x separately, which also scales the bend. Acceptable.
Since the plane's local y goes 0..1, bend formula:
```glsl
float b = position.y;
transformed.x += (sin(uTime*1.3 + uPhase + b*2.5) * 0.35 + sin(uTime*2.7 + uPhase*1.8) * 0.1) * b * b * 2.0;
```
At b=1: amplitude up to 0.9 in local x, but scaled by mesh scale.x ≈ 1. Hmm, the blade is 5 units tall and bends up to 0.9 — reasonable.
Actually the bend amount should increase with height so the base stays put: b*b does that. Good.
Material: MeshStandardMaterial({ color: 0x2f7d5a, roughness: 0.9, side: DoubleSide, emissive: 0x0a2a1e, emissiveIntensity: 0.6 }).
Hmm, with lighting from above, the kelp will be lit. Fine.
Place 4 clusters at (-9, -3, -3), (-7, -3, 3), (8.5, -3, -2), (10, -3, 2.5) with 8-12 blades each.
Actually, the visible floor is around y=-3. Let's put the clusters near the left and right edges so they frame the scene.
#### Rocks
Place ~10 rocks along the floor with random scale 0.6-2.2, at y ≈ -3 + 0.3*scale. Slight y-squash.
#### Fish flee radius vs. cursor depth
The cursor point is on the z=0 plane, so fish at z=-5 or +5 will still react if their x,y is close (since we use 3D distance, the z-difference adds). With fleeR = 4.5, a fish at z=-5 is at least 5 away → never flees. Hmm! That's a problem: fish in the back half wouldn't react at all.
Better: compute the distance in a way that ignores or heavily attenuates z. Or place the cursor point at the fish's z. Simplest: use distance in x/y only (2.5D), scaled: `dx, dy` and maybe `dz*0.35`. Let's use an ellipsoid: `d = sqrt(dx² + dy² + (dz*0.4)²)`. Then a fish at z=-5 → dz*0.4 = 2, still within a radius of 4.5 → reacts. Hmm, that means fish far in the back react to the cursor, which might feel odd but ensures all fish react. It's an aquarium; the "hand" reaching through is a stylistic choice.
Alternatively, set the interaction plane at the fish's z... no.
I think the ellipsoid with a z factor of ~0.5 is a good compromise. Let's use fleeR = 4.5 with dz*0.45.
Also, fish should react to the cursor even when it's outside the tank — the ray may hit outside the tank. Clamp the hand position to the tank bounds:
```js
handTarget.x = clamp(x, -12, 12); y = clamp(y, -3, 8); z = 0;
```
#### Putting handPos into the scene
Use the raycast target directly, smoothed.
Let's write the pointer code:
```js
const pointerNDC = new THREE.Vector2(0,0);
const raycaster = new THREE.Raycaster();
const handPlane = new THREE.Plane(new THREE.Vector3(0,0,1), 0);
const handTarget = new THREE.Vector3(0,2,0);
const handPos = new THREE.Vector3(0,2,0);
let realPointer = false;
let lastMove = -10;
let elapsed = 0;
function updatePointer(dt){
if (!realPointer){
// auto demo path
const t = elapsed;
pointerNDC.set(
Math.sin(t*0.55)*0.72 + Math.sin(t*1.31+1.0)*0.14,
Math.cos(t*0.37)*0.42 + Math.sin(t*0.93)*0.12
);
}
raycaster.setFromCamera(pointerNDC, camera);
if (raycaster.ray.intersectPlane(handPlane, _v3)){
handTarget.copy(_v3);
}
handTarget.x = clamp(handTarget.x, -12, 12);
handTarget.y = clamp(handTarget.y, -2.6, 7.5);
handPos.lerp(handTarget, 1 - Math.exp(-dt * 10));
}
```
Note: `raycaster.setFromCamera` with a PerspectiveCamera → fine.
`intersectPlane` returns null if the ray is parallel — with the camera looking down -z it's fine. But guard anyway.
On pointermove:
```js
window.addEventListener('pointermove', (e) => {
if (!realPointer){ realPointer = true; badge... }
pointerNDC.x = (e.clientX / window.innerWidth) * 2 - 1;
pointerNDC.y = -(e.clientY / window.innerHeight) * 2 + 1;
});
```
That's it — updatePointer will use pointerNDC when realPointer is true.
Also, on touch devices, `pointermove` fires during a drag. Add `pointerdown` too, to set realPointer and position. Good.
Add a fallback: if `realPointer` and no movement for 12 seconds, resume the demo? Nah — but then a screenshot after a click and no movement... The screenshot is taken at ~3s, before any interaction, so the demo is running. Good.
Hmm, but what if the screenshot tool moves the mouse? Unlikely. Fine.
Wait — if the page gets a `pointermove` from the automated screenshot at load (e.g., the mouse happens to be over the iframe), the demo would stop and the fish would react to a static cursor. The fish would still be swimming and a glow would show. Acceptable. But to be safe, require some minimal movement: only switch to real mode if the pointer moves more than a few pixels... Let's just switch on any pointermove; it's fine. Actually, if the cursor sits still at the edge, the scene looks less dynamic. Let's add: if `realPointer` and no pointer movement for 6 seconds, blend back to auto? That could be confusing when the user just holds still to watch. I'll skip it.
Hmm, but here's a consideration for the side-by-side comparison: the auto-demo makes the fish scatter, showing the feature. Good. I'll keep the fish reacting to the auto cursor.
#### Also: keyboard? Not needed. But the instructions mention games needing click/keyboard. Our page needs neither. But maybe add a fallback "click/tap" to reset? Not needed.
I'll still make sure the pointer events work without a click (they do).
#### Now the water surface shader
```js
const surfaceGeo = new THREE.PlaneGeometry(90, 90, 1, 1);
const surfaceMat = new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
side: THREE.DoubleSide,
uniforms: { uTime },
vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
fragmentShader: `
varying vec2 vUv;
uniform float uTime;
float causticPattern(vec2 p, float t){ ... }
void main(){
vec2 p = vUv * 26.0;
float c = causticPattern(p, uTime * 0.9);
float edge = smoothstep(0.0, 0.15, vUv.x) * smoothstep(1.0, 0.85, vUv.x) * smoothstep(0.0,0.15,vUv.y) * smoothstep(1.0,0.85,vUv.y);
vec3 col = mix(vec3(0.03,0.16,0.26), vec3(0.55,0.92,1.0), c);
gl_FragColor = vec4(col * c * 1.2, c * 0.7 * edge);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`
});
```
Hmm, additive blending with a transparent material: the alpha doesn't matter much with additive (src*1 + dst*1 with premultiplied alpha? Three uses `blendSrc = SrcAlphaFactor` for NormalBlending; for AdditiveBlending it's `blendSrc = SrcAlpha, blendDst = One`). Let me recall: AdditiveBlending in three = `gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE, gl.ONE, gl.ONE)`? Actually `AdditiveBlending: src = SrcAlphaFactor, dst = OneFactor`. So the fragment color is multiplied by its alpha then added. So alpha matters. Setting alpha = c*0.7 means the bright parts show. Good.
The `#include <tonemapping_fragment>` on additive content might dim it. Fine.
Wait, order matters: tonemapping applies to gl_FragColor.rgb, then colorspace converts to sRGB. With additive blending, the sRGB conversion happens before blending, which is not physically correct but is what three does for transparent materials. Fine.
The water plane at y=7, rotated `-Math.PI/2`? A plane's normal is +z. To face downward (-y), rotate x by +PI/2: rotating (0,0,1) about X by +90° gives (0, ... ) let's compute: R_x(θ) applied to (0,0,1) = (0, -sinθ, cosθ). At θ=+90°: (0,-1,0) → pointing down. Good, visible from below (front face down). With DoubleSide, it doesn't matter.
Position y = 7.
Hmm, but with `depthWrite:false` and additive, the surface will render over the fish if it's drawn after them? No — depth test still applies (depthTest default true), so fish in front (closer to the camera) will occlude it. Since the plane is above and behind (far), it's fine.
Add a subtle bright "sun" spot on the water surface? Skip.
#### Scene composition check
The floor plane at y=-3, size 160 → extends to z=-80, well into the fog. Good.
#### Background sphere radius 160 vs. camera far 400 — fine. But the floor extends 80 units and the sphere is 160 radius; the floor's far edge at 80 will be inside the sphere. Good.
Hmm, the floor is 160x160 centered at origin → extends to ±80 in x and z. The sphere radius 160. Fine.
#### Fog color vs. background
Fog 0x07314c. The background sphere at the horizon is around 0x062f4a. Good match.
Let's now write everything out carefully.
Additional detail: tone mapping ACES will desaturate/darken. Set exposure 1.2.
Lighting intensities with ACES: DirectionalLight 2.5, Hemisphere 1.5. Standard material with roughness 0.45 → fine.
Let me reconsider the fish colors: with ACES and underwater blue light, orange fish will pop nicely. Good.
One more thing: add a subtle rim/emissive so fish don't go pitch black in shadow. Set `emissive = color * 0.12`.
#### Bubbles texture
```js
function makeCircleTexture(){
const s = 64;
const c = document.createElement('canvas'); c.width = c.height = s;
const ctx = c.getContext('2d');
const g = ctx.createRadialGradient(s/2, s/2, 0, s/2, s/2, s/2);
g.addColorStop(0, 'rgba(255,255,255,1)');
g.addColorStop(0.45, 'rgba(200,240,255,0.75)');
g.addColorStop(0.8, 'rgba(160,220,255,0.18)');
g.addColorStop(1, 'rgba(160,220,255,0)');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(s/2,s/2,s/2,0,Math.PI*2); ctx.fill();
const t = new THREE.CanvasTexture(c);
t.colorSpace = THREE.SRGBColorSpace;
return t;
}
```
For Points with `map` and `transparent`, `alphaTest` not needed with additive.
#### Marine snow
Points with 400 particles spread over a large volume, tiny size, low opacity, slow drift. Update in JS with a small sine. Or use a static Points and just rotate slowly. Let's animate the y slowly (like 0.05/s) and wrap.
I'll combine bubbles and snow into one update loop with separate arrays.
#### Let me now think about the "hand" glow more.
`cursorSprite` position = handPos, with scale pulsing.
Plus a ring sprite.
Ring texture:
```js
function makeRingTexture(){
const s = 128;
const c = document.createElement('canvas'); c.width = c.height = s;
const ctx = c.getContext('2d');
ctx.strokeStyle = 'rgba(170,235,255,1)';
ctx.lineWidth = 6;
ctx.beginPath(); ctx.arc(s/2, s/2, s/2 - 8, 0, Math.PI*2); ctx.stroke();
// blur it by drawing a radial gradient? Use shadowBlur
...
}
```
Add `ctx.shadowBlur = 12; ctx.shadowColor = 'rgba(150,230,255,1)';` before stroking for a glow. Good.
Ring sprite scale pulse:
```js
const p = (elapsed * 0.55) % 1;
ring.scale.setScalar(1.0 + p * 3.4);
ring.material.opacity = (1 - p) * 0.35;
```
Both sprites at handPos.
#### Making sure the first paint is good
Everything is created synchronously before the first render, except onBeforeCompile which compiles at first render — fine.
Potential issue: `#include <tonemapping_fragment>` inside a ShaderMaterial fragment where `gl_FragColor` is used — fine.
Let's double-check that the ShaderMaterial doesn't complain about missing `uv` attribute — PlaneGeometry has uv. Fine.
Now — one more risk: using `#include <colorspace_fragment>` in a ShaderMaterial. If it fails, the whole scene breaks (shader compile error → console error → "zero errors" requirement violated). Let me weigh: I'm ~92% confident it works. Many three.js examples do exactly this. E.g., the official "webgl_shader" example? Hmm, those use RawShaderMaterial without the includes. But the docs for ShaderMaterial mention that for correct color management you should include `#include <colorspace_fragment>`. Yes, that's documented. I'm confident.
Actually, in three r152+, the docs say: "ShaderMaterial ... the output is expected to be in linear-sRGB color space, and three.js will convert it to sRGB automatically if you include `#include <colorspace_fragment>`." I've also seen the pattern used in many places. OK, proceed.
Alternatively, avoid ShaderMaterials entirely... no, let's go.
Hmm, but one thing: for `#include <tonemapping_fragment>`, if `material.toneMapped` is true and the renderer's toneMapping is ACES, the define TONE_MAPPING is set. Good.
#### Now the code for onBeforeCompile uniform sharing
For the floor and kelp, I'll use separate uniform objects but update them from the shared time value each frame, or just share `uTime` object. Sharing is fine since the value is the same.
#### Time variable
```js
let time = 0;
const uTime = { value: 0 };
```
In the loop: `time += dt; uTime.value = time;`
#### Write the animate function
```js
function animate(){
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
time += dt;
uTime.value = time;
updatePointer(dt);
updateFish(dt);
updateParticles(dt);
updateDecor();
renderer.render(scene, camera);
}
```
Camera drift: subtle.
```js
camera.position.x = Math.sin(time*0.13) * 0.8 + pointerNDC.x * 0.6;
camera.position.y = 2.4 + Math.sin(time*0.17)*0.25 + pointerNDC.y*0.3;
camera.lookAt(0, 1.8, 0);
```
But we set camera.position.z in onResize; keep that. The parallax based on the pointer adds immersion. Note: moving the camera changes the raycast → feedback loop with the hand position. Since the parallax is small (0.6), it's fine, but it could cause jitter. Let's use a smoothed pointer for the camera. Hmm, the raycast is done with the current camera; if the camera moves based on the hand, there's feedback: pointer → hand → camera → raycast → hand. With smoothing, it should be stable enough. To be safe, use a separate smoothed value for the camera parallax and lower the gain (0.35). Or skip camera parallax. I'll include it with a small gain and heavy smoothing; it adds depth.
Actually, let's avoid the feedback loop and use a fixed camera with a slow automatic drift (sin waves) only. That's simpler and gives a "floating" feel without feedback. Hmm, but the parallax with the mouse is a nice 3D cue. I'll do the drift only, no pointer parallax. Wait, actually, since the hand is computed from the raycast against the z=0 plane, a small camera shift changes the hand position slightly. That's actually natural. But it can oscillate. Let's keep it simple: no camera movement at all, or a very subtle drift. I'll add a subtle constant drift (independent of the pointer):
```js
camera.position.x = Math.sin(time * 0.07) * 1.2;
camera.position.y = baseY + Math.sin(time * 0.11) * 0.3;
```
and `camera.lookAt(0, 1.7, 0)` each frame. Good.
Wait: if the camera drifts, the raycast changes, causing the hand to move slightly — that's fine (no feedback since the camera doesn't depend on the pointer).
#### updateFish: needs handPos and dt.
Also, we should give fish a slight "avoid the surface and floor" behavior. Done via bounds.
Let me now write the full updateFish with proper temp vector usage:
```js
const _v1 = new THREE.Vector3();
const _v2 = new THREE.Vector3();
const _v3 = new THREE.Vector3();
const _q = new THREE.Quaternion();
const _m = new THREE.Matrix4();
const _ax = new THREE.Vector3();
const _ay = new THREE.Vector3();
const _az = new THREE.Vector3();
const _upv = new THREE.Vector3(0,1,0);
function updateFish(dt){
for (let i = 0; i < fishes.length; i++){
const f = fishes[i];
// pick new wander target
f.timer -= dt;
if (f.timer <= 0){
f.timer = 2 + Math.random() * 5;
f.target.set(
-9.5 + Math.random() * 19,
-1.6 + Math.random() * 6.8,
-5.5 + Math.random() * 11
);
}
const desired = _v1.set(0, 0, 0);
// seek
_v2.subVectors(f.target, f.pos);
const dTarget = _v2.length();
if (dTarget > 0.001){
desired.addScaledVector(_v2.multiplyScalar(1/dTarget), f.speed);
}
// flee from cursor
let fleeing = false;
_v2.subVectors(f.pos, handPos);
_v2.z *= 0.45; // ellipsoid: react across depth
const dHand = _v2.length();
if (dHand < FLEE_R){
const k = 1 - dHand / FLEE_R;
_v2.multiplyScalar(1 / Math.max(_v2.length(), 0.0001)); // hmm, length changed after z*=
...
}
```
Careful: after `_v2.z *= 0.45`, `_v2.length()` != dHand. Let's compute the normalized direction from the ORIGINAL offset, not the scaled one. Let me use a separate temp:
```js
_v2.subVectors(f.pos, handPos); // full 3D
const dHand = Math.hypot(_v2.x, _v2.y, _v2.z * 0.45); // squashed distance
if (dHand < FLEE_R){
const k = 1 - dHand / FLEE_R;
_v2.normalize(); // normalize full 3D offset
desired.addScaledVector(_v2, f.speed * 5.0 * k * k);
f.alarm = 0.8;
fleeing = true;
}
```
Fine. `Math.hypot` on every fish per frame is fine (44 fish).
If the hand is exactly at the fish position, `_v2.normalize()` gives (0,0,0) — no NaN, just zero. Then the flee does nothing; rare. Add a small random push if length is tiny. Let's guard: if `_v2.lengthSq() < 1e-6` set `_v2.set(0,1,0)`. Actually `normalize()` handles zero by dividing by 1 (three's `divideScalar(length() || 1)`). Good, no NaN.
Bounds:
```js
const BX = 10.5, BY0 = -2.2, BY1 = 6.0, BZ = 6.0;
const margin = 1.6;
if (f.pos.x < -BX + margin) desired.x += (-BX + margin - f.pos.x) * 3.0;
if (f.pos.x > BX - margin) desired.x -= (f.pos.x - (BX - margin)) * 3.0;
... y, z
```
Hmm, wanting them to turn before the wall. Use the desired velocity approach: the added velocity is proportional to the distance beyond the margin. Good.
Separation:
```js
for (let j = 0; j < fishes.length; j++){
if (j === i) continue;
const o = fishes[j];
const dx = f.pos.x - o.pos.x, dy = f.pos.y - o.pos.y, dz = f.pos.z - o.pos.z;
const d2 = dx*dx + dy*dy + dz*dz;
if (d2 < 2.25 && d2 > 0.0001){
const inv = 1 / Math.sqrt(d2);
const push = (1.5 - Math.sqrt(d2)) / 1.5;
desired.x += dx * inv * push * 2.0;
desired.y += dy * inv * push * 1.2;
desired.z += dz * inv * push * 2.0;
}
}
```
The desired velocity here is in units/s; adding a push velocity of up to 2.0 is comparable to the swim speed. Good.
Clamp:
```js
const maxD = f.speed * (fleeing ? 4.5 : 1.5);
if (desired.lengthSq() > maxD*maxD) desired.setLength(maxD);
...
```
Hmm, `desired` for the seek is already `f.speed`; adding separation/flee can exceed. Set the max to `f.speed * (fleeing ? 4.5 : 1.8)`.
Integrate:
```js
const lerpK = 1 - Math.exp(-dt * (fleeing ? 10 : 2.5));
f.vel.lerp(desired, lerpK);
f.pos.addScaledVector(f.vel, dt);
// hard clamp
f.pos.x = clamp(f.pos.x, -BX, BX);
...
```
Hard clamps keep them in the tank even if something goes wrong. Good.
Then the mesh:
```js
f.mesh.position.copy(f.pos);
const vlen = f.vel.length();
if (vlen > 0.05){
_ax.copy(f.vel).divideScalar(vlen);
_upv.set(0,1,0);
if (Math.abs(_ax.y) > 0.95) _upv.set(0,0,1);
_az.crossVectors(_ax, _upv).normalize();
_ay.crossVectors(_az, _ax).normalize();
_m.makeBasis(_ax, _ay, _az);
_q.setFromRotationMatrix(_m);
f.mesh.quaternion.slerp(_q, 1 - Math.exp(-dt * 7));
}
```
Hmm, `_upv` is a shared temp that we reset every time — fine.
Wait, there's an issue with `_az.crossVectors(_ax, _upv)`: if `_ax` is nearly parallel to `_upv`, the result is tiny → normalize() → zero vector (three's normalize divides by length || 1 → returns (0,0,0)), then `_ay = _az × _ax` = 0 → makeBasis with zero vectors → degenerate quaternion → maybe NaN. Guard with the `|_ax.y| > 0.95` check switching `_upv` to (0,0,1). Good.
Also, `setFromRotationMatrix` on a degenerate matrix produces NaN. The guard handles the main case.
Tail anim speed:
```js
if (f.mat.userData.shader){
f.mat.userData.shader.uniforms.uFreq.value = 5.0 + vlen * 2.2;
}
```
#### updateParticles
Bubbles: I'll store arrays of base data and update the position attribute.
```js
const bubbleCount = 200;
const bubblePos = new Float32Array(bubbleCount*3);
const bubbleData = [];
for (let i=0;i<bubbleCount;i++){
const x = -12 + Math.random()*24;
const y = -3 + Math.random()*10;
const z = -7 + Math.random()*14;
bubblePos[i*3]=x; bubblePos[i*3+1]=y; bubblePos[i*3+2]=z;
bubbleData.push({ speed: 0.5 + Math.random()*1.2, phase: Math.random()*Math.PI*2, amp: 0.15 + Math.random()*0.5 });
}
```
Update:
```js
for (let i=0;i<bubbleCount;i++){
let y = bubblePos[i*3+1] + bubbleData[i].speed * dt;
let x = bubblePos[i*3] + Math.sin(time*1.6 + bubbleData[i].phase) * bubbleData[i].amp * dt;
if (y > 7.2){ y = -3; x = -12 + Math.random()*24; bubblePos[i*3+2] = -7 + Math.random()*14; }
bubblePos[i*3] = x; bubblePos[i*3+1] = y;
}
geom.attributes.position.needsUpdate = true;
```
Good.
#### Kelp update
The kelp is shader-animated, so nothing needed in JS besides uTime.
#### Rocks
Static.
Alright, let me also consider adding a "school" of tiny fish in the distance to add depth. Skip.
#### CSS/HUD
Make sure the HUD doesn't block pointer events: `#hud { pointer-events: none; }`.
Text content:
- Brand: "DEEP REEF" with a subtitle "an interactive three.js aquarium".
- Bottom pill: "Move your cursor to scatter the fish" with a pulsing dot.
- Top-right badge: "AUTO DEMO" with a pulsing orange dot; on interaction → "LIVE".
Also, maybe a small credit "three.js" bottom right. Skip.
Add a loading state? The scene loads instantly (all procedural). Fine.
Let's write the CSS.
```css
*{box-sizing:border-box}
html,body{margin:0;height:100%;overflow:hidden;background:#04121e;}
body{font-family:...;color:#d8f2ff;-webkit-font-smoothing:antialiased;}
canvas{position:fixed;inset:0;display:block;z-index:1;}
#vignette{position:fixed;inset:0;z-index:2;pointer-events:none;
background:
radial-gradient(120% 90% at 50% 40%, rgba(0,0,0,0) 35%, rgba(2,12,24,.72) 100%),
linear-gradient(180deg, rgba(10,70,110,.25), rgba(0,0,0,0) 30%);
}
#hud{position:fixed;inset:0;z-index:3;pointer-events:none;display:flex;flex-direction:column;justify-content:space-between;padding:clamp(14px,2.2vw,28px);}
header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
.brand{font-size:clamp(13px,1.4vw,17px);letter-spacing:.42em;font-weight:600;text-transform:uppercase;color:#e6faff;text-shadow:0 0 24px rgba(90,200,255,.5)}
.sub{margin-top:6px;font-size:11px;letter-spacing:.24em;text-transform:uppercase;color:#7fbfe0;opacity:.85}
.badge{display:flex;align-items:center;gap:8px;font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:#a9e2ff;border:1px solid rgba(130,215,255,.28);background:rgba(6,32,52,.42);backdrop-filter:blur(6px);padding:7px 11px;border-radius:999px;}
.dot{width:6px;height:6px;border-radius:50%;background:#5fe0ff;box-shadow:0 0 10px #5fe0ff;animation:pulse 1.8s ease-in-out infinite}
footer{display:flex;justify-content:center;padding-bottom:4px}
.pill{...}
@keyframes pulse{0%,100%{opacity:.35;transform:scale(.85)}50%{opacity:1;transform:scale(1.15)}}
@media (max-width:600px){ .sub{display:none} .brand{letter-spacing:.3em;font-size:12px} .pill{font-size:11px;padding:8px 14px} }
```
Also add a fade-in for the HUD and the canvas.
Now, one concern: `backdrop-filter` is supported in Chrome. Fine.
#### Let me write out the full JS now, carefully.
```js
import * as THREE from 'three';
/* ---------------- renderer / scene ---------------- */
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x07314c, 0.02);
const camera = new THREE.PerspectiveCamera(52, window.innerWidth/window.innerHeight, 0.1, 500);
camera.position.set(0, 2.4, 15);
const camBaseY = 2.4;
```
Wait, the camera's position.z is set in the resize function. Initialize with a call to onResize after defining it.
Lights.
Backdrop.
Floor, rocks, kelp.
Fish.
Particles.
Hand.
Then the loop.
Order in the file: constants → helpers → scene → objects → loop.
Let me write the whole thing now, being careful with syntax.
```js
/* ---------- utility ---------- */
const clamp = (v, a, b) => Math.min(Math.max(v, a), b);
const rand = (a, b) => a + Math.random() * (b - a);
```
```js
/* ---------- lights ---------- */
const hemi = new THREE.HemisphereLight(0x9fe8ff, 0x08202e, 1.6);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xd8f7ff, 2.6);
sun.position.set(6, 20, 10);
scene.add(sun);
const rimA = new THREE.PointLight(0x2f9dff, 260, 70, 2);
rimA.position.set(-14, 5, 10);
scene.add(rimA);
const rimB = new THREE.PointLight(0x25e0b0, 160, 60, 2);
rimB.position.set(13, 2, -6);
scene.add(rimB);
```
PointLight intensity in candela with decay 2: at distance 14, 260/196 = 1.3. Reasonable.
Hmm, `new THREE.PointLight(color, intensity, distance, decay)`.
Backdrop:
```js
const bgUniforms = {
uTop: { value: new THREE.Color(0x0d5f86) },
uMid: { value: new THREE.Color(0x063853) },
uBot: { value: new THREE.Color(0x01101c) },
};
const bg = new THREE.Mesh(
new THREE.SphereGeometry(180, 32, 24),
new THREE.ShaderMaterial({
side: THREE.BackSide,
depthWrite: false,
uniforms: bgUniforms,
vertexShader: `varying vec3 vP; void main(){ vP = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `
varying vec3 vP;
uniform vec3 uTop; uniform vec3 uMid; uniform vec3 uBot;
void main(){
vec3 d = normalize(vP);
float h = d.y;
vec3 c = mix(uBot, uMid, smoothstep(-0.75, 0.05, h));
c = mix(c, uTop, smoothstep(0.0, 0.85, h));
float glow = pow(max(dot(d, normalize(vec3(0.1, 0.9, -0.25))), 0.0), 6.0);
c += vec3(0.18, 0.42, 0.5) * glow;
gl_FragColor = vec4(c, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`,
})
);
scene.add(bg);
```
Note: SphereGeometry positions are at radius 180 — normalize gives the direction. Good.
Careful: the background sphere is centered at the origin, and the camera moves inside — fine.
Floor:
```js
const causticGLSL = `
float causticPattern(vec2 p, float t){
vec2 q = p;
float v = 0.0;
for (int i = 0; i < 3; i++){
float fi = float(i);
q = q * 1.63 + vec2(1.7, 2.3);
v += sin(q.x + t * (0.9 + fi * 0.21)) * cos(q.y - t * (0.7 + fi * 0.17));
}
v /= 3.0;
return pow(1.0 - abs(v), 8.0);
}
`;
```
Hmm, note that q is scaled by 1.63 each iteration and offset — this yields a fractal-ish look. Since we divide by 3 after summing, the values are in [-1,1]. The `pow(1-|v|, 8)` creates bright lines. But if v never gets near 0... it will, since the sum oscillates.
Hmm, one concern: after `q = q*1.63 + offset` three times, q is ~4.3x the original p. With p = worldXZ*0.55 and world coords up to 80, q up to 200 → sin/cos of large numbers is fine precision-wise in GLSL (highp float) but may alias. Since the floor extends 160 units, the far parts will alias badly (moiré). Mitigate by fading out the caustics with distance from the origin:
```glsl
float fade = 1.0 - smoothstep(20.0, 55.0, length(vWorldPos.xz));
diffuseColor.rgb += causticColor * cs * fade;
```
Good, matches the fog fade.
Also, use a smaller scale factor: `vWorldPos.xz * 0.35` so features are ~3 units. With q multiplied 1.63³ = 4.33, the effective frequency is 1.5 per unit for the highest octave → fine.
The floor material:
```js
const floorMat = new THREE.MeshStandardMaterial({ color: 0x1c4155, roughness: 1.0, metalness: 0.0 });
floorMat.onBeforeCompile = (shader) => {
shader.uniforms.uTime = uTime;
shader.vertexShader = shader.vertexShader
.replace('#include <common>', '#include <common>\nvarying vec3 vWorldPos;')
.replace('#include <begin_vertex>', '#include <begin_vertex>\n vWorldPos = (modelMatrix * vec4(transformed, 1.0)).xyz;');
shader.fragmentShader = shader.fragmentShader
.replace('#include <common>', '#include <common>\nvarying vec3 vWorldPos;\nuniform float uTime;\n' + causticGLSL)
.replace('#include <color_fragment>', `#include <color_fragment>
float cs = causticPattern(vWorldPos.xz * 0.35, uTime);
float fade = 1.0 - smoothstep(18.0, 52.0, length(vWorldPos.xz));
diffuseColor.rgb += vec3(0.35, 0.72, 0.85) * cs * 1.35 * fade;
float sand = sin(vWorldPos.x * 0.23 + sin(vWorldPos.z * 0.19) * 2.4) * 0.5 + 0.5;
diffuseColor.rgb *= 0.85 + sand * 0.3;
`);
};
```
`vWorldPos` is used in the fragment — declared varying in both. Good.
Wait, the `#include <common>` replacement in the fragment shader — the causticGLSL contains a function definition; placing it in the common include is fine (it's at the top level).
One issue: `#include <color_fragment>` appears in meshphysical_frag. Yes.
Hmm — is it `#include <color_fragment>` or is that only present when vertexColors are used? It's always included; the chunk itself is guarded by `#if defined( USE_COLOR_ALPHA ) ... #elif defined( USE_COLOR )`. The include line itself is unconditional in the shader source, so the string replace works. Good.
Floor mesh:
```js
const floorGeo = new THREE.PlaneGeometry(200, 200, 1, 1);
floorGeo.rotateX(-Math.PI / 2);
const floor = new THREE.Mesh(floorGeo, floorMat);
floor.position.y = -3;
scene.add(floor);
```
Note: rotating the geometry means vWorldPos uses the rotated positions — fine.
Rocks:
```js
const rockGeoBase = new THREE.IcosahedronGeometry(1, 1);
// jitter
{
const p = rockGeoBase.attributes.position;
for (let i = 0; i < p.count; i++){ ... }
}
```
With detail 1, there are 80 faces → 42 unique vertices but non-indexed → 240 verts. Jitter is fine.
Hmm, `IcosahedronGeometry(1, 1)` — non-indexed (PolyhedronGeometry produces non-indexed). Yes.
Rock material: `new THREE.MeshStandardMaterial({ color: 0x123543, roughness: 0.95, metalness: 0.05, flatShading: true })`.
Rocks placement: ring around the tank edges at floor level.
```js
const rockPositions = [
[-9.5, -3, -4, 1.8], [-11.5, -3, 1.5, 2.4], [-6, -3, -6.5, 1.2],
[9, -3, -3.5, 2.0], [11.8, -3, 2.5, 1.6], [6.5, -3, -7, 1.1],
[2, -3, -8.5, 2.6], [-2.5, -3, -8, 1.7], [0.5, -3, -7.5, 1.0],
];
```
Hmm, positions near z=-7..-8 are far back; with fog they'd be dim. Fine, gives depth.
Each rock: `mesh.scale.set(s*1.2, s*0.75, s*1.1)`, `rotation.y = random*PI`, `position.y = -3 + s*0.35`.
Actually, for a rock with scale y = s*0.75, the bottom is at -s*0.75 below its center; to sit on the floor at y=-3, set position.y = -3 + s*0.75*0.6 to bury part of it. Let's use `y = -3 + s*0.35`.
Kelp:
```js
const kelpGeo = new THREE.PlaneGeometry(1, 1, 1, 8);
kelpGeo.translate(0, 0.5, 0);
```
So it spans y 0..1, x -0.5..0.5.
For each blade, scale (width, height, 1) and position at the floor.
Kelp materials: 5 variants with different color/phase.
```js
const kelpMats = [];
for (let i=0;i<4;i++){
const m = new THREE.MeshStandardMaterial({ color: new THREE.Color().setHSL(0.38 + Math.random()*0.06, 0.45, 0.22 + i*0.02), roughness: 0.9, metalness: 0.0, side: THREE.DoubleSide, emissive: 0x0a2418, emissiveIntensity: 0.6 });
m.onBeforeCompile = (shader) => {
shader.uniforms.uTime = uTime;
shader.uniforms.uPhase = { value: Math.random()*10 };
shader.vertexShader = `uniform float uTime; uniform float uPhase;\n` + shader.vertexShader.replace('#include <begin_vertex>', `#include <begin_vertex>
float bb = position.y;
float amp = bb * bb;
transformed.x += (sin(uTime * 1.1 + uPhase + bb * 2.0) * 0.5 + sin(uTime * 2.3 + uPhase * 1.7 + bb * 4.5) * 0.16) * amp * 0.7;
`);
};
m.customProgramCacheKey = () => 'kelp-sway';
kelpMats.push(m);
}
```
Note: `position.y` is 0..1 in the geometry (before scaling). Good.
Hmm, but `customProgramCacheKey` returning the same string for materials whose onBeforeCompile differs only by uniforms is fine.
Wait — careful. If two materials have the same program cache key but different onBeforeCompile functions (fish vs kelp), the programs could collide. Use different keys: 'fish' and 'kelp'. Yes.
Blade placement:
```js
const clusters = [
{ x: -10.5, z: -2, n: 9 }, { x: -8.5, z: 4, n: 7 },
{ x: 9.5, z: -1.5, n: 9 }, { x: 11, z: 4.5, n: 6 },
{ x: -1.5, z: -8, n: 6 },
];
for (const c of clusters){
for (let i=0;i<c.n;i++){
const m = new THREE.Mesh(kelpGeo, kelpMats[Math.floor(Math.random()*kelpMats.length)]);
const h = rand(2.5, 6.5);
const w = rand(0.35, 0.8);
m.scale.set(w, h, 1);
m.position.set(c.x + rand(-1.8,1.8), -3, c.z + rand(-1.5,1.5));
m.rotation.y = rand(0, Math.PI*2);
scene.add(m);
}
}
```
37 blades → 37 draw calls. Acceptable. Total draw calls: 44 fish + 37 kelp + ~10 rocks + floor + bg + surface + 2 points + 2 sprites ≈ 100. Fine.
Hmm, the kelp at y=-3 with height 6.5 reaches y=3.5. Good.
But careful: the blades are planes; with `rotation.y` random they face various directions. DoubleSide handles visibility. Good.
Now the water surface and fish etc.
Let me write out the fish geometry function fully:
```js
function makeFishGeometry(){
const positions = [];
const indices = [];
const RINGS = 20, SEG = 10;
const L = 2.0, R0 = 0.44;
const ringStart = [];
for (let i = 0; i <= RINGS; i++){
const t = i / RINGS;
const x = (0.5 - t) * L;
const prof = Math.pow(Math.sin(Math.PI * Math.pow(t, 0.62)), 0.85);
const r = Math.max(R0 * prof, 0.012);
const hScale = 1.0 + 0.22 * Math.sin(Math.PI * t);
ringStart.push(positions.length / 3);
for (let j = 0; j < SEG; j++){
const a = (j / SEG) * Math.PI * 2;
positions.push(x, Math.cos(a) * r * 1.3 * hScale, Math.sin(a) * r * 0.68);
}
}
for (let i = 0; i < RINGS; i++){
const a0 = ringStart[i], b0 = ringStart[i+1];
for (let j = 0; j < SEG; j++){
const j2 = (j + 1) % SEG;
indices.push(a0+j, b0+j, a0+j2);
indices.push(a0+j2, b0+j, b0+j2);
}
}
// fins
function tri(ax,ay,az, bx,by,bz, cx,cy,cz){
const base = positions.length / 3;
positions.push(ax,ay,az, bx,by,bz, cx,cy,cz);
indices.push(base, base+1, base+2);
}
// caudal fin (tail)
tri(-0.88, 0, 0, -1.62, 0.66, 0, -1.22, 0.02, 0);
tri(-0.88, 0, 0, -1.22, 0.02, 0, -1.62, -0.66, 0);
// dorsal
tri(0.34, 0.22, 0, -0.06, 1.02, 0, -0.56, 0.22, 0);
// anal
tri(-0.2, -0.2, 0, -0.62, -0.5, 0, -0.78, -0.14, 0);
// pectoral (small, slightly angled)
tri(0.34, -0.05, 0.16, 0.02, -0.32, 0.42, 0.14, 0.05, 0.2);
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
g.setIndex(indices);
g.computeVertexNormals();
return g;
}
```
Hmm, the pectoral fin: at z=0.16 to 0.42 — the body's z half-width at x=0.34 is r*0.68. r at t=0.33 ≈ 0.44*sin(pi*0.33^0.62)^0.85: 0.33^0.62 = e^{-0.687} = 0.503; sin(pi*0.503)=0.9999; ^0.85≈1; r=0.44. z half-width = 0.44*0.68 = 0.30. So a fin at z from 0.16 to 0.42 pokes out 0.12. Small. Fine, or skip the pectoral. I'll keep it — adds a small detail.
Wait, the tail fin triangle: (-0.88,0) → (-1.62,0.66) → (-1.22,0.02). Is that a reasonable shape? The tail base is at x=-0.88, and the fin extends to -1.62 with a notch at -1.22. Good fork.
The dorsal fin: base from (0.34, 0.22) to (-0.56, 0.22) with the peak at (-0.06, 1.02). At x=0.34 the body top is y≈0.63, at x=-0.56 it's y≈0.36. So the fin base at y=0.22 is inside the body at both ends. Good. And the peak at y=1.02 is well above.
Anal fin at (-0.2,-0.2) → (-0.62,-0.5) → (-0.78,-0.14). The body bottom at x=-0.2: t=0.6, prof: 0.6^0.62 = e^{-0.317}=0.729; sin(pi*0.729)=sin(131°)=0.755; ^0.85=0.787; r=0.346; *1.3*hScale(1+0.22*sin(pi*0.6)=1.209) = 0.346*1.3*1.209=0.544. So the body bottom at x=-0.2 is y=-0.54. My anal fin starts at y=-0.2 (inside the body) and goes to -0.5 — barely visible. Let me lower it: (-0.25,-0.25) → (-0.6,-0.75) → (-0.8,-0.2). Better. Meh, it's a minor detail. I'll set the middle point to -0.72.
Actually, since the body bottom at x=-0.6 is: t=0.8, 0.8^0.62 = e^{-0.1384}=0.871; sin(pi*0.871)=sin(156.8°)=0.394; ^0.85 = 0.451; r=0.198; *1.3*(1+0.22*sin(144°)=1.129) = 0.29. So the body bottom at x=-0.6 is -0.29. A fin point at (-0.6,-0.72) sticks out 0.43 below. Good.
OK.
The tail fin appears at z=0, which is the fish's mid-plane. When viewed from above/below it's invisible (edge-on), but from the side it's fine. Normal.
Now, the shader sway uses `transformed.x` — the fins are at x from -1.62 to 0.34 so they bend along with the body.
Fish count and material creation — 44 materials, each compiling a shader. Since they share a program cache key, only one program is compiled. Good.
Now, one issue with `customProgramCacheKey`: In three r170, `material.customProgramCacheKey()` is included in the program cache key. Since all fish return 'fish', they share. But their onBeforeCompile modifies uniforms only (same code). Good. However — the program cache key also includes other material properties; all fish materials have the same properties except for color, which is a uniform. So they'd share anyway. Fine.
Careful: `flatShading: false`, `side: DoubleSide` — consistent.
#### Hand sprites
```js
const glowTex = makeGlowTexture();
const glowSprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: glowTex, color: 0xbfefff, transparent: true, blending: THREE.AdditiveBlending,
depthWrite: false, depthTest: false, opacity: 0.85, toneMapped: false,
}));
glowSprite.scale.set(2.2, 2.2, 1);
glowSprite.renderOrder = 10;
scene.add(glowSprite);
```
`toneMapped: false` — supported on materials? SpriteMaterial has `toneMapped` property (from Material), yes.
Ring sprite similar with a ring texture.
Position them at handPos each frame.
Also, maybe scale the glow based on the "speed" of the hand to show energy. Skip.
#### Now, the resize function and the initial call.
```js
function onResize(){
const w = window.innerWidth, h = window.innerHeight;
camera.aspect = w / h;
camera.position.z = clamp(14.5 * Math.pow(1.6 / camera.aspect, 0.5), 11.5, 22);
camera.updateProjectionMatrix();
renderer.setSize(w, h);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
}
window.addEventListener('resize', onResize);
onResize();
```
At aspect 1.6 → 14.5. At 0.5 → 14.5*1.79 = 25.9 → 22. Hmm, that's far. At 22, the vertical half-height = 22*tan26 = 10.7 → the tank (height 10) fits with room to spare. The fish will look small but visible. OK.
Hmm, at aspect 0.5 and z=22, the horizontal half-width = 10.7*0.5 = 5.35 — so we only see x from -5.35 to 5.35. Fish swimming outside that are off-screen. Since the fish spread from -10 to 10, half of them would be off-screen on a phone. Hmm. Maybe for narrow screens, the tank should shrink. Alternative: adjust the camera to fit the width: `z = (wantHalfWidth) / (tan(fov/2)*aspect)`. Want half-width 13 at z=0 → z = 13/(0.488*0.5) = 53. Way too far, the fish become tiny.
Better approach for narrow screens: reduce the tank width, i.e., confine the fish to a narrower region when the aspect is small. Or zoom out less and accept fewer visible fish. I think keeping the current approach (z up to 22, showing x ±5.3 on a narrow phone) is not great.
Alternative: use a wider fov on narrow screens plus a moderate z. E.g., set fov = 52 for wide, and for narrow use fov 70 and z=16 → half-height = 16*tan35 = 11.2, half-width = 11.2*0.5 = 5.6. Same problem — narrow screens inherently have a narrow horizontal field.
The real fix: restrict the fish's x range based on the aspect. Let's define `TANK_HALF_X` dynamically:
```js
const visibleHalfWidth = camera.position.z * Math.tan(fov/2) * aspect;
const tankX = Math.min(10, Math.max(3.5, visibleHalfWidth * 0.85));
```
And the fish's bounds use `tankX`. Also, on resize, clamp fish positions into the new bounds. That way, on mobile the fish stay in view. Their spawn positions should also use tankX.
That's a nice adaptive touch. Similarly the kelp/rocks at ±10 would be off-screen on mobile — that's OK (they'd just be cropped), but then the edges look empty. Hmm, we could scale the whole decor group horizontally. Simpler: put the kelp clusters and rocks at a fraction of tankX. Let's compute the decor positions relative to the initial layout, and on resize, scale the world group's x? That would distort the fish. Hmm.
Pragmatic approach: keep the scene fixed and use a moderate camera distance formula. Most viewers will be on a desktop (1280×800 for the screenshot). The mobile adaptation just needs to not break — showing the center of the tank with the fish is fine. Let me define tankX dynamically for the fish only (so fish stay visible), and place kelp at ±(tankX*0.85) too, computed after the resize. Since the tank bounds are computed at init, and the resize only matters for a later window resize, I'll compute the initial tankX at load and use it for placement, and update bounds on resize.
Let's do:
```js
let TANK_X = 10.5;
function computeTank(){
const halfW = camera.position.z * Math.tan(THREE.MathUtils.degToRad(camera.fov/2)) * camera.aspect;
TANK_X = clamp(halfW * 0.88, 3.0, 11.0);
}
```
At 1280x800: aspect 1.6, z=14.5, tan26=0.4877 → halfW = 14.5*0.4877*1.6 = 11.3 → TANK_X = 9.95. Good.
At 375x800: aspect 0.469, z = clamp(14.5*sqrt(1.6/0.469)=14.5*1.847=26.8 → 22). halfW = 22*0.4877*0.469 = 5.03 → TANK_X = 4.4. So the fish stay within ±4.4 and are visible.
But the decor (kelp at x=±10) would be off-screen. Let me place the decor using a function of TANK_X at load, and on resize... the decor just stays. It's acceptable: on mobile you see the middle, with the kelp off-screen. Hmm, then mobile looks emptier.
Alternative: put a decor group and scale it horizontally on resize:
```js
decorGroup.scale.x = TANK_X / 10.0;
```
That would squash the rocks — acceptable visually if the scale is between 0.45 and 1.1. Rocks get squashed, kelp gets squashed. Slightly odd but fine. Actually, better: move the decor cluster positions (not scales) toward the center. That requires per-object bookkeeping. Since there are ~50 objects, store `userData.baseX` and set `position.x = baseX * ratio` on resize. Simple! Let's do that for rocks and kelp.
So: `ratio = TANK_X / 10.0`. Rocks and kelp get `mesh.position.x = mesh.userData.bx * ratio`. Since positions were authored relative to a ±10 tank.
And on first layout, call the same function.
Good. Fish bounds use TANK_X.
Also the floor/surface sizes: fine.
Also, `handPos.x` clamp uses TANK_X + 2.
Alright.
The camera z clamp: at aspect 1.6, we want z≈14.5 to see the whole tank. With the formula `clamp(14.5 * Math.pow(1.6/aspect, 0.5), 11.5, 22)`, at aspect 1.6 → 14.5.
Let me simplify: `camera.position.z = clamp(14.5 * Math.sqrt(1.6 / camera.aspect), 12, 22)`.
At 2.0 → 14.5*0.894 = 13. At 1.0 → 18.3. At 0.47 → 26.8 → 22.
OK.
#### Now, will the fish be visible on first paint?
Yes, they're created immediately with initial positions and oriented via the first frame's update (velocity is set, so the orientation is computed on frame 1).
But careful: on the first frame, the mesh quaternion starts as identity (nose pointing +X, i.e., to the right) and slerps toward the velocity direction at 7/s * 0.016 = 0.11 per frame — so it takes ~0.5s to align. Fine.
#### Checking the colors and tone mapping
The scene may come out too dark or too bright. Let's set toneMappingExposure = 1.25 and use fairly bright lights. Hemisphere 1.8, directional 2.2. With ACES, a white surface under intensity ~2.5 directional + 1.8 hemi ≈ moderate. Standard material with roughness 1 and albedo 0.2 → dark. The fish are colored (albedo ~0.5), so they'll be mid-bright.
Hmm, hard to predict. Let's aim for slightly bright: hemi 2.2, directional 3.0, plus the two point lights. With ACES, overexposure is gracefully handled (rolls off to white). I'd rather be too bright than too dark. Also, the emissive on the fish (color*0.15) adds a base glow.
Actually, with `useLegacyLights` removed in r165+, light intensities are physical: directional light intensity 3 = quite bright. Three's default example scenes use directional 3 and hemi 1-2 with ACES exposure 1. Yes, that's typical. Good.
#### Potential issue: `THREE.SRGBColorSpace` on canvas textures
Fine.
#### The `#include <colorspace_fragment>` in the water shader with additive blending
OK.
Let me write the final code now.
One more consideration on the "demo mode" text: The task says games must start in a self-playing demo mode behind a "Click or press a key to start" message. Ours is not a game, but I'll show a hint overlay. Since the mouse interaction doesn't require a click, no click gate is needed. But to be safe and to follow the spirit, I'll display a hint that's clear. I won't block anything behind a click.
Hmm, the requirement "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." We don't use the keyboard. Fine.
Let me write everything.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Deep Reef — a three.js aquarium</title>
<style>...</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="vignette"></div>
<div id="hud">
<header>
<div>
<div class="brand">Deep Reef</div>
<div class="sub">an interactive three.js aquarium</div>
</div>
<div class="badge"><span class="dot" id="dot"></span><span id="badgeText">auto demo</span></div>
</header>
<footer>
<div class="pill"><span class="pin"></span><span id="hintText">Move your cursor through the water — the fish will scatter</span></div>
</footer>
</div>
<script type="module"> ... </script>
</body>
</html>
```
Now the module script. I'll write it in order.
Let me be careful about the order of definitions: `uTime` must be defined before materials use it. And `TANK_X` before fish creation.
Let me draft:
```js
import * as THREE from 'three';
/* ============ helpers ============ */
const clamp = (v, a, b) => Math.min(Math.max(v, a), b);
const rand = (a, b) => a + Math.random() * (b - a);
/* ============ renderer ============ */
...
```
Let me write the final code in full, carefully checking syntax.
Potential pitfalls to double check:
1. `new THREE.Float32BufferAttribute(positions, 3)` with a JS array — works.
2. `geometry.setIndex(indices)` with a JS array of numbers — works (it converts to Uint16/Uint32 based on max).
3. `computeVertexNormals()` — fine.
4. In the fish geometry, the fin triangles added after the body: the `tri` helper pushes 3 new vertices and an index. The index buffer uses `base` etc. Fine.
5. `positions.length / 3` before pushing — correct.
6. In `makeFishMaterial`, I reference `uTime` — a global defined before the function is called. Fine (function hoisting/temporal dead zone: `uTime` must be initialized before `makeFishMaterial` is *called*, which it will be).
7. `mat.userData.shader` — Material has `userData` by default ({}). Yes.
8. Sprite scale — Sprite has scale.
9. `THREE.CanvasTexture` — fine.
10. `renderer.domElement` appended to body — but the HUD is also in the body; the canvas has `position: fixed; z-index: 1` and the HUD z-index 3. The canvas is appended after the HUD divs (since the script is at the end and appends to the body), but z-index handles stacking. Good.
Let me now write it all.
Also, one more visual: the water surface plane at y=7.5 might be above the camera's view at the top. We computed the visible top to be ~8.7 at z=0, so it's visible. Good.
And the floor at y=-3: the visible bottom at z=0 is ~-5.5. The floor will be visible in the lower part, receding. Good.
Fish y range: -1.8 to 5.5.
Let's write the code.
```js
/* ---------- bubbles ---------- */
const bubbleTex = makeDotTexture();
const bubbleGeo = new THREE.BufferGeometry();
const BUBBLES = 180;
const bubbleArr = new Float32Array(BUBBLES * 3);
const bubbleMeta = [];
for (let i = 0; i < BUBBLES; i++) {
bubbleArr[i*3] = rand(-12, 12);
bubbleArr[i*3+1] = rand(-3, 7);
bubbleArr[i*3+2] = rand(-8, 6);
bubbleMeta.push({ sp: rand(0.35, 1.1), ph: rand(0, 6.28), amp: rand(0.1, 0.45) });
}
bubbleGeo.setAttribute('position', new THREE.BufferAttribute(bubbleArr, 3));
const bubbleMat = new THREE.PointsMaterial({ size: 0.22, map: bubbleTex, transparent: true, opacity: 0.6, blending: THREE.AdditiveBlending, depthWrite: false, color: 0xbfefff, sizeAttenuation: true });
const bubbles = new THREE.Points(bubbleGeo, bubbleMat);
scene.add(bubbles);
```
Marine snow similar with size 0.07 and 500 particles, opacity 0.4, normal blending maybe. Additive is fine.
Update loop for both.
Now, the update of `bubbleGeo.attributes.position.array` — I'll keep a reference: `const bubbleAttr = bubbleGeo.attributes.position;` and `bubbleAttr.needsUpdate = true`.
Alright. Writing the final answer now.
One more consideration about performance: 44 fish each with a unique material and a per-frame userData.shader lookup — fine.
Let me write `updateFish` with the `fleeing` flag and alarm.
Also, fish should avoid the hand when it's not moving? If the cursor is stationary and a fish is within the radius, it keeps fleeing — that's fine, it just moves away.
The fish's wander target might be right where the hand is → it approaches and then flees. That's the desired behavior.
Let me finalize the numbers:
- FLEE_R = 4.6
- flee strength: f.speed * 6 * k² (so up to 6x speed)
- max desired speed while fleeing: f.speed * 5
Let's compute: an average fish speed of 1.8, flee speed up to 9 units/s. The tank is 20 wide, so it crosses in 2.2s. Good, energetic.
Hmm, that might be too fast/jittery. Let's use 4.5x and a max desired of 7 units/s. Fine.
The lerp toward desired with k=10 → the fish reaches the flee velocity in ~0.1s. Snappy. Good.
Now `f.alarm` — I'll skip using it; not needed.
Let me write the final code.
Also: don't forget `renderer.render(scene, camera)` at least once before the animation starts, in case of timing. The first rAF will handle it.
Another important detail: since fish materials use onBeforeCompile with `customProgramCacheKey`, the first render compiles ~10 programs (standard fish, kelp, floor variants, shader materials). Should take well under a second.
Let me also add `scene.background = null` — default.
Writing final code now.
For the HUD "badge", on real pointer input:
```js
document.getElementById('badgeText').textContent = 'live';
document.getElementById('dot').style.background = '#7dffb0';
document.getElementById('hintText').textContent = 'Nice. Keep moving — they remember you.';
```
Maybe simpler: just change the badge text to "LIVE" and keep the hint. Let's change the hint to something else or leave it. I'll leave the hint and just update the badge.
Hmm, `document.getElementById('dot')` — the class `dot` also has an animation. Fine.
Now, writing.
For the CSS, use a `@keyframes pulse` and `fadeIn`.
Also, add `user-select: none` and `cursor: crosshair` on the body? A crosshair cursor might be nice for the "move through water" feel. But we already show a glow. Let's use `cursor: none`? That would hide the OS cursor and make the glow the cursor — cool, but risky if the glow lags. Let's keep the default cursor. Actually `cursor: crosshair` communicates "interactive". I'll use crosshair.
Final check on the vignette: `radial-gradient(120% 90% at 50% 45%, transparent 35%, rgba(1,10,20,.75) 100%)`. Good.
Writing the answer now.
One more: the water surface — set `renderOrder`? The transparent surface will render after opaque objects. Fine.
Also `depthWrite: false` for the surface — correct for transparency.
Let me assemble everything.
Actually, wait. I want to double check the `#include <color_fragment>` replacement in the floor material. If the material has no vertex colors, the chunk expands to nothing (well, to an empty string guarded by #if). The replacement string is:
```
#include <color_fragment>
float cs = ...;
```
which after chunk resolution becomes the code. Good.
But note: the shader source at the time onBeforeCompile is called still contains the `#include` directives (they're resolved later by `resolveIncludes`). Yes — onBeforeCompile receives the raw shader with includes, and our replace targets those. Correct.
Also, `#include <begin_vertex>` exists in the vertex shader. Yes.
Now writing for real. I'll be careful with template literal escaping inside the module script — the shaders use backticks, and the whole thing is inside a `<script>` tag in the HTML, not inside a JS template literal, so no escaping issues.
Let me produce the code.
```js
import * as THREE from 'three';
const clamp = (v, a, b) => Math.min(Math.max(v, a), b);
const rand = (a, b) => a + Math.random() * (b - a);
const TAU = Math.PI * 2;
/* ---------- Renderer ---------- */
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);
/* ---------- Scene & Camera ---------- */
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x07314c, 0.019);
const camera = new THREE.PerspectiveCamera(52, window.innerWidth / window.innerHeight, 0.1, 600);
camera.position.set(0, 2.4, 15);
const uTime = { value: 0 };
let time = 0;
let TANK_X = 10;
function layoutCamera() {
const w = window.innerWidth, h = window.innerHeight;
const aspect = w / h;
camera.aspect = aspect;
camera.position.z = clamp(14.5 * Math.sqrt(1.6 / aspect), 12, 22);
camera.updateProjectionMatrix();
const halfW = camera.position.z * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * aspect;
TANK_X = clamp(halfW * 0.88, 3.0, 10.5);
renderer.setSize(w, h);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
layoutDecor();
}
```
`layoutDecor` must be defined (function declaration, hoisted) — I'll define it as a function declaration later. Since layoutCamera is called after everything is created, fine.
Careful: `layoutCamera()` is called at the end of setup; it calls `layoutDecor()` which iterates over arrays of decor meshes. Make sure those arrays exist at that point.
Continue:
Lights, backdrop, floor, rocks, kelp...
I'll now write the whole thing in the final answer. Let me be meticulous.
Also note: `document.body.appendChild(renderer.domElement)` puts the canvas after the HUD in DOM order, but z-index takes care of stacking (position: fixed with z-index on both). The CSS must set the canvas z-index: 1 and the HUD 3, vignette 2.
Alright, writing the final code.
Regarding `layoutDecor`, I'll store `{mesh, bx}` in an array `decorX`.
```js
const decorX = [];
function addDecor(mesh) {
decorX.push({ m: mesh, bx: mesh.position.x });
}
function layoutDecor() {
const r = clamp(TANK_X / 10, 0.42, 1.05);
for (const d of decorX) d.m.position.x = d.bx * r;
}
```
Good.
Now the fish bounds use TANK_X directly, updated on resize. Fish may be outside on shrink; the hard clamp pulls them in.
Writing now for real.
Final detail: the initial `layoutCamera()` call should happen before creating the fish? TANK_X is used in `layoutDecor` only, and the fish's initial positions use TANK_X too. Let's call `layoutCamera()` early (right after the camera is created) but `layoutDecor` needs the decor array — which is `const decorX = []` declared before. If `layoutCamera()` is called before the decor is created, `layoutDecor` iterates an empty array — fine. Then after creating the decor, call `layoutDecor()` again.
Order:
1. renderer, scene, camera
2. `const decorX = []`, layoutCamera defined, layoutDecor defined
3. call layoutCamera() (decor empty, ok)
4. build everything (uses TANK_X)
5. call layoutDecor()
6. animate
The fish initial positions use `rand(-TANK_X*0.85, TANK_X*0.85)`. Fine.
Now writing.
I also want to double check `THREE.MathUtils.degToRad` exists — yes.
Final code below. I'll write it now in full.
One thing about the fish seek: if the target is very close (dTarget small), the desired velocity from the seek is still full speed → the fish overshoots and circles. Add a slow-down near the target: `const sp = f.speed * Math.min(1, dTarget / 2.0)`. Better behavior.
Let me write it as:
```js
_v2.subVectors(f.target, f.pos);
const dT = _v2.length();
if (dT > 0.01) desired.addScaledVector(_v2, (f.speed * Math.min(1, dT / 3)) / dT);
```
This normalizes and scales in one step. Good.
And for the flee direction, similar.
Let me write the final.
Also add a `THREE.MathUtils.lerp`? Not needed.
Writing.
Now, about the water surface: I want the surface plane to be large enough to cover the view: 120x120 at y=7.2.
The camera sees up to y≈9 at z=0, so the surface at y=7.2 covers the upper part of the view with a receding perspective. Good.
Let me write the final code.
Regarding `#include <tonemapping_fragment>` in the water shader — the surface is additive so tonemapping helps avoid clipping. Keep.
Final concern: `gl_FragColor` deprecated in WebGL2/GLSL3? Three still uses gl_FragColor in its chunks. Fine.
Write it.