WIREFRAME BUTTERFLY - THREEJS PROCEDURAL ANIMATION SOURCE CODE
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aether — Butterfly</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; }
body { background: #000; overflow: hidden; height: 100vh; cursor: grab; }
body.dragging { cursor: grabbing; }
#bgCanvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 1; }
#animationCanvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 2; }
</style>
</head>
<body>
<canvas id="bgCanvas"></canvas>
<canvas id="animationCanvas"></canvas>
<script>
const colors = {
primary: 0x4db8d4,
dark1: 0x0d2a3a,
dark3: 0x000000,
light: 0xaee8f5
};
// ── THEME SYNC ──
function syncTheme() {
try {
let isDark = true;
if (window.parent && window.parent.document) {
isDark = window.parent.document.documentElement.classList.contains('dark');
}
if (!isDark) {
document.body.style.backgroundColor = '#ffffff';
colors.dark3 = 0xffffff;
colors.dark1 = 0xcccccc;
if (typeof scene !== 'undefined' && scene) {
scene.background = new THREE.Color(colors.dark3);
}
} else {
document.body.style.backgroundColor = '#000000';
colors.dark3 = 0x000000;
colors.dark1 = 0x0d2a3a;
if (typeof scene !== 'undefined' && scene) {
scene.background = new THREE.Color(colors.dark3);
}
}
} catch (e) {}
}
syncTheme();
if (window.parent && window.parent.document) {
try {
var observer = new MutationObserver(syncTheme);
observer.observe(window.parent.document.documentElement, { attributes: true, attributeFilter: ['class'] });
} catch(e) {}
}
// ── Background ──
const bgCanvas = document.getElementById('bgCanvas');
const bgCtx = bgCanvas.getContext('2d');
const pixelSize = 8;
let cols, rows, pixels = [];
// Precomputed color ramp so we don't build strings every pixel, every frame
const RAMP_STEPS = 48;
const ramp = Array.from({ length: RAMP_STEPS + 1 }, (_, i) => {
const b = (i / RAMP_STEPS) * 0.4;
return `rgb(${Math.floor(b*10)},${Math.floor(b*30)},${Math.floor(b*50)})`;
});
function initBackground() {
bgCanvas.width = window.innerWidth;
bgCanvas.height = window.innerHeight;
cols = Math.ceil(bgCanvas.width / pixelSize);
rows = Math.ceil(bgCanvas.height / pixelSize);
pixels = [];
for (let i = 0; i < cols * rows; i++)
pixels.push({ brightness: Math.random() * 0.3, speed: 0.001 + Math.random() * 0.003 });
}
let bgFrame = 0;
function animateBackground() {
// Half the frame rate is imperceptible here and halves the fill cost
if ((bgFrame++ & 1) === 0) {
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const p = pixels[y * cols + x];
p.brightness += p.speed * 2;
if (p.brightness > 0.4) p.brightness = 0;
bgCtx.fillStyle = ramp[Math.min(RAMP_STEPS, (p.brightness * RAMP_STEPS / 0.4) | 0)];
bgCtx.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
}
}
}
requestAnimationFrame(animateBackground);
}
// ── Three.js ──
const renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('animationCanvas'), antialias: true, alpha: true });
let scene, camera, butterfly;
let wings = [], antennae = [], legs = [], abdomenGroup;
function updateSize() {
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
}
function updateCamera() {
const w = window.innerWidth;
if (w < 480) { camera.position.set(0, 1.2, 9.5); camera.fov = 55; }
else if (w < 768) { camera.position.set(0, 1.0, 8); camera.fov = 52; }
else { camera.position.set(0, 0.9, 6.5); camera.fov = 50; }
camera.lookAt(0, 0, -0.1);
camera.updateProjectionMatrix();
}
// Wireframe MeshBasicMaterial ignores lights, so materials can be
// shared freely — one per color instead of one per mesh.
const matCache = new Map();
function mat(color) {
if (!matCache.has(color))
matCache.set(color, new THREE.MeshBasicMaterial({ color, wireframe: true }));
return matCache.get(color);
}
function tube(p0, p1, radius, color, mid) {
const pts = mid ? [p0, mid, p1] : [p0, p1];
const curve = new THREE.CatmullRomCurve3(pts);
return new THREE.Mesh(new THREE.TubeGeometry(curve, 8, radius, 6, false), mat(color));
}
function ball(r, color, x, y, z, sx=1, sy=1, sz=1) {
const m = new THREE.Mesh(new THREE.SphereGeometry(r, 20, 20), mat(color));
m.position.set(x, y, z);
m.scale.set(sx, sy, sz);
return m;
}
function addFuzz(group, cx, cy, cz, radius, count, color) {
for (let i = 0; i < count; i++) {
const phi = Math.acos(2 * Math.random() - 1);
const theta = Math.random() * Math.PI * 2;
const r = radius + 0.01 + Math.random() * 0.03;
const fuzz = new THREE.Mesh(new THREE.SphereGeometry(0.014 + Math.random()*0.01, 4, 4), mat(color));
fuzz.position.set(
cx + r * Math.sin(phi) * Math.cos(theta),
cy + r * Math.sin(phi) * Math.sin(theta),
cz + r * Math.cos(phi)
);
group.add(fuzz);
}
}
// Ringed eyespot: dark disc, inner ring, bright pupil — the classic
// concentric wing marking (in wing shape-space; +x out, +y forward)
function addEyespot(group, s, x, y, r) {
const disc = new THREE.Mesh(new THREE.CircleGeometry(r, 12), mat(colors.dark1));
disc.position.set(s * x, y, 0.02);
group.add(disc);
const ring = new THREE.Mesh(new THREE.TorusGeometry(r * 0.62, 0.012, 5, 16), mat(colors.primary));
ring.position.set(s * x, y, 0.03);
group.add(ring);
const pupil = new THREE.Mesh(new THREE.CircleGeometry(r * 0.24, 8), mat(colors.light));
pupil.position.set(s * x, y, 0.035);
group.add(pupil);
}
// Wings are built flat in shape-space (+x = outward span, +y = toward
// the head) and then rotated into the horizontal plane by their holder.
function makeForewing(s) {
const group = new THREE.Group();
const shape = new THREE.Shape();
shape.moveTo(0, 0);
// Costal (leading) edge sweeping out to a pointed apex
shape.bezierCurveTo(s*0.7, 0.55, s*1.5, 0.95, s*2.15, 1.05);
// Scalloped outer margin: control points bulge outside each chord
shape.quadraticCurveTo(s*2.13, 0.83, s*1.95, 0.55);
shape.quadraticCurveTo(s*1.93, 0.36, s*1.75, 0.12);
shape.quadraticCurveTo(s*1.73, -0.05, s*1.55, -0.25);
shape.quadraticCurveTo(s*1.53, -0.42, s*1.35, -0.55);
// Inner margin back to the root
shape.bezierCurveTo(s*0.8, -0.5, s*0.3, -0.2, 0, 0);
const geo = new THREE.ShapeGeometry(shape, 16);
group.add(new THREE.Mesh(geo, new THREE.MeshBasicMaterial({
color: colors.primary, wireframe: true, side: THREE.DoubleSide,
transparent: true, opacity: 0.9
})));
group.add(new THREE.LineSegments(new THREE.EdgesGeometry(geo), new THREE.LineBasicMaterial({ color: colors.light })));
// Discal cell — the closed cell all veins radiate from
[
[[s*0.12, 0.05], [s*0.65, 0.32], [s*1.05, 0.28]],
[[s*0.12, -0.02], [s*0.6, 0.02], [s*1.05, 0.28]],
].forEach(pts => {
const curve = new THREE.CatmullRomCurve3(pts.map(p => new THREE.Vector3(p[0], p[1], 0.01)));
group.add(new THREE.Mesh(new THREE.TubeGeometry(curve, 6, 0.010, 4, false), mat(colors.light)));
});
// Radiating veins, cell to margin
[
[[s*1.05, 0.28], [s*1.6, 0.72], [s*2.05, 0.95]],
[[s*1.05, 0.28], [s*1.5, 0.5], [s*1.9, 0.5]],
[[s*1.05, 0.28], [s*1.45, 0.25],[s*1.72, 0.1]],
[[s*1.0, 0.1], [s*1.3, -0.05], [s*1.52, -0.22]],
[[s*0.9, -0.05],[s*1.1, -0.28], [s*1.32, -0.5]],
[[s*0.5, -0.15],[s*0.8, -0.38], [s*1.0, -0.52]],
].forEach(pts => {
const curve = new THREE.CatmullRomCurve3(pts.map(p => new THREE.Vector3(p[0], p[1], 0.01)));
group.add(new THREE.Mesh(new THREE.TubeGeometry(curve, 6, 0.008, 4, false), mat(colors.light)));
});
addEyespot(group, s, 1.62, 0.48, 0.16);
addEyespot(group, s, 1.68, 0.02, 0.09);
return group;
}
function makeHindwing(s) {
const group = new THREE.Group();
const shape = new THREE.Shape();
shape.moveTo(0, 0);
// Leading edge tucks under the forewing
shape.bezierCurveTo(s*0.6, 0.08, s*1.2, 0.05, s*1.6, -0.15);
// Scalloped outer margin curving down and back
shape.quadraticCurveTo(s*1.72, -0.32, s*1.58, -0.52);
shape.quadraticCurveTo(s*1.62, -0.70, s*1.42, -0.85);
shape.quadraticCurveTo(s*1.44, -1.00, s*1.18, -1.05);
// Swallowtail streamer
shape.bezierCurveTo(s*1.06, -1.12, s*1.02, -1.30, s*0.92, -1.44);
shape.quadraticCurveTo(s*0.85, -1.26, s*0.76, -1.10);
// Inner margin back to the root
shape.bezierCurveTo(s*0.4, -0.95, s*0.15, -0.5, 0, 0);
const geo = new THREE.ShapeGeometry(shape, 14);
group.add(new THREE.Mesh(geo, new THREE.MeshBasicMaterial({
color: colors.primary, wireframe: true, side: THREE.DoubleSide,
transparent: true, opacity: 0.9
})));
group.add(new THREE.LineSegments(new THREE.EdgesGeometry(geo), new THREE.LineBasicMaterial({ color: colors.light })));
// Veins radiating from the root, one running into the streamer
[
[[s*0.1, 0.0], [s*0.9, 0.0], [s*1.5, -0.18]],
[[s*0.1, -0.05],[s*0.85, -0.3], [s*1.45, -0.55]],
[[s*0.1, -0.1], [s*0.7, -0.55], [s*1.25, -0.9]],
[[s*0.1, -0.15],[s*0.55, -0.7], [s*0.92, -1.35]],
[[s*0.08, -0.2],[s*0.4, -0.65], [s*0.62, -0.95]],
].forEach(pts => {
const curve = new THREE.CatmullRomCurve3(pts.map(p => new THREE.Vector3(p[0], p[1], 0.01)));
group.add(new THREE.Mesh(new THREE.TubeGeometry(curve, 6, 0.008, 4, false), mat(colors.light)));
});
addEyespot(group, s, 1.18, -0.62, 0.13);
addEyespot(group, s, 0.85, -0.92, 0.10);
return group;
}
function createButterfly() {
const group = new THREE.Group();
// Body lies horizontal along Z, head forward at +Z — the original
// stood the body vertically, which is why it never read as flight.
// ── HEAD ──
group.add(ball(0.13, colors.primary, 0, 0.05, 0.55));
[-1, 1].forEach(side => {
group.add(ball(0.05, colors.light, side * 0.09, 0.08, 0.62));
});
// Coiled proboscis — a tightening spiral tucked under the head
const coilPts = [];
const TURNS = 1.75, SAMPLES = 26;
for (let i = 0; i <= SAMPLES; i++) {
const t = i / SAMPLES;
const a = t * TURNS * Math.PI * 2 - Math.PI * 0.5;
const r = 0.10 * (1 - 0.72 * t);
coilPts.push(new THREE.Vector3(0, -0.12 + r * Math.sin(a) * 0.9, 0.56 + r * Math.cos(a)));
}
group.add(new THREE.Mesh(
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(coilPts), 32, 0.008, 5, false),
mat(colors.primary)
));
// ── ANTENNAE ── clubbed, in pivoting groups so they can sway
[-1, 1].forEach(side => {
const grp = new THREE.Group();
grp.position.set(side * 0.05, 0.12, 0.58);
const curve = new THREE.CatmullRomCurve3([
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(side * 0.12, 0.28, 0.18),
new THREE.Vector3(side * 0.20, 0.50, 0.28),
]);
grp.add(new THREE.Mesh(new THREE.TubeGeometry(curve, 8, 0.010, 4, false), mat(colors.dark1)));
grp.add(ball(0.032, colors.light, side * 0.20, 0.52, 0.29)); // club
group.add(grp);
antennae.push({ group: grp, side });
});
// ── THORAX ── fuzzy
group.add(ball(0.17, colors.primary, 0, 0.02, 0.25, 1, 1, 1.35));
addFuzz(group, 0, 0.02, 0.25, 0.18, 14, colors.light);
// ── ABDOMEN ── slender, curving gently downward, in one pivoting
// group at the waist so it can flex with the wingbeat
abdomenGroup = new THREE.Group();
abdomenGroup.position.set(0, 0, 0.0);
const abdSegs = 7;
for (let i = 0; i < abdSegs; i++) {
const r = 0.085 - i * 0.008;
abdomenGroup.add(ball(
r, i % 2 === 0 ? colors.dark1 : colors.primary,
0, -i * i * 0.007, -0.05 - i * 0.17,
0.9, 0.95, 1.25
));
}
group.add(abdomenGroup);
// ── WINGS ── four, each on its own flap pivot at the body.
// Structure: pivot (animated: rolls about the body axis) → holder
// (static: lays the flat shape into the horizontal plane) → shape.
const wingDefs = [
{ side: -1, fore: true, z: 0.18 },
{ side: 1, fore: true, z: 0.18 },
{ side: -1, fore: false, z: 0.02 },
{ side: 1, fore: false, z: 0.02 },
];
wingDefs.forEach(def => {
const pivot = new THREE.Group();
pivot.position.set(def.side * 0.06, 0.08, def.z);
const holder = new THREE.Group();
holder.rotation.x = Math.PI / 2; // shape-space +y → world +z (forward)
holder.add(def.fore ? makeForewing(def.side) : makeHindwing(def.side));
pivot.add(holder);
group.add(pivot);
wings.push({ group: pivot, side: def.side, fore: def.fore });
});
// ── LEGS ── six slender legs tucked beneath the thorax
// (family fan-sector builder, forward = +Z on this model)
const profile = [[0.08, -0.05], [0.20, -0.12], [0.18, -0.22], [0.08, -0.06]];
const pairs = [
{ attachZ: 0.34, fanAngle: Math.PI * 0.30, scale: 0.90 },
{ attachZ: 0.22, fanAngle: Math.PI * 0.50, scale: 1.00 },
{ attachZ: 0.08, fanAngle: Math.PI * 0.70, scale: 1.05 },
];
pairs.forEach((pair, pi) => {
[-1, 1].forEach(side => {
const oX = side * Math.sin(pair.fanAngle);
const oZ = Math.cos(pair.fanAngle);
const sc = pair.scale;
const legGroup = new THREE.Group();
legGroup.position.set(side * 0.12, -0.08, pair.attachZ);
let h = 0, v = 0;
const pts = [new THREE.Vector3()];
profile.forEach(([dh, dv]) => {
h += dh; v += dv;
pts.push(new THREE.Vector3(oX * h * sc, v * sc, oZ * h * sc));
});
for (let i = 1; i < pts.length; i++) {
legGroup.add(tube(pts[i-1], pts[i], 0.012 - i * 0.002, colors.dark1));
}
legGroup.add(ball(0.014, colors.light, pts[1].x, pts[1].y, pts[1].z));
group.add(legGroup);
const outward = new THREE.Vector3(oX, 0, oZ).normalize();
const liftAxis = new THREE.Vector3().crossVectors(outward, new THREE.Vector3(0, 1, 0)).normalize();
const phase = ((pi + (side > 0 ? 0 : 1)) % 2) * Math.PI + pi * 0.3;
legs.push({ group: legGroup, liftAxis, phase });
});
});
group.position.set(0, 0.2, 0);
group.scale.setScalar(2.0);
return group;
}
// ── Pointer interaction: drag to spin, with inertia ──
let dragging = false, lastX = 0, spinVel = 0, userSpin = 0;
function onDown(x) { dragging = true; lastX = x; document.body.classList.add('dragging'); }
function onMove(x) {
if (!dragging) return;
spinVel = (x - lastX) * 0.006;
userSpin += spinVel;
lastX = x;
}
function onUp() { dragging = false; document.body.classList.remove('dragging'); }
window.addEventListener('pointerdown', e => onDown(e.clientX));
window.addEventListener('pointermove', e => onMove(e.clientX));
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
function init() {
initBackground();
animateBackground();
updateSize();
scene = new THREE.Scene();
scene.background = new THREE.Color(colors.dark3);
camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 1000);
updateCamera();
butterfly = createButterfly();
scene.add(butterfly);
animate();
}
const _q = new THREE.Quaternion();
function animate() {
requestAnimationFrame(animate);
const t = Date.now();
if (butterfly) {
// Auto-rotate plus user drag with inertia
if (!dragging) {
userSpin += spinVel;
spinVel *= 0.95;
}
butterfly.rotation.y = userSpin + t * 0.0004;
// The flap drives everything: slow, deep strokes
const beat = t * 0.0045;
const flap = Math.sin(beat) * 0.85 - 0.12;
const flapHind = Math.sin(beat - 0.35) * 0.70 - 0.10;
for (const w of wings) {
w.group.rotation.z = w.side * (w.fore ? flap : flapHind);
}
// Flight coupled to the wingbeat: the body surges up on the
// downstroke and pitches gently, over a slow wandering drift
butterfly.position.y = 0.2
+ Math.sin(beat + 1.2) * 0.10
+ Math.sin(t * 0.0009) * 0.15;
butterfly.rotation.x = Math.sin(beat + 0.8) * 0.05;
butterfly.rotation.z = Math.sin(t * 0.0013) * 0.02;
// Abdomen flexes against the beat
abdomenGroup.rotation.x = Math.sin(beat + Math.PI * 0.7) * 0.07;
// Legs drift, tucked at hover
for (const leg of legs) {
const lift = Math.sin(t * 0.0016 + leg.phase) * 0.03;
const swing = Math.sin(t * 0.0016 + leg.phase + Math.PI / 2) * 0.015;
_q.setFromAxisAngle(leg.liftAxis, lift);
leg.group.quaternion.copy(_q);
leg.group.rotateY(swing);
}
// Antennae feeling the air
for (const a of antennae) {
a.group.rotation.z = Math.sin(t * 0.0026 + a.side) * 0.07 * a.side;
a.group.rotation.x = Math.sin(t * 0.0031) * 0.05;
}
}
renderer.render(scene, camera);
}
window.addEventListener('resize', () => {
updateSize(); initBackground();
if (camera) { camera.aspect = window.innerWidth / window.innerHeight; updateCamera(); }
});
window.addEventListener('load', init);
</script>
</body>
</html>