Fleet Jump-in
Twelve capital ships of a hundred thousand triangles each, dropping out of hyperspace one every other beat. One mesh, one draw call, a shadow map, and a stopwatch tied to the music — explained from first principles, with the real shaders running here in your browser.
The scene
The fleet lasts thirty-two beats — 15.36 seconds at 125 beats per minute. It opens with a white flash that hides the cut from the canyon run: we are in low orbit, a huge blue planet below, stars above, the sun low behind the camera. The sky is empty. Then, on the fourth beat, the first capital ship arrives: it shoots in from far behind, smeared along its length, brakes in a fraction of a second and settles into place with a blue streak and a flash of light. Every two beats another one follows, until twelve ships hold a wedge formation and the camera, drifting alongside, hands over to the next scene — the same dreadnought, up close.
The scene is almost pure reuse: the ship's procedural mesh, its hull shader and the planet sky all come from the dreadnought scene that follows. What is new is small but carries the whole effect: instancing (drawing twelve copies of one mesh with a single command), a few exponential curves that turn a position into a hyperspace exit, a fullscreen pass that paints the streaks and flashes, and a schedule that puts every arrival exactly on a beat of the music.
fleet 32b flash 1b post.bloom=0.2 post.threshold=2.0: 32 beats long,
blended in from the canyon with a one-beat flash transition (an HDR white flash peaking exactly at the cut, so bloom flares the
whole screen), and with two post-processing overrides that turn the bloom down — see §6 for why. The dreadnought then follows
with a two-beat crossfade.Live: the real shaders
This is the fleet scene, rebuilt in WebGL2. The ship is generated in JavaScript by a line-for-line port of the demo's model code
(same shapes, same random seed), and drawn with the demo's own vertex, shadow, hull, sky and flash shaders. The per-frame logic —
arrival times, braking, stretch, camera path, shadow cascades, projected flash positions — is a JavaScript mirror of
FleetScene::render(). Drag the time slider to scrub: like every scene in the demo, each frame is a pure function
of time.
gl_BaseInstance, so the
per-instance data reaches the vertex shader through instanced vertex attributes (§3) instead of an SSBO; the shadow map wraps
with CLAMP_TO_EDGE because WebGL2 has no border color; the Poisson table in lighting.glsl gets an explicit
array size. Everything else in the shaders is the demo's code. The post chain (13-tap bloom with a soft knee, chromatic aberration,
vignette, grain) is replaced by a light stand-in: ACES tone mapping plus a glow with a hard threshold of 2.0 on blurred mip samples.
The demo's soft knee starts admitting light above a brightness of 1.0 at the fleet's threshold of 2.0. The glow slider defaults
to the fleet's bloom intensity of 0.2. The one-beat flash transition from the canyon is not reproduced. The stars of sky.glsl are sized in pixels, so here
they are sized for the demo's 1080-line frame instead of this smaller canvas.
The ship's random detail uses a JavaScript Mersenne Twister with the demo's seed; tiny floating-point differences between C++ and
JavaScript can move an occasional block.Background
Nine ideas make up the scene, from how the ship is built to how its light reaches the screen.
1 · A ship built from boxes
GPUs draw triangles. A 3D model — a mesh — is a list of corner points (vertices) plus a list saying which three vertices form each triangle (indices). Each vertex carries a position and a normal, the direction the surface faces, which lighting needs. The demo's vertices also carry a material id (hull, engine, dark recess, dome, window) and a baked occlusion value that darkens crevices.
No modelling program was used: the dreadnought is built by C++ code when the demo starts. Almost everything is made with one primitive, the loft: take a regular polygon, stretch it to fill a square, and pull it through a series of rings along the ship's length, each ring with its own size and offset. Two rings and four sides make a box; six sides make the main hull, with a ridge on top and bottom and short vertical side walls; twenty sides make an engine nozzle.
The polygon's corners start on a circle, rotated half a step so it has a flat bottom
(dashed). Dividing by the largest x and y makes it touch all four sides of the square (solid), so a ring's scale is
exactly its half width and half height. The hull rings are (z, half width, half height) =
(−100, 55, 9), (−20, 33, 6.5), (60, 11, 2.8), (100, 0.6, 0.4): a dagger
200 units long, wide at the stern and nearly a point at the nose.
Then come the detail layers, placed by rules and random numbers: a "city" of thousands of small blocks on the decks (denser in districts chosen by noise, with the occasional tower and long industrial rows, and only where all four corners of a block sit on the same level), armoured rims along every edge, turret batteries, rows of lit windows in the side walls, plates and fittings under the belly, the command tower with its bridge and twin domes, and radiator fins on the engine block. The random numbers come from a Mersenne Twister (Matsumoto and Nishimura, 1998) with a fixed seed, so every run builds exactly the same ship.
Finally each part is flattened: every triangle gets its own three vertices carrying the triangle's own normal. Shared vertices would average the normals and make the hull look soft and rounded; flat normals give the crisp facets of armour plating.
2 · Braking out of hyperspace
To put a mesh somewhere in the world, the GPU multiplies each vertex by a model matrix: a 4×4 table of numbers that can
move (translate), turn (rotate) and resize (scale) the whole object in one operation. The ship is modelled around the origin with its
nose along +z; the fleet only translates it to its position and scales it along z:
The arrival is three exponential curves of s, the time since that ship's arrival moment:
An exponential decay is the natural shape of braking: the speed is proportional to the remaining distance, so the ship is
fastest at the start (14,000 units per second — the derivative of 2000·e−7s) and slows smoothly, never
overshooting. After 0.3 s it is 245 units from its slot, after one second under 2 units. The stretch decays even faster: the ship
starts 26 times longer than normal, a classic way to show the "snap" out of hyperspace, and is within 20 % of its true length after
half a second. Because the scale is applied around the model's origin, the stretched ship reaches far ahead of its own position: at
s = 0 its nose is already past the slot.
mesh.vert transforms normals with
mat3(model), which is exact only for rotations and uniform scales. During the stretch the normals tilt slightly, for a
fraction of a second while the ship is a blur anyway.3 · Twelve ships, one draw call
Every time the CPU asks the GPU to draw something it issues a draw call, and each call has a fixed cost: the driver
validates state, the GPU switches context. Drawing twelve ships the obvious way means twelve calls per pass, each preceded by a
uniform update with that ship's matrix. Instancing asks once: "draw this mesh n times". The GPU runs the vertex shader
for every vertex of every copy, and tells it which copy it is working on through the built-in counter gl_InstanceID
(0, 1, 2, …). Hardware instancing has been core OpenGL since version 3.1.
The per-copy data sits in a GPU buffer. The demo uses a shader storage buffer (SSBO) — a raw array of structs the shader
can index freely — holding one Instance { mat4 model; vec4 color; } per ship: 16 + 4 floats, 80 bytes. Every frame the
CPU writes the arrived ships into it and draws with glDrawElementsInstancedBaseInstance. The vertex shader picks its
struct with instances[gl_BaseInstance + gl_InstanceID]; the base instance (core since OpenGL 4.2) lets other
scenes keep several groups in one buffer and draw each slice separately. The fleet uses one group, so its base is 0.
vertexAttribDivisor(location, 1) — "advance once per
instance, not once per vertex". The shader receives them as in mat4 iModel; in vec4 iColor;. Another option is to store
the data in a floating-point texture and fetch it with texelFetch by gl_InstanceID; with twelve instances the
attributes are simpler.4 · On the beat
The demo's music runs at 125 beats per minute, so one beat lasts 60 / 125 = 0.48 seconds. Scene entries in the
timeline are measured in beats and the fleet begins on beat 272, so the scene's local time t is aligned with the beat grid:
t / 0.48 is the beat number inside the scene. Two timeline parameters set the schedule — first = 4 beats and
every = 2 beats:
Ship 0 appears at 1.92 s, ship 11 at beat 26 (12.48 s), leaving the last six beats for the whole formation to cruise together.
Nothing is triggered or remembered: at any time t the code simply computes, for every ship, how long ago it arrived — which
is why scrubbing works and why the flashes land exactly on the beat whatever the frame rate.
A second, subtler sync: the engines. Their glow is 15 · (1 + 0.3 · beatPulse), where
beatPulse = e−5 · sinceBeat jumps to 1 on every beat and fades over a fraction of a second. The engine discs
are HDR emitters (brightness 15 to 19.5), so the pulse reads as a throb through the bloom.
5 · Camera and projection
The whole formation cruises along +z at 8 units per second (drift = 8t), and so does everything else that
matters: the slots, the camera, its target and the shadow cascades. The camera starts at (1100, 150, 600) — off the
formation's right flank, a little above it so the planet shows below — and glides to (950, 120, 300) along a
smoothstep curve over the 15.36 seconds, always looking at (0, −80, −350) + drift, the middle of the
wedge.
Two matrices turn world positions into the screen. The view matrix (glm::lookAt) re-expresses everything
relative to the camera. The projection matrix (glm::perspective) performs the pinhole camera's division by depth
(see the warp explainer, §1) and squeezes the visible region into a cube from −1 to 1, clip space. The camera's
focal = 1.4 is the distance to an image plane one screen-height tall, so the vertical field of view is
2 · atan(0.5 / 1.4) ≈ 39°. Near and far planes sit at 1 and 30,000 units.
6 · Streaks, flashes and bloom
The streak and the flash are not 3D objects. After the ships are drawn, a single fullscreen pass adds them on top with
additive blending. For that, the CPU first projects two points of every arriving ship to pixel coordinates — the
same math the GPU applies to vertices: multiply by the view-projection matrix, divide by w (the depth term), map −1…1 to
0…width and 0…height:
The pixel positions and two intensities per ship are passed to the shader as small uniform arrays. For every pixel and every ship,
the shader measures the distance to the segment tail→head and adds a sharp core plus a wide, faint halo
(2·e−0.8d + 0.15·e−0.08d); around the head it adds a round flash and a thin horizontal flare
(e−0.4|y| · e−0.004|x|: fast falloff vertically, very slow horizontally), the classic anamorphic-lens
streak. Intensities decay as 3·e−6s and 4·e−4s, so the streak is gone first and the flash
lingers. Distances are measured in pixels of a 1080-line frame (scale = height / 1080), so the look does not depend on
the resolution. If either end of the streak is behind the camera (w ≤ 0) the projection is meaningless, so that streak is
skipped.
Why the bloom is turned down. The demo's post chain extracts bright light from the HDR frame using a threshold with a soft "knee", blurs it over six mip levels, and adds it back scaled by an intensity (the 13-tap downsample from Jimenez's 2014 Call of Duty talk). Brightness is the maximum RGB channel of each filtered sample. With the default knee width of half the threshold, contribution starts above half the threshold: 0.5 by default, 1.0 for the fleet, and 1.25 for the dreadnought. The default — threshold 1, intensity 0.5 — suits glowing nebulae, but a sunlit grey hull stays below about 0.9 and its specular highlights and windows go well above 1: the fine plating would dissolve into a haze. The fleet raises the threshold to 2 and lowers the intensity to 0.2, so bloom mainly emphasizes the engines, flashes and brightest windows, and the hull detail stays crisp. The dreadnought scene goes further still (2.5 and 0.15).
bloom_down.frag: how much of a pixel's brightness goes into the bloom,
times the bloom intensity. Gold bands mark typical values in this scene.7 · Shadow maps
A point is in shadow if something lies between it and the sun. Testing that directly for every pixel against a hundred thousand triangles per ship is out of the question. Shadow mapping (Lance Williams, 1978) turns it into a lookup. First render the scene from the light's point of view, keeping only depth: each texel of this shadow map stores how far the light travels before it hits the first surface. Then, while shading a pixel from the camera, compute where that point falls in the shadow map and how far it is from the light. If it is farther than the stored depth, something closer to the light covers it: shadow.
The sun is a directional light (its rays are parallel), so the light's "camera" uses an orthographic projection, a box
instead of a pyramid. fitDirectionalShadow fits the box around a sphere of the scene, extends it 400 units toward the sun so
ships outside the sphere still cast shadows into it, and snaps it to whole texels so the shadows do not shimmer as the formation drifts.
Hardware does the comparison: the map is a depth texture with compare mode on, read as sampler2DArrayShadow, and
each lookup returns the lit fraction, already averaged over 2×2 texels.
The details the demo uses, each fixing one artefact:
- Bias: while rendering the map,
glPolygonOffset(2, 4)pushes the stored depths slightly away from the light; the lookup also subtracts 0.0005 from the point's depth. Normal offset: the lookup position is moved 1.5 texels along the surface normal. Together they cure the acne above. - Soft edges (PCF): percentage-closer filtering (Reeves, Salesin and Cook, 1987) averages the comparison over several nearby map positions instead of averaging depths. The demo takes 12 samples in a Poisson-disc pattern, 1.5 texels wide, rotated by a per-pixel hash so the pattern turns into fine noise instead of visible banding.
- Cascades: one map cannot be both large and detailed. The fleet renders two 2048 × 2048 maps (a texture array with two layers): a near cascade covering a sphere of radius 700 around the middle of the formation, and a far cascade of radius 1400. Each pixel uses the first cascade whose sphere contains it with 8 % margin; the far one catches everything else. In the near one a texel is about 0.7 units — the size of the smallest deck blocks.
8 · Painting metal
The hull's fragment shader decides the color of every pixel of every ship. It first looks at the vertex's material: engine discs (material 1) simply glow blue, with a tiny flicker; windows (4) glow warm or, for 15 % of them, cold, with about one in eight left dark. Everything else is metal, shaded by a short sum of light contributions:
- Diffuse: Lambert's cosine law (1760) — a surface receives light in proportion to
N·L, the cosine of the angle between its normal and the light — times the shadow factor of §7 and half of the baked occlusion. - Specular: the sun's highlight, a normalized Blinn-Phong lobe (Phong 1975, Blinn 1977) whose sharpness comes from a roughness value, times a Fresnel term in Schlick's 1994 approximation: metal reflects little head-on and much more at grazing angles.
- Fill: a blue bounce from the planet on surfaces facing down (
max(−N.y, 0)) and a very faint cold ambient. - Reflection: no real environment map, just the idea of one — black space above, the planet's blue glow below — chosen by the reflected view direction.
The variation that makes it read as armour plating is procedural too. The shader picks the two coordinates of the plane the surface faces most (planar mapping) and cuts them into rectangular plates: regions of 28 units each pick their own plate size and orientation from a hash, some plates are split in two, and each plate gets its own hash that sets its tone and roughness. The distance to the nearest plate edge gives dark seams; a sparse grid of raised blocks adds small bumps; low-frequency noise adds weathering and grime streaks along the hull.
Seams and blocks are not geometry: they are a height that tilts the normal, bump mapping (Blinn, 1978). Because the
mesh has no texture coordinates, the demo uses Morten Mikkelsen's 2010 method, which needs only the screen-space derivatives
dFdx/dFdy — the difference of a value between a pixel and its neighbour — of the position and the height.
panel() and
blocks(). The bump normal here is also a finite difference between neighbouring samples, just as dFdx is.
Try: a grazing sun (elevation around 5–10°) makes the seams and blocks pop; hover to read a plate's hash and roughness.vObjectPos — before the model matrix —
so it sticks to the ship. During the arrival stretch it is the plating that gets stretched, not a fresh pattern on a longer hull.9 · The planet below
Behind the ships, a fullscreen pass paints the sky: for each pixel, a ray from the camera through that pixel. Stars come from hashed
3D cells around the view direction (two layers, sparse bright and dense faint), plus the sun's disc and halo. The planet is a sphere of
radius 20,000 whose surface lies 450 units below the formation's origin, and the question "does this ray hit it?" has an exact answer.
A ray ro + t·rd meets the sphere where its distance to the centre equals the radius; writing that out gives a quadratic
equation in t:
Its discriminant h = b² − (|oc|² − R²) decides everything: if h > 0 and the nearer root
−b − √h is in front of the camera, the ray hits, and the surface point and its normal follow. Then the shader paints oceans and
continents from fractal noise, sun glint on the water, drifting clouds, city lights on the night side and blue haze toward the edge. If
the ray misses but passes over the planet (−b > 0), its closest approach to the centre gives the altitude at which it
skims the atmosphere, and a glow of e−altitude/60 draws the thin bright line above the horizon.
The algorithm, step by step
The complete scene, in order, with each step linked to the idea above that explains it.
// once, at start-up ship = makeDreadnought() // lofts, boxes, seed 20261017 upload ship as one vertex + index buffer shadowMap = depth array 2048², 2 layers |
The mesh is built procedurally and flattened for crisp facets (§1): about 100,000 triangles. The shadow map is a depth texture array with hardware comparison (§7). |
// CPU, every frame beat = 60 / 125 // 0.48 s s = smoothstep(0, 1, t / duration) drift = (0, 0, 8 · t) camera = lookAt(mix(A, B, s) + drift, (0, −80, −350) + drift, focal 1.4) viewProj = perspective(39.3°, aspect, 1, 30000) · view(camera) L = sunDirection(azimuth 70°, elevation 18°) |
The camera glides between two points along a smoothstep while the whole formation cruises along z (§5). |
for i in 0..11: sᵢ = t − (first + i · every) · beat if sᵢ < 0: continue // not yet pos = slot[i] + drift − (0, 0, 2000·e^(−7sᵢ)) stretch = 1 + 25 · e^(−10sᵢ) instances += { translate(pos) · scale(1, 1, stretch), color (1, 1, 1, 1) } tail = project(pos − (0, 0, 120 · stretch)) head = project(pos + (0, 0, 100)) streak[i] = both in front ? 3·e^(−6sᵢ) : 0 flash[i] = head in front ? 4·e^(−4sᵢ) : 0 |
Arrivals are scheduled on the beat (§4). Exponential braking and stretch (§2) become one model matrix per ship. The streak's ends and the flash are projected to pixels on the CPU (§6). |
cascade c = sphere((0, −40, −420) + drift,
c == 0 ? 700 : 1400)
lightMatrix[c] = ortho box around the sphere,
+400 toward the sun, texel-snapped
upload instances // visible × 80 bytes |
Two nested cascades around the formation (§7). The instance buffer gets only the ships that have arrived (§3). |
// pass 1: shadow maps polygonOffset(2, 4) for c in 0..1: clear depth layer c drawInstanced(ship, visible) gl_Position = lightMatrix[c] · instance.model · vertex |
Depth only, from the sun's point of view, all ships in one call per cascade (§3, §7). |
// pass 2: sky, per pixel (no depth test) rd = cameraRay(pixel) col = stars(rd) + sun disc and halo if ray hits planet: col = oceans, land, clouds, glint, night lights, limb haze else if ray passes over it: col += blue · e^(−altitude / 60) · daySide |
A fullscreen ray per pixel: hashed star cells, then a ray-sphere test against the planet (§9). |
// pass 3: hulls, drawInstanced(ship, visible) // vertex shader inst = instances[gl_BaseInstance + gl_InstanceID] world = inst.model · position normal = mat3(inst.model) · normal objectPos = position // for the plating |
Each vertex reads its ship's matrix by instance index (§3). The WebGL port reads it from instanced attributes instead. |
// fragment shader if engine: return blue · 15·(1 + 0.3·beatPulse) if window: return warm/cold · 2.5 (some dark) plate, seam = panel(planarUV(objectPos)) N = bump(normal, 0.04·seam + blocks) albedo = grey · plate tone · seam · weathering shadow = 12-tap PCF in cascade(world) col = albedo · sun · N·L · shadow + sun · specular · fresnel · shadow + albedo · (planet bounce + ambient) + environment reflection |
Emissive materials first, then metal: procedural plates and seams (§8), shadow lookup with normal offset and a rotated Poisson disc (§7), and a sum of diffuse, specular, fill and reflection. |
// pass 4: flashes, fullscreen, additive for i in 0..11: d = distance(pixel, segment(tail, head)) col += blue · streak[i] · (2·e^(−0.8d) + 0.15·e^(−0.08d)) r = distance(pixel, head) col += white · flash[i] · (1.5·e^(−0.05r) + 0.2·e^(−0.01r)) col += blue · flash[i] · horizontalFlare |
Screen-space glows summed per pixel, in 1080p pixel units (§6). |
// post chain, with this entry's overrides
bloom threshold 2.0, intensity 0.2
→ chromatic aberration → ACES → vignette
→ grain → sRGB |
Only true light sources bloom, so the hull detail stays readable (§6). |
The real source
These are the files as they are in the demo's repository. The browser version above uses the same shader code, with the changes listed under the live demo.
src/scenes/fleet/FleetScene.cpp C++ · the whole scene
shaders/scenes/fleet/flashes.frag fullscreen · additive
shaders/common/mesh.vert vertex shader · instanced
shaders/common/shadow.vert + shadow.frag depth only
shaders/scenes/dreadnought/hull.frag fragment shader · metal
shaders/common/lighting.glsl shadows · bump · specular
shaders/scenes/dreadnought/sky.frag fullscreen · planet and stars
shaders/common/sky.glsl star layers
src/scenes/dreadnought/DreadnoughtModel.cpp C++ · the procedural ship
src/gfx/MeshData.cpp — makeLoft(), flattened() C++ · excerpt
src/gfx/ShadowMap.cpp — fitDirectionalShadow() C++ · excerpt
Glossary
- Mesh, vertex, index
- A 3D model as a list of corner points (vertices) and a list of triples of their numbers (indices), one triple per triangle.
- Normal
- The direction a surface faces, stored per vertex; lighting compares it with the light and view directions.
- Loft
- A shape made by pulling a cross-section through rings along an axis; with two rings and four sides it is a box.
- Flat shading
- Giving each triangle its own normal (no shared vertices), for crisp faceted surfaces.
- Model / view / projection matrix
- 4×4 matrices that place an object in the world, express the world relative to the camera, and apply perspective.
- Clip space
- The coordinates after projection; dividing by
wgives positions from −1 to 1 across the screen. - Draw call
- One request from the CPU to the GPU to draw something. Each has a fixed overhead.
- Instancing
- Drawing many copies of one mesh with a single draw call; the shader tells them apart by
gl_InstanceID. - SSBO (shader storage buffer)
- A GPU buffer the shader can read as an array of structs. The demo keeps its per-instance data in one; WebGL2 has none.
- gl_BaseInstance
- The first instance of a draw within the buffer, so several draws can share one buffer. Not available in WebGL2.
- Vertex attribute divisor
- Makes a vertex attribute advance once per instance instead of once per vertex — WebGL's way of feeding instance data.
- Uniform
- A value set by the CPU before a draw and shared by every vertex and pixel of that draw.
- Beat, BPM
- The music's pulse; at 125 beats per minute one beat lasts 0.48 s. The demo measures scenes and events in beats.
- Exponential decay
e−kt: drops fast, then ever more slowly; the shape of braking, fading flashes and beat pulses.- Additive blending
- Adding a new color to the existing pixel instead of replacing it — how light combines.
- Shadow map
- A depth image rendered from the light; a point is in shadow if it is farther from the light than the depth stored there.
- Shadow acne, bias, normal offset
- Stripes of false self-shadowing on lit surfaces, and the small depth and position offsets that cure them.
- PCF
- Percentage-closer filtering: averaging several shadow comparisons for soft shadow edges.
- Cascades
- Several shadow maps of increasing size, so nearby shadows get fine texels and distant ones are still covered.
- Lambert diffuse
- Matte reflection proportional to
N·L, the cosine of the angle to the light. - Blinn-Phong specular
- A highlight computed from the half-way vector between light and view directions; rougher surfaces give wider, dimmer highlights.
- Fresnel
- Surfaces reflect more at grazing angles; Schlick's formula approximates it cheaply.
- Bump mapping
- Tilting the normal according to a height pattern so a flat surface shades as if it had relief.
- Planar mapping
- Using two of the three position coordinates as texture coordinates, chosen by the direction the surface faces.
- Hash
- A deterministic scrambling function: same input, same "random-looking" output. Used instead of stored random numbers.
- Ray-sphere intersection
- Solving a quadratic to find where a ray enters a sphere, or how close it passes.
- 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, threshold, knee
- The glow around bright areas; the soft knee admits some light below the nominal threshold, beginning above threshold minus knee width.
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.
← Previous: 08 · Canyon run · All scenes · Next: 10 · Dreadnought →
