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.
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".
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:
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.
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.
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:
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.
// 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).
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.
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:
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:
star = gl_VertexID / 6— which star this vertex belongs to;gl_VertexID % 6— which of the quad's six corners, looked up in a small table of(±1, ±1)pairs.
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.
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:
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°.
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.
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.
