Custom Code Experiment •
Pip
Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Pip the Pufferfish</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=Fredoka:wght@400;500;600&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: #aef0ef;
--water-mid: #53bdd3;
--water-deep: #176fa0;
--fog: #55b8cf;
--sunlight: rgba(255, 252, 226, 0.9);
--vignette: rgba(6, 52, 82, 0.26);
--ink: #073a4f;
--ink-soft: rgba(7, 58, 79, 0.74);
--glass: rgba(255, 255, 255, 0.46);
--glass-strong: rgba(255, 255, 255, 0.74);
--glass-edge: rgba(255, 255, 255, 0.82);
--accent: #ef8a25;
--accent-ink: #ffffff;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--mode: 1;
--water-top: #143f69;
--water-mid: #0b2748;
--water-deep: #030916;
--fog: #0c2848;
--sunlight: rgba(150, 200, 255, 0.3);
--vignette: rgba(0, 4, 12, 0.6);
--ink: #e3f6ff;
--ink-soft: rgba(227, 246, 255, 0.72);
--glass: rgba(8, 30, 56, 0.5);
--glass-strong: rgba(14, 44, 80, 0.82);
--glass-edge: rgba(130, 200, 255, 0.26);
--accent: #ffbe45;
--accent-ink: #1c1203;
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--mode: 1;
--water-top: #143f69;
--water-mid: #0b2748;
--water-deep: #030916;
--fog: #0c2848;
--sunlight: rgba(150, 200, 255, 0.3);
--vignette: rgba(0, 4, 12, 0.6);
--ink: #e3f6ff;
--ink-soft: rgba(227, 246, 255, 0.72);
--glass: rgba(8, 30, 56, 0.5);
--glass-strong: rgba(14, 44, 80, 0.82);
--glass-edge: rgba(130, 200, 255, 0.26);
--accent: #ffbe45;
--accent-ink: #1c1203;
}
html { height: 100%; scroll-padding-top: env(safe-area-inset-top, 0px); }
body {
height: 100%;
margin: 0;
overflow: hidden;
background: linear-gradient(180deg, var(--water-top), var(--water-deep));
color: var(--ink);
font-family: "Fredoka", ui-rounded, "SF Pro Rounded", "Hiragino Maru Gothic ProN", "Arial Rounded MT Bold", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
}
#stage {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background:
radial-gradient(110% 60% at 28% -12%, var(--sunlight), transparent 62%),
linear-gradient(180deg, var(--water-top) 0%, var(--water-mid) 44%, var(--water-deep) 100%);
}
#stage canvas {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
display: block;
touch-action: none;
outline: none;
}
.vignette {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none;
background: radial-gradient(130% 95% at 50% 38%, transparent 55%, var(--vignette) 100%);
}
.title {
position: absolute;
top: clamp(14px, 3vw, 28px);
left: clamp(16px, 3.4vw, 34px);
pointer-events: none;
user-select: none;
-webkit-user-select: none;
}
.title h1 {
margin: 0;
font-weight: 600;
font-size: clamp(30px, 5vw, 52px);
line-height: 1;
}
.title p {
margin: 6px 0 0;
font-size: clamp(14px, 1.6vw, 17px);
color: var(--ink-soft);
}
.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: 18px;
font-size: 15px;
line-height: 1.3;
max-width: 34em;
background: var(--glass);
border: 1px solid var(--glass-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: 500;
color: var(--ink);
background: var(--glass-strong);
border: 1px solid var(--glass-edge);
padding: 10px 18px;
border-radius: 999px;
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; }
.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: 520px) {
.hint { font-size: 14px; }
.btn { padding: 9px 15px; 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>
<header class="title">
<h1>Pip</h1>
<p>A very round little fish</p>
</header>
<div class="bar">
<p class="hint" id="hint">Tap Pip to puff up, or tap the water to drop food.</p>
<div class="actions">
<button class="btn" id="feedBtn" type="button">Drop food</button>
<button class="btn primary" id="puffBtn" type="button">Puff up</button>
</div>
</div>
</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 puffBtn = document.getElementById('puffBtn');
const feedBtn = document.getElementById('feedBtn');
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 MOTION = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 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', 'Pip, a cartoon pufferfish, swimming above a sandy seabed');
stage.insertBefore(canvas, stage.firstChild);
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0x55b8cf, 9, 34);
const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 120);
const tanHalf = Math.tan(THREE.MathUtils.degToRad(camera.fov / 2));
const camLook = new V3(0, -0.15, 0);
let camDist = 7.3, viewHalfW = 4, viewTop = 2.5;
const hemi = new THREE.HemisphereLight(0xc8f5ff, 0xe6cc98, 0.72);
const key = new THREE.DirectionalLight(0xfff0cf, 0.85);
key.position.set(-3, 8, 6); // sun from the upper left, matching the light rays
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 = -2.3;
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,
});
function paintSkin() {
const W = 1024, H = 512;
const cMap = makeCanvas(W, H), cGlow = makeCanvas(W, H);
const g = cMap.getContext('2d'), gg = cGlow.getContext('2d');
// golden back fading to a creamy belly
const grad = g.createLinearGradient(0, 0, 0, H);
[[0, '#c8892a'], [0.2, '#e0a53c'], [0.4, '#f4cb5a'], [0.52, '#f8dd90'], [0.62, '#fff1d4'], [1, '#fffaf0']]
.forEach(([o, c]) => grad.addColorStop(o, c));
g.fillStyle = grad;
g.fillRect(0, 0, W, H);
gg.fillStyle = '#000';
gg.fillRect(0, 0, W, H);
// fine freckles on the back
for (let i = 0; i < 2400; i++) {
const u = rand(), v = Math.pow(rand(), 1.25) * 0.6;
const s = Math.max(Math.sin(v * Math.PI), 0.12), r = rr(0.8, 2.2);
g.fillStyle = `rgba(150,95,32,${rr(0.05, 0.14).toFixed(3)})`;
g.beginPath(); g.ellipse(u * W, v * H, r / s, r, 0, 0, TAU); g.fill();
}
// round spots (kept off the face); also painted into the glow map for night mode
const spots = [];
for (let tries = 0; spots.length < 80 && tries < 6000; tries++) {
const u = rand(), v = rr(0.05, 0.5);
const du = Math.min(Math.abs(u - 0.25), 1 - Math.abs(u - 0.25));
if (du < 0.14 && v > 0.27) continue;
const a = rr(0.032, 0.07), d = uvToDir(u, v);
if (spots.some((s) => d.angleTo(s.d) < a + s.a + 0.05)) continue;
spots.push({ u, v, a, d });
}
for (const s of spots) {
const ry = (s.a * H) / Math.PI;
const rx = ry / Math.max(Math.sin(s.v * Math.PI), 0.12); // undo equirect squash
for (const off of [-W, 0, W]) {
const x = s.u * W + off, y = s.v * H;
if (x + rx < 0 || x - rx > W) continue;
g.fillStyle = 'rgba(110,62,22,0.88)';
g.beginPath(); g.ellipse(x, y, rx, ry, 0, 0, TAU); g.fill();
gg.fillStyle = '#ffffff';
gg.beginPath(); gg.ellipse(x, y, rx * 0.85, ry * 0.85, 0, 0, TAU); gg.fill();
}
}
// rosy cheeks
for (const sx of [-1, 1]) {
const { u, v } = dirToUV(new V3(0.6 * sx, -0.12, 0.79).normalize());
const ry = (0.2 * H) / Math.PI;
g.save();
g.translate(u * W, v * H);
g.scale(1 / Math.sin(v * Math.PI), 1);
const rg = g.createRadialGradient(0, 0, 0, 0, 0, ry);
rg.addColorStop(0, 'rgba(255,118,140,0.62)');
rg.addColorStop(0.6, 'rgba(255,128,150,0.26)');
rg.addColorStop(1, 'rgba(255,140,160,0)');
g.fillStyle = rg;
g.beginPath(); g.arc(0, 0, ry, 0, TAU); g.fill();
g.restore();
}
const map = new THREE.CanvasTexture(cMap);
const glow = new THREE.CanvasTexture(cGlow);
map.anisotropy = glow.anisotropy = maxAniso;
return { map, glow };
}
// Fan-shaped fin membrane with ribs and a scalloped edge (alpha in the texture).
function paintFin() {
const N = 256;
const c = makeCanvas(N, N), g = c.getContext('2d');
const img = g.createImageData(N, N), d = img.data;
for (let y = 0; y < N; y++) {
for (let x = 0; x < N; x++) {
const px = ((x + 0.5) / N) * 2 - 1, py = 1 - ((y + 0.5) / N) * 2;
const r = Math.hypot(px, py), ang = Math.atan2(py, px);
const ribC = Math.abs(Math.cos(ang * 9));
const rib = Math.pow(ribC, 10);
const edge = 0.84 + 0.12 * Math.pow(ribC, 3);
let a = 1 - smoothstep(edge - 0.05, edge, r);
a *= 0.5 + 0.35 * (1 - r) + 0.35 * rib;
const shade = 1 - 0.32 * rib * smoothstep(0.1, 0.5, r);
const i = (y * N + x) * 4;
d[i] = d[i + 1] = d[i + 2] = Math.round(255 * shade);
d[i + 3] = Math.round(255 * clamp(a, 0, 1));
}
}
g.putImageData(img, 0, 0);
const t = new THREE.CanvasTexture(c);
t.anisotropy = maxAniso;
return t;
}
// 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 });
[
[-4.6, -2.8, 1.1, 1.4, 0.75, 1.1], [-3.3, -3.7, 0.55, 1.2, 0.8, 1], [4.4, -3.0, 0.9, 1.3, 0.8, 1],
[5.5, -2.1, 0.45, 1.2, 0.75, 1.1], [-1.4, -7.6, 1.4, 1.5, 0.7, 1.2], [2.8, -8.6, 1.0, 1.3, 0.8, 1],
[-7.0, -6.2, 1.2, 1.3, 0.8, 1.1], [7.6, -6.6, 1.3, 1.2, 0.85, 1],
[-2.6, 0.8, 0.22, 1.3, 0.7, 1], [2.3, 0.4, 0.28, 1.2, 0.7, 1.1], [3.1, 1.2, 0.16, 1.2, 0.8, 1],
[-1.4, 3.4, 0.3, 1.3, 0.7, 1], [1.2, 4.6, 0.36, 1.2, 0.7, 1.2], [-3.2, 5.3, 0.42, 1.3, 0.75, 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);
});
// ─── 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'];
[
[-4.1, -2.1, 3.4, 0.32, GREEN], [-4.6, -2.0, 2.6, 0.26, LIME], [-3.7, -2.5, 4.2, 0.3, KELP],
[3.9, -2.4, 3.0, 0.3, LIME], [4.5, -2.2, 3.9, 0.34, GREEN], [-1.9, -6.8, 4.6, 0.36, KELP],
[-0.9, -6.9, 3.6, 0.3, GREEN], [3.4, -7.8, 4.2, 0.34, LIME], [-6.4, -5.4, 4.0, 0.34, GREEN],
[7.1, -5.8, 4.4, 0.36, KELP], [-3.0, 1.6, 2.0, 0.22, LIME], [3.0, 2.6, 1.7, 0.2, GREEN],
].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(0.9, 2.4), 17), mat);
m.position.set(-8 + i * 2.6 + rr(-0.8, 0.8), 3.6, rr(-9, -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 = 360;
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(-16, 16); snPos[i * 3 + 1] = rr(SAND_Y, 8); snPos[i * 3 + 2] = rr(-16, 7);
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 += 10.3;
snPos[i * 3 + 1] = y;
}
snGeo.attributes.position.needsUpdate = true;
}
// ─── Bubbles (one Points cloud, drawn procedurally in the shader) ─────
const BMAX = 220;
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 = 7.5;
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 - 2, 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: -3.9, z: -2.0, t: 1.0, burst: 0 },
{ x: 4.1, z: -2.6, 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);
}
}
}
// ─── A distant school of tiny fish ────────────────────────────────────
const schoolMat = new THREE.MeshStandardMaterial({ color: '#3f86b3', roughness: 0.45, metalness: 0.15 });
const schoolTailMat = new THREE.MeshStandardMaterial({ color: '#3f86b3', roughness: 0.5, side: THREE.DoubleSide });
const smallBody = new THREE.SphereGeometry(1, 14, 10);
const smallTail = new THREE.BufferGeometry();
smallTail.setAttribute('position', new THREE.BufferAttribute(new Float32Array([0, 0, 0, 0, 0.13, -0.2, 0, -0.13, -0.2]), 3));
smallTail.computeVertexNormals();
const school = [];
for (let i = 0; i < 9; i++) {
const g = new THREE.Group();
const b = new THREE.Mesh(smallBody, schoolMat);
b.scale.set(0.085, 0.075, 0.26);
g.add(b);
const tail = new THREE.Group();
tail.position.z = -0.22;
tail.add(new THREE.Mesh(smallTail, schoolTailMat));
g.add(tail);
scene.add(g);
school.push({ g, tail, off: new V3(rr(-0.7, 0.7), rr(-0.45, 0.45), rr(-0.6, 0.6)), lag: i * 0.03 + rr(0, 0.02), ph: rand() * TAU });
}
const tSchool = new V3();
function updateSchool(t) {
for (const f of school) {
const a = t * 0.11 * MOTION - f.lag;
const x = 12.5 * Math.cos(a) + f.off.x;
const z = -12 + 3.5 * Math.sin(a) + f.off.z;
const y = 1.7 + 0.5 * Math.sin(a * 1.7) + f.off.y + Math.sin(t * 2 + f.ph) * 0.05;
f.g.position.set(x, y, z);
f.g.lookAt(tSchool.set(x - 12.5 * Math.sin(a), y + 0.085 * Math.cos(a * 1.7), z + 3.5 * Math.cos(a)));
f.tail.rotation.y = Math.sin(t * 11 + f.ph) * 0.45;
}
}
// ─── Pip ──────────────────────────────────────────────────────────────
// fish: world position + heading. bob: idle wobble. Features are pinned
// to the body surface and re-placed every frame so they ride the puff.
const fish = new THREE.Group();
fish.rotation.order = 'YXZ';
const bob = new THREE.Group();
fish.add(bob);
scene.add(fish);
const BASE_S = new V3(1, 0.93, 1.08);
const PUFF_S = new V3(1.5, 1.48, 1.5);
const S = BASE_S.clone();
const normalAt = (d, s) => new V3(d.x / s.x, d.y / s.y, d.z / s.z).normalize();
const features = [];
const pin = (obj, dir, inset = 1) => {
features.push({ obj, dir: dir.clone().normalize(), inset });
bob.add(obj);
};
const skin = paintSkin();
const bodyMat = new THREE.MeshStandardMaterial({
map: skin.map, emissiveMap: skin.glow, emissive: 0x46f0ff, emissiveIntensity: 0,
roughness: 0.42, metalness: 0,
});
const body = new THREE.Mesh(new THREE.SphereGeometry(1, 96, 64), bodyMat);
bob.add(body);
// Eyes: white ball, iris + pupil + shines on a pivot that aims, and a
// hemispherical lid that rotates down to blink.
const unitSphere = new THREE.SphereGeometry(1, 32, 20);
const scleraMat = new THREE.MeshStandardMaterial({ color: '#ffffff', roughness: 0.18 });
const irisMat = new THREE.MeshStandardMaterial({ color: '#3a2415', roughness: 0.3 });
const pupilMat = new THREE.MeshStandardMaterial({ color: '#0b0705', roughness: 0.15 });
const shineMat = new THREE.MeshBasicMaterial({ color: '#ffffff' });
const lidMat = new THREE.MeshStandardMaterial({ color: '#f0c250', roughness: 0.45, side: THREE.DoubleSide });
const ER = 0.27, LID_OPEN = -0.78, LID_CLOSED = 1.66;
const eyes = [];
for (const sx of [-1, 1]) {
const dir = new V3(0.5 * sx, 0.36, 0.8).normalize();
const g = new THREE.Group();
g.lookAt(normalAt(dir, BASE_S).add(new V3(0, 0.05, 0.55)).normalize());
const sclera = new THREE.Mesh(new THREE.SphereGeometry(ER, 40, 28), scleraMat);
sclera.position.z = -0.07;
g.add(sclera);
const pivot = new THREE.Group();
pivot.position.z = -0.07;
g.add(pivot);
const iris = new THREE.Mesh(unitSphere, irisMat);
iris.scale.set(0.15, 0.15, 0.07);
iris.position.z = 0.212;
const pupil = new THREE.Mesh(unitSphere, pupilMat);
pupil.scale.set(0.112, 0.112, 0.06);
pupil.position.z = 0.23;
const s1 = new THREE.Mesh(unitSphere, shineMat);
s1.scale.setScalar(0.042);
s1.position.set(-0.048, 0.052, 0.282);
const s2 = new THREE.Mesh(unitSphere, shineMat);
s2.scale.setScalar(0.02);
s2.position.set(0.05, -0.042, 0.276);
pivot.add(iris, pupil, s1, s2);
const lid = new THREE.Mesh(new THREE.SphereGeometry(ER * 1.05, 40, 16, 0, TAU, 0, Math.PI / 2), lidMat);
lid.position.z = -0.07;
lid.rotation.x = LID_OPEN;
g.add(lid);
pin(g, dir, 1);
eyes.push({ g, pivot, lid, dir });
}
// Mouth: a little smile, plus an "open" shape used when chomping food.
const mouthMat = new THREE.MeshStandardMaterial({ color: '#4a2412', roughness: 0.5 });
const gapeMat = new THREE.MeshStandardMaterial({ color: '#7a2b2b', roughness: 0.6 });
const mouthDir = new V3(0, -0.15, 1).normalize();
const mouth = new THREE.Group();
mouth.lookAt(normalAt(mouthDir, BASE_S));
const smile = new THREE.Group();
const arc = new THREE.Mesh(new THREE.TorusGeometry(0.11, 0.021, 10, 28, Math.PI), mouthMat);
arc.rotation.z = Math.PI;
smile.add(arc);
for (const sx of [-1, 1]) {
const cap = new THREE.Mesh(unitSphere, mouthMat);
cap.scale.setScalar(0.021);
cap.position.set(0.11 * sx, 0, 0);
smile.add(cap);
}
smile.position.set(0, 0.04, 0.006);
mouth.add(smile);
const gape = new THREE.Mesh(new THREE.CircleGeometry(0.1, 28), gapeMat);
gape.position.set(0, -0.02, 0.014);
gape.scale.set(0.001, 0.001, 1);
mouth.add(gape);
pin(mouth, mouthDir, 1);
// Fins: fan-shaped sectors. pivot = orientation on the body, flap = beat.
const finMat = new THREE.MeshStandardMaterial({
color: '#f6a43c', map: paintFin(), transparent: true, side: THREE.DoubleSide,
roughness: 0.55, depthWrite: false,
});
function makeFin(radius, spread, pointDir, hingeDir, surfDir, inset) {
const mesh = new THREE.Mesh(new THREE.CircleGeometry(radius, 24, -spread / 2, spread), finMat);
const flap = new THREE.Group();
flap.add(mesh);
const pivot = new THREE.Group();
pivot.add(flap);
const x = pointDir.clone().normalize();
const z = new V3().crossVectors(x, hingeDir).normalize();
const y = new V3().crossVectors(z, x).normalize();
pivot.quaternion.setFromRotationMatrix(new THREE.Matrix4().makeBasis(x, y, z));
pin(pivot, surfDir, inset);
return { pivot, flap, dir: surfDir.clone().normalize() };
}
const UP = new V3(0, 1, 0);
const fins = {
pecL: makeFin(0.4, 1.75, new V3(-1, 0.05, -0.8), UP, new V3(-0.97, -0.1, 0.12), 0.97),
pecR: makeFin(0.4, 1.75, new V3(1, 0.05, -0.8), UP, new V3(0.97, -0.1, 0.12), 0.97),
tail: makeFin(0.58, 1.9, new V3(0, 0.08, -1), UP, new V3(0, 0.06, -1), 0.93),
dorsal: makeFin(0.34, 1.35, new V3(0, 0.8, -0.65), new V3(0, 0.65, 0.8), new V3(0, 0.62, -0.78), 0.95),
anal: makeFin(0.3, 1.3, new V3(0, -0.75, -0.7), new V3(0, -0.7, 0.75), new V3(0, -0.55, -0.83), 0.95),
};
// Spikes: tiny nubs normally, full spines when puffed (one InstancedMesh).
const avoid = [
...eyes.map((e) => ({ d: e.dir, r: 0.46 })),
{ d: mouthDir, r: 0.3 },
{ d: fins.pecL.dir, r: 0.3 }, { d: fins.pecR.dir, r: 0.3 },
{ d: fins.tail.dir, r: 0.42 }, { d: fins.dorsal.dir, r: 0.3 }, { d: fins.anal.dir, r: 0.28 },
{ d: new V3(0.6, -0.12, 0.79).normalize(), r: 0.22 }, { d: new V3(-0.6, -0.12, 0.79).normalize(), r: 0.22 },
];
const spikeDirs = [];
const NSP = 300;
for (let i = 0; i < NSP; i++) {
const y = 1 - ((i + 0.5) / NSP) * 2, r = Math.sqrt(1 - y * y), phi = i * Math.PI * (3 - Math.sqrt(5));
const d = new V3(Math.cos(phi) * r, y, Math.sin(phi) * r);
if (!avoid.some((a) => d.angleTo(a.d) < a.r)) spikeDirs.push(d);
}
const spikeGeo = new THREE.ConeGeometry(0.045, 0.25, 7, 1);
spikeGeo.translate(0, 0.125, 0);
const spikeMesh = new THREE.InstancedMesh(spikeGeo, new THREE.MeshStandardMaterial({ color: '#ffffff', roughness: 0.45 }), spikeDirs.length);
spikeMesh.frustumCulled = false;
const spikeLow = new THREE.Color('#fff4dc'), spikeHigh = new THREE.Color('#d99b35'), spikeCol = new THREE.Color();
const spikes = spikeDirs.map((d, i) => {
spikeMesh.setColorAt(i, spikeCol.copy(spikeLow).lerp(spikeHigh, smoothstep(-0.25, 0.6, d.y)));
return { d, q: new THREE.Quaternion().setFromUnitVectors(UP, normalAt(d, BASE_S)), k: rr(0.85, 1.15) };
});
if (spikeMesh.instanceColor) spikeMesh.instanceColor.needsUpdate = true;
bob.add(spikeMesh);
// ─── Food flakes ──────────────────────────────────────────────────────
const foods = [];
const foodGeo = new THREE.DodecahedronGeometry(0.08, 0);
foodGeo.scale(1.1, 0.45, 0.9);
const foodMats = ['#ff7a3d', '#ffb23f', '#8fd14f', '#ff5e7a'].map(
(c) => new THREE.MeshStandardMaterial({ color: c, roughness: 0.6, flatShading: true }));
const tFood = new V3();
function dropFood(p) {
const x = clamp(p.x, -viewHalfW + 0.35, viewHalfW - 0.35);
const y = clamp(p.y, sandAt(x, p.z) + 0.4, viewTop - 0.35);
const n = 1 + (rand() < 0.55 ? 1 : 0) + (rand() < 0.3 ? 1 : 0);
for (let i = 0; i < n; i++) {
if (foods.length >= 10) scene.remove(foods.shift().mesh);
const m = new THREE.Mesh(foodGeo, foodMats[Math.floor(rand() * foodMats.length)]);
m.position.set(x + rr(-0.18, 0.18), y + rr(-0.1, 0.1), p.z + rr(-0.15, 0.15));
m.rotation.set(rand() * TAU, rand() * TAU, rand() * TAU);
m.scale.setScalar(0.001);
scene.add(m);
foods.push({ mesh: m, t: 0, ph: rand() * TAU, spin: rr(-2, 2), landed: false, landT: 0 });
}
for (let i = 0; i < 3; i++) spawnBubble(tFood.set(x + rr(-0.1, 0.1), y, p.z), rr(0.04, 0.07), 0, 0.3, 0);
}
// ─── Light / dark theme (reads tokens from CSS, blends smoothly) ──────
const PAL = {
light: {
sky: '#c8f5ff', ground: '#e6cc98', hemiI: 0.72, key: '#fff0cf', keyI: 0.85, rim: '#a9f2ff', rimI: 0.45,
sand: '#e9d3a0', sandDark: '#c9ab72', caustic: '#fffbea', causticI: 0.38, ray: '#ffffff', rayI: 1,
snow: '#ffffff', snowI: 0.34, twinkle: 0, glow: 0, bubble: '#ffffff', small: '#3f86b3',
},
dark: {
sky: '#5277b3', ground: '#0f1a30', hemiI: 0.85, key: '#a9c7ff', keyI: 0.62, rim: '#62f1ff', rimI: 0.9,
sand: '#2f3f62', sandDark: '#1b2644', caustic: '#8ec6ff', causticI: 0.26, ray: '#7bafff', rayI: 0.5,
snow: '#7ef9ff', snowI: 1, twinkle: 1, glow: 0.9, bubble: '#d4ecff', small: '#2a4f78',
},
};
const PC = {};
for (const k of Object.keys(PAL)) {
PC[k] = {};
for (const [n, v] of Object.entries(PAL[k])) PC[k][n] = typeof v === 'string' ? new THREE.Color(v) : v;
}
const mixC = (target, name, m) => target.copy(PC.light[name]).lerp(PC.dark[name], m);
const mixN = (name, m) => lerp(PC.light[name], PC.dark[name], m);
const theme = { m: 0, target: 0, fog: new THREE.Color('#55b8cf'), fogTarget: new THREE.Color('#55b8cf'), ready: false };
function readTheme() {
const cs = getComputedStyle(document.documentElement);
theme.target = (parseFloat(cs.getPropertyValue('--mode')) || 0) > 0.5 ? 1 : 0;
const f = cs.getPropertyValue('--fog').trim();
if (f) theme.fogTarget.set(f);
if (!theme.ready) { theme.m = theme.target; theme.fog.copy(theme.fogTarget); theme.ready = true; }
}
function applyTheme(t) {
const m = theme.m;
mixC(hemi.color, 'sky', m); mixC(hemi.groundColor, 'ground', 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);
scene.fog.color.copy(theme.fog);
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);
bodyMat.emissiveIntensity = mixN('glow', m) * (0.7 + 0.3 * Math.sin(t * 1.8));
mixC(schoolMat.color, 'small', m);
schoolTailMat.color.copy(schoolMat.color);
}
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 Pip framed on any screen shape ──────────────────────
const bounds = { x: 2.6, yMin: SAND_Y + 1.35, yMax: 2.0, zMin: -2.4, zMax: 1.1 };
function layout() {
const w = Math.max(1, stage.clientWidth), h = Math.max(1, stage.clientHeight);
renderer.setSize(w, h, false);
camera.aspect = w / h;
camDist = Math.max(2.5, 2.1 / camera.aspect) / tanHalf;
camera.updateProjectionMatrix();
const halfH = camDist * tanHalf;
viewHalfW = halfH * camera.aspect;
viewTop = camLook.y + halfH;
bounds.x = Math.max(0.3, viewHalfW - 1.55);
bounds.yMax = Math.max(0.6, Math.min(2.2, viewTop - 1.5));
scene.fog.near = camDist + 1.5;
scene.fog.far = camDist + 27;
sandMat.uniforms.uFade.value.set(camDist + 7, camDist + 32);
sandMat.uniforms.uDetail.value.set(camDist + 4, camDist + 19);
const sc = renderer.domElement.height / (2 * tanHalf);
bMat.uniforms.uScale.value = sc;
snMat.uniforms.uScale.value = sc;
}
if (window.ResizeObserver) new ResizeObserver(layout).observe(stage);
else window.addEventListener('resize', layout);
layout();
// ─── Pip's state ──────────────────────────────────────────────────────
const st = {
pos: new V3(-(bounds.x + 3.5), 0.5, -0.6), vel: new V3(0.5, 0, 0), target: new V3(0, 0.2, 0.2),
wanderT: 5, yaw: Math.PI / 2, pitch: 0, roll: 0, intro: true,
puff: 0, puffV: 0, puffTarget: 0, puffHold: 0, deflate: 0,
blinkIn: 2.2, blinkT: -1, happy: 0, chomp: 0, gape: 0,
gaze: new V3(0, 0, 10), sac: new V3(), sacT: 2, bubbleIn: 3, finPh: 0, tailPh: 0,
};
function pickWander() {
st.wanderT = rr(3.5, 7);
st.target.set(rr(-bounds.x, bounds.x) * 0.9, rr(bounds.yMin + 0.2, Math.min(bounds.yMax, 1.3)), rr(bounds.zMin * 0.7, bounds.zMax * 0.8));
}
function puff() {
if (st.puffTarget === 1) { st.puffV += 3.5; st.puffHold = Math.max(st.puffHold, 1.8); return; } // boing
st.puffTarget = 1;
st.puffHold = 2.8;
st.puffV += 1.5;
st.blinkT = -1;
}
// ─── Input ────────────────────────────────────────────────────────────
const pointer = { ndc: new THREE.Vector2(), inside: false, active: 0, sx: 0, sy: 0 };
const pointerWorld = new V3(0, 0, 5);
const raycaster = new THREE.Raycaster();
const gazePlane = new THREE.Plane(new V3(0, 0, 1), 0);
const foodPlane = new THREE.Plane(new V3(0, 0, 1), 0);
const hitSphere = new THREE.Sphere();
const tHit = new V3();
const coarse = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
hintEl.textContent = coarse
? 'Tap Pip to puff up, or tap the water to drop food.'
: 'Click Pip to puff up, or click the water to drop food.';
let hintGone = false;
const dismissHint = () => { if (!hintGone) { hintGone = true; hintEl.classList.add('gone'); } };
function setNDC(e) {
const r = canvas.getBoundingClientRect();
pointer.ndc.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1);
}
function overFish() {
raycaster.setFromCamera(pointer.ndc, camera);
body.getWorldPosition(hitSphere.center);
hitSphere.radius = Math.max(S.x, S.y, S.z) * 1.1 + 0.15;
return raycaster.ray.intersectsSphere(hitSphere);
}
let down = null;
canvas.addEventListener('pointermove', (e) => {
setNDC(e);
pointer.inside = true;
pointer.active = 2.5;
if (e.pointerType === 'mouse') canvas.style.cursor = overFish() ? 'pointer' : 'default';
});
canvas.addEventListener('pointerleave', () => { pointer.inside = false; });
canvas.addEventListener('pointerdown', (e) => {
setNDC(e);
pointer.inside = true;
pointer.active = 2.5;
down = { x: e.clientX, y: e.clientY, t: performance.now() };
});
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;
setNDC(e);
dismissHint();
if (overFish()) { puff(); return; }
raycaster.setFromCamera(pointer.ndc, camera);
if (raycaster.ray.intersectPlane(foodPlane, tHit)) dropFood(tHit);
});
canvas.addEventListener('pointercancel', () => { down = null; });
puffBtn.addEventListener('click', () => { dismissHint(); puff(); });
feedBtn.addEventListener('click', () => {
dismissHint();
dropFood(tHit.set(st.pos.x + rr(-1.4, 1.4), Math.min(viewTop - 0.7, st.pos.y + 1.8), clamp(st.pos.z + rr(-0.2, 0.4), -0.5, 0.6)));
});
window.addEventListener('keydown', (e) => {
const tag = e.target && e.target.tagName;
if (tag === 'BUTTON' || tag === 'INPUT' || tag === 'TEXTAREA') return;
if (e.key === ' ' || e.key === 'p' || e.key === 'P') { e.preventDefault(); dismissHint(); puff(); }
else if (e.key === 'f' || e.key === 'F') feedBtn.click();
});
// ─── Per-frame update ─────────────────────────────────────────────────
const tA = new V3(), tB = new V3(), tC = new V3(), mouthW = new V3(), fwd = new V3();
const m4 = new THREE.Matrix4(), sclTmp = new V3();
let time = 0;
function nearestFood() {
let best = null, bd = Infinity;
for (const f of foods) {
const d = f.mesh.position.distanceToSquared(mouthW);
if (d < bd) { bd = d; best = f; }
}
return best;
}
function update(dt) {
time += dt;
const t = time;
theme.m = damp(theme.m, theme.target, 5, dt);
theme.fog.lerp(theme.fogTarget, 1 - Math.exp(-5 * dt));
applyTheme(t);
// gentle parallax toward the pointer
pointer.active = Math.max(0, pointer.active - dt);
pointer.sx = damp(pointer.sx, pointer.inside ? pointer.ndc.x : 0, 1.8, dt);
pointer.sy = damp(pointer.sy, pointer.inside ? pointer.ndc.y : 0, 1.8, dt);
camera.position.set(pointer.sx * 0.5 * MOTION, 0.55 + pointer.sy * 0.28 * MOTION, camDist);
camera.lookAt(camLook);
camera.updateMatrixWorld();
// ── where does Pip want to go?
const puffed = st.puff > 0.35 || st.puffTarget > 0.5;
const food = puffed ? null : nearestFood();
let maxSpeed = 0.65 * MOTION, resp = 1.5;
if (st.intro) {
maxSpeed = 1.1;
if (st.pos.x > -bounds.x) st.intro = false;
}
if (food) {
st.target.copy(food.mesh.position).sub(tA.subVectors(mouthW, st.pos)); // bring the mouth, not the middle
maxSpeed = 1.8;
resp = 3.2;
} else if (puffed) {
st.target.set(clamp(st.pos.x, -bounds.x * 0.5, bounds.x * 0.5), clamp(st.pos.y + 0.1, bounds.yMin + 0.5, viewTop - 2.1), clamp(st.pos.z, -0.8, 0.2));
maxSpeed = 0.35;
resp = 1.2;
} else {
st.wanderT -= dt;
if (!st.intro && (st.wanderT <= 0 || st.pos.distanceTo(st.target) < 0.3)) pickWander();
}
tA.subVectors(st.target, st.pos);
const dist = tA.length();
tA.multiplyScalar(dist > 1e-4 ? Math.min(maxSpeed, dist * 1.2) / dist : 0);
st.vel.lerp(tA, 1 - Math.exp(-resp * dt));
st.pos.addScaledVector(st.vel, dt);
const floorY = sandAt(st.pos.x, st.pos.z) + (food ? 1.02 : 1.25) + Math.max(0, st.puff) * 0.55;
const roomX = viewHalfW - Math.max(S.x, S.z) - 0.25; // keep the whole body on screen
st.pos.x = clamp(st.pos.x, st.intro ? -bounds.x - 8 : -Math.max(roomX, 0), Math.max(roomX, 0));
st.pos.y = clamp(st.pos.y, floorY, Math.max(floorY, Math.min(food ? viewTop - 0.7 : bounds.yMax, viewTop - S.y - 0.35)));
st.pos.z = clamp(st.pos.z, bounds.zMin, bounds.zMax);
// ── heading: travel direction, biased toward the viewer when idle
const speed = st.vel.length();
let hx, hz;
if (food) {
hx = food.mesh.position.x - st.pos.x;
hz = food.mesh.position.z - st.pos.z;
const l = Math.hypot(hx, hz);
if (l > 1e-3) { hx /= l; hz /= l; } else { hx = 0; hz = 1; }
hz += 0.12;
} else {
hx = st.vel.x * 1.6;
hz = st.vel.z * 1.6 + 0.55;
if (pointer.active > 0 && pointer.inside) hx += clamp(pointerWorld.x - st.pos.x, -2, 2) * 0.18;
}
const prevYaw = st.yaw;
st.yaw = dampAngle(st.yaw, Math.atan2(hx, hz), food ? 5 : 2.4, dt);
const yawRate = wrapAngle(st.yaw - prevYaw) / Math.max(dt, 1e-3);
let pitchT = clamp(-st.vel.y * 0.55, -0.45, 0.45);
if (food) {
const dy = food.mesh.position.y - st.pos.y;
const hd = Math.hypot(food.mesh.position.x - st.pos.x, food.mesh.position.z - st.pos.z);
pitchT = clamp(-Math.atan2(dy, Math.max(hd, 0.35)) * 0.9, -0.8, 0.8);
}
st.pitch = damp(st.pitch, pitchT, 3.2, dt);
st.roll = damp(st.roll, clamp(-yawRate * 0.2, -0.38, 0.38), 3, dt);
fish.position.copy(st.pos);
fish.rotation.set(st.pitch, st.yaw, st.roll);
// ── puff spring (bouncy going up, softer coming down)
if (st.puffHold > 0) {
st.puffHold -= dt;
if (st.puffHold <= 0) { st.puffTarget = 0; st.deflate = 1.4; }
}
const inflating = st.puffTarget > 0.5;
const K = inflating ? 78 : 30, C = inflating ? 7.5 : 8.8;
st.puffV += (K * (st.puffTarget - st.puff) - C * st.puffV) * dt;
st.puff += st.puffV * dt;
const P = clamp(st.puff, -0.12, 1.3), P01 = clamp(P, 0, 1);
S.set(lerp(BASE_S.x, PUFF_S.x, P), lerp(BASE_S.y, PUFF_S.y, P), lerp(BASE_S.z, PUFF_S.z, P));
S.multiplyScalar(1 + 0.016 * Math.sin(t * 2.3) * (1 - P01)); // breathing
st.chomp = Math.max(0, st.chomp - dt * 3.5);
const ch = Math.sin(st.chomp * Math.PI) * 0.1;
S.x *= 1 + ch * 0.5; S.z *= 1 + ch * 0.4; S.y *= 1 - ch;
body.scale.copy(S);
for (const f of features) f.obj.position.copy(f.dir).multiply(S).multiplyScalar(f.inset);
const spk = lerp(0.14, 1, smoothstep(0.08, 0.9, P)) + Math.max(0, P - 1) * 0.5;
for (let i = 0; i < spikes.length; i++) {
const s = spikes[i], kk = spk * s.k;
tA.copy(s.d).multiply(S).multiplyScalar(0.985);
sclTmp.set(0.45 + 0.55 * kk, kk, 0.45 + 0.55 * kk);
spikeMesh.setMatrixAt(i, m4.compose(tA, s.q, sclTmp));
}
spikeMesh.instanceMatrix.needsUpdate = true;
// ── fins
st.finPh += dt * (puffed ? 19 : 9 + speed * 5);
st.tailPh += dt * (3 + speed * 7 + (puffed ? 5 : 0));
const pa = 0.42 + 0.18 * P01;
fins.pecR.flap.rotation.y = Math.sin(st.finPh) * pa;
fins.pecL.flap.rotation.y = -Math.sin(st.finPh + 0.8) * pa;
fins.tail.flap.rotation.y = Math.sin(st.tailPh) * (0.2 + Math.min(speed, 1.5) * 0.22);
fins.dorsal.flap.rotation.y = Math.sin(st.finPh * 0.6 + 1) * 0.32;
fins.anal.flap.rotation.y = Math.sin(st.finPh * 0.6 + 2.4) * 0.32;
bob.position.y = Math.sin(t * 1.7) * 0.055 * MOTION;
bob.rotation.x = Math.sin(t * 1.3 + 1) * 0.03;
bob.rotation.z = Math.sin(t * 1.1) * 0.035;
fish.updateMatrixWorld(true);
mouth.getWorldPosition(mouthW);
fwd.set(0, 0, 1).applyQuaternion(fish.quaternion);
// ── gaze: pointer > food > the viewer (with little glances around)
if (pointer.active > 0 && pointer.inside) {
raycaster.setFromCamera(pointer.ndc, camera);
gazePlane.constant = -(st.pos.z + 1.4);
if (raycaster.ray.intersectPlane(gazePlane, tB)) pointerWorld.copy(tB); else tB.copy(camera.position);
} else if (food) {
tB.copy(food.mesh.position);
} else {
st.sacT -= dt;
if (st.sacT <= 0) {
st.sacT = rr(1, 3.5);
if (rand() < 0.6) st.sac.set(0, 0, 0); else st.sac.set(rr(-4, 4), rr(-2, 2.5), rr(-3, 0));
}
tB.copy(camera.position).add(st.sac);
}
st.gaze.lerp(tB, 1 - Math.exp(-14 * dt));
for (const e of eyes) {
tC.copy(st.gaze);
e.g.worldToLocal(tC);
tC.sub(e.pivot.position);
const yaw = clamp(Math.atan2(tC.x, tC.z), -0.62, 0.62);
const pitch = clamp(Math.atan2(tC.y, Math.hypot(tC.x, tC.z)), -0.5, 0.42);
e.pivot.rotation.set(-pitch, yaw, 0);
}
// ── lids: blink, go wide when puffed, squint happily after a snack
st.blinkIn -= dt;
if (st.blinkIn <= 0 && st.blinkT < 0) { st.blinkT = 0; st.blinkIn = rand() < 0.2 ? 0.3 : rr(2.4, 5.6); }
let close = 0;
if (st.blinkT >= 0) {
st.blinkT += dt;
const x = st.blinkT / 0.18;
close = x < 0.45 ? x / 0.45 : 1 - (x - 0.45) / 0.55;
if (x >= 1) { st.blinkT = -1; close = 0; }
}
st.happy = Math.max(0, st.happy - dt);
const happyK = smoothstep(0, 0.3, st.happy);
const openA = lerp(lerp(LID_OPEN, -1.15, P01), -0.12, happyK);
const lidA = lerp(openA, LID_CLOSED, clamp(close, 0, 1));
for (const e of eyes) e.lid.rotation.x = lidA;
// ── mouth
st.gape = Math.max(0, st.gape - dt * 4.5);
const gp = Math.sin(st.gape * Math.PI);
gape.scale.set(0.001 + gp * 1.15, 0.001 + gp * 0.75, 1);
const sk = Math.max((1 - 0.5 * P01) * (1 - gp) * (1 + 0.15 * happyK), 0.001);
smile.scale.set(sk, sk * (1 + 0.3 * happyK), sk);
// ── bubbles from Pip
st.bubbleIn -= dt;
if (st.bubbleIn <= 0 && !puffed) {
st.bubbleIn = rr(2.5, 6.5);
const n = 1 + Math.floor(rand() * 3);
for (let i = 0; i < n; i++) {
tA.copy(mouthW).addScaledVector(fwd, 0.05 + i * 0.05).add(tC.set(rr(-0.03, 0.03), i * 0.09, rr(-0.03, 0.03)));
spawnBubble(tA, rr(0.04, 0.085), fwd.x * 0.3, 0.15, fwd.z * 0.3);
}
}
if (st.deflate > 0) {
st.deflate -= dt;
if (rand() < 36 * smoothstep(0, 0.4, st.deflate) * dt) {
tA.copy(mouthW).add(tC.set(rr(-0.04, 0.04), rr(-0.04, 0.04), rr(-0.04, 0.04)));
spawnBubble(tA, rr(0.035, 0.1), fwd.x * 0.9, 0.2, fwd.z * 0.9);
}
}
// ── food: sink, settle, fade, or get eaten
for (let i = foods.length - 1; i >= 0; i--) {
const f = foods[i], m = f.mesh;
f.t += dt;
let fade = 1;
if (!f.landed) {
m.position.y -= 0.24 * dt;
m.position.x += Math.sin(f.t * 2.2 + f.ph) * 0.14 * dt;
m.rotation.x += f.spin * dt;
m.rotation.z += f.spin * 0.6 * dt;
const fy = sandAt(m.position.x, m.position.z) + 0.06;
if (m.position.y <= fy) { m.position.y = fy; f.landed = true; f.landT = f.t; }
} else {
fade = 1 - smoothstep(10, 13, f.t - f.landT);
if (fade <= 0) { scene.remove(m); foods.splice(i, 1); continue; }
}
m.scale.setScalar(Math.max(0.001, smoothstep(0, 0.25, f.t) * fade));
if (!puffed && mouthW.distanceTo(m.position) < 0.4) {
scene.remove(m);
foods.splice(i, 1);
st.chomp = 1; st.gape = 1; st.happy = 1.1;
for (let k = 0; k < 4; k++) {
tA.copy(mouthW).add(tC.set(rr(-0.08, 0.08), rr(0, 0.1), rr(-0.05, 0.05)));
spawnBubble(tA, rr(0.03, 0.06), rr(-0.1, 0.1), 0.25, rr(-0.1, 0.1));
}
}
}
// ── soft shadow under Pip (drawn by the sand shader)
const hgt = Math.max(0, st.pos.y - sandAt(st.pos.x, st.pos.z));
sandMat.uniforms.uShadow.value.set(
st.pos.x + hgt * 0.1, st.pos.z - hgt * 0.14,
clamp(0.6 - hgt * 0.1, 0.18, 0.55) * lerp(1, 0.55, theme.m),
Math.max(S.x, S.z) * 1.1 + hgt * 0.2);
sandMat.uniforms.uTime.value = t * MOTION;
snMat.uniforms.uTime.value = t;
updateKelp(t);
updateSnow(dt, t);
updateVents(dt);
updateBubbles(dt);
updateSchool(t);
}
readTheme();
applyTheme(0);
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
update(Math.min(clock.getDelta(), 1 / 20));
renderer.render(scene, camera);
});
})();
</script>
</body>
</html>