Custom Code Experiment •
Metamorphosis 2
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.0">
<title>Metamorphosis 2</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0e0c08; overflow: hidden; }
canvas { display: block; }
#ui {
position: absolute; bottom: 22px; left: 50%; transform: translateX(-50%);
width: min(580px, 88vw);
background: rgba(6,5,3,0.80);
border: 1px solid rgba(240,232,200,0.10);
border-radius: 11px; padding: 13px 18px;
backdrop-filter: blur(16px);
user-select: none;
}
.controls { display: flex; align-items: center; gap: 10px; }
input[type=range] {
flex: 1; -webkit-appearance: none; background: transparent; height: 28px; cursor: pointer;
}
input[type=range]:focus { outline: none; }
input[type=range]::-webkit-slider-runnable-track {
height: 4px; border-radius: 2px;
background: linear-gradient(to right,
#c8b870 0%, #c8b870 18%,
#8b1a0a 22%, #8b1a0a 38%,
#c8901a 42%, #c8901a 60%,
#8a7040 64%, #8a7040 82%,
#c8b870 86%, #c8b870 100%);
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%;
background: #f0e8c8; margin-top: -7px;
box-shadow: 0 0 5px rgba(240,232,200,0.40);
}
.btn {
background: rgba(240,232,200,0.06);
border: 1px solid rgba(240,232,200,0.14);
color: #c8b870; padding: 5px 11px; border-radius: 6px;
font-size: 0.80rem; cursor: pointer; white-space: nowrap; font-family: serif;
}
.btn:hover { background: rgba(240,232,200,0.12); }
</style>
</head>
<body>
<div id="ui">
<div class="controls">
<button class="btn" id="autoBtn">▶</button>
<input type="range" id="slider" min="0" max="1" step="0.0005" value="0.12">
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// ═══════════════════════════════════════════════════════════════════════════════
// CONSTANTS
// ═══════════════════════════════════════════════════════════════════════════════
const PLANE_W = 110, PLANE_D = 52;
const TILES_X = 22, TILES_Z = 10;
const RES = 16; // higher res for sharper edges
const SEG_X = TILES_X * RES;
const SEG_Z = TILES_Z * RES;
const TILE_W = PLANE_W / TILES_X;
const TILE_D = PLANE_D / TILES_Z;
const N_PHASES = 4;
const PI = Math.PI, sin = Math.sin, cos = Math.cos, abs = Math.abs;
// ─── Woodcut palettes [ink/dark tile, paper/light tile] ───────────────────────
// True Escher: near-black ink, warm cream paper, one chromatic accent per phase
const INK = new THREE.Color(0x0e0c08); // near-black ink (all phases base)
const PAPER = new THREE.Color(0xf0e8c8); // warm cream paper
const PALETTES = [
// Squares: pure woodcut — ink black / aged parchment
[ new THREE.Color(0x0e0c08), new THREE.Color(0xf0e8c8) ],
// Fish: deep prussian blue / vermillion-coral (Escher Day and Night ocean)
[ new THREE.Color(0x0a1828), new THREE.Color(0xc83810) ],
// Birds: ink black silhouette / amber-gold sky — classic Day and Night
[ new THREE.Color(0x080806), new THREE.Color(0xe8a020) ],
// City: charcoal / warm sandstone with ochre accent
[ new THREE.Color(0x060504), new THREE.Color(0xc8a858) ],
];
// ─── Sky shader colors per phase ──────────────────────────────────────────────
// Each sky has [horizonTop, horizonBot, zenith] — interpolated in the shader
const SKY_PHASES = [
// Squares: warm parchment dusk — cream to deep umber
{ top: [0.94, 0.91, 0.78], mid: [0.70, 0.55, 0.30], bot: [0.08, 0.06, 0.03] },
// Fish: night ocean — deep navy zenith, dark water horizon
{ top: [0.01, 0.04, 0.12], mid: [0.02, 0.07, 0.18], bot: [0.00, 0.02, 0.06] },
// Birds: pale dawn — pale gold horizon, washed blue zenith
{ top: [0.20, 0.32, 0.52], mid: [0.82, 0.68, 0.38], bot: [0.10, 0.08, 0.05] },
// City night: near-black zenith, deep orange glow at horizon
{ top: [0.01, 0.01, 0.02], mid: [0.22, 0.08, 0.01], bot: [0.04, 0.02, 0.00] },
];
// ─── Ambient / sun per phase ──────────────────────────────────────────────────
const LIGHTING = [
{ ambCol: 0xffe8b0, ambI: 0.55, sunCol: 0xfff0c0, sunI: 0.90 }, // squares
{ ambCol: 0x3060a0, ambI: 0.30, sunCol: 0x6090cc, sunI: 0.60 }, // fish
{ ambCol: 0xffd080, ambI: 0.60, sunCol: 0xffe0a0, sunI: 1.20 }, // birds (bright day)
{ ambCol: 0xff6010, ambI: 0.22, sunCol: 0xff8822, sunI: 0.70 }, // city
];
// ═══════════════════════════════════════════════════════════════════════════════
// SCENE
// ═══════════════════════════════════════════════════════════════════════════════
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(48, innerWidth / innerHeight, 0.1, 1200);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.autoClear = false; // we'll clear manually — sky renders first
document.body.appendChild(renderer.domElement);
// Separate scene for sky quad (rendered before main scene, no depth write)
const skyScene = new THREE.Scene();
const skyCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
// ─── Orbit ────────────────────────────────────────────────────────────────────
let isDragging = false, lastX = 0, lastY = 0;
let yaw = -0.48, pitch = 0.54, camDist = 62;
function updateCamera() {
const cx = camDist * sin(yaw) * cos(pitch);
const cy = camDist * sin(pitch);
const cz = camDist * cos(yaw) * cos(pitch);
camera.position.set(cx, cy, cz);
camera.lookAt(0, 0, 0);
}
renderer.domElement.addEventListener('mousedown', e => { isDragging = true; lastX = e.clientX; lastY = e.clientY; });
window.addEventListener('mouseup', () => isDragging = false);
window.addEventListener('mousemove', e => {
if (!isDragging) return;
yaw += (e.clientX - lastX) * 0.005;
pitch = Math.max(0.06, Math.min(1.45, pitch + (e.clientY - lastY) * 0.005));
lastX = e.clientX; lastY = e.clientY;
});
renderer.domElement.addEventListener('wheel', e => {
camDist = Math.max(16, Math.min(130, camDist + e.deltaY * 0.06));
}, { passive: true });
renderer.domElement.addEventListener('touchstart', e => { isDragging = true; lastX = e.touches[0].clientX; lastY = e.touches[0].clientY; });
renderer.domElement.addEventListener('touchend', () => isDragging = false);
renderer.domElement.addEventListener('touchmove', e => {
if (!isDragging) return;
yaw += (e.touches[0].clientX - lastX) * 0.006;
pitch = Math.max(0.06, Math.min(1.45, pitch + (e.touches[0].clientY - lastY) * 0.006));
lastX = e.touches[0].clientX; lastY = e.touches[0].clientY;
});
// ─── Lighting ─────────────────────────────────────────────────────────────────
const ambLight = new THREE.AmbientLight(0xffe8b0, 0.55);
scene.add(ambLight);
const sunLight = new THREE.DirectionalLight(0xfff0c0, 0.90);
sunLight.position.set(40, 80, 20);
scene.add(sunLight);
const fillLight = new THREE.DirectionalLight(0x224466, 0.35);
fillLight.position.set(-50, 20, -20);
scene.add(fillLight);
const rimLight = new THREE.DirectionalLight(0x442211, 0.25);
rimLight.position.set(0, -20, -50);
scene.add(rimLight);
// ═══════════════════════════════════════════════════════════════════════════════
// SKY BACKDROP — full-screen quad with GLSL gradient
// ═══════════════════════════════════════════════════════════════════════════════
const skyUniforms = {
uTopA: { value: new THREE.Color() },
uTopB: { value: new THREE.Color() },
uMidA: { value: new THREE.Color() },
uMidB: { value: new THREE.Color() },
uBotA: { value: new THREE.Color() },
uBotB: { value: new THREE.Color() },
uBlend: { value: 0.0 },
uTime: { value: 0.0 },
uPhase: { value: 0.0 }, // 0=squares,1=fish,2=birds,3=city (blended float)
};
const skyMat = new THREE.ShaderMaterial({
uniforms: skyUniforms,
depthWrite: false,
depthTest: false,
vertexShader: `
varying vec2 vUV;
void main() {
vUV = uv;
gl_Position = vec4(position.xy, 1.0, 1.0);
}
`,
fragmentShader: `
uniform vec3 uTopA, uTopB, uMidA, uMidB, uBotA, uBotB;
uniform float uBlend, uTime, uPhase;
varying vec2 vUV;
// Hash for stars / city lights
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
void main() {
float y = vUV.y; // 0=bottom, 1=top
// Three-stop gradient: bot → mid → top
vec3 colA = y < 0.5
? mix(uBotA, uMidA, y * 2.0)
: mix(uMidA, uTopA, (y - 0.5) * 2.0);
vec3 colB = y < 0.5
? mix(uBotB, uMidB, y * 2.0)
: mix(uMidB, uTopB, (y - 0.5) * 2.0);
vec3 col = mix(colA, colB, uBlend);
// ── STARS (fish / ocean phase) ──
float starAmt = clamp(uPhase < 1.0 ? uPhase // squares→fish
: uPhase < 2.0 ? 2.0 - uPhase // fish→birds
: 0.0, 0.0, 1.0);
if (starAmt > 0.001) {
vec2 sg = floor(vUV * vec2(120.0, 80.0));
float h = hash(sg);
float brightness = step(0.985, h) * (0.5 + 0.5 * sin(uTime * 1.8 + h * 20.0));
// Stars only in upper sky
brightness *= smoothstep(0.35, 0.65, y);
col += starAmt * brightness * vec3(0.85, 0.90, 1.00);
}
// ── HORIZON GLOW (birds / dawn phase) ──
float birdAmt = clamp(uPhase < 1.0 ? 0.0
: uPhase < 2.0 ? uPhase - 1.0
: uPhase < 3.0 ? 3.0 - uPhase
: 0.0, 0.0, 1.0);
if (birdAmt > 0.001) {
float glow = exp(-((y - 0.28) * (y - 0.28)) / 0.018);
col += birdAmt * glow * vec3(1.00, 0.60, 0.10) * 0.55;
}
// ── CITY GLOW (dome of orange light low in sky) ──
float cityAmt = clamp(uPhase < 2.0 ? 0.0
: uPhase < 3.0 ? uPhase - 2.0
: uPhase < 4.0 ? 4.0 - uPhase
: 0.0, 0.0, 1.0);
if (cityAmt > 0.001) {
float dome = exp(-((y - 0.10) * (y - 0.10)) / 0.04) * (1.0 - y * 0.8);
col += cityAmt * dome * vec3(1.00, 0.38, 0.04) * 0.70;
// City window-light scatter near horizon
float scatter = exp(-y * 12.0) * 0.4;
col += cityAmt * scatter * vec3(1.00, 0.55, 0.10);
}
gl_FragColor = vec4(col, 1.0);
}
`
});
const skyQuad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), skyMat);
skyScene.add(skyQuad);
// ── Star particles (THREE.Points) for the fish/ocean phase ───────────────────
// Extra crisp star points that float in world-space above the tessellation
const STAR_COUNT = 280;
const starPositions = new Float32Array(STAR_COUNT * 3);
for (let i = 0; i < STAR_COUNT; i++) {
starPositions[i*3] = (Math.random() - 0.5) * 160;
starPositions[i*3+1] = 18 + Math.random() * 40;
starPositions[i*3+2] = (Math.random() - 0.5) * 100;
}
const starGeo = new THREE.BufferGeometry();
starGeo.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
const starMat = new THREE.PointsMaterial({
color: 0xd8e8ff, size: 0.35, transparent: true, opacity: 0.0,
sizeAttenuation: true, depthWrite: false
});
const stars = new THREE.Points(starGeo, starMat);
scene.add(stars);
// ═══════════════════════════════════════════════════════════════════════════════
// CHECKER TEXTURE
// ═══════════════════════════════════════════════════════════════════════════════
const texC = document.createElement('canvas');
texC.width = texC.height = 512;
const tc = texC.getContext('2d');
const csz = 512 / 16;
for (let r = 0; r < 16; r++) for (let c = 0; c < 16; c++) {
tc.fillStyle = ((r + c) % 2 === 0) ? '#fff' : '#000';
tc.fillRect(c * csz, r * csz, csz, csz);
}
const tex = new THREE.CanvasTexture(texC);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(TILES_X / 2, TILES_Z / 2);
// ═══════════════════════════════════════════════════════════════════════════════
// GEOMETRY
// ═══════════════════════════════════════════════════════════════════════════════
const geo = new THREE.PlaneGeometry(PLANE_W, PLANE_D, SEG_X, SEG_Z);
geo.rotateX(-PI / 2);
const base = new Float32Array(geo.attributes.position.array);
const NV = (SEG_X + 1) * (SEG_Z + 1);
const colBuf = new Float32Array(NV * 3);
geo.setAttribute('color', new THREE.BufferAttribute(colBuf, 3));
const mat = new THREE.MeshStandardMaterial({
map: tex, vertexColors: true,
roughness: 0.38, metalness: 0.04,
side: THREE.DoubleSide,
});
// Multiply B&W texture by vertex color → clean woodcut tinting
mat.onBeforeCompile = sh => {
sh.fragmentShader = sh.fragmentShader.replace(
'#include <color_fragment>',
`#include <color_fragment>\ndiffuseColor.rgb *= vColor.rgb;`
);
};
scene.add(new THREE.Mesh(geo, mat));
// ═══════════════════════════════════════════════════════════════════════════════
// DEFORMATION FUNCTIONS — redesigned for stronger physical character
// ═══════════════════════════════════════════════════════════════════════════════
// ALL dx/dz must be zero at lx=0,1 and lz=0,1 (shared tile edges → no gaps)
// dy is free.
// Basis: sin(n*PI*x) — zero at both endpoints always.
function s1(x) { return sin(x * PI); }
function s2(x) { return sin(x * PI * 2); }
function s3(x) { return sin(x * PI * 3); }
// Smooth plateau function: flat top, steep sides
// Rises quickly at 'edge', plateau in center
function plateau(x, edge) {
const t = Math.max(0, Math.min(1, (x - edge) / (1 - 2 * edge)));
const u = Math.max(0, Math.min(1, (1 - x - edge) / (1 - 2 * edge)));
return Math.min(t * t * (3 - 2 * t), u * u * (3 - 2 * u));
}
// ─── SQUARES ──────────────────────────────────────────────────────────────────
function squareDeform() { return { dx: 0, dz: 0, dy: 0 }; }
// ─── FISH — flat cut-paper silhouette ─────────────────────────────────────────
// Key character: these are FLAT like woodblock prints — almost zero height.
// Strong lateral silhouette (dx) defines the fish shape.
// The body is a bold oval bulge; tail tapers sharply.
function fishDeform(lx, lz) {
// Bold belly bulge — one clean sine wave
// lz=0 and lz=1 are both tile boundaries → s1(lz) is zero there ✓
const belly = s1(lz) * 0.44;
// Secondary S-curve gives the slightly asymmetric Escher fish shape
const sCurve = s2(lz) * 0.06;
const dx = belly + sCurve;
// Fore-aft: tail-end pinch. s1(lx) zero at lx=0,1 ✓
// (lz-0.5) is antisymmetric — top half pinches one way, bottom other
// This creates the narrow tail and slightly blunt nose
const dz = s1(lx) * (lz - 0.5) * 0.60
+ s2(lx) * (lz - 0.5) * 0.08;
// HEIGHT: very flat — fish are cut-paper, not bumps.
// Just a very slight dorsal ridge to catch the light
const ridge = Math.exp(-((lz - 0.30) ** 2) / 0.025) * s1(lx);
const dy = ridge * 0.18; // << deliberately flat
return { dx, dz, dy };
}
// ─── BIRDS — strong V-arch with sharp crease at wingtip ──────────────────────
// Character: dramatic upswept wings, each tile a single bird body.
// Wing tip is at lz=0.5 (the tile's z-midpoint, shared with next bird above).
// Body keel hangs low at lz=0 and lz=1.
// This creates the classic Escher Day-and-Night silhouette.
function birdDeform(lx, lz) {
// Wing sweep: bold horizontal spread, zero at z-boundaries ✓
const wingSpread = s1(lz) * 0.48 + s2(lz) * 0.07;
const dx = wingSpread;
// Very slight body tilt — mostly avoid dz to keep birds symmetric
const dz = s1(lx) * (lz - 0.5) * 0.18;
// HEIGHT: strong V-arch — this is the key visual change.
// Use a SHARP Gaussian at lz=0.5 (wing center) for the arching wing,
// and subtract a trough for the body.
const wingTip = Math.exp(-((lz - 0.5) ** 2) / 0.025); // sharp peak at tip
const bodyTrough = (1.0 - wingTip) * 0.15;
const dy = (wingTip * 0.95 - bodyTrough) * s1(lx);
return { dx, dz, dy };
}
// ─── CITY — brutally vertical towers with hard flat tops ─────────────────────
// Character: towers shoot straight up then flatten abruptly.
// We use a plateau function that is nearly flat on top (like a building roof)
// and rises steeply from the tile base. dy dominates; dx/dz are minimal.
function cityDeform(lx, lz) {
// Tower footprint uses plateau — steep sides, flat roof
// plateau(x, 0.15) = flat across 70% of the tile, sharp drop at 15% from each edge
const footprint = plateau(lx, 0.14) * plateau(lz, 0.14);
const dy = footprint * 0.90; // scaled by per-tile height multiplier in caller
// Barely any lateral deform — buildings are vertical
const dx = s2(lz) * s1(lx) * 0.02;
const dz = s2(lx) * s1(lz) * 0.02;
return { dx, dz, dy };
}
function cityTileHeight(tc, tr) {
const v = abs(sin(tc * 2.31 + tr * 5.73) * 0.5 + sin(tc * 7.13 - tr * 3.21) * 0.5);
return 0.30 + v * 1.25; // wider range for more dramatic skyline
}
// ═══════════════════════════════════════════════════════════════════════════════
// BLEND LOGIC
// ═══════════════════════════════════════════════════════════════════════════════
function smoothStep(x) { return x <= 0 ? 0 : x >= 1 ? 1 : x * x * (3 - 2 * x); }
function lerp(a, b, t) { return a + (b - a) * t; }
function getBlend(xNorm, sliderT) {
const offset = sliderT * 1.20;
const wrapped = ((xNorm - offset + 2.0) % 1.0 + 1.0) % 1.0;
const raw = wrapped * N_PHASES;
const phaseIdx = Math.floor(raw) % N_PHASES;
const nextIdx = (phaseIdx + 1) % N_PHASES;
const rawFrac = raw - Math.floor(raw);
const EDGE = 0.22;
const bandFrac = Math.max(0, Math.min(1, (rawFrac - EDGE) / (1 - EDGE * 2)));
return { phaseIdx, nextIdx, frac: smoothStep(bandFrac) };
}
// Blended phase float for sky shader (0=squares, 1=fish, 2=birds, 3=city)
function getPhaseFloat(sliderT) {
const { phaseIdx, nextIdx, frac } = getBlend(0.5, sliderT);
return phaseIdx + frac;
}
// ═══════════════════════════════════════════════════════════════════════════════
// PER-FRAME GEOMETRY UPDATE
// ═══════════════════════════════════════════════════════════════════════════════
function evalPhase(idx, lx, lz, tileC, tileR) {
if (idx === 0) return squareDeform();
if (idx === 1) return fishDeform(lx, lz);
if (idx === 2) return birdDeform(lx, lz);
// idx === 3: city
const th = cityTileHeight(tileC, tileR);
const raw = cityDeform(lx, lz);
return { dx: raw.dx, dz: raw.dz, dy: raw.dy * th };
}
function updateGeometry(sliderT) {
const pos = geo.attributes.position.array;
const col = geo.attributes.color.array;
for (let vi = 0; vi < NV; vi++) {
const bx = base[vi * 3];
const bz = base[vi * 3 + 2];
const xNorm = (bx + PLANE_W / 2) / PLANE_W;
const gx = (bx + PLANE_W / 2) / TILE_W;
const gz = (bz + PLANE_D / 2) / TILE_D;
const lx = gx - Math.floor(gx);
const lz = gz - Math.floor(gz);
const tileC = Math.floor(gx);
const tileR = Math.floor(gz);
const { phaseIdx, nextIdx, frac } = getBlend(xNorm, sliderT);
const d0 = evalPhase(phaseIdx, lx, lz, tileC, tileR);
const d1 = evalPhase(nextIdx, lx, lz, tileC, tileR);
const dx = lerp(d0.dx, d1.dx, frac);
const dz = lerp(d0.dz, d1.dz, frac);
const dy = lerp(d0.dy, d1.dy, frac);
pos[vi * 3] = bx + dx * TILE_W;
pos[vi * 3 + 1] = base[vi * 3 + 1] + dy * TILE_W;
pos[vi * 3 + 2] = bz + dz * TILE_D;
// ─── Woodcut vertex color ───
const [cA0, cB0] = PALETTES[phaseIdx];
const [cA1, cB1] = PALETTES[nextIdx];
const cA = new THREE.Color().lerpColors(cA0, cA1, frac);
const cB = new THREE.Color().lerpColors(cB0, cB1, frac);
const checker = (Math.floor(gx) + Math.floor(gz)) % 2;
const c = checker === 0 ? cA : cB;
// Woodcut shading: shadows in the ink creases (low dy = darker on ink tiles)
// Crease darkening — edges of tiles catch deeper shadows
const edgeDark = checker === 0 ? (1.0 - (lx - 0.5) ** 2 * 0.8 - (lz - 0.5) ** 2 * 0.8) : 1.0;
let brightness = 0.85 + dy * 0.55 + edgeDark * 0.15;
// City lit windows — specific tiles have amber-lit windows
const cityAmt = (phaseIdx === 3 ? (1 - frac) : 0) + (nextIdx === 3 ? frac : 0);
if (cityAmt > 0.01 && checker === 0) {
const winH = abs(sin(tileC * 13.07 + tileR * 7.31));
if (winH > 0.60) brightness += cityAmt * 2.8; // hot amber window
}
col[vi * 3] = Math.min(1, c.r * brightness);
col[vi * 3 + 1] = Math.min(1, c.g * brightness);
col[vi * 3 + 2] = Math.min(1, c.b * brightness);
}
geo.attributes.position.needsUpdate = true;
geo.attributes.color.needsUpdate = true;
geo.computeVertexNormals();
}
// ═══════════════════════════════════════════════════════════════════════════════
// SKY + WORLD UPDATE
// ═══════════════════════════════════════════════════════════════════════════════
function lerpArr(a, b, t) { return a.map((v, i) => v + (b[i] - v) * t); }
function updateSkyAndWorld(sliderT, time) {
const { phaseIdx, nextIdx, frac } = getBlend(0.5, sliderT);
const s0 = SKY_PHASES[phaseIdx], s1p = SKY_PHASES[nextIdx];
const top = lerpArr(s0.top, s1p.top, frac);
const mid = lerpArr(s0.mid, s1p.mid, frac);
const bot = lerpArr(s0.bot, s1p.bot, frac);
// Next phase colors for shader blend within transition
const top2 = lerpArr(s1p.top, SKY_PHASES[(nextIdx + 1) % N_PHASES].top, 0);
const mid2 = lerpArr(s1p.mid, SKY_PHASES[(nextIdx + 1) % N_PHASES].mid, 0);
const bot2 = lerpArr(s1p.bot, SKY_PHASES[(nextIdx + 1) % N_PHASES].bot, 0);
skyUniforms.uTopA.value.setRGB(...top);
skyUniforms.uMidA.value.setRGB(...mid);
skyUniforms.uBotA.value.setRGB(...bot);
skyUniforms.uTopB.value.setRGB(...top2);
skyUniforms.uMidB.value.setRGB(...mid2);
skyUniforms.uBotB.value.setRGB(...bot2);
skyUniforms.uBlend.value = 0.0; // already blended in CPU
skyUniforms.uTime.value = time * 0.001;
skyUniforms.uPhase.value = getPhaseFloat(sliderT);
// Fog matches sky bottom
scene.fog = new THREE.FogExp2(
new THREE.Color(...bot).getHex(), 0.0085
);
// Lighting
const L0 = LIGHTING[phaseIdx], L1 = LIGHTING[nextIdx];
ambLight.color.set(new THREE.Color(L0.ambCol).lerp(new THREE.Color(L1.ambCol), frac));
ambLight.intensity = lerp(L0.ambI, L1.ambI, frac);
sunLight.color.set(new THREE.Color(L0.sunCol).lerp(new THREE.Color(L1.sunCol), frac));
sunLight.intensity = lerp(L0.sunI, L1.sunI, frac);
// Star opacity: visible only in ocean/fish phase
const phF = getPhaseFloat(sliderT);
const starVis = phF < 1.0 ? phF : phF < 2.0 ? 2.0 - phF : 0.0;
starMat.opacity = Math.max(0, Math.min(0.9, starVis));
}
// ═══════════════════════════════════════════════════════════════════════════════
// UI
// ═══════════════════════════════════════════════════════════════════════════════
const slider = document.getElementById('slider');
const autoBtn = document.getElementById('autoBtn');
let targetT = 0.12, currentT = 0.12, autoPlay = false, autoDir = 1;
slider.addEventListener('input', e => { targetT = parseFloat(e.target.value); });
autoBtn.addEventListener('click', () => {
autoPlay = !autoPlay;
autoBtn.innerHTML = autoPlay ? '▮▮' : '▶';
});
// ═══════════════════════════════════════════════════════════════════════════════
// ANIMATION LOOP
// ═══════════════════════════════════════════════════════════════════════════════
let lastTime = 0;
function animate(time) {
requestAnimationFrame(animate);
const dt = Math.min((time - lastTime) / 1000, 0.05);
lastTime = time;
if (autoPlay) {
targetT += autoDir * dt * 0.042;
if (targetT >= 0.93) { targetT = 0.93; autoDir = -1; }
if (targetT <= 0.07) { targetT = 0.07; autoDir = 1; }
slider.value = targetT;
}
currentT += (targetT - currentT) * 0.07;
updateCamera();
updateSkyAndWorld(currentT, time);
updateGeometry(currentT);
// Orbit sun slowly
const a = time * 0.00020;
sunLight.position.set(40 * cos(a), 80, 20 * sin(a));
// Render sky first (no depth), then main scene on top
renderer.clear();
renderer.render(skyScene, skyCamera);
renderer.render(scene, camera);
}
animate(0);
window.addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
</script>
</body>
</html>