Made by AI
The fighter racing down the canyon at dusk, the ringed giant in the sky
Space Travel · Interactive explainer · Scene 08

The Canyon Run

A fighter skims a winding canyon at dusk, in and out of the cliffs' shadows. There is no terrain model anywhere: every pixel finds the rock by walking along its own ray. How one height function, a noise texture and a shared depth buffer make a moon — explained from first principles, with the real shaders running in your browser.

The scene

For forty-eight beats — 23 seconds at 125 BPM — we chase a small fighter low through a canyon on one of the ringed giant's moons. The sun hangs low, ahead and to the left, so most of the canyon floor lies in shade while light spills over the rim and catches the dust hanging in the air. The giant and its rings float in the sky. The ship banks into every turn, and its shadow slides over the rock below.

The core trick: the landscape is not a model made of triangles. It is a single function, h(x, z), that answers "how high is the ground here?" for any point on the map. To draw it, the shader sends a ray from the camera through each pixel and walks along it until it ends up under the ground — ray marching. The fighter, on the other hand, is an ordinary triangle mesh drawn the ordinary way. The two worlds meet in the depth buffer: the ray marcher writes down how far away the rock is in every pixel, so the ship can be correctly hidden behind it.

You don't need graphics experience to follow along. Each section introduces one concept, with a small interactive figure. Code appears in two forms: pseudo-code (plain logic) and the real GLSL that runs in the demo. The figures run JavaScript copies of the real functions — including a copy of the noise texture the terrain is built from — so the heights, shadows and paths you see are the canyon's own.

Live: the real shaders

This is the actual canyon: the demo's terrain fragment shader, its upscale pass and the fighter's vertex and fragment shaders, ported to WebGL2 and driven by a JavaScript copy of CanyonScene::render() — the same flight path, banking and chase camera. The noise texture the terrain is built from is generated when the page loads, by a port of the demo's own compute shader. Drag time to scrub: the whole flight is a pure function of time, so any instant can be rendered directly. The terrain is heavy, so it renders at reduced resolution, exactly like in the demo (the render scale slider).

Generating the 128³ noise volume…
View:
Try: switch the view to march steps: dark pixels found the rock in a few steps, bright ones needed a hundred or more — rays grazing the floor and the cliff edges. Lower the steps budget and distant rock turns into sky. Set bisection to 0 and the step factor to 1 to see the stair-steps that refinement hides. Set the flight height to 1: the ship ploughs into the sand and the depth buffer cuts it exactly where it enters the rock — then untick ship tested against terrain depth and it is drawn straight through the ground. Set dust to 0 and haze to 0 to see the bare rock.
Differences from the demo: WebGL2 has no compute shaders, so the noise volume is generated by a fragment shader, one slice at a time; it has no storage buffers, so the fighter's per-instance matrix becomes a uniform; texture reads of the noise volume use textureLod(…, 0.0) (identical, the volume has a single mip level) so that loops with break compile on every browser backend. Step factor, bisection, flight height and the view modes are explainer-only knobs: the demo hard-codes 0.3, 5 and 9. The demo renders the terrain at scale 0.6 (0.5 here by default) and finishes with its full post chain (bloom, chromatic aberration, grain), replaced here by ACES tone mapping and a glow with a hard threshold of 1.0 on blurred samples. The demo's soft knee starts admitting light above a brightness of 0.5 at that threshold.

Background

Nine ideas make up the canyon: where the shape comes from (1–3), how a pixel finds the rock (4–5), how it is lit (6–7), how the ship flies (8), and how the ray-marched rock and the triangle ship end up in one picture (9).

1 · Noise baked into a texture

