Made by AI
The intro: chrome logo over copper bars, rainbow scroller and its reflection
Space Travel · Interactive explainer · Scene 01

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.

You don't need graphics experience to follow along. Each section introduces one idea, 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 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.

logo text
scroller text
Try: restart and watch the logo bounce in during the first 1.6 s. Type your own logo text — the blocks resize so it still fills 84% of the width. Turn off beat sync to see how much of the life comes from the music. Set the copper speed to 0 to freeze the bars and study their shading.
WebGL changes to the shader: the #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.

Hover the image to follow one pixel through the recipe.
Try: hover the image to follow one pixel through 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.

Top: the whole atlas, as the GPU stores it (click a glyph to select it). Bottom: the selected glyph and its eight source strings. Try: click cells to edit the glyph — the change goes straight into the atlas, so the live demo above and every figure on this page now draw with your font.

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.

your text
Hover the rendered line.
Top: the text texture, one byte per character (note the lowercase letters mapped to uppercase glyphs). Middle: the line as 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:

Finally the colour is multiplied by 1.6 + 1.2 · beatPulse, so the logo flashes on every beat (§9).

Left: two characters of the logo, evaluated by a copy of the shader's 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.

drop = (1 − easeOutBounce(clamp(t / 1.6, 0, 1))) · 0.7 · screenHeight

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,

local.x += sin(row · 0.7 + 2.5 t) · wobble · (0.4 + beatPulse)

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.

Left: the logo's height above its resting place over time; each colour is one of the four parabolas, the grey ticks are beats. Right: the logo at that moment, on a 384×216 screen. Try: raise the wobble and watch the rows sway, swelling on each 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,

phase = t · copperSpeed + 0.45 i     center = 0.6 + 0.24 sin(phase)     depth = cos(phase)

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.

Left: the wheel seen from the side — height on screen up, depth across, the viewer on the right. Middle: the result on screen. Right: brightness across one bar: the rounded tube (violet), the specular line (cyan) and their sum (white). Try: turn off the depth test: then the last bar in the loop always wins, and bars stop passing behind each other.

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:

color(t) = a + b · cos(2π · (c · t + d))

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.

Top: the palette over two cycles. Bottom: the red, green and blue cosines that build it. The markers show where the seven bars sample it at the live demo's current time. Try: set all three 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:

tx = (x − screenWidth) / pix + t · scrollSpeed

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,

wave = 2.5 · sin(0.04 tx + 2t) + 1.5 · sin(0.013 tx − 1.3t)

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)).

Hover the image.
The bottom of a 480×270 screen, computed by a copy of the shader. The gold curve is the text's baseline, 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,

pulse = exp(−decay · secondsSinceHit)

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:

Left: four beats of the two pulses. Right: what they drive, at the current moment. The 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.

All scenes  ·  02 · Parallax stars →