From 07b56d937f3c111740fe56d194b9f9ce8fcafef3 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 00:12:39 -0400 Subject: [PATCH] feat: add black hole with gravitational lensing shader (#118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Event horizon: pure black sphere in the far distance - Gravitational lensing sphere: Fresnel-based shader with sharp Einstein ring at photon sphere edge and animated secondary ring shimmer - Accretion disk: RingGeometry with custom shader featuring Doppler brightening, turbulent swirl animation, and hot inner / cool outer color gradient (white-blue → orange → purple) Fixes #118 --- app.js | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/app.js b/app.js index 4902f2f..14d8486 100644 --- a/app.js +++ b/app.js @@ -116,6 +116,129 @@ function buildConstellationLines() { const constellationLines = buildConstellationLines(); scene.add(constellationLines); +// === BLACK HOLE with GRAVITATIONAL LENSING === +const BH_POS = new THREE.Vector3(-120, 30, -250); +const BH_RADIUS = 14; + +// Event horizon — pure black sphere +const eventHorizonGeo = new THREE.SphereGeometry(BH_RADIUS, 64, 64); +const eventHorizonMat = new THREE.MeshBasicMaterial({ color: 0x000000 }); +const eventHorizon = new THREE.Mesh(eventHorizonGeo, eventHorizonMat); +eventHorizon.position.copy(BH_POS); +scene.add(eventHorizon); + +// Gravitational lensing sphere — custom Fresnel-based shader simulating light bending +const lensRadius = BH_RADIUS * 2.6; +const lensGeo = new THREE.SphereGeometry(lensRadius, 128, 128); +const lensMat = new THREE.ShaderMaterial({ + uniforms: { + time: { value: 0.0 }, + }, + vertexShader: /* glsl */` + varying vec3 vViewNormal; + void main() { + vViewNormal = normalize(normalMatrix * normal); + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: /* glsl */` + uniform float time; + varying vec3 vViewNormal; + + void main() { + // Fresnel: 0 at face-on, 1 at grazing angle (edge of sphere) + float fresnel = 1.0 - abs(vViewNormal.z); + + // Einstein ring — sharp bright ring at the edge (photon sphere) + float einsteinRing = pow(fresnel, 10.0); + + // Secondary photon ring — fainter inner ring + float ring2 = smoothstep(0.55, 0.60, fresnel) * smoothstep(0.72, 0.67, fresnel) * 0.5; + + // Shimmer animation + float shimmer = sin(time * 1.8) * 0.08 + 0.92; + + // Blue-white for Einstein ring, warmer for secondary + vec3 einsteinColor = mix(vec3(0.6, 0.8, 1.0), vec3(1.0, 0.9, 0.7), ring2); + float alpha = (einsteinRing * 1.8 + ring2) * shimmer; + + gl_FragColor = vec4(einsteinColor, clamp(alpha, 0.0, 1.0)); + } + `, + transparent: true, + side: THREE.FrontSide, + depthWrite: false, + blending: THREE.AdditiveBlending, +}); +const lensMesh = new THREE.Mesh(lensGeo, lensMat); +lensMesh.position.copy(BH_POS); +scene.add(lensMesh); + +// Accretion disk — glowing ring with Doppler-shifted animated shader +const diskInner = BH_RADIUS * 1.25; +const diskOuter = BH_RADIUS * 4.2; +const diskGeo = new THREE.RingGeometry(diskInner, diskOuter, 128, 8); +const diskMat = new THREE.ShaderMaterial({ + uniforms: { + time: { value: 0.0 }, + }, + vertexShader: /* glsl */` + varying vec2 vUv; + varying vec3 vLocalPos; + void main() { + vUv = uv; + vLocalPos = position; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: /* glsl */` + uniform float time; + varying vec2 vUv; + varying vec3 vLocalPos; + + float hash(float n) { return fract(sin(n) * 43758.5453); } + float noise(float x) { + float i = floor(x); + float f = fract(x); + return mix(hash(i), hash(i + 1.0), smoothstep(0.0, 1.0, f)); + } + + void main() { + // vUv.x: 0=inner edge, 1=outer edge (RingGeometry convention) + float r = vUv.x; + float angle = atan(vLocalPos.y, vLocalPos.x); + + // Animated turbulent swirl + float swirl = noise(angle * 4.0 - time * 0.8 + r * 6.0) * 0.5 + 0.5; + + // Color: white-blue inner (hottest), orange mid, dim purple outer + vec3 innerColor = vec3(1.0, 0.95, 0.85); + vec3 midColor = vec3(1.0, 0.45, 0.08); + vec3 outerColor = vec3(0.4, 0.08, 0.35); + vec3 diskColor = r < 0.35 + ? mix(innerColor, midColor, r / 0.35) + : mix(midColor, outerColor, (r - 0.35) / 0.65); + + // Relativistic Doppler brightening: approaching side brightens + float doppler = sin(angle - time * 0.25) * 0.35 + 0.75; + + float turbulence = swirl * 0.55 + 0.45; + float opacity = (1.0 - r * 0.65) * turbulence * doppler; + opacity = clamp(opacity, 0.0, 1.0); + + gl_FragColor = vec4(diskColor * (0.8 + doppler * 0.6), opacity * 0.95); + } + `, + transparent: true, + side: THREE.DoubleSide, + depthWrite: false, + blending: THREE.AdditiveBlending, +}); +const diskMesh = new THREE.Mesh(diskGeo, diskMat); +diskMesh.position.copy(BH_POS); +diskMesh.rotation.x = Math.PI * 0.18; // slight tilt for 3D depth +scene.add(diskMesh); + // === MOUSE-DRIVEN ROTATION === let mouseX = 0; let mouseY = 0; @@ -250,6 +373,10 @@ function animate() { // Subtle pulse on constellation opacity constellationLines.material.opacity = 0.12 + Math.sin(elapsed * 0.5) * 0.06; + // Animate black hole shaders + lensMat.uniforms.time.value = elapsed; + diskMat.uniforms.time.value = elapsed; + if (photoMode) { orbitControls.update(); composer.render(); -- 2.43.0