Image 1 of 2
During Bruno Simon's Awwwards Conference 2022 shader workshop in Amsterdam, I built this interactive black hole as a tribute to Interstellar's Gargantua — rendered entirely in the browser with Three.js and custom GLSL. No imported 3D models, no texture files, and no post-processing stack: just a handful of planes and spheres brought to life with shader code.
The workshop was a crash course in writing your own shaders — the point where Three.js stops feeling like magic and starts feeling like graphics programming. The challenge was convincing GLSL: bending light around the event horizon, animating the accretion disk with procedural noise, and keeping the whole scene at 60fps in a normal browser tab.
What stuck with me was how much atmosphere you can get from almost nothing. Drag to orbit the scene — the gravitational lensing looks different from every angle, which is half the fun. It is still one of the projects I am most proud of, and the reason I kept going deeper into WebGL.
I followed Bruno Simon’s Awwwards 2022 workshop guide, which walks through the scene step by step — from an empty Three.js project to a finished black hole with a glowing accretion disk and bent starlight. Bruno also maintains a public reference repo (webgl-black-hole) that is useful to compare against once you have finished the workshop steps.
If you want the full foundations behind the workshop — scene graph, cameras, renderers, materials, and a deep dive into shader math — Three.js Journey is Bruno’s course. The shaders chapter is where this project really starts to make sense: uniforms, varyings, noise functions, and writing fragment logic that Three.js materials cannot give you out of the box.
The workshop starter is a small Vite + Three.js project. Clone or download the starter from the guide, install dependencies once, then run the dev server. The scene will be available on localhost (typically port 8080) and rebuilds as you edit the shader files.
# Install dependencies (first time only)
npm install
# Run the local dev server
npm run dev
# Build for production (outputs to dist/)
npm run buildBefore touching shaders, the guide sets up the basic Three.js loop: a WebGL renderer sized to the viewport, a perspective camera placed back from the origin, and a requestAnimationFrame tick that renders every frame. OrbitControls are added so you can drag to inspect the scene — essential when tuning lensing by eye.
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100)
camera.position.set(0, 2, 6)
const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
document.body.appendChild(renderer.domElement)
const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
const clock = new THREE.Clock()
function tick() {
const elapsedTime = clock.getElapsedTime()
// Pass elapsedTime into shader uniforms here
controls.update()
renderer.render(scene, camera)
requestAnimationFrame(tick)
}
tick()The visual trick is to stop using MeshBasicMaterial or MeshStandardMaterial. Instead, attach a ShaderMaterial to a sphere (the event horizon) and a plane (the accretion disk). The vertex shader mostly passes UVs and world positions through; the fragment shader does the heavy lifting — colour, glow, noise, and lensing distortion.
const blackHoleMaterial = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
uTime: { value: 0 },
uResolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) },
},
transparent: true,
depthWrite: false,
})
const blackHole = new THREE.Mesh(new THREE.SphereGeometry(1, 64, 64), blackHoleMaterial)
scene.add(blackHole)// Fragment shader (simplified lensing idea)
uniform float uTime;
varying vec2 vUv;
void main() {
vec2 center = vec2(0.5);
vec2 dir = vUv - center;
float dist = length(dir);
// Pull UVs toward the centre — stronger when closer
float strength = 0.08 / (dist * dist + 0.02);
vec2 lensedUv = vUv - normalize(dir) * strength;
// Sample noise / colour using lensedUv, animate with uTime
vec3 colour = vec3(0.0);
// ... disk noise, glow, star sampling ...
gl_FragColor = vec4(colour, 1.0);
}The workshop ends with polish: adjusting colour grading on the disk, clamping pixel ratio on high-DPI screens, and checking frame rate on a laptop GPU. Once you are happy with the look, npm run build bundles the project for static hosting — which is how the live demo above is deployed on Vercel.
My version above is my own take from that workshop session — same core technique, with extra experimentation on the disk colours and lensing strength. If you work through the workshop guide and then continue with the shaders track on Three.js Journey, you will have a solid path from this one-day demo to building shader-driven experiences of your own.