Made by AI
Streaking parallax stars in front of a banded gas giant
Space Travel · Interactive explainer · Scene 02

Parallax Stars

Seven thousand stars in six layers, a gas giant painted on a single fullscreen triangle, and not one vertex stored in memory. How the demo's classic sideways starfield works — explained from first principles, with the real shaders running right here in your browser.

The scene

The scene lasts sixteen beats — 7.68 seconds at 125 BPM — and starts 15.36 s into the demo, fading in from the intro over two beats. The camera slides sideways through space: stars stream from right to left, the near ones fast, big and bright, the far ones slow and faint. A banded gas giant drifts in from the right between the layers: the far stars pass behind it, the near ones in front of it. In the last quarter the stars speed up ninefold and stretch into streaks, carrying the momentum into the next scene.

The core trick is that none of this is 3D. There are no 3D star positions and no camera matrix. Every star is a point on the screen that slides left at a speed set by its layer — the old parallax trick of 1980s arcade games and Amiga demos. Depth is suggested by three cues only: speed, size and brightness all grow with the layer. The planet isn't a mesh either: one fullscreen pass asks, for every pixel, "am I on the disc, and if so, what does the sphere's surface look like here?"

And, as everywhere in this demo, each frame is a pure function of time: every star is recomputed from scratch from its index and from how far the camera has travelled — nothing is stored or updated from frame to frame.

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 actual scene: the same four draws in the same order — backdrop, far stars, planet, near stars — with the demo's shaders ported to WebGL2, driven by a JavaScript copy of the C++ render() function. Drag the time slider to scrub: because every frame is a pure function of time, scrubbing is just "render the frame for this time".

Try: scrub past 6 s to see the acceleration and the streaks. Turn off the near layers and watch the planet hide the far stars; turn off the far layers and every star you still see is in front of it. Turn off motion streaks (this sends uSpeed = 0) and the stars stay round even at full speed. Turn off everything but the backdrop to find the faint nebula wisps.
Port notes: the shaders are the demo's files with only the #version / precision lines changed and the #included helpers pasted in (quadCorner's array constructor is written with an explicit size, vec2[6](…), as in the other explainers). The demo's post chain (bloom, chromatic aberration, vignette, grain) is replaced by ACES tone mapping and a glow with a hard threshold of 1.0 on blurred mip samples. The demo's soft knee starts admitting light above a brightness of 0.5 at that threshold. The two-beat crossfade from the intro is not shown, and the beat pulse is computed from the 125 BPM grid instead of the music player. Because star sizes are in pixels, the frame is rendered 1080 lines high, like the art-book captures, and scaled to fit the canvas; untick "render 1080 lines high" to render at the canvas's own resolution and see the same stars become relatively bigger and the field denser.

Background

Nine small ideas make up the whole scene: five for the stars, two for the planet, one for the backdrop, and one for how the pieces are stacked.

1 · Parallax: depth without 3D

Look out of a train window: the fence by the track rushes past, distant hills crawl, and the moon seems to ride along with you. That difference in apparent speed is parallax, and the brain reads it as depth. Geometry says why: if the camera moves sideways at speed v, an object at distance D crosses the view at a speed proportional to v / D — twice as far, half as fast.

Video games have faked depth this way since the early 1980s (parallax scrolling; the arcade game Moon Patrol, 1982, is usually cited as an early example), and it became a staple of Amiga demos — the star shader's own comment calls itself a "classic Amiga parallax starfield". Instead of a distance, each of the six layers gets a parallax factor: the fraction of the nearest layer's speed it moves at.

depth = (layer + 1) / 6     parallax = mix(0.06, 1.0, depth²)

mix(a, b, t) is GLSL's linear blend, a + (b − a)·t. The nearest layer (depth 1) has factor 1; the farthest (depth 1/6) has 0.086, as if it were 11.6 times farther away. Squaring the depth is an artistic choice: it packs the far layers together at slow speeds and spreads the near ones apart, which reads as a deep field with a few close, fast stars. The same depth also sets the other two cues — radius mix(0.7, 2.6, depth) pixels and brightness mix(0.25, 3.0, depth²): near stars are bigger and brighter.

