← Mike Higgins Home
Custom Code Experiment •

Unicorn

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>Unicorn</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
  * { margin:0; padding:0; box-sizing:border-box; }
  html, body { background:#000; overflow:hidden; height:100%; }
  #bgCanvas { position:fixed; inset:0; width:100%; height:100%; z-index:1; }
  #animationCanvas { position:fixed; inset:0; width:100%; height:100%; z-index:2; cursor:grab; touch-action:none; }
  #animationCanvas:active { cursor:grabbing; }
  #retroscope{
    position:fixed; z-index:6; left:50%; bottom:22px; transform:translateX(-50%);
    font:600 12px/1 ui-monospace, monospace; letter-spacing:.28em; text-transform:uppercase;
    color:#9fb6cc; background:rgba(10,14,20,.55); border:1px solid rgba(150,180,205,.35);
    padding:12px 22px; border-radius:40px; cursor:pointer; backdrop-filter:blur(6px);
    transition:all .25s ease; user-select:none;
  }
  #retroscope:hover{ border-color:rgba(150,180,205,.7); color:#dff; }
  body.scope-on #retroscope{
    color:#032; background:rgba(30,255,130,.9); border-color:#a9ffbf;
    box-shadow:0 0 18px rgba(40,255,130,.7), 0 0 46px rgba(40,255,130,.35);
  }
  body.scope-on #animationCanvas{
    filter:grayscale(1) sepia(1) hue-rotate(80deg) saturate(5) brightness(1.15) contrast(1.2);
  }
  #scope{ position:fixed; inset:0; z-index:4; pointer-events:none; display:none; }
  body.scope-on #scope{ display:block; }
  #scope > div{ position:absolute; inset:0; }
  #scope .glow{ background:radial-gradient(circle at 50% 46%, rgba(30,255,130,.08), rgba(0,0,0,0) 62%); animation:nvflick 3.6s steps(24) infinite; }
  #scope .scan{ background:repeating-linear-gradient(to bottom, rgba(0,0,0,0) 0 2px, rgba(0,25,8,.45) 2px 3px); animation:nvscan 7s linear infinite; }
  #scope .vig{ box-shadow: inset 0 0 200px 50px rgba(0,0,0,.8); }
  #scope .mask{ background:radial-gradient(circle at 50% 50%, rgba(0,0,0,0) 54%, rgba(0,0,0,.5) 72%, rgba(0,0,0,.94) 100%); }
  @keyframes nvscan{ from{background-position:0 0} to{background-position:0 240px} }
  @keyframes nvflick{ 0%,100%{opacity:.55} 40%{opacity:.9} 62%{opacity:.4} 80%{opacity:.75} }
</style>
</head>
<body>
<canvas id="bgCanvas"></canvas>
<canvas id="animationCanvas"></canvas>
<div id="scope"><div class="glow"></div><div class="scan"></div><div class="vig"></div><div class="mask"></div></div>
<button id="retroscope">retroscope</button>

<script>
const TAU = Math.PI*2;

const bgCanvas = document.getElementById('bgCanvas');
const bgCtx = bgCanvas.getContext('2d');
function initBackground(){
  bgCanvas.width = innerWidth; bgCanvas.height = innerHeight;
  bgCtx.fillStyle = '#000'; bgCtx.fillRect(0,0,bgCanvas.width,bgCanvas.height);
}

const renderer = new THREE.WebGLRenderer({ canvas:document.getElementById('animationCanvas'), antialias:true, alpha:true });
let scene, camera, unicorn, body, headGroup, neckRoot, tailRoot;
const iriMats = [];
const legs = [], neckJoints = [], tailJoints = [], maneStrands = [], tailStrands = [];
let hueShift = 0;

function updateSize(){
  renderer.setSize(innerWidth, innerHeight);
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
}

const cam = { dist:8.5, yaw:0.85, pitch:0.16, targetYaw:0.85, targetPitch:0.16, targetDist:8.5, autoYaw:0 };
function updateCamera(){
  const w = innerWidth;
  if (w < 480){ cam.targetDist = Math.min(Math.max(cam.targetDist,10.5),16); camera.fov = 58; }
  else if (w < 768){ camera.fov = 52; }
  else { camera.fov = 46; }
  camera.updateProjectionMatrix();
}
function applyCamera(){
  cam.yaw   += (cam.targetYaw   - cam.yaw)   * 0.08;
  cam.pitch += (cam.targetPitch - cam.pitch) * 0.08;
  cam.dist  += (cam.targetDist  - cam.dist)  * 0.08;
  const total = cam.yaw + cam.autoYaw;
  const p = Math.max(-1.35, Math.min(1.35, cam.pitch));
  camera.position.set(
    Math.sin(total)*Math.cos(p)*cam.dist,
    Math.sin(p)*cam.dist + 0.35,
    Math.cos(total)*Math.cos(p)*cam.dist
  );
  camera.lookAt(0, -0.15, 0);
}

