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>