01
What You're Looking At
The animation behind this page is not a video or a GIF or anything pre-rendered. It's a fragment shader, a small program running on your GPU right now, computing the color of every pixel on screen sixty times a second.
A fragment shader takes in a pixel coordinate and spits out a color. There's no scene graph, no physics, no loop over objects. It's pure math: position goes in, color comes out. All the complexity you see on screen, the grain, the swirl, the shape of the black hole, it all comes from a few hundred lines of GLSL running in parallel across millions of pixels.
This article walks through that shader piece by piece. I wrote the explanation I wish I had when I was learning this stuff. You don't need prior shader experience, just some curiosity.
02
The Setup
The renderer is Three.js with WebGL. But the 3D engine barely does anything here. There are no models, no lighting, no materials in the traditional sense. Three.js is really just a bridge to get our shader onto the GPU.
The scene has exactly two objects:
- A background plane: a flat rectangle filling the screen with a solid dark color. Think of it as the wall behind the effect.
- A grain plane: another flat rectangle layered in front, where the actual shader runs. This one is transparent. It outputs an alpha channel, so wherever the effect doesn't draw, the dark background shows through.
The camera is orthographic, so there's no perspective distortion. The grain plane stays the same size on screen regardless of distance. This matters because the shader works in UV coordinates (0 to 1 across the surface), and we need that mapping to stay stable.
JavaScript — Scene setup
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(
-1, 1, // left, right
1/aspect, -1/aspect, // top, bottom (corrected for aspect ratio)
0.1, 1000 // near, far
);
// Background: solid color, z = 0
const bgMesh = new THREE.Mesh(
new THREE.PlaneGeometry(4, 4),
solidColorMaterial
);
// Grain effect: transparent shader, z = 1 (in front)
const grainMesh = new THREE.Mesh(
new THREE.PlaneGeometry(3, 3),
new THREE.RawShaderMaterial({
uniforms, vertexShader, fragmentShader,
transparent: true
})
);
The vertex shader is basically a pass-through. It just forwards the UV coordinates to the fragment shader. All the interesting work happens per-pixel.
03
Noise
The foundation of the entire effect is simplex noise. It's a mathematical function that takes a 2D coordinate and returns a smooth, random-looking value between -1 and 1. Unlike true randomness (which would look like TV static), simplex noise is coherent: nearby points return similar values, so you get these organic gradients and blobs instead of pure chaos.
We're using the Ashima Arts implementation, a well-known open-source simplex noise that runs efficiently on GPUs. The raw function snoise(vec2) returns values in [-1, 1]. We remap it to [0, 1] with a wrapper:
GLSL — Noise primitives
float snoise01(vec2 v) {
return (1.0 + snoise(v)) * 0.5;
}
float noise2d(vec2 st) {
return snoise01(vec2(
st.x + time * 0.02, // drift right slowly
st.y - time * 0.04 + seed // drift down, offset by random seed
));
}
noise2d adds time to the input coordinates, which is what makes the noise drift and creates that slow, evolving motion. The seed uniform is a random number set once on page load, so every visitor sees a slightly different pattern.
Domain Warping
On its own, raw noise just looks like blurry blobs. To get the complex, swirling, almost-organic patterns you see, we use a technique called domain warping. The idea is simple: use noise to distort the input coordinates of other noise.
GLSL — Domain warping (the pattern function)
float pattern(vec2 p) {
// Layer 1: two noise samples offset in space
vec2 q = vec2(
noise2d(p + vec2(0.0, 0.0)),
noise2d(p + vec2(5.2, 1.3))
);
// Layer 2: use q to warp the coordinates of new noise
vec2 r = vec2(
noise2d(p + 4.0*q + vec2(1.7, 9.2)),
noise2d(p + 4.0*q + vec2(8.3, 2.8))
);
// Layer 3: final noise, warped by r
return noise2d(p + 1.0*r);
}
This is noise of noise of noise. Each layer feeds into the next, amplifying the distortion. The magic numbers (5.2, 1.3, 1.7, 9.2...) are arbitrary offsets that prevent the layers from correlating. They just need to be "far apart" in noise space. The result looks like slow turbulence, like ink in water or smoke in still air.
04
The Grain Texture
If domain warping creates the flow, the grain texture is what creates the texture. It's the gritty, particulate quality that makes the light feel physical rather than digital.
grain.webp is a 1024×1024 image, but it doesn't contain a picture. It encodes two values per pixel:
- Red channel stores magnitude: how far this particle should scatter from its position.
- Green channel stores angle: the direction of the scatter, from 0 to 2π.
Together, they define a polar displacement vector for each pixel. The shader samples this texture in pixel space (not UV space), so the grain pattern is density-independent. It looks the same regardless of the mesh size.
GLSL — Grain sampling and polar displacement
// Sample grain at pixel coords, tiled to 1024px
vec3 grainColor = texture2D(grainTex,
mod(p * param1 * 5.0, 1024.0) / 1024.0).rgb;
// Convert R (magnitude) and G (angle) to cartesian offset
float gr = pow(grainColor.r, 1.5) + 0.5 * (1.0 - blurAlpha);
float gg = grainColor.g;
float ax = param2 * gr * cos(gg * 2.0 * PI);
float ay = param2 * gr * sin(gg * 2.0 * PI);
pow(grainColor.r, 1.5) compresses the magnitude distribution so that most particles scatter a little and only a few scatter a lot. The offset ax, ay gets added to the noise coordinates before calling pattern(), which means each pixel ends up evaluating the noise at a slightly different location. That's what breaks up the smooth noise field into that grainy, particulate texture.
The term 0.5 * (1.0 - blurAlpha) increases scatter outside the shape, which softens the boundary. Inside the shape where blurAlpha = 1, scatter is tighter and the grain looks denser.
05
The Shape
The black hole shape is not an image. There's no PNG loaded from a file. It's built entirely from math inside the fragment shader as a procedural shape mask called blurAlpha.
The mask has two components:
- The ring: a donut shape with an inner radius (the "hole") and an outer radius that varies with elevation angle. This variation is what creates the illusion of a 3D torus seen at an angle.
- The accretion disk: a thin horizontal band that tapers as it extends outward, like matter spiraling into the black hole.
Before computing the shape, we rotate the coordinate system about 29 degrees and scale it, which tilts the black hole across the viewport.
GLSL — Black hole shape mask
// Translate to center, rotate ~29°, scale
vec2 d = uv - vec2(0.63, 0.50);
float ca = cos(0.50), sa = sin(0.50);
d = vec2(ca * d.x + sa * d.y, -sa * d.x + ca * d.y);
d *= 0.79;
float dist = length(d);
float holeR = 0.10; // inner void radius
// Ring: outer radius varies with "elevation" for 3D illusion
float sinEl = d.y / (dist + 0.001);
float outerR = mix(0.16, 0.135, (sinEl + 1.0) * 0.5);
float equator = 1.0 - abs(sinEl);
outerR += equator * equator * 0.015; // slight bulge at equator
float ring = smoothstep(holeR, holeR + 0.02, dist)
* (1.0 - smoothstep(outerR, outerR + 0.06, dist));
// Disk: horizontal band that tapers outward
float taper = mix(0.07, 0.015, smoothstep(0.12, 0.35, abs(d.x)));
float hBand = (1.0 - smoothstep(0.0, taper, abs(d.y)))
* (1.0 - smoothstep(0.22, 0.40, abs(d.x)));
float blurAlpha = max(ring, hBand);
smoothstep does most of the heavy lifting here. It creates smooth transitions, so the ring doesn't have a hard edge. Instead it fades from 0 to 1 over a small range. The inner smoothstep carves out the hole, the outer one fades the ring away. Multiplying them together creates the donut.
The sinEl trick is how we approximate viewing angle. At the "top" and "bottom" of the ring, the outer radius shrinks. At the "sides" (the equator), it's widest. That one line of math is what sells the illusion of looking at a 3D torus from an angle.
The accretion disk (hBand) is simpler, just a horizontal stripe that's thick near the center and tapers to nothing at the edges. max(ring, hBand) combines both: wherever either shape is active, particles show up.
06
The Threshold
At this point we have a noise value n between 0 and 1 for every pixel. If you used it directly as brightness, you'd see a smooth, cloudy wash of gray. Soft and lifeless.
The threshold step is what gives the grain its character. It's the difference between a blurry fog and sparse bright particles against deep darkness.
GLSL — The contrast curve
float n = pattern(vec2(nx, ny)); // value in [0, 1]
n = pow(n * 1.05, 6.0); // crush everything below the peak
n = smoothstep(0.0, 1.0, n); // clamp and smooth
pow(x, 6.0) is a steep contrast curve. A noise value of 0.5 becomes 0.56 = 0.016, practically invisible. A value of 0.8 becomes 0.86 = 0.26, barely there. Only values very close to 1.0 survive with any real brightness. Most of the noise field goes dark, and only the brightest peaks remain as visible particles.
The 1.05 multiplier is a subtle bias that pushes all values slightly up before the power function. It controls the overall density of visible grain. Bump it up and you get more particles. Lower it and the effect gets sparser.
The final smoothstep clamps the result to [0, 1] and adds a slight S-curve, giving us clean output values for the color mix.
Honestly, pow(n * 1.05, 6.0) is probably the most important line in the entire shader. It's what transforms amorphous noise into something that actually looks like physical particles of light.
07
Color and Compositing
The final step blends everything into a visible result.
GLSL — Final output
vec3 front = vec3(0.84, 0.86, 0.80); // warm off-white
vec3 result = mix(back, front, n); // blend by intensity
gl_FragColor = vec4(result, blurAlpha);
mix(back, front, n) is linear interpolation. When n = 0 (which is most pixels), you get back, the dark background color. When n = 1 (the bright grain peaks), you get front, a warm off-white. Anything in between produces subtle grays.
The alpha channel gets set to blurAlpha, the shape mask. This is the key to the two-layer trick: inside the black hole shape, the grain plane is opaque and the particles are visible. Outside the shape, alpha drops to zero, the grain plane disappears, and the solid dark background plane behind it shows through.
And that's the whole thing. Particles of light that exist only within the geometry of a black hole, flickering and drifting as the noise field evolves. No images, no pre-rendered frames, just math running live on your GPU.
Credits
Credits
The grain texture, simplex noise implementation, domain warping technique, and animation parameters are inspired by p5aholic (Keita Yamada) and his portfolio. The original effect uses a pre-rendered blur texture for the shape mask. We replaced that with a procedural black hole built entirely in GLSL.
The simplex noise is the Ashima Arts implementation by Ian McEwan and Stefan Gustavson.