HomeProjects3D Black Hole
WiSiHe

Senior Frontend Developer & Digital Artist

Henrik Wilhelm Sissener

Design engineer, painter, and maker based in Oslo. Born in Hammerfest, raised across Norway — from military towns and the coast to Tromsø, Bergen, and the capital. I build accessible web products and make art on the side.

Send a message

Explore

  • Paintings
  • Projects
  • Videos
  • Photography
  • Recipes
  • Contact

More

  • Presentations
  • 3D Workshop
  • Design System

Connect

© 2026 Henrik Wilhelm Sissener

PrivacyBuilt in Oslo with Next.js & Sanityv1.1.0

Image 1 of 2

Development

3D Black Hole

StatusCompleted
Year2022
Views231
Likes1

Tech Stack

Overview

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.

Live site

workshop-shaders.vercel.app

Opens in a new tab — not embedded for performance and privacy.

Visit live site

How the demo was built

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.

1. Project setup

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.

terminal
# Install dependencies (first time only) npm install # Run the local dev server npm run dev # Build for production (outputs to dist/) npm run build

2. Scene, camera, and controls

Before 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.

  • Create a PerspectiveCamera and position it so the black hole sits near the centre of the frame.
  • Attach a WebGLRenderer to the page and handle window resize so the canvas stays full-screen.
  • Add OrbitControls with damping enabled — the slow settle after dragging makes the lensing effect easier to appreciate.
  • Drive uTime (or equivalent) from the animation loop so shaders can animate the accretion disk over time.
main.js
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()

3. Custom shaders and the black hole look

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.

  • Event horizon: a dark sphere with a shader that absorbs light and creates a hard falloff at the edge.
  • Accretion disk: a flat plane (or slightly warped geometry) with procedural noise scrolling over UVs to simulate swirling hot gas.
  • Starfield: either a large inverted sphere or a particle field behind the black hole, sampled in the same fragment pass.
  • Gravitational lensing: in the fragment shader, offset UV coordinates toward the black hole centre based on distance — closer pixels bend more, which sells the Interstellar look.
BlackHole.js
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.glsl
// 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); }

4. Tuning, performance, and deploy

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.

  • Keep geometry simple — a subdivided sphere and one or two planes are enough. Complexity lives in the shader, not the mesh count.
  • Cap devicePixelRatio at 2 to avoid burning GPU on retina displays.
  • Iterate on uniform values (lensing strength, disk speed, colour ramps) while orbiting — small GLSL tweaks change the mood dramatically.
  • Deploy the dist/ folder to any static host. The live demo runs as a self-contained WebGL page with no backend.

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.