BRAZILIAN TREEHOPPER - 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>Brazilian Treehopper</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; }
</style>
</head>
<body>
<canvas id="bgCanvas"></canvas>
<canvas id="animationCanvas"></canvas>
<script>
const TAU = Math.PI*2;
const GOLD = Math.PI*(3-Math.sqrt(5));
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, hopper;
const iriMats = [];
const globes = [], antennae = [], legParts = [], ghostWings = [];
let helmet, hub, cluster, spikeArm, wingL, wingR;
let hueShift = 0;
let buzzLevel = 0, buzzUntil = 0, nextBuzz = 2.5;
function updateSize(){
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
}
const cam = { dist: 4.5, yaw: 0.75, pitch: 0.12, targetYaw: 0.75, targetPitch: 0.12, targetDist: 4.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.9,
Math.cos(total)*Math.cos(p)*cam.dist
);
camera.lookAt(0, 0.75, 0);
}
function iri(offset, opts={}){
const { sat=0.85, light=0.5, roughness=0.32, metalness=0.55, 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 dimpledSphere(r, amp, nPhi, nTheta){
const geo = new THREE.SphereGeometry(r, 60, 44);
const pos = geo.attributes.position, v = new THREE.Vector3();
for (let i=0;i<pos.count;i++){
v.fromBufferAttribute(pos, i);
const len = v.length() || 1;
let c = v.y/len; c = c<-1?-1:c>1?1:c;
const phi = Math.acos(c);
const theta = Math.atan2(v.z, v.x);
const dim = (0.5+0.5*Math.cos(phi*nPhi)) * (0.5+0.5*Math.cos(theta*nTheta));
v.multiplyScalar(1 - amp*dim);
pos.setXYZ(i, v.x, v.y, v.z);
}
pos.needsUpdate = true;
geo.computeVertexNormals();
return geo;
}
function makeGlobe(r, hue, nHair=46){
const g = new THREE.Group();
g.add(new THREE.Mesh(dimpledSphere(r, 0.16, 11, 13), iri(hue, { roughness:0.38, metalness:0.5 })));
const hm = iri(hue+0.04, { roughness:0.5, metalness:0.35, light:0.62 });
for (let i=0;i<nHair;i++){
const y = 1 - (i/(nHair-1))*2;
const rad = Math.sqrt(Math.max(0,1-y*y));
const th = i*GOLD;
const dir = new THREE.Vector3(Math.cos(th)*rad, y, Math.sin(th)*rad);
const L = r*(1.5 + (i%3)*0.35);
const tip = dir.clone().multiplyScalar(L).add(new THREE.Vector3(0, r*0.15, 0));
g.add(segmentBetween(dir.clone().multiplyScalar(r*0.95), tip, r*0.035, 0.004, hm, 5));
}
return g;
}
function makeProcess(points, r0, r1, hue, hairs=26, hairLen=0.34){
const g = new THREE.Group();
const curve = new THREE.CatmullRomCurve3(points);
const N = 60;
for (let i=0;i<N;i++){
const t0=i/N, t1=(i+1)/N;
const a=curve.getPoint(t0), b=curve.getPoint(t1);
g.add(segmentBetween(a, b, r0+(r1-r0)*t0, r0+(r1-r0)*t1, iri(hue + t0*0.12, { roughness:0.4, metalness:0.5 }), 12));
}
const hm = iri(hue+0.06, { roughness:0.5, metalness:0.35, light:0.62 });
for (let i=1;i<hairs;i++){
const t = i/hairs;
const p = curve.getPoint(t), tan = curve.getTangent(t);
const perp = new THREE.Vector3(-tan.z, 0, tan.x).normalize();
const up = new THREE.Vector3().crossVectors(tan, perp).normalize();
for (let k=0;k<3;k++){
const a = (k/3)*TAU + t*3.0;
const dir = perp.clone().multiplyScalar(Math.cos(a)).add(up.clone().multiplyScalar(Math.sin(a))).normalize();
const L = hairLen*(0.7 + (i%3)*0.28);
g.add(segmentBetween(p, p.clone().add(dir.multiplyScalar(L)), 0.012, 0.002, hm, 4));
}
}
return g;
}
function makeWing(side, hue, ghost){
const matStart = iriMats.length;
const len = 2.9, wid = 0.92;
const sh = new THREE.Shape();
sh.moveTo(0,0);
sh.bezierCurveTo(len*0.2, wid*0.75, len*0.55, wid, len*0.8, wid*0.72);
sh.bezierCurveTo(len*0.94, wid*0.5, len, wid*0.15, len*0.99, -wid*0.02);
sh.bezierCurveTo(len*0.85,-wid*0.34, len*0.4,-wid*0.4, 0, 0);
const w = new THREE.Group();
w.add(new THREE.Mesh(new THREE.ShapeGeometry(sh, 40),
iri(hue, { opacity:0.55, transparent:true, roughness:0.25, metalness:0.4, light:0.6 })));
const vm = iri(hue+0.07, { roughness:0.35, metalness:0.5, light:0.66 });
const veins = [
[[0.02,0.0],[0.55,0.14],[0.99,0.0]],
[[0.02,0.0],[0.45,0.42],[0.82,0.62]],
[[0.05,-0.02],[0.5,-0.2],[0.88,-0.12]],
[[0.3,0.05],[0.45,0.3],[0.6,0.52]],
[[0.55,0.02],[0.66,0.26],[0.78,0.5]],
[[0.72,-0.02],[0.82,0.18],[0.9,0.36]],
];
veins.forEach(v=>{
const c = new THREE.QuadraticBezierCurve3(
new THREE.Vector3(v[0][0]*len, v[0][1]*wid, 0.012),
new THREE.Vector3(v[1][0]*len, v[1][1]*wid, 0.012),
new THREE.Vector3(v[2][0]*len, v[2][1]*wid, 0.012));
w.add(new THREE.Mesh(new THREE.TubeGeometry(c, 16, 0.016, 6, false), vm));
});
w.position.set(0.10, 0, 0);
w.rotation.set(-0.15, Math.PI/2 - 0.22, 0);
const roll = new THREE.Group();
roll.position.set(0.10, 0.34, 0.34);
roll.rotation.z = 0.62;
roll.add(w);
const wrap = new THREE.Group();
wrap.add(roll);
if (side < 0) wrap.scale.x = -1;
wrap.userData.roll = roll;
wrap.userData.mats = iriMats.slice(matStart);
if (ghost){
wrap.userData.mats.forEach(m=>{ m.transparent = true; m.opacity = 0; m.depthWrite = false; });
wrap.visible = false;
}
return wrap;
}
function makeLeg(cfg){
const s = cfg.side, m = iri(cfg.hue, { roughness:0.45, metalness:0.4, light:0.6 });
const hm = iri(cfg.hue+0.05, { roughness:0.55, metalness:0.3, light:0.66 });
const root = new THREE.Group();
root.position.set(s*cfg.x, cfg.y, cfg.z);
root.rotation.y = cfg.yaw||0;
const lift = new THREE.Group();
lift.rotation.z = s*cfg.out;
root.add(lift);
const A = new THREE.Vector3(s*cfg.f, -cfg.fdrop, 0);
lift.add(segmentBetween(new THREE.Vector3(0,0,0), A, 0.052, 0.034, m, 12));
const knee = new THREE.Group(); knee.position.copy(A); lift.add(knee);
const B = new THREE.Vector3(s*cfg.t, -cfg.tdrop, 0.03);
knee.add(segmentBetween(new THREE.Vector3(0,0,0), B, 0.034, 0.020, m, 12));
const C = B.clone().add(new THREE.Vector3(s*0.14, -0.18, 0.02));
knee.add(segmentBetween(B, C, 0.018, 0.009, m, 10));
for (let i=1;i<7;i++){
const p = new THREE.Vector3().lerpVectors(new THREE.Vector3(0,0,0), B, i/7);
[-1,1].forEach(d=>{
knee.add(segmentBetween(p, p.clone().add(new THREE.Vector3(s*0.04*d, -0.09, 0.03*d)), 0.009, 0.001, hm, 4));
});
}
for (let i=1;i<5;i++){
const p = new THREE.Vector3().lerpVectors(new THREE.Vector3(0,0,0), A, i/5);
lift.add(segmentBetween(p, p.clone().add(new THREE.Vector3(s*0.03, -0.08, 0.02)), 0.009, 0.001, hm, 4));
}
legParts.push({ root, lift, knee, side:s, baseYaw:cfg.yaw||0, baseOut:s*cfg.out, phase:cfg.phase||0, swing:cfg.swing??0.15 });
return root;
}
function createHopper(){
const g = new THREE.Group();
const head = new THREE.Mesh(new THREE.SphereGeometry(0.30, 32, 26), iri(0.02, { roughness:0.4 }));
head.scale.set(1.05, 0.95, 0.9); head.position.set(0, 0.06, 1.06); g.add(head);
[-1,1].forEach(sx=>{
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.155, 26, 22), iri(0.10, { roughness:0.2, metalness:0.65, light:0.55 }));
eye.position.set(sx*0.27, 0.10, 1.05); eye.scale.set(0.9,1,0.95); g.add(eye);
});
const face = new THREE.Mesh(new THREE.SphereGeometry(0.2, 24, 20), iri(0.0, { roughness:0.45, light:0.46 }));
face.scale.set(0.9, 1.05, 0.6); face.position.set(0, -0.08, 1.22); g.add(face);
[-1,1].forEach(sx=>{
const an = new THREE.Group();
an.position.set(sx*0.14, -0.06, 1.3);
an.add(segmentBetween(new THREE.Vector3(0,0,0), new THREE.Vector3(sx*0.12,0.06,0.16), 0.022,0.006, iri(0.9,{light:0.6}), 8));
g.add(an); antennae.push({ an });
});
const shield = new THREE.Mesh(dimpledSphere(0.56, 0.10, 9, 14), iri(0.06, { roughness:0.4, metalness:0.5 }));
shield.scale.set(1.0, 0.95, 1.25);
shield.position.set(0, 0.16, 0.46);
g.add(shield);
[-1,1].forEach(sx=>{
const sh = new THREE.Mesh(new THREE.SphereGeometry(0.19, 20, 16), iri(0.04, { roughness:0.45 }));
sh.position.set(sx*0.5, 0.02, 0.66); sh.scale.set(1,0.8,1.1); g.add(sh);
});
for (let i=0;i<5;i++){
const t = i/4;
const r = 0.30*(1-t*0.6);
const seg = new THREE.Mesh(new THREE.SphereGeometry(r, 22, 18), iri(0.14 + t*0.2, { roughness:0.45, light:0.46 }));
seg.position.set(0, -0.02 - t*0.10, 0.10 - i*0.30);
seg.scale.set(1,0.9,1.05); g.add(seg);
}
wingL = makeWing(-1, 0.46); wingR = makeWing(1, 0.46);
g.add(wingL); g.add(wingR);
for (let k=0;k<2;k++){
const gl = makeWing(-1, 0.46, true), gr = makeWing(1, 0.46, true);
g.add(gl); g.add(gr);
ghostWings.push({ wrap:gl, lag:(k+1)*0.85 }, { wrap:gr, lag:(k+1)*0.85 });
}
const legPairs = [
{ x:0.26, y:-0.10, z:0.78, out:0.42, yaw:-0.75, f:0.30, fdrop:0.36, t:0.32, tdrop:0.60, hue:0.16, swing:0.15, phase:0 },
{ x:0.30, y:-0.10, z:0.40, out:0.46, yaw: 0.00, f:0.32, fdrop:0.38, t:0.36, tdrop:0.64, hue:0.14, swing:0.15, phase:Math.PI },
{ x:0.32, y:-0.10, z:0.00, out:0.50, yaw: 0.80, f:0.42, fdrop:0.42, t:0.54, tdrop:0.74, hue:0.12, swing:0.17, phase:0 },
];
legPairs.forEach(c=>{
g.add(makeLeg(Object.assign({}, c, { side: 1 })));
g.add(makeLeg(Object.assign({}, c, { side:-1, yaw:-c.yaw, phase:c.phase + Math.PI })));
});
helmet = new THREE.Group();
g.add(helmet);
helmet.add(makeProcess([
new THREE.Vector3(0, 0.30, 0.50),
new THREE.Vector3(0, 0.72, 0.60),
new THREE.Vector3(0, 1.15, 0.66),
new THREE.Vector3(0, 1.52, 0.60),
], 0.105, 0.055, 0.20, 22, 0.30));
hub = new THREE.Group();
hub.position.set(0, 1.56, 0.58);
helmet.add(hub);
hub.add(new THREE.Mesh(new THREE.SphereGeometry(0.09, 18, 14), iri(0.22, { roughness:0.4 })));
cluster = new THREE.Group();
hub.add(cluster);
const cDefs = [
{ p:new THREE.Vector3( 0.00, 0.30, 0.30), r:0.29, hue:0.00 },
{ p:new THREE.Vector3( 0.00, 0.34, -0.26), r:0.31, hue:0.12 },
{ p:new THREE.Vector3(-0.30, 0.10, 0.02), r:0.26, hue:0.30 },
{ p:new THREE.Vector3( 0.30, 0.10, 0.02), r:0.26, hue:0.42 },
];
cDefs.forEach(d=>{
cluster.add(segmentBetween(new THREE.Vector3(0,0,0), d.p, 0.05, 0.035, iri(0.22,{roughness:0.4}), 10));
const globe = makeGlobe(d.r, d.hue);
globe.position.copy(d.p);
cluster.add(globe);
globes.push({ group:globe, base:d.p.clone(), phase:Math.random()*TAU });
});
spikeArm = new THREE.Group();
hub.add(spikeArm);
spikeArm.add(makeProcess([
new THREE.Vector3(0, 0.06, -0.10),
new THREE.Vector3(0, 0.16, -0.75),
new THREE.Vector3(0, 0.06, -1.55),
new THREE.Vector3(0, -0.20, -2.45),
new THREE.Vector3(0, -0.52, -3.20),
], 0.062, 0.004, 0.62, 30, 0.30));
const spikeGlobe = makeGlobe(0.27, 0.76);
spikeGlobe.position.set(0, 0.14, -0.62);
spikeArm.add(spikeGlobe);
globes.push({ group:spikeGlobe, base:spikeGlobe.position.clone(), phase:Math.random()*TAU });
g.position.y = 0.05;
g.scale.setScalar(0.92);
return g;
}
function init(){
initBackground(); updateSize();
scene = new THREE.Scene(); scene.background = null;
camera = new THREE.PerspectiveCamera(46, innerWidth/innerHeight, 0.1, 1000);
updateCamera();
hopper = createHopper();
scene.add(hopper);
scene.add(new THREE.HemisphereLight(0xffffff, 0x101018, 0.55));
const key = new THREE.DirectionalLight(0xffffff, 1.35); key.position.set(4,8,6); scene.add(key);
const rim = new THREE.PointLight(0x33ccff, 1.5, 70); rim.position.set(-7,4,-4); scene.add(rim);
const rim2 = new THREE.PointLight(0xff33aa, 1.3, 70); rim2.position.set(7,-1,-3); scene.add(rim2);
const under= new THREE.PointLight(0x8855ff, 0.6, 50); under.position.set(0,-5,3); scene.add(under);
const glint= new THREE.PointLight(0xffffff, 1.0, 55); glint.position.set(2,6,9); scene.add(glint);
addPointerControls(); animate();
}
function animate(){
requestAnimationFrame(animate);
const t = performance.now() * 0.001;
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); }
if (hopper){
hopper.position.x = 0;
hopper.position.y = 0.05 + Math.sin(t*1.1)*0.04;
hopper.rotation.z = Math.sin(t*0.8)*0.025;
hopper.rotation.x = Math.sin(t*0.6)*0.018;
}
if (helmet) helmet.rotation.z = Math.sin(t*1.15)*0.035;
if (hub) hub.rotation.x = Math.sin(t*0.95)*0.045;
if (cluster){ cluster.rotation.x = Math.sin(t*1.5)*0.07; cluster.rotation.y = Math.sin(t*1.05)*0.06; }
if (spikeArm){ spikeArm.rotation.x = Math.sin(t*0.85)*0.05; spikeArm.rotation.z = Math.sin(t*1.3)*0.03; }
for (let i=0;i<globes.length;i++){
const gl = globes[i];
gl.group.rotation.y += 0.004;
gl.group.position.copy(gl.base).add(new THREE.Vector3(0, Math.sin(t*2.1+gl.phase)*0.022, 0));
}
for (let i=0;i<antennae.length;i++){ antennae[i].an.rotation.z = Math.sin(t*4+i)*0.18; antennae[i].an.rotation.x = Math.cos(t*3+i)*0.12; }
const walk = t * 1.1;
for (let i=0;i<legParts.length;i++){
const L = legParts[i];
const ph = walk + L.phase;
const recover = Math.max(0, Math.cos(ph));
const ease = recover*recover*(3 - 2*recover);
L.root.rotation.y = L.baseYaw + L.side * L.swing * Math.sin(ph);
L.lift.rotation.z = L.baseOut + L.side * 0.26 * ease;
L.knee.rotation.z = L.side * 0.20 * ease;
}
if (t > nextBuzz){
buzzUntil = t + 0.5 + Math.random()*1.3;
nextBuzz = buzzUntil + 1.8 + Math.random()*3.5;
}
const buzzing = t < buzzUntil;
buzzLevel += ((buzzing ? 1 : 0) - buzzLevel) * 0.14;
const BUZZ_HZ = 14;
const beat = t * BUZZ_HZ * TAU;
const restRoll = 0.62;
const openRoll = 0.20;
const baseRoll = restRoll + (openRoll - restRoll) * buzzLevel;
const amp = 0.62 * buzzLevel;
const wingAngle = k => baseRoll + amp * Math.sin(beat - k)
+ (1 - buzzLevel) * Math.sin(t*1.6) * 0.025;
if (wingR) wingR.userData.roll.rotation.z = wingAngle(0);
if (wingL) wingL.userData.roll.rotation.z = wingAngle(0);
for (let i=0;i<ghostWings.length;i++){
const gw = ghostWings[i];
gw.wrap.visible = buzzLevel > 0.02;
gw.wrap.userData.roll.rotation.z = wingAngle(gw.lag);
const o = 0.22 * buzzLevel;
for (let j=0;j<gw.wrap.userData.mats.length;j++) gw.wrap.userData.mats[j].opacity = o;
}
if (hopper && buzzLevel > 0.001){
hopper.position.x += Math.sin(t*BUZZ_HZ*TAU*0.5)*0.012*buzzLevel;
hopper.position.y += 0.06*buzzLevel + Math.sin(beat*0.5)*0.008*buzzLevel;
hopper.rotation.z += Math.sin(beat*0.33)*0.012*buzzLevel;
}
if (!dragging) cam.autoYaw += 0.0016;
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(18, cam.targetDist + e.deltaY*0.01)); }, {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(18, 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>