Through the Wormhole
A sphere you can look through into another universe, our own sky bent into rings around it, a vortex of glowing gas, then the plunge. One fullscreen shader, no geometry, no physics engine: just one question asked for every pixel — how close does my ray pass to the mouth?
The scene
The supernova has faded. Ahead, a strange sphere hangs in space. It is not a planet: through it you see a different sky, a spiral galaxy that doesn't exist anywhere around us. The stars around the sphere are smeared into arcs and rings, as if seen through a glass ball, and a vortex of violet gas whirls around its edge. The camera glides in for about ten seconds, the view dissolves into a tunnel of streaking light that rushes past faster and faster, a white glow grows at its end — and we come out under the other sky, facing the galaxy of the next scene.
The scene lasts 32 beats, 15.36 seconds at 125 BPM (it starts at beat 432, about 3 min 27 s into the demo, crossfading in
from the supernova over 2 beats). Everything you see is computed by a single fragment shader drawn over the whole screen.
For each pixel, the shader shoots a ray from the camera and asks one question: how close does this ray pass to the center of
the mouth? That one distance — the impact parameter b — decides everything: rays that pass inside the
mouth see the other universe, rays that pass outside are bent and see a distorted version of ours, rays that graze its edge
light up a thin ring. The gas and the tunnel are layered on top.
Live: the real shaders
This is the actual wormhole scene: the demo's wormhole.frag, with the helpers it includes copied in, running in
WebGL2. The camera path, the tunnel and exit blends, the tunnel's travel and the beat pulse come from a line-by-line JavaScript
copy of the scene's C++ render(). The 3D noise texture the shader reads is generated on your GPU when the page
loads, by the demo's own noise generator. Drag the time slider to scrub: every frame is a pure function of time.
Changes for WebGL: #version 300 es and precision qualifiers; the noise
volume generator (a compute shader in the demo) runs as a fragment shader drawing the 128 slices of the 128³ texture one by one.
The demo's post chain (bloom, chromatic aberration, grain) is replaced by a simple ACES tone map and a glow with a hard threshold of
1.0 on blurred samples. The demo's soft knee starts admitting light above a brightness of 0.5 at that threshold. One line differs in the shader: the stars of
sky.glsl are sized in pixels, so their pixelAngle uses the demo's 1080-line frame instead of this
smaller canvas — otherwise every star would cover several times more of the picture than in the demo.
Background
Eight small ideas make up the scene. A word first about what it is not: it is not a general-relativity simulation. Real wormholes are hypothetical solutions of Einstein's equations — the "Einstein–Rosen bridge" of 1935, named wormhole by John Wheeler in 1957 — and rendering one exactly means integrating curved light paths through warped spacetime, as was done for the film Interstellar (2014). The demo borrows the look of those images (a sphere showing another sky, lensed stars around it) with a few lines of cheap, closed-form math.
1 · A script, not a simulation
Like every scene of the demo, the wormhole is a pure function of time: give it a time t between 0 and
15.36 s and it computes the frame, with no memory of earlier frames. The C++ code turns t into a handful of
numbers, the scene's script:
- approach: over the first 72 % of the scene, the camera's distance from the mouth eases from 220 units down to
10.5 — just outside the sphere of radius
R= 10. Asmoothstepmakes it start and stop gently. - The camera drifts sideways (
sin(0.17 t) · distance · 0.12), sits a little above the mouth's plane (distance · 0.08 + 2) and always looks at the mouth's center. - tunnel: a blend weight that rises from 0 to 1 between 64 % and 74 % of the scene, and falls back between 90 % and 97 %.
- exit: rises from 0 to 1 between 90 % and 97 % — the blend to the other sky.
- travel: how far we have gone down the tunnel,
0.8 · tunnelTime²wheretunnelTimecounts the seconds since 64 %. A square means constant acceleration: the walls rush past faster and faster. - roll: the camera turns around its view axis,
tunnel · tunnelTime · 0.4radians. - pulse: a flash on every beat of the music that decays exponentially,
0.6 · e−5 · secondsSinceBeat.
2 · One ray per pixel, and its closest approach
The scene has no triangles. Instead it uses ray casting: for every pixel, the shader builds the direction of the line of
sight through that pixel — a ray starting at the camera position ro ("ray origin") and heading along the unit
vector rd ("ray direction") — and then works out what that line of sight would see. The direction comes from the
same pinhole model as the warp: rd = normalize(forward · focal + right · x + up · y), with x, y the pixel's
position on screen, measured in screen heights from the center.
The mouth is a sphere of radius R at the origin. Points on the ray are ro + rd · t, where t
is the distance travelled along it. The point of the ray closest to the center is where the ray is perpendicular to the line to the
center; with a little algebra (the dot product), it is at:
b is the impact parameter, a term from physics: how far off-center a projectile would pass if nothing
deflected it. If tc is negative, the closest point is behind the camera: the ray points away from the
mouth and nothing happens to it. If b < R the ray enters the sphere, and the entry point is
tc − √(R² − b²) along the ray (Pythagoras in the right triangle formed by the center, the closest point
and the entry point).
b, from the mouth's center to the ray's closest point. The panel on the right says which branch of the shader this
ray takes and the numbers it uses. Aim so the gold segment just touches the circle: that ray lights the photon ring.3 · Bending light
Mass bends light. Einstein's general relativity (1915) predicts that a ray passing a mass M at distance
b is deflected by an angle 4GM / (c² b): the closer it passes, the more it bends, in proportion to
1 / b. Arthur Eddington's expedition measured the effect on stars near the Sun during the solar eclipse of 1919. When a
distant light source sits exactly behind the lens, its light can reach us from every side at once, and it appears as a circle:
an Einstein ring, a geometry Einstein himself described in 1936.
The shader keeps exactly that shape — deflection proportional to 1/b — and nothing else of the physics:
out is the unit vector from the mouth's center toward the ray's closest point. Rotating rd by
bend radians away from out turns the ray toward the mouth, as gravity would. At the mouth's edge
the bend is 1.2 radians (about 69°) at the default lens strength; twice as far out, half that. The 1.2 is an artistic tuning value,
and lens is the scene's lens parameter (default 1). Notice what the shader does not do: it does
not trace the curved path step by step. Only the ray's final direction matters, because the sky is infinitely far away;
the sky color is then simply looked up in that direction.
Where is the Einstein ring? A ray leaving the camera at a small angle α ≈ b / D from the line to the mouth
(D = the camera's distance) ends up pointing exactly behind the mouth when its bend equals α:
1.2 · lens · R / b = b / D, so b = √(1.2 · lens · R · D). At the start of the scene
(D = 220) that is about 51 units, or 13° from the center of the view.
4 · Two skies, one screen
A ray that passes inside the mouth (b < R) continues in the other universe. What it should see there is
decided by another rotation, this time growing with how far from the center the ray crosses the mouth:
Through the very center (u = 0) the ray goes straight on: you see the other sky directly ahead. Halfway out
(u = 0.5), it is turned by 45°. At the rim (u = 1), by 180°: it looks straight back, at the part of
the other sky behind you. So the disc shows the entire far sky, like a 360° fisheye photo — the forward half in the inner 71 %
of the radius (where θ reaches 90°), everything behind you squeezed into the thin outer band. That compression near the
rim is also why the shader brightens it, by 1 + 1.5 · u¹² (an artistic touch: nearly 1 until close to the rim, 2.5 at
the rim).
Each universe has its own sky function. ourSky is a dark background, stars, and the pink-and-cyan glow of the
supernova remnant we just left, concentrated around the direction uRemnantDir (exp(−3a) with a
the angle from it); that direction lies mostly behind the camera, so the remnant only shows faintly at the edges.
otherSky has a different star field (the same star function, fed the direction with its axes permuted,
d.zxy) and the spiral galaxy of section 5. On top of both, wherever b is close to R, a
thin photon ring is added: exp(−|b − R| / (0.015 R)) · 0.8, a glow that falls to a third within 1.5 % of the
radius.
π u² (gold) versus a plain linear π u (dashed), and the rim brightening (pink).5 · A galaxy painted on the sky
The other universe's galaxy is not a 3D object: it is painted onto the sky as a function of direction. To get a face-on
disc, the shader projects every direction d onto a flat plane facing the galaxy's direction G — a
gnomonic projection, one of the oldest map projections, which projects the sphere from its center onto a tangent plane.
With g = d · G (the cosine of the angle to the galaxy), and two axes U, V spanning the plane:
Dividing by g is what turns the sphere into a plane: it is exactly where the line of sight hits a flat sheet
placed one unit away. The factor 3 sets the size — r = 1 is about 18° from the galaxy's center — and the
smoothstep(0, 0.2, g) fade hides the projection's blow-up toward 90°.
The arms are logarithmic spirals — curves whose radius grows exponentially with angle, studied by Descartes and by Jacob
Bernoulli, who called it spira mirabilis. Spiral galaxies' arms are often described this way. The trick is to subtract
2.6 · ln r from the angle before taking a cosine: along a curve φ = 2.6 ln r + const the cosine stays
constant, so its bright crests trace spirals. The 2 inside the cosine gives two arms, and raising
½ + ½ cos(…) to the 3rd power sharpens them:
The rest is layering: a disc that fades as e−2.2 r, dimmer between the arms
(0.35 + 0.65 · arms), mottled by the noise texture (grain); a color shifting from blue at the edge to warm
in the center; a bright warm core 3 · e−9 r; and pink star-forming knots where the cellular noise channel
peaks along the arms.
q from −1.6 to 1.6), computed with the shader's formula and
the real noise texture. Change the winding (the demo uses 2.6) and the arm sharpness (3). Show the spiral guides to see the curves
φ = 2.6 ln r + kπ that the arms follow. Hover to read r, φ and the arm value.6 · A cube of noise
Clouds, gas, the galaxy's grain and the tunnel walls all need noise: smooth, natural-looking randomness. Computing fractal noise from scratch at every sample is expensive (the warp's tunnel does it, five octaves per pixel). This scene instead looks it up in a shared 3D texture — a cube of 128 × 128 × 128 values, built once when the demo starts and used by several scenes. Reading a texture is one of the cheapest things a GPU does, and it interpolates between neighboring values for free (trilinear filtering).
The cube stores two noises in its two channels:
- R — value-noise fbm. Random values on a grid, smoothly blended, summed over five octaves (grids of 4, 8, 16, 32 and 64 cells across the cube, each octave half as strong). Soft and cloudy. (Value noise is the simpler cousin of Ken Perlin's 1985 gradient noise; "fractal Brownian motion" sums of octaves are explained in the Warp explainer.)
- G — inverted Worley noise. Steven Worley's cellular noise (1996): scatter one random point per cell, and at every position
measure the distance to the nearest point. Inverted (
1 − distance), it gives bright blobs with crisp boundaries — cells, bubbles, billows. Three octaves (4, 8 and 16 cells) weighted 0.625, 0.25, 0.125.
The important property is that the cube tiles: every octave's grid wraps around at the cube's edge (the cell index is taken
modulo the grid size), so the right face matches the left face, the top matches the bottom, front matches back. With the texture's
REPEAT mode, any coordinate can be used and the noise repeats every 1.0 without a seam. Section 8 uses that to wrap noise
around a tunnel.
7 · Glowing gas: marching through a volume
The violet vortex is a volume: gas with no surface, glowing a little at every point inside it. To draw it, the shader
walks along the ray in small steps — ray marching — and adds up the light emitted at each step. The gas lives in a shell
between the mouth and a sphere of radius 3R, so the shader first intersects the ray with that outer sphere (the
classic ray–sphere quadratic) to know where to start and stop, and stops early where the ray enters the mouth
(tEnter from section 2). Then it takes 40 equal steps.
At each step it computes the density: a shell profile that fades in just outside the mouth (from 0.95 R to
1.15 R) and fades out toward 3R (from 1.2 R), times noise read from the cube, thresholded with
smoothstep(0.45, 0.8, n) into wispy clouds, times the gas parameter. The color mixes blue to lilac with the
noise, and gets much brighter near the mouth: 1.5 + 3 · e−(r − R) / 0.2R, plus the music pulse.
Light from behind is partly absorbed on its way through the gas. The fraction that survives, the transmittance, follows
the Beer–Lambert law (Bouguer, Lambert and Beer, 18th–19th century): each step multiplies it by
e−density · dt · 0.05. Light emitted at a step is added after being dimmed by the gas in front of it:
At the end, the background (sky and mouth) is multiplied by the final T and the gas color is added on top. Two
details. The swirl: before sampling the noise, each point is rotated around the view axis by
0.4 t + 3R / r. The first term turns the whole vortex at 0.4 radians per second; the second rotates inner points more
than outer ones — 3 radians at the mouth's edge, 1 at the outer edge — which winds the clouds into a spiral. (The code's comment says
"inner gas whirls faster"; strictly, the angular speed is the same everywhere and it is the twist that grows inward, which
reads as a whirlpool.) And the jitter: the first step is shifted by a random fraction of a step per pixel
(hash12(gl_FragCoord.xy)), turning the visible banding of 40 fixed slices into fine noise.
z = 0), from the real shell profile,
swirl and noise texture; the circles are R, 1.2R and 3R. Drag the gold ray up and down.
Right: the march along that ray, step by step: density (violet bars), transmittance T (cyan) and the accumulated
glow (gold). Rays that hit the mouth stop at its surface. Turn off the twist to see the noise un-wound; turn off jitter and lower
the steps to see where banding comes from.8 · The tunnel
The plunge is not computed from the 3D scene at all. When tunnel rises, the shader blends toward a second, purely
2D image, built in polar coordinates like the warp's hyperspace tunnel: each pixel's distance r from the screen
center becomes depth down a tube (far parts of a pipe appear near the center, so depth ≈ 1 / r), and its angle
goes around the tube.
Adding travel (which grows as 0.8 · tunnelTime²) scrolls the walls toward you, faster and faster. The
noise is read from the cube at (angle + depth · 0.05 + t · 0.05, depth · 0.15, 0.3) for cloudy bands (channel R), and at
twice the angle and a faster depth for cellular sparkle (channel G, raised to the 4th power). The depth · 0.05 term
shears the pattern: bands become slow spirals. The walls are faded out right at the center (smoothstep(0.02, 0.2, r)),
and a glow sits there instead: the light at the end of the tunnel, e−8r, which widens and turns white as
exit rises.
The seam: an angle jumps from +½ to −½ turn when you cross the negative x axis. In the warp, the tunnel avoided the jump by
using (cos, sin) of the angle. Here, the angle is divided by 2π so it spans exactly 1.0 — the noise cube's period. The
jump of exactly one period lands on identical noise, and the seam disappears for free (for the cellular channel, the doubled angle
jumps by 2 periods: still seamless).
Finally the layers are blended in order: the mouth view (sky, photon ring), the gas over it, then
mix(…, tunnel, tunnel), then mix(…, otherSky(rd), exit). Because tunnel falls exactly as
exit rises, the last frames show only the other sky, seen through the plain unbent camera ray — the galaxy, ready for the
next scene.
The algorithm, step by step
Here is the complete scene, in order, with each step linked to the idea above that explains it.
// CPU, once per frame (D = 15.36 s)
p = t / D
approach = smoothstep(0, 1, min(p / 0.72, 1))
dist = mix(220, 1.05 · R, approach)
tunnel = smoothstep(0.64, 0.74, p)
· (1 − smoothstep(0.9, 0.97, p))
exit = smoothstep(0.9, 0.97, p)
tt = max(0, t − 0.64 · D)
travel = 0.8 · tt²
camPos = (sin(0.17t) · dist · 0.12,
dist · 0.08 + 2, dist)
camera = lookAt(camPos, origin, focal 1.2,
roll = tunnel · tt · 0.4)
pulse = 0.6 · beatPulse(decay 5) |
The script (§1): every moving thing is a closed-form function of t, so any frame can be rendered directly.
These values become uniforms. R = 10; the 1.05 leaves the camera just outside the mouth. If the scale parameter is
below 1, the frame is rendered into a smaller target and stretched (bilinear) to the screen. |
// GPU, for each pixel
rd = normalize(fwd · focal + right · x + up · y)
tc = −ro · rd
c = ro + rd · tc; b = |c|; out = c / b |
One ray per pixel and its closest approach to the mouth's center (§2). |
if tc > 0 and b < R: // through u = b / R; θ = π · u² color = otherSky(rd·cos θ − out·sin θ) · (1 + 1.5 · u¹²) tEnter = tc − √(R² − b²) else: // around bend = tc > 0 ? lens·1.2·R / max(b, R) : 0 color = ourSky(rd·cos bend − out·sin bend) tEnter = ∞ color += ringBlue · e^(−|b−R| / 0.015R) · 0.8 if tc ≥ 0 |
The impact parameter picks the universe (§4). Inside, a rotation growing with u² maps the whole far sky onto the
disc; outside, a 1/b deflection lenses our sky into rings (§3). The photon ring is a thin glow at b ≈ R. |
ourSky(d) = dark + stars(d) · 0.8
+ (pink·clouds + cyan·filaments·0.6)
· e^(−3 · angle(d, remnant)) · 0.6
otherSky(d) = dark + stars(d.zxy) · 1.2
g = d · G; if g > 0:
q = rotate(0.3) · (d·U, d·V) / g · 3
arms = (½ + ½cos(2(φ − 2.6 ln r)))³
disc = e^(−2.2r) · (0.35 + 0.65·arms)
· (0.6 + 0.8·grain)
c = mix(blue, warm, e^(−3r)) · disc · 1.6
+ warm · e^(−9r) · 3 + pinkKnots
add c · smoothstep(0, 0.2, g) |
Both skies are functions of direction only. Clouds, filaments, grain and knots are reads from the noise cube (§6). The galaxy is a gnomonic projection onto its own plane with logarithmic-spiral arms (§5). |
// gas: shell between R and 3R [t0, t1] = ray ∩ sphere(3R), t1 ≤ tEnter dt = (t1 − t0) / 40 t = t0 + dt · hash12(pixel) // jitter T = 1; gas = 0 repeat 40 times, t += dt: p = ro + rd·t; r = |p| shell = fadeOut(3R → 1.2R) · fadeIn(.95R → 1.15R) q = rotate(0.4·time + 3R/r) · p.xy, p.z n = 0.6·R(q·0.06) + 0.4·G(q·0.15 + 0.5) ρ = shell · smoothstep(0.45, 0.8, n) · gas e = mix(blue, lilac, n) · (1.5 + 3·e^(−(r−R)/0.2R)) · (1 + pulse) gas += T · e · ρ · dt · 0.08 T *= e^(−ρ · dt · 0.05) color = color · T + gas |
Ray marching through a glowing, slightly absorbing medium (§7): emission is accumulated front to back, dimmed by the Beer–Lambert transmittance. The swirl winds the noise into a vortex; the jitter hides banding. |
// tunnel, purely in screen space
r = |uv|; a = atan2(uv) / 2π
depth = 0.35 / r + travel
s = a + depth · 0.05 + time · 0.05
n = R(s, depth · 0.15, 0.3)
w = G(2s, depth · 0.4, 0.6)
wall = mix(blue, pink, n)
· (smoothstep(.55, .85, n)·2 + w⁴·1.5)
end = mix(paleBlue, white, exit)
· e^(−r(8 − 6·exit)) · (1 + 4·exit)
tun = wall · smoothstep(.02, .2, r) · 0.45
· (1 + pulse/2) + end |
Polar coordinates turn the screen into a view down a tube; the angle divided by 2π matches the noise cube's period, so the walls close without a seam (§8). |
color = mix(color, tun, tunnel)
color = mix(color, otherSky(rd), exit)
out = color // HDR |
Crossfades driven by the script (§1). The result goes to the demo's post chain (bloom, tone mapping, grain). |
The real source
These are the files as they are in the demo's repository. The browser version above uses the same code, with the
#included helpers copied in.
shaders/scenes/wormhole/wormhole.frag fragment shader · fullscreen
src/scenes/wormhole/WormholeScene.cpp C++ · the whole scene
shaders/common/camera.glsl included · cameraRay
shaders/common/sky.glsl included · stars
shaders/common/noise3d.comp compute shader · the 128³ noise cube
src/scene/Camera.h — lookAt C++ · excerpt
Glossary
- Fragment shader
- A program run once per covered pixel (fragment); it decides the pixel's color. This whole scene is one.
- Uniform
- A value set by the CPU before a draw and shared by every pixel of that draw (time, camera, tunnel blend…).
- Ray casting
- Computing a pixel's color by following the line of sight through it into the scene.
- Ray origin / direction (ro, rd)
- The start point of a ray (the camera) and its unit direction; points on it are
ro + rd · t. - Impact parameter (b)
- The distance by which a straight ray misses a center point; here it decides which universe a pixel sees.
- Gravitational lensing
- The bending of light by mass, which distorts and multiplies images of what lies behind.
- Einstein ring
- The circular image of a source lying exactly behind a lens.
- Photon ring
- Here: a thin glow added where rays graze the mouth's edge (
b ≈ R). - Wormhole
- A hypothetical tunnel connecting two distant regions of spacetime; in the demo, a sphere showing another sky.
- Gnomonic projection
- Projecting a sphere from its center onto a tangent plane; used to lay the galaxy flat on the sky.
- Logarithmic spiral
- A spiral whose radius grows exponentially with the angle:
φ = k · ln r. - 3D texture
- A cube of values the GPU can read at any 3D coordinate, with interpolation between neighbors.
- Tileable / REPEAT
- A texture whose opposite faces match, read with coordinates that wrap around, so it repeats seamlessly.
- Value noise / fbm
- Smoothly interpolated random values on a grid; fbm sums several octaves of it for natural detail.
- Worley (cellular) noise
- The distance to the nearest of randomly scattered points; gives cell- and bubble-like patterns.
- Ray marching
- Walking along a ray in steps, sampling something (here, gas density) at each step.
- Transmittance
- The fraction of light that gets through a medium; it multiplies by
e−density · stepper step (Beer–Lambert). - Emission–absorption
- The simplest volume rendering model: every point glows a little and dims what lies behind it.
- Jitter / dithering
- Offsetting samples by a small random amount per pixel to turn regular artifacts (banding) into fine noise.
- Polar coordinates
- Describing a point by distance and angle from a center instead of x and y.
- smoothstep
- A gentle S-shaped ramp from 0 to 1 between two edges. With the edges reversed (as in the gas shell) it ramps from 1 down to 0 — strictly undefined by the GLSL specification, but computed identically by GPUs.
- HDR / tone mapping (ACES)
- Keeping brightness above 1.0 while rendering, then compressing it for the screen with a filmic curve.
Part of the Space Travel documentation. Direction & design: blacklizard · Code: Claude. The figures on this page are computed live in your browser; nothing is pre-rendered except the header image.
