← Mike Higgins Home
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">&#9654;</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 ? '&#9646;&#9646;' : '&#9654;';
});

// ═══════════════════════════════════════════════════════════════════════════════
// 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>