GREAT WHITE - INTERACTIVE APP SOURCE CODE
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Great white</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600&family=Big+Shoulders+Display:wght@800&display=swap">
<style>
:root {
box-sizing: border-box;
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
color-scheme: light;
--mode: 0;
--water-top: #9edcef;
--water-mid: #1f7aa3;
--water-deep: #052640;
--ink: #04263b;
--ink-soft: rgba(4, 38, 59, 0.78);
--chip: rgba(236, 249, 255, 0.84);
--chip-ink: #04263b;
--chip-edge: rgba(255, 255, 255, 0.9);
--accent: #c0101f;
--accent-ink: #ffffff;
--flash: rgba(150, 0, 12, 0.6);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--mode: 1;
--water-top: #1d4166;
--water-mid: #08203a;
--water-deep: #010810;
--ink: #e4f5ff;
--ink-soft: rgba(228, 245, 255, 0.76);
--chip: rgba(6, 22, 40, 0.8);
--chip-ink: #e4f5ff;
--chip-edge: rgba(120, 200, 255, 0.26);
--accent: #e0303c;
--accent-ink: #ffffff;
--flash: rgba(120, 0, 10, 0.65);
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--mode: 1;
--water-top: #1d4166;
--water-mid: #08203a;
--water-deep: #010810;
--ink: #e4f5ff;
--ink-soft: rgba(228, 245, 255, 0.76);
--chip: rgba(6, 22, 40, 0.8);
--chip-ink: #e4f5ff;
--chip-edge: rgba(120, 200, 255, 0.26);
--accent: #e0303c;
--accent-ink: #ffffff;
--flash: rgba(120, 0, 10, 0.65);
}
html { height: 100%; scroll-padding-top: env(safe-area-inset-top, 0px); }
body {
height: 100%;
margin: 0;
overflow: hidden;
background: var(--water-deep);
color: var(--ink);
font-family: "Archivo", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
}
#stage {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: linear-gradient(180deg, var(--water-top) 0%, var(--water-mid) 40%, var(--water-deep) 100%);
}
#stage canvas {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
display: block;
touch-action: none;
outline: none;
}
.vignette, .flash {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none;
}
.vignette { background: radial-gradient(130% 100% at 50% 35%, transparent 52%, rgba(0, 10, 22, 0.5) 100%); }
.flash { opacity: 0; background: radial-gradient(120% 95% at 50% 50%, transparent 35%, var(--flash) 100%); }
.bar {
position: absolute;
left: 0; right: 0; bottom: 0;
padding: 0 clamp(12px, 3vw, 28px) clamp(12px, 3vw, 24px);
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 10px;
pointer-events: none;
}
.hint {
margin: 0;
padding: 9px 15px;
border-radius: 14px;
font-size: 15px;
line-height: 1.35;
max-width: 32em;
color: var(--chip-ink);
background: var(--chip);
border: 1px solid var(--chip-edge);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
transition: opacity 0.7s ease, transform 0.7s ease;
}
.hint.gone { opacity: 0; transform: translateY(8px); }
.actions { display: flex; gap: 8px; margin-left: auto; pointer-events: auto; }
.btn {
appearance: none;
-webkit-appearance: none;
font: inherit;
font-size: 16px;
font-weight: 600;
color: var(--chip-ink);
background: var(--chip);
border: 1px solid var(--chip-edge);
padding: 10px 18px;
border-radius: 10px;
cursor: pointer;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
transition: transform 0.15s ease;
}
.btn:active { transform: translateY(1px) scale(0.97); }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.btn.primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
.sr-only {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.fallback {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
display: grid;
place-items: center;
padding: 32px;
text-align: center;
font-size: 17px;
}
@media (max-width: 560px) {
.hint { font-size: 14px; }
.btn { padding: 9px 13px; font-size: 15px; }
}
@media (prefers-reduced-motion: reduce) {
.hint, .btn { transition: none; }
}
</style>
</head>
<body>
<div id="stage">
<div class="vignette" aria-hidden="true"></div>
<div class="flash" id="flash" aria-hidden="true"></div>
<div class="bar">
<p class="hint" id="hint">Tap the surface to put a seal there, or tap the shark to make it charge.</p>
<div class="actions">
<button class="btn" id="chargeBtn" type="button">Make it charge</button>
<button class="btn primary" id="sealBtn" type="button">Add a seal</button>
</div>
</div>
<p class="sr-only" id="live" aria-live="polite"></p>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(() => {
'use strict';
const stage = document.getElementById('stage');
const hintEl = document.getElementById('hint');
const sealBtn = document.getElementById('sealBtn');
const chargeBtn = document.getElementById('chargeBtn');
const liveEl = document.getElementById('live');
const flashEl = document.getElementById('flash');
function fail(msg) {
const d = document.createElement('div');
d.className = 'fallback';
d.textContent = msg;
stage.appendChild(d);
const bar = document.querySelector('.bar');
if (bar) bar.style.display = 'none';
}
if (!window.THREE) { fail('The 3D library didn’t load. Check your connection and refresh the page.'); return; }
// ─── Helpers ──────────────────────────────────────────────────────────
const V3 = THREE.Vector3;
const TAU = Math.PI * 2;
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
const lerp = (a, b, t) => a + (b - a) * t;
const damp = (a, b, k, dt) => lerp(a, b, 1 - Math.exp(-k * dt));
const smoothstep = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
const wrapAngle = (a) => Math.atan2(Math.sin(a), Math.cos(a));
const dampAngle = (a, b, k, dt) => a + wrapAngle(b - a) * (1 - Math.exp(-k * dt));
let seed = 4021;
const rand = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
const rr = (a, b) => a + (b - a) * rand();
const REDUCED = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
const MOTION = REDUCED ? 0.5 : 1;
// ─── Renderer, scene, camera, lights ──────────────────────────────────
let renderer;
try {
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'high-performance' });
} catch (err) {
fail('This little aquarium needs WebGL, which isn’t available in this browser.');
return;
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setClearColor(0x000000, 0);
const canvas = renderer.domElement;
canvas.setAttribute('role', 'img');
canvas.setAttribute('aria-label', 'A great white shark hunting below the surface beside a kelp forest');
stage.insertBefore(canvas, stage.firstChild);
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0x1f6a8c, 5, 42);
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 200);
const tanHalf = Math.tan(THREE.MathUtils.degToRad(camera.fov / 2));
const camLook = new V3(0, -2.4, -2);
const camBase = new V3(0, -4.2, 11);
let viewHalfW = 8;
const hemi = new THREE.HemisphereLight(0xc8f5ff, 0xe6cc98, 0.72);
const key = new THREE.DirectionalLight(0xfff0cf, 0.85);
key.position.set(-2.5, 10, 2);
const rim = new THREE.DirectionalLight(0xa9f2ff, 0.5);
rim.position.set(5, 2.5, -6);
scene.add(hemi, key, rim);
// ─── Sand height (shared by JS and the sand shader) ───────────────────
const SAND_Y = -11;
const duneH = (x, z) =>
0.16 * Math.sin(x * 0.35 + 1.3) * Math.cos(z * 0.3) +
0.07 * Math.sin(x * 0.9 + z * 0.6) +
0.05 * Math.cos(z * 1.1 - x * 0.4);
const sandAt = (x, z) => SAND_Y + duneH(x, z);
// ─── Textures painted on 2D canvases ──────────────────────────────────
const maxAniso = renderer.capabilities.getMaxAnisotropy();
const makeCanvas = (w, h) => { const c = document.createElement('canvas'); c.width = w; c.height = h; return c; };
// Equirectangular mapping used by THREE.SphereGeometry (u = 0.25 is the +z "front").
const uvToDir = (u, v) => {
const th = v * Math.PI, ph = u * TAU;
return new V3(-Math.cos(ph) * Math.sin(th), Math.cos(th), Math.sin(ph) * Math.sin(th));
};
const dirToUV = (d) => ({
u: ((Math.atan2(d.z, -d.x) / TAU) % 1 + 1) % 1,
v: Math.acos(clamp(d.y, -1, 1)) / Math.PI,
});
// Soft vertical beam for the sun rays.
function paintRay() {
const W = 64, H = 256;
const c = makeCanvas(W, H), g = c.getContext('2d');
const img = g.createImageData(W, H), d = img.data;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const u = ((x + 0.5) / W) * 2 - 1, v = (y + 0.5) / H;
const prof = Math.exp(-u * u * 4.5) * (1 - smoothstep(0.85, 1, Math.abs(u)));
const fall = Math.pow(1 - v, 1.6) * smoothstep(0, 0.08, v);
const i = (y * W + x) * 4;
d[i] = d[i + 1] = d[i + 2] = 255;
d[i + 3] = Math.round(255 * prof * fall);
}
}
g.putImageData(img, 0, 0);
return new THREE.CanvasTexture(c);
}
// ─── Sand with animated caustics ──────────────────────────────────────
const DUNE_GLSL = `
float duneH(vec2 p) {
return 0.16 * sin(p.x * 0.35 + 1.3) * cos(p.y * 0.3)
+ 0.07 * sin(p.x * 0.9 + p.y * 0.6)
+ 0.05 * cos(p.y * 1.1 - p.x * 0.4);
}`;
const sandVS = `
varying vec3 vWorld;
varying vec3 vN;
varying float vDepth;
#include <fog_pars_vertex>
${DUNE_GLSL}
void main() {
vec3 p = position;
p.y += duneH(p.xz);
float e = 0.06;
float hx = duneH(p.xz + vec2(e, 0.0)) - duneH(p.xz - vec2(e, 0.0));
float hz = duneH(p.xz + vec2(0.0, e)) - duneH(p.xz - vec2(0.0, e));
vN = normalize(vec3(-hx, 2.0 * e, -hz));
vec4 world = modelMatrix * vec4(p, 1.0);
vWorld = world.xyz;
vec4 mvPosition = viewMatrix * world;
vDepth = -mvPosition.z;
gl_Position = projectionMatrix * mvPosition;
#include <fog_vertex>
}`;
const sandFS = `
uniform float uTime;
uniform vec3 uSand;
uniform vec3 uSandDark;
uniform vec3 uCaustic;
uniform float uCausticStrength;
uniform vec4 uShadow;
uniform vec3 uLightDir;
uniform vec2 uFade;
uniform vec2 uDetail;
varying vec3 vWorld;
varying vec3 vN;
varying float vDepth;
#include <fog_pars_fragment>
float hash1(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
vec2 hash2(vec2 p) {
p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
return fract(sin(p) * 43758.5453);
}
float vnoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(hash1(i), hash1(i + vec2(1.0, 0.0)), u.x),
mix(hash1(i + vec2(0.0, 1.0)), hash1(i + vec2(1.0, 1.0)), u.x), u.y);
}
// Bright web along Voronoi cell borders, with drifting cell centres.
float caustic(vec2 p, float t) {
vec2 ip = floor(p);
vec2 fp = fract(p);
float d1 = 8.0;
float d2 = 8.0;
for (int j = -1; j <= 1; j++) {
for (int i = -1; i <= 1; i++) {
vec2 g = vec2(float(i), float(j));
vec2 o = hash2(ip + g);
o = 0.5 + 0.42 * sin(t + 6.2831 * o);
vec2 r = g + o - fp;
float d = dot(r, r);
if (d < d1) { d2 = d1; d1 = d; }
else if (d < d2) { d2 = d; }
}
}
float edge = sqrt(d2) - sqrt(d1);
return pow(1.0 - smoothstep(0.0, 0.09, edge), 2.5);
}
void main() {
vec2 p = vWorld.xz;
float detail = 1.0 - smoothstep(uDetail.x, uDetail.y, vDepth);
float n = vnoise(p * 0.55) * 0.65 + vnoise(p * 2.1 + 7.0) * 0.35;
vec3 col = mix(uSandDark, uSand, n);
col *= 1.0 + (hash1(floor(p * 70.0)) - 0.5) * 0.1 * detail;
float diff = max(dot(normalize(vN), uLightDir), 0.0);
col *= 0.7 + 0.4 * diff;
float t = uTime;
vec2 q = p + 0.45 * vec2(vnoise(p * 0.8 + t * 0.12), vnoise(p * 0.8 + 5.3 - t * 0.1));
float c = caustic(q * 1.5 + vec2(t * 0.05, t * 0.03), t * 0.75);
c = max(c * 0.75, caustic(q * 2.2 + vec2(3.7, 1.2) - vec2(t * 0.04, -t * 0.025), -t * 0.6) * 0.55);
c *= detail;
float sd = length(p - uShadow.xy);
float sh = uShadow.z * (1.0 - smoothstep(0.0, uShadow.w, sd));
col += uCaustic * c * uCausticStrength * (1.0 - sh);
col *= 1.0 - sh * 0.6;
gl_FragColor = vec4(col, 1.0);
#include <fog_fragment>
gl_FragColor.a = 1.0 - smoothstep(uFade.x, uFade.y, vDepth);
}`;
const sandGeo = new THREE.PlaneGeometry(90, 90, 180, 180);
sandGeo.rotateX(-Math.PI / 2);
const sandMat = new THREE.ShaderMaterial({
uniforms: THREE.UniformsUtils.merge([THREE.UniformsLib.fog, {
uTime: { value: 0 },
uSand: { value: new THREE.Color('#edd9a8') },
uSandDark: { value: new THREE.Color('#d0b47c') },
uCaustic: { value: new THREE.Color('#fffbea') },
uCausticStrength: { value: 0.6 },
uShadow: { value: new THREE.Vector4(0, 0, 0, 1) },
uLightDir: { value: new V3(-0.3, 1, 0.45).normalize() },
uFade: { value: new THREE.Vector2(14, 40) },
uDetail: { value: new THREE.Vector2(11, 26) },
}]),
vertexShader: sandVS,
fragmentShader: sandFS,
fog: true,
transparent: true,
});
const sand = new THREE.Mesh(sandGeo, sandMat);
sand.position.y = SAND_Y;
sand.renderOrder = -1; // draw before other see-through things so fins never punch holes in it
scene.add(sand);
// ─── Rocks ────────────────────────────────────────────────────────────
// Average normals of vertices that share a position (hides sphere seams).
function weldNormals(geo) {
const pos = geo.attributes.position, nrm = geo.attributes.normal, groups = new Map();
for (let i = 0; i < pos.count; i++) {
const k = `${pos.getX(i).toFixed(3)},${pos.getY(i).toFixed(3)},${pos.getZ(i).toFixed(3)}`;
let e = groups.get(k);
if (!e) { e = { n: new V3(), idx: [] }; groups.set(k, e); }
e.n.x += nrm.getX(i); e.n.y += nrm.getY(i); e.n.z += nrm.getZ(i);
e.idx.push(i);
}
groups.forEach((e) => { e.n.normalize(); e.idx.forEach((i) => nrm.setXYZ(i, e.n.x, e.n.y, e.n.z)); });
nrm.needsUpdate = true;
}
const rockBase = new THREE.Color('#7b8a8e'), rockDark = new THREE.Color('#4c5a63'), rockMoss = new THREE.Color('#6f9150');
function rockGeometry(r, s) {
const geo = new THREE.SphereGeometry(r, 36, 26);
const pos = geo.attributes.position, v = new V3();
for (let i = 0; i < pos.count; i++) {
v.fromBufferAttribute(pos, i).normalize();
const n = Math.sin(v.x * 2.1 + s) * Math.cos(v.y * 2.7 - s * 0.7) * 0.5 +
Math.sin(v.z * 3.3 + v.x * 1.7 + s * 1.3) * 0.3 +
Math.sin((v.x + v.y + v.z) * 6 + s) * 0.06;
v.multiplyScalar(r * (1 + 0.2 * n));
pos.setXYZ(i, v.x, v.y, v.z);
}
geo.computeVertexNormals();
weldNormals(geo);
const nrm = geo.attributes.normal, cols = new Float32Array(pos.count * 3), c = new THREE.Color();
for (let i = 0; i < pos.count; i++) {
const ny = nrm.getY(i);
c.copy(rockBase).lerp(rockDark, smoothstep(0.2, -0.6, ny)).lerp(rockMoss, smoothstep(0.45, 0.95, ny) * 0.6);
cols[i * 3] = c.r; cols[i * 3 + 1] = c.g; cols[i * 3 + 2] = c.b;
}
geo.setAttribute('color', new THREE.BufferAttribute(cols, 3));
return geo;
}
const rockMat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.92 });
const rockList = [];
[
[-11, -9, 2.6, 1.3, 0.8, 1.1], [-6.5, -13, 3.2, 1.4, 0.75, 1], [9.5, -10, 2.8, 1.2, 0.85, 1.1],
[3, -17, 3.6, 1.5, 0.7, 1.2], [-14, -18, 3.4, 1.3, 0.8, 1], [15, -19, 3.8, 1.2, 0.8, 1.1],
[-2.5, -9.5, 1.3, 1.3, 0.7, 1], [5.5, -7.5, 1.1, 1.2, 0.8, 1], [-8.5, -4.5, 1.6, 1.3, 0.75, 1.1], [12, -3.5, 1.9, 1.2, 0.8, 1],
].forEach(([x, z, r, sx, sy, sz], i) => {
const m = new THREE.Mesh(rockGeometry(r, i * 1.7 + 0.3), rockMat);
m.scale.set(sx, sy, sz);
m.rotation.y = rr(0, TAU);
m.position.set(x, sandAt(x, z) + r * sy * 0.25, z);
scene.add(m);
rockList.push({ p: m.position.clone(), r: r * Math.max(sx, sy, sz) * 1.05 });
});
// ─── Kelp ribbons (vertices rewritten each frame) ─────────────────────
const kelpMat = new THREE.MeshStandardMaterial({ vertexColors: true, side: THREE.DoubleSide, roughness: 0.7 });
const kelps = [];
function addKelp(x, z, h, w, c0, c1) {
const segs = 28;
const geo = new THREE.BufferGeometry();
const pos = new Float32Array((segs + 1) * 6), col = new Float32Array((segs + 1) * 6), idx = [];
const a = new THREE.Color(c0), b = new THREE.Color(c1), c = new THREE.Color();
for (let i = 0; i <= segs; i++) {
c.copy(a).lerp(b, Math.pow(i / segs, 0.8));
col.set([c.r, c.g, c.b, c.r, c.g, c.b], i * 6);
if (i < segs) { const q = i * 2; idx.push(q, q + 1, q + 2, q + 1, q + 3, q + 2); }
}
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
geo.setIndex(idx);
const mesh = new THREE.Mesh(geo, kelpMat);
mesh.frustumCulled = false;
scene.add(mesh);
kelps.push({ geo, x, z, y0: sandAt(x, z) - 0.05, h, w, segs, ph: rand() * TAU, tw: rr(0.6, 1.6) });
}
const GREEN = ['#2e6b45', '#8fd27a'], LIME = ['#3c7a35', '#b2db63'], KELP = ['#6b4a2b', '#c99d55'];
[
[-8.6, 1.5, 9.6, 0.62, KELP], [-12.5, -1, 10.2, 0.7, KELP], [-9.5, -3.5, 9.9, 0.6, LIME], [-13.5, -6, 10.3, 0.66, KELP],
[-10.5, -9, 10.0, 0.6, GREEN], [-15, -13, 10.4, 0.7, KELP], [-11, -15.5, 10.2, 0.64, KELP],
[8.8, 1, 9.7, 0.62, KELP], [12.8, -2.5, 10.1, 0.68, LIME], [9.8, -5, 9.9, 0.6, KELP], [14, -8, 10.4, 0.7, KELP],
[11, -12, 10.2, 0.62, GREEN], [15.5, -15, 10.4, 0.7, KELP], [-4, -21, 10.3, 0.66, KELP], [5, -23, 10.4, 0.68, KELP],
].forEach(([x, z, h, w, [c0, c1]]) => addKelp(x, z, h, w, c0, c1));
function updateKelp(t) {
for (const k of kelps) {
const p = k.geo.attributes.position;
for (let i = 0; i <= k.segs; i++) {
const s = i / k.segs, bend = Math.pow(s, 1.5);
const sx = (Math.sin(t * 0.9 + k.ph + s * 2.4) * 0.42 + Math.sin(t * 0.37 + k.ph * 2) * 0.28) * bend * MOTION;
const sz = Math.cos(t * 0.7 + k.ph + s * 2) * 0.25 * bend * MOTION;
const w = k.w * Math.pow(Math.max(Math.sin(Math.PI * (0.1 + 0.9 * s)), 0), 0.6) * (1 + 0.12 * Math.sin(s * 15 + k.ph));
const ang = k.ph + s * k.tw + Math.sin(t * 0.5 + k.ph) * 0.3;
const dx = Math.cos(ang) * w * 0.5, dz = Math.sin(ang) * w * 0.5;
const cx = k.x + sx, cy = k.y0 + s * k.h, cz = k.z + sz;
p.setXYZ(i * 2, cx - dx, cy, cz - dz);
p.setXYZ(i * 2 + 1, cx + dx, cy, cz + dz);
}
p.needsUpdate = true;
k.geo.computeVertexNormals();
}
}
// ─── Sun rays ─────────────────────────────────────────────────────────
const rayTex = paintRay();
const rays = [];
for (let i = 0; i < 7; i++) {
const mat = new THREE.MeshBasicMaterial({
map: rayTex, color: 0xffffff, transparent: true, opacity: 0.1,
blending: THREE.AdditiveBlending, depthWrite: false, fog: false,
});
const m = new THREE.Mesh(new THREE.PlaneGeometry(rr(1.2, 3.4), 17), mat);
m.position.set(-13 + i * 4.3 + rr(-1, 1), -8.4, rr(-13, -3));
m.rotation.z = 0.3 + rr(-0.05, 0.05);
scene.add(m);
rays.push({ m, base: rr(0.07, 0.16), ph: rand() * TAU, sp: rr(0.25, 0.6), rot: m.rotation.z });
}
// ─── Drifting specks (plankton that twinkles at night) ────────────────
const SN = 520;
const snGeo = new THREE.BufferGeometry();
const snPos = new Float32Array(SN * 3), snSize = new Float32Array(SN), snPh = new Float32Array(SN);
for (let i = 0; i < SN; i++) {
snPos[i * 3] = rr(-18, 18); snPos[i * 3 + 1] = rr(SAND_Y, 0); snPos[i * 3 + 2] = rr(-24, 9);
snSize[i] = rr(0.025, 0.07); snPh[i] = rand();
}
snGeo.setAttribute('position', new THREE.BufferAttribute(snPos, 3));
snGeo.setAttribute('aSize', new THREE.BufferAttribute(snSize, 1));
snGeo.setAttribute('aPh', new THREE.BufferAttribute(snPh, 1));
const snMat = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 }, uScale: { value: 300 }, uColor: { value: new THREE.Color('#ffffff') },
uOpacity: { value: 0.34 }, uTwinkle: { value: 0 },
},
vertexShader: `
attribute float aSize;
attribute float aPh;
uniform float uTime;
uniform float uScale;
uniform float uTwinkle;
varying float vA;
void main() {
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * mv;
float d = max(-mv.z, 0.1);
gl_PointSize = max(aSize * uScale / d, 1.0);
float tw = mix(1.0, 0.3 + 0.7 * (0.5 + 0.5 * sin(uTime * (0.7 + aPh) + aPh * 40.0)), uTwinkle);
vA = tw * (1.0 - smoothstep(14.0, 30.0, d)) * smoothstep(0.5, 2.0, d);
}`,
fragmentShader: `
uniform vec3 uColor;
uniform float uOpacity;
varying float vA;
void main() {
vec2 uv = gl_PointCoord * 2.0 - 1.0;
float r2 = dot(uv, uv);
if (r2 > 1.0) discard;
gl_FragColor = vec4(uColor, exp(-r2 * 3.5) * vA * uOpacity);
}`,
transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
});
const snow = new THREE.Points(snGeo, snMat);
snow.frustumCulled = false;
scene.add(snow);
function updateSnow(dt, t) {
for (let i = 0; i < SN; i++) {
const ph = snPh[i];
let y = snPos[i * 3 + 1] - (0.04 + 0.05 * ph) * dt * MOTION;
snPos[i * 3] += Math.sin(t * 0.25 + ph * 20) * 0.06 * dt * MOTION;
if (y < SAND_Y) y -= SAND_Y;
snPos[i * 3 + 1] = y;
}
snGeo.attributes.position.needsUpdate = true;
}
// ─── Bubbles (one Points cloud, drawn procedurally in the shader) ─────
const BMAX = 700;
const bGeo = new THREE.BufferGeometry();
const bPos = new Float32Array(BMAX * 3), bSize = new Float32Array(BMAX), bAlpha = new Float32Array(BMAX);
bGeo.setAttribute('position', new THREE.BufferAttribute(bPos, 3));
bGeo.setAttribute('aSize', new THREE.BufferAttribute(bSize, 1));
bGeo.setAttribute('aAlpha', new THREE.BufferAttribute(bAlpha, 1));
const bMat = new THREE.ShaderMaterial({
uniforms: { uScale: { value: 300 }, uColor: { value: new THREE.Color('#ffffff') } },
vertexShader: `
attribute float aSize;
attribute float aAlpha;
uniform float uScale;
varying float vA;
void main() {
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * mv;
gl_PointSize = aSize * uScale / max(-mv.z, 0.1);
vA = aAlpha;
}`,
fragmentShader: `
uniform vec3 uColor;
varying float vA;
void main() {
vec2 uv = gl_PointCoord * 2.0 - 1.0;
float r = length(uv);
if (r > 1.0 || vA <= 0.0) discard;
float rim = smoothstep(0.62, 0.92, r) * (1.0 - smoothstep(0.92, 1.0, r));
float hl = 1.0 - smoothstep(0.0, 0.3, length(uv - vec2(-0.34, -0.36)));
float a = clamp(rim * 0.85 + hl * 0.95 + 0.12 * (1.0 - r), 0.0, 1.0);
gl_FragColor = vec4(uColor, a * vA);
}`,
transparent: true, depthWrite: false,
});
const bubblePoints = new THREE.Points(bGeo, bMat);
bubblePoints.frustumCulled = false;
scene.add(bubblePoints);
const bubbles = Array.from({ length: BMAX }, () => ({ on: false, p: new V3(), v: new V3(), s: 0, age: 0, life: 0, ph: 0 }));
let bCursor = 0;
function spawnBubble(p, size, vx, vy, vz) {
for (let k = 0; k < BMAX; k++) {
const i = (bCursor + k) % BMAX, b = bubbles[i];
if (b.on) continue;
b.on = true; b.p.copy(p); b.v.set(vx, vy, vz); b.s = size; b.age = 0; b.life = rr(5, 9); b.ph = rand() * TAU;
bCursor = (i + 1) % BMAX;
return;
}
}
function updateBubbles(dt) {
const top = -0.06;
for (let i = 0; i < BMAX; i++) {
const b = bubbles[i];
if (!b.on) { bAlpha[i] = 0; bSize[i] = 0; continue; }
b.age += dt;
b.v.y = Math.min(b.v.y + 1.1 * dt, 0.75 + b.s * 3);
b.v.x *= Math.exp(-1.5 * dt);
b.v.z *= Math.exp(-1.5 * dt);
b.p.x += (b.v.x + Math.sin(b.age * 7 + b.ph) * 0.12) * dt;
b.p.y += b.v.y * dt;
b.p.z += (b.v.z + Math.cos(b.age * 6 + b.ph) * 0.08) * dt;
if (b.age > b.life || b.p.y > top) { b.on = false; bAlpha[i] = 0; bSize[i] = 0; continue; }
bPos[i * 3] = b.p.x; bPos[i * 3 + 1] = b.p.y; bPos[i * 3 + 2] = b.p.z;
bSize[i] = b.s * (1 + 0.15 * Math.min(b.age, 3) / 3) * (1 + 0.06 * Math.sin(b.age * 12 + b.ph));
bAlpha[i] = smoothstep(0, 0.15, b.age) * (1 - smoothstep(top - 0.5, top, b.p.y)) * (1 - smoothstep(b.life - 1, b.life, b.age));
}
bGeo.attributes.position.needsUpdate = true;
bGeo.attributes.aSize.needsUpdate = true;
bGeo.attributes.aAlpha.needsUpdate = true;
}
const vents = [
{ x: -7.5, z: -10, t: 1.0, burst: 0 },
{ x: 6.5, z: -13, t: 3.0, burst: 0 },
].map((v) => Object.assign(v, { y: sandAt(v.x, v.z) + 0.05 }));
const tVent = new V3();
function updateVents(dt) {
for (const v of vents) {
v.t -= dt;
if (v.t > 0) continue;
if (v.burst > 0) {
spawnBubble(tVent.set(v.x + rr(-0.05, 0.05), v.y, v.z + rr(-0.05, 0.05)), rr(0.035, 0.08), 0, 0.2, 0);
v.burst--;
v.t = rr(0.06, 0.18);
} else {
v.burst = 4 + Math.floor(rand() * 7);
v.t = rr(2.5, 6);
}
}
}
// ─── The surface, seen from below ─────────────────────────────────────
const ripples = Array.from({ length: 8 }, () => new THREE.Vector4(0, 0, 0, 0));
function addRipple(x, z, strength) {
let slot = ripples[0];
for (const r of ripples) { if (r.w <= 0) { slot = r; break; } if (r.z > slot.z) slot = r; }
slot.set(x, z, 0, strength);
}
const surfMat = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 }, uSky: { value: new THREE.Color() }, uRefl: { value: new THREE.Color() },
uFog: { value: new THREE.Color() }, uSun: { value: new THREE.Color() }, uSunPos: { value: new THREE.Vector2(-2.5, 4) },
uRip: { value: ripples }, uFade: { value: new THREE.Vector2(24, 60) },
},
vertexShader: `
varying vec3 vWorld;
void main() {
vec4 w = modelMatrix * vec4(position, 1.0);
vWorld = w.xyz;
gl_Position = projectionMatrix * viewMatrix * w;
}`,
fragmentShader: `
uniform float uTime;
uniform vec3 uSky;
uniform vec3 uRefl;
uniform vec3 uFog;
uniform vec3 uSun;
uniform vec2 uSunPos;
uniform vec4 uRip[8];
uniform vec2 uFade;
varying vec3 vWorld;
void main() {
vec3 toCam = cameraPosition - vWorld;
float dist = length(toCam);
vec3 V = toCam / dist;
vec2 p = vWorld.xz;
float t = uTime;
vec2 g = vec2(0.0);
g += vec2(0.6, 0.8) * cos(dot(p, vec2(0.6, 0.8)) * 1.1 + t * 1.3) * 0.08;
g += vec2(-0.8, 0.5) * cos(dot(p, vec2(-0.8, 0.5)) * 1.9 + t * 1.7) * 0.06;
g += vec2(0.3, -0.95) * cos(dot(p, vec2(0.3, -0.95)) * 3.1 + t * 2.3) * 0.04;
g += vec2(0.9, 0.2) * cos(dot(p, vec2(0.9, 0.2)) * 5.3 + t * 3.1) * 0.025;
float foam = 0.0;
for (int i = 0; i < 8; i++) {
vec4 r = uRip[i];
if (r.w > 0.001) {
vec2 d = p - r.xy;
float len = length(d) + 0.0001;
float x = len - r.z * 2.4;
float env = exp(-x * x * 0.6) * r.w * exp(-r.z * 0.45);
g += (d / len) * sin(x * 4.0) * env * 0.6;
foam += r.w * exp(-len * len * 0.4) * exp(-r.z * 1.1);
}
}
vec3 N = normalize(vec3(g.x, -1.0, g.y));
float cosI = clamp(dot(V, N), 0.0, 1.0);
float win = smoothstep(0.26, 0.5, cosI);
float edge = exp(-pow((cosI - 0.3) * 14.0, 2.0));
float sparkle = clamp(length(g) * 5.0, 0.0, 1.0);
vec3 col = mix(uRefl * (0.7 + 0.6 * sparkle * sparkle), uSky * (0.8 + 0.4 * sparkle), win);
vec2 sp = vWorld.xz + g * 5.0 - uSunPos;
col += uSun * win * exp(-dot(sp, sp) * 0.06) * 0.8;
col += uSky * edge * 0.18;
float f = clamp(foam, 0.0, 1.0);
col = mix(col, max(uSky, vec3(0.55)), f * 0.8);
col = mix(col, uFog, smoothstep(uFade.x * 0.25, uFade.y, dist) * 0.9);
float a = max(mix(0.92, 0.6, win), f);
a *= 1.0 - smoothstep(uFade.x, uFade.y, dist);
gl_FragColor = vec4(col, a);
}`,
transparent: true, depthWrite: false, side: THREE.DoubleSide,
});
const surfGeo = new THREE.PlaneGeometry(260, 260);
surfGeo.rotateX(Math.PI / 2);
const surface = new THREE.Mesh(surfGeo, surfMat);
surface.position.set(0, 0, -70);
surface.renderOrder = -0.5; // everything underwater sits between the camera and the surface, so draw it first
surface.frustumCulled = false;
scene.add(surface);
// ─── Soft particle pools: blood, surface foam, night sparks ───────────
const pools = [];
function makePool(n, color, opacity, additive, order) {
const geo = new THREE.BufferGeometry();
const pos = new Float32Array(n * 3), size = new Float32Array(n), alpha = new Float32Array(n);
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setAttribute('aSize', new THREE.BufferAttribute(size, 1));
geo.setAttribute('aAlpha', new THREE.BufferAttribute(alpha, 1));
const mat = new THREE.ShaderMaterial({
uniforms: { uScale: { value: 300 }, uColor: { value: new THREE.Color(color) }, uOpacity: { value: opacity } },
vertexShader: `
attribute float aSize;
attribute float aAlpha;
uniform float uScale;
varying float vA;
void main() {
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * mv;
gl_PointSize = clamp(aSize * uScale / max(-mv.z, 0.1), 0.0, 900.0);
vA = aAlpha;
}`,
fragmentShader: `
uniform vec3 uColor;
uniform float uOpacity;
varying float vA;
void main() {
vec2 uv = gl_PointCoord * 2.0 - 1.0;
float r2 = dot(uv, uv);
if (r2 > 1.0 || vA <= 0.0) discard;
gl_FragColor = vec4(uColor, exp(-r2 * 2.6) * (1.0 - r2) * vA * uOpacity);
}`,
transparent: true, depthWrite: false, blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending,
});
const pts = new THREE.Points(geo, mat);
pts.frustumCulled = false;
pts.renderOrder = order;
scene.add(pts);
const P = Array.from({ length: n }, () => ({ on: false, p: new V3(), v: new V3(), age: 0, life: 1, s0: 0, s1: 1, a: 1, drag: 1, rise: 0 }));
let cursor = 0;
const pool = {
mat,
spawn(x, y, z, vx, vy, vz, life, s0, s1, a, drag, rise) {
const q = P[cursor];
cursor = (cursor + 1) % n;
q.on = true; q.p.set(x, y, z); q.v.set(vx, vy, vz); q.age = 0; q.life = life;
q.s0 = s0; q.s1 = s1; q.a = a; q.drag = drag; q.rise = rise;
},
update(dt) {
for (let i = 0; i < n; i++) {
const q = P[i];
if (q.on) {
q.age += dt;
if (q.age >= q.life) q.on = false;
else {
q.v.multiplyScalar(Math.exp(-q.drag * dt));
q.v.y += q.rise * dt;
q.p.addScaledVector(q.v, dt);
if (q.p.y > -0.04) { q.p.y = -0.04; if (q.v.y > 0) q.v.y = 0; }
}
}
const k = q.age / q.life;
pos[i * 3] = q.p.x; pos[i * 3 + 1] = q.p.y; pos[i * 3 + 2] = q.p.z;
size[i] = q.on ? lerp(q.s0, q.s1, 1 - (1 - k) * (1 - k)) : 0;
alpha[i] = q.on ? q.a * smoothstep(0, 0.06, k) * Math.pow(1 - k, 1.4) : 0;
}
geo.attributes.position.needsUpdate = true;
geo.attributes.aSize.needsUpdate = true;
geo.attributes.aAlpha.needsUpdate = true;
},
};
pools.push(pool);
return pool;
}
const blood = makePool(900, '#8e0f17', 0.6, false, 3);
const foam = makePool(320, '#ffffff', 0.9, false, 6);
const sparks = makePool(400, '#7ef9ff', 0, true, 4);
const tSp = new V3();
function surfaceSplash(x, z, power) {
addRipple(x, z, 0.8 * power);
const nf = Math.round(70 * power), nb = Math.round(120 * power);
for (let i = 0; i < nf; i++) {
const a = rand() * TAU, r = Math.sqrt(rand()) * 1.2 * power, sp = rr(0.3, 1.8) * power;
foam.spawn(x + Math.cos(a) * r, -0.05, z + Math.sin(a) * r, Math.cos(a) * sp, 0, Math.sin(a) * sp,
rr(2.5, 5.5), rr(0.3, 0.6), rr(1.2, 2.6) * power, rr(0.5, 0.9), 1.1, 0);
}
for (let i = 0; i < nb; i++) {
const a = rand() * TAU, r = Math.sqrt(rand()) * 1.5 * power;
spawnBubble(tSp.set(x + Math.cos(a) * r, rr(-2.4, -0.15) * power, z + Math.sin(a) * r), rr(0.04, 0.15),
Math.cos(a) * rr(0, 1.4), rr(-2.2, 0.4), Math.sin(a) * rr(0, 1.4));
}
}
// ─── A school of baitfish (boids) ─────────────────────────────────────
const NF = 90;
const fishGeo = (() => {
const body = new THREE.SphereGeometry(1, 10, 7).toNonIndexed();
body.scale(0.035, 0.055, 0.17);
const bp = body.attributes.position.array, bn = body.attributes.normal.array;
const pos = new Float32Array(bp.length + 9), nor = new Float32Array(bp.length + 9);
pos.set(bp); nor.set(bn);
pos.set([0, 0, -0.14, 0, 0.065, -0.26, 0, -0.065, -0.26], bp.length);
for (let i = 0; i < 3; i++) nor.set([1, 0, 0], bp.length + i * 3);
const col = new Float32Array(pos.length), c = new THREE.Color(), top = new THREE.Color('#2c5a74'), bot = new THREE.Color('#e8f1f4');
for (let i = 0; i < pos.length / 3; i++) {
c.copy(bot).lerp(top, smoothstep(-0.01, 0.035, pos[i * 3 + 1]));
col.set([c.r, c.g, c.b], i * 3);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
g.setAttribute('normal', new THREE.BufferAttribute(nor, 3));
g.setAttribute('color', new THREE.BufferAttribute(col, 3));
const ph = new Float32Array(NF);
for (let i = 0; i < NF; i++) ph[i] = rand();
g.setAttribute('aPhase', new THREE.InstancedBufferAttribute(ph, 1));
return g;
})();
const fishTime = { value: 0 };
const fishMat = new THREE.MeshStandardMaterial({ vertexColors: true, metalness: 0.55, roughness: 0.35, side: THREE.DoubleSide });
fishMat.onBeforeCompile = (sh) => {
sh.uniforms.uTime = fishTime;
sh.vertexShader = 'attribute float aPhase;\nuniform float uTime;\n' + sh.vertexShader.replace('#include <begin_vertex>', `#include <begin_vertex>
transformed.x += sin(uTime * 16.0 + aPhase * 6.2831 + position.z * 18.0) * smoothstep(0.06, -0.25, position.z) * 0.045;`);
};
const fishSchool = new THREE.InstancedMesh(fishGeo, fishMat, NF);
fishSchool.frustumCulled = false;
scene.add(fishSchool);
const fishes = [];
for (let i = 0; i < NF; i++) {
fishes.push({ p: new V3(rr(-2, 2), rr(-4.2, -2.6), rr(-8, -5)), v: new V3(rr(-0.5, 0.5), 0, 1).setLength(1.4), alive: true, back: 0 });
}
const fCenter = new V3(), fAvg = new V3(), fHome = new V3(), fSteer = new V3(), fT = new V3(), segP = new V3(), segD = new V3(), segT = new V3();
const fQ = new THREE.Quaternion(), fM = new THREE.Matrix4(), FZ = new V3(0, 0, 1), F_ONE = new V3(1, 1, 1), F_ZERO = new V3(0, 0, 0);
function closestOnShark(p, out) {
segD.subVectors(tailW, snoutW);
const k = clamp(segT.subVectors(p, snoutW).dot(segD) / (segD.lengthSq() || 1), 0, 1);
return out.copy(snoutW).addScaledVector(segD, k);
}
function updateSchool(dt, t) {
fishTime.value = t;
fHome.set(Math.min(patrolAmpX, 6) * 0.8 * Math.sin(t * 0.06 + 2), -3.3 + 1.1 * Math.sin(t * 0.1), -6.5 + 2.6 * Math.sin(t * 0.08 + 1));
fCenter.set(0, 0, 0); fAvg.set(0, 0, 0);
let n = 0;
for (const f of fishes) if (f.alive) { fCenter.add(f.p); fAvg.add(f.v); n++; }
if (n) { fCenter.divideScalar(n); fAvg.divideScalar(n); }
for (let i = 0; i < NF; i++) {
const f = fishes[i];
if (!f.alive) {
f.back -= dt;
if (f.back <= 0) { f.alive = true; f.p.set(fHome.x + rr(-1.5, 1.5), fHome.y + rr(-0.6, 0.6), fHome.z - rr(5, 8)); f.v.set(0, 0, 1.4); }
fishSchool.setMatrixAt(i, fM.compose(f.p, fQ.identity(), F_ZERO));
continue;
}
fSteer.subVectors(fCenter, f.p).multiplyScalar(0.5);
fSteer.addScaledVector(fT.subVectors(fAvg, f.v), 0.9);
fSteer.addScaledVector(fT.subVectors(fHome, f.p), 0.3);
for (let j = 0; j < NF; j++) {
const g = fishes[j];
if (j === i || !g.alive) continue;
const dx = f.p.x - g.p.x, dy = f.p.y - g.p.y, dz = f.p.z - g.p.z, d2 = dx * dx + dy * dy + dz * dz;
if (d2 < 0.12 && d2 > 1e-6) { const k = (0.12 - d2) * 30; fSteer.x += dx * k; fSteer.y += dy * k; fSteer.z += dz * k; }
}
closestOnShark(f.p, segP);
const ds = f.p.distanceTo(segP);
const panic = ds < 4.2;
if (panic) fSteer.addScaledVector(fT.subVectors(f.p, segP).normalize(), (4.2 - ds) * 9);
if (f.p.y > -0.7) fSteer.y -= 5;
if (f.p.y < SAND_Y + 2) fSteer.y += 5;
f.v.addScaledVector(fSteer, dt);
const sp = f.v.length(), maxS = panic ? 5.5 : 2.2;
if (sp > maxS) f.v.multiplyScalar(maxS / sp);
else if (sp < 0.9) f.v.multiplyScalar(0.9 / Math.max(sp, 1e-4));
f.p.addScaledVector(f.v, dt);
fQ.setFromUnitVectors(FZ, fT.copy(f.v).normalize());
fishSchool.setMatrixAt(i, fM.compose(f.p, fQ, F_ONE));
}
fishSchool.instanceMatrix.needsUpdate = true;
}
function eatFishNear(p, r) {
let n = 0;
for (const f of fishes) if (f.alive && f.p.distanceTo(p) < r) { f.alive = false; f.back = rr(6, 12); n++; }
return n;
}
// ─── Seals resting at the surface ─────────────────────────────────────
const seals = [];
const sealBodyGeo = (() => {
const g = new THREE.SphereGeometry(1, 24, 14), p = g.attributes.position;
const col = new Float32Array(p.count * 3), c = new THREE.Color(), back = new THREE.Color('#3a312b'), belly = new THREE.Color('#7a6a5a');
for (let i = 0; i < p.count; i++) {
const x = p.getX(i), y = p.getY(i), z = p.getZ(i), taper = z > 0 ? 1 - 0.3 * z * z : 1 + 0.6 * z;
p.setXYZ(i, x * 0.22 * taper, y * 0.19 * taper, z * 0.78);
c.copy(belly).lerp(back, smoothstep(-0.3, 0.4, y));
col.set([c.r, c.g, c.b], i * 3);
}
g.setAttribute('color', new THREE.BufferAttribute(col, 3));
g.computeVertexNormals();
return g;
})();
const sealSkin = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.65 });
const sealHeadMat = new THREE.MeshStandardMaterial({ color: '#463c35', roughness: 0.6 });
const sealFlipMat = new THREE.MeshStandardMaterial({ color: '#2f2823', roughness: 0.7 });
const sealEyeMat = new THREE.MeshStandardMaterial({ color: '#050505', roughness: 0.1 });
const sphere1 = new THREE.SphereGeometry(1, 14, 10);
function makeSeal(x, z) {
const g = new THREE.Group();
g.rotation.order = 'YXZ';
g.add(new THREE.Mesh(sealBodyGeo, sealSkin));
const head = new THREE.Mesh(sphere1, sealHeadMat);
head.scale.set(0.15, 0.14, 0.17); head.position.set(0, 0.04, 0.8);
const snout = new THREE.Mesh(sphere1, sealHeadMat);
snout.scale.set(0.075, 0.065, 0.1); snout.position.set(0, 0, 0.95);
g.add(head, snout);
for (const sx of [-1, 1]) {
const e = new THREE.Mesh(sphere1, sealEyeMat);
e.scale.setScalar(0.022); e.position.set(sx * 0.085, 0.08, 0.9);
g.add(e);
}
const fore = [-1, 1].map((sx) => {
const p = new THREE.Group(), m = new THREE.Mesh(sphere1, sealFlipMat);
p.position.set(sx * 0.17, -0.08, 0.36);
m.scale.set(0.2, 0.022, 0.075); m.position.x = sx * 0.17;
p.add(m); g.add(p);
return p;
});
const hind = [-1, 1].map((sx) => {
const p = new THREE.Group(), m = new THREE.Mesh(sphere1, sealFlipMat);
p.position.set(sx * 0.06, 0, -0.76);
m.scale.set(0.06, 0.018, 0.2); m.position.z = -0.17;
p.add(m); g.add(p);
return p;
});
scene.add(g);
const s = { g, fore, hind, pos: new V3(x, -0.14, z), heading: rand() * TAU, turn: 0, turnT: 0, ph: rand() * TAU, held: false, panic: 0 };
seals.push(s);
addRipple(x, z, 0.35);
for (let i = 0; i < 14; i++) spawnBubble(tSp.set(x + rr(-0.4, 0.4), rr(-0.6, -0.1), z + rr(-0.4, 0.4)), rr(0.03, 0.07), 0, 0.2, 0);
return s;
}
function updateSeals(dt, t) {
for (const s of seals) {
const k = s.held ? 3 : s.panic > 0 ? 2.2 : 1, paddle = Math.sin(t * 2.4 * k + s.ph), kick = Math.sin(t * 3 * k + s.ph);
s.fore[0].rotation.set(0, 0.4 + paddle * 0.4, -0.35 - paddle * 0.35);
s.fore[1].rotation.set(0, -0.4 - paddle * 0.4, 0.35 + paddle * 0.35);
s.hind[0].rotation.y = kick * 0.45 + 0.15;
s.hind[1].rotation.y = kick * 0.45 - 0.15;
if (s.held) continue;
s.panic = Math.max(0, s.panic - dt);
s.turnT -= dt;
if (s.turnT <= 0) { s.turnT = rr(2, 5); s.turn = rr(-0.5, 0.5); }
const spd = s.panic > 0 ? 1.5 : 0.32;
if (Math.abs(s.pos.x) > sealZoneX || s.pos.z < -5.5 || s.pos.z > 2) s.heading = dampAngle(s.heading, Math.atan2(-s.pos.x, -1.8 - s.pos.z), 2, dt);
else s.heading += s.turn * dt * (s.panic > 0 ? 4 : 1);
s.pos.x += Math.sin(s.heading) * spd * dt;
s.pos.z += Math.cos(s.heading) * spd * dt;
s.pos.y = -0.14 + Math.sin(t * 1.6 + s.ph) * 0.03;
s.g.position.copy(s.pos);
s.g.rotation.set(Math.sin(t * 1.1 + s.ph) * 0.05, s.heading, Math.sin(t * 1.3 + s.ph) * 0.08);
}
}
// ─── Great white ──────────────────────────────────────────────────────
// The body is a lofted tube: s runs from the snout (0) to the tail stock (1).
const SL = 4.6, NS = 72, NA = 40, NR = NS + 2, K_WAVE = TAU * 0.8;
const zOf = (s) => SL * (0.5 - s);
const frontF = (s) => Math.pow(Math.sin(Math.min(s / 0.38, 1) * Math.PI / 2), 0.72);
const backF = (s) => (s < 0.38 ? 1 : 1 - 0.8 * Math.pow((s - 0.38) / 0.62, 1.15));
const hOf = (s) => 0.56 * frontF(s) * backF(s);
const wOf = (s) => 0.47 * frontF(s) * backF(s) * (1 + 0.35 * smoothstep(0.85, 1, s));
const cOf = (s) => 0.12 * (1 - smoothstep(0, 0.35, s)) + 0.05 * smoothstep(0.6, 1, s);
const flatOf = (s, yn) => (yn < 0 && s < 0.25 ? lerp(0.72, 1, s / 0.25) : 1);
const mouthLine = (s) => lerp(-0.6, -0.12, smoothstep(0.05, 0.19, s));
const isJawAt = (s, yn) => s > 0.05 && s < 0.2 && yn < mouthLine(s);
const liftW = (s) => 1 - smoothstep(0.08, 0.26, s);
const protW = (s, yn) => (1 - smoothstep(0.16, 0.22, s)) * (1 - smoothstep(mouthLine(s) + 0.05, mouthLine(s) + 0.4, yn));
const HY = cOf(0.205) - 0.12 * hOf(0.205), HZ = zOf(0.205); // jaw hinge
const H2Y = cOf(0.3) + 0.35 * hOf(0.3), H2Z = zOf(0.3); // snout lift pivot
function skinPoint(out, s, yn, side, inset) {
const k = inset === undefined ? 1 : inset, a = Math.asin(clamp(yn, -1, 1));
return out.set(side * wOf(s) * Math.cos(a) * k, cOf(s) + hOf(s) * yn * flatOf(s, yn) * k, zOf(s));
}
const ringS = new Float32Array(NR);
for (let i = 1; i <= NS; i++) ringS[i] = Math.pow(i / NS, 1.25);
ringS[NR - 1] = 1;
const NV = 2 + NS * NA, TAILV = NV - 1;
const rest = new Float32Array(NV * 3), vRing = new Uint8Array(NV), vJaw = new Uint8Array(NV), vProt = new Float32Array(NV), vYn = new Float32Array(NV);
const vid = (i, j) => 1 + (i - 1) * NA + (j % NA);
rest[1] = cOf(0); rest[2] = zOf(0);
for (let i = 1; i <= NS; i++) {
const s = ringS[i];
for (let j = 0; j < NA; j++) {
const th = (j / NA) * TAU, yn = Math.sin(th), v = vid(i, j);
rest[v * 3] = wOf(s) * Math.cos(th);
rest[v * 3 + 1] = cOf(s) + hOf(s) * yn * flatOf(s, yn);
rest[v * 3 + 2] = zOf(s);
vRing[v] = i; vYn[v] = yn;
vJaw[v] = isJawAt(s, yn) ? 1 : 0;
vProt[v] = vJaw[v] ? 0 : protW(s, yn);
}
}
rest[TAILV * 3 + 1] = cOf(1); rest[TAILV * 3 + 2] = zOf(1) - 0.03; vRing[TAILV] = NR - 1;
// Triangles that straddle the lip line are left out: that gap is the mouth.
const idx = [], upperLip = new Set(), lowerLip = new Set();
function tri(a, b, c) {
const n = vJaw[a] + vJaw[b] + vJaw[c];
if (n > 0 && n < 3 && Math.max(ringS[vRing[a]], ringS[vRing[b]], ringS[vRing[c]]) < 0.185) {
for (const q of [a, b, c]) (vJaw[q] ? lowerLip : upperLip).add(q);
return;
}
idx.push(a, b, c);
}
for (let j = 0; j < NA; j++) tri(0, vid(1, j), vid(1, j + 1));
for (let i = 1; i < NS; i++) {
for (let j = 0; j < NA; j++) {
const a = vid(i, j), b = vid(i, j + 1), c = vid(i + 1, j), d = vid(i + 1, j + 1);
tri(a, c, b); tri(b, c, d);
}
}
for (let j = 0; j < NA; j++) tri(TAILV, vid(NS, j + 1), vid(NS, j));
// Countershading with the ragged grey/white boundary great whites have.
const C_TOP = new THREE.Color('#46535d'), C_FLANK = new THREE.Color('#7c8992'), C_BELLY = new THREE.Color('#eeeee8');
const C_GUM = new THREE.Color('#c79a98'), C_DARK = new THREE.Color('#1c2227'), C_WHITE = new THREE.Color('#eeeee8');
const bodyCol = new Float32Array(NV * 3), tc = new THREE.Color();
for (let v = 0; v < NV; v++) {
const s = ringS[vRing[v]], yn = vYn[v], xn = rest[v * 3] / Math.max(wOf(s), 1e-3);
const jag = 0.07 * Math.sin(s * 47 + 1.3) + 0.045 * Math.sin(s * 113 + 0.4) + 0.03 * Math.sin(xn * 9 + s * 20);
const bnd = -0.06 - 0.24 * smoothstep(0.12, 0.5, s) + 0.12 * smoothstep(0.78, 1, s) + jag;
tc.copy(C_BELLY).lerp(C_FLANK, smoothstep(bnd - 0.035, bnd + 0.035, yn)).lerp(C_TOP, smoothstep(0.25, 0.95, yn));
tc.multiplyScalar(0.95 + 0.05 * Math.sin(s * 90 + xn * 7) * Math.sin(yn * 11 + s * 30));
if (s < 0.2) tc.lerp(C_GUM, 0.55 * Math.exp(-Math.pow((yn - mouthLine(s)) / 0.07, 2)) * (1 - smoothstep(0.17, 0.2, s)));
bodyCol[v * 3] = tc.r; bodyCol[v * 3 + 1] = tc.g; bodyCol[v * 3 + 2] = tc.b;
}
const bodyGeo = new THREE.BufferGeometry();
bodyGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(rest), 3));
bodyGeo.setAttribute('color', new THREE.BufferAttribute(bodyCol, 3));
bodyGeo.setIndex(idx);
bodyGeo.computeVertexNormals();
// Double-sided so the inside of the head reads as a dark red mouth through the gap.
const bodyMat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.5, metalness: 0.05, side: THREE.DoubleSide });
bodyMat.onBeforeCompile = (sh) => {
sh.fragmentShader = sh.fragmentShader.replace('#include <color_fragment>',
'#include <color_fragment>\n if (!gl_FrontFacing) diffuseColor.rgb = vec3(0.32, 0.04, 0.06);');
};
const sharkRoot = new THREE.Group();
sharkRoot.rotation.order = 'YXZ';
const bodyMesh = new THREE.Mesh(bodyGeo, bodyMat);
bodyMesh.frustumCulled = false;
sharkRoot.add(bodyMesh);
scene.add(sharkRoot);
// ─── Pose: jaw drop, snout lift, upper jaw protrusion, swimming wave ─
// Swimming wave for a lamnid shark: the front is stiff and nearly all the
// side-to-side motion is in the rear third, growing toward the tail. When
// turning, the whole body bows along the arc it is following.
const cur = { jaw: 0, A: 0.5, ph: 0, curv: 0 };
const BO = { x: 0, c: 1, s: 0, b: 0 };
const SL2 = SL * SL, PIVOT = 0.38;
function bend(s) {
const sp = Math.pow(Math.max(s, 0), 1.4), env = 0.006 + 0.084 * sp * s, envd = 0.2016 * sp;
const arg = cur.ph - K_WAVE * s, sa = Math.sin(arg), ca = Math.cos(arg), d = PIVOT - s;
BO.x = cur.A * SL * env * sa + 0.5 * cur.curv * SL2 * d * d;
const dxds = cur.A * SL * (envd * sa - env * K_WAVE * ca) - cur.curv * SL2 * d;
BO.b = Math.atan(-dxds / SL);
BO.c = Math.cos(BO.b); BO.s = Math.sin(BO.b);
return BO;
}
function posePoint(out, x, y, z, s, isJaw, pr) {
const j = cur.jaw;
if (isJaw) {
const phi = j * 0.95, c = Math.cos(phi), sn = Math.sin(phi), dy = y - HY, dz = z - HZ;
y = HY + dy * c - dz * sn; z = HZ + dy * sn + dz * c;
} else {
const l = -j * 0.2 * liftW(s);
if (l !== 0) { const c = Math.cos(l), sn = Math.sin(l), dy = y - H2Y, dz = z - H2Z; y = H2Y + dy * c - dz * sn; z = H2Z + dy * sn + dz * c; }
if (pr > 0) { y -= 0.06 * j * pr; z += 0.06 * j * pr; }
}
bend(s);
return out.set(BO.x + x * BO.c, y, z - x * BO.s);
}
const bodyPos = bodyGeo.attributes.position.array;
const rX = new Float32Array(NR), rC = new Float32Array(NR), rSn = new Float32Array(NR), rLC = new Float32Array(NR), rLS = new Float32Array(NR);
function poseBody() {
const j = cur.jaw, phi = j * 0.95, cp = Math.cos(phi), sp = Math.sin(phi), prY = -0.06 * j, prZ = 0.06 * j;
for (let r = 0; r < NR; r++) {
bend(ringS[r]);
rX[r] = BO.x; rC[r] = BO.c; rSn[r] = BO.s;
const l = -j * 0.2 * liftW(ringS[r]);
rLC[r] = Math.cos(l); rLS[r] = Math.sin(l);
}
for (let v = 0; v < NV; v++) {
const x = rest[v * 3], r = vRing[v];
let y = rest[v * 3 + 1], z = rest[v * 3 + 2];
if (vJaw[v]) {
const dy = y - HY, dz = z - HZ;
y = HY + dy * cp - dz * sp; z = HZ + dy * sp + dz * cp;
} else {
if (rLS[r] !== 0) { const dy = y - H2Y, dz = z - H2Z; y = H2Y + dy * rLC[r] - dz * rLS[r]; z = H2Z + dy * rLS[r] + dz * rLC[r]; }
const pw = vProt[v];
if (pw > 0) { y += prY * pw; z += prZ * pw; }
}
bodyPos[v * 3] = rX[r] + x * rC[r];
bodyPos[v * 3 + 1] = y;
bodyPos[v * 3 + 2] = z - x * rSn[r];
}
bodyGeo.attributes.position.needsUpdate = true;
bodyGeo.computeVertexNormals();
}
// ─── Teeth, placed along the lip gaps found while cutting the mouth ───
function lipTeeth(set, n, lower) {
const B = 14, bins = Array.from({ length: B }, () => ({ x: 0, y: 0, z: 0, s: 0, pr: 0, n: 0 }));
for (const v of set) {
const x = rest[v * 3], y = rest[v * 3 + 1], z = rest[v * 3 + 2], a = Math.atan2(x, z - HZ);
if (Math.abs(a) > 1.3) continue;
const b = bins[Math.min(B - 1, Math.floor(((a + 1.3) / 2.6) * B))];
b.x += x; b.y += y; b.z += z; b.s += ringS[vRing[v]]; b.pr += vProt[v]; b.n++;
}
const poly = bins.filter((b) => b.n).map((b) => ({ x: b.x / b.n, y: b.y / b.n, z: b.z / b.n, s: b.s / b.n, pr: b.pr / b.n }));
if (poly.length < 2) return [];
const cum = [0];
for (let i = 1; i < poly.length; i++) cum.push(cum[i - 1] + Math.hypot(poly[i].x - poly[i - 1].x, poly[i].y - poly[i - 1].y, poly[i].z - poly[i - 1].z));
const total = cum[cum.length - 1], out = [];
for (let k = 0; k < n; k++) {
const d = (total * (k + 0.5)) / n;
let i = 1;
while (i < poly.length - 1 && cum[i] < d) i++;
const f = clamp((d - cum[i - 1]) / Math.max(cum[i] - cum[i - 1], 1e-6), 0, 1), A = poly[i - 1], Bp = poly[i];
const x = lerp(A.x, Bp.x, f), y = lerp(A.y, Bp.y, f), z = lerp(A.z, Bp.z, f);
const mid = Math.abs((k + 0.5) / n - 0.5) * 2, size = lerp(1.05, 0.55, mid * mid), len = 0.075 * size;
const bx = x * 0.9, by = y + (lower ? -0.012 : 0.012), bz = z - 0.025;
out.push({
jaw: lower ? 1 : 0, s: lerp(A.s, Bp.s, f), pr: lower ? 0 : lerp(A.pr, Bp.pr, f), size,
bx, by, bz, tx: bx * 0.94, ty: by + (lower ? len : -len), tz: bz - 0.01, ox: x, oz: Math.max(z - HZ, 0.02),
});
}
return out;
}
const allTeeth = lipTeeth(upperLip, 24, false).concat(lipTeeth(lowerLip, 22, true));
const toothGeo = new THREE.ConeGeometry(1, 1, 4, 1);
toothGeo.translate(0, 0.5, 0);
const teethMesh = new THREE.InstancedMesh(toothGeo, new THREE.MeshStandardMaterial({ color: '#f3efe2', roughness: 0.3 }), Math.max(1, allTeeth.length));
teethMesh.frustumCulled = false;
sharkRoot.add(teethMesh);
// ─── Eyes (black, rolling back to white when it bites) ────────────────
const eyeGeo = new THREE.SphereGeometry(1, 18, 12);
const eyeMat = new THREE.MeshStandardMaterial({ color: '#030405', roughness: 0.06, metalness: 0.4 });
const eyeWhiteMat = new THREE.MeshStandardMaterial({ color: '#d6d9d4', roughness: 0.4 });
const eyes = [1, -1].map((side) => {
const r = skinPoint(new V3(), 0.115, 0.16, side, 0.93);
const black = new THREE.Mesh(eyeGeo, eyeMat), white = new THREE.Mesh(eyeGeo, eyeWhiteMat);
black.scale.setScalar(0.042); white.scale.setScalar(0.044); white.visible = false;
sharkRoot.add(black, white);
return { r, black, white };
});
// ─── Sensory pores and nostrils on the snout ──────────────────────────
const pores = [];
for (let i = 0; i < 90 && pores.length < 38; i++) {
const s = rr(0.012, 0.11), th = rr(-1.2, 1.35), yn = Math.sin(th), side = rand() < 0.5 ? -1 : 1;
if (isJawAt(s, yn) || Math.abs(yn - mouthLine(s)) < 0.12) continue;
const p = skinPoint(new V3(), s, yn, side, 1);
pores.push({ x: p.x, y: p.y, z: p.z, s, pr: protW(s, yn), sz: rr(0.006, 0.0105), fy: 1 });
}
for (const side of [1, -1]) {
const p = skinPoint(new V3(), 0.038, -0.42, side, 0.99);
pores.push({ x: p.x, y: p.y, z: p.z, s: 0.038, pr: protW(0.038, -0.42), sz: 0.024, fy: 0.45 });
}
const poreMesh = new THREE.InstancedMesh(new THREE.SphereGeometry(1, 6, 4), new THREE.MeshStandardMaterial({ color: '#1e2428', roughness: 0.6 }), pores.length);
poreMesh.frustumCulled = false;
sharkRoot.add(poreMesh);
// ─── Five gill slits a side ───────────────────────────────────────────
const gillMat = new THREE.MeshStandardMaterial({ color: '#2a3238', roughness: 0.7 });
const gills = [];
for (const side of [1, -1]) {
for (let k = 0; k < 5; k++) {
const s = 0.205 + k * 0.021, top = 0.4 - Math.abs(k - 2) * 0.04, bot = -0.5 + k * 0.035;
const r = skinPoint(new V3(), s, 0, side, 1), pts = [];
for (let m = 0; m <= 8; m++) {
const f = m / 8;
pts.push(skinPoint(new V3(), s + 0.008 * (1 - Math.pow(f * 2 - 1, 2)), lerp(bot, top, f), side, 1.015).sub(r));
}
const grp = new THREE.Group();
grp.add(new THREE.Mesh(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 14, 0.009, 4, false), gillMat));
sharkRoot.add(grp);
gills.push({ grp, r, s });
}
}
// ─── Fins (extruded outlines, painted per vertex) ─────────────────────
const finMat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.55 });
function makeFin(shape, depth, chord, span, paint) {
const g = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: true, bevelThickness: depth * 0.5, bevelSize: depth * 0.45, bevelSegments: 2, curveSegments: 16 });
g.translate(0, 0, -depth / 2);
const c = chord.clone().normalize(), sp = span.clone().addScaledVector(c, -span.dot(c)).normalize(), n = new V3().crossVectors(c, sp);
g.applyMatrix4(new THREE.Matrix4().makeBasis(c, sp, n));
const p = g.attributes.position, nr = g.attributes.normal, col = new Float32Array(p.count * 3), cc = new THREE.Color(), P = new V3(), N = new V3();
let maxS = 1e-3;
for (let i = 0; i < p.count; i++) maxS = Math.max(maxS, P.fromBufferAttribute(p, i).dot(sp));
for (let i = 0; i < p.count; i++) {
P.fromBufferAttribute(p, i); N.fromBufferAttribute(nr, i);
paint(cc, clamp(P.dot(sp) / maxS, 0, 1), N);
col[i * 3] = cc.r; col[i * 3 + 1] = cc.g; col[i * 3 + 2] = cc.b;
}
g.setAttribute('color', new THREE.BufferAttribute(col, 3));
const grp = new THREE.Group();
grp.add(new THREE.Mesh(g, finMat));
sharkRoot.add(grp);
return grp;
}
const paintGrey = (cc, f) => cc.copy(C_FLANK).lerp(C_TOP, smoothstep(0.1, 0.85, f));
const paintPec = (cc, f, N) => (N.y < -0.15 ? cc.copy(C_WHITE).lerp(C_DARK, smoothstep(0.72, 0.92, f)) : cc.copy(C_FLANK).lerp(C_TOP, smoothstep(0.2, 0.9, f)));
const paintLow = (cc, f, N) => cc.copy(N.y < -0.15 ? C_WHITE : C_FLANK).lerp(C_TOP, f * 0.3);
const dorsalShape = new THREE.Shape();
dorsalShape.moveTo(0.34, -0.12); dorsalShape.lineTo(0.34, 0);
dorsalShape.bezierCurveTo(0.28, 0.26, 0.1, 0.5, -0.12, 0.6);
dorsalShape.quadraticCurveTo(-0.04, 0.24, -0.28, 0);
dorsalShape.lineTo(-0.28, -0.12); dorsalShape.closePath();
const pecShape = new THREE.Shape();
pecShape.moveTo(0.26, -0.05); pecShape.lineTo(0.26, 0);
pecShape.bezierCurveTo(0.22, 0.32, 0.04, 0.74, -0.27, 0.95);
pecShape.bezierCurveTo(-0.13, 0.66, -0.12, 0.3, -0.2, 0);
pecShape.lineTo(-0.2, -0.05); pecShape.closePath();
const ck = 0.82, tailShape = new THREE.Shape();
tailShape.moveTo(-0.1 * ck, 0.12 * ck);
tailShape.bezierCurveTo(0.15 * ck, 0.3 * ck, 0.42 * ck, 0.74 * ck, 0.74 * ck, 1.02 * ck);
tailShape.quadraticCurveTo(0.42 * ck, 0.45 * ck, 0.47 * ck, 0.02 * ck);
tailShape.quadraticCurveTo(0.42 * ck, -0.4 * ck, 0.63 * ck, -0.84 * ck);
tailShape.bezierCurveTo(0.36 * ck, -0.55 * ck, 0.1 * ck, -0.2 * ck, -0.1 * ck, -0.1 * ck);
tailShape.closePath();
const smallFin = (h, back) => {
const s = new THREE.Shape();
s.moveTo(0.07, -0.04); s.lineTo(0.07, 0);
s.quadraticCurveTo(0.04, h * 0.7, -back, h);
s.quadraticCurveTo(-0.03, h * 0.3, -0.08, 0);
s.lineTo(-0.08, -0.04); s.closePath();
return s;
};
const Z1 = new V3(0, 0, 1), Y1 = new V3(0, 1, 0);
const fins = [];
fins.push({ grp: makeFin(dorsalShape, 0.05, Z1, Y1, paintGrey), s: 0.43, r: new V3(0, cOf(0.43) + hOf(0.43) * 0.95, zOf(0.43)) });
for (const side of [1, -1]) {
fins.push({ grp: makeFin(pecShape, 0.045, new V3(0, -0.08, 1), new V3(side, -0.5, -0.1), paintPec), s: 0.3, r: skinPoint(new V3(), 0.3, -0.42, side, 0.9), pec: side });
fins.push({ grp: makeFin(smallFin(0.2, 0.12), 0.035, Z1, new V3(side * 0.6, -0.8, 0), paintLow), s: 0.66, r: skinPoint(new V3(), 0.66, -0.7, side, 0.9) });
}
fins.push({ grp: makeFin(smallFin(0.12, 0.06), 0.03, Z1, Y1, paintGrey), s: 0.86, r: new V3(0, cOf(0.86) + hOf(0.86) * 0.92, zOf(0.86)) });
fins.push({ grp: makeFin(smallFin(0.11, 0.06), 0.03, Z1, new V3(0, -1, 0), paintLow), s: 0.88, r: new V3(0, cOf(0.88) - hOf(0.88) * 0.92, zOf(0.88)) });
fins.push({ grp: makeFin(tailShape, 0.05, new V3(0, 0, -1), Y1, (cc, f) => cc.copy(C_FLANK).lerp(C_TOP, 0.35 + 0.5 * f)), s: 1, r: new V3(0, cOf(1), zOf(1) + 0.02), tail: true });
// ─── Pose everything for this frame ───────────────────────────────────
const tb = new V3(), tt = new V3(), tu = new V3(), to = new V3(), tg = new V3(), tmat = new THREE.Matrix4(), tqI = new THREE.Quaternion(), tsc = new V3();
const bitePt = new V3(), snoutPt = new V3(), tailPt = new V3(), lipA = new V3(), lipB = new V3();
const upRest = new V3(0, cOf(0.045) - hOf(0.045) * flatOf(0.045, -1), zOf(0.045));
const loRest = new V3(0, cOf(0.08) - hOf(0.08) * flatOf(0.08, -1), zOf(0.08));
function poseShark(jaw, A, ph, curv, eyeRoll, pecAoA, pecLift) {
cur.jaw = jaw; cur.A = A; cur.ph = ph; cur.curv = curv;
poseBody();
for (let i = 0; i < allTeeth.length; i++) {
const q = allTeeth[i];
posePoint(tb, q.bx, q.by, q.bz, q.s, q.jaw, q.pr);
posePoint(tt, q.tx, q.ty, q.tz, q.s, q.jaw, q.pr);
tu.subVectors(tt, tb);
const len = tu.length() || 1e-3;
tu.divideScalar(len);
to.set(q.ox, 0, q.oz).normalize();
to.addScaledVector(tu, -to.dot(tu)).normalize();
tg.crossVectors(tu, to);
const w = 0.028 * q.size;
tmat.set(tg.x * w, tu.x * len, to.x * w * 0.35, tb.x,
tg.y * w, tu.y * len, to.y * w * 0.35, tb.y,
tg.z * w, tu.z * len, to.z * w * 0.35, tb.z,
0, 0, 0, 1);
teethMesh.setMatrixAt(i, tmat);
}
teethMesh.instanceMatrix.needsUpdate = true;
for (const e of eyes) {
posePoint(e.black.position, e.r.x, e.r.y, e.r.z, 0.115, 0, 0);
e.white.position.copy(e.black.position);
e.white.visible = eyeRoll > 0.5;
e.black.visible = !e.white.visible;
}
for (let i = 0; i < pores.length; i++) {
const q = pores[i];
posePoint(tb, q.x, q.y, q.z, q.s, 0, q.pr);
poreMesh.setMatrixAt(i, tmat.compose(tb, tqI, tsc.set(q.sz, q.sz * q.fy, q.sz)));
}
poreMesh.instanceMatrix.needsUpdate = true;
for (const g of gills) {
posePoint(g.grp.position, g.r.x, g.r.y, g.r.z, g.s, 0, 0);
g.grp.rotation.y = BO.b;
}
for (const f of fins) {
posePoint(f.grp.position, f.r.x, f.r.y, f.r.z, f.s, 0, 0);
if (f.tail) bend(1.14);
if (f.pec) f.grp.rotation.set(-pecAoA, BO.b, f.pec * (pecLift + 0.02 * Math.sin(ph * 0.5 + f.pec)));
else f.grp.rotation.set(0, BO.b, 0);
}
posePoint(lipA, upRest.x, upRest.y, upRest.z, 0.045, 0, 1);
posePoint(lipB, loRest.x, loRest.y, loRest.z, 0.08, 1, 0);
bitePt.addVectors(lipA, lipB).multiplyScalar(0.5);
bitePt.y += 0.06; bitePt.z -= 0.1;
posePoint(snoutPt, 0, cOf(0.02), zOf(0.02), 0.02, 0, 0);
posePoint(tailPt, 0, cOf(0.95), zOf(0.95), 0.95, 0, 0);
}
// ─── Day and night palettes (blended smoothly) ────────────────────────
const PAL = {
day: {
fog: '#1f6a8c', hemiSky: '#c4ecff', hemiGround: '#24546b', hemiI: 0.95, key: '#fff4de', keyI: 0.95, rim: '#8fe3ff', rimI: 0.4,
sand: '#c7b68c', sandDark: '#9c8b66', caustic: '#eafcff', causticI: 0.35, ray: '#e2f8ff', rayI: 0.9,
snow: '#e6fbff', snowI: 0.3, twinkle: 0, sky: '#f4fdff', refl: '#62b8d8', surfFog: '#2a7da3', sun: '#fffbe8',
blood: '#8e0f17', bloodI: 0.6, foam: '#ffffff', bubble: '#ffffff', spark: 0, shadow: 0.35,
},
night: {
fog: '#071c30', hemiSky: '#5a7bb2', hemiGround: '#050f1b', hemiI: 0.75, key: '#aac6ff', keyI: 0.5, rim: '#55d0ff', rimI: 0.55,
sand: '#3a465e', sandDark: '#232b3e', caustic: '#9ecbff', causticI: 0.1, ray: '#7fa2e0', rayI: 0.35,
snow: '#7ef9ff', snowI: 0.9, twinkle: 1, sky: '#8fa7d6', refl: '#163454', surfFog: '#0b2440', sun: '#dce8ff',
blood: '#5e0810', bloodI: 0.75, foam: '#cfe2ff', bubble: '#cfe6ff', spark: 1, shadow: 0.15,
},
};
for (const k of Object.keys(PAL)) for (const [n, v] of Object.entries(PAL[k])) if (typeof v === 'string') PAL[k][n] = new THREE.Color(v);
const mixC = (out, n, m) => out.copy(PAL.day[n]).lerp(PAL.night[n], m);
const mixN = (n, m) => lerp(PAL.day[n], PAL.night[n], m);
const theme = { m: 0, target: 0, ready: false };
function readTheme() {
const cs = getComputedStyle(document.documentElement);
theme.target = (parseFloat(cs.getPropertyValue('--mode')) || 0) > 0.5 ? 1 : 0;
if (!theme.ready) { theme.m = theme.target; theme.ready = true; }
}
function applyTheme(t) {
const m = theme.m;
mixC(hemi.color, 'hemiSky', m); mixC(hemi.groundColor, 'hemiGround', m); hemi.intensity = mixN('hemiI', m);
mixC(key.color, 'key', m); key.intensity = mixN('keyI', m);
mixC(rim.color, 'rim', m); rim.intensity = mixN('rimI', m);
mixC(scene.fog.color, 'fog', m);
const u = sandMat.uniforms;
mixC(u.uSand.value, 'sand', m); mixC(u.uSandDark.value, 'sandDark', m);
mixC(u.uCaustic.value, 'caustic', m); u.uCausticStrength.value = mixN('causticI', m);
const rayI = mixN('rayI', m);
for (const r of rays) {
mixC(r.m.material.color, 'ray', m);
r.m.material.opacity = r.base * rayI * (0.6 + 0.4 * Math.sin(t * r.sp + r.ph));
r.m.rotation.z = r.rot + Math.sin(t * 0.15 + r.ph) * 0.03 * MOTION;
}
mixC(snMat.uniforms.uColor.value, 'snow', m);
snMat.uniforms.uOpacity.value = mixN('snowI', m);
snMat.uniforms.uTwinkle.value = mixN('twinkle', m);
mixC(bMat.uniforms.uColor.value, 'bubble', m);
const su = surfMat.uniforms;
mixC(su.uSky.value, 'sky', m); mixC(su.uRefl.value, 'refl', m); mixC(su.uFog.value, 'surfFog', m); mixC(su.uSun.value, 'sun', m);
mixC(blood.mat.uniforms.uColor.value, 'blood', m);
blood.mat.uniforms.uOpacity.value = mixN('bloodI', m);
mixC(foam.mat.uniforms.uColor.value, 'foam', m);
sparks.mat.uniforms.uOpacity.value = mixN('spark', m);
}
const darkQuery = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null;
if (darkQuery) {
if (darkQuery.addEventListener) darkQuery.addEventListener('change', readTheme);
else if (darkQuery.addListener) darkQuery.addListener(readTheme);
}
new MutationObserver(readTheme).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class', 'style'] });
// ─── Layout: keep the hunting ground framed on any screen ─────────────
let patrolAmpX = 7, sealZoneX = 5;
function layout() {
const w = Math.max(1, stage.clientWidth), h = Math.max(1, stage.clientHeight);
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
const a = camera.aspect, camZ = Math.max(11, 4.6 / (tanHalf * a) - 3);
camBase.set(0, -4.2, camZ);
const halfAt = (z) => (camZ - z) * tanHalf * a;
viewHalfW = halfAt(-2);
patrolAmpX = clamp(halfAt(-4) - 2.6, 1.2, 7.5);
sealZoneX = clamp(halfAt(-2) - 1.6, 0.8, 7);
scene.fog.near = camZ * 0.35;
scene.fog.far = camZ + 34;
sandMat.uniforms.uFade.value.set(camZ + 16, camZ + 42);
sandMat.uniforms.uDetail.value.set(camZ + 8, camZ + 26);
surfMat.uniforms.uFade.value.set(camZ + 14, camZ + 55);
surfMat.uniforms.uSunPos.value.set(-2.5, camZ - 6);
const sc = renderer.domElement.height / (2 * tanHalf);
bMat.uniforms.uScale.value = sc;
snMat.uniforms.uScale.value = sc;
for (const p of pools) p.mat.uniforms.uScale.value = sc;
}
if (window.ResizeObserver) new ResizeObserver(layout).observe(stage);
else window.addEventListener('resize', layout);
layout();
// ─── The hunt ─────────────────────────────────────────────────────────
// How the shark moves: its heading turns with its own angular velocity, so
// turns ease in and out instead of snapping; speed changes through smoothed
// acceleration; and its velocity trails the heading a little, giving a hint
// of sideslip in hard turns. Tail-beat rate and size follow the thrust it
// needs (fast and wide when accelerating, nearly still when gliding), the
// body bows along the arc it is turning on, it banks into turns, and the
// pectoral fins tilt to pitch it up or down and drop when it gets agitated.
const S = {
pos: new V3(-5, -5, -3), head: new V3(1, 0, 0.3).normalize(), up: new V3(0, 1, 0),
vel: new V3(), angVel: new V3(), speed: 1.6, acc: 0,
roll: 0, extraYaw: 0, spin: 0, shake: 0, mode: 'patrol', t: 0, wait: 0,
phase: 0, freq: 0.66, A: 0.5, curv: 0, glide: 0, glideIn: rr(7, 12),
jaw: 0, jawT: 0, jawRate: 6, eye: 0, eyeT: 0, armed: false, pecAoA: 0, pecLift: 0.05, yawW: 0, pitchW: 0, wpT: 0,
seal: null, held: null, side: 1, target: new V3(), wp: new V3(), wpSide: -1,
above: false, snoutAbove: false, bleedAcc: 0, sparkAcc: 0,
};
S.up.addScaledVector(S.head, -S.head.y).normalize();
S.vel.copy(S.head).multiplyScalar(S.speed);
const biteW = new V3(), snoutW = new V3(), tailW = new V3();
const dDes = new V3(), tq = new V3(), tR = new V3(), tF = new V3(), tV = new V3(), probe = new V3(), avoidV = new V3(), wDes = new V3(), errAx = new V3(), camFwd = new V3();
const WORLD_UP = new V3(0, 1, 0);
const qT = new THREE.Quaternion(), qB = new THREE.Quaternion(), qL = new THREE.Quaternion(), mB = new THREE.Matrix4(), eL = new THREE.Euler(0, 0, 0, 'YZX');
let slow = 0, shakeAmt = 0, flashAmt = 0;
// ─── Steering helpers ─────────────────────────────────────────────────
function rotateBy(w, dt) {
const m = w.length();
if (m < 1e-7) return;
qT.setFromAxisAngle(tq.copy(w).divideScalar(m), m * dt);
S.head.applyQuaternion(qT).normalize();
S.up.applyQuaternion(qT);
}
// Fish turn by yawing flat and pitching only a little, so steering is split
// into a yaw rate (about world up) and a capped pitch rate. Each rate eases
// toward what the heading error asks for: a damped spring on the heading.
function steer(dir, maxRate, gain, resp, dt, pitchMax) {
let yw = 0, pw = 0;
const L = dir.length();
if (L > 1e-6) {
const hz = Math.hypot(S.head.x, S.head.z), dzl = Math.hypot(dir.x, dir.z);
if (dzl > 1e-4 && hz > 1e-4) {
yw = clamp(wrapAngle(Math.atan2(dir.x, dir.z) - Math.atan2(S.head.x, S.head.z)) * gain, -maxRate, maxRate) * smoothstep(0.08, 0.35, hz);
}
const pT = clamp(Math.asin(clamp(dir.y / L, -1, 1)), -pitchMax, pitchMax);
pw = clamp((pT - Math.asin(clamp(S.head.y, -1, 1))) * gain, -maxRate * 0.8, maxRate * 0.8);
}
const k = 1 - Math.exp(-resp * dt);
S.yawW += (yw - S.yawW) * k;
S.pitchW += (pw - S.pitchW) * k;
tR.crossVectors(WORLD_UP, S.head);
if (tR.lengthSq() < 1e-8) tR.crossVectors(S.up, S.head);
tR.normalize();
S.angVel.copy(WORLD_UP).multiplyScalar(S.yawW).addScaledVector(tR, -S.pitchW);
rotateBy(S.angVel, dt);
}
// While airborne the nose simply follows the flight path.
function faceTowards(dir, k, dt) {
tF.copy(S.head).lerp(dir, 1 - Math.exp(-k * dt));
if (tF.lengthSq() < 1e-8) return;
tF.normalize();
qT.setFromUnitVectors(S.head, tF);
S.head.copy(tF);
S.up.applyQuaternion(qT);
S.angVel.multiplyScalar(Math.exp(-4 * dt));
S.yawW *= Math.exp(-4 * dt); S.pitchW *= Math.exp(-4 * dt);
}
// Keep the dorsal side up without ever flipping, even when pointing straight up.
function levelUp(dt, k) {
S.up.addScaledVector(S.head, -S.up.dot(S.head));
if (S.up.lengthSq() < 1e-6) S.up.set(0, 1, 0).addScaledVector(S.head, -S.head.y);
if (S.up.lengthSq() < 1e-6) S.up.set(1, 0, 0);
S.up.normalize();
tR.copy(WORLD_UP).addScaledVector(S.head, -S.head.y);
const l = tR.length();
if (l > 0.08) {
S.up.lerp(tR.divideScalar(l), (1 - Math.exp(-k * dt)) * smoothstep(0.08, 0.4, l));
S.up.addScaledVector(S.head, -S.up.dot(S.head)).normalize();
}
}
// Gentle pushes away from rocks, the seabed, the surface and the camera,
// applied to a point a little ahead so it turns early instead of sliding along walls.
function avoidance(out, allowSurface, allowNear) {
out.set(0, 0, 0);
probe.copy(S.pos).addScaledVector(S.head, 2.6);
for (const r of rockList) {
for (let k = 0; k < 2; k++) {
const pt = k ? probe : S.pos, d = pt.distanceTo(r.p), lim = r.r + 1.4;
if (d < lim) out.addScaledVector(tq.subVectors(pt, r.p).normalize(), ((lim - d) / lim) * 2.2);
}
}
const vis = Math.max(3, (camBase.z - probe.z) * tanHalf * camera.aspect); // half-width of the view at that depth
const xl = allowNear ? 12.5 : Math.min(12.5, vis - 1.2);
if (Math.abs(probe.x) > xl) out.x -= Math.sign(probe.x) * (Math.abs(probe.x) - xl) * 0.6;
const floor = sandAt(probe.x, probe.z) + 1.9;
if (probe.y < floor) out.y += (floor - probe.y) * 1.1;
if (!allowSurface && probe.y > -1.4) out.y -= (probe.y + 1.4) * 1.1;
if (probe.z < -19) out.z += (-19 - probe.z) * 0.5;
const nearZ = camBase.z - 6.5;
if (!allowNear && probe.z > nearZ) out.z -= (probe.z - nearZ) * 0.6;
return out;
}
function nextWaypoint() {
S.wpSide = -S.wpSide;
S.wp.set(S.wpSide * rr(0.3, 0.75) * patrolAmpX, rr(-6.2, -3.4), rr(-8, 0.5));
S.wpT = 0;
}
nextWaypoint();
// ─── Events ───────────────────────────────────────────────────────────
const say = (msg) => { liveEl.textContent = msg; };
function bleed(p, n, power) {
for (let i = 0; i < n; i++) {
blood.spawn(p.x + rr(-0.25, 0.25) * power, p.y + rr(-0.2, 0.2) * power, p.z + rr(-0.25, 0.25) * power,
rr(-1, 1) * power, rr(-0.5, 0.8) * power, rr(-1, 1) * power,
rr(7, 13), rr(0.25, 0.5) * (0.6 + 0.4 * power), rr(1.6, 3.2) * (0.6 + 0.4 * power), rr(0.3, 0.55), 0.9, 0.03);
}
}
function bubbleBurst(p, n, spread, up) {
for (let i = 0; i < n; i++) {
spawnBubble(tq.set(p.x + rr(-spread, spread), p.y + rr(-spread, spread), p.z + rr(-spread, spread)),
rr(0.04, 0.12), rr(-0.8, 0.8), rr(0, up), rr(-0.8, 0.8));
}
}
const freeSeal = () => seals.find((s) => !s.held) || null;
function startStalk(s) { S.mode = 'stalk'; S.seal = s; S.t = 0; S.glide = 0; S.side = S.pos.x < s.pos.x ? -1 : 1; }
function toPatrol() {
S.mode = 'patrol'; S.t = 0; S.jawRate = 4; S.eyeT = 0; S.seal = null; S.wait = 1.5;
S.wpSide = S.pos.x > 0 ? 1 : -1;
nextWaypoint();
}
function charge() {
if (S.mode === 'breach' || S.mode === 'thrash') return;
S.mode = 'charge'; S.t = 0; S.glide = 0;
say('The shark is charging the camera.');
}
function lunge(p) {
if (S.mode === 'breach' || S.mode === 'thrash' || S.mode === 'strike') return;
S.mode = 'lunge'; S.t = 0; S.glide = 0;
S.target.set(clamp(p.x, -15, 15), clamp(p.y, SAND_Y + 1.5, -1.2), clamp(p.z, -18, camBase.z - 6));
}
function addSeal(x, z) {
if (seals.filter((s) => !s.held).length >= 3) { say('There are already three seals at the surface.'); return; }
makeSeal(clamp(x, -sealZoneX, sealZoneX), clamp(z, -5.5, 2));
say('A seal is resting at the surface.');
if (S.mode === 'patrol') S.wait = Math.max(S.wait, 1.2);
}
function grab(s) {
S.held = s; s.held = true; s.panic = 0;
scene.remove(s.g); sharkRoot.add(s.g);
S.mode = 'breach'; S.t = 0; S.above = false; S.snoutAbove = false;
S.vel.copy(S.head).multiplyScalar(Math.max(S.speed, 8.5));
S.speed = S.vel.length();
S.jawT = 0.5; S.jawRate = 22;
slow = 1.5; shakeAmt = Math.max(shakeAmt, 0.18); flashAmt = 0.7;
bleed(biteW, 70, 1.3);
say('The shark hit the seal from below.');
}
function consume() {
const s = S.held;
if (!s) return;
if (s.g.parent) s.g.parent.remove(s.g);
const i = seals.indexOf(s);
if (i >= 0) seals.splice(i, 1);
S.held = null;
bleed(biteW, 140, 2.0);
bubbleBurst(biteW, 30, 0.5, 1.5);
flashAmt = Math.max(flashAmt, 0.4);
say('The shark took the seal.');
}
// ─── Per-frame update ─────────────────────────────────────────────────
function updateShark(dt, t) {
S.t += dt;
let want = 1.6, maxRate = 0.42, gain = 0.9, resp = 2.6, accel = 0.8, decel = 0.9, velK = 4, pitchMax = 0.45;
let allowSurface = false, allowNear = false, agitated = false;
dDes.set(0, 0, 0);
switch (S.mode) {
case 'patrol': {
S.wpT += dt;
tq.subVectors(S.wp, S.pos);
if (tq.length() < 3.2 || (tq.dot(S.head) < 0 && Math.hypot(tq.x, tq.z) < 6) || S.wpT > 16) nextWaypoint();
dDes.subVectors(S.wp, S.pos);
const h = Math.hypot(dDes.x, dDes.z);
dDes.y = clamp(dDes.y, -0.3 * h, 0.3 * h);
// swim, then coast: great whites glide on a slight descent between bouts of tail beats
S.glideIn -= dt;
if (S.glideIn <= 0 && S.glide <= 0) { S.glide = rr(2.5, 4.5); S.glideIn = rr(9, 16); }
if (S.glide > 0) { S.glide -= dt; want = 1.15; dDes.y -= 0.12 * h; }
S.jawT = 0.1 + 0.06 * Math.sin(S.t * 0.5);
S.wait -= dt;
const s = freeSeal();
if (s && S.wait <= 0) startStalk(s);
break;
}
case 'stalk': {
const s = S.seal;
if (!s || s.held || seals.indexOf(s) < 0) { toPatrol(); break; }
S.target.set(clamp(s.pos.x + S.side * 3.4, -12, 12), SAND_Y + 3.4, s.pos.z - 3.2);
dDes.subVectors(S.target, S.pos);
want = 2.4; maxRate = 0.6; gain = 1.1; resp = 3.2; accel = 1.2; decel = 1; pitchMax = 0.7;
S.jawT = 0.05;
if (S.pos.distanceTo(S.target) < 2.4 || S.t > 7) { S.mode = 'strike'; S.t = 0; }
break;
}
case 'strike': {
const s = S.seal;
if (!s || s.held || seals.indexOf(s) < 0) { toPatrol(); break; }
S.target.copy(s.pos); S.target.y -= 0.1;
dDes.subVectors(S.target, snoutW);
want = 9.5; maxRate = 1.5; gain = 3.2; resp = 7; accel = 7.5; decel = 4; velK = 8; pitchMax = 1.45;
allowSurface = true; agitated = true;
const d = snoutW.distanceTo(S.target);
S.jawT = d < 3.6 ? 1 : 0.1; S.jawRate = 8; S.eyeT = d < 4.5 ? 1 : 0;
if (d < 7) s.panic = 1;
if (biteW.distanceTo(s.pos) < 1.1 || d < 0.7) { grab(s); break; }
if (S.t > 5) toPatrol();
break;
}
case 'breach': agitated = true; break;
case 'thrash': {
dDes.set(S.head.x, -0.08, S.head.z);
want = 0.8; maxRate = 0.5; gain = 1; resp = 3; accel = 1.5; decel = 5; velK = 3; pitchMax = 0.6;
agitated = true;
if (S.t > 1.8 && S.held) consume();
if (S.t > 2.8) toPatrol();
break;
}
case 'charge': {
// aim at a point on the camera's line of sight that slides in as it closes,
// so it swings onto the lens axis first and the last rush is head-on
camera.getWorldDirection(camFwd);
const d = snoutW.distanceTo(camera.position);
S.target.copy(camera.position).addScaledVector(camFwd, clamp(d * 0.55, 1.6, 9));
S.target.y -= 0.35;
dDes.subVectors(S.target, snoutW);
const align = S.head.dot(tq.copy(dDes).normalize());
want = lerp(2.6, 7.5, smoothstep(0.55, 0.92, align)); maxRate = 1.4; gain = 2.4; resp = 5.5; accel = 5.5; decel = 3; velK = 6; pitchMax = 0.9;
allowNear = true; agitated = true;
S.jawT = d < 6.5 && align > 0.8 ? 1 : 0; S.jawRate = 7; S.eyeT = d < 7.5 ? 1 : 0;
if (d < 2.4 + 0.1 * S.speed) {
S.jawT = 0; S.jawRate = 30; shakeAmt = 0.5; flashAmt = 0.55;
bubbleBurst(snoutW, 26, 0.4, 1);
eatFishNear(biteW, 1.2);
S.side = S.pos.x >= camera.position.x ? 1 : -1;
S.mode = 'veer'; S.t = 0;
} else if (S.t > 7) toPatrol();
break;
}
case 'veer': {
S.target.set(camera.position.x + S.side * 8, camera.position.y - 2.5, camera.position.z - 7);
dDes.subVectors(S.target, S.pos);
want = 4.5; maxRate = 1.9; gain = 3; resp = 7; accel = 3; decel = 7; velK = 6; pitchMax = 0.8;
allowNear = true; agitated = S.t < 0.6;
if (S.t > 0.35) S.eyeT = 0;
if (S.t > 2) toPatrol();
break;
}
case 'lunge': {
dDes.subVectors(S.target, snoutW);
want = 7; maxRate = 1.6; gain = 3; resp = 6.5; accel = 6.5; decel = 4; velK = 7; pitchMax = 1.0;
agitated = true;
const d = snoutW.distanceTo(S.target);
S.jawT = d < 2.6 ? 1 : 0; S.jawRate = 9; S.eyeT = d < 3 ? 1 : 0;
if (d < 0.8 || S.t > 2.6) { S.jawT = 0; S.jawRate = 26; S.mode = 'recover'; S.t = 0; }
break;
}
case 'recover': {
dDes.set(S.head.x, 0, S.head.z);
want = 2.4; maxRate = 0.6; gain = 1; resp = 3; accel = 1; decel = 2.5; pitchMax = 0.5;
if (S.t > 1.2) toPatrol();
break;
}
default: break;
}
if (S.mode === 'breach') {
// ballistic once enough of the body is out of the water
if (S.pos.y > -1.4) S.vel.y -= 9.8 * dt;
else S.vel.multiplyScalar(Math.exp((S.vel.y < 0 ? -2.4 : -0.5) * dt));
S.pos.addScaledVector(S.vel, dt);
S.speed = S.vel.length(); S.acc = 0;
if (S.speed > 0.1) faceTowards(tV.copy(S.vel).divideScalar(S.speed), 6, dt);
levelUp(dt, 1.5);
if (S.pos.y > -0.5) S.spin = Math.min(S.spin + dt * 2.4, 1.6);
if (snoutW.y > 0 && !S.snoutAbove) { S.snoutAbove = true; surfaceSplash(snoutW.x, snoutW.z, 1.1); }
const above = S.pos.y > 0;
if (S.above && !above && S.vel.y < 0) { surfaceSplash(S.pos.x, S.pos.z, 1.7); shakeAmt = Math.max(shakeAmt, 0.14); }
S.above = above;
if ((S.vel.y < 0 && S.pos.y < -2) || (S.t > 1.2 && S.pos.y < -1.4 && !S.snoutAbove) || S.t > 6) { S.mode = 'thrash'; S.t = 0; }
} else {
dDes.normalize().add(avoidance(avoidV, allowSurface, allowNear));
steer(dDes, maxRate, gain, resp, dt, pitchMax);
levelUp(dt, 1.2);
const aT = clamp((want - S.speed) * 1.8, -decel, accel);
S.acc = damp(S.acc, aT, 5, dt);
S.speed = Math.max(0.2, S.speed + S.acc * dt);
const surge = 1 + 0.03 * S.A * Math.sin(2 * S.phase); // each tail stroke gives a small push
S.vel.lerp(tV.copy(S.head).multiplyScalar(S.speed * surge), 1 - Math.exp(-velK * dt));
const zLim = camBase.z - 3.3; // soft stop in front of the lens instead of a hard wall
if (S.pos.z > zLim) S.vel.z -= ((S.pos.z - zLim) * 25 + Math.max(S.vel.z, 0) * 5) * dt;
S.pos.addScaledVector(S.vel, dt);
// hard limits only as a last resort; the steering above keeps it well inside
S.pos.x = clamp(S.pos.x, -16, 16);
S.pos.y = clamp(S.pos.y, sandAt(S.pos.x, S.pos.z) + 1.1, -0.2);
S.pos.z = clamp(S.pos.z, -22, camBase.z - 2.4);
S.spin = damp(S.spin, 0, 2.5, dt);
}
// tail beat follows the thrust it needs
let fT, aT;
if (S.mode === 'thrash') { fT = 2.0; aT = 1.0; }
else if (S.mode === 'breach') { fT = S.pos.y > 0 ? 1.6 : 2.6; aT = 1.05; }
else {
const push = Math.max(0, S.acc);
fT = Math.min(0.2 + 0.3 * S.speed + 0.22 * push, 3.2);
aT = clamp(0.4 + 0.05 * S.speed + 0.1 * push, 0.4, 1.05);
if (S.glide > 0 || S.acc < -0.3) { fT *= 0.55; aT *= 0.35; }
}
S.freq = damp(S.freq, fT, 2.2, dt);
S.A = damp(S.A, aT, 2, dt);
S.phase += TAU * S.freq * dt;
// body curve, banking and pectoral fins come from how it is turning
const right = tR.crossVectors(S.up, S.head).normalize();
const yawRate = S.angVel.dot(S.up), pitchUp = -S.angVel.dot(right);
S.curv = damp(S.curv, S.mode === 'breach' ? 0 : clamp(0.75 * yawRate / Math.max(S.speed, 1.2), -0.17, 0.17), 4, dt);
S.roll = damp(S.roll, clamp(-Math.atan(S.speed * yawRate / 7), -0.55, 0.55), 2.5, dt);
S.pecAoA = damp(S.pecAoA, clamp(pitchUp * 0.9 + S.head.y * 0.25, -0.35, 0.35), 3, dt);
S.pecLift = damp(S.pecLift, agitated ? -0.32 : S.glide > 0 ? 0.12 : 0.05, 3, dt);
// head-shaking with the seal clamped in its jaws, in time with the body wave
if (S.mode === 'thrash') {
S.shake = smoothstep(0, 0.25, S.t) * (1 - smoothstep(2.0, 2.7, S.t));
S.bleedAcc += dt * 120 * S.shake;
} else S.shake = damp(S.shake, 0, 5, dt);
S.extraYaw = 0.45 * S.shake * Math.sin(S.phase + 1.2);
const thrashRoll = 0.28 * S.shake * Math.sin(S.phase + 2.4);
while (S.bleedAcc >= 1) { S.bleedAcc -= 1; bleed(biteW, 1, 0.95); }
if (S.jaw > 0.3 && S.mode !== 'thrash' && S.mode !== 'breach') {
const n = eatFishNear(biteW, 0.7);
if (n) bleed(biteW, 12 * n, 0.5);
}
S.jaw += (S.jawT - S.jaw) * (1 - Math.exp(-S.jawRate * dt));
if (S.jaw > 0.6) S.armed = true;
if (S.armed && S.jaw < 0.25) { S.armed = false; bubbleBurst(biteW, 10, 0.25, 0.6); }
S.eye = damp(S.eye, S.eyeT, 10, dt);
// orientation from the heading/up frame, plus small local yaw and roll
S.up.crossVectors(S.head, right).normalize();
mB.makeBasis(right, S.up, S.head);
qB.setFromRotationMatrix(mB);
const recoil = -0.035 * S.A * Math.sin(S.phase + 0.6); // head yaws slightly against each tail stroke
eL.set(0, S.extraYaw + recoil, S.roll + thrashRoll + S.spin * S.side, 'YZX');
qL.setFromEuler(eL);
sharkRoot.quaternion.copy(qB).multiply(qL);
sharkRoot.position.copy(S.pos);
poseShark(S.jaw, S.A, S.phase, S.curv, S.eye, S.pecAoA, S.pecLift);
sharkRoot.updateMatrixWorld(true);
sharkRoot.localToWorld(biteW.copy(bitePt));
sharkRoot.localToWorld(snoutW.copy(snoutPt));
sharkRoot.localToWorld(tailW.copy(tailPt));
if (S.held) {
const g = S.held.g;
g.position.copy(bitePt);
g.rotation.set(0, Math.PI / 2 + Math.sin(t * 13) * 0.15, Math.sin(t * 9) * 0.2);
}
// bioluminescent wake at night
if (theme.m > 0.05 && S.speed > 1.8) {
S.sparkAcc += dt * S.speed * 9;
while (S.sparkAcc >= 1) {
S.sparkAcc -= 1;
sparks.spawn(tailW.x + rr(-0.3, 0.3), tailW.y + rr(-0.3, 0.3), tailW.z + rr(-0.3, 0.3), rr(-0.3, 0.3), rr(-0.2, 0.2), rr(-0.3, 0.3),
rr(1.2, 2.4), rr(0.05, 0.1), rr(0.01, 0.03), 1, 1.5, 0);
}
}
sandMat.uniforms.uShadow.value.set(S.pos.x, S.pos.z, mixN('shadow', theme.m) * clamp(1 - (S.pos.y - SAND_Y) / 14, 0, 1), 2.4);
}
// ─── Input ────────────────────────────────────────────────────────────
const raycaster = new THREE.Raycaster(), ndc = new THREE.Vector2(), tRay = new V3();
const pointer = { x: 0, y: 0, inside: false, sx: 0, sy: 0 };
function castFrom(e) {
const r = canvas.getBoundingClientRect();
ndc.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1);
raycaster.setFromCamera(ndc, camera);
return raycaster.ray;
}
const overShark = (ray) => ray.distanceSqToSegment(snoutW, tailW) < 0.85 * 0.85;
const coarse = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
hintEl.textContent = coarse
? 'Tap the surface to put a seal there, or tap the shark to make it charge.'
: 'Click the surface to put a seal there, or click the shark to make it charge.';
let hintGone = false;
const dismissHint = () => { if (!hintGone) { hintGone = true; hintEl.classList.add('gone'); } };
let down = null;
canvas.addEventListener('pointermove', (e) => {
const r = canvas.getBoundingClientRect();
pointer.x = ((e.clientX - r.left) / r.width) * 2 - 1;
pointer.y = -((e.clientY - r.top) / r.height) * 2 + 1;
pointer.inside = true;
if (e.pointerType === 'mouse') canvas.style.cursor = overShark(castFrom(e)) ? 'pointer' : 'default';
});
canvas.addEventListener('pointerleave', () => { pointer.inside = false; });
canvas.addEventListener('pointerdown', (e) => { down = { x: e.clientX, y: e.clientY, t: performance.now() }; });
canvas.addEventListener('pointercancel', () => { down = null; });
canvas.addEventListener('pointerup', (e) => {
if (!down) return;
const moved = Math.hypot(e.clientX - down.x, e.clientY - down.y), held = performance.now() - down.t;
down = null;
if (moved > 12 || held > 700) return;
dismissHint();
const ray = castFrom(e);
if (overShark(ray)) { charge(); return; }
if (ray.direction.y > 0.02) {
const k = -ray.origin.y / ray.direction.y;
if (k < 80) { addSeal(ray.origin.x + ray.direction.x * k, ray.origin.z + ray.direction.z * k); return; }
}
tRay.copy(ray.direction).multiplyScalar(Math.max(4, camera.position.distanceTo(S.pos))).add(ray.origin);
lunge(tRay);
});
const randomSeal = () => addSeal(rr(-sealZoneX, sealZoneX), rr(-4.5, 1.5));
sealBtn.addEventListener('click', () => { dismissHint(); randomSeal(); });
chargeBtn.addEventListener('click', () => { dismissHint(); charge(); });
window.addEventListener('keydown', (e) => {
const tag = e.target && e.target.tagName;
if (tag === 'BUTTON' || tag === 'INPUT' || tag === 'TEXTAREA' || e.metaKey || e.ctrlKey || e.altKey) return;
const k = e.key.toLowerCase();
if (k === 's') { dismissHint(); randomSeal(); }
else if (k === 'c') { dismissHint(); charge(); }
});
// ─── Frame update ─────────────────────────────────────────────────────
let time = 0, simT = 0;
function update(dt, real) {
time += real;
simT += dt;
theme.m = damp(theme.m, theme.target, 4, real);
applyTheme(simT);
const par = REDUCED ? 0 : 1;
pointer.sx = damp(pointer.sx, pointer.inside ? pointer.x : 0, 1.6, real);
pointer.sy = damp(pointer.sy, pointer.inside ? pointer.y : 0, 1.6, real);
shakeAmt *= Math.exp(-4.5 * real);
flashAmt *= Math.exp(-3.2 * real);
const sh = REDUCED ? 0 : shakeAmt;
camera.position.set(
camBase.x + pointer.sx * 0.8 * par + Math.sin(time * 61) * sh * 0.4,
camBase.y + pointer.sy * 0.4 * par + Math.sin(time * 47 + 1) * sh * 0.3,
camBase.z);
camera.lookAt(camLook.x + pointer.sx * 0.4 * par + Math.sin(time * 53) * sh * 0.5, camLook.y + Math.cos(time * 41) * sh * 0.4, camLook.z);
camera.updateMatrixWorld();
flashEl.style.opacity = flashAmt.toFixed(3);
updateShark(dt, simT);
updateSeals(dt, simT);
updateSchool(dt, simT);
updateKelp(simT);
updateSnow(dt, simT);
updateVents(dt);
updateBubbles(dt);
for (const p of pools) p.update(dt);
for (const r of ripples) if (r.w > 0) { r.z += dt; if (r.z > 8) r.w = 0; }
surfMat.uniforms.uTime.value = simT * MOTION;
sandMat.uniforms.uTime.value = simT * MOTION;
snMat.uniforms.uTime.value = simT;
}
rockMat.color.setRGB(0.62, 0.66, 0.68);
readTheme();
applyTheme(0);
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
const real = Math.min(clock.getDelta(), 1 / 20);
if (slow > 0) slow -= real;
const ts = lerp(1, 0.28, smoothstep(0, 0.45, slow));
update(real * ts, real);
renderer.render(scene, camera);
});
})();
</script>
</body>
</html>