Left: the parallax factor of each layer (L1 = farthest). Right: a strip of "screen" with 70 stars per layer, sized and lit like the demo's (slightly enlarged). Highlight one layer to follow it, and compare the two curves: with the linear one the middle layers move at similar speeds and the field looks flatter.

2 · Geometry from an index

GPUs draw triangles. Each star is drawn as a small rectangle — a quad — made of two triangles, so six corners (vertices). There is no vertex data in memory at all: the C++ code asks the GPU to "draw 6 × stars vertices", and the vertex shader — a small program the GPU runs once per vertex — receives only its number, gl_VertexID. From it:

That numbering is what lets the planet sit between the layers. glDrawArrays(mode, first, count) draws the vertex numbers first … first + count − 1, and gl_VertexID counts from first. So the scene draws the far half of the vertex range, then the planet, then the near half — same program, same uniforms, two draw calls.

The vertex stream: all 43,200 vertex numbers of the default scene (6 layers × 1200 stars × 6). Move the slider (or step with the buttons) and watch one number become a star, a corner and a layer. The two shaded triangles of the quad are (0, 1, 2) and (3, 4, 5); corners 0/3 and 2/4 coincide.
A detail for readers of the C++: the star uniforms are set before m_stars->use(). That works because the demo's set() uses glProgramUniform* (OpenGL 4.1+), which writes into a program without binding it. Uniforms live in the program object, so they are set once and serve both star draw calls.

3 · A star from two hashes

