Made by AI
The warp scene in full hyperspace
Space Travel · Interactive explainer · Scene 04

Inside the Warp

Sixty thousand stars, one draw call, no simulation. How a few lines of shader code turn into the jump to hyperspace — explained from first principles, with the real shaders running right here in your browser.

The scene

In the demo, the warp lasts sixteen beats — under eight seconds. We cruise through a cloud of stars that drift slowly toward us. Then the ship accelerates: stars stretch into streaks, the camera starts to roll, the streaks turn blue and a glowing tunnel opens around a bright vanishing point. A white flash, and we're out of hyperspace beside a giant star.

It looks like a lot of motion, but almost nothing in it moves in the usual sense. There is no list of stars being updated every frame, no physics. Every star's position is computed from scratch, each frame, from just two things: its index (which star it is) and how far the ship has travelled. Everything else — the streaks, the roll, the colors — is derived from those two numbers. This page builds that idea up one piece at a time.

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.

Live: the real shaders

This is the warp scene ported to WebGL2, with version and precision changes, included helpers pasted in, and explicit array sizes. An explainer-only uEnergy switch lets you disable the streak-length compensation that the demo always applies. The browser sets uKick to zero: it omits the music-driven kick pulse that can boost the demo's star brightness by 60 %. The demo's full post-processing chain is replaced here by simple tone mapping and glow. Drag the time slider to scrub: because every browser frame is a pure function of time, scrubbing is just "render the frame for this time".

Try: pause near 6 s and move the shutter slider — streak length is pure motion blur. Set the shutter to 0 and the stars become dots again. Turn off energy conservation to see why long streaks must be dimmed. Turn off the stars to see the tunnel alone.

Background

Nine small ideas make up the whole effect. Each one is simple; the magic is in how they combine.

1 · A pinhole camera

To draw a 3D point on a 2D screen, imagine a box with a tiny hole in one side — a pinhole camera. Light from a star travels in a straight line through the hole and lands on the back wall, the image plane. Put the hole at the origin, look down the z axis, and place the image plane at distance f (the focal length). By similar triangles, a star at height y and depth z lands at:

yscreen = f · y / z     xscreen = f · x / z

That division by z is perspective: far things (large z) land near the center, near things land far from it. Now let the ship fly forward: every star's z shrinks, so y / z grows — the star slides outward from the center, slowly at first and then faster and faster as it gets close. That is the whole "flying through stars" illusion.

Left: a side view. The camera's pinhole is at the left, stars approach from the right, each with a ray to the pinhole. The thin vertical line next to the pinhole is the image plane. Right: that image plane enlarged and seen face-on — the "screen" — with a crowd of other stars; the colored ones are the same stars as in the side view. Watch them accelerate outward as they approach. Raise the speed or the shutter and dots become streaks: that is section 5.
In the shader, the camera never moves: the stars move toward it. And the "focal length" is simply a number: head = xy / z * uFocal. A larger uFocal is a zoom lens (narrower view), smaller is a wide angle.

2 · A star cloud from hashes

We need sixty thousand stars, each with its own position, brightness and color — but we never want to store them. Instead, each star is just an index k = 0, 1, 2, … and its properties are computed by a hash function: a little formula that scrambles a number into something that looks random but is completely repeatable. The same k always gives the same star.

float hash12(vec2 p) {                 // Dave Hoskins' "hash without sine"
    vec3 p3 = fract(vec3(p.xyx) * 0.1031); // keep only the fractional part
    p3 += dot(p3, p3.yzx + 33.33);      // mix the components together
    return fract((p3.x + p3.y) * p3.z);   // one number in [0, 1)
}

Each star calls it four times with different "channels" — hash12(vec2(k, 1.7)), hash12(vec2(k, 5.3)), … — to get four independent random numbers: an angle around the view axis, a radius from the axis, a starting depth and a color class. Together they place the star in a hollow cylinder around the direction of travel.

One subtlety: if the radius were simply random × R, stars would bunch up near the axis, because a ring near the center is much smaller than a ring near the edge yet would receive just as many stars. Taking the square root spreads them evenly over the disc's area. The small minimum (0.04 before the root) leaves a thin empty core, so no star flies straight into the camera.

