Inside the Intro
Copper bars, a bouncing chrome logo and a rainbow sine scroller: a 1990 floppy-disk intro, rebuilt as one fragment shader with a font typed into the source code as X's and dots. Explained from first principles, with the real shader running right here in your browser.
The scene
The demo opens the way a home-computer demo would have opened around 1990. Seven glossy horizontal bars swing up and down, passing in front of and behind each other. The title SPACE TRAVEL, built from big bevelled blocks with a chrome finish, drops in from above and bounces to rest. Along the bottom a rainbow line of text wriggles in from the right — a sine scroller — mirrored in rippling water. Two layers of tiny stars drift past in the background, and everything pulses on the beat of the music.
The scene lasts 32 beats: at the demo's 125 beats per minute (one beat = 0.48 s), that is 15.36 seconds. It fades
in from black over its first two beats (the timeline's crossfade 2b; being the first entry, it fades in from
black) and crossfades into scene 02 over the next scene's first two beats.
The core trick, in plain words: there are no sprites, no meshes and no text renderer. The whole picture is one fullscreen
fragment shader — a small function the graphics card runs for every pixel, answering "what colour is this pixel at time
t?". To draw letters it asks a tiny texture "is pixel (x, y) of this character ink or paper?". Everything else — the
bars, the bounce, the waves, the reflection — is arithmetic on the pixel's coordinates and the time.
Live: the real shader
This is the actual intro: the demo's fragment shader running in WebGL2, fed with the same font atlas and text texture, built
here in JavaScript from the very glyph strings and text strings of the C++ source (listed at the bottom of the page). The
uniforms are driven by a JavaScript copy of the scene's render(). Only the demo's post-processing chain is replaced,
by a simple tone mapping and glow. Drag time to scrub: every frame is a pure function of time.
#version and precision lines, and one renamed function parameter
(length → lineLength in textPixel, because a variable hiding the built-in
length() trips some WebGL compilers). The shader works in real screen pixels, so the size of the chunky blocks follows
the canvas: at 1920×1080 a logo block is 16 screen pixels and a scroller block 12; here they are smaller, in proportion.
The demo's music is not played; the kick and beat pulses are recomputed from the 125 BPM grid (§9).Background
Nine small ideas make up the whole scene. Each one is simple; the charm is in how they combine.
1 · One shader, every pixel
A graphics card draws by running small programs called shaders. A fragment shader runs once for every pixel covered by a shape and returns that pixel's colour. The intro draws a single triangle large enough to cover the whole screen, so its fragment shader runs for every pixel of the frame — about two million times per frame at 1920×1080, spread over thousands of small processors working in parallel. No pixel knows anything about its neighbours: each one evaluates the same recipe with its own position.
That position arrives as gl_FragCoord, in screen pixels, with the origin at the bottom-left and
y going up. The rest of the input is a handful of uniforms — values the CPU sets once per frame and every
pixel shares: the time, the resolution, the beat pulses, the text lengths. The recipe in main() is the one a painter
would use — paint the back first, then whatever is in front:
if pixel is above the water line: color = skyGradient + driftingStars // background() color = copper bar covering this row, if any // copperBars() color *= 1 − 0.75 · logoShadow // logo(): drop shadow first... color = logo color, if a logo block is here // ...then the logo itself color = scroller color, if a letter is here // scroller() else: color = dark water + reflection of the scroller + bright water line
Each layer either leaves the colour alone or replaces it: the shader writes mix(col, layer, alpha) with an alpha
of exactly 0 or 1. The stars are a neat cheap trick: the screen is cut into 3×3-pixel cells and each cell's
hash — a repeatable pseudo-random number computed from the cell's coordinates — decides whether it is a star (only when
the hash exceeds 0.9975, one cell in 400). Adding time × speed to x before cutting makes the whole field slide left.
Two layers, at 12 and 30 pixels per second, the faster one brighter, give a sense of depth (parallax) — a teaser for
scene 02.
main(). Turn layers off to see what
each one contributes. This frame is computed by a JavaScript copy of the shader at 384×216 pixels — every pixel through the
same function — while the live demo above runs the real one on the GPU.2 · An 8×8 font in the source code
Home computers of the 1980s drew text from bitmap fonts: every character a tiny grid of on/off pixels, usually 8×8
(the Commodore 64's character set and the IBM PC's text-mode fonts are famous examples). Demo coders drew their own, and so does
this demo: each glyph is typed straight into src/core/BitmapFont.cpp as eight strings of eight characters,
X for ink and . for paper. Strokes are two pixels wide, drawn in a 7×7 area, with the bottom row and right
column left empty so neighbouring letters never touch. A compile-time check (static_assert) rejects any glyph that
isn't exactly 64 X's and dots.
At start-up, the BitmapFont constructor copies all 51 glyphs side by side into one small image on the GPU, a
texture atlas: 408×8 texels (texture pixels), one 8-bit channel each (format R8). Glyph g occupies
texels x = 8g … 8g+7. Filtering is set to nearest, so the shader always reads a crisp 0 or 1, never a blurred
in-between. Characters become glyph numbers through a 128-entry lookup table: lowercase is upper-cased first, and anything the
font doesn't have becomes glyph 0, the space.
3 · Text is a texture too
To draw a string, the shader must know which glyph sits at each position. The CPU encodes every line as glyph numbers — one
byte per character — and uploads them as a second texture (TextTexture): row 0 holds the logo, row 1 the scroller.
This one is an integer texture (R8UI, read through a usampler2D): it returns exact whole numbers,
and must use nearest filtering, because blending glyph 12 with glyph 13 would be meaningless.
With both textures in place, one small function in common/font.glsl answers the only question the scene ever asks
about text — "is pixel p of this line ink?" — with two lookups:
float textPixel(ivec2 p, int line, int length) { // p.x along the line, p.y = 0..7 from the top if (p.x < 0 || p.y < 0 || p.y >= 8 || p.x >= length * 8) return 0.0; uint glyph = texelFetch(uText, ivec2(p.x / 8, line), 0).r; // which character? return texelFetch(uFont, ivec2(int(glyph) * 8 + p.x % 8, p.y), 0).r; // ink or paper? }
texelFetch reads one texel by its integer coordinates — no filtering, no 0-to-1 texture coordinates. From here on,
drawing text is only a matter of turning a screen pixel into a text pixel p: the logo, the scroller and the
reflection all end in a call to textPixel.
textPixel sees it, 8 text pixels per character. Bottom: the font atlas.
Hover the middle row to follow the two lookups for one pixel.4 · Chunky chrome blocks
The logo's font pixels are huge. Each is pix = floor(0.84 × screenWidth / (12 × 8)) screen pixels wide — 16 at
1920 wide — so the twelve characters span 84% of the screen; rounding down keeps every block the same whole number of pixels. For
a screen pixel, local = (pixel − origin) / pix is its position measured in font pixels. The integer part
(floor) says which font pixel we are in — that goes to textPixel. The fractional part
(fract, 0 to 1) says where inside that block we are — and that alone is enough to decorate each block:
- Mortar:
step(0.07, f.x) · step(0.07, f.y)is 0 in a thin strip along the left and bottom of every block, leaving dark gaps between the tiles. - Bevel: brightness +60% near the top and left edges, −50% near the bottom and right edges (soft ramps made with
smoothstep): every block looks raised and lit from the top-left. - Chrome: the colour depends on the glyph row. Rows 0–3 go from sky blue to white, rows 4–6 from brown to gold, with a hard switch in between. That is the classic airbrushed chrome look: shiny metal reflects the sky above a horizon and the ground below it.
- Shine:
band = fract((x + 8 − y) / 70 − 0.3 t)is constant along 45° diagonals, repeats every 70 font pixels and slides right over time;exp(−((band − 0.5) · 14)²)turns it into one narrow bright stripe. Its +3 makes it "whiter than white" so the glow picks it up. - Drop shadow: the shader also asks
textPixelabout the point half a block up-left. If that is ink, this pixel lies in the shadow of a letter (half a block down-right of it) and the background is darkened by 75%.
Finally the colour is multiplied by 1.6 + 1.2 · beatPulse, so the logo flashes on every beat (§9).
logo() 16 times per
font pixel across. Right: the chrome colour of each glyph row. Try: switch the decorations off one at a time; with
everything off, what's left is the bare font.5 · Bounce and wobble
Animators use easing functions to make motion feel physical: a function that takes progress from 0 to 1 and returns a
position from 0 to 1, but not in a straight line. The logo uses easeOutBounce, from Robert Penner's easing equations
(popular in Flash animation in the early 2000s, now in every animation library). It is four parabolas glued together: a fall that
speeds up, then three ever-smaller hops — heights of 25%, 6.25% and about 1.6% of the fall.
The logo starts 70% of a screen height above its resting place, hits the ground at 0.58 s, bounces at 1.16 s and 1.45 s, and is still at 1.6 s. Note this timing is not tied to the beat: 1.6 s is 3⅓ beats.
Once landed, the logo keeps moving: each row of font pixels is shifted sideways by a sine of its row number,
so the letters ripple like a flag. The shift happens before floor() picks the block, so whole rows of
blocks slide, mortar lines and all. With the default wobble = 0.8 the ripple is 0.32 font pixels at rest and swells to
1.12 on every beat.
6 · Copper bars
The Commodore Amiga (1985) had a tiny co-processor called the Copper, which ran in step with the video beam as it painted the screen line by line. Its program was a list of "wait until the beam reaches this line, then write this value into that hardware register". Writing a new background colour at every scanline costs the main processor nothing, and demo coders used it to paint smooth horizontal bands of colour — copper bars (also called raster bars; on the Commodore 64 coders got the same effect with raster interrupts).
The shader recreates them with a simple model: each of the seven bars is a bead on a wheel seen edge-on. For bar
i,
As the wheel turns, a bead goes up and down on the screen (sin) while it moves from the back to the front
(cos). Where bars overlap, the one with the largest depth wins: the loop keeps a nearest
value and only accepts a bar nearer than any accepted so far — a one-number version of the depth test that 3D graphics use
to hide what's behind. Shading turns each flat band into a glossy tube: d is the distance from the bar's centre in
units of its half-thickness (3.2% of the screen height), 1 − d² is a rounded profile, (1 − d²)¹⁶ is a
thin bright specular line along the middle, and 0.55 + 0.45 · depth darkens the bars at the back.
Notice that a bar depends only on y: every pixel of a row gets the same colour — exactly the constraint of the
original hardware, and the reason copper bars are horizontal.
7 · Cosine palettes
Both the bars and the scroller get their rainbow colours from one line of maths, a cosine palette popularised by
Inigo Quilez. Each colour channel is a cosine wave of the input t:
a is the average brightness, b how far it swings, c how fast it cycles, and
d shifts each channel. The demo uses c = 1 and d = (0, 0.33, 0.67): red, green and blue peak a
third of a cycle apart, which walks around the colour wheel — a rainbow that repeats every 1.0 of t. The bars use
a = b = 0.5 (full range, from black to saturated); the scroller uses a = 0.6, b = 0.4 (paler, never
dark).
The bars sample it at i / 7 + 0.05 t: seven hues evenly spread around the wheel, the whole set rotating once every 20
seconds. The scroller samples it at textColumn · 0.004 + 0.15 t: the rainbow repeats every 250 font pixels (about 31
characters) along the text and shifts over time.
d to the
same value — the channels move together and the rainbow collapses to greys.8 · The sine scroller and its reflection
Scrolling text was the voice of the demoscene: greetings, jokes and boasts crawling across the bottom of the screen, often on a sine wave. Here the scroller never moves in memory. Each pixel computes which text column is under it right now:
Column 0 starts at the right edge of the screen; as t grows, higher columns arrive there and everything appears to
slide left at 70 font pixels per second. Then the column's height is bent by two sines added together,
and the pixel's row inside the text is (y − base) / pix − wave. Each column is shifted on its own, so letters are
sheared up and down rather than rotated — exactly the look of the old scrollers. Two sines with unrelated frequencies make a wave
that never quite repeats. mod(tx, length · 8 + 48) wraps the text around after a gap of 48 columns (six blank
characters). Each block is shaded with a mortar line, a gradient from 70% at its bottom to 130% at its top, and the palette colour of
§7.
In the demo the scroller gets as far as "…ABOUT TO JUMP TO HYPER" before the scene ends: in 15.36 s at 70 font pixels per second, about 1,075 columns — 134 characters — have entered the screen. The greetings at the end of the string are never on screen in the demo; raise the scroll speed in the live demo to read them.
The water. Below a line just under the text, the shader paints dark water instead and asks the scroller for the colour
at the pixel's mirror image: y' = 2 · waterY − y. Pushing x sideways by
sin(0.15 y + 5t) · 0.4 · pix before the lookup makes the reflection ripple. The reflection is tinted blue and fades with
depth, and a thin bright line glows right at the water's edge (exp(−0.5 · distance)).
base + wave · pix. Try: turn off one sine at a time; set the scroll speed to 0 and watch the wave
still travel through the frozen text; switch off the ripple to get a perfect mirror.9 · On the beat
Demos are music videos computed live, and the effects are tied to the music. The demo's music is a tracker module: a song stored as patterns of notes, the format of the Amiga's ProTracker. The engine reads the note data along with the time, so for any moment it knows how long ago the last beat was and how long ago each instrument last played. A scene turns that into a pulse: 1 at the hit, then decaying exponentially,
The intro uses two: uKick (instrument 1, the kick drum, decay = 6) and uBeatPulse
(the beat grid, decay = 5). The placeholder song plays the kick "four on the floor" — on every beat — so in this scene both
pulse every 0.48 s, the kick fading a little faster. Because they are computed from the time alone, scrubbing backwards gives the same
pulses as playing. The scene uses them three times:
- copper bars brightness
× (1 + 0.8 · kick); - logo brightness
× (1.6 + 1.2 · beatPulse); - logo wobble amplitude
× (0.4 + beatPulse).
kick timeline parameter picks which instrument drives uKick; with the placeholder song, instrument 1 is
the kick.The algorithm, step by step
Here is the complete scene, in order, with each step linked to the idea above that explains it.
// start-up (C++) atlas = 51 glyphs × 8×8, side by side // R8, 408×8 text = [encode("SPACE TRAVEL"), encode(scrollText)] // R8UI, one row per line |
The font is typed in the source as X's and dots and copied into a texture atlas (§2). Each line of text becomes a row of glyph numbers in an integer texture (§3). |
// each frame (C++) bind atlas → unit 0, text → unit 1 uTime = t // 0 … 15.36 s uKick = exp(−6 · sinceKick) uBeatPulse = exp(−5 · sinceBeat) uScrollSpeed = 70 · scrollParam uWobble = 0.8; uCopperSpeed = 1.3 draw one fullscreen triangle |
Everything that changes is reduced to a few uniforms computed from t alone (§9). One triangle covers the screen,
so the fragment shader runs once per pixel (§1). |
// per pixel: layout scrollPix = max(1, floor(H / 90)) scrollBase = 0.13 · H waterY = scrollBase − 3 · scrollPix if y ≥ waterY: sky branch else: water branch |
Sizes are derived from the resolution, so the layout is the same on any screen: the scroller's blocks are 1/90 of the height, its baseline at 13% of it. |
// sky branch, back to front col = mix(darkBlue, darkViolet, y / H) for layer in (12 px/s, 30 px/s): cell = floor((pixel + (t·speed, 0)) / 3) if hash12(cell) > 0.9975: col += star nearest = −2 for i in 0..6: phase = t·1.3 + 0.45 i d = |y/H − (0.6 + 0.24 sin phase)| / 0.032 if d < 1 and cos phase > nearest: nearest = cos phase col = palette(i/7 + 0.05t) · (0.25 + 1.4(1−d²)) · (0.55 + 0.45 cos phase) + 0.9 (1−d²)¹⁶ ; col ·= 1 + 0.8 kick |
A gradient and two layers of hashed stars sliding left at different speeds (§1). Seven copper bars on a turning wheel; the nearest bar covering the row wins, shaded as a glossy tube (§6) in a cosine-palette colour (§7). |
// logo pix = max(1, floor(0.84 W / (12 · 8))) drop = (1 − easeOutBounce(t / 1.6)) · 0.7 H local = (pixel − origin(drop)) / pix local.x += sin(row·0.7 + 2.5t) · 0.8 · (0.4 + beat) shadow = textPixel(floor(local + (−½, ½)), line 0) if textPixel(floor(local), line 0): f = fract(local) col = chrome(row) · bevel(f) · (1.6 + 1.2 beat) + 3 · shine(local, t) ; col ·= mortar(f) col_before_logo ·= 1 − 0.75 · shadow |
Screen pixel to font pixel; the bounce and the wobble only move that mapping (§5). Two text lookups (§3) give the block and its drop shadow. The fractional part decorates each block: mortar, bevel, chrome by row and a sweeping shine (§4). |
// scroller tx = (x − W) / scrollPix + t · speed wave = 2.5 sin(0.04 tx + 2t) + 1.5 sin(0.013 tx − 1.3t) ty = (y − scrollBase) / scrollPix − wave if 0 ≤ ty < 8: on = textPixel((mod(tx, len·8 + 48), 7 − ty), line 1) col = mix(col, palette(0.004 tx + 0.15t) · mix(0.7, 1.3, fract ty) · 2.2 · mortar, on) |
Scrolling is only a time offset in the lookup; each column is bent by two summed sines (§8) and coloured by the palette (§7). |
// water branch
depth = (waterY − y) / waterY
col = mix(deepBlue, black, depth)
m = (x + sin(0.15 y + 5t) · 0.4 · scrollPix,
2 · waterY − y)
col += scroller(m) · blueTint · 0.4 · (1 − depth)
col += lightBlue · 0.6 · exp(−0.5 (waterY − y)) |
The water mirrors the lookup position about the water line and ripples it sideways (§8). The output is linear HDR colour; the demo's post chain (bloom, tone mapping, grain) turns it into the final image. |
The real source
These are the files as they are in the demo's repository. The live demo above compiles the same shader (with its includes inlined), and this page builds its font atlas and text by reading the glyph table and strings out of the C++ listings below.
shaders/scenes/intro/intro.frag fragment shader · the whole scene
shaders/common/font.glsl GLSL · textPixel()
src/scenes/intro/IntroScene.cpp C++ · the scene, texts and render()
src/core/BitmapFont.cpp C++ · the 8×8 font and the text texture
Helpers (excerpts) palette, hash, font header, sync pulses, drum pattern
Glossary
- Fragment shader
- A program run once per covered pixel (fragment); it decides the pixel's color. The whole intro is one.
- Fullscreen triangle
- One triangle big enough to cover the screen, drawn so that the fragment shader runs for every pixel.
- gl_FragCoord
- The position of the pixel being shaded, in screen pixels, origin at the bottom-left.
- Uniform
- A value set by the CPU before a draw and shared by every pixel of that draw (time, resolution, beat pulses…).
- Texture, texel
- An image stored on the GPU for shaders to read; a texel is one of its pixels.
- Texture atlas
- Many small images packed into one texture — here all 51 glyphs side by side.
- Integer texture (R8UI, usampler2D)
- A texture holding exact whole numbers rather than colours; used for the glyph numbers of the text.
- texelFetch
- Reads one texel by integer coordinates, with no filtering.
- Nearest filtering
- Reading the single closest texel instead of blending neighbours; keeps pixel art crisp.
- Bitmap font, glyph
- A font stored as grids of on/off pixels; a glyph is one character's grid.
- floor / fract
- The integer part and the fractional part of a number: which block a pixel is in, and where inside it.
- Hash
- A deterministic scrambling function: same input, same "random-looking" output. Used here to scatter the stars.
- Parallax
- Nearer things appear to move faster than farther ones; two star layers at different speeds suggest depth.
- Copper, copper bars
- The Amiga's display co-processor, which could change colours at chosen scanlines; and the horizontal colour bands demo coders made with it.
- Scanline
- One horizontal line of the picture, as drawn by the video beam of a CRT display.
- Depth test
- Keeping, for each pixel, only the nearest of several overlapping things.
- Specular highlight
- The small bright reflection of a light on a shiny surface; here a thin line along each bar.
- Bevel
- Lighter and darker edges that make a flat shape look raised.
- Easing function
- A curve mapping animation progress (0 to 1) to position, for motion that speeds up, slows down or bounces.
- Cosine palette
a + b·cos(2π(c·t + d))per channel: a smooth, cyclic colour ramp from four parameters.- Sine scroller
- Scrolling text whose columns are shifted up and down by a sine wave.
- Tracker module
- A music file storing samples plus patterns of notes, played back by software; the Amiga's ProTracker made the format famous.
- BPM, beat
- Beats per minute; at 125 BPM one beat lasts 0.48 s.
- Pulse
- A value that jumps to 1 on a musical hit and decays exponentially:
exp(−decay · time since hit). - 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 neighbourhood, 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.