function iri(offset, opts={}){
  const { sat=0.8, light=0.56, roughness=0.44, metalness=0.34, opacity=1, transparent=false } = opts;
  const m = new THREE.MeshStandardMaterial({ color:0xffffff, roughness, metalness, side:THREE.DoubleSide, transparent, opacity });
  m.userData = { offset, sat, light };
  m.color.setHSL(((offset%1)+1)%1, sat, light);
  iriMats.push(m);
  return m;
}

function segmentBetween(p0, p1, r0, r1, material, radialSeg=12){
  const dir = new THREE.Vector3().subVectors(p1, p0);
  const len = dir.length();
  const geo = new THREE.CylinderGeometry(r1, r0, len, radialSeg, 1, false);
  const mesh = new THREE.Mesh(geo, material);
  mesh.position.copy(p0).add(p1).multiplyScalar(0.5);
  mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0), dir.clone().normalize());
  return mesh;
}

function makeHorn(len, r0){
  const g = new THREE.Group();
  const core  = iri(0.12, { roughness:0.16, metalness:0.85, light:0.62 });
  const ridge = iri(0.20, { roughness:0.12, metalness:0.9,  light:0.7 });
  const N = 22;
  let prev = new THREE.Vector3(0,0,0);
  for (let i=1;i<=N;i++){
    const f = i/N, pf = (i-1)/N;
    const nxt = new THREE.Vector3(0, len*f, 0);
    g.add(segmentBetween(prev, nxt, r0*(1-pf*0.94)+0.004, r0*(1-f*0.94)+0.004, core, 12));
    prev = nxt;
  }
  const turns = 5, M = 66;
  let pp = null;
  for (let i=0;i<=M;i++){
    const f = i/M, a = f*turns*TAU;
    const rr = r0*(1-f*0.94)+0.012;
    const p = new THREE.Vector3(Math.cos(a)*rr, len*f, Math.sin(a)*rr);
    if (pp) g.add(segmentBetween(pp, p, 0.022*(1-f*0.85)+0.003, 0.022*(1-f*0.85)+0.003, ridge, 6));
    pp = p;
  }
  return g;
}

function makeHead(){
  const g = new THREE.Group();
  const coat = iri(0.02, { roughness:0.3, metalness:0.5 });
  const dark = iri(0.9,  { roughness:0.35, metalness:0.5, light:0.35 });

  const skull = new THREE.Mesh(new THREE.SphereGeometry(1, 30, 24), coat);
  skull.scale.set(0.22, 0.26, 0.30); g.add(skull);
  const muzzle = new THREE.Mesh(new THREE.SphereGeometry(1, 26, 20), coat);
  muzzle.scale.set(0.15, 0.16, 0.34); muzzle.position.set(0, -0.10, 0.44); g.add(muzzle);
  const nose = new THREE.Mesh(new THREE.SphereGeometry(1, 20, 16), dark);
  nose.scale.set(0.13, 0.11, 0.10); nose.position.set(0, -0.13, 0.74); g.add(nose);
  const cheek = new THREE.Mesh(new THREE.SphereGeometry(1, 22, 18), coat);
  cheek.scale.set(0.20, 0.18, 0.20); cheek.position.set(0, -0.16, 0.10); g.add(cheek);

  [-1,1].forEach(sx=>{
    const eye = new THREE.Mesh(new THREE.SphereGeometry(0.072, 18, 14), dark);
    eye.position.set(sx*0.19, 0.07, 0.20); g.add(eye);
    const gl = new THREE.Mesh(new THREE.SphereGeometry(0.024, 10, 8), iri(0.5,{roughness:0.1,metalness:0.4,light:0.8}));
    gl.position.set(sx*0.21, 0.10, 0.25); g.add(gl);
    const nn = new THREE.Mesh(new THREE.SphereGeometry(0.028,10,8), dark);
    nn.position.set(sx*0.07, -0.10, 0.76); g.add(nn);
    const ear = new THREE.Group();
    ear.position.set(sx*0.13, 0.24, -0.06);
    ear.rotation.set(-0.25, sx*0.30, sx*0.18);
    ear.add(segmentBetween(new THREE.Vector3(0,0,0), new THREE.Vector3(0,0.26,-0.04), 0.075, 0.006, coat, 10));
    g.add(ear);
  });

  const horn = makeHorn(1.05, 0.085);
  horn.position.set(0, 0.24, 0.20);
  horn.rotation.x = 0.42;
  g.add(horn);

  for (let i=0;i<5;i++){
    const s = new THREE.Group();
    s.position.set((i-2)*0.055, 0.24, 0.06);
    s.add(segmentBetween(new THREE.Vector3(0,0,0), new THREE.Vector3((i-2)*0.03, 0.14, 0.22), 0.032, 0.004,
          iri(0.3 + i*0.05, { roughness:0.25, metalness:0.6, light:0.62 }), 6));
    g.add(s); maneStrands.push({ g:s, base:0, phase:i*0.5, amp:0.18 });
  }
  return g;
}

