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.
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".
uSpeed = 0) and the stars stay round even at full speed. Turn off everything
but the backdrop to find the faint nebula wisps.#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.
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.
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:
star = gl_VertexID / 6(integer division) — which star this vertex belongs to;quadCorner(gl_VertexID % 6)— which of the quad's six corners, a(±1, ±1)pair from a small table;layer = star / uStarsPerLayer— stars are numbered layer by layer: 0–1199 are layer 0 (farthest), 1200–2399 layer 1, and so on, with 1200 stars per layer by default (times the timeline'sdensityparameter).
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.
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.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:
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 %.
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:
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.
// 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
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:
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):
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.
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²):
At the center n points straight at the viewer; at the rim it lies sideways. With a normal, everything else is classic
shading:
- Diffuse light (Lambert's cosine law, from his Photometria of 1760): a surface facing the light gets the full
light, one at an angle gets the cosine of that angle,
max(n·L, 0). The planet uses0.015 + 1.5 · max(n·L, 0): a tiny ambient term keeps the night side from being pure black. The light directionL = normalize(−0.65, 0.4, 0.6)comes from the upper left, slightly in front. - Limb darkening: astronomers' name for a sphere's disc looking darker toward its edge. Here simply
× mix(0.55, 1.0, n.z)— 55 % brightness where the surface turns away from us. - Blue rim:
(0.35, 0.55, 1.0) · (1 − n.z)³ · smoothstep(−0.1, 0.4, n·L) · 0.8— light that grows sharply toward the edge, only on the lit side. A cheap stand-in for the glow of an atmosphere seen edge-on. - Halo outside the disc:
exp(−14·(r − 1)), a thin falloff, made brighter on the side facing the light (0.25 + 0.75·max(dir·L.xy, 0)) and scaled by 0.7. - A smooth edge:
coverage = smoothstep(1 + px, 1 − px, r), wherepxis one screen pixel in planet radii. Instead of a hard in/out step, the rim fades over two pixels — cheap anti-aliasing.
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.
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.
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.
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):
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.
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 fromfirst. 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
smoothstepacross 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.