Looking down the tunnel: 2,500 stars placed by hashes, seen from the camera. Compare the two radius formulas. Use the slider to inspect one star: its four hash values and what each becomes.

3 · Time, speed and travel

The ship's speed follows a curve: a gentle cruise, then a steep ramp to forty times faster near the end. A natural way to move things in a game is to add speed × frameTime to a position every frame. The demo deliberately doesn't do that: anything accumulated frame by frame depends on the whole history, so you couldn't jump to second 6.2 without replaying seconds 0 to 6.2 — and the result would differ slightly with the frame rate.

Instead, the distance travelled is computed directly as the integral of the speed curve — the area under it — from the start of the scene up to now:

travel(t) = ∫0t speed(τ) dτ

The CPU approximates that area with the trapezoid rule: cut the interval into 256 thin slices, treat each slice as a trapezoid, sum their areas. It costs microseconds, and the same t always gives the same travel — so the frame for any instant can be rendered directly. The camera roll gets the same treatment: roll = ∫ rollRate.

Pink: speed over the 7.68-second scene. Cyan: travel, the running area under the pink curve. The shaded trapezoids are the area summed so far. Notice how travel barely moves during the cruise and then shoots up.
// C++, each frame (src/scenes/warp/WarpScene.cpp)
speedAt(s)    = cruise + (top − cruise) · smoothstep(0.30, 0.97, s / duration)³
hyperAt(s)    = smoothstep(0.55, 1.0, s / duration)       // 0 → 1 over the second half
rollRateAt(s) = 0.06 + 1.2 · hyperAt(s)²                   // radians per second

travel = integrate(speedAt, t)        // trapezoid rule, 256 slices
roll   = integrate(rollRateAt, t)
// both are sent to the GPU as uniforms; nothing else about the stars lives on the CPU

4 · Infinite depth

The stars occupy a slab of space D = 60 units deep. As the ship travels, a star's distance shrinks; when it would pass behind the camera it must reappear at the far end — like a treadmill. The mod (modulo) function does exactly this: mod(a, D) wraps any number into the range [0, D).

z = mod(h3 · D − travel, D) + near

h3 · D is the star's starting depth (from its third hash), travel grows forever, and near = 0.4 keeps every star a little in front of the lens. Because far stars are tiny and dim, the moment a star jumps from "just passed" to "far away" is invisible — and a smooth fade-in over the farthest 40 % of the slab hides it completely.

Depth only: 400 stars along the view axis, camera at the left. Increase travel: every star slides left and wraps back to the right edge. Brightness grows as 1/z, so a star only becomes noticeable as it comes close.

5 · Motion blur as streaks

A real camera keeps its shutter open for a short time, and anything moving during that time is smeared. The warp uses this as its main visual. For each star we compute two points: where it is now (the head) and where it was a moment ago (the tail) — and draw a streak between them.

A moment ago the star was speed × shutter farther away, so:

head = f · xy / z     tail = f · xy / (z + speed · shutter)

This one formula produces everything you see. At cruise speed the two points nearly coincide: dots. As speed climbs, the tail falls back toward the center while the head flies outward: streaks that grow longer the closer and faster the star is — and all of them point away from the vanishing point, because both ends lie on the same line through the center. Go back to the camera figure in section 1 and move its shutter slider: the right panel shows exactly these head/tail segments.

6 · Geometry from an index

GPUs draw triangles. A streak is drawn as a thin rectangle — a quad — made of two triangles, so six corners (vertices). There is no vertex data in memory at all: the demo asks the GPU to draw 6 × 60,000 vertices, and the vertex shader receives only its number, gl_VertexID. From it:

The corner's x picks the end (−1 = tail, +1 = head) and its y picks the side. The quad extends a half-width beyond each end, so the streak gets rounded caps, and the fragment shader shades it as a capsule: brightest along the middle, fading at the edges, a little brighter at the head.

Drag the head (the cyan handle). Numbers are vertex indices 0–5; the two shaded triangles are (0, 1, 2) and (3, 4, 5). The glow inside is the actual capsule shading the fragment shader computes for each pixel.
vec2 quadCorner(int i) {   // common/math.glsl
    const vec2 corners[6] = vec2[](vec2(-1,-1), vec2(1,-1), vec2(1,1),
                                   vec2(-1,-1), vec2(1,1),  vec2(-1,1));
    return corners[i];
}