function makeLeg(side, cfg){
  const coat = iri(cfg.hue, { roughness:0.32, metalness:0.55 });
  const hoofM= iri(cfg.hue+0.5, { roughness:0.2, metalness:0.8, light:0.4 });
  const wrap = new THREE.Group();
  wrap.position.set(side*cfg.x, cfg.y, cfg.z);

  const mus = new THREE.Mesh(new THREE.SphereGeometry(1, 20, 16), coat);
  mus.scale.set(0.20, 0.30, 0.28); wrap.add(mus);

  const upper = new THREE.Group(); upper.rotation.x = cfg.aU; wrap.add(upper);
  upper.add(segmentBetween(new THREE.Vector3(0,0,0), new THREE.Vector3(0,-cfg.Lu,0), 0.165, 0.105, coat, 14));

  const knee = new THREE.Group(); knee.position.set(0,-cfg.Lu,0); knee.rotation.x = cfg.aK; upper.add(knee);
  knee.add(new THREE.Mesh(new THREE.SphereGeometry(0.105, 14, 12), coat));
  knee.add(segmentBetween(new THREE.Vector3(0,0,0), new THREE.Vector3(0,-cfg.Lk,0), 0.098, 0.058, coat, 14));

  const ankle = new THREE.Group(); ankle.position.set(0,-cfg.Lk,0); ankle.rotation.x = cfg.aA; knee.add(ankle);
  ankle.add(new THREE.Mesh(new THREE.SphereGeometry(0.062, 12, 10), coat));
  ankle.add(segmentBetween(new THREE.Vector3(0,0,0), new THREE.Vector3(0,-cfg.La*0.66,0.02), 0.055, 0.05, coat, 12));
  for (let i=0;i<5;i++){
    const a = (i/4-0.5)*1.4;
    ankle.add(segmentBetween(new THREE.Vector3(Math.sin(a)*0.05,-0.04,Math.cos(a)*0.03),
              new THREE.Vector3(Math.sin(a)*0.10,-cfg.La*0.55,Math.cos(a)*0.06-0.05), 0.022, 0.003,
              iri(cfg.hue+0.3,{roughness:0.3,metalness:0.55,light:0.62}), 5));
  }
  const hf = new THREE.Mesh(new THREE.CylinderGeometry(0.075, 0.095, cfg.La*0.34, 14), hoofM);
  hf.position.set(0, -cfg.La*0.84, 0.03); ankle.add(hf);

  const marker = new THREE.Object3D();
  marker.position.set(0, -cfg.La*0.9, 0.03);
  ankle.add(marker);
  legs.push({ upper, knee, ankle, aA:cfg.aA, delay:cfg.delay, hind:!!cfg.hind,
              rootY:cfg.y, rootX:side*cfg.x, rootZ:cfg.z,
              L1:cfg.Lu, L2:cfg.Lk + cfg.La*0.8, marker, prevU:0 });
  return wrap;
}