Natural shapes — hills, clouds, rock — are irregular but not random from point to point: nearby places look alike. Graphics programmers produce that with noise functions. Value noise (a simpler cousin of Ken Perlin's 1985 gradient noise) puts a random number on every point of a grid and blends smoothly between them. Summing several layers ("octaves"), each with twice the frequency and half the strength, gives fractal noise (fbm): big shapes with smaller and smaller detail. Worley noise (Steven Worley, 1996) is different: scatter one random point in every grid cell and measure the distance to the nearest one. The result looks like cells, pebbles or cracked mud; inverted (1 − distance) it looks billowy.

The terrain function is called hundreds of times for every pixel, so computing five octaves of noise each time would be far too slow. Instead, the demo computes noise once, at start-up, into a 3D texture: a 128 × 128 × 128 block of values, with two 8-bit channels (format RG8). Channel R holds value-noise fbm (5 octaves), channel G inverted Worley noise (3 octaves). Reading a texture is something GPUs do in hardware, and it comes with free trilinear filtering: the GPU blends the 8 nearest stored values, so the noise stays smooth between them.

One detail makes it endless: every octave's grid period (4, 8, 16, 32, 64 cells) divides the texture size exactly, so the right edge matches the left edge — the texture tiles without seams, and is sampled with REPEAT wrapping. The canyon only ever reads fixed slices of the block for its height: w = 0.37 and w = 0.53, which turns the 3D texture into a free 2D noise. The dust (§7) is the one part that uses the full 3D volume.

float terrainNoise(vec2 p) { return texture(uNoise, vec3(p, 0.37)).r; }   // one slice = 2D noise
One slice of the noise volume, computed here by a JavaScript copy of noise3d.comp (same hashes, same octaves, rounded to 8 bits). Try: drag the slice through the block — the pattern morphs smoothly, because it is three-dimensional noise. The right panel repeats the slice 2 × 2: look for the seams; there are none.
A helper with a history. The function above is called terrainNoise. Its first name was noise2 — which is also the name of one of GLSL's own long-deprecated built-in functions (noise1 … noise4). With that name the canyon rendered black; renaming it fixed it. GLSL reserves more names than you might expect.

2 · A world as a height map

A heightfield describes a landscape with one number per map position: for every point (x, z) on the ground plan, the height y = h(x, z). It cannot express overhangs, arches or caves — every vertical line crosses the ground exactly once — but it is compact, fast to query, and perfect for mesas and canyons. In this scene x runs across the canyon, z along the flight, y up; the ship travels from z = 0 to z ≈ 1037 (45 units per second for 23 seconds).

Nothing is stored: the height of any point is recomputed from noise reads whenever it is needed. The map below is exactly that — one call to the canyon's terrainHeight per sample.

Hover over the map.
Top-down view of 900 units of the flight, shaded by height (dark floor, red and orange walls, pale mesa tops). Sampled at 230 × 72 points with the same function the shader calls. Hover to read the height under the cursor; the pink triangle is the live demo's ship.

3 · Carving the canyon

The landscape is two surfaces and a rule for blending them:

The canyon follows a center line that winds: center(z) = 40·sin(0.006 z) + 18·sin(0.017 z + 1). Two sine waves with unrelated wavelengths (about 1047 and 370 units) make bends that never quite repeat. Its half-width breathes too: w(z) = 28 + 8·sin(0.011 z + 2), between 20 and 36 units.

For each point the shader measures the distance d from the center line, adding ±5 units of noise so the walls look eroded instead of machined. That distance becomes a blend factor:

s = clamp((d − 0.35·w) / (0.9·w), 0, 1)     h = mix(ground, mesa, terrace(s))

s is 0 on the floor (closer than 0.35·w to the center) and 1 on the plateau (farther than 1.25·w). A straight blend would give smooth V-shaped valley sides. The terrace function turns that ramp into a staircase: it splits [0, 1] into five steps, each flat for 55 % of its width and then rising along a smoothstep curve. Flat ledges, steep risers — like the stepped walls of desert canyons, where hard rock layers resist erosion.

float terrace(float x) {
    float f = x * 5.0;                                              // five steps
    return (floor(f) + smoothstep(0.55, 1.0, fract(f))) / 5.0;   // flat, then a steep rise
}
A slice across the canyon at one z, at true scale (1 unit across = 1 unit up). Dashed: the mesa and ground surfaces. Violet: the blend factor s; pink: terrace(s) (both on the 0–1 scale at the right). The small ship marks the flight height. Try: turn off the terrace and the cliffs become smooth slopes; turn off erosion and the walls become perfectly parallel to the center line. The inset plots terrace(x) itself.
The ship's flight path uses the very same canyonCenter, written twice — in the shader and in the C++ — with a comment in both places: keep these in sync. If they drift apart the fighter flies into a wall.

4 · Ray marching a heightfield

Each pixel looks along a ray: it starts at the camera ro and goes in direction rd through that pixel (the same pinhole camera as in the warp scene: rd = normalize(forward·focal + right·u + up·v)). A point on the ray at distance t is p = ro + rd·t. We want the first t where the ray is under the ground. For a sphere or a plane there is a formula; for a noise landscape there isn't. So we walk:

t = 0.5
repeat up to 180 times:
    p = ro + rd·t
    Δ = p.y − h(p.x, p.z)                // how high above the ground are we?
    if Δ < 0.002·t: hit, stop             // close enough (or below)
    previous = t
    t += max(0.3·Δ, 0.05 + 0.002·t)       // step forward
    if t > 2500: stop                     // sky

How far may we step? Too short and the march is slow; too long and we may jump straight through a wall. The height gap Δ is a hint: right below us the ground is Δ away. But the ground ahead may rise steeply. If we know the terrain never gets steeper than slope K — mathematicians call such a function Lipschitz with constant K, after Rudolf Lipschitz — then the gap can shrink by at most K + 1 units per unit travelled, so a step of Δ / (K + 1) is always safe. The demo steps 0.3·Δ, which would be safe for slopes up to about K ≈ 2.3 (≈ 67°).

The terraced cliffs are much steeper than that, so now and then a step lands inside the rock. That is not a disaster: Δ turns negative, the loop stops, and we know the surface lies between the last point above ground (previous) and this one. Five rounds of bisection — one of the oldest root-finding methods: test the middle, keep the half that still contains the crossing — shrink that interval 32 times. Only a thin feature, thinner than one step, could be skipped completely; the canyon's walls are thick.

Distance-dependent tolerances. Far away, one pixel covers a lot of ground — at 1000 units, about 2 units. So the hit test accepts Δ < 0.002·t, and the minimum step 0.05 + 0.002·t grows with distance too. The minimum step matters for rays that graze the floor: their gap stays tiny for a long time, and without a floor on the step size they would spend their whole budget crawling. A ray that runs out of steps or passes 2500 units is treated as sky.

One ray, seen from the side. Horizontal: distance t along the ray; vertical: height. The brown area is the terrain under the ray, computed with the real terrainHeight, and the ray starts at the live demo's camera. Cyan dots are the march samples, with their gaps Δ dashed. Bottom left: a zoom on the last step, with the bisection midpoints. Bottom right: the length of every step. Try: raise the step factor above 1 and watch for "tunnelled" in the readout; lower the pixel row so the ray grazes the floor and count the steps.

5 · Normals from differences

To light a surface we need its orientation: the normal, the direction pointing straight out of it. For a heightfield the normal follows from how fast the height changes — the slopes ∂h/∂x and ∂h/∂z: n = normalize(−∂h/∂x, 1, −∂h/∂z). We have no formula for those slopes, but we can measure them: read the height a little to each side and divide the difference by the distance. These central differences cost four extra height reads. Multiplying everything by 2e (which normalize undoes anyway) gives the shader's form:

n = normalize( h(x−e, z) − h(x+e, z),  2e,  h(x, z−e) − h(x, z+e) )

The spacing e is not constant: e = 0.15 + 0.002·t. Near the camera it is small, so the normal follows every ledge; far away it grows (2.15 units at 1000) and averages away details smaller than a pixel. Measuring those tiny details from far away would make the shading sparkle and crawl from frame to frame — aliasing. A wider e is a cheap blur matched to the pixel size.

Drag the probe (cyan) along a cross-section of the canyon. The two gold points are the samples at x ± e; the line through them is the measured slope and the arrow the resulting normal (dashed white: the exact one). The inset magnifies the probe. The two strips below shade the whole slice with a light from the upper left: with your e, and with a tiny one. Try: raise e to 10 — the ledges melt into a smooth slope, which is what a distant cliff should look like when it is only a few pixels tall.

6 · Sunlight and soft shadows

Direct light. Sunlight falling on a surface is strongest when the surface faces the sun and fades as it turns away — in proportion to the cosine of the angle, max(n·L, 0), where L points toward the sun (Lambert's cosine law, 1760). Here L = normalize(−0.5, 0.18, 0.85): a dusk sun only about 10° above the horizon, ahead and to the left. Low sun means long shadows, which is most of this scene's mood.

Shadows by marching toward the sun. Is the sun visible from a point p? Walk from p toward the sun, over the same heightfield, and see whether the walk ever goes under the ground. That alone gives hard, pixel-sharp shadows. The demo uses a well-known refinement (popularised by Inigo Quilez for ray-marched scenes): at every step, compare the clearance Δ above the terrain with the distance t travelled. Δ / t is roughly the angle by which the shadow ray clears the terrain. A ray that only just scrapes past a ridge is "almost blocked" — the sun's disc is partly hidden — so it gets partial light: a penumbra.

float terrainShadow(vec3 p, vec3 L) {
    float visibility = 1.0, t = 1.0;
    for (int i = 0; i < 24; ++i) {
        vec3 q = p + L * t;
        if (q.y > 140.0) break;                         // above every mesa: nothing left to hit
        float h = q.y - terrainHeight(q.xz);           // clearance
        visibility = min(visibility, 10.0 * h / t);   // tightest angle so far
        if (visibility < 0.01) break;
        t += clamp(h * 0.5, 1.0, 25.0);
    }
    return clamp(visibility, 0.0, 1.0);
}

The factor 10 sets how soft the penumbra is (an artistic tuning value: larger is sharper). The march starts at p + n·0.3, nudged off the surface, so the surface doesn't shadow itself by rounding error ("shadow acne").

A vertical slice through the canyon, aligned with the sun (the sun is to the right). Drag the receiver (cyan): its shadow ray marches toward the sun; each gold dot is a step, and the pink line marks the step that came closest to the rock — the one that sets the visibility. The strips below show visibility for every point of the slice: soft (with your k) and hard. Try: raise the sun to see the shadows retreat; set k to 2 for very soft edges, 100 for nearly hard ones.

The ship's shadow needs no march at all. Seen from the ship's own axes and scaled by its size (2.6 × 0.5 × 2.3), its bounding ellipsoid becomes a unit sphere, and the sun ray from a rock point becomes a straight line. How close that line passes to the sphere's center is one dot product away: closer than 0.8 → the rock gets 25 % of the sun; beyond 1.15 → full sun, with a smooth edge between. An exact, soft, free shadow of a flattened blob — at the ship's distance nobody can tell it from the real shape. The fighter, in turn, calls the same terrainShadow for its own pixels, so it flies in and out of the cliffs' shade.

The rest of the light. Shadowed rock isn't black: it gets sky light, stronger on surfaces facing up (0.5 + 0.5·n.y), and a warm bounce from the sunlit rock opposite, stronger on walls (1 − n.y). Both are dimmed toward the canyon floor (mix(0.5, 1, smoothstep(0, 30, y))), a cheap stand-in for how little sky a deep canyon sees. The rock's colour comes from horizontal strata: y·0.22 plus a little noise, fed through three fract waves with different periods that mix in red, orange, pale and dark bands; flat ground (n.y > 0.6…0.9) turns to sand; and cliffs get dark vertical streaks from a stretched noise read.

7 · Dust, light shafts and haze

Haze. Air is not perfectly clear. Light crossing a uniform haze loses the same fraction of itself per unit of distance, so what survives decays exponentially: e−σt — the Beer–Lambert law. The shader blends each rock colour toward a haze colour by what was lost: mix(color, haze, 1 − e−0.0015·t·haze). The haze is warm horizon orange, turning sun-coloured when you look toward the sun.

Dust is a participating medium: it dims the light passing through it and also scatters sunlight toward the camera. The shader marches each pixel's ray a second time — 16 samples up to the rock or 260 units — and at each sample:

Sixteen samples are few, and with all pixels sampling at the same distances the dust would show stripes. So each pixel starts its march at a random fraction of a step (dt · hash12(pixel)): the stripes turn into fine grain, which the eye forgives — you can see it in the captures.

Left: the Henyey–Greenstein phase function as a polar plot — how much light a grain sends in each direction, for sunlight arriving from the left. The gold ray is your viewing direction. Right: the light scattered by the dust in the sun-aligned slice of §6 (noise replaced by its average). Try: compare the two-probe test with the full shadow march: the probes only see rock within 40 units, so dust deep in a long shadow is wrongly lit — a trade-off the demo accepts for speed, because inside the narrow canyon the shading rock is usually close. Move the view angle toward 0° and the dust brightens.

The sky. Rays that miss the rock get a gradient from horizon orange to deep blue, a glow and a disc for the sun, and the ringed giant: a sphere of radius 900, 4000 units away, found with the textbook ray–sphere intersection (a quadratic equation), with sine bands for clouds; its rings are a ray–plane intersection, opaque between 1.4 and 2.5 planet radii with sin(40r) ringlets. Both sink into the horizon haze.

8 · Flying the canyon

The ship flies along the canyon's own center line, at height 9 with a gentle ±1.5 swell: path(z) = (center(z), 9 + 1.5·sin(0.021 z), z), and its position along the canyon is simply z = speed · t with speed 45. Like everything in the demo, the pose is a pure function of time.

Heading. The ship points along the path: forward = normalize(path(z+1) − path(z−1)) — a central difference again, as in §5. Banking. The second difference, path(z+1) − 2·path(z) + path(z−1), measures how the path curves; multiplied by speed² it becomes the acceleration needed to follow it (because z = speed·t, a second derivative in time is speed² times one in z). Its sideways part is the turn's pull toward the inside. An aircraft turns by tilting its wings so that its lift points partly into the turn; the demo does the same:

lean = normalize( up + lateral · 0.05 · bank )     side = lean × forward     up′ = forward × side

That is exactly the tilt of a coordinated turn in a world with a gravity of 20 units/s² (1 / 0.05). The chase camera computes the same pose 0.3 s earlier on the path (with 30 % of the bank), sits 3 units above and 9 behind that point, and looks at a spot 8 units ahead of the ship. Because it trails along the canyon itself, it always stays inside the canyon, and in the bends the ship swings across the frame and the horizon rolls a little — a camera operator chasing a target.

Left: the canyon from above (floor and rim lines), the ship (pink), the chase camera (cyan) with its view, and the sideways acceleration (gold arrow). Right: the ship seen from behind, banking. Speed, bank and lag are shared with the live demo. Try: double the speed — acceleration grows with speed², so the bank becomes dramatic.

9 · Two renderers, one depth buffer

Ray marching costs a lot per pixel; triangles are cheap. So the demo splits the work. The terrain is marched at reduced resolution — 0.6 × the screen in each direction, only 36 % of the pixels — then stretched to full size. The fighter is drawn afterwards, as triangles, at full resolution, so the one object the eye follows stays crisp.

For the two to overlap correctly, they must agree on what is in front. GPUs settle that with a depth buffer: next to each pixel's colour it stores how far away the thing drawn there is, and a new triangle only overwrites a pixel if it is closer (depth test LESS). A rasterizer writes depth automatically; a ray marcher must do it by hand — but it knows its exact hit point, so it projects that point with the very same camera matrix the ship uses and writes the result to gl_FragDepth:

vec4 clip = uViewProj * vec4(p, 1.0);                         // the ship's vertices go through the same matrix
depth = clamp(clip.z / clip.w * 0.5 + 0.5, 0.0, 1.0);      // -1..1 → 0..1; sky stays 1.0
gl_FragDepth = depth;

Stored depth is not distance: with the demo's near plane 0.1 and far plane 3000, it is roughly 1 − 0.1/distance — 0.99 at 10 units, 0.999 at 100 — so most of the precision sits close to the camera. That is why the demo keeps depth in 32-bit floating point. The upscale pass then copies the low-resolution result to full size: colour with bilinear filtering (smooth), but depth with nearest filtering. Averaging the depth of a cliff edge with the sky behind it would invent a surface that exists nowhere — a halo floating in mid-air that could wrongly hide the ship.

A cliff edge, pixel by pixel. The cliff is 40 units away, the sky at the far plane, the ship (outlined) at a distance you choose. Left: what the terrain pass produces at the chosen render scale — big pixels. Right: after the upscale, with the ship drawn at full resolution and depth-tested. Try: put the ship at 60 units — behind the cliff — and notice the blocky edge where it disappears: that edge follows the low-resolution depth. Then put the ship at 150, turn on bilinear depth, and watch a phantom rim around the cliff eat into the ship.

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, the shared noise volume is generated (§1).

// CPU, once per frame (CanyonScene::render)
speed  = 45
ship   = poseAt(speed·t, speed, 0.05·bank)
chase  = poseAt(speed·(t − 0.3), speed, 0.3·0.05·bank)
camPos = chase.pos + chase.up·3 − chase.forward·9
camFwd = normalize(ship.pos + ship.forward·8 − camPos)
camRight = normalize(camFwd × chase.up)
viewProj = perspective(2·atan(0.5/focal), aspect, 0.1, 3000)
           · lookAt(camPos, camPos + camFwd, camUp)
L = normalize(−0.5, 0.18, 0.85);  sun = (1, 0.62, 0.35)·3
The ship's pose and the chase camera come straight from the path (§8). The camera matrix is built once and shared by the ray marcher (to write depth) and the ship (to rasterize), which is what makes them agree (§9).
// pass 1: terrain, fullscreen at 0.6 × resolution,
// depth test ALWAYS (every pixel writes depth)
for each low-res pixel:
    ro = camPos;  rd = cameraRay(pixel)
    t = 0.5
    repeat up to 180 times:
        p = ro + rd·t
        Δ = p.y − h(p.xz)
        if Δ < 0.002·t: hit; break
        previous = t
        t += max(0.3·Δ, 0.05 + 0.002·t)
        if t > 2500: break
March each ray over the heightfield with cautious steps (§4). h reads two slices of the noise volume (§1) and blends ground and mesa through the terraced canyon profile (§2, §3).
    if hit: lo = previous; hi = t
        repeat 5 times:
            mid = (lo + hi) / 2
            if below ground at mid: hi = mid
            else: lo = mid
        t = hi
Bisection pins the surface down between the last point above and the first point below (§4).
    p = ro + rd·t;   e = 0.15 + 0.002·t
    n = normalize(h(x−e)−h(x+e), 2e, h(z−e)−h(z+e))
    albedo = rockColor(p, n)   // strata, sand, streaks
Normal by central differences, with a spacing that grows with distance against aliasing (§5); rock colour from height bands, flatness and noise streaks (§6).
    sun  = max(n·L, 0) · terrainShadow(p + 0.3n, L)
                       · shipShadow(p)
    deep = mix(0.5, 1, smoothstep(0, 30, p.y))
    col  = albedo · ( sunColor·sun
                    + sky·(0.5 + 0.5 n.y)·0.25·deep
                    + bounce·0.12·(1 − n.y)·deep )
    col  = mix(col, haze, 1 − e^(−0.0015·t·hazeAmount))
    depth = (viewProj·p).z / w · 0.5 + 0.5
Lambert sunlight with soft heightfield shadows and the ship's analytic ellipsoid shadow (§6); sky fill and warm bounce, darker deep in the canyon; Beer–Lambert haze (§7); the hit point's depth, as the rasterizer would compute it (§9).
    else:  // missed, or out of steps
        col = sky(rd)    // gradient, ringed giant, sun
        t = 2500;  depth = 1
Ray–sphere and ray–plane intersections draw the giant and its rings (§7).
    // dust: 16 samples to min(t, 260), jittered start
    T = 1;  dust = 0
    for each sample q:
        ρ   = e^(−q.y/12) · (0.4 + noise3D(q·0.012 + wind))
              · dustAmount · 0.012
        lit = Πs ∈ {12, 40} clamp((q′.y − h(q′.xz))·0.2, 0, 1),
              q′ = q + L·s
        dust += T · (sunColor·lit·HG(rd·L, 0.6)·0.8
                     + sky·0.15) · ρ · Δt
        T   *= e^(−ρ·Δt)
    out = col·T + dust;   gl_FragDepth = depth
Single scattering in a height-fog of dust: density, a two-probe sun test that draws the light shafts, forward scattering, Beer–Lambert transmittance (§7).
// pass 2: upscale to full resolution, depth ALWAYS
for each pixel:
    color        = bilinear(lowColor)
    gl_FragDepth = nearest(lowDepth)
Stretch the terrain to full size, keeping its depth exact — never averaged (§9).
// pass 3: the fighter mesh, depth test LESS
for each ship pixel closer than the rock:
    engines: engineColor · 14·(1 + 0.4·beatPulse)
    nav lights: red / green, blinking
    hull, glass:
        sun = terrainShadow(pos, L)
        col = albedo·sunColor·max(N·L, 0)·sun
            + sunColor·specular·fresnel·sun
            + albedo·(rock bounce from below + sky from above)
The ship is lit by the same sun and the same soft terrain shadows as the rock (§6), and hidden wherever the rock's depth is closer (§9). The engines pulse on the beat. The demo's post chain (bloom, tone mapping, grain) follows.

The real source

These are the files as they are in the demo's repository. The browser version above uses the same code, with the changes listed under the live demo.

shaders/scenes/canyon/terrain.frag fullscreen · heightfield march, light, dust, depth
shaders/scenes/canyon/terrain.glsl the height function and soft shadows · shared with the ship
shaders/scenes/canyon/fighter.frag fragment shader · the ship
shaders/common/upscale.frag colour + depth upscale
shaders/common/mesh.vert vertex shader · instanced meshes
shaders/common/noise3d.comp compute shader · the shared noise volume
src/scenes/canyon/CanyonScene.cpp C++ · flight path, camera, the three passes
src/scenes/asteroids/Fighter.cpp C++ · the fighter mesh, lofted from rings

Glossary

Fragment shader
A program the GPU runs once per pixel (fragment); it decides the pixel's colour — and here, its depth.
Uniform
A value set by the CPU before a draw and shared by every pixel of that draw (camera, time, sun, ship pose…).
Heightfield
A landscape described by one height per map position, y = h(x, z). No overhangs or caves.
Ray marching
Finding where a ray meets a surface by walking along it in steps and testing each point.
Lipschitz constant
A bound on how fast a function can change: |h(a) − h(b)| ≤ K·|a − b|. It tells a ray marcher how far it can safely step.
Bisection
Finding a crossing by repeatedly halving an interval known to contain it.
Value noise / fbm
Smoothly interpolated random values on a grid; fbm sums several octaves of it for natural detail.
Worley noise
Distance to the nearest of randomly scattered points, one per grid cell (Worley, 1996). Cellular patterns.
3D texture
A block of stored values the GPU can read at any 3D position, blending the neighbours (trilinear filtering).
Tileable
A pattern whose opposite edges match, so copies placed side by side show no seams.
Terrace function
A staircase-shaped remapping of a ramp: flat steps joined by steep rises.
Normal
The direction pointing straight out of a surface; lighting depends on it.
Central differences
Estimating a slope as (f(x+e) − f(x−e)) / 2e.
Aliasing
Sparkle and crawl caused by detail smaller than a pixel being sampled at a single point.
Penumbra
The soft edge of a shadow, where the light source is only partly hidden.
Beer–Lambert law
Light through a uniform medium decays exponentially with distance: T = e−σd.
Transmittance
The fraction of light that gets through a medium without being absorbed or scattered away.
Phase function (Henyey–Greenstein)
How much light a particle scatters in each direction; g > 0 means mostly forward.
Depth buffer
A per-pixel record of how far away the drawn surface is, used to hide what is behind it.
gl_FragDepth
A fragment shader output that overrides the depth the GPU would otherwise compute for the pixel.
Upscaling
Stretching a lower-resolution image to the screen's size, blending neighbouring pixels.
Banking
Rolling into a turn so that the lift points partly toward the inside of the curve.
HDR, tone mapping (ACES), bloom
Keeping brightness above 1.0 until a final curve compresses it for the screen; very bright areas glow into their surroundings.

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.

← 07 · Rings  ·  All scenes  ·  09 · Fleet →