Made by AI
The wormhole mouth: a sphere showing a spiral galaxy, wrapped in swirling violet gas, with our star field bent into rings around it
Space Travel · Interactive explainer · Scene 12

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.

You don't need graphics experience to follow along. Each section introduces one concept, with a small interactive figure. Code appears in two forms: pseudo-code (plain logic) and the real GLSL that runs in the demo. If you haven't yet, the Warp explainer introduces the pinhole camera, hashes and noise from scratch.

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.

Try: pause around 6.5 s and drag lensing to 0: the rings of stars around the mouth vanish and the sphere becomes a flat disc pasted on the sky. Set gas to 0 to see the bare mouth and its thin photon ring. Turn off tunnel & exit and scrub to the end: the camera never goes inside — it stops just outside the sphere.

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:

Left: the script over the 15.36-second scene. White: camera distance (right axis, 220 → 10.5). Violet: tunnel blend. Gold: exit blend. Cyan: travel down the tunnel. Pink: the beat pulse. Right: a side view, to scale: the camera (white dot) closing in on the mouth (radius 10) inside its shell of gas (radius 30). Notice that the camera stops outside the sphere: the plunge is a crossfade to a separate tunnel image, not a flight through geometry.

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:

tc = −ro · rd     closest = ro + rd · tc     b = |closest|

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

Drag the camera (white) and the aim point (cyan). The dashed line is the ray; the gold segment is 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:

bend = lens · 1.2 · R / max(b, R)     d = rd · cos(bend) − out · sin(bend)

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.

A 2D slice of the scene. Drag the camera (white). Each ray from it is a pixel of one screen column. Rays outside the mouth are bent at their closest point (the shader only needs the final direction, so the kink stands in for the real smooth curve). The outer ring is the sky at infinity, colored by direction; each ray's tick on it shows which part of the sky that pixel sees. The strip at the bottom is the resulting column of pixels. Rays that end up looking at the white star — the point straight behind the mouth — are drawn white: in 3D, these form the Einstein ring. Rays through the mouth (gold) leave our universe: their outgoing direction is section 4.

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:

u = b / R     θ = π · u²     d = rd · cos θ − out · sin θ

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.

Left: the scene's real mouth and lens math (CPU, low resolution) with two test-pattern skies: our sky is a blue latitude/longitude grid, the other sky a warm one. Look how the grid lines bend around the mouth and how the warm grid inside crowds toward the rim. Hover to probe a pixel. Right: the turn angle θ across the mouth: the demo's π 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:

q = rotate(0.3) · (d · U, d · V) / g · 3     r = |q|     φ = atan2(q)

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:

arms = (½ + ½ · cos(2 · (φ − 2.6 · ln r)))³

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.

The galaxy in its own plane (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:

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.

One slice of the cube, as generated by your GPU for the live demo. Left: channel R (fbm). Right: channel G (inverted Worley). Move through the cube with the slice slider; with the 2 × 2 view on, look for the borders between copies — you won't find them.

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:

color += T · emission · density · dt · 0.08     T ×= e−density · dt · 0.05

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.

Left: the gas density in the plane facing the camera (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.

depth = 0.35 / r + travel     angle = atan2(y, x) / 2π

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

Left: the tunnel as seen on screen, from the shader's formula and the real noise texture. Right: the same pattern unrolled: angle across, depth down. Hover over the left image to see where a pixel reads. Switch the angle scale to 0.9 to break the periodicity: a seam appears along the left horizontal axis.

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 · step per 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.

← 11 · Supernova  ·  All scenes  ·  13 · Galaxy →