function createUnicorn(){
  const g = new THREE.Group();
  body = new THREE.Group();
  g.add(body);

  const coat = iri(0.06, { roughness:0.28, metalness:0.55 });

  const barrel = new THREE.Mesh(new THREE.SphereGeometry(1, 34, 26), coat);
  barrel.scale.set(0.47, 0.55, 1.38); barrel.position.set(0, 0.05, 0); body.add(barrel);
  const chest = new THREE.Mesh(new THREE.SphereGeometry(1, 28, 22), iri(0.12,{roughness:0.28,metalness:0.55}));
  chest.scale.set(0.46, 0.54, 0.56); chest.position.set(0, 0.10, 0.95); body.add(chest);
  const rump = new THREE.Mesh(new THREE.SphereGeometry(1, 30, 24), iri(0.5,{roughness:0.28,metalness:0.55}));
  rump.scale.set(0.50, 0.57, 0.64); rump.position.set(0, 0.12, -1.02); body.add(rump);
  const withers = new THREE.Mesh(new THREE.SphereGeometry(1, 22, 18), coat);
  withers.scale.set(0.30, 0.26, 0.40); withers.position.set(0, 0.48, 0.86); body.add(withers);

  neckRoot = new THREE.Group();
  neckRoot.position.set(0, 0.50, 1.02);
  neckRoot.rotation.x = -0.60;
  body.add(neckRoot);
  let parent = neckRoot;
  const NN = 7;
  for (let i=0;i<NN;i++){
    const f = i/NN;
    const j = new THREE.Group();
    j.userData.base = -0.06 - f*0.05;
    j.rotation.x = j.userData.base;
    const segLen = 0.225;
    const r0 = 0.28*(1-f*0.42), r1 = 0.30*(1-(f+1/NN)*0.42);
    j.add(segmentBetween(new THREE.Vector3(0,0,0), new THREE.Vector3(0,0,segLen), r0, r1,
          iri(0.06 + f*0.30, { roughness:0.3, metalness:0.55 }), 16));
    for (let k=0;k<3;k++){
      const off = (k-1)*0.055;
      const st = new THREE.Group();
      st.position.set(off, r0*0.95, segLen*0.4);
      const L = 0.52 + k*0.06;
      st.add(segmentBetween(new THREE.Vector3(0,0,0), new THREE.Vector3(off*1.6, 0.12, -L), 0.042, 0.004,
             iri(0.3 + f*0.5 + k*0.04, { roughness:0.28, metalness:0.6, light:0.64 }), 6));
      j.add(st);
      maneStrands.push({ g:st, base:0, phase:i*0.7 + k*0.5, amp:0.26 });
    }
    const holder = new THREE.Group(); holder.position.set(0,0,segLen); j.add(holder);
    parent.add(j); parent = holder;
    neckJoints.push(j);
  }
  headGroup = makeHead();
  headGroup.rotation.x = 1.10;
  parent.add(headGroup);

  tailRoot = new THREE.Group();
  tailRoot.position.set(0, 0.42, -1.42);
  tailRoot.rotation.y = Math.PI;
  tailRoot.rotation.x = -0.35;
  body.add(tailRoot);
  let tp = tailRoot;
  const TN = 9;
  for (let i=0;i<TN;i++){
    const f = i/TN;
    const j = new THREE.Group();
    j.userData.base = 0.10 + f*0.06;
    j.rotation.x = j.userData.base;
    const segLen = 0.22;
    j.add(segmentBetween(new THREE.Vector3(0,0,0), new THREE.Vector3(0,0,segLen), 0.11*(1-f*0.55), 0.11*(1-(f+1/TN)*0.55),
          iri(0.55 + f*0.4, { roughness:0.3, metalness:0.6 }), 12));
    for (let k=0;k<3;k++){
      const a = (k-1)*0.5;
      const st = new THREE.Group();
      st.position.set(Math.sin(a)*0.07, Math.cos(a)*0.05, segLen*0.5);
      st.add(segmentBetween(new THREE.Vector3(0,0,0), new THREE.Vector3(Math.sin(a)*0.13, -0.06, 0.52), 0.028, 0.004,
             iri(0.55 + f*0.4 + k*0.05, { roughness:0.25, metalness:0.65, light:0.62 }), 5));
      j.add(st);
      tailStrands.push({ g:st, phase:i*0.6 + k*0.4, amp:0.22 });
    }
    const holder = new THREE.Group(); holder.position.set(0,0,segLen); j.add(holder);
    tp.add(j); tp = holder;
    tailJoints.push(j);
  }

  const FORE = { x:0.40, y:0.30, z:1.08, Lu:0.68, Lk:0.62, La:0.34, aU:0.10, aK:-0.08, aA:0.03 };
  const HIND = { x:0.42, y:0.32, z:-1.08, Lu:0.70, Lk:0.66, La:0.36, aU:-0.36, aK:0.76, aA:-0.36, hind:true };
  body.add(makeLeg(-1, Object.assign({}, HIND, { hue:0.62, delay:0.00 })));
  body.add(makeLeg( 1, Object.assign({}, HIND, { hue:0.62, delay:0.12 })));
  body.add(makeLeg(-1, Object.assign({}, FORE, { hue:0.72, delay:0.48 })));
  body.add(makeLeg( 1, Object.assign({}, FORE, { hue:0.72, delay:0.60 })));

  g.scale.setScalar(0.95);
  return g;
}

