Show HN: Physically Accurate Black Hole Shader You Can Add to Your Web Project
Meta Description: Show HN: Physically accurate black hole shader built with Three.js and GLSL. Learn how to implement gravitational lensing, ray marching, and accretion disks in your web projects with this practical tutorial.
If you've ever wanted to visualize General Relativity in a browser, you've likely encountered the magic of physically accurate black hole simulations. Recently, a project titled Show HN: Physically accurate black hole caught my attention. It demonstrates a WebGL renderer that simulates gravitational lensing, the photon sphere, and the event horizon with genuine astrophysical fidelity.
In this tutorial, we'll break down how these shaders work and walk through how to create your own physically accurate black hole using Three.js and custom GLSL shaders. Whether you're building an educational tool, a creative coding experiment, or just want to add some serious visual juice to your portfolio, this guide will show you how to implement ray marching in curved spacetime.
What Makes a Black Hole "Physically Accurate"?
When we say a black hole simulation is "physically accurate," we aren't just talking about a black circle with a glow. We're simulating the behavior of light in a strong gravitational field.
Key phenomena that a physically accurate black hole shader must reproduce:
- Gravitational Lensing: Light bends around the mass, creating Einstein rings and multiple images of background stars.
- The Event Horizon: The point of no return where the escape velocity exceeds the speed of light.
- The Photon Sphere: A region where photons orbit the black hole, creating a bright ring of light.
- Doppler Beaming: If the black hole has an accretion disk, one side moves toward you (brighter/bluer) and the other away (dimmer/redder).
The demo at blackhole.plav.in uses these principles to render a Schwarzschild black hole. To achieve this, we use ray marching through a refractive index field that mimics the spacetime curvature described by the Schwarzschild metric.
The Math: Ray Marching in Curved Spacetime
In standard ray marching (like distance field rendering), we step along a straight ray until we hit a surface. In a black hole shader, the ray itself curves.
We approximate this by treating spacetime as a medium with a variable refractive index $n(r)$:
// Schwarzschild radius approximation
// n(r) = 1 - rs / r
float refractiveIndex(float r) {
return 1.0 - (schwarzschildRadius / r);
}
As a light ray passes closer to the black hole, r decreases, and the refractive index drops. This causes the ray to bend toward the mass, simulating gravity's effect on light without solving the full geodesic equation in real-time.
Step-by-Step Tutorial: Building the Shader
Let's build a physically accurate black hole component you can drop into your web app. We'll use Three.js for the scene graph and raw GLSL for the physics.
Step 1: Project Setup
You can scaffold this project quickly. I recommend using GitHub to version-control your shader experiments from day one, especially when dealing with complex GLSL code that benefits from collaboration and history tracking.
mkdir black-hole-demo
cd black-hole-demo
npm init -y
npm install three
Create an index.html and a shader.js. We'll set up a full-screen quad to render our fragment shader.
Step 2: The Fragment Shader
This is where the magic happens. We'll implement a ray marching loop that refracts the view direction based on the black hole's position.
// fragment.glsl
uniform vec2 u_resolution;
uniform vec2 u_mouse;
uniform float u_time;
// Physics constants
const float rs = 0.5; // Schwarzschild radius
const float MAX_STEPS = 80;
const float STEP_SIZE = 0.05;
// Simple hash for procedural noise (accretion disk)
float hash(vec2 p) {
return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453);
}
void main() {
vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution.xy) / u_resolution.y;
// Camera setup
vec3 ro = vec3(0.0, 0.0, 5.0); // Ray origin
vec3 rd = normalize(vec3(uv, -1.0)); // Ray direction
vec3 color = vec3(0.0);
float r = length(ro);
// Ray marching loop
for(int i = 0; i < MAX_STEPS; i++) {
vec3 p = ro + rd * r;
float dist = length(p);
if(dist < rs) {
// Hit the event horizon
color = vec3(0.0); // Pure black
break;
}
// Refraction based on Schwarzschild metric approximation
float n = 1.0 - rs / dist;
// Compute gradient of refractive index
vec2 dp = vec2(0.001, 0.0);
float n_x = 1.0 - rs / (dist + dp.x);
float grad_n = (n_x - n) / dp.x;
// Bend the ray
rd -= grad_n * normalize(p) * STEP_SIZE * 0.5;
rd = normalize(rd);
// Accumulate accretion disk light (simplified)
if(p.y < 0.1 && p.y > -0.1 && dist > rs * 1.5) {
float angle = atan(p.z, p.x);
float noise = hash(vec2(angle * 5.0, dist * 2.0 - u_time));
float intensity = smoothstep(rs * 1.5, rs * 4.0, dist);
color += vec3(1.0, 0.6, 0.2) * noise * intensity * 0.05;
}
r += STEP_SIZE;
if(r > 20.0) break; // Escape to background
}
// Add background stars
if(length(color) < 0.1) {
float star = step(0.998, hash(uv * 100.0));
color += vec3(star);
}
gl_FragColor = vec4(color, 1.0);
}
Step 3: Integrating with Three.js
Wrap your shader in a ShaderMaterial and apply it to a PlaneGeometry that faces the camera. This allows you to reuse the component across your site.
import * as THREE from 'three';
import fragmentShader from './fragment.glsl';
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const renderer = new THREE.WebGLRenderer();
const material = new THREE.ShaderMaterial({
uniforms: {
u_resolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) },
u_time: { value: 0 },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 1.0);
}
`,
fragmentShader: fragmentShader
});
const geometry = new THREE.PlaneGeometry(2, 2);
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
function animate(time) {
material.uniforms.u_time.value = time * 0.001;
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
Enhancing the Simulation
The basic shader above gives you the core lensing effect. To make it truly physically accurate, consider adding these advanced features:
Accretion Disk Dynamics
A real black hole is rarely alone. The accretion disk is a disk of superheated matter spiraling inward. You can simulate this using procedural noise and velocity fields in the shader. Use a spiral noise function to generate the swirling gas pattern, and apply a Fresnel effect to make the disk glow from the edges.
Gravitational Redshift
As light climbs out of the gravity well, it loses energy and shifts toward the red end of the spectrum. You can implement this by multiplying the color intensity by a factor based on the emission radius:
float redshift = sqrt(1.0 - rs / emissionRadius);
color *= redshift;
Interactive Controls
Allow users to change the mass of the black hole or orbit the camera. Use OrbitControls from Three.js to let users explore the simulation from different angles. This interactivity significantly boosts engagement for Show HN posts and tutorials.
Deploying Your Black Hole Demo
Once you've built your physically accurate black hole, you'll want to share it with
Enjoyed this? I build simple, powerful AI tools — try the free Text Summarizer or browse the full toolkit at Solomon Tools. No signup, no subscription.
Top comments (0)