← Mike Higgins Home
Custom Code Experiment •

Ophanim

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, user-scalable=no">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{overflow:hidden;background:#000;cursor:crosshair}
canvas{display:block}
</style>
</head>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);
scene.fog = new THREE.FogExp2(0x000000, 0.003);

const camera = new THREE.PerspectiveCamera(72, innerWidth/innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({antialias:true});
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
document.body.appendChild(renderer.domElement);

scene.add(new THREE.AmbientLight(0xffffff, 0.4));
const sun = new THREE.DirectionalLight(0xffffff, 0.9);
sun.position.set(1,2,1.5); scene.add(sun);
const backLight = new THREE.PointLight(0xffaa66, 0.7);
backLight.position.set(-30,20,-40); scene.add(backLight);
const fillLight = new THREE.PointLight(0x66aaff, 0.4);
fillLight.position.set(0,-60,0); scene.add(fillLight);
const movingLight = new THREE.PointLight(0xff8855, 0.6);
movingLight.position.set(40,30,50); scene.add(movingLight);
const pulseLight = new THREE.PointLight(0xcc44ff, 0.0);
pulseLight.position.set(0,0,0); scene.add(pulseLight);

const ATTRACT_DIST = 28, REPULSE_DIST = 14;
const ATTRACT_STR = 0.00012, REPULSE_STR = 0.018;
const DAMPING = 0.88, ANCHOR_STR = 0.012;

const netGroup = new THREE.Group();
scene.add(netGroup);

const LAYERS = 10, NODES = 15, R = 50;
const nodes = [], lineObjects = [], layersNodes = [];

for (let i = 0; i < LAYERS; i++) {
  const layerArr = [];
  const theta = (Math.PI*2*i)/LAYERS;
  for (let j = 0; j < NODES; j++) {
    const phi = (Math.PI*2*j)/NODES;
    const ox = R*Math.sin(theta)*Math.cos(phi);
    const oy = R*Math.sin(theta)*Math.sin(phi);
    const oz = R*Math.cos(theta);
    const baseRadius = 0.45 + Math.random()*1.8;
    const mat = new THREE.MeshPhongMaterial({color:0xFF9C66, shininess:90, emissive:new THREE.Color(0x221100)});
    const mesh = new THREE.Mesh(new THREE.SphereGeometry(baseRadius,18,12), mat);
    mesh.position.set(ox,oy,oz);
    netGroup.add(mesh);

    if (baseRadius > 1.5) {
      for (let k = 0; k < 3; k++) {
        const ang = (Math.PI*2*k)/3;
        const sMat = new THREE.MeshPhongMaterial({color:0xcc44ff,shininess:120,emissive:new THREE.Color(0x220033),transparent:true,opacity:0.85});
        const sMesh = new THREE.Mesh(new THREE.SphereGeometry(baseRadius*0.28,8,6), sMat);
        const sx = ox+Math.cos(ang)*baseRadius*2.2;
        const sy = oy+Math.sin(ang)*baseRadius*2.2;
        sMesh.position.set(sx,sy,oz);
        netGroup.add(sMesh);
        const sg = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(ox,oy,oz),new THREE.Vector3(sx,sy,oz)]);
        netGroup.add(new THREE.Line(sg, new THREE.LineBasicMaterial({color:0xcc44ff,transparent:true,opacity:0.22})));
        if (k>0) {
          const pa = (Math.PI*2*(k-1))/3;
          const px2=ox+Math.cos(pa)*baseRadius*2.2, py2=oy+Math.sin(pa)*baseRadius*2.2;
          const sg2=new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(sx,sy,oz),new THREE.Vector3(px2,py2,oz)]);
          netGroup.add(new THREE.Line(sg2, new THREE.LineBasicMaterial({color:0x8833ff,transparent:true,opacity:0.15})));
        }
      }
    }

    const nd = {
      mesh, mat,
      origin: new THREE.Vector3(ox,oy,oz),
      vel: new THREE.Vector3((Math.random()-0.5)*0.1,(Math.random()-0.5)*0.1,(Math.random()-0.5)*0.1),
      phase: Math.random()*Math.PI*2,
      baseRadius,
      dragged: false,
      dragTarget: null
    };
    nodes.push(nd);
    layerArr.push(nd);
  }
  layersNodes.push(layerArr);
}