const SAND_Y = -1.133, SHORE_X = -1.2;
let water, waterBase, sandMat, foamMat, sunGlow;
const splashes = [], ripples = [];

function makeSandTexture(){
  const c = document.createElement('canvas'); c.width = c.height = 512;
  const x = c.getContext('2d');
  x.fillStyle = '#8d7a60'; x.fillRect(0,0,512,512);
  for (let i=0;i<26000;i++){
    const g = 70 + Math.random()*95;
    x.fillStyle = 'rgba('+(g+28)+','+(g+8)+','+(g-18)+','+(0.10+Math.random()*0.30)+')';
    x.fillRect(Math.random()*512, Math.random()*512, 1.6, 1.6);
  }
  for (let i=0;i<130;i++){
    x.strokeStyle = 'rgba(120,104,80,'+(0.05+Math.random()*0.10)+')';
    x.lineWidth = 1+Math.random()*2.5;
    x.beginPath();
    const y0 = Math.random()*512;
    x.moveTo(0,y0);
    x.bezierCurveTo(170, y0+(Math.random()-0.5)*40, 340, y0+(Math.random()-0.5)*40, 512, y0+(Math.random()-0.5)*20);
    x.stroke();
  }
  const tex = new THREE.CanvasTexture(c);
  tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
  tex.repeat.set(48, 48);
  return tex;
}
function makeSkyTexture(){
  const c = document.createElement('canvas'); c.width = 8; c.height = 512;
  const x = c.getContext('2d');
  const gr = x.createLinearGradient(0,0,0,512);
  gr.addColorStop(0.00, '#10184a');
  gr.addColorStop(0.34, '#3d3f86');
  gr.addColorStop(0.55, '#9a5f8a');
  gr.addColorStop(0.72, '#e8825a');
  gr.addColorStop(0.86, '#ffb672');
  gr.addColorStop(1.00, '#ffdca8');
  x.fillStyle = gr; x.fillRect(0,0,8,512);
  return new THREE.CanvasTexture(c);
}
function makeFoamTexture(){
  const c = document.createElement('canvas'); c.width = 512; c.height = 64;
  const x = c.getContext('2d');
  x.clearRect(0,0,512,64);
  for (let i=0;i<1400;i++){
    const px = Math.random()*512;
    const py = 32 + (Math.random()-0.5)*Math.random()*54;
    const a = 0.5*(1 - Math.abs(py-32)/30);
    x.fillStyle = 'rgba(255,252,246,'+Math.max(0,a)+')';
    x.beginPath(); x.arc(px, py, 0.7+Math.random()*2.6, 0, Math.PI*2); x.fill();
  }
  const tex = new THREE.CanvasTexture(c);
  tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
  tex.repeat.set(14, 1);
  return tex;
}

