Custom Code Experiment •
Hexahedron
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">
<title>Hexahedron</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html,body { width:100%; height:100vh; background:#000; overflow:hidden; font-family:sans-serif; }
canvas#bg { position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; }
/* Controls sit at the bottom so they never fight the centred circle */
#controls {
position:absolute; bottom:20px; left:50%; transform:translateX(-50%);
display:flex; align-items:center; gap:18px;
background:rgba(0,0,0,0.65); border:0.5px solid rgba(200,100,255,0.3);
border-radius:14px; padding:12px 22px; z-index:10; white-space:nowrap;
}
.ctrl { display:flex; align-items:center; gap:10px; }
label {
color:rgba(255,255,255,0.45); font-size:11px; letter-spacing:0.08em;
user-select:none; -webkit-user-select:none; white-space:nowrap;
}
input[type=range] {
-webkit-appearance:none; appearance:none;
width:100px; height:40px; background:transparent;
cursor:pointer; touch-action:pan-y;
}
input[type=range]::-webkit-slider-runnable-track {
height:3px; border-radius:2px; background:rgba(200,100,255,0.3);
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance:none; appearance:none;
width:24px; height:24px; border-radius:50%; margin-top:-10.5px;
background:#cc44ff; border:2px solid rgba(255,255,255,0.25);
box-shadow:0 0 10px rgba(200,80,255,0.55);
}
input[type=range]::-moz-range-track {
height:3px; border-radius:2px; background:rgba(200,100,255,0.3);
}
input[type=range]::-moz-range-thumb {
width:24px; height:24px; border-radius:50%;
background:#cc44ff; border:2px solid rgba(255,255,255,0.25);
box-shadow:0 0 10px rgba(200,80,255,0.55);
}
.val { color:#cc66ff; font-size:11px; min-width:32px; text-align:right; }
/* Mobile: stack vertically */
@media (max-width:580px) {
#controls {
flex-direction:column; gap:4px; align-items:stretch;
bottom:12px; left:12px; right:12px;
transform:none; width:auto; padding:14px 16px; border-radius:16px;
}
.ctrl { justify-content:space-between; gap:10px; }
label { font-size:12px; min-width:58px; }
input[type=range] { flex:1; width:auto; }
input[type=range]::-webkit-slider-thumb { width:28px; height:28px; margin-top:-12.5px; }
input[type=range]::-moz-range-thumb { width:28px; height:28px; }
.val { font-size:12px; }
}
#info {
position:absolute; top:14px; left:50%; transform:translateX(-50%);
color:rgba(255,255,255,0.15); font-size:10px; letter-spacing:0.09em;
pointer-events:none; white-space:nowrap;
}
</style>
</head>
<body>
<canvas id="bg"></canvas>
<div id="controls">
<div class="ctrl">
<label>ENERGY</label>
<input id="energy" type="range" min="0" max="1" value="0.7" step="0.01">
<span class="val" id="ev">0.70</span>
</div>
<div class="ctrl">
<label>SPEED</label>
<input id="speed" type="range" min="0.1" max="2" value="1" step="0.05">
<span class="val" id="sv">1.00</span>
</div>
<div class="ctrl">
<label>CONNECT</label>
<input id="connect" type="range" min="0" max="1" value="0.6" step="0.01">
<span class="val" id="cv">0.60</span>
</div>
</div>
<div id="info">drag to orbit · scroll to zoom</div>
<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.158.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.158.0/examples/jsm/"}}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const bg = document.getElementById('bg');
const ctx = bg.getContext('2d');
const controlsEl = document.getElementById('controls');
const energyEl = document.getElementById('energy');
const speedEl = document.getElementById('speed');
const connectEl = document.getElementById('connect');
energyEl.addEventListener('input', () => { document.getElementById('ev').textContent = parseFloat(energyEl.value).toFixed(2); });
speedEl.addEventListener('input', () => { document.getElementById('sv').textContent = parseFloat(speedEl.value).toFixed(2); });
connectEl.addEventListener('input', () => { document.getElementById('cv').textContent = parseFloat(connectEl.value).toFixed(2); });
function resize() { bg.width = window.innerWidth; bg.height = window.innerHeight; }
resize();
// ── Circle layout ──────────────────────────────────────────────────────────
// The 3D cube always renders centred at W/2, H/2 (full viewport).
// The 2D circle matches that centre exactly.
// We shrink R so the circle doesn't overlap the bottom controls bar.
const CIRCLE_PAD = 24; // min gap between circle edge and controls bar
function circleLayout() {
const W = window.innerWidth, H = window.innerHeight;
const cx = W / 2, cy = H / 2;
// Controls bar top edge (in CSS px from top of viewport)
const navTop = H - controlsEl.getBoundingClientRect().height - 20; // approx top of bar
const ctrlRect = controlsEl.getBoundingClientRect();
// Max radius so circle bottom (cy + R) + pad <= controls top
const maxR = ctrlRect.top - cy - CIRCLE_PAD;
// Also cap so circle top doesn't clip screen
const R = Math.min(maxR, cy - CIRCLE_PAD, W * 0.46);
return { cx, cy, R: Math.max(R, 60) };
}
// ── Pulses ─────────────────────────────────────────────────────────────────
const pulses = [];
function spawnPulse(x1,y1,x2,y2,hue,spd) {
pulses.push({x1,y1,x2,y2,t:0,speed:spd*(0.006+Math.random()*0.01),hue,size:2+Math.random()*3});
}
// ── Outer ring nodes ───────────────────────────────────────────────────────
const OUTER_N = 18;
let outerNodes = [];
function buildOuterNodes() {
outerNodes = [];
for (let i=0; i<OUTER_N; i++) {
outerNodes.push({
baseAngle: (i/OUTER_N)*Math.PI*2,
drift: (Math.random()-0.5)*0.4,
driftSpeed: 0.3+Math.random()*0.4,
driftPhase: Math.random()*Math.PI*2,
radialPulse:Math.random()*Math.PI*2,
radialAmp: 0.04+Math.random()*0.07,
hue: 260+Math.floor(i/OUTER_N*140),
pulseTimer: Math.random()*200,
x:0, y:0
});
}
}
// ── Inner orbital nodes ────────────────────────────────────────────────────
const INNER_N = 8;
let innerNodes = [];
function buildInnerNodes(r) {
innerNodes = [];
for (let i=0; i<INNER_N; i++) {
innerNodes.push({
baseAngle: (i/INNER_N)*Math.PI*2,
orbitSpeed: (i%2===0?1:-1)*(0.2+Math.random()*0.15),
orbitR: r*(0.9+Math.random()*0.3),
phase: Math.random()*Math.PI*2,
hue: 180+Math.floor(i/INNER_N*120),
pulseTimer: Math.random()*200,
x:0, y:0
});
}
}
function updateOuterNodes(t,spd,cx,cy,R) {
outerNodes.forEach(n => {
n.r = R;
const wobble = n.drift*Math.sin(t*n.driftSpeed*spd+n.driftPhase);
const radial = 1+n.radialAmp*Math.sin(t*1.2*spd+n.radialPulse);
const a = n.baseAngle+wobble+t*0.012*spd;
n.x = cx+n.r*radial*Math.cos(a);
n.y = cy+n.r*radial*Math.sin(a);
n.pulseTimer += spd;
});
}
function updateInnerNodes(t,spd,cx,cy) {
innerNodes.forEach(n => {
const a = n.baseAngle+t*n.orbitSpeed*spd+n.phase;
n.x = cx+n.orbitR*Math.cos(a);
n.y = cy+n.orbitR*Math.sin(a);
n.pulseTimer += spd;
});
}
// ── 2D draw ────────────────────────────────────────────────────────────────
let projectedVerts = [];
function draw2D(t,spd,energy,connectStrength) {
const W = bg.width, H = bg.height;
const {cx,cy,R} = circleLayout();
const r = R*0.28;
ctx.fillStyle = `rgba(0,0,0,${0.14+(1-energy)*0.22})`;
ctx.fillRect(0,0,W,H);
updateOuterNodes(t,spd,cx,cy,R);
updateInnerNodes(t,spd,cx,cy);
// Outer glow ring
const breathe = 1+0.015*Math.sin(t*1.4*spd);
ctx.beginPath(); ctx.arc(cx,cy,R*breathe,0,Math.PI*2);
ctx.strokeStyle = `hsl(${300+30*Math.sin(t*0.4)},90%,60%)`;
ctx.lineWidth = 1.5; ctx.stroke();
// Rainbow dot belt
for (let i=0;i<90;i++) {
const a=(i/90)*Math.PI*2+t*0.018*spd;
const rx=cx+(R+4)*Math.cos(a), ry=cy+(R+4)*Math.sin(a);
ctx.beginPath(); ctx.arc(rx,ry,1.3,0,Math.PI*2);
ctx.fillStyle=`hsla(${(i/90)*360},100%,80%,${0.3+0.3*Math.sin(t*2.5+i*0.45)})`; ctx.fill();
}
// 2D geo nodes (centre + 2 hex rings)
const rot = t*0.035*spd;
const geoNodes = [{x:cx,y:cy,size:5,hue:300}];
for (let i=0;i<6;i++) {
const a=(i/6)*Math.PI*2+rot;
geoNodes.push({x:cx+r*Math.cos(a),y:cy+r*Math.sin(a),size:4,hue:260+(i/6)*80});
}
for (let i=0;i<6;i++) {
const a=(i/6)*Math.PI*2+Math.PI/6-rot;
geoNodes.push({x:cx+r*1.9*Math.cos(a),y:cy+r*1.9*Math.sin(a),size:3.5,hue:200+(i/6)*80});
}
// Geo web
ctx.lineWidth = 0.35;
for (let i=0;i<geoNodes.length;i++) for (let j=i+1;j<geoNodes.length;j++) {
const d=Math.hypot(geoNodes[i].x-geoNodes[j].x,geoNodes[i].y-geoNodes[j].y);
if (d<r*2.2) {
ctx.strokeStyle=`rgba(200,150,255,${(0.05+0.05*Math.sin(t+i+j))*energy})`;
ctx.beginPath(); ctx.moveTo(geoNodes[i].x,geoNodes[i].y);
ctx.lineTo(geoNodes[j].x,geoNodes[j].y); ctx.stroke();
}
}
// Cube verts → outer ring
if (projectedVerts.length>0 && connectStrength>0) {
projectedVerts.forEach((pv,vi) => {
const sorted=outerNodes
.map((on,oi)=>({oi,d:Math.hypot(pv.sx-on.x,pv.sy-on.y)}))
.sort((a,b)=>a.d-b.d);
for (let k=0;k<3;k++) {
if (!sorted[k]) continue;
const on=outerNodes[sorted[k].oi];
const maxDist=R*1.6; if (sorted[k].d>maxDist) continue;
const prox=1-sorted[k].d/maxDist;
const alpha=connectStrength*energy*prox*(0.5+0.3*Math.sin(t*1.5+vi*0.7+k));
const hue=260+(vi/8)*120+k*20;
const g=ctx.createLinearGradient(pv.sx,pv.sy,on.x,on.y);
g.addColorStop(0,`hsla(${hue+40},100%,80%,${alpha})`);
g.addColorStop(0.5,`hsla(${hue+20},100%,70%,${alpha*0.6})`);
g.addColorStop(1,`hsla(${on.hue},100%,70%,${alpha*0.3})`);
ctx.strokeStyle=g; ctx.lineWidth=0.5+prox*0.8;
ctx.beginPath(); ctx.moveTo(pv.sx,pv.sy); ctx.lineTo(on.x,on.y); ctx.stroke();
}
innerNodes.forEach((inn,ii) => {
const d=Math.hypot(pv.sx-inn.x,pv.sy-inn.y);
const maxD=r*2.2; if (d>maxD) return;
const prox=1-d/maxD;
ctx.strokeStyle=`hsla(${180+(vi/8)*80},90%,75%,${connectStrength*energy*prox*0.6*(0.4+0.4*Math.sin(t*2+vi+ii))})`;
ctx.lineWidth=0.4+prox*0.5;
ctx.beginPath(); ctx.moveTo(pv.sx,pv.sy); ctx.lineTo(inn.x,inn.y); ctx.stroke();
});
});
}
// Inner → geo + outer
innerNodes.forEach((inn,ii) => {
geoNodes.forEach((gn,gi) => {
const d=Math.hypot(inn.x-gn.x,inn.y-gn.y);
if (d<r*1.4) {
ctx.strokeStyle=`hsla(${inn.hue},80%,70%,${connectStrength*(0.3-d/(r*5))*(0.5+0.5*Math.sin(t+ii+gi))*energy})`;
ctx.lineWidth=0.4;
ctx.beginPath(); ctx.moveTo(inn.x,inn.y); ctx.lineTo(gn.x,gn.y); ctx.stroke();
}
});
const s2=outerNodes.map((on,oi)=>({oi,d:Math.hypot(inn.x-on.x,inn.y-on.y)})).sort((a,b)=>a.d-b.d);
for (let k=0;k<2;k++) {
const on=outerNodes[s2[k].oi]; const maxD=R*1.1; if (s2[k].d>maxD) continue;
const prox=1-s2[k].d/maxD;
ctx.strokeStyle=`hsla(${(inn.hue+on.hue)/2},85%,72%,${connectStrength*energy*prox*(0.25+0.2*Math.sin(t*1.8+ii+k))})`;
ctx.lineWidth=0.35; ctx.beginPath(); ctx.moveTo(inn.x,inn.y); ctx.lineTo(on.x,on.y); ctx.stroke();
}
});
// Outer ring neighbour web
outerNodes.forEach((n,i) => {
[outerNodes[(i+1)%OUTER_N],outerNodes[(i+OUTER_N-1)%OUTER_N]].forEach(nb => {
ctx.strokeStyle=`hsla(${n.hue},80%,65%,${energy*(0.12+0.08*Math.sin(t*0.8+i))})`;
ctx.lineWidth=0.4; ctx.beginPath(); ctx.moveTo(n.x,n.y); ctx.lineTo(nb.x,nb.y); ctx.stroke();
});
});
// Pulses
for (let i=pulses.length-1;i>=0;i--) {
const p=pulses[i]; p.t+=p.speed;
if (p.t>=1) { pulses.splice(i,1); continue; }
const px=p.x1+(p.x2-p.x1)*p.t, py=p.y1+(p.y2-p.y1)*p.t;
const alpha=energy*Math.sin(p.t*Math.PI)*0.95;
const g=ctx.createRadialGradient(px,py,0,px,py,p.size*2);
g.addColorStop(0,`hsla(${p.hue},100%,90%,${alpha})`);
g.addColorStop(1,`hsla(${p.hue},100%,60%,0)`);
ctx.beginPath(); ctx.arc(px,py,p.size*2,0,Math.PI*2); ctx.fillStyle=g; ctx.fill();
}
// Outer node glows
outerNodes.forEach((n,i) => {
if (Math.floor(n.pulseTimer)%55===0) {
const gn=geoNodes[Math.floor(Math.random()*geoNodes.length)];
spawnPulse(n.x,n.y,gn.x,gn.y,n.hue,spd);
if (Math.random()<0.5) { const inn=innerNodes[Math.floor(Math.random()*INNER_N)]; spawnPulse(n.x,n.y,inn.x,inn.y,n.hue+40,spd); }
}
const pulse=0.6+0.4*Math.sin(t*2.2+i);
const g=ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,7*pulse);
g.addColorStop(0,`hsla(${n.hue},100%,88%,${energy*0.95})`);
g.addColorStop(0.5,`hsla(${n.hue},100%,65%,${energy*0.45})`);
g.addColorStop(1,`hsla(${n.hue},100%,50%,0)`);
ctx.beginPath(); ctx.arc(n.x,n.y,7*pulse,0,Math.PI*2); ctx.fillStyle=g; ctx.fill();
ctx.beginPath(); ctx.arc(n.x,n.y,2.5,0,Math.PI*2);
ctx.fillStyle=`rgba(255,255,255,${energy*0.9})`; ctx.fill();
});
// Inner node glows
innerNodes.forEach((n,i) => {
if (Math.floor(n.pulseTimer)%70===0) { const on=outerNodes[Math.floor(Math.random()*OUTER_N)]; spawnPulse(n.x,n.y,on.x,on.y,n.hue,spd); }
const pulse=0.7+0.3*Math.sin(t*2.5+i*0.8);
const g=ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,9*pulse);
g.addColorStop(0,`hsla(${n.hue},100%,85%,${energy*0.9})`);
g.addColorStop(0.5,`hsla(${n.hue},100%,60%,${energy*0.4})`);
g.addColorStop(1,`hsla(${n.hue},100%,45%,0)`);
ctx.beginPath(); ctx.arc(n.x,n.y,9*pulse,0,Math.PI*2); ctx.fillStyle=g; ctx.fill();
ctx.beginPath(); ctx.arc(n.x,n.y,3,0,Math.PI*2);
ctx.fillStyle=`rgba(255,255,255,${energy*0.85})`; ctx.fill();
});
// Geo node glows
geoNodes.forEach((n,i) => {
const pulse=0.7+0.3*Math.sin(t*1.6+i*0.9);
ctx.beginPath(); ctx.arc(n.x,n.y,n.size*pulse,0,Math.PI*2);
ctx.fillStyle=`hsla(${n.hue},100%,85%,${0.75+0.25*pulse})`; ctx.fill();
});
// Projected vertex glows (cube corners)
projectedVerts.forEach((pv,i) => {
const pulse=0.6+0.4*Math.sin(t*3+i);
const hue=240+(i/8)*120;
const g=ctx.createRadialGradient(pv.sx,pv.sy,0,pv.sx,pv.sy,12*pulse);
g.addColorStop(0,`hsla(${hue},100%,90%,${energy*0.85})`);
g.addColorStop(0.5,`hsla(${hue},100%,70%,${energy*0.4})`);
g.addColorStop(1,`hsla(${hue},100%,50%,0)`);
ctx.beginPath(); ctx.arc(pv.sx,pv.sy,12*pulse,0,Math.PI*2); ctx.fillStyle=g; ctx.fill();
ctx.beginPath(); ctx.arc(pv.sx,pv.sy,3,0,Math.PI*2);
ctx.fillStyle=`rgba(255,255,255,${energy})`; ctx.fill();
});
}
// ── THREE.js ───────────────────────────────────────────────────────────────
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(52, window.innerWidth/window.innerHeight, 0.1, 1000);
camera.position.set(0,0,6);
const wireMat = new THREE.ShaderMaterial({
uniforms:{uTime:{value:0},uEnergy:{value:0.7}},
wireframe:true, transparent:true,
vertexShader:`uniform float uTime;uniform float uEnergy;varying vec2 vUv;
void main(){vUv=uv;vec3 pos=position;float amp=uEnergy*0.09;
pos.x+=sin(uTime*0.9+position.y*1.4+position.z*0.6)*amp;
pos.y+=sin(uTime*1.1+position.x*1.3+position.z*0.8)*amp;
pos.z+=sin(uTime*0.8+position.z*1.5+position.y*0.5)*amp;
gl_Position=projectionMatrix*modelViewMatrix*vec4(pos,1.0);}`,
fragmentShader:`uniform float uTime;varying vec2 vUv;
void main(){float h=mod(uTime*0.08+vUv.x*0.5+vUv.y*0.3,1.0);
gl_FragColor=vec4(abs(sin(h*6.28))*0.8+0.2,abs(sin(h*6.28+2.09))*0.5+0.2,abs(sin(h*6.28+4.19))*0.9+0.1,0.55+0.35*sin(uTime*1.8+vUv.x*6.28));}`
});
const solidMat = new THREE.MeshBasicMaterial({color:0x880066,transparent:true,opacity:0.4,side:THREE.DoubleSide,depthWrite:false});
const boxGeo = new THREE.BoxGeometry(2.2,2.2,2.2);
const solidMesh= new THREE.Mesh(boxGeo,solidMat); scene.add(solidMesh);
const wireMesh = new THREE.Mesh(boxGeo,wireMat); scene.add(wireMesh);
const SV=1.1;
const verts8=[[-SV,-SV,-SV],[SV,-SV,-SV],[SV,SV,-SV],[-SV,SV,-SV],[-SV,-SV,SV],[SV,-SV,SV],[SV,SV,SV],[-SV,SV,SV]];
const spheres=verts8.map((v,i)=>{
const color=new THREE.Color().setHSL((240+(i/8)*120)/360,1,0.7);
const m=new THREE.Mesh(new THREE.SphereGeometry(0.09,10,10),new THREE.MeshBasicMaterial({color}));
m.position.set(...v); m.userData.base=[...v]; scene.add(m); return m;
});
const vlPos=new Float32Array(8*2*3);
const vlGeo=new THREE.BufferGeometry();
vlGeo.setAttribute('position',new THREE.BufferAttribute(vlPos,3));
scene.add(new THREE.LineSegments(vlGeo,new THREE.LineBasicMaterial({color:0xff88ff,transparent:true,opacity:0.18})));
const edgePairs=[];
for(let i=0;i<8;i++) for(let j=i+1;j<8;j++) {
if(Math.hypot(...verts8[i].map((v,k)=>v-verts8[j][k]))<2.6) edgePairs.push([i,j]);
}
const edgePos=[]; edgePairs.forEach(([a,b])=>{edgePos.push(...verts8[a],...verts8[b]);});
const edgeGeo=new THREE.BufferGeometry();
edgeGeo.setAttribute('position',new THREE.Float32BufferAttribute(edgePos,3));
const edgeMat=new THREE.ShaderMaterial({
uniforms:{uTime:{value:0}},transparent:true,
vertexShader:`varying vec3 vPos;void main(){vPos=position;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
fragmentShader:`uniform float uTime;varying vec3 vPos;void main(){float h=mod(vPos.x*0.3+vPos.y*0.2+uTime*0.06,1.0);gl_FragColor=vec4(abs(sin(h*6.28)),abs(sin(h*6.28+2.09))*0.6+0.2,abs(sin(h*6.28+4.19)),0.6);}`
});
scene.add(new THREE.LineSegments(edgeGeo,edgeMat));
const wireMat2=new THREE.ShaderMaterial({
uniforms:{uTime:{value:0},uEnergy:{value:0.7}},wireframe:true,transparent:true,
vertexShader:`uniform float uTime;uniform float uEnergy;varying vec2 vUv;void main(){vUv=uv;vec3 pos=position;pos+=sin(uTime*0.7+pos.yzx*1.3)*uEnergy*0.05;gl_Position=projectionMatrix*modelViewMatrix*vec4(pos,1.0);}`,
fragmentShader:`uniform float uTime;varying vec2 vUv;void main(){float h=mod(uTime*0.05+vUv.y*0.4,1.0);gl_FragColor=vec4(abs(sin(h*6.28+1.0))*0.6,abs(sin(h*6.28+3.0))*0.4,abs(sin(h*6.28+5.0))*0.9,0.25+0.15*sin(uTime*2.0+vUv.x*6.28));}`
});
const outerBox=new THREE.Mesh(new THREE.BoxGeometry(3.2,3.2,3.2),wireMat2); scene.add(outerBox);
const innerMat=new THREE.ShaderMaterial({
uniforms:{uTime:{value:0}},wireframe:true,transparent:true,
vertexShader:`uniform float uTime;varying vec2 vUv;void main(){vUv=uv;vec3 p=position;p+=sin(uTime*1.5+p.zxy*2.0)*0.06;gl_Position=projectionMatrix*modelViewMatrix*vec4(p,1.0);}`,
fragmentShader:`uniform float uTime;varying vec2 vUv;void main(){float h=mod(uTime*0.1+vUv.x*0.6,1.0);gl_FragColor=vec4(abs(sin(h*6.28)),abs(sin(h*6.28+2.0)),abs(sin(h*6.28+4.0)),0.3+0.2*sin(uTime*3.0+vUv.y*6.28));}`
});
const innerCube=new THREE.Mesh(new THREE.BoxGeometry(1.2,1.2,1.2),innerMat); scene.add(innerCube);
// ── Renderer — full viewport, cube always at W/2 H/2 ──────────────────────
const renderer=new THREE.WebGLRenderer({antialias:true,alpha:true});
renderer.setClearColor(0,0);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth,window.innerHeight);
renderer.domElement.style.cssText='position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:auto;';
document.body.appendChild(renderer.domElement);
const orbitCtrls=new OrbitControls(camera,renderer.domElement);
orbitCtrls.enableDamping=true; orbitCtrls.dampingFactor=0.05;
orbitCtrls.minDistance=2; orbitCtrls.maxDistance=20;
orbitCtrls.target.set(0,0,0);
buildOuterNodes();
buildInnerNodes(80); // placeholder, recalculated each frame via circleLayout
window.addEventListener('resize',()=>{
resize();
camera.aspect=window.innerWidth/window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth,window.innerHeight);
});
// Project 3D world pos → 2D screen px (full-viewport render, simple NDC)
function project3DtoScreen(pos3d) {
const v = pos3d.clone().project(camera);
const W = window.innerWidth, H = window.innerHeight;
return { sx: (v.x+1)/2*W, sy: (-v.y+1)/2*H };
}
let t=0;
function animate() {
requestAnimationFrame(animate);
const energy = parseFloat(energyEl.value);
const spd = parseFloat(speedEl.value);
const connectStrength = parseFloat(connectEl.value);
t += 0.007*spd;
// Rebuild inner node radii from live circle size
const {R} = circleLayout();
innerNodes.forEach(n => { n.orbitR = R*0.28*(0.9 + 0.3*(innerNodes.indexOf(n)/INNER_N)); });
const uT=t*10;
wireMat.uniforms.uTime.value=uT; wireMat.uniforms.uEnergy.value=energy;
wireMat2.uniforms.uTime.value=uT; wireMat2.uniforms.uEnergy.value=energy;
innerMat.uniforms.uTime.value=uT; edgeMat.uniforms.uTime.value=uT;
solidMat.opacity=0.25+energy*0.35;
solidMat.color.setHSL(0.82+0.06*Math.sin(t*0.3),0.9,0.3);
const baseRot=t*0.003*spd;
wireMesh.rotation.y+=baseRot*1.4; wireMesh.rotation.x+=baseRot*0.5;
solidMesh.rotation.y=wireMesh.rotation.y; solidMesh.rotation.x=wireMesh.rotation.x;
outerBox.rotation.y=-t*0.0025*spd; outerBox.rotation.z=t*0.0018*spd;
innerCube.rotation.y=t*0.006*spd; innerCube.rotation.x=-t*0.004*spd;
spheres.forEach((sp,i)=>{
const s2=1+0.2*energy*Math.sin(t*3.5+i*0.7);
sp.position.set(sp.userData.base[0]*s2,sp.userData.base[1]*s2,sp.userData.base[2]*s2);
sp.material.color.setHSL((240+(i/8)*120)/360,1,0.65+0.2*Math.sin(t+i));
sp.rotation.y=t*2.5;
});
const arr=vlGeo.attributes.position.array;
spheres.forEach((sp,i)=>{
const dir=sp.position.clone().normalize().multiplyScalar(4+energy*2*Math.sin(t*1.8+i));
arr[i*6]=sp.position.x; arr[i*6+1]=sp.position.y; arr[i*6+2]=sp.position.z;
arr[i*6+3]=dir.x; arr[i*6+4]=dir.y; arr[i*6+5]=dir.z;
});
vlGeo.attributes.position.needsUpdate=true;
// Render 3D to full viewport — cube is always at screen centre
renderer.render(scene,camera);
camera.updateMatrixWorld();
// Project cube vertices using simple full-viewport NDC
projectedVerts = spheres.map(sp => project3DtoScreen(sp.position));
// Draw 2D overlay — circle centred at W/2,H/2, radius shrunk to clear controls
draw2D(t,spd,energy,connectStrength);
orbitCtrls.update();
renderer.render(scene,camera);
}
animate();
</script>
</body>
</html>