for (let li = 1; li < LAYERS; li++) {
  const prev = layersNodes[li-1], curr = layersNodes[li];
  for (const cn of curr) {
    for (const pn of prev) {
      const hot = Math.random() < 0.07;
      const pts = [cn.mesh.position.clone(), pn.mesh.position.clone()];
      const geo = new THREE.BufferGeometry().setFromPoints(pts);
      const mat = hot
        ? new THREE.LineBasicMaterial({color:0xff6633,transparent:true,opacity:0.0})
        : new THREE.LineBasicMaterial({color:0xffffff,transparent:true,opacity:0.14});
      const line = new THREE.Line(geo, mat);
      netGroup.add(line);
      lineObjects.push({line, a:cn, b:pn, hot});
    }
  }
}

// Elastic drag threads — visual rubber bands from dragged node to its origin
const elasticLines = [];
for (let i = 0; i < nodes.length; i++) {
  const geo = new THREE.BufferGeometry().setFromPoints([nodes[i].mesh.position.clone(), nodes[i].origin.clone()]);
  const mat = new THREE.LineBasicMaterial({color:0x00ffcc, transparent:true, opacity:0.0, linewidth:1});
  const line = new THREE.Line(geo, mat);
  netGroup.add(line);
  elasticLines.push(line);
}

const starPos = new Float32Array(1800*3);
for (let i=0;i<1800;i++){
  starPos[i*3]=(Math.random()-0.5)*700;
  starPos[i*3+1]=(Math.random()-0.5)*500;
  starPos[i*3+2]=(Math.random()-0.5)*400-100;
}
const starGeo = new THREE.BufferGeometry();
starGeo.setAttribute('position',new THREE.BufferAttribute(starPos,3));
const stars = new THREE.Points(starGeo,new THREE.PointsMaterial({color:0xaaccff,size:0.26,transparent:true,opacity:0.45}));
scene.add(stars);

const auraSphere = new THREE.Mesh(
  new THREE.SphereGeometry(72,32,16),
  new THREE.MeshBasicMaterial({color:0x336699,wireframe:true,transparent:true,opacity:0.055})
);
scene.add(auraSphere);

function buildMobius(majorR,minorR,twists,color,opacity){
  const segs=200,tube=14,verts=[],idx=[];
  for(let i=0;i<=segs;i++){
    const u=(i/segs)*Math.PI*2, twist=(twists*u)/2;
    for(let j=0;j<=tube;j++){
      const v=(j/tube)*Math.PI*2;
      const cv=Math.cos(v+twist),sv=Math.sin(v+twist),cu=Math.cos(u),su=Math.sin(u);
      verts.push((majorR+minorR*cv)*cu,(majorR+minorR*cv)*su,minorR*sv);
    }
  }
  for(let i=0;i<segs;i++) for(let j=0;j<tube;j++){
    const a=i*(tube+1)+j,b=a+tube+1;
    idx.push(a,b,a+1,b,b+1,a+1);
  }
  const geo=new THREE.BufferGeometry();
  geo.setAttribute('position',new THREE.BufferAttribute(new Float32Array(verts),3));
  geo.setIndex(idx); geo.computeVertexNormals();
  return new THREE.Mesh(geo,new THREE.MeshPhongMaterial({
    color,transparent:true,opacity,side:THREE.DoubleSide,
    shininess:80,emissive:new THREE.Color(color),emissiveIntensity:0.1
  }));
}

const escherGroup = new THREE.Group();
scene.add(escherGroup);
const r1 = buildMobius(80,2.0,1,0x8833ff,0.22);
escherGroup.add(r1);
const r2wrap = new THREE.Group(); r2wrap.rotation.x=Math.PI/2;
r2wrap.add(buildMobius(80,1.4,3,0xff3388,0.18)); escherGroup.add(r2wrap);
const r3wrap = new THREE.Group(); r3wrap.rotation.z=Math.PI/3;
r3wrap.add(buildMobius(80,1.0,5,0x33aaff,0.14)); escherGroup.add(r3wrap);
const innerRing = buildMobius(30,1.1,1,0xcc66ff,0.30); escherGroup.add(innerRing);
const innerInner = buildMobius(11,0.55,1,0xff99cc,0.34); escherGroup.add(innerInner);