function createBeach(){
  const env = new THREE.Group();

  const sky = new THREE.Mesh(new THREE.SphereGeometry(320, 32, 24),
    new THREE.MeshBasicMaterial({ map:makeSkyTexture(), side:THREE.BackSide, fog:false, depthWrite:false }));
  env.add(sky);

  const sun = new THREE.Mesh(new THREE.CircleGeometry(7.5, 40),
    new THREE.MeshBasicMaterial({ color:0xfff0c2, fog:false, transparent:true, opacity:0.96 }));
  sun.position.set(70, 6.5, -250); sun.lookAt(0, 6.5, 0); env.add(sun);
  sunGlow = new THREE.Mesh(new THREE.CircleGeometry(26, 40),
    new THREE.MeshBasicMaterial({ color:0xffb072, fog:false, transparent:true, opacity:0.30, blending:THREE.AdditiveBlending, depthWrite:false }));
  sunGlow.position.copy(sun.position); sunGlow.lookAt(0, 6.5, 0); env.add(sunGlow);

  for (let i=0;i<11;i++){
    const cl = new THREE.Mesh(new THREE.SphereGeometry(1, 18, 10),
      new THREE.MeshBasicMaterial({ color:i%2?0xffc79a:0xd98f8a, fog:false, transparent:true, opacity:0.30, depthWrite:false }));
    cl.scale.set(24+Math.random()*40, 1.1+Math.random()*1.6, 5);
    cl.position.set(-160+Math.random()*340, 13+Math.random()*26, -170-Math.random()*130);
    env.add(cl);
  }

  sandMat = new THREE.MeshStandardMaterial({ map:makeSandTexture(), roughness:0.92, metalness:0.03 });
  const sand = new THREE.Mesh(new THREE.PlaneGeometry(900, 900), sandMat);
  sand.rotation.x = -Math.PI/2; sand.position.y = SAND_Y; env.add(sand);

  const wet = new THREE.Mesh(new THREE.PlaneGeometry(7, 900),
    new THREE.MeshStandardMaterial({ color:0x6b5a45, roughness:0.22, metalness:0.35, transparent:true, opacity:0.75 }));
  wet.rotation.x = -Math.PI/2; wet.position.set(SHORE_X+2.4, SAND_Y+0.004, 0); env.add(wet);

  const wg = new THREE.PlaneGeometry(700, 700, 90, 90);
  water = new THREE.Mesh(wg, new THREE.MeshStandardMaterial({
    color:0x2a6478, roughness:0.14, metalness:0.6, transparent:true, opacity:0.86 }));
  water.rotation.x = -Math.PI/2;
  water.position.set(SHORE_X + 350, SAND_Y + 0.05, 0);
  waterBase = Float32Array.from(wg.attributes.position.array);
  env.add(water);

  const streak = new THREE.Mesh(new THREE.PlaneGeometry(11, 260),
    new THREE.MeshBasicMaterial({ color:0xffc98a, transparent:true, opacity:0.24, blending:THREE.AdditiveBlending, depthWrite:false, fog:false }));
  streak.rotation.x = -Math.PI/2; streak.position.set(24, SAND_Y+0.09, -120); env.add(streak);

  foamMat = new THREE.MeshBasicMaterial({ map:makeFoamTexture(), transparent:true, opacity:0.85, depthWrite:false });
  const foam = new THREE.Mesh(new THREE.PlaneGeometry(3.2, 900), foamMat);
  foam.rotation.x = -Math.PI/2; foam.position.set(SHORE_X+0.7, SAND_Y+0.06, 0);
  foam.name = 'foam'; env.add(foam);
  env.userData.foam = foam;

  const dropGeo = new THREE.SphereGeometry(0.035, 7, 6);
  const dropMat = new THREE.MeshStandardMaterial({ color:0xdff2f6, roughness:0.1, metalness:0.2, transparent:true, opacity:0.9 });
  for (let i=0;i<90;i++){
    const m = new THREE.Mesh(dropGeo, dropMat);
    m.visible = false; env.add(m);
    splashes.push({ m, v:new THREE.Vector3(), life:0 });
  }
  for (let i=0;i<10;i++){
    const mat = new THREE.MeshBasicMaterial({ color:0xcfeef5, transparent:true, opacity:0, depthWrite:false });
    const m = new THREE.Mesh(new THREE.TorusGeometry(1, 0.035, 6, 40), mat);
    m.rotation.x = -Math.PI/2; m.visible = false; env.add(m);
    ripples.push({ m, mat, life:0 });
  }
  return env;
}

function spawnSplash(pos){
  let n = 0;
  for (let i=0;i<splashes.length && n<7;i++){
    const s = splashes[i];
    if (s.life > 0) continue;
    s.m.position.copy(pos);
    s.v.set((Math.random()-0.5)*1.5, 1.2+Math.random()*1.7, (Math.random()-0.5)*1.5);
    s.life = 1; s.m.visible = true; s.m.scale.setScalar(0.7+Math.random()*0.8);
    n++;
  }
  for (let i=0;i<ripples.length;i++){
    const r = ripples[i];
    if (r.life > 0) continue;
    r.m.position.set(pos.x, SAND_Y+0.055, pos.z);
    r.m.scale.setScalar(0.05); r.life = 1; r.m.visible = true;
    break;
  }
}

function updateBeach(t, dt){
  if (water){
    const pos = water.geometry.attributes.position;
    for (let i=0;i<pos.count;i++){
      const bx = waterBase[i*3], by = waterBase[i*3+1];
      const worldX = bx + water.position.x;
      const ramp = Math.min(1, Math.max(0, (worldX - SHORE_X)/26));
      const a = ramp*0.34;
      const h = Math.sin(by*0.16 + t*1.15)*a
              + Math.sin(by*0.37 - t*1.7 + bx*0.05)*a*0.45
              + Math.sin(bx*0.09 + t*0.8)*a*0.3;
      pos.array[i*3+2] = h;
    }
    pos.needsUpdate = true;
    water.geometry.computeVertexNormals();
  }
  if (sandMat && sandMat.map) sandMat.map.offset.y -= dt*0.16;
  if (foamMat && foamMat.map){
    foamMat.map.offset.x -= dt*0.05;
    foamMat.opacity = 0.62 + Math.sin(t*0.5)*0.22;
  }
  if (sunGlow) sunGlow.material.opacity = 0.26 + Math.sin(t*0.7)*0.05;

  for (let i=0;i<splashes.length;i++){
    const s = splashes[i];
    if (s.life <= 0) continue;
    s.v.y -= 7.5*dt;
    s.m.position.addScaledVector(s.v, dt);
    s.life -= dt*0.85;
    if (s.m.position.y < SAND_Y+0.02 || s.life <= 0){ s.life = 0; s.m.visible = false; }
  }
  for (let i=0;i<ripples.length;i++){
    const r = ripples[i];
    if (r.life <= 0) continue;
    r.life -= dt*0.7;
    const k = 1 - r.life;
    r.m.scale.setScalar(0.05 + k*1.5);
    r.mat.opacity = Math.max(0, r.life*0.55);
    if (r.life <= 0) r.m.visible = false;
  }
}

