Interactive singularity scene featuring smooth state transitions from calm accretion flow to turbulence and relativistic collapse. Built with JavaScript, Three.js, GLSL shaders, and GSAP.
A Pen by Kevin Marville on CodePen.
Interactive singularity scene featuring smooth state transitions from calm accretion flow to turbulence and relativistic collapse. Built with JavaScript, Three.js, GLSL shaders, and GSAP.
A Pen by Kevin Marville on CodePen.
| <style> | |
| body { | |
| margin: 0; | |
| overflow: hidden; | |
| background-color: #010103; | |
| font-family: 'Inter', -apple-system, sans-serif; | |
| color: #fff; | |
| } | |
| canvas { | |
| display: block; | |
| } | |
| #overlay { | |
| position: absolute; | |
| inset: 0; | |
| pointer-events: none; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: space-between; | |
| padding: 40px; | |
| background: radial-gradient(circle at center, transparent 30%, rgba(0, 0, 0, 0.5) 100%); | |
| z-index: 10; | |
| } | |
| .header { | |
| text-align: center; | |
| } | |
| .title { | |
| font-size: 1.2rem; | |
| letter-spacing: 0.8em; | |
| text-transform: uppercase; | |
| color: #fff; | |
| margin-bottom: 12px; | |
| font-weight: 300; | |
| opacity: 0.9; | |
| } | |
| .status-pill { | |
| display: inline-block; | |
| padding: 6px 20px; | |
| background: rgba(255, 255, 255, 0.03); | |
| border: 1px solid rgba(255, 255, 255, 0.15); | |
| border-radius: 30px; | |
| font-size: 0.65rem; | |
| letter-spacing: 0.25em; | |
| text-transform: uppercase; | |
| transition: all 1.5s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| .hud-bottom { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: flex-end; | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 0.7rem; | |
| opacity: 0.6; | |
| letter-spacing: 1px; | |
| } | |
| .metric { | |
| margin-bottom: 6px; | |
| } | |
| .val { | |
| color: #00f3ff; | |
| font-weight: bold; | |
| transition: color 1.5s ease; | |
| } | |
| #vignette { | |
| position: fixed; | |
| inset: 0; | |
| background: radial-gradient(circle, transparent 50%, black 150%); | |
| pointer-events: none; | |
| z-index: 5; | |
| } | |
| </style> | |
| <script async src="https://unpkg.com/es-module-shims@1.8.0/dist/es-module-shims.js"></script> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js", | |
| "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/", | |
| "gsap": "https://unpkg.com/gsap@3.12.5/index.js" | |
| } | |
| } | |
| </script> | |
| <div id="vignette"></div> | |
| <div id="overlay"> | |
| <div class="header"> | |
| <div class="title" id="main-title">Stable Singularity</div> | |
| <div class="status-pill" id="status-text">Topology: Nominal</div> | |
| </div> | |
| <div class="hud-bottom"> | |
| <div> | |
| <div class="metric">MASS_INDEX: <span class="val">4.2M SOL</span></div> | |
| <div class="metric">LENSING: <span class="val" id="lensing-val">SCHWARZSCHILD</span></div> | |
| </div> | |
| <div style="text-align: right"> | |
| <div class="metric">RELATIVITY: <span class="val" id="vel-val">0.45c</span></div> | |
| <div class="metric">RADIATION: <span class="val">DETECTION ON</span></div> | |
| </div> | |
| </div> | |
| </div> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| import { | |
| OrbitControls | |
| } from 'three/addons/controls/OrbitControls.js'; | |
| import gsap from 'gsap'; | |
| const scene = new THREE.Scene(); | |
| const camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| camera.position.set(60, 30, 60); | |
| const renderer = new THREE.WebGLRenderer({ | |
| antialias: true, | |
| powerPreference: "high-performance" | |
| }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; | |
| renderer.toneMappingExposure = 1.6; | |
| document.body.appendChild(renderer.domElement); | |
| const controls = new OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.03; | |
| controls.autoRotate = true; | |
| controls.autoRotateSpeed = 0.4; | |
| const noiseChunk = ` | |
| vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } | |
| vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } | |
| vec4 permute(vec4 x) { return mod289(((x*34.0)+1.0)*x); } | |
| vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; } | |
| float snoise(vec3 v) { | |
| const vec2 C = vec2(1.0/6.0, 1.0/3.0) ; | |
| const vec4 D = vec4(0.0, 0.5, 1.0, 2.0); | |
| vec3 i = floor(v + dot(v, C.yyy) ); | |
| vec3 x0 = v - i + dot(i, C.xxx) ; | |
| vec3 g = step(x0.yzx, x0.xyz); | |
| vec3 l = 1.0 - g; | |
| vec3 i1 = min( g.xyz, l.zxy ); | |
| vec3 i2 = max( g.xyz, l.zxy ); | |
| vec3 x1 = x0 - i1 + C.xxx; | |
| vec3 x2 = x0 - i2 + C.yyy; | |
| vec3 x3 = x0 - D.yyy; | |
| i = mod289(i); | |
| vec4 p = permute( permute( permute( i.z + vec4(0.0, i1.z, i2.z, 1.0 )) + i.y + vec4(0.0, i1.y, i2.y, 1.0 )) + i.x + vec4(0.0, i1.x, i2.x, 1.0 )); | |
| float n_ = 0.142857142857; | |
| vec3 ns = n_ * D.wyz - D.xzx; | |
| vec4 j = p - 49.0 * floor(p * ns.z * ns.z); | |
| vec4 x_ = floor(j * ns.z); | |
| vec4 y_ = floor(j - 7.0 * x_ ); | |
| vec4 x = x_ *ns.x + ns.yyyy; | |
| vec4 y = y_ *ns.x + ns.yyyy; | |
| vec4 h = 1.0 - abs(x) - abs(y); | |
| vec4 b0 = vec4( x.xy, y.xy ); | |
| vec4 b1 = vec4( x.zw, y.zw ); | |
| vec4 s0 = floor(b0)*2.0 + 1.0; | |
| vec4 s1 = floor(b1)*2.0 + 1.0; | |
| vec4 sh = -step(h, vec4(0.0)); | |
| vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy ; | |
| vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww ; | |
| vec3 p0 = vec3(a0.xy,h.x); | |
| vec3 p1 = vec3(a0.zw,h.y); | |
| vec3 p2 = vec3(a1.xy,h.z); | |
| vec3 p3 = vec3(a1.zw,h.w); | |
| vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3))); | |
| p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w; | |
| vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0); | |
| m = m * m; | |
| return 42.0 * dot( m*m, vec4( dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3) ) ); | |
| } | |
| `; | |
| const coreGroup = new THREE.Group(); | |
| scene.add(coreGroup); | |
| const bhMat = new THREE.MeshBasicMaterial({ | |
| color: 0x000000 | |
| }); | |
| const bhGeo = new THREE.SphereGeometry(4, 64, 64); | |
| coreGroup.add(new THREE.Mesh(bhGeo, bhMat)); | |
| const auraMat = new THREE.ShaderMaterial({ | |
| uniforms: { | |
| uTime: { | |
| value: 0 | |
| }, | |
| uIntensity: { | |
| value: 1.0 | |
| } | |
| }, | |
| vertexShader: ` | |
| varying vec3 vNormal; | |
| varying vec3 vView; | |
| void main() { | |
| vNormal = normalize(normalMatrix * normal); | |
| vView = normalize(-(modelViewMatrix * vec4(position, 1.0)).xyz); | |
| gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); | |
| } | |
| `, | |
| fragmentShader: ` | |
| uniform float uIntensity; | |
| varying vec3 vNormal; | |
| varying vec3 vView; | |
| void main() { | |
| float rim = pow(1.0 - max(dot(vNormal, vView), 0.0), 4.0); | |
| gl_FragColor = vec4(vec3(1.0, 0.45, 0.1) * rim * uIntensity * 5.0, 1.0); | |
| } | |
| `, | |
| side: THREE.BackSide, | |
| transparent: true, | |
| blending: THREE.AdditiveBlending | |
| }); | |
| coreGroup.add(new THREE.Mesh(new THREE.SphereGeometry(4.25, 64, 64), auraMat)); | |
| const instanceCount = 5000; | |
| const streakGeo = new THREE.CylinderGeometry(0.01, 0.12, 2.2, 3); | |
| streakGeo.rotateX(Math.PI / 2); | |
| const diskMaterial = new THREE.ShaderMaterial({ | |
| uniforms: { | |
| uTime: { | |
| value: 0 | |
| }, | |
| uMorph: { | |
| value: 0.1 | |
| }, | |
| uCompression: { | |
| value: 1.0 | |
| }, | |
| uIntensity: { | |
| value: 1.0 | |
| }, | |
| uOrbitScale: { | |
| value: 1.0 | |
| } | |
| }, | |
| vertexShader: ` | |
| ${noiseChunk} | |
| uniform float uTime; | |
| uniform float uMorph; | |
| uniform float uCompression; | |
| uniform float uIntensity; | |
| uniform float uOrbitScale; | |
| varying vec3 vColor; | |
| varying float vOpacity; | |
| void main() { | |
| vec4 instPos = instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0); | |
| float rOriginal = length(instPos.xz); | |
| float r = rOriginal * uCompression; | |
| float initialAngle = atan(instPos.z, instPos.x); | |
| float orbitalVelocity = (1.5 / sqrt(rOriginal)) * uOrbitScale; | |
| float currentAngle = initialAngle + (uTime * orbitalVelocity); | |
| vec3 morphedWorldPos = vec3(cos(currentAngle) * r, instPos.y, sin(currentAngle) * r); | |
| float noise = snoise(vec3(morphedWorldPos.x * 0.08, morphedWorldPos.z * 0.08, uTime * 0.3)); | |
| morphedWorldPos.y += noise * uMorph * 4.0; | |
| vec3 viewDir = normalize(cameraPosition - morphedWorldPos); | |
| vec3 orbitDir = normalize(vec3(-sin(currentAngle), 0.0, cos(currentAngle))); | |
| float doppler = dot(orbitDir, viewDir); | |
| vec3 hot = vec3(1.0, 0.95, 0.9); | |
| vec3 warm = vec3(1.0, 0.45, 0.1); | |
| vec3 cool = vec3(0.1, 0.35, 1.0); | |
| vec3 color = mix(cool, warm, smoothstep(45.0, 12.0, r)); | |
| color = mix(color, hot, smoothstep(10.0, 4.0, r)); | |
| vColor = color * (1.3 + doppler * 0.7) * uIntensity; | |
| vOpacity = (smoothstep(3.8, 5.5, r) * (1.0 - smoothstep(38.0, 48.0, r))) * 0.8; | |
| float deltaAngle = currentAngle - initialAngle; | |
| float c = cos(deltaAngle); | |
| float s = sin(deltaAngle); | |
| mat3 rotY = mat3( | |
| c, 0, s, | |
| 0, 1, 0, | |
| -s, 0, c | |
| ); | |
| vec3 localPos = (instanceMatrix * vec4(position, 0.0)).xyz; | |
| vec3 rotatedLocalPos = rotY * localPos; | |
| gl_Position = projectionMatrix * viewMatrix * vec4(morphedWorldPos + rotatedLocalPos, 1.0); | |
| } | |
| `, | |
| fragmentShader: ` | |
| varying vec3 vColor; | |
| varying float vOpacity; | |
| void main() { | |
| gl_FragColor = vec4(vColor, vOpacity); | |
| } | |
| `, | |
| transparent: true, | |
| blending: THREE.AdditiveBlending, | |
| depthWrite: false | |
| }); | |
| const instancedDisk = new THREE.InstancedMesh(streakGeo, diskMaterial, instanceCount); | |
| const dummy = new THREE.Object3D(); | |
| for (let i = 0; i < instanceCount; i++) { | |
| const r = 5 + Math.pow(Math.random(), 1.3) * 40; | |
| const angle = Math.random() * Math.PI * 2; | |
| dummy.position.set(Math.cos(angle) * r, (Math.random() - 0.5) * (8 / r), Math.sin(angle) * r); | |
| dummy.lookAt(dummy.position.x + Math.sin(angle), dummy.position.y, dummy.position.z - Math.cos(angle)); | |
| dummy.updateMatrix(); | |
| instancedDisk.setMatrixAt(i, dummy.matrix); | |
| } | |
| scene.add(instancedDisk); | |
| const config = [{ | |
| title: "Stable Singularity", | |
| status: "Topology: Nominal", | |
| morph: 0.1, | |
| compress: 1.0, | |
| intensity: 1.0, | |
| rotate: 0.4, | |
| camY: 25, | |
| camDist: 85, | |
| orbit: 1.0, | |
| color: "#00f3ff", | |
| vel: "0.45c" | |
| }, | |
| { | |
| title: "Accretion Turbulence", | |
| status: "Topology: Fluctuating", | |
| morph: 4.5, | |
| compress: 1.15, | |
| intensity: 1.4, | |
| rotate: 1.5, | |
| camY: 45, | |
| camDist: 95, | |
| orbit: 1.8, | |
| color: "#ffaa00", | |
| vel: "0.78c" | |
| }, | |
| { | |
| title: "Relativistic Collapse", | |
| status: "Topology: Critical", | |
| morph: 0.8, | |
| compress: 0.38, | |
| intensity: 3.5, | |
| rotate: 5.0, | |
| camY: 12, | |
| camDist: 55, | |
| orbit: 4.5, | |
| color: "#ff0044", | |
| vel: "0.99c" | |
| } | |
| ]; | |
| let stateIdx = 0; | |
| const mainTitle = document.getElementById('main-title'); | |
| const statusText = document.getElementById('status-text'); | |
| const velVal = document.getElementById('vel-val'); | |
| const camControl = { | |
| distance: 85 | |
| }; | |
| function transition() { | |
| stateIdx = (stateIdx + 1) % config.length; | |
| const s = config[stateIdx]; | |
| const tl = gsap.timeline({ | |
| defaults: { | |
| duration: 4.0, | |
| ease: "power2.inOut" | |
| } | |
| }); | |
| tl.to(diskMaterial.uniforms.uMorph, { | |
| value: s.morph | |
| }, 0); | |
| tl.to(diskMaterial.uniforms.uCompression, { | |
| value: s.compress | |
| }, 0); | |
| tl.to(diskMaterial.uniforms.uIntensity, { | |
| value: s.intensity | |
| }, 0); | |
| tl.to(diskMaterial.uniforms.uOrbitScale, { | |
| value: s.orbit | |
| }, 0); | |
| tl.to(auraMat.uniforms.uIntensity, { | |
| value: s.intensity | |
| }, 0); | |
| tl.to(controls, { | |
| autoRotateSpeed: s.rotate | |
| }, 0); | |
| tl.to(camera.position, { | |
| y: s.camY | |
| }, 0); | |
| tl.to(camControl, { | |
| distance: s.camDist | |
| }, 0); | |
| gsap.to([mainTitle, statusText, '.val'], { | |
| opacity: 0, | |
| duration: 0.8, | |
| onComplete: () => { | |
| mainTitle.innerText = s.title; | |
| statusText.innerText = s.status; | |
| statusText.style.color = s.color; | |
| statusText.style.borderColor = s.color; | |
| velVal.innerText = s.vel; | |
| velVal.style.color = s.color; | |
| gsap.to([mainTitle, statusText, '.val'], { | |
| opacity: 1, | |
| duration: 1.2 | |
| }); | |
| } | |
| }); | |
| } | |
| setInterval(transition, 10000); | |
| const clock = new THREE.Clock(); | |
| function animate() { | |
| const time = clock.getElapsedTime(); | |
| diskMaterial.uniforms.uTime.value = time; | |
| auraMat.uniforms.uTime.value = time; | |
| instancedDisk.rotation.y += 0.0005; | |
| const currentDir = new THREE.Vector3().subVectors(camera.position, controls.target).normalize(); | |
| camera.position.x = controls.target.x + currentDir.x * camControl.distance; | |
| camera.position.z = controls.target.z + currentDir.z * camControl.distance; | |
| controls.update(); | |
| renderer.render(scene, camera); | |
| requestAnimationFrame(animate); | |
| } | |
| window.addEventListener('resize', () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| animate(); | |
| </script> |