// Mouse / drag system
const raycaster = new THREE.Raycaster();
raycaster.params.Points = {threshold:2};
const mouse = new THREE.Vector2();
let dragNode = null;
let dragPlane = new THREE.Plane();
let dragOffset = new THREE.Vector3();
let camPaused = false;
let camAngle = 0;
let camAnglePaused = 0;

const meshToNode = new Map();
for (const nd of nodes) meshToNode.set(nd.mesh, nd);

function getNDC(clientX, clientY) {
  return new THREE.Vector2(
    (clientX/innerWidth)*2-1,
    -(clientY/innerHeight)*2+1
  );
}

function pickNode(ndc) {
  raycaster.setFromCamera(ndc, camera);
  const meshes = nodes.map(n=>n.mesh);
  const hits = raycaster.intersectObjects(meshes);
  if (hits.length > 0) return meshToNode.get(hits[0].object);
  return null;
}

renderer.domElement.addEventListener('mousedown', e => {
  mouse.copy(getNDC(e.clientX, e.clientY));
  const nd = pickNode(mouse);
  if (nd) {
    dragNode = nd;
    nd.dragged = true;
    nd.vel.set(0,0,0);
    // drag plane faces camera
    const camDir = camera.position.clone().normalize();
    dragPlane.setFromNormalAndCoplanarPoint(camDir, nd.mesh.position);
    camPaused = true;
    camAnglePaused = camAngle;
    document.body.style.cursor = 'grabbing';
  }
});

renderer.domElement.addEventListener('mousemove', e => {
  if (!dragNode) return;
  mouse.copy(getNDC(e.clientX, e.clientY));
  raycaster.setFromCamera(mouse, camera);
  const target = new THREE.Vector3();
  raycaster.ray.intersectPlane(dragPlane, target);
  if (target) {
    dragNode.dragTarget = target.clone();
  }
});

renderer.domElement.addEventListener('mouseup', () => {
  if (dragNode) {
    // release: give node a flick velocity from stretch
    const stretch = new THREE.Vector3().subVectors(dragNode.mesh.position, dragNode.origin);
    dragNode.vel.copy(stretch.multiplyScalar(-0.08));
    dragNode.dragged = false;
    dragNode.dragTarget = null;
    dragNode = null;
    camPaused = false;
    document.body.style.cursor = 'crosshair';
  }
});

// Touch support
renderer.domElement.addEventListener('touchstart', e => {
  const t = e.touches[0];
  mouse.copy(getNDC(t.clientX, t.clientY));
  const nd = pickNode(mouse);
  if (nd) {
    dragNode = nd;
    nd.dragged = true;
    nd.vel.set(0,0,0);
    const camDir = camera.position.clone().normalize();
    dragPlane.setFromNormalAndCoplanarPoint(camDir, nd.mesh.position);
    camPaused = true;
  }
}, {passive:true});

renderer.domElement.addEventListener('touchmove', e => {
  if (!dragNode) return;
  const t = e.touches[0];
  mouse.copy(getNDC(t.clientX, t.clientY));
  raycaster.setFromCamera(mouse, camera);
  const target = new THREE.Vector3();
  raycaster.ray.intersectPlane(dragPlane, target);
  if (target) dragNode.dragTarget = target.clone();
}, {passive:true});

renderer.domElement.addEventListener('touchend', () => {
  if (dragNode) {
    const stretch = new THREE.Vector3().subVectors(dragNode.mesh.position, dragNode.origin);
    dragNode.vel.copy(stretch.multiplyScalar(-0.08));
    dragNode.dragged = false;
    dragNode.dragTarget = null;
    dragNode = null;
    camPaused = false;
  }
}, {passive:true});

const _tmp = new THREE.Vector3();