function init(){
  initBackground(); updateSize();
  scene = new THREE.Scene(); scene.background = null;
  camera = new THREE.PerspectiveCamera(46, innerWidth/innerHeight, 0.1, 1000);
  updateCamera();
  scene.add(createBeach());
  scene.fog = new THREE.FogExp2(0xe0a887, 0.0075);

  unicorn = createUnicorn();
  scene.add(unicorn);

  scene.add(new THREE.HemisphereLight(0xbfd4ff, 0x6b5340, 0.5));
  const key  = new THREE.DirectionalLight(0xfff0d8, 1.15); key.position.set(6,7,-8); scene.add(key);
  const sunRim = new THREE.DirectionalLight(0xffb877, 1.5); sunRim.position.set(14,2.5,-22); scene.add(sunRim);
  const rim  = new THREE.PointLight(0x59d4ff, 1.0, 80); rim.position.set(-8,4,6); scene.add(rim);
  const rim2 = new THREE.PointLight(0xff5fae, 0.8, 80); rim2.position.set(7,-1,5); scene.add(rim2);
  const bounce= new THREE.PointLight(0xffcf9a, 0.55, 40); bounce.position.set(0,-2.5,2); scene.add(bounce);

  addPointerControls(); setupScope(); animate();
}
function setupScope(){
  document.getElementById('retroscope').addEventListener('click', ()=> document.body.classList.toggle('scope-on'));
}

function solveIK(L, targetY, targetZ){
  const L1 = L.L1, L2 = L.L2;
  let ty = targetY - L.rootY, tz = targetZ;
  let d = Math.hypot(ty, tz) || 1e-5;
  const maxD = (L1+L2)*0.995, minD = Math.abs(L1-L2)+0.02;
  const dc = Math.min(maxD, Math.max(minD, d));
  const sc = dc/d; ty *= sc; tz *= sc; d = dc;
  const cl = v => v<-1?-1:v>1?1:v;
  const total    = Math.atan2(-tz, -ty);
  const alpha    = Math.acos(cl((d*d + L1*L1 - L2*L2)/(2*d*L1)));
  const interior = Math.acos(cl((L1*L1 + L2*L2 - d*d)/(2*L1*L2)));
  const s = L.hind ? -1 : 1;
  return { upper: total - s*alpha, knee: s*(Math.PI - interior) };
}

