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.
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).
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
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.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.
3 · Carving the canyon
The landscape is two surfaces and a rule for blending them:
- The mesa — a high plateau:
70 + (coarse.r − 0.5)·40 + (medium.g − 0.5)·12. Around 70 units high, rolling by up to ±20 over hundreds of units (coarse value noise) with ±6 of billowy Worley detail. - The ground — the canyon floor:
(coarse.g − 0.5)·2.5, almost flat (±1.25).
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 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 }
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.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.
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:
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.
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").
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:
- density
ρ = e−y/12 · (0.4 + noise) · dust · 0.012: thick near the floor, halving every ≈ 8.3 units of height (an exponential height falloff, like a real atmosphere); the 3D noise drifts slowly with time — wind; - is it in sunlight? Two probes toward the sun, 12 and 40 units away: each asks "how far above the rock is this
probe?" and
clamp((q.y − h)·0.2, 0, 1)turns that into 0…1. Dust where the sun clears the rim is lit; dust in the canyon's shade is not. The boundary between them is what you see as light shafts; - which way does it scatter? Dust grains throw most light forward, so dust between you and the sun glows. The
Henyey–Greenstein phase function (1941, made to describe light scattered by interstellar dust) models that with one
number,
g: 0 scatters evenly, toward 1 strongly forward. The canyon usesg = 0.6; - accumulate:
color += T · (sun·lit·phase·0.8 + sky·0.15) · ρ · Δt, thenT *= e−ρ·Δt— the transmittanceTis Beer–Lambert again, step by step. At the end the pixel becomesrock·T + dust.
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.
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:
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.
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.
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.
