The End Credits
A hundred and twenty-eight shiny balls, a chunky 8×8 font and a scroll of greetings: the demoscene's oldest way to say goodbye, rebuilt with modern shaders — and running right here in your browser.
The scene
The last 64 beats of the demo — 30.72 seconds at 125 BPM — are its closing credits. It fades in from the black hole over three beats. On the left, a cluster of 128 glossy, rainbow-tinted balls changes shape every eight beats (3.84 s): a sphere, a cube lattice, a knot, a double helix, the word SPACE, a spiral — and then sphere and cube again, before the demo loops back to its title. The balls throb on every beat, and the cluster spins, except while it spells the word: then it turns to face you. On the right, the credits and greetings rise slowly in the demo's chunky 8×8 font. Behind everything, a dim starfield turns slowly over a deep violet gradient.
This scene is an homage. Vector balls, scrollers and bitmap fonts were the bread and butter of Amiga, Atari ST and PC demos in the late 1980s and early 1990s, when every pixel was pushed by hand. Here none of it is hand-pushed, and nothing is stored as an image or a model: the six shapes are short formulas evaluated once at start-up (one of them reads the pixels of the font), the morph is a per-ball blend driven by the beat counter, and the text is drawn by a fragment shader that, for every screen pixel, works out which letter — and which pixel of that letter — it is looking at.
Live: the real shaders
This is the actual credits scene: the demo's three passes — backdrop, balls, scroller — with their real shaders ported to WebGL2,
driven by a JavaScript copy of the scene's C++ render(). The ball sphere mesh, the six shapes, the font and the credits
text are all built in this page from the same code and the same data (the font and the text are even read out of the C++ source
listed at the bottom of the page). Drag the time slider to scrub: like every scene, each frame is a pure function of time.
Edit the credits text (one line per row; the shader takes up to 48 lines)
Changes for WebGL2: mesh.vert reads each ball's
matrix and color from instanced vertex attributes instead of a storage buffer (WebGL2 has neither storage buffers nor
gl_BaseInstance); font.glsl's parameter length is renamed so it doesn't shadow the built-in
length(); precision qualifiers added. Not reproduced: the demo's post chain (bloom, chromatic aberration, grain —
replaced by ACES tone mapping and a glow with a hard threshold of 1.0 on blurred samples) and the crossfade from the black hole.
The demo's bloom uses a soft knee, which starts admitting light above a brightness of 0.5 at that threshold. The beat pulse uses the 125 BPM grid. The
text size follows the shader's rule max(1, floor(height / 220)), so in this smaller canvas a font pixel is 2–3 screen
pixels instead of 4; the backdrop sizes its stars in screen pixels, so this port sizes them for the demo's 1080-line frame
(1080 instead of uResolution.y in the star pixel angle) to keep them as fine as in the demo.
Background
Nine short chapters. The first five are about the balls, the next two about the text, and the last two about the two classics the credits greet by name — the sine scroller and the copper bar — and what the scene kept of them.
1 · Vector balls, then and now
The classic. In the late 1980s, Amiga and Atari ST demos were full of vector balls: dozens of shaded balls placed at the corners of a 3D shape, rotating, bouncing and morphing. Those machines could not light a sphere pixel by pixel in real time, so the ball was drawn once, by hand, as a small bitmap — a disc with a shiny highlight — and stamped onto the screen at each ball's projected position. On the Amiga such a moving stamp was called a bob (blitter object), because the blitter chip did the copying.
A stamp simply covers whatever is underneath. So the order matters: the balls must be drawn from the farthest to the nearest, the way a painter paints the sky before the trees — the painter's algorithm. Every frame, the coder rotated the points, sorted them by depth, and blitted them back to front.
The modern version. The demo draws every ball as a real 3D sphere: an icosphere mesh (a 20-sided solid whose triangles are split three times, giving 1,280 triangles) drawn 128 times in a single instanced draw call, each copy with its own position, size and color. No sorting: the GPU keeps a depth buffer (z-buffer, described by Edwin Catmull in 1974) that stores, for every pixel, the depth of the nearest surface drawn so far, and rejects anything behind it. Because the test is done per pixel rather than per ball, even two balls that cut into each other — as they do when the beat pulse swells them — are resolved exactly.
2 · Six shapes from formulas
A shape is simply an array of 128 entries, one per ball: a 3D position and a scale, where scale 0 means "hidden". The demo builds the six shapes once, at start-up, from short formulas. Pick one below and read its recipe; then turn on show order to see how the formula numbers the balls — ball 0 to ball 127. That order matters more than it seems, as the next section shows.
hue = i / 128 + 0.05 t), so neighbours in the list are neighbours in color.3 · Morphing with a ripple
To morph from one shape to the next, ball i of the old shape simply flies to ball i of the new one.
There is no matching of nearest points, no path planning: just a straight-line blend between two positions,
where m goes from 0 to 1. The scale is blended the same way, so balls that are hidden in one shape grow out of (or
shrink into) the center. This works because the formulas all number their balls in orderly sweeps — top to bottom, around and
around — so neighbours stay roughly neighbours and the cloud moves as a coherent swarm.
Two details make it feel alive. First, m is not a straight ramp but an ease in / ease out curve,
smoothstep — slow start, fast middle, gentle landing — over two beats. Second, each ball starts a little after the
previous one: ball i waits i × 0.006 beats. Ball 127 leaves 0.76 beats after ball 0, and because of the
ordering that delay sweeps across the shape like a ripple.
m over time for five balls; the dashed line is "now". Set the
ripple to 0 and all curves coincide; raise it and watch the wave travel through the swarm. Turn off easing for a mechanical,
constant-speed morph.4 · Living on the beat
The tune runs at 125 beats per minute, so one beat lasts 0.48 s. The scene turns its local time t into a beat count
and derives everything from it:
beat = t · 125 / 60 current = ⌊beat / 8⌋ mod 6 // which shape we are heading to previous = (current + 5) mod 6 // the one we come from into = beat mod 8 // beats since this shape began first = beat < 8 // very first shape: no morph, just the sphere
The scene lasts 64 beats, eight slots of eight, so the six shapes play once and the first two play again. Because the credits start exactly on a musical section, the morphs land on the bars of the tune.
The throb. On each beat the balls swell by 30 % and relax: radius × (1 + 0.3 · pulse), with
pulse = exp(−6 · secondsSinceBeat) — 1 on the beat, down to 1/e after a sixth of a second.
Facing the camera. The cluster normally spins around the vertical axis at 0.6 rad/s (yaw) and nods gently
(pitch = 0.35 sin 0.4t). But a word is only readable from the front, so while it spells SPACE a facing
factor blends both angles toward "front": yaw → 0.25 sin 0.5t (a slight sway), pitch → 0. Facing follows
the same two-beat smoothstep as the morph, in on the way to SPACE and out on the way to the spiral.
There is one trap. By beat 32 the cluster has spun about 1.5 turns (0.6 t ≈ 9.2 rad), and blending straight to a
front pose near 0 would whirl it back through all of them in under a second. Any whole number of turns looks the same, so the code
moves the front pose instead: it adds turns · 2π, with turns = the whole number of turns nearest to
the spin at the start of the ramp (the first beat of the shape). The cluster then only has to turn by the leftover, less than
half a turn, and because turns is fixed for the whole ramp, the blend stays smooth.
spin = 0.6 t // left unwrapped
rampStart = time of this shape's first beat
turns = round((0.6 · rampStart − 0.25 sin 0.5·rampStart) / 2π)
faceYaw = 0.25 sin 0.5t + turns · 2π
yaw = mix(spin, faceYaw, facing)
std::remainder. That also keeps the
unwinding short, but the wrapped spin jumps from +π to −π every 10.5 s. While facing is 0 the jump is a whole turn and
invisible; one of them, though, fell at t ≈ 15.71 s, inside the ramp toward SPACE where yaw is a blend, and there the jump is
scaled by 1 − facing: the cluster snapped by about 110° in one frame. Choosing the turn count once, at the start of the
ramp, removes it. (A whole-turn jump does still happen at beat 40, when the next shape's ramp picks its own count, but there
facing is exactly 1, so the pose jumps by exactly 2π and nothing moves.)5 · Glossy balls
The old bobs were painted shiny. The demo's balls are lit shiny: the fragment shader adds up four kinds of light for
every pixel of every ball, using the surface normal N (the direction the surface faces) and the
view direction V (toward the camera).
- Key light, diffuse.
tint · (0.08 + 0.9 · max(N·key, 0)): Lambert's law — a surface is as bright as it faces the light — from the upper left, plus a little ambient so the dark side isn't black. - Specular highlight. A sharp white spot where the light would reflect into the eye: normalized
Blinn–Phong (Jim Blinn, 1977) with roughness 0.15, i.e. an exponent of 2 / 0.15² ≈ 89; the factor
(n + 8) / 25(25 ≈ 8π) keeps the highlight's total energy constant as it gets sharper. - Rim light.
pow(max(N·rim, 0), 3) · 0.6in magenta, from behind on the right: a colored edge that separates the balls from the dark background — a classic of studio photography. - Environment reflection. The reflected view ray
reflect(−V, N)looks up a made-up "studio": a gradient from dark violet (below) to blue (above), a white softbox and a magenta panel. It is weighted by Fresnel reflectance — surfaces reflect more at grazing angles — using Christophe Schlick's 1994 approximation withf0 = 0.08, scaled to between 25 % (facing you) and 100 % (at the edge).
ball.frag. Right: the
whole studio environment, unrolled (left–right: all the way around; top: straight up). Hover over the ball to see where
its reflection ray lands in the studio. Toggle the terms one at a time to see what each contributes.6 · An 8×8 bitmap font
Home computers of the 1980s drew text from bitmap fonts: each character is a tiny grid of on/off pixels. The Commodore 64 and the IBM PC's CGA text mode both used 8×8 character cells, and demos drew their scrollers with fonts of the same kind — often painted with gradients and drop shadows, but still a fixed grid per letter. An 8×8 glyph is just 64 bits: eight bytes.
The demo carries its own font in src/core/BitmapFont.cpp: 51 glyphs (space, punctuation, digits and capital
letters), each written in the C++ source as eight strings of eight characters, X for a lit pixel and .
for an empty one. You can read the letters right in the code. The design uses two-pixel-wide strokes in a 7×7 area, leaving the
bottom row and the right column empty so neighbouring letters never touch. A static_assert checks at compile time that
every glyph is exactly 64 characters of X and ..
At start-up the glyphs are packed side by side into one small texture, an atlas 408 × 8 pixels: glyph g sits
in columns 8g … 8g+7. Letters are looked up by a table that maps lower case to upper case and anything unknown to glyph
0, the space.
7 · Text in a fragment shader
Classic scrollers copied letters into screen memory. A fragment shader works the other way around: it runs once for every screen pixel and has to answer one question — "is this pixel part of a letter, and if so which color?" The scroller draws one fullscreen triangle and answers it with a chain of simple divisions:
- Size.
pix = max(1, floor(height / 220))screen pixels per font pixel: 4 at 1080p. A text line is 12 font pixels tall (8 for the glyph, 4 of gap): 48 screen pixels. - Which line? Measure the pixel's distance from the top, add the scroll offset (100 px/s at 1080p, scaled with the height),
subtract the screen height so the text starts below the bottom edge, and divide by the line height. Outside the text:
discard. - Which font pixel? The remainder gives the
row(0–7 inside the glyph). Each line is centered on a column at 72 % of the screen width, so its left edge is0.72 · width − length · 8 · pix / 2, and the horizontal font pixel is(x − left) / pix. - Which letter, and is it lit? Two texture reads (
font.glsl): the text texture — one row per line, one texel per character, holding glyph numbers — gives the glyph at columnp.x / 8; the atlas at(glyph · 8 + p.x mod 8, row)says on or off. The text texture is an integer texture (R8UI, read withusampler2DandtexelFetch) so glyph numbers come back exact, never blended. - Style. The fractional position inside the font pixel gives a thin darker bevel on its top and left (the 12 % nearest
the edge:
step(0.12, f)) — a chunky, LED-like look. Each line gets its own color from a cosine palette that drifts with time (§9), and the text fades in over the bottom 12 % of the screen and out over the top 15 %. The result is alpha-blended over the balls.
scroller.frag run for each of the 294,000 pixels, with the column at 50 % so the
text is centered. At the demo's 4 screen pixels per font pixel the bevel is at most one screen pixel wide and appears only where
a pixel center falls inside it; raise pixel size to see it clearly.8 · The sine scroller
The credits greet "whoever wrote a sine scroller", and that is an honest tribute: the scene's own scroller is a straight, dignified credits roll. The sine scroller is its rowdy ancestor, a fixture of 1980s cracktros and demos on the C64 and the Amiga: a single line of text slides across the screen from right to left while every column of it is lifted by a sine wave, so the letters ride over hills that roll along on their own. Coders kept a precomputed sine table in memory, because computing a sine on a 6502 or a 68000 was far too slow; each column read its height from the table at an index advanced by its position and the frame counter.
In a fragment shader the same effect is one extra line: before looking up the font, offset the pixel's vertical position by a
sine of its horizontal position. With the scroller's textPixel() it could look like this:
9 · Copper bars and raster colors
The Amiga's display chips contained the Copper (coprocessor), part of the Agnus chip: a tiny processor running in step
with the video beam. Its program — the copper list — has essentially two instructions: WAIT for the beam to
reach a given position, and MOVE a value into a hardware register. Point the MOVE at a color register and the
background color can change on every scanline, for free, while the CPU does something else. Stacking shaded horizontal stripes and
moving them on sine curves gave the famous copper bars (or raster bars, as they were called on machines like the C64, where
the same trick was done with interrupts timed to the raster beam). The original Amiga chipset could show 4,096 colors — 4 bits per
channel — so every MOVE wrote a 12-bit color.
What the scene kept. There are no copper bars in the credits, but two of their descendants. The backdrop's base color is a
vertical gradient — mix(violet, deep blue, y / height) — the per-pixel version of the copper's per-line sky gradients.
And the scroller gives each text line its own color from Inigo Quilez's cosine palette,
0.7 + 0.3 · cos(2π (0.07 · line + 0.05 t + (0, 0.33, 0.67))): the three channels are the same wave shifted by a third
of a turn, so the colors walk around the rainbow from line to line and drift slowly over time — color per line, changing every
frame, just as a copper list would.
The rest of the backdrop. Behind the gradient the shader adds a faint violet nebula (3D fractal noise, kept only where it is
dense: smoothstep(0.5, 0.9, fbm)) and the demo's shared star sky — two layers of stars hashed into 3D cells on the celestial
sphere, as explained on the other scene pages. The view direction is turned around the vertical axis at 0.03 rad/s, so the stars drift
slowly behind the balls.
The algorithm, step by step
The complete scene, in order, with each step linked to the idea above that explains it.
// CPU, once at start-up ball = icosphere(3) // 642 vertices, 1280 triangles shapes = [ fibonacciSphere, lattice 5³, torusKnot(2,3), doubleHelix, fontPixels("SPACE"), spiral ] // each: 128 × (x, y, z, scale) text = R8UI texture of glyph numbers, one row per credits line atlas = R8 texture 408 × 8, 51 glyphs |
The sphere mesh, the six shapes (§2) and the two text textures (§6, §7) are built once. Every shape is a list of 128 slots; slots a shape doesn't need get scale 0 and sit at the center. The word is made of the font's own pixels, every second one. |
// CPU, every frame beat = t · 125 / 60 current = ⌊beat / 8⌋ mod 6 previous = (current + 5) mod 6 into = beat mod 8; first = beat < 8 morphAll = smoothstep(0, 2, into) facing = current == SPACE ? morphAll : previous == SPACE ? 1 − morphAll : 0 turns = round((0.6·t₀ − 0.25 sin 0.5t₀) / 2π) // t₀ = this shape's first beat yaw = mix(0.6 t, 0.25 sin 0.5t + 2π·turns, facing) pitch = mix(0.35 sin 0.4t, 0, facing) pulse = 1 + 0.3 · exp(−6 · secondsSinceBeat) |
Everything that changes over time comes from the beat counter (§4). facing turns the cluster to the camera while it
spells the word; the front pose is shifted by whole turns, chosen once per ramp, so the turn to the front is short and smooth. pulse swells the balls on each beat. |
// CPU, for each ball i = 0 … 127
m = first ? 1 : smoothstep(0.006 i,
2 + 0.006 i, into)
s = mix(shapes[previous][i],
shapes[current][i], m)
pos = (−1.6, 0.15, 0)
+ Rx(pitch) · Ry(yaw) · s.xyz · 1.3
r = 0.09 · s.scale · pulse
hue = i / 128 + 0.05 t
tint = 0.55 + 0.45 cos 2π(hue + (0, .33, .67))
instance[i] = (translate(pos) · scale(r), tint) |
The ripple morph (§3): each ball blends between its slot in the old and the new shape, starting 0.006 beats after the previous ball. The cluster sits left of the credits column. The 128 matrices and colors go to the GPU in one buffer. |
// draw 1: backdrop, fullscreen for each pixel: d = Ry(0.03 t) · cameraRay(pixel) col = mix(violet, deepBlue, y / height) + nebula · smoothstep(0.5, 0.9, fbm(2d)) + starSky(d) |
A per-pixel vertical gradient — the copper sky's heir (§9) — with a faint nebula and the shared hashed star sky, slowly turning. No depth is written. |
// draw 2: 128 instanced spheres, depth test on vertex: world = model[i] · vertex N = normalize(model[i] · normal) fragment: col = tint · (0.08 + 0.9 max(N·key, 0)) col += specular(N, V, key, 0.15) · 0.9 col += magenta · max(N·rim, 0)³ · 0.6 col += studio(reflect(−V, N)) · mix(0.25, 1, fresnel) |
One draw call for all balls (§1); the depth buffer replaces the sorting of the bob era. Each pixel adds key light, a Blinn–Phong highlight, a magenta rim and a Fresnel-weighted reflection of a made-up studio (§5). |
// draw 3: scroller, fullscreen, alpha blend for each pixel: pix = max(1, ⌊height / 220⌋) scrolled = fromTop − height + scroll line = ⌊scrolled / (12 · pix)⌋ if outside the text: discard row = (scrolled − line · 12 pix) / pix left = 0.72 · width − len[line] · 4 · pix p = (⌊(x − left) / pix⌋, ⌊row⌋) g = text[line][p.x / 8] if !atlas[g · 8 + p.x mod 8][p.y]: discard color = palette(0.07 line + 0.05 t) · 1.6 · (bevel ? 0.7 : 1) alpha = fade(y) · (bevel ? 0.6 : 1) |
Per-pixel text (§7): a few divisions find the line, the font pixel and the glyph; two texelFetch reads decide on or
off (§6). Each line has its own drifting palette color (§9), and the text fades at the top and bottom edges. |
// post (shared by all scenes)
bloom → chromatic aberration → ACES
→ vignette → grain → sRGB |
The frame is rendered in 16-bit floating point; the demo's post chain then makes the highlights and the bright text glow. The live demo above uses a lighter stand-in: ACES tone mapping and a mip-map glow. |
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 WebGL2 changes listed under the live demo); the font and the credits text in this page are parsed from the two C++ listings below.
src/scenes/credits/CreditsScene.cpp C++ · shapes, ripple morph, three passes
shaders/scenes/credits/background.frag fullscreen · gradient, nebula, stars
shaders/common/mesh.vert vertex shader · instanced meshes
shaders/scenes/credits/ball.frag fragment shader · glossy balls
shaders/scenes/credits/scroller.frag fullscreen · per-pixel text
shaders/common/font.glsl GLSL · two texel fetches
src/core/BitmapFont.cpp C++ · the 8×8 font as X/. strings
Helpers (excerpts) Motion.h · SyncState.h · Camera.h · color.glsl · lighting.glsl
Glossary
- Demoscene
- The computer art subculture, born in the 1980s on home computers like the C64, Amiga, Atari ST and PC, that makes real-time audiovisual "demos" to show off code, graphics and music.
- Vector balls
- A classic demo effect: shaded balls at the points of a rotating, morphing 3D shape.
- Bob / blitter
- On the Amiga, a bob (blitter object) is a bitmap copied onto the screen by the blitter, a chip that moves rectangular blocks of memory.
- Painter's algorithm
- Drawing objects from the farthest to the nearest so that nearer ones cover farther ones. Needs sorting, and fails for objects that intersect.
- Depth buffer (z-buffer)
- A per-pixel memory of the nearest depth drawn so far; a new fragment is kept only if it is nearer. Makes the draw order irrelevant for opaque objects.
- Instancing
- Drawing the same mesh many times in one call, each copy with its own per-instance data (here a matrix and a color).
- Icosphere
- A sphere mesh made by repeatedly splitting the 20 triangles of an icosahedron and pushing the new points onto the sphere; its triangles are nearly all the same size.
- Golden angle / Fibonacci sphere
- π(3 − √5) ≈ 137.5°. Turning by it at each step of a spiral spreads points almost evenly, on a sunflower head or on a sphere.
- Torus knot
- A closed curve wound around a doughnut; the (2, 3) torus knot is the trefoil knot.
- mix / linear interpolation
mix(a, b, m) = a + (b − a) m: the point a fractionmof the way from a to b.- smoothstep / easing
- An S-shaped 0-to-1 curve (
3x² − 2x³) that starts and ends with zero speed, used to make motion ease in and out. - Beat / BPM
- The pulse of the music; 125 beats per minute = 0.48 s per beat. The demo's timeline and morphs are measured in beats.
- Bitmap font
- A font stored as a grid of on/off pixels per character, here 8×8.
- Texture atlas
- Many small images (here 51 glyphs) packed into one texture, addressed by offset.
- texelFetch / integer texture
- Reading one exact texel by integer coordinates, with no filtering; integer textures
(
R8UI,usampler2D) return whole numbers such as glyph indices. - discard
- A fragment shader statement that throws the current pixel away: nothing is written.
- Sine scroller
- A horizontally scrolling line of text whose columns or letters are displaced by a sine wave.
- Copper / copper list
- The Amiga's beam-synchronized coprocessor and its program of WAIT and MOVE instructions, used to change colors and other registers mid-screen.
- Raster bars (copper bars)
- Horizontal color bands made by changing the background color on every scanline.
- Cosine palette
- Inigo Quilez's
a + b · cos(2π (c t + d)): a smooth color ramp from four vectors. - Normal
- The unit vector perpendicular to a surface; it decides how light reflects there.
- Lambert / diffuse
- Matte lighting: brightness proportional to the cosine of the angle between the normal and the light.
- Blinn–Phong specular
- A highlight model using the half-vector between light and view; a higher exponent gives a smaller, sharper spot.
- Rim light
- A light from behind the subject that outlines its edge.
- Fresnel / Schlick
- Surfaces reflect more at grazing angles; Schlick's formula
f0 + (1 − f0)(1 − cos θ)⁵approximates it cheaply. - Environment reflection
- Coloring a shiny surface with what its reflected view ray would see, here an analytic "studio".
- Alpha blending
new · α + old · (1 − α): drawing a partly transparent layer over the image.- Uniform
- A value set by the CPU before a draw and shared by every vertex and pixel of that draw (time, scroll, resolution…).
- Vertex / fragment shader
- GPU programs run once per vertex (placing it on screen) and once per covered pixel (coloring it).
- HDR / tone mapping (ACES)
- Keeping brightness above 1.0 while rendering, then compressing it into the screen's range with a filmic curve.
- Bloom
- Letting very bright areas glow into their neighbourhood.
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.
