UNICORN - RETROSCOPIC CRT SHADER SOURCE CODE
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Unicorn · Retroscope</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;
filter: grayscale(1) sepia(1) hue-rotate(80deg) saturate(5) brightness(1.15) contrast(1.2);
}
#animationCanvas:active {
cursor: grabbing;
}
#scope {
position: fixed;
inset: 0;
z-index: 4;
pointer-events: none;
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>
<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) {
opts = 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) {
radialSeg = 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();
animate();
}
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>