Where is star 4711, how big is it, what color? Nothing is stored: everything is computed by a hash function, a little formula that scrambles numbers into values that look random but are perfectly repeatable (the demo uses Dave Hoskins' "hash without sine"; the warp explainer shows it in detail). The star shader calls two hashes:

(h.x, h.y) = hash22(1.37·k, 11)     h2 = hash12(k, 29)

h.y becomes the height on screen (y = 2·h.y − 1, the full height). h.x is the star's place along a horizontal loop, and also the phase of its twinkle. h2 jitters the size (×0.7 to ×1.3), sets the twinkle rate and, through fract(7·h2), the color — multiplying by 7 and keeping the fractional part turns h2 into a sawtooth, so color and size don't look correlated.

The loop. In the shader's units the screen spans x = −aspect … +aspect. The loop is a little longer: span = 2·aspect + 0.4, a 0.2 margin on each side so a star (and its streak) has fully left the screen before it reappears on the other side. The function fract keeps only the fractional part of a number — it wraps any value into [0, 1) like a treadmill:

x = fract(h.x − travel · parallax · 2·aspect / span) · span − span / 2

Read it from the inside: travel is how many screen widths the nearest layer has moved; times parallax for this layer; times 2·aspect to turn screen widths into shader units; divided by span to get a fraction of the loop; subtracted from the home position h.x; wrapped; and stretched back to the loop's size.

Twinkle and the beat. Brightness is multiplied by 0.75 + 0.25·sin(t·(2 + 4·h2) + 50·h.x) — ±25 %, each star with its own rate (2 to 6 radians per second, a period of 1 to 3 s) and phase — and by 1 + 0.5·beatPulse·depth, where beatPulse = exp(−6 · seconds since the last beat) jumps to 1 on every beat and decays. Near layers flash up to +50 % on the beat, the farthest only +8 %.

Top: one layer's loop (150 of its stars). The violet box is the visible screen, the pink bands the off-screen margins; the gold ring is the inspected star — watch it leave on the left and come back on the right. Bottom: the inspected star's brightness over eight beats: the dashed line is twinkle only, the solid line adds the beat pulse.

4 · Time, speed and travel

The nearest layer cruises at 0.12 screen widths per second. Over the last quarter of the scene the speed is multiplied by 1 + boost · smoothstep(0.75, 1, t / T)² with boost = 8, so it ends nine times faster (1.08 screen widths per second). smoothstep(a, b, x) is 0 below a, 1 above b, and an S-shaped ramp in between; squaring it makes the start of the acceleration even gentler.

A game would typically move each star by speed × frameTime every frame. The demo deliberately doesn't: anything accumulated frame by frame depends on the whole history, so you couldn't jump straight to second 6.2, and the result would depend on the frame rate. Instead, the distance travelled is computed directly as the integral of the speed — the area under the speed curve — from the start of the scene up to now:

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

The CPU approximates the area with the trapezoid rule (motion::integrate): cut [0, t] into 256 slices, treat each as a trapezoid, add them up. At the default settings the nearest layer travels about 1.6 screen widths in the whole scene: 0.69 during the cruise, 0.92 in the final 1.92 seconds. Every moving thing reads the same travel: the star layers (times their parallax), the backdrop's wisps, and the planet.

Pink: speed of the nearest layer over the 7.68-second scene. Cyan: travel, the running area under the pink curve. The shaded trapezoids are the area summed so far. Raise boost and see how the last quarter dominates the distance.
// C++, each frame (src/scenes/hstars/HStarsScene.cpp)
baseSpeed  = 0.12 · speedParam                                // screen widths / s
speedAt(t) = baseSpeed · (1 + boost · smoothstep(0.75, 1.0, t / duration)²)
travel     = integrate(speedAt, t)                               // trapezoid rule, 256 slices
speed      = speedAt(t)                                          // sets the streak length (§5)
planet.x   = aspect · 0.5 − travel · 0.5 · aspect                     // enters at the right edge
The planet's center is in units where the screen is 1 high (so aspect wide), while travel is in screen widths. travel · 0.5 · aspect therefore moves the planet half a screen width per screen width of travel — the parallax of a layer between L4 (0.48) and L5 (0.71). Over the scene it drifts from the right edge to about a fifth of the screen from the left edge.

5 · Motion streaks

A real camera keeps its shutter open for a moment, and anything moving during that moment is smeared. The stars imitate this: each quad is stretched along the motion by half the distance the star moves in 1/60 s (one frame at 60 Hz) on each side, so the total streak is the distance covered in one frame:

streak = speed · parallax · width / 60 · 0.5   px     halfSize = (2·radius + streak, 2·radius)

At cruise speed on a 1920-pixel screen the nearest layer's streak is under 2 pixels — the stars look round. At the final speed it is about 17 pixels on each side.

The quad's corners are at center ± halfSize. The vertex shader also hands the fragment shader (the program run for every covered pixel) the corner's position measured in star radii, vLocal = corner · halfSize / radius, and the streak length in radii, vStreak. The fragment shader shades a soft capsule: the distance to the center segment of length 2·vStreak, fed into a Gaussian bell curve (named after Carl Friedrich Gauss):

dx = max(|local.x| − vStreak, 0)     color · exp(−1.5 · (dx² + local.y²))

The quad reaches two radii beyond the segment, where the bell has dropped to exp(−6) ≈ 0.25 % — so its square edges are invisible. Finally, a long streak covers many more pixels than a dot, so the color is divided by 1 + 0.3 · vStreak. That doesn't conserve the star's light exactly (the figure's readout shows the total still grows), but it keeps fast streaks from turning into glaring bars.

Left: one star's quad, magnified (the faint grid is the screen's pixels), shaded exactly like stars.frag. Right: its brightness along the streak's axis, with (solid) and without (dashed) the division by 1 + 0.3·vStreak. Raise the speed to see a dot become a capsule.

6 · A sphere without geometry

The planet is drawn by a fullscreen pass: one triangle covering the whole screen, so the fragment shader runs for every pixel. Each pixel measures its offset from the planet's center in planet radii, d = (p − center) / R, and its distance r = |d|. If r > 1.8 it is far from the planet and outputs nothing. If r ≤ 1 it is on the disc.

The normal. To light a surface we need its normal, the direction it faces. On a unit sphere x² + y² + z² = 1, and the normal at a point is the point itself. We know x and y (that's d), so the visible front half has z = √(1 − r²):

n = (d.x, d.y, √(1 − r²))

At the center n points straight at the viewer; at the rim it lies sideways. With a normal, everything else is classic shading:

Drag the light in the small disc (a map of directions seen from the camera; the center is "from the viewer"). The planet is a WebGL copy of planet.frag with a switch on every term. Bottom: the terms along the planet's horizontal center line, from 1.8 radii left to 1.8 radii right (surface color fixed at the middle band tone). Put the light behind the planet: only the rim and the halo on the lit side remain.

7 · Bands, turbulence and storms

A gas giant's look is mostly bands parallel to its equator. The shader computes a latitude-like angle from the normal, latitude = asin(n.y + 0.15·n.x): for an upright sphere n.y is the sine of the latitude, and adding 0.15·n.x tilts the bands by roughly 8.5°. Then sin(14·latitude) gives seven light and seven dark stripes from pole to pole.

Straight stripes look artificial, so the sine's argument is pushed around by noise: sin(14·latitude + 4·turbulence) with turbulence = fbm(3·q). Disturbing a function's input with noise (often called domain warping) makes the stripe edges wave and swirl. The noise is 3D fractal noise (fbm, §8) sampled at the surface point itself, so there is no seam and no pinching at the poles, as a 2D texture wrapped around a sphere would have. q is the normal rotated around the vertical axis by 0.06 rad/s × t (26° over the scene): only the noise turns, so the bands stay put while the swirls drift across them.

Finally, the stripe value picks a color between brown (0.5, 0.3, 0.16) and cream (0.95, 0.8, 0.6), and a second, finer noise fbm(6·q + 3), cut with smoothstep(0.62, 0.85, …), paints rusty storms (0.8, 0.35, 0.18) where it is highest.

The surface color, step by step (unlit, as seen from the camera). The demo's values are 14 bands, turbulence 4 and tilt 0.15. Turn the rotation: panels 3–5 change, panels 1–2 don't.

8 · The backdrop: fractal noise

Behind everything, a fullscreen pass paints the sky: a vertical gradient between two almost-blacks (violet at the bottom, blue at the top) plus two layers of faint nebula wisps. The wisps come from value noise: random values (hashes again) at the integer grid points, blended smoothly in between. On its own it is a soft blobby pattern with one size of feature. Fractal Brownian motion (fbm) sums several copies — octaves — each at about twice the frequency and half the amplitude of the previous one, so big shapes get small details, like clouds or coastlines (Ken Perlin's 1985 paper "An Image Synthesizer" made this sum-of-octaves idea a graphics staple). The 2D version also rotates each octave by about 37° (the matrix with cos = 0.8, sin = 0.6) and offsets it by 17, so the octaves' grids never line up.

wisps = smoothstep(0.45, 0.85, fbm(1.6·p + (0.15·travel, 0)))

smoothstep turns the noise into wisps: everything below 0.45 becomes empty sky, only the peaks remain. The first layer (violet, (0.05, 0.012, 0.08)) drifts at 0.15·travel — in screen widths, about the same as the farthest star layer; the second (blue, (0, 0.025, 0.06), finer scale 3, thresholds 0.5–0.9, offset 7 so it doesn't copy the first) at 0.25·travel. They are very dim: barely-there color that the tone mapping and bloom turn into atmosphere.

Left to right: the raw fbm of the first layer, the wisps after the threshold, and the final backdrop with both layers (brightened 12× so you can see it). The threshold sliders move both layers' edges together (the second layer keeps its +0.05 offset); octaves is an explainer-only control — the demo always sums five.

9 · Draw order and premultiplied alpha

Like a painter, the scene draws from back to front: the backdrop overwrites the frame; the far layers are added on top (glBlendFunc(GL_ONE, GL_ONE): new color + old color, the way light combines — and since addition doesn't care about order, 3,600 stars need no sorting); then the planet; then the near layers, added again.

The planet needs something else: its disc must cover the stars behind it, while its halo must add light. One blend mode does both. The shader outputs a premultiplied color — already multiplied by its own coverage — together with α = coverage, and the GPU blends with glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA):

result = src.rgb + dst.rgb · (1 − α)

This is the "over" operator of Porter and Duff's 1984 paper Compositing Digital Images. On the disc, α = 1: the stars behind are replaced. At the anti-aliased edge, α is fractional: a smooth mix. In the halo, the shader outputs glow with α = 0: pure addition, the stars shine through the glow.

Hover over any panel to probe a pixel.
The four steps on a small 160 × 90 frame (fewer and larger stars than the demo, for legibility). Hover to read one pixel's numbers. With "just add", stars show through the planet and it looks like a ghost; with "no blending", the fullscreen pass wipes out everything it touches — including the sky beyond the halo, where it outputs zero.

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
base   = 0.12 · speedParam       // screen widths / s
speed  = base · (1 + boost ·
         smoothstep(0.75, 1, t / T)²)
travel = ∫₀ᵗ speed            // 256 trapezoids
N      = 1200 · density        // stars per layer
pulse  = exp(−6 · secondsSinceBeat)
Everything that changes over time is reduced to a few numbers computed directly from t (§4). They become uniforms: values shared by every vertex and pixel of a draw. boost (8), speed, density and planet are timeline parameters.
// draw 0: backdrop, fullscreen, no blending
for each pixel p:      // centered, height 1
    col  = mix(violetBlack, blueBlack, p.y + 0.5)
    col += violet · smoothstep(0.45, 0.85,
             fbm(1.6·p + (0.15·travel, 0)))
    col += blue · smoothstep(0.5, 0.9,
             fbm(3·p + (0.25·travel, 7)))
A dark gradient with two layers of thresholded fractal noise drifting slowly with the travel (§8).
// draw 1: far layers, additive (ONE, ONE)
glDrawArrays(TRIANGLES, 0, 3·N·6)
for each vertex v:
    star   = v / 6;  corner = quadCorner(v % 6)
    layer  = star / N
    depth  = (layer + 1) / 6
    (hx, hy) = hash22(1.37·star, 11)
    h2     = hash12(star, 29)
The vertex number alone identifies the star, the corner and the layer (§2). Two hashes give everything random about the star, repeatably (§3).
    parallax = mix(0.06, 1, depth²)
    span = 2·aspect + 0.4
    x = fract(hx − travel·parallax·2·aspect/span)
        · span − span / 2
    y = 2·hy − 1
Each layer slides at its own fraction of the travel (§1) around a loop slightly wider than the screen (§3).
    radius = mix(0.7, 2.6, depth)
             · (0.7 + 0.6·h2)             // px
    streak = speed·parallax·width/60·0.5 // px
    half   = (2·radius + streak, 2·radius)
    gl_Position = (x / aspect, y)
                  + corner · half · 2 / resolution
    local  = corner · half / radius
    s      = streak / radius
The quad is centered on the star, two radii around it, stretched by the distance it moves in a 60th of a second (§5). Sizes are in pixels, so stars stay crisp at any resolution.
    twinkle = 0.75 + 0.25·sin(t·(2 + 4·h2) + 50·hx)
    tint    = mix(coolWhite, warmWhite, fract(7·h2))
    bright  = mix(0.25, 3, depth²) · twinkle
              · (1 + 0.5·pulse·depth)
    color   = tint · bright / (1 + 0.3·s)
Brightness grows with depth (§1), twinkles and pulses on the beat (§3), and is spread over the streak (§5).
// fragment shader, per covered pixel
dx  = max(|local.x| − s, 0)
out = color · exp(−1.5 · (dx² + local.y²))
A Gaussian capsule around the streak's center segment, invisible at the quad's edges (§5).
// draw 2: planet, fullscreen,
// blend (ONE, ONE_MINUS_SRC_ALPHA)
center = (aspect/2 − travel·0.25·2·aspect, −0.38)
d = (p − center) / R;  r = |d|      // R = 0.5
if r > 1.8: out = (0, 0, 0, 0)
coverage = smoothstep(1 + px, 1 − px, r)
n = (d, √(1 − r²))
q = rotateY(0.06·t) · n
Every pixel finds out whether it is on the disc and, if so, the sphere's normal there (§6). The rotated normal q only feeds the noise (§7).
lat   = asin(n.y + 0.15·n.x)
bands = sin(14·lat + 4·fbm(3·q))
base  = mix(brown, cream, 0.5 + 0.5·bands)
base  = mix(base, rust,
        smoothstep(0.62, 0.85, fbm(6·q + 3)))
col   = base · (0.015 + 1.5·max(n·L, 0))
             · mix(0.55, 1, n.z)
      + blue · (1 − n.z)³ · smoothstep(−0.1, 0.4, n·L) · 0.8
Tilted, noise-warped bands and storms give the surface color (§7); Lambert diffuse light, limb darkening and a blue rim light it (§6).
halo = exp(−14·max(r − 1, 0)) · (1 − coverage)
glow = blue · halo · (0.25 + 0.75·max(dir·L.xy, 0)) · 0.7
out  = (col · coverage + glow, coverage)
// GPU: result = out.rgb + dst · (1 − coverage)
A thin halo outside the disc, brighter toward the light. Premultiplied output: the disc covers, the halo adds (§9).
// draw 3: near layers, additive
glDrawArrays(TRIANGLES, 3·N·6, 3·N·6)
The same star program on the second half of the vertex range: these stars pass in front of the planet (§2, §9). Then the demo's post chain (bloom, tone mapping…) runs on the finished HDR frame.

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/hstars/stars.vert vertex shader · one star per 6 vertices
shaders/scenes/hstars/stars.frag fragment shader · Gaussian capsule
shaders/scenes/hstars/planet.frag fullscreen · sphere shading
shaders/scenes/hstars/background.frag fullscreen · gradient + fbm wisps
src/scenes/hstars/HStarsScene.cpp C++ · the whole scene
shaders/common/math.glsl + noise.glsl the helpers this scene uses
src/scene/Motion.h C++ · smoothstep and integrate

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.
Fullscreen pass
Drawing one triangle that covers the whole screen, so a fragment shader runs for every pixel — used here for the backdrop and the planet.
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 number of the vertex being processed; with glDrawArrays(first, count) it runs from first. With no vertex data at all, it is enough to build geometry procedurally.
Quad
A rectangle drawn as two triangles (six vertices).
Parallax
Nearer things appear to move faster than farther ones when the viewer moves; the brain reads it as depth.
Hash
A deterministic scrambling function: same input, same "random-looking" output. Used instead of stored random numbers.
fract
The fractional part of a number, x − floor(x); it wraps any value into [0, 1), which makes positions loop.
mix / smoothstep
mix(a, b, t) blends linearly from a to b; smoothstep(a, b, x) rises smoothly from 0 to 1 as x goes from a to b.
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.
Gaussian
The bell curve exp(−k·d²): bright at the center, smoothly vanishing with distance. Used here for soft star dots.
Normal
The direction a surface faces at a point; on a unit sphere, the point itself.
Lambert (diffuse) lighting
A matte surface's brightness is proportional to the cosine of the angle between its normal and the light: max(n·L, 0).
Limb darkening
A sphere's disc looking darker toward its edge (the "limb").
Anti-aliasing
Softening hard edges over about a pixel so they don't look jagged; here a smoothstep across the planet's rim.
Value noise / fbm
Smoothly interpolated random values on a grid; fbm sums several octaves of it for natural detail.
Domain warping
Disturbing the input of a function with noise, so its pattern (here, stripes) waves and swirls.
Additive blending
Adding a new color to the existing pixel instead of replacing it — how light combines.
Alpha / premultiplied alpha
Alpha is coverage or opacity. Premultiplied means the color is already multiplied by it, so "over" is src + dst·(1 − α) and α = 0 gives pure addition.
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.

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.

← 01 · Intro  ·  All scenes  ·  03 · Nebula pillars →