Show description
M87* — Supermassive Black Hole
M87* — Supermassive Black Hole
INITIALISING SINGULARITY
M87* SUPERMASSIVE BLACK HOLE
Mass 6.5 × 10⁹ M☉
Distance 55 Million Light Years
Horizon 40 Billion km
Jet Speed 0.999 c (relativistic)
Drag to orbit · Scroll to zoom
VIRGO A · M87
RA 12h 30m 49.4s
Dec +12° 23′ 28″
Redshift z = 0.00436
M87* — Supermassive Black Hole
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>M87* — Supermassive Black Hole</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #000008; overflow: hidden; width: 100vw; height: 100vh; }
canvas { display: block; }
#hud-tl {
position: absolute; top: 20px; left: 22px;
font-family: monospace; font-size: 11px; line-height: 1.7;
color: rgba(155,185,255,0.55); pointer-events: none; user-select: none;
}
#hud-tl .title {
font-size: 13px; letter-spacing: 2px;
color: rgba(200,155,255,0.75); margin-bottom: 5px;
}
#hud-tl .dim {
color: rgba(100,130,200,0.4); margin-top: 8px; font-size: 10px;
}
#hud-tr {
position: absolute; top: 20px; right: 22px; text-align: right;
font-family: monospace; font-size: 10px; line-height: 1.6;
color: rgba(140,180,255,0.45); pointer-events: none; user-select: none;
}
#hud-tr .label {
font-size: 12px; color: rgba(180,120,255,0.7); letter-spacing: 1px;
}
#loading {
position: absolute; inset: 0; display: flex; align-items: center;
justify-content: center; flex-direction: column; gap: 14px;
background: #000008; color: rgba(180,140,255,0.7);
font-family: monospace; font-size: 13px; letter-spacing: 2px;
transition: opacity 0.6s;
}
#loading .bar-wrap {
width: 220px; height: 2px; background: rgba(255,255,255,0.08);
}
#loading .bar {
height: 100%; width: 0%; background: rgba(180,120,255,0.6);
transition: width 0.3s;
}
</style>
</head>
<body>
<div id="loading">
<div>INITIALISING SINGULARITY</div>
<div class="bar-wrap"><div class="bar" id="bar"></div></div>
</div>
<div id="hud-tl" style="display:none">
<div class="title">M87* SUPERMASSIVE BLACK HOLE</div>
<div>Mass 6.5 × 10⁹ M☉</div>
<div>Distance 55 Million Light Years</div>
<div>Horizon 40 Billion km</div>
<div>Jet Speed 0.999 c (relativistic)</div>
<div class="dim">Drag to orbit · Scroll to zoom</div>
</div>
<div id="hud-tr" style="display:none">
<div class="label">VIRGO A · M87</div>
<div>RA 12h 30m 49.4s</div>
<div>Dec +12° 23′ 28″</div>
<div>Redshift z = 0.00436</div>
</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"
}
}
</script>
<script type="module">
import * as THREE from 'three';
// ── Helpers ──────────────────────────────────────────────────────────────────
const progress = (p) => { document.getElementById('bar').style.width = p + '%'; };
function gauss() {
let u = 0, v = 0;
while (!u) u = Math.random();
while (!v) v = Math.random();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
function clamp01(x) { return Math.max(0, Math.min(1, x)); }
function lerpC(a, b, t) {
return [a[0]+(b[0]-a[0])*t, a[1]+(b[1]-a[1])*t, a[2]+(b[2]-a[2])*t];
}
// ── Ring geometry with radial UV ─────────────────────────────────────────────
function buildRingGeo(innerR, outerR, segs, rings) {
const verts=[], uvs=[], radials=[], idxs=[];
for (let r=0; r<=rings; r++) {
const t=r/rings, radius=innerR+(outerR-innerR)*t;
for (let s=0; s<=segs; s++) {
const a=(s/segs)*Math.PI*2;
verts.push(radius*Math.cos(a), 0, radius*Math.sin(a));
uvs.push(s/segs, t);
radials.push(t);
}
}
for (let r=0; r<rings; r++) {
for (let s=0; s<segs; s++) {
const a=r*(segs+1)+s, b=a+1, c=a+segs+1, d=c+1;
idxs.push(a,b,c, b,d,c);
}
}
const geo=new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geo.setAttribute('aRadial', new THREE.Float32BufferAttribute(radials, 1));
geo.setIndex(idxs);
return geo;
}
// ── Galaxy particle geometry ──────────────────────────────────────────────────
function buildGalaxyGeo(count, arms) {
const positions=new Float32Array(count*3);
const colors =new Float32Array(count*3);
const scales =new Float32Array(count);
const radii =new Float32Array(count);
const angles =new Float32Array(count);
const omegas =new Float32Array(count);
const heights =new Float32Array(count);
const armPal=[
[0.55,0.75,1.00],
[1.00,0.65,0.25],
[0.85,0.30,0.90],
[0.25,0.90,0.70],
];
const bulgeN=Math.floor(count*0.14);
for (let i=0; i<bulgeN; i++) {
const r =Math.pow(Math.random(),0.6)*18;
const theta=Math.random()*Math.PI*2;
const phi =(Math.random()-0.5)*Math.PI;
positions[i*3] =r*Math.cos(theta)*Math.cos(phi);
positions[i*3+1]=r*Math.sin(phi)*0.35;
positions[i*3+2]=r*Math.sin(theta)*Math.cos(phi);
radii[i]=r; angles[i]=theta; heights[i]=positions[i*3+1];
omegas[i]=0.35/(r+2.5);
const br=Math.random();
colors[i*3]=1.0; colors[i*3+1]=0.88+br*0.12; colors[i*3+2]=0.55+br*0.45;
scales[i]=Math.random()*1.8+0.4;
}
for (let i=bulgeN; i<count; i++) {
const arm =Math.floor(Math.random()*arms);
const armStart=(arm/arms)*Math.PI*2;
const t =Math.pow(Math.random(),0.65);
const r =16+t*145;
const spiral =0.58;
const angle =armStart+Math.log(r/16)/spiral;
const scatter=(Math.pow(r/180,1.2)*18+2);
const x=r*Math.cos(angle)+gauss()*scatter;
const z=r*Math.sin(angle)+gauss()*scatter;
const y=gauss()*(r<55?2.5:7)*Math.max(0,1-r/195);
positions[i*3]=x; positions[i*3+1]=y; positions[i*3+2]=z;
const rad=Math.sqrt(x*x+z*z);
radii[i]=rad; angles[i]=Math.atan2(z,x); heights[i]=y;
omegas[i]=0.32/(rad+7);
const nr=clamp01(rad/148);
const base=armPal[arm];
let col;
if (nr<0.22) col=lerpC([1.0,0.92,0.80],base,nr/0.22);
else if (nr<0.68) col=base;
else col=lerpC(base,[0.45+Math.random()*0.25,0.08,0.28+Math.random()*0.3],(nr-0.68)/0.32);
colors[i*3] =clamp01(col[0]+gauss()*0.08);
colors[i*3+1]=clamp01(col[1]+gauss()*0.08);
colors[i*3+2]=clamp01(col[2]+gauss()*0.08);
scales[i]=Math.random()*2.2+0.25;
}
const geo=new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions,3));
geo.setAttribute('aColor', new THREE.BufferAttribute(colors,3));
geo.setAttribute('aScale', new THREE.BufferAttribute(scales,1));
geo.setAttribute('aRadius', new THREE.BufferAttribute(radii,1));
geo.setAttribute('aAngle', new THREE.BufferAttribute(angles,1));
geo.setAttribute('aOmega', new THREE.BufferAttribute(omegas,1));
geo.setAttribute('aHeight', new THREE.BufferAttribute(heights,1));
return geo;
}
// ─────────────────────────────────────────────────────────────────────────────
// MAIN
// ─────────────────────────────────────────────────────────────────────────────
progress(5);
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(0x000008);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x000008, 0.0012);
const camera = new THREE.PerspectiveCamera(55, window.innerWidth/window.innerHeight, 0.1, 8000);
camera.position.set(0, 110, 265);
camera.lookAt(0, 0, 0);
const clock = new THREE.Clock();
const BH = 12;
const animMats = [];
progress(10);
// ── 1. GALAXY ─────────────────────────────────────────────────────────────────
const galaxyMat = new THREE.ShaderMaterial({
uniforms: { uTime:{value:0}, uSize:{value:2.8} },
vertexShader: `
uniform float uTime;
uniform float uSize;
attribute float aScale;
attribute vec3 aColor;
attribute float aRadius;
attribute float aAngle;
attribute float aOmega;
attribute float aHeight;
varying vec3 vColor;
varying float vAlpha;
void main() {
vColor = aColor;
float ang = aAngle + uTime * aOmega;
vec3 pos = vec3(aRadius*cos(ang), aHeight, aRadius*sin(ang));
vec4 mvPos = modelViewMatrix * vec4(pos, 1.0);
float camDist = -mvPos.z;
float twinkle = sin(uTime*1.8 + aScale*557.3)*0.22 + 0.78;
gl_PointSize = uSize * aScale * (260.0/camDist) * twinkle;
gl_PointSize = clamp(gl_PointSize, 0.4, 14.0);
vAlpha = 1.0;
gl_Position = projectionMatrix * mvPos;
}
`,
fragmentShader: `
varying vec3 vColor;
varying float vAlpha;
void main() {
vec2 uv = gl_PointCoord - 0.5;
float r = length(uv);
if (r > 0.5) discard;
float alpha = pow(1.0 - smoothstep(0.0, 0.5, r), 1.4) * vAlpha;
gl_FragColor = vec4(vColor, alpha);
}
`,
transparent:true, depthWrite:false, blending:THREE.AdditiveBlending,
});
const galaxyPts = new THREE.Points(buildGalaxyGeo(120000, 4), galaxyMat);
galaxyPts.frustumCulled = false;
scene.add(galaxyPts);
animMats.push(galaxyMat);
progress(30);
// ── 2. BACKGROUND STARS ───────────────────────────────────────────────────────
{
const N=22000;
const pos=new Float32Array(N*3), col=new Float32Array(N*3), sc=new Float32Array(N);
const starCols=[[1,1,1],[0.75,0.88,1],[1,0.92,0.82],[0.9,0.8,1],[0.7,0.9,1]];
for (let i=0;i<N;i++) {
const th=Math.random()*Math.PI*2;
const ph=Math.acos(2*Math.random()-1);
const r=600+Math.random()*3200;
pos[i*3]=r*Math.sin(ph)*Math.cos(th);
pos[i*3+1]=r*Math.sin(ph)*Math.sin(th);
pos[i*3+2]=r*Math.cos(ph);
const c=starCols[Math.floor(Math.random()*starCols.length)];
col[i*3]=c[0]; col[i*3+1]=c[1]; col[i*3+2]=c[2];
sc[i]=Math.random()*1.6+0.2;
}
const sGeo=new THREE.BufferGeometry();
sGeo.setAttribute('position',new THREE.BufferAttribute(pos,3));
sGeo.setAttribute('aColor', new THREE.BufferAttribute(col,3));
sGeo.setAttribute('aScale', new THREE.BufferAttribute(sc,1));
const sMat=new THREE.ShaderMaterial({
uniforms:{uTime:{value:0}},
vertexShader:`
uniform float uTime;
attribute float aScale;
attribute vec3 aColor;
varying vec3 vColor;
void main() {
vColor = aColor;
vec4 mvPos = modelViewMatrix * vec4(position, 1.0);
float tw = sin(uTime*0.7 + aScale*1247.3)*0.28+0.72;
gl_PointSize = aScale*(420.0/-mvPos.z)*tw;
gl_PointSize = clamp(gl_PointSize, 0.4, 5.0);
gl_Position = projectionMatrix * mvPos;
}
`,
fragmentShader:`
varying vec3 vColor;
void main() {
vec2 uv=gl_PointCoord-0.5;
if(length(uv)>0.5) discard;
float a=1.0-smoothstep(0.0,0.5,length(uv));
gl_FragColor=vec4(vColor, a*0.82);
}
`,
transparent:true,depthWrite:false,blending:THREE.AdditiveBlending,
});
const sPts=new THREE.Points(sGeo,sMat);
sPts.frustumCulled=false;
scene.add(sPts);
animMats.push(sMat);
}
progress(45);
// ── 3. BLACK HOLE EVENT HORIZON ───────────────────────────────────────────────
scene.add(new THREE.Mesh(
new THREE.SphereGeometry(BH-0.4, 64, 64),
new THREE.MeshBasicMaterial({color:0x000000})
));
const bhMat = new THREE.ShaderMaterial({
uniforms: { uCamPos:{value:camera.position.clone()}, uTime:{value:0} },
vertexShader:`
varying vec3 vNormal;
varying vec3 vWorldPos;
void main() {
vNormal = normalize(normalMatrix * normal);
vWorldPos = (modelMatrix * vec4(position,1.0)).xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0);
}
`,
fragmentShader:`
uniform vec3 uCamPos;
uniform float uTime;
varying vec3 vNormal;
varying vec3 vWorldPos;
void main() {
vec3 viewDir = normalize(uCamPos - vWorldPos);
float fr = pow(1.0 - max(0.0, dot(viewDir, vNormal)), 3.2);
float pulse = sin(uTime*0.8)*0.08 + 0.92;
vec3 ring1 = vec3(0.85,0.50,1.00)*fr;
vec3 ring2 = vec3(1.00,0.75,0.30)*pow(fr,2.8);
gl_FragColor = vec4((ring1+ring2)*pulse, fr*0.92);
}
`,
transparent:true,depthWrite:false,blending:THREE.AdditiveBlending,side:THREE.FrontSide,
});
scene.add(new THREE.Mesh(new THREE.SphereGeometry(BH, 64, 64), bhMat));
animMats.push(bhMat);
// ── 4. ACCRETION DISK ─────────────────────────────────────────────────────────
const diskVert = `
attribute float aRadial;
varying vec2 vUv;
varying float vRadial;
void main() {
vUv=uv; vRadial=aRadial;
gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);
}
`;
const diskMat = new THREE.ShaderMaterial({
uniforms:{uTime:{value:0}},
vertexShader:diskVert,
fragmentShader:`
uniform float uTime;
varying vec2 vUv;
varying float vRadial;
void main() {
float omega = mix(7.5, 0.9, vRadial);
float ang = vUv.x*6.28318 + uTime*omega;
float s1 = sin(ang*16.0)*0.5+0.5;
float s2 = sin(ang*9.3+1.9)*0.5+0.5;
float s3 = sin(ang*27.0-uTime*0.4)*0.5+0.5;
float turb = clamp(pow(s1*s2,1.6)+s3*0.25, 0.0, 1.0);
vec3 c0=vec3(1.00,0.97,0.92);
vec3 c1=vec3(1.00,0.62,0.10);
vec3 c2=vec3(0.88,0.22,0.04);
vec3 c3=vec3(0.28,0.04,0.14);
vec3 col=mix(c0,c1,smoothstep(0.00,0.24,vRadial));
col=mix(col,c2,smoothstep(0.24,0.62,vRadial));
col=mix(col,c3,smoothstep(0.62,1.00,vRadial));
vec3 flashCol=mix(vec3(1.0,0.88,0.55),vec3(1.0,0.28,0.08),vRadial);
col+=turb*flashCol*mix(0.95,0.22,vRadial);
float alpha=smoothstep(1.0,0.90,vRadial)*smoothstep(0.0,0.055,vRadial);
alpha*=0.60+0.40*turb;
gl_FragColor=vec4(col,alpha);
}
`,
transparent:true,side:THREE.DoubleSide,depthWrite:false,blending:THREE.AdditiveBlending,
});
scene.add(new THREE.Mesh(buildRingGeo(BH*1.52, BH*7.2, 256, 64), diskMat));
animMats.push(diskMat);
// Halo
const haloMat = new THREE.ShaderMaterial({
uniforms:{uTime:{value:0}},
vertexShader:diskVert,
fragmentShader:`
varying float vRadial;
void main() {
float alpha=(1.0-vRadial)*0.10*smoothstep(0.0,0.08,vRadial);
vec3 col=mix(vec3(1.0,0.55,0.10),vec3(0.45,0.08,0.28),vRadial);
gl_FragColor=vec4(col,alpha);
}
`,
transparent:true,side:THREE.DoubleSide,depthWrite:false,blending:THREE.AdditiveBlending,
});
scene.add(new THREE.Mesh(buildRingGeo(BH*1.2, BH*13, 128, 32), haloMat));
animMats.push(haloMat);
// Photon sphere
const photonMat = new THREE.ShaderMaterial({
uniforms:{uTime:{value:0}},
vertexShader:diskVert,
fragmentShader:`
uniform float uTime;
varying float vRadial;
void main() {
float pulse=sin(uTime*2.2)*0.08+0.92;
float alpha=(1.0-smoothstep(0.25,1.0,vRadial))*smoothstep(0.0,0.25,vRadial);
alpha*=0.78*pulse;
vec3 col=mix(vec3(1.0,0.92,0.65),vec3(0.65,0.38,1.00),vRadial);
gl_FragColor=vec4(col,alpha);
}
`,
transparent:true,side:THREE.DoubleSide,depthWrite:false,blending:THREE.AdditiveBlending,
});
scene.add(new THREE.Mesh(buildRingGeo(BH*1.08, BH*1.62, 256, 20), photonMat));
animMats.push(photonMat);
progress(60);
// ── 5. RELATIVISTIC JETS ──────────────────────────────────────────────────────
const jetVert=`
uniform float uTime;
uniform float uDir;
attribute float aT;
varying float vAlpha;
varying vec3 vCol;
void main() {
float flow = fract(aT + uTime*0.42);
float spread= flow*flow*9.0;
vec3 pos = position;
pos.y = uDir*flow*168.0;
pos.x += sin(aT*147.3+uTime*0.3)*spread;
pos.z += cos(aT*213.7+uTime*0.3)*spread;
vCol = mix(vec3(1.0,0.72,0.38),vec3(0.38,0.52,1.0),flow);
vAlpha = pow(1.0-flow,1.6)*0.88;
vec4 mvPos = modelViewMatrix*vec4(pos,1.0);
gl_PointSize=(1.0-flow)*11.0*(160.0/-mvPos.z);
gl_PointSize=clamp(gl_PointSize,0.5,18.0);
gl_Position=projectionMatrix*mvPos;
}
`;
const jetFrag=`
varying float vAlpha;
varying vec3 vCol;
void main() {
vec2 uv=gl_PointCoord-0.5;
if(length(uv)>0.5) discard;
float a=(1.0-length(uv)*2.0)*vAlpha;
gl_FragColor=vec4(vCol,a);
}
`;
for (const dir of [1,-1]) {
const N=5000;
const pos=new Float32Array(N*3), ts=new Float32Array(N);
for (let i=0;i<N;i++){
pos[i*3]=gauss()*0.5; pos[i*3+1]=0; pos[i*3+2]=gauss()*0.5;
ts[i]=Math.random();
}
const jGeo=new THREE.BufferGeometry();
jGeo.setAttribute('position',new THREE.BufferAttribute(pos,3));
jGeo.setAttribute('aT', new THREE.BufferAttribute(ts,1));
const jMat=new THREE.ShaderMaterial({
uniforms:{uTime:{value:0},uDir:{value:dir}},
vertexShader:jetVert, fragmentShader:jetFrag,
transparent:true,depthWrite:false,blending:THREE.AdditiveBlending,
});
const jPts=new THREE.Points(jGeo,jMat);
jPts.frustumCulled=false;
scene.add(jPts);
animMats.push(jMat);
}
progress(75);
// ── 6. NEBULA WISPS ───────────────────────────────────────────────────────────
const nebulaVert=`
varying vec2 vUv;
void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }
`;
const nebConfigs=[
{pos:[85,12,-65], rot:[0.30,0.00, 0.12], sz:128, h:0.70},
{pos:[-95,-6,42], rot:[-0.22,0.50,0.00], sz:108, h:0.14},
{pos:[32,22,108], rot:[0.10,0.22,-0.30], sz:118, h:0.55},
{pos:[-45,-18,-108],rot:[0.38,-0.32,0.18],sz:96, h:0.84},
];
nebConfigs.forEach(({pos,rot,sz,h})=>{
const H=h.toFixed(4);
const nMat=new THREE.ShaderMaterial({
uniforms:{uTime:{value:0}},
vertexShader:nebulaVert,
fragmentShader:`
uniform float uTime;
varying vec2 vUv;
float hash(vec2 p){
p=fract(p*vec2(234.34,435.345));
p+=dot(p,p+34.23);
return fract(p.x*p.y);
}
float noise(vec2 p){
vec2 i=floor(p),f=fract(p);
vec2 u=f*f*(3.0-2.0*f);
return mix(mix(hash(i),hash(i+vec2(1,0)),u.x),
mix(hash(i+vec2(0,1)),hash(i+vec2(1,1)),u.x),u.y);
}
float fbm(vec2 p){
float v=0.0,a=0.5;
for(int i=0;i<6;i++){v+=a*noise(p);p=p*2.1+vec2(1.7,9.2);a*=0.5;}
return v;
}
void main(){
vec2 uv=( vUv-0.5)*3.2;
float n =fbm(uv+vec2(uTime*0.018,uTime*0.013));
float n2=fbm(uv*1.65-vec2(uTime*0.009,uTime*0.022)+vec2(5.3,1.7));
float cloud=smoothstep(0.34,0.78,n*1.5+n2*0.75);
float edge =1.0-smoothstep(0.28,0.72,length(vUv-0.5)*2.2);
float hue=${H};
vec3 c1=vec3(0.5+0.5*cos(6.28*(hue+0.00)),0.5+0.5*cos(6.28*(hue+0.33)),0.5+0.5*cos(6.28*(hue+0.67)));
vec3 c2=vec3(0.5+0.5*cos(6.28*(hue+0.15)),0.5+0.5*cos(6.28*(hue+0.48)),0.5+0.5*cos(6.28*(hue+0.82)));
gl_FragColor=vec4(mix(c1,c2,n2),cloud*edge*0.13);
}
`,
transparent:true,depthWrite:false,side:THREE.DoubleSide,blending:THREE.AdditiveBlending,
});
const mesh=new THREE.Mesh(new THREE.PlaneGeometry(sz,sz),nMat);
mesh.position.set(...pos);
mesh.rotation.set(...rot);
scene.add(mesh);
animMats.push(nMat);
});
progress(90);
// ── 7. ORBIT CAMERA ───────────────────────────────────────────────────────────
let sph={theta:0.35, phi:1.25, r:265};
let vel={theta:0, phi:0};
let drag={on:false, px:0, py:0};
const getXY=(e)=>e.touches?{x:e.touches[0].clientX,y:e.touches[0].clientY}:{x:e.clientX,y:e.clientY};
renderer.domElement.addEventListener('mousedown', e=>{ drag.on=true; const{x,y}=getXY(e); drag.px=x; drag.py=y; });
renderer.domElement.addEventListener('mousemove', e=>{ if(!drag.on)return; const{x,y}=getXY(e); vel.theta=(x-drag.px)*0.006; vel.phi=(y-drag.py)*0.006; sph.theta+=vel.theta; sph.phi=Math.max(0.08,Math.min(Math.PI-0.08,sph.phi+vel.phi)); drag.px=x; drag.py=y; });
renderer.domElement.addEventListener('mouseup', ()=>drag.on=false);
renderer.domElement.addEventListener('mouseleave', ()=>drag.on=false);
renderer.domElement.addEventListener('touchstart', e=>{ drag.on=true; const{x,y}=getXY(e); drag.px=x; drag.py=y; },{passive:true});
renderer.domElement.addEventListener('touchmove', e=>{ if(!drag.on)return; const{x,y}=getXY(e); vel.theta=(x-drag.px)*0.006; vel.phi=(y-drag.py)*0.006; sph.theta+=vel.theta; sph.phi=Math.max(0.08,Math.min(Math.PI-0.08,sph.phi+vel.phi)); drag.px=x; drag.py=y; },{passive:true});
renderer.domElement.addEventListener('touchend', ()=>drag.on=false);
renderer.domElement.addEventListener('wheel', e=>{ sph.r=Math.max(28,Math.min(900,sph.r+e.deltaY*0.35)); },{passive:true});
// ── 8. LOOP ───────────────────────────────────────────────────────────────────
progress(100);
setTimeout(()=>{
const loading=document.getElementById('loading');
loading.style.opacity='0';
setTimeout(()=>{ loading.style.display='none'; },600);
document.getElementById('hud-tl').style.display='block';
document.getElementById('hud-tr').style.display='block';
},400);
function animate() {
requestAnimationFrame(animate);
const t=clock.getElapsedTime();
if (!drag.on) {
vel.theta*=0.92; vel.phi*=0.92;
sph.theta+=0.00075+vel.theta;
sph.phi=Math.max(0.08,Math.min(Math.PI-0.08,sph.phi+vel.phi));
}
camera.position.set(
sph.r*Math.sin(sph.phi)*Math.cos(sph.theta),
sph.r*Math.cos(sph.phi),
sph.r*Math.sin(sph.phi)*Math.sin(sph.theta)
);
camera.lookAt(0,0,0);
for (const mat of animMats) {
if (mat.uniforms.uTime) mat.uniforms.uTime.value=t;
}
bhMat.uniforms.uCamPos.value.copy(camera.position);
renderer.render(scene,camera);
}
animate();
// ── RESIZE ────────────────────────────────────────────────────────────────────
window.addEventListener('resize', ()=>{
camera.aspect=window.innerWidth/window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth,window.innerHeight);
});
</script>
</body>
</html>