Skip to content
LAM
Read Home Blog
Make Projects HTML Tools Games
Touch grass Notes Resume Links
Home Blog HTML Projects
Tools Games Notes Resume Links
Back M87* — Supermassive Black Hole Life
Download Open
Show description 317 chars · Life

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

23,455 bytes · HTML source
<!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      &nbsp;&nbsp; 6.5 × 10⁹ M☉</div>
  <div>Distance  &nbsp;&nbsp; 55 Million Light Years</div>
  <div>Horizon   &nbsp;&nbsp; 40 Billion km</div>
  <div>Jet Speed &nbsp;&nbsp; 0.999 c &nbsp;(relativistic)</div>
  <div class="dim">Drag to orbit &nbsp;·&nbsp; Scroll to zoom</div>
</div>

<div id="hud-tr" style="display:none">
  <div class="label">VIRGO A · M87</div>
  <div>RA &nbsp;&nbsp;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>