• 01 - Critter Models 02
  • 02 - VantaJS Shaders
  • 03 - App Bundle 02
  • 04 - Cathedrals
  • 05 - App Bundle 01
  • 06 - Retroscopic
  • 07 - Games
  • 08 - Voronoi
  • 09 - Terrain
  • 10 - Critter Models 01
  • 11 - Chromatophores
  • 12 - Conformal Maps
  • 13 - Echo Logix
  • 14 - Music
  • 15 - Colour Theory
MIKE'S NOTEBOOK
Notes
Number:
Project:
01Critter Models 02
└ Locust└ Butterfly└ Cockroach└ Spider└ Scorpion└ Dragon└ Brazilian Treehopper└ Tardigrade└ Diatoms└ Bumblebee└ Wasp└ Hammerhead Worm└ Giraffe Weevil└ Trilobite Beetle
02VantaJS Shaders
└ Clouds└ Waves└ Cells└ Rings└ Halo└ Globe└ Net└ Dots└ Birds└ Trunk└ Topology└ About VantaJS
03App Bundle 02
└ Gutindex└ Insect Builder└ OpenLayers
04Cathedrals
└ Lattice└ Lost└ Lucent└ Lumen└ Glass└ Disco└ Hyperion└ Latent
05App Bundle 01
└ Robohash└ PrismJS└ Horoscope└ Binary Jazz└ Morse Code└ URL Extractor└ Entity Escaper└ Phonetic Alphabet└ Toki Pona└ Semaphore└ Cut Up Machine└ Text Mixer└ Currency Freaks└ Host IO└ CrUX API└ Logo Dev└ Web Speech API└ GEO DB
06Retroscopic
└ Galaxy└ Ocean└ Slime└ 24-Cell└ Unicorn└ Polyp└ Oneiric 1└ Oneiric 2└ Node└ Torus Knot└ GLSL Grid└ Liquid Crystal└ Silky└ Butterfly└ Ocean 2└ Icosahedron└ Luma└ Swarm└ Broken Solid└ Hypersphere└ Constraints└ Psycho Killer
01 / 03

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>
MikeHiggins.me.uk LinkedIn GitHub Bluesky Contra Upwork
© 2026 Mike Higgins. All rights reserved.