function physicsStep(t) {
  for (let ai=0;ai<nodes.length;ai++) {
    const A = nodes[ai];
    const pos = A.mesh.position;

    // Pulse
    const pulse = Math.sin(t*0.0028+A.phase);
    A.mesh.scale.setScalar(1.0+pulse*0.32);
    const hot=(pulse+1)*0.5;
    A.mat.emissive.setRGB(hot*0.4,hot*0.08,0);

    if (A.dragged && A.dragTarget) {
      // Elastic: smoothly pull toward drag target
      const diff = new THREE.Vector3().subVectors(A.dragTarget, pos);
      A.vel.add(diff.multiplyScalar(0.18));
    } else {
      // Normal physics
      for (let bi=ai+1;bi<nodes.length;bi++) {
        const B = nodes[bi];
        _tmp.subVectors(B.mesh.position, pos);
        const dist = _tmp.length();
        if (dist < 0.001) continue;
        let force = 0;
        if (dist < REPULSE_DIST) force = -REPULSE_STR/(dist*dist);
        else if (dist < ATTRACT_DIST) force = ATTRACT_STR*(dist-REPULSE_DIST);
        if (force === 0) continue;
        const nx=_tmp.x/dist*force, ny=_tmp.y/dist*force, nz=_tmp.z/dist*force;
        A.vel.x+=nx; A.vel.y+=ny; A.vel.z+=nz;
        B.vel.x-=nx; B.vel.y-=ny; B.vel.z-=nz;
      }
      A.vel.x+=(A.origin.x-pos.x)*ANCHOR_STR;
      A.vel.y+=(A.origin.y-pos.y)*ANCHOR_STR;
      A.vel.z+=(A.origin.z-pos.z)*ANCHOR_STR;
    }
    A.vel.multiplyScalar(DAMPING);
    pos.add(A.vel);
  }
}

function updateLines(t) {
  for (const {line,a,b,hot} of lineObjects) {
    const pa=line.geometry.attributes.position;
    pa.setXYZ(0,a.mesh.position.x,a.mesh.position.y,a.mesh.position.z);
    pa.setXYZ(1,b.mesh.position.x,b.mesh.position.y,b.mesh.position.z);
    pa.needsUpdate=true;
    if (hot) line.material.opacity=0.1+Math.abs(Math.sin(t*0.003+a.phase))*0.6;
  }
  // Elastic threads
  for (let i=0;i<nodes.length;i++) {
    const nd = nodes[i];
    const el = elasticLines[i];
    const pa = el.geometry.attributes.position;
    pa.setXYZ(0,nd.mesh.position.x,nd.mesh.position.y,nd.mesh.position.z);
    pa.setXYZ(1,nd.origin.x,nd.origin.y,nd.origin.z);
    pa.needsUpdate=true;
    // Only show elastic thread when node is displaced beyond threshold
    const stretch = nd.mesh.position.distanceTo(nd.origin);
    el.material.opacity = nd.dragged
      ? Math.min(0.9, stretch*0.06)
      : Math.max(0, el.material.opacity - 0.04);
    // Color shifts from cyan to hot orange as stretch increases
    if (stretch > 2) {
      const t2 = Math.min(1, stretch/30);
      el.material.color.setRGB(1-t2*0.5, 0.4+t2*0.3, 1-t2);
    }
  }
}

function animate() {
  requestAnimationFrame(animate);
  const t = Date.now();
  if (!camPaused) camAngle += 0.0021;
  camera.position.set(
    115*Math.sin(camAngle)*Math.cos(Math.PI/9),
    115*Math.sin(Math.PI/9),
    115*Math.cos(camAngle)*Math.cos(Math.PI/9)
  );
  camera.lookAt(0,0,0);

  physicsStep(t);
  updateLines(t);

  escherGroup.rotation.y+=0.0017;
  r1.rotation.y+=0.0008;
  r2wrap.rotation.z+=0.0012;
  r3wrap.rotation.x+=0.001;
  innerRing.rotation.z+=0.003;
  innerInner.rotation.y+=0.006;
  innerInner.rotation.x+=0.004;

  pulseLight.intensity=Math.max(0,Math.sin(t*0.0018)*0.8);
  pulseLight.color.setHSL((t*0.00005)%1,0.8,0.5);
  movingLight.position.x=48*Math.sin(t*0.0006);
  movingLight.position.z=48*Math.cos(t*0.0007);
  movingLight.intensity=0.5+Math.sin(t*0.002)*0.2;

  stars.rotation.y+=0.00035;
  auraSphere.rotation.y+=0.0005;

  renderer.render(scene, camera);
}
animate();

window.addEventListener('resize',()=>{
  renderer.setSize(innerWidth,innerHeight);
  camera.aspect=innerWidth/innerHeight;
  camera.updateProjectionMatrix();
});
</script>
</body>
</html>