7 · Light, energy and glow

Brightness by distance. A star's brightness is 1.2 / z + 0.1: near stars blaze, far ones are faint specks.

Energy conservation. When a star turns into a streak, its light is spread over many more pixels. If each pixel kept the full brightness, fast streaks would become blinding bars. So the color is divided by 1 + 0.12 × length: a long streak is dimmer per pixel, roughly preserving the star's total light. Toggle it off in the live demo to see the difference.

Additive blending. Streaks are drawn with glBlendFunc(GL_ONE, GL_ONE): each pixel's new color is added to what is already there. Light behaves that way — two overlapping glows are brighter than either — and addition is order-independent, so 60,000 streaks need no sorting.

High dynamic range and glow. Sums of light easily exceed 1.0 ("whiter than white"). The demo renders into a 16-bit floating-point image that keeps those values, then a tone mapping curve (ACES) compresses them for the screen, and a bloom pass makes the brightest areas bleed light into their surroundings. The live demo here uses a lighter version of the same idea: tone mapping plus a glow made from blurred, downscaled copies of the image.

Blue shift. As the hyperspace factor grows, every star's tint is mixed toward a cold blue, mix(tint, vec3(0.55, 0.7, 1.0), uHyper) — a nod to the Doppler effect, not a simulation of it.

8 · The tunnel

Behind the stars, a fullscreen fragment shader paints the hyperspace tunnel. It works in polar coordinates: for each pixel, its distance r from the screen center and its angle around it. The trick is to treat r as depth down a tube: if you look down a long pipe, its far parts appear near the center. A point at screen radius r corresponds to tube depth roughly 1 / r:

depth = 0.25 / r + travel · 0.04

Adding travel makes the pattern rush toward you as the ship accelerates. The pattern itself is noise sampled at (cos angle, sin angle) × 5 and depth × 0.6: around the circle the noise changes quickly, along the depth slowly — so features are stretched into long radial streaks. Using cos/sin instead of the raw angle avoids a visible seam where the angle wraps from 360° back to 0°.

Left: the tunnel as seen on screen. Right: the same pattern "unrolled": angle across, depth down. Hover over the left image to see where each pixel reads the noise. Rings of constant depth on screen are rows on the right; increasing travel scrolls the rows.

Finally the tunnel is masked: dark in the very center (smoothstep(0.03, 0.4, r)), plus a white-blue glow right at the vanishing point, and everything is multiplied by uHyper so the tunnel only fades in during the jump. The rotation by −uRoll keeps the streaks turning with the stars.

9 · Fractal noise

Value noise assigns a random value (a hash, again) to every integer point and blends smoothly between neighbors. On its own it is a soft, blobby pattern with one size of feature. Fractal Brownian motion (fbm) adds several copies — octaves — each with twice the frequency and half the amplitude of the previous one. Big shapes get small details, small details get finer ones, like coastlines or clouds.

fbm(p) = Σi 0.5i+1 · noise(2.03i · p + offseti)
One-dimensional fbm: thin lines are the individual octaves, the thick line is their sum. The tunnel uses five octaves of the 3D version. The factor 2.03 (instead of exactly 2) and the offsets avoid octaves lining up with each other.

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
p      = t / duration
speed  = speedAt(t)
travel = ∫₀ᵗ speedAt
roll   = ∫₀ᵗ rollRateAt
hyper  = smoothstep(0.55, 1.0, p)
Everything that changes over time is reduced to four numbers, all computed directly from t (§3). They become uniforms: values shared by every vertex and pixel of the draw.
// draw 1: tunnel, a fullscreen triangle
for each pixel:
    p     = rotate(−roll) · centered(pixel)
    r     = |p|;  dir = p / r
    depth = 0.25 / r + travel · 0.04
    n     = fbm(vec3(dir · 5, depth · 0.6))
    color = mix(blue, violet, n)
            · smoothstep(0.45, 0.85, n) · 2.5
            · smoothstep(0.03, 0.4, r)
            + whiteBlue · exp(−7r) · 2
    out   = color · hyper + faintBackdrop
Polar coordinates turn the screen into a view down a tube (§8); fbm noise stretched along the tube makes radial streaks (§9). At cruise speed hyper is 0 and only a faint dark-violet backdrop remains.
// draw 2: stars, 6 × count vertices, additive
for each vertex v:
    star   = v / 6;   corner = quadCorner(v % 6)
    h1..h4 = hash12(star, channel)
    xy     = rotate(roll) ·
             (cos, sin)(h1·2π) · √mix(0.04, 30, h2)
    z      = mod(h3·D − travel, D) + 0.4
    zTail  = z + speed · shutter
The vertex index alone identifies the star and the corner (§6). Hashes give a repeatable random position in a hollow cylinder with even density (§2). Depth wraps around the slab (§4). The tail is where the star was shutter seconds ago (§5).
    head  = xy / z     · focal
    tail  = xy / zTail · focal
    w     = max(0.01 / z, 0.8 px)
    axis  = normalize(head − tail)
    perp  = (−axis.y, axis.x)
    end   = corner.x < 0 ? tail − axis·w
                         : head + axis·w
    pos   = end + perp · corner.y · w
    gl_Position = (pos.x / aspect, pos.y)
Perspective projection of both ends (§1). The streak's width shrinks with distance but never below most of a pixel, so far stars don't flicker away. The corner chooses an end and a side; dividing x by the aspect ratio maps to the screen.
    halfLen = |head − tail| / 2 / w
    local   = (corner.x · (halfLen + 1), corner.y)
    tint    = colorClass(h4) → blue by hyper
    color   = tint · (1.2/z + 0.1)
              · fadeIn(z) / (1 + 0.12·halfLen)
              · (1 + 0.6 · kick)
The vertex passes the fragment shader its position inside the capsule, measured in half-widths, and a color: brighter when near, faded in at the far end, divided by length to conserve energy, and pulsed on the music's kick drum (§7).
// fragment shader, per covered pixel
dx   = max(|local.x| − halfLen, 0)
d²   = dx² + local.y²
core = max(1 − d², 0)
out  = color · core² · mix(0.3, 1, headward)
Distance to the capsule's center segment gives the soft profile; the streak brightens from tail (30 %) to head. Additive blending sums all streaks (§7).

The real source

These are the files as they are in the demo's repository. The browser version above uses the same code.

shaders/scenes/warp/stars.vert vertex shader · one star per 6 vertices
shaders/scenes/warp/stars.frag fragment shader · capsule
shaders/scenes/warp/tunnel.frag fullscreen · polar noise
src/scenes/warp/WarpScene.cpp — render() C++ · per frame

Glossary

Vertex shader
A small program the GPU runs once per vertex; it decides where the vertex lands on screen.
Fragment shader
A program run once per covered pixel (fragment); it decides the pixel's color.
Uniform
A value set by the CPU before a draw and shared by every vertex and pixel of that draw (time, travel, speed…).
gl_VertexID
The index of the vertex being processed. With no vertex data at all, it is enough to build geometry procedurally.
Hash
A deterministic scrambling function: same input, same "random-looking" output. Used instead of stored random numbers.
Perspective projection
Dividing by depth: screen = f · position / z. Far things shrink toward the center.
Motion blur
The smear of a moving object during the shutter time; here, the segment from the star's past to present position.
Integral
The accumulated area under a curve; the integral of speed over time is distance.
Trapezoid rule
Approximating an integral by slicing the area into thin trapezoids and adding them up.
Additive blending
Adding a new color to the existing pixel instead of replacing it — how light combines.
HDR
High dynamic range: keeping brightness values above 1.0 until a final tone-mapping step.
Tone mapping (ACES)
A curve compressing HDR brightness into the screen's range while keeping highlights pleasant.
Bloom
Letting very bright areas glow into their neighborhood, like light scattering in a lens or eye.
Value noise / fbm
Smoothly interpolated random values on a grid; fbm sums several octaves of it for natural detail.
Polar coordinates
Describing a point by distance and angle from a center instead of x and y.

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.

← 03 · Pillars  ·  All scenes  ·  05 · Corona →