Asteroid Chase
Three thousand rocks, a small fighter, one star — and a flight path built so that rock after rock eclipses the star and sweeps its shadow over the hull. Meshes, instancing and shadow maps from first principles, with the real shaders running in your browser.
The scene
The asteroid chase lasts thirty-two beats — 15.36 seconds at 125 BPM — and follows the giant star of scene 05 (it starts at beat 144, 69 seconds into the demo, with a two-beat crossfade). A small fighter weaves through a dense asteroid field, seen from a chase camera just behind and above it. A small, very bright star hangs ahead, up and to the right — with the default settings just outside the frame. The rocks are almost black on their shadowed sides and outlined by thin bright rims; whenever a big rock passes between the ship and the star, a hard shadow slides across the fighter's hull. Light shafts reach into the picture from the star's direction between the rocks; when the star itself comes into view, a lens flare appears and blinks out whenever a rock covers it.
The art direction asked for strong contrast between light and shadow, and the whole scene is organised around that. Unlike most scenes of the demo, which are drawn by ray marching a mathematical description of the world pixel by pixel, this one is rasterized: the rocks and the ship are real meshes of triangles, as in a video game, and shadows come from shadow maps. The core trick is in the flight path: it is not drawn by hand. The program first places big "hero" rocks, then puts one waypoint behind each of them as seen from the star — so when the ship passes that waypoint, the rock is exactly between the ship and the star. The path is then a smooth curve through those waypoints. Shadows on the hull are not luck: the path is made of them.
Live: the real shaders
This is the asteroid scene running in your browser. The rock, ship, sky, shadow, light-shaft and composite shaders are the
demo's own, ported to WebGL2; the CPU side of render() — field and path generation, the ship and camera poses,
culling, level of detail, the two shadow cascades — is mirrored in JavaScript from AsteroidsScene.cpp, and the rock
and ship meshes are built by a port of MeshData.cpp and Fighter.cpp. Drag the time slider to
scrub: every frame is a pure function of time. Turn on show shadow maps to see what the star "sees".
gl_BaseInstance, so per-instance matrices arrive as instanced vertex attributes (the data is the same);
the shadow map clamps to its edge instead of a white border (the shader already treats outside as lit); arrays are declared
with explicit sizes and precision qualifiers were added. The field uses the same Mersenne Twister seeds, but C++ does not fix the
order in which constructor arguments are evaluated, so individual rocks may land elsewhere than in the demo. The music beat that
pulses the engines is a plain 0.48 s clock. The demo's post-processing (bloom, chromatic aberration, grain) is replaced by
ACES tone mapping and a glow with a hard threshold of 1.0 on blurred mip samples. The demo's soft knee starts admitting light
above a brightness of 0.5 at that threshold. The 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.Background
Nine ideas make up the scene: how the rocks are built, how thousands of them are drawn cheaply, how the path is designed, how surfaces are lit, and how shadows work — from the basic shadow map to its soft, cascaded form — and finally the post-effects that sell the bright star.
1 · Rocks from a sphere
GPUs draw triangles. A 3D object is a mesh: a list of corner points (vertices) and a list saying which three vertices form each triangle (indices). Sharing vertices between neighbouring triangles keeps the surface closed and the data small. The demo has no modelled assets, so every rock is computed at start-up.
It starts from an icosahedron: the regular solid with 20 equilateral triangles and 12 vertices, whose coordinates are built from the golden ratio φ = (1 + √5) / 2 (points like (±1, ±φ, 0)). To make it rounder, each triangle is split into four by adding the midpoints of its edges, and the midpoints are pushed out onto the unit sphere (normalized). Each level multiplies the triangle count by four: 20 · 4n. Unlike a globe-style sphere made of latitude and longitude lines, the result — an icosphere — has triangles of almost equal size everywhere and no crowded poles. A small lookup table remembers each edge's midpoint so neighbouring triangles reuse it instead of creating a duplicate: no cracks.
Then every vertex is moved along its own direction d from the centre — displacement:
- Lumps: two layers of fractal noise on the direction,
(fbm(d·1.4) − 0.5)·0.7for big shapes and(fbm(d·5) − 0.5)·0.15for smaller ones (value noise computed on the CPU,src/gfx/CpuNoise.h). - Craters: 6 to 14 per rock. For each,
x= the angle betweendand the crater centre divided by the crater radius. Inside (x < 1) a parabolic bowl is subtracted,depth·(1 − x²); around it a raised rim is added,0.35·depth·exp(−((x − 1.05) / 0.2)²). Radii are0.12 + 0.38·u·uwithurandom — multiplying two random numbers makes small craters common and big ones rare. - Stretch: the rock is scaled by 1–1.5 along x, 0.7–1 along y and 0.8–1.2 along z, so no two look like balls.
Finally everything is divided by the largest radius, so every rock fits exactly in a unit sphere — handy later for culling. The normal of each vertex (the direction the surface faces, needed for lighting) is the average of the surrounding triangles' normals, weighted by their area: smooth normals, so the light varies softly across facets.
The random choices come from a seeded generator — the Mersenne Twister (Matsumoto and Nishimura, 1998) — with seeds
1000 + 7919·v for eight variants v. And because the displacement depends only on the direction
d, the same seed gives the same shape at any subdivision level: each variant is built twice, a detailed
LOD 0 (4 subdivisions, 5,120 triangles) and a light LOD 1 (2 subdivisions, 320 triangles) that matches its
silhouette.
makeAsteroid(). Switch the noise, craters and stretch on and off to see what each
contributes, and go down to 2 subdivisions to see LOD 1. (Drawn flat-shaded here so the triangles show; the demo uses the smooth
normals.)2 · Thousands of rocks, a handful of draw calls
The field holds about 3,300 rocks with the default settings. Each request to the GPU to draw something — a draw call — has a fixed CPU cost, and 3,300 draws per frame, plus more for the shadows, would waste most of the frame on bookkeeping. The answer is instancing: one draw call renders the same mesh many times, and the vertex shader receives an instance number to look up what differs between copies — here a model matrix (position, rotation and size in one 4×4 matrix) and a color (the rock's albedo, plus its size in the fourth channel). That is 80 bytes per rock.
Every frame, the CPU walks over the field and keeps only rocks that matter:
- Frustum culling. The camera sees a truncated pyramid, the view frustum. Its six planes can be read directly off
the view-projection matrix (the Gribb–Hartmann method). A rock is skipped if its bounding sphere — radius
1.05 · size, since the mesh fits a unit sphere — lies entirely behind any plane. - Shadow casters. A rock outside the view may still throw a shadow into it, so each rock is also tested against the two shadow-map boxes (§8).
- Level of detail. Visible rocks closer than
45 + 6 · sizeget the 5,120-triangle mesh; the rest the 320-triangle one. Far away the difference is smaller than a pixel.
Survivors go into buckets by (variant, LOD) — at most 16 for the camera, 8 per shadow cascade — and all buckets plus the ship
are packed into one storage buffer. Each bucket then becomes a single instanced draw that starts at its own offset in the buffer
(gl_BaseInstance). A few dozen draw calls in total for the whole frame.
The rocks also tumble: each has a random axis, a phase, and a spin of (u − 0.5) · 0.8 / √size radians per second, so
big rocks turn slowly, like heavy objects should. The angle is phase + spin · t — again a pure function of time.
// mesh.vert: one instance's data, looked up by instance number struct Instance { mat4 model; vec4 color; }; // 80 bytes layout(std430, binding = 0) readonly buffer InstanceBuffer { Instance instances[]; }; Instance instance = instances[gl_BaseInstance + gl_InstanceID]; vec4 world = instance.model * vec4(aPosition, 1.0);
3 · A path made for shadows
The ship flies along +z at a constant 28 units per second, so its distance is simply z = 28 · t.
The path decides only x and y as smooth functions of z — so it can never turn back on itself
— and it is built from the field, in four steps:
- Hero rocks. Every 70 units (± 20 %), a big rock of size 6–13 is placed near a slowly wandering centre line
(14 sin(0.011 z), 7 sin(0.008 z + 1)), offset randomly by up to ±16 sideways and ±10 vertically. - A waypoint in each hero's shadow. Let
Lbe the unit vector toward the star. Walking from the rock a distancebackaway from the star puts you right behind it, in the middle of its shadow: waypoint = rock − L · back. Walking that way also moves you sideways (in x and y) by|L.xy| · back, so choosingback = (size + pass) / |L.xy|makes the sideways gap exactly the rock's radius plus a skim distancepass= 5. (|L.xy| is clamped to at least 0.3 so a star straight ahead does not send the waypoint miles back.) - A smooth curve. The waypoints, sorted by
z, are joined by a cubic Hermite spline: between two waypoints the curve is a cubic polynomial matching both positions and both slopes. The slope at each waypoint comes from its two neighbours — the Catmull–Rom rule (Catmull and Rom, 1974). The curve may swing too close to some hero, so each hero is checked against the path and the camera's position above it and pushed away until it keepssize + 0.8 · passof clearance. - The rest of the field is scattered around the path: 60 % small rocks (0.6–2.5), 32 % medium (2.5–7), 8 % large
(9–26). A third stay within 50 units of the path for close passes, the others spread up to 180 units (288 for the large ones),
never closer than
(8 + size) · 1.2— a free corridor. The distance is√mix(min², max², u), which spreads rocks evenly over the ring's area instead of crowding them at the inner edge.
The chase camera rides the same path, lag = 0.35 seconds (about 10 units) behind the ship, raised 5.1 units
along its "up" and pulled back 5 units, and it aims at a point 6 units ahead of the ship. Because it follows the curve with a delay,
it reaches every turn a moment after the ship does — the image always seems to be catching up.
Banking. Aircraft lean into turns, and so does the ship, purely for the look. Sideways acceleration is measured from three nearby points on the path, a second difference, scaled by speed²:
where a⊥ is the part of a perpendicular to the flight direction. The ship tilts its "up" toward
the inside of the turn; the camera uses 30 % of the same lean, so the horizon tilts gently while the ship banks visibly.
4 · Light on a surface
The star is so far away that all its rays arrive parallel: a directional light, described by one unit vector
L pointing toward it and a color, (1, 0.9, 0.75) × 3.5 — a warm white, brighter than "1.0" because
the image is high dynamic range. Every term below is computed per pixel from three unit vectors: the surface normal
N, the direction to the light L and the direction to the viewer V.
- Diffuse (Lambert's cosine law, 1760): a matte surface receives light in proportion to how squarely it faces the light,
max(N·L, 0). Multiplied by the albedo (the surface's own color) and by the shadow factor (§6). - Specular: the shiny highlight. The demo uses the Blinn–Phong model (Blinn, 1977): the half vector
H = normalize(L + V)is the normal a perfect mirror would need to reflect the star into your eye, and(N·H)pmeasures how close the actual normal is. A rough surface has a small exponent (wide, dim highlight), a polished one a large exponent. The demo derivesp = 2 / roughness²and multiplies by(p + 8) / 25, a normalization (25 ≈ 8π) that keeps the total reflected light roughly constant as the highlight narrows. - Fresnel: every surface becomes more mirror-like at grazing angles — look across a lake versus straight down. Schlick's
approximation (1994) gives the reflectance as
F₀ + (1 − F₀)(1 − N·V)5, withF₀= 0.04 for the hull and 0.06 for the canopy. The ship's highlights are scaled by it; the rocks use a small fixed specular (0.03). - Ambient: light from everything that is not the star. Here it is deliberately tiny — 0.04 — and cold blue,
slightly stronger on upward-facing surfaces (
0.6 + 0.4·N.y). Shadows are nearly black: that is the contrast. - Rim:
(1 − N·V)4 · max(−V·L, 0) · 0.15. The first factor is large only at a rock's outline, where the surface turns away from you; the second only when the star is behind the rock. Together they paint the thin bright outlines of backlit rocks — an artistic term, not a physical one.
The star sits ahead of the camera most of the time, so we mainly see the rocks' unlit sides: dark shapes with bright rims, the look of the art-book captures.
5 · Bumps without tangents
Even 5,120 triangles cannot carry the grit of a rock surface. Bump mapping (Blinn, 1978) fakes it: the geometry stays smooth, but the normal used for lighting is tilted according to the slope of a height field, so light and shade behave as if the surface were rough. The classic way needs a tangent frame at every vertex — two directions along the surface — to know which way "uphill" is. Procedural rocks have no natural tangent directions.
The demo uses Mikkelsen's method (2010), which needs no tangents at all. GPUs shade pixels in 2×2 blocks and can tell a shader
how any value changes from one pixel to the next: dFdx and dFdy, the screen-space derivatives.
Applied to the world position they give two small vectors lying in the surface; applied to the height they give how much the height
changes across those vectors. Two cross products turn that into the surface gradient of the height, and the normal is tilted
against it:
vec3 r1 = cross(dpdy, N), r2 = cross(N, dpdx); float det = dot(dpdx, r1); vec3 gradient = sign(det) * (dhdx * r1 + dhdy * r2); N' = normalize(abs(det) * N − strength * gradient);
The height itself is three octaves of value noise at frequencies 3, 8 and 21, computed from the object-space position — the vertex position before the rock's rotation — so the bumps stay glued to the tumbling rock instead of swimming over it. It is multiplied by the rock's size, so big rocks get proportionally bigger bumps. The same noise darkens the albedo in patches (grit), and the shadow lookup keeps using the smooth geometric normal.
perturbNormal() — the same triangles, the same GLSL as
rock.frag (without shadows). Bring the light to a grazing angle to make the bumps stand out; switch on show
height to see the noise being differentiated.6 · Shadow maps
A point is in shadow if something lies between it and the light. Testing that against thousands of rocks per pixel would be far too slow. The shadow map (Lance Williams, 1978) answers the question with a picture: first render the scene from the light's point of view, keeping for every pixel only its depth — the distance to the first surface the light hits. That image is the shadow map. Then, while shading a point seen by the camera, transform it into the light's view, find its pixel (texel) in the map, and compare: if the point is farther from the light than the depth stored there, something else got there first, and the point is in shadow.
For a directional light the "camera" of the shadow pass is orthographic: parallel rays, a box instead of a pyramid. Each
cascade's box is centred on a sphere of interest (§8) and extended 400 units toward the star, so rocks outside the sphere but
between it and the star still cast into it. The comparison is done by the texture hardware: the map is a depth texture with
compare mode on, and texture(uShadowMap, vec4(uv, layer, depth)) returns 1 when the point is lit and 0
when it is not.
The catch is resolution. Each texel stores a single depth for a whole little square of surface. On a surface tilted
relative to the light, parts of that square are deeper than the stored value, and the surface shadows itself in stripes —
shadow acne. The demo uses three remedies together: a polygon offset during the shadow pass
(glPolygonOffset(2, 4)) pushes the stored depths slightly away from the light, more on steep slopes; a small constant
bias (0.0005 of the depth range, about 0.2–0.4 units) is subtracted before the comparison; and the lookup point is moved
1.5 texels along the surface normal — normal offset. Too much bias detaches shadows from their casters
("peter-panning").
7 · Soft edges: filtering the comparison
A single comparison gives a binary answer, so shadow edges are hard and show the map's square texels as stair steps. Averaging the colors of the shadow map would not help — averaging depths is meaningless. Instead, percentage-closer filtering (PCF, Reeves, Salesin and Cook, 1987) makes several comparisons around the point and averages the results: the fraction of lit samples. Along an edge that fraction ramps from 0 to 1, a soft penumbra.
The demo layers two kinds of filtering. The hardware already blends the 2×2 nearest comparisons when the depth texture uses
linear filtering (free PCF). On top of that, sampleCascade() takes 12 taps spread over a disc of radius
softness = 2 texels. The tap positions form a Poisson disc: random-looking but with no two points too close,
so they cover the disc evenly. With the same pattern at every pixel, the edge shows faint repeated bands; so the disc is
rotated by a different random angle at every pixel, hash12(gl_FragCoord.xy) · 2π. The bands turn into fine
noise, which the eye (and the film grain) accepts far more easily.
8 · Two cascades
One shadow map has a fixed number of texels — 2048 × 2048 here. Spread over the whole visible field, hundreds of units wide, a texel would be bigger than the ship's wing tips, and the shadows sweeping over the hull would be blurry blocks. Cascaded shadow maps, a standard technique in games, use several maps of different coverage: a small one where detail matters, a large one for everything else.
The demo uses two, stored as the two layers of one depth-texture array:
- Cascade 0: a sphere of radius 28 around the ship. One texel = 2·28 / 2048 ≈ 0.027 units: the ship, 4.3 units long, spans about 160 texels. This is the cascade that draws the shadows on the hull, and its casters use the detailed LOD 0 meshes.
- Cascade 1: a sphere of radius 190 centred 150 units ahead of the camera, covering the visible field. One texel ≈ 0.19 units; its casters use LOD 1.
Most cascade schemes slice the camera's view by distance; this one follows the subject. A pixel uses cascade 0 when it lies within 92 % of that sphere's radius — the margin keeps the 12-tap kernel and the normal offset inside the map — and cascade 1 otherwise.
Texel snapping. The boxes move with the ship and camera every frame. If a box slides by a fraction of a texel, every rock is rasterized into the map slightly differently, and shadow edges crawl and shimmer. So the box centre, expressed in the light's coordinates, is rounded down to a whole number of texels: the texel grid stays fixed in the world, and the box jumps from cell to cell.
9 · Light shafts and the lens flare
The star sits in the sky shader as a tiny disc (0.6° in radius) 60 times brighter than its colour, wrapped in two exponential glows.
After the meshes are drawn, two screen-space effects sell its brightness. Both start by finding the star on screen: multiplying
vec4(L, 0) by the view-projection matrix projects a point infinitely far away in direction L.
Light shafts (a screen-space radial blur, popularized by Kenny Mitchell's chapter in GPU Gems 3, 2007). The depth
buffer tells where the sky is visible: wherever no rock was drawn, the depth is still the cleared value 1.0. At half resolution, each
pixel marches 48 steps toward the star's screen position, adding sky? × glow at that sample × decayi.
Rocks along the way contribute nothing, carving dark streaks into the rays; decay = 0.975 per step makes nearby samples
count more. The start of the march is jittered per pixel by up to one step, which hides the sampling bands.
Lens flare. Real lenses reflect a bright source internally, producing ghost images along the line from the source through the image centre. The composite shader draws five coloured discs on that line, a thin horizontal streak through the star and a faint halo ring — all multiplied by the star's visibility: the fraction of 9 depth samples around the star that see sky. When a rock slides over the star, the flare fades with it.
The algorithm, step by step
Here is the complete scene, in order, with each step linked to the idea above that explains it.
// CPU, once at start-up for v = 0..7: seed = 1000 + 7919·v rock[v][0] = makeAsteroid(seed, 4) // 5,120 tris rock[v][1] = makeAsteroid(seed, 2) // 320 tris ship = makeFighter() // lofted panels shadowMap = depth array 2048² × 2 layers |
Icospheres displaced by noise lumps, crater bowls with raised rims, and a random stretch; the same seed at two subdivision levels gives matching LODs (§1). The fighter is built from lofted polygon sections, flat-shaded. |
// CPU, only when parameters change (ensureField) L = dir(azimuth −32°, elevation 18°) zMax = speed · (duration + 3) + 900 for z = 40; z < zMax; z += 70 · rand(0.8, 1.2): hero = rock(size 6..13) near centreLine(z) back = (hero.size + 5) / max(|L.xy|, 0.3) waypoints += hero.pos − L · back path = Hermite(waypoints by z, Catmull-Rom slopes) push heroes until path and camera keep size + 0.8 · 5 of clearance ~3,300 more rocks around path(z), outside the corridor, distance = √mix(min², max², u) |
The field and the path are generated together and cached; they depend on the light, so moving the star rebuilds them. Each waypoint sits in its hero's shadow, at a sideways distance of size + 5 (§3). |
// CPU, each frame: poses and camera
ship = poseAt(path, 28 · t)
chase = poseAt(path, 28 · (t − 0.35))
camPos = chase.pos + chase.up · 5.1 − chase.fwd · 5
camFwd = normalize(ship.pos + ship.fwd · 6 − camPos)
viewProj = perspective(2·atan(0.5 / 1.2), aspect,
0.1, 2500) · lookAt(camPos, camFwd) |
Constant speed, so distance is just speed · t. poseAt gives a forward direction from neighbouring path
points and leans "up" into the turn by the sideways acceleration (§3). |
// CPU: the two shadow cascades lightView = lookAt(origin, −L) sphere[0] = (ship.pos, 28) sphere[1] = (camPos + camFwd · 150, 190) for c in 0, 1: centre = lightView · sphere[c].centre centre.xy = floor(centre.xy / texel) · texel lightMatrix[c] = ortho(centre ± r, depth −r − 400 … +r) · lightView |
An orthographic box per cascade, extended 400 units toward the star so off-screen rocks still cast, and snapped to whole texels so edges do not shimmer (§6, §8). |
// CPU: culling and instancing for each rock: visible = frustum ∩ sphere(pos, 1.05 · size) casts[c] = sphere overlaps box c if none: continue model = translate(pos) · rotate(phase + spin·t) · scale(size) lod = |pos − camPos| < 45 + 6·size ? 0 : 1 buckets by (variant, lod) + ship → one buffer |
Frustum planes come from the view-projection matrix. Each bucket becomes one instanced draw reading its slice of the buffer
through gl_BaseInstance (§2). |
// GPU: shadow pass, per cascade, depth only clear depth to 1; polygonOffset(2, 4) draw casters // LOD 0 for cascade 0, LOD 1 for 1 draw ship |
The scene as seen from the star; only the nearest depth per texel is kept (§6). |
// GPU: main pass into HDR colour + depth
sky (no depth writes):
dust fbm + two star layers
+ starColor · (disc·60 + exp(−45θ)·2
+ exp(−7θ)·0.12) |
The backdrop leaves the depth buffer at 1.0 wherever the sky shows — the light shafts and the flare rely on that (§9). |
// rock.frag, per pixel
h = rockHeight(objectPos · 2) · size
N = perturbNormal(normal, worldPos, h · 0.06)
s = shadowFactor(worldPos, normal)
col = albedo · light · max(N·L, 0) · s
+ light · spec(N, V, L, 0.6) · 0.03 · s
+ albedo · 0.04 · coldSky · (0.6 + 0.4·N.y)
+ light · albedo · (1 − N·V)⁴ · max(−V·L, 0)
· 0.15 |
Bump mapping from screen-space derivatives (§5); diffuse, a dull specular, a tiny cold ambient and a rim for backlit edges (§4); the shadow factor multiplies only the direct light. |
// common/lighting.glsl shadowFactor(p, n): c = |p − ship| < 0.92·28 ? 0 : 1 p += n · 1.5 · texelWorld(c) uv, d = lightMatrix[c] · p θ = hash12(pixel) · 2π return mean over 12 Poisson taps k: compare(uv + rot(θ)·poisson[k]·2 texels, d − 0.0005) |
Cascade choice (§8), normal offset and bias against acne (§6), 12 rotated Poisson taps, each a hardware 2×2 comparison (§7). |
// ship.frag, per pixel
engine: colour · 14 · (1 + 0.4·beat) · flicker
nav lights: red / green, blinking alternately
hull: panels + red stripe; diffuse · s
+ spec(0.35) · Fresnel(0.04) · 2 · s
canopy: near black; spec(0.08) · Fresnel(0.06) · 4
+ sky reflection · Fresnel · 0.4 |
Same lighting with Fresnel-weighted highlights (§4). The engines and navigation lights are emissive and way above 1.0, so the bloom makes them glow. |
// shafts.frag, half resolution starUV = project(viewProj · vec4(L, 0)) p = pixel + stride · hash12(pixel) for i in 0..47: p += (starUV − pixel) / 48 sum += (depth(p) == 1) · exp(−5·|p − starUV|) · 0.975i shafts = starColor · sum / 48 · 1.2 |
A radial blur of the sky mask toward the star: rocks carve dark streaks into the rays (§9). |
// composite.frag col = frame + shafts vis = share of 3×3 depth samples at the star = 1 col += (5 ghosts + streak + halo) · vis · flare // then the demo's post chain: bloom, ACES, grain… |
The lens flare fades as rocks cover the star (§9). Bloom and tone mapping happen in the engine's shared post-processing. |
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 WebGL2 changes listed under the live demo.
src/scenes/asteroids/AsteroidsScene.cpp C++ · the whole scene
shaders/scenes/asteroids/rock.frag fragment shader · rocks
shaders/scenes/asteroids/ship.frag fragment shader · fighter
shaders/common/lighting.glsl shadows · bump · specular
shaders/common/mesh.vert vertex shader · instanced
shaders/common/shadow.vert + shadow.frag depth-only pass
shaders/scenes/asteroids/sky.frag fullscreen · backdrop
shaders/common/sky.glsl star field
shaders/scenes/asteroids/shafts.frag half resolution · radial blur
shaders/scenes/asteroids/composite.frag shafts + lens flare
src/gfx/MeshData.cpp — makeIcosphere() and makeAsteroid() C++ · excerpt
src/gfx/CpuNoise.h C++ · value noise for meshes
src/scenes/asteroids/Fighter.cpp C++ · the ship
src/gfx/ShadowMap.cpp — the depth texture array C++ · excerpt
src/gfx/Frustum.h C++ · culling
Glossary
- Rasterization
- Drawing by projecting triangles onto the screen and filling the pixels they cover — how GPUs and games draw meshes. The opposite approach, ray marching, starts from each pixel and searches along its ray.
- Mesh, vertex, index
- A mesh is a surface made of triangles; vertices are its corner points (with a normal and other data); indices list which three vertices form each triangle.
- Icosphere
- A sphere made by repeatedly splitting the 20 faces of an icosahedron and pushing the new points onto the sphere.
- Displacement
- Moving vertices along their direction or normal by a height function, to turn a smooth shape into a lumpy one.
- Normal
- The unit vector perpendicular to a surface — the direction it faces. All lighting starts from it.
- LOD (level of detail)
- Several versions of a mesh with fewer triangles for objects far away, where detail would be smaller than a pixel.
- Vertex shader
- A small program the GPU runs once per vertex; it decides where the vertex lands on screen.
- Fragment shader
- A program run once per covered pixel (fragment); it decides the pixel's color.
- Uniform
- A value set by the CPU before a draw and shared by every vertex and pixel of that draw (matrices, light direction…).
- Draw call
- One request from the CPU to the GPU to draw something. Each has a fixed cost, so fewer is better.
- Instancing
- Drawing many copies of a mesh in one draw call; each copy reads its own data (here a matrix and a color) by instance number.
- Storage buffer (SSBO)
- A block of GPU memory a shader can index freely; the demo keeps all instance data of a frame in one.
- Model / view / projection matrix
- 4×4 matrices that move a mesh into the world, the world in front of the camera, and the camera's view onto the screen. Multiplied together they take a vertex straight to the screen.
- View frustum, culling
- The truncated pyramid the camera sees; culling skips objects whose bounding sphere lies outside it.
- Directional light
- A light so far away that its rays are parallel; described only by a direction and a color.
- Albedo
- The fraction of light a surface reflects diffusely, per color channel — its "own" color.
- Lambert diffuse
- Brightness proportional to the cosine between normal and light direction,
max(N·L, 0). - Blinn–Phong specular
- A highlight computed from the half vector between the light and view directions; a larger exponent means a smaller, sharper highlight.
- Fresnel effect
- Surfaces reflect more at grazing angles; Schlick's formula approximates it cheaply.
- Rim light
- A highlight along an object's silhouette, here where the star is behind the rock.
- Bump mapping
- Tilting the shading normal by the slope of a height field to fake small relief without more geometry.
- dFdx / dFdy
- Screen-space derivatives: how a value changes from one pixel to its neighbour, computed by the GPU across 2×2 pixel blocks.
- Shadow map
- A depth image rendered from the light; a point is shadowed if it is farther from the light than the depth stored for it.
- Texel
- One pixel of a texture (here, of the shadow map).
- Orthographic projection
- A projection without perspective: parallel rays, a box-shaped view. Used for directional lights.
- Shadow acne, bias
- False self-shadowing stripes caused by the map's limited resolution; fixed by offsetting depths or lookup points slightly.
- PCF
- Percentage-closer filtering: averaging several shadow comparisons around a point, giving soft edges.
- Poisson disc
- Sample points spread randomly but never too close to each other, covering an area evenly.
- Cascaded shadow maps
- Several shadow maps of different coverage, so nearby or important areas get more texels per unit.
- Hermite spline, Catmull–Rom
- A smooth curve made of cubic pieces matching positions and slopes at the joints; Catmull–Rom takes each slope from the two neighbouring points.
- Light shafts
- Visible rays of light; here a screen-space blur of the sky mask toward the star.
- Lens flare
- Ghost images and streaks created inside a camera lens by a bright source.
- 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.
- Value noise / fbm
- Smoothly interpolated random values on a grid; fbm sums several octaves of it for natural detail.
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.