const _hoofPos = new THREE.Vector3();
const GALLOP = 0.85;
let _prevT = 0;
function animate(){
  requestAnimationFrame(animate);
  const t = performance.now()*0.001;
  const dt = Math.min(0.05, _prevT ? t-_prevT : 0.016); _prevT = t;
  updateBeach(t, dt);

  hueShift = (t*0.05) % 1;
  for (let i=0;i<iriMats.length;i++){ const m = iriMats[i]; m.color.setHSL((hueShift+m.userData.offset)%1, m.userData.sat, m.userData.light); }

  const cycle = t*GALLOP;
  const STANCE = 0.34;
  const GROUND = -1.14, REACH = 0.46, LIFT = 0.42;

  const bp = body ? body.rotation.x : 0, br = body ? body.rotation.z : 0, by = body ? body.position.y : 0;
  for (let i=0;i<legs.length;i++){
    const L = legs[i];
    let u = (cycle - L.delay) % 1; if (u < 0) u += 1;
    const groundLocal = (GROUND - by + L.rootZ*Math.sin(bp) - L.rootX*Math.sin(br))/Math.cos(bp);
    if (u < L.prevU){
      L.marker.getWorldPosition(_hoofPos);
      spawnSplash(_hoofPos);
    }
    L.prevU = u;
    let tz, ty, load = 0;
    if (u < STANCE){
      const s = u/STANCE;
      tz = REACH - s*2*REACH;
      ty = groundLocal;
      load = Math.sin(s*Math.PI);
    } else {
      const s = (u-STANCE)/(1-STANCE);
      const e = s*s*(3-2*s);
      tz = -REACH + e*2*REACH;
      ty = groundLocal + Math.sin(s*Math.PI)*LIFT;
    }
    const a = solveIK(L, ty, tz);
    L.upper.rotation.x = a.upper;
    L.knee.rotation.x  = a.knee;
    L.ankle.rotation.x = L.aA + load*0.22 - (1-load)*0.10;
  }

  if (body){
    body.position.y = Math.sin(cycle*TAU)*0.075 + Math.sin(cycle*TAU*2)*0.03;
    body.rotation.x = Math.sin(cycle*TAU - 0.6)*0.07;
    body.rotation.z = Math.sin(cycle*TAU)*0.022;
    const breathe = 1 + Math.sin(t*1.1)*0.012;
    body.scale.set(breathe, breathe, 1);
  }

  for (let i=0;i<neckJoints.length;i++){
    const j = neckJoints[i];
    j.rotation.x = j.userData.base + Math.sin(cycle*TAU - i*0.35)*0.055;
    j.rotation.y = Math.sin(t*0.8 - i*0.3)*0.03;
  }
  if (headGroup){
    headGroup.rotation.x = 1.10 - Math.sin(cycle*TAU)*0.10;
    headGroup.rotation.y = Math.sin(t*0.6)*0.10;
  }

  for (let i=0;i<maneStrands.length;i++){
    const s = maneStrands[i];
    s.g.rotation.x = Math.sin(cycle*TAU*1.1 - s.phase)*s.amp - 0.25 + Math.sin(t*0.9 - s.phase*0.4)*0.10;
    s.g.rotation.z = Math.sin(cycle*TAU*0.8 - s.phase*0.7)*s.amp*0.5 + Math.sin(t*0.7 - s.phase*0.3)*0.08;
  }
  for (let i=0;i<tailJoints.length;i++){
    const j = tailJoints[i];
    j.rotation.x = j.userData.base + Math.sin(cycle*TAU*1.0 - i*0.4)*0.07;
    j.rotation.y = Math.sin(cycle*TAU*0.7 - i*0.35)*0.06;
  }
  for (let i=0;i<tailStrands.length;i++){
    const s = tailStrands[i];
    s.g.rotation.x = Math.sin(cycle*TAU*1.2 - s.phase)*s.amp + Math.sin(t*0.8 - s.phase*0.5)*0.09;
    s.g.rotation.y = Math.sin(cycle*TAU*0.9 - s.phase*0.8)*s.amp*0.6 + Math.sin(t*0.6 - s.phase*0.35)*0.07;
  }

  if (unicorn){
    unicorn.rotation.y = Math.sin(t*0.10)*0.10;
    unicorn.position.y = -0.05;
  }

  if (!dragging) cam.autoYaw += 0.0007;
  applyCamera();
  renderer.render(scene, camera);
}

let dragging=false, lastX=0, lastY=0, pinchD=0;
function addPointerControls(){
  const c = renderer.domElement;
  c.addEventListener('pointerdown', e=>{ dragging=true; lastX=e.clientX; lastY=e.clientY; c.setPointerCapture(e.pointerId); });
  c.addEventListener('pointermove', e=>{ if(!dragging) return; cam.targetYaw += (e.clientX-lastX)*0.006; cam.targetPitch += (e.clientY-lastY)*0.006; lastX=e.clientX; lastY=e.clientY; });
  const stop=()=>dragging=false;
  c.addEventListener('pointerup', stop); c.addEventListener('pointercancel', stop); c.addEventListener('pointerleave', stop);
  c.addEventListener('wheel', e=>{ e.preventDefault(); cam.targetDist = Math.max(4.5, Math.min(20, cam.targetDist + e.deltaY*0.012)); }, {passive:false});
  c.addEventListener('touchmove', e=>{ if(e.touches.length===2){ const dx=e.touches[0].clientX-e.touches[1].clientX, dy=e.touches[0].clientY-e.touches[1].clientY; const d=Math.hypot(dx,dy); if(pinchD) cam.targetDist = Math.max(4.5, Math.min(20, cam.targetDist + (pinchD-d)*0.02)); pinchD=d; } }, {passive:true});
  c.addEventListener('touchend', ()=>pinchD=0);
}
addEventListener('resize', ()=>{ updateSize(); initBackground(); if(camera){ camera.aspect = innerWidth/innerHeight; updateCamera(); } });
addEventListener('load', init);
</script>
</body>
</html>