The Ringed Giant
A banded planet, a sheet of rings with gaps and shadows, and a dive straight through it among tumbling chunks of ice. One fullscreen shader computes the planet and the rings with a little geometry, then real 3D meshes take over near the camera. Explained from first principles, with the real shaders running right here in your browser.
The scene
After the asteroid belt, the ship reaches a ringed giant. The scene lasts forty-eight beats — 23.04 seconds at 125 BPM. We start high above the rings, far out, with the cream-coloured planet straight ahead and the rings sweeping around it. The camera descends in a straight line, always looking at the planet. As it nears the ring plane, the painted-looking sheet around us dissolves into real chunks of ice tumbling past; we pass through the ring plane at about 12.6 s, and the rest of the flight happens below the rings, looking up at the planet's night side, striped by the shadows of the rings.
The planet and the ring disc are not 3D models. They are drawn by a ray tracer: for every pixel, a fragment shader works out which line of sight the pixel looks along, and computes exactly where that line hits a sphere (the planet) and a flat plane (the rings) — a few lines of high-school algebra each. Everything about their look — bands, storms, ring gaps, shadows — is a formula evaluated at the hit point. Near the camera, where a flat sheet would look fake, the shader fades the rings out and a second pass draws real meshes: a few hundred small ice chunks (up to about 870), placed with the same radial density as the rings. The ray tracer writes a depth value for each pixel, so the meshes and the traced planet hide each other correctly.
Live: the real shaders
This is the actual scene: the demo's planet-and-rings fragment shader and its ice-chunk shaders, ported to WebGL2, driven by a
JavaScript copy of the scene's C++ render() (camera path, sun direction, chunk placement). Drag the time
slider to scrub: every frame is a pure function of time. The demo's full post-processing chain (bloom, chromatic aberration,
vignette, grain) is replaced by a simple ACES tone mapping and glow.
Port notes: the planet shader is line for line the demo's (plus #version/precision
and one explainer-only uShadows switch). The glow uses 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. Those stars (from
sky.glsl) are sized in pixels, so here they are sized for the demo's 1080-line frame instead of this smaller canvas. WebGL2 has no storage buffers and no gl_BaseInstance, so
common/mesh.vert reads each chunk's matrix and colour from instanced vertex attributes instead; ice.frag
is unchanged. The four chunk meshes come from a JavaScript port of makeAsteroid(); its random generator is the same
algorithm (Mersenne Twister), but C++ leaves the evaluation order of some calls to the compiler, so the shapes may differ in detail.
Render scale renders at a lower resolution and stretches the result.
Background
Nine ideas make up the scene. The first three are geometry (where does a line of sight hit the planet or the rings?), the next four are about light and colour, and the last two are about the hand-over from the traced sheet to real 3D chunks.
1 · A camera that shoots rays
There are two classic ways to make a 3D picture. Rasterization takes each triangle of a model, projects its corners onto the screen and fills the pixels it covers — that is how the ice chunks are drawn. Ray tracing goes the other way around: for each pixel, it shoots a line — a ray — from the eye through that pixel into the scene and asks what the ray hits first. The idea is old (Arthur Appel's ray casting, 1968; Turner Whitted's recursive ray tracing, 1980) and it is perfect for shapes that have a simple equation, like a sphere or a plane: there is no need for any triangles at all.
A ray is a starting point and a direction: every point on it is ro + t · rd, where ro is the ray
origin (the camera), rd a unit-length direction, and t the distance travelled along it. The demo's
pinhole camera builds rd for a pixel from three axes — forward, right and up — and a
focal length:
where (u, v) is the pixel's position on the screen, measured in screen heights from the centre (so v
runs from −0.5 to +0.5). With focal = 1.1, the vertical field of view is 2 · atan(0.5 / 1.1) ≈ 49°.
The axes come from a look-at construction: forward points from the camera to the target, right is perpendicular to forward
and to the world's up direction, and up completes the set.
The flight itself is one line of C++: the camera slides along a straight segment from (80, 40, 160) to
(−40, −30, 90), eased with a smoothstep so it starts and stops gently, while always looking at the point
(0, 5, 0), just above the planet's centre. The planet sits at the origin with radius 60; the rings lie in the
y = 0 plane between radius 80 and 150.
s = smoothstep(0, 1, t / 23.04). The camera crosses the ring plane at radius ≈ 120, in the densest part of the rings.// C++, each frame (src/scenes/rings/RingsScene.cpp) s = smoothstep(0, 1, t / duration) // 0 → 1, eased at both ends camera = lookAt(mix(from, to, s), target = (0, 5, 0), focal = 1.1) L = (sin az · cos el, sin el, cos az · cos el) // toward the sun: az = 58°, el = 20° sun = (1.0, 0.96, 0.9) · 2.2 // slightly warm, HDR intensity
2 · Hitting a sphere
A sphere of radius R centred at the origin is every point whose distance from the origin is R:
|p|² = R². Put the ray's points p = ro + t · rd into that and expand (with |rd| = 1):
A quadratic equation in t. Its solutions are t = −b ± √h with h = b² − c. If
h is negative, there is no solution: the ray misses. If it is positive, the ray enters the sphere at
−b − √h and leaves at −b + √h; we only need the entry point, the nearer one. There is a neat
geometric reading: −b is the distance along the ray to the point closest to the centre, and
h = R² − d², where d is how far the ray passes from the centre — so h > 0 means "passes
closer than R".
float b = dot(ro, rd); float h = b * b - (dot(ro, ro) - uPlanetRadius * uPlanetRadius); float tPlanet = h > 0.0 ? -b - sqrt(h) : -1.0; // −1 = "no hit"
f(t) = |ro + t·rd|² − R² along the ray — it is negative
exactly where the ray is inside the sphere, and its zero crossings are the two hits. Put the camera inside the planet: the entry
point is behind the camera (t < 0), and the shader treats that as "no hit".The hit point is p = ro + rd · tPlanet, and because the sphere is centred at the origin, the surface normal —
the direction straight out of the surface, needed for lighting — is simply normalize(p).
3 · Hitting the ring plane
The rings are infinitely thin and lie in the plane y = 0. A ray reaches that plane when its height
ro.y + t · rd.y becomes zero:
If rd.y is (almost) zero, the ray runs parallel to the plane and never meets it; if tRing is
negative, the plane is behind the camera. Otherwise the hit point is p = ro + rd · tRing, and its distance from the
planet's axis, r = length(p.xz), tells us where we are in the ring system: nothing inside 80 or outside 150, and in
between a density that depends only on r (section 4). That turns a plane into an annulus — a flat disc with
a hole.
Now the two shapes must be combined. A pixel may look at the planet, at the rings, at both or at neither. The rule in the shader: draw the planet (or the sky) first; then, if the ring hit is in front of the camera and nearer than the planet hit, blend the ring colour over it with the ring's opacity as the weight. If the planet is nearer, the rings behind it are simply hidden.
float tRing = abs(rd.y) > 1e-6 ? -ro.y / rd.y : -1.0; if (tRing > 0.0 && (tPlanet < 0.0 || tRing < tPlanet)) { // in front, and not hidden by the planet vec3 p = ro + rd * tRing; float r = length(p.xz); float opacity = ringOpacity(r) * smoothstep(uNearFade * 0.35, uNearFade, tRing); if (opacity > 0.0) col = mix(col, ringColor(p, r, opacity, ro, rd), opacity); }
mix(a, b, α) = a · (1 − α) + b · α is the classic "over" blend for a semi-transparent layer: with opacity 0.3, the
pixel keeps 70 % of what is behind the ring and adds 30 % of the ring's own colour. Thin parts of the rings let the planet and the
stars show through.4 · The ring profile
Real planetary rings are made of countless particles — for Saturn, mostly water ice, from dust grains to boulders a few metres across — orbiting in a sheet that is enormously wide and very thin. Seen from afar, what matters is how much of the sky they block at each distance from the planet: their opacity profile. Saturn's is full of structure: a faint inner ring, a bright dense ring, the dark Cassini Division (a gap discovered by Giovanni Cassini in 1675), and narrow gaps such as the Encke Gap near the outer edge.
The demo builds a profile in the same spirit as a product of simple factors, each a function of the radius r
(with x = 0 at the inner edge and 1 at the outer edge). Its proportions — 1.33 to 2.5 planet radii — are roughly
Saturn-like.
- base: 0.3 in the faint inner ring, rising to 0.95 between
x= 0.1 and 0.45, then easing down to 55 % over the outer part (x0.72 → 0.95). - ringlets: two slow sines (periods of about 7 and 27 units) times fine value noise — the many thin ringlets.
- wide gap: fully empty within ±2 units of
r= 128.3 (x= 0.69), back to full by ±3.5. - narrow gap: empty within ±0.4 of
r= 143 (x= 0.9). - edges: a soft start over the first 3 % and a soft end over the last 4 %.
The product is clamped to at most 0.97, so even the densest ring lets a little light through. Each smooth transition uses
smoothstep(a, b, x): 0 before a, 1 after b, and an S-shaped curve in between. The colour of
the ring material, ringAlbedo(r), is another 1D noise along the radius, between a warm grey-brown and a pale cream.
ringAlbedo × opacity.
Right: the opacity along the radius (thick white) and its factors (thin). Switch factors off to see what each one adds,
and hover over the plot to read the values. The dashed gold curve is the simpler CPU copy used to place ice chunks (section 8):
the same profile without the fine ringlets, times 0.8.5 · A banded gas giant
Gas giants are striped because they rotate fast: winds are organised into bands of alternating direction along lines of
latitude, and the turbulence where they meet makes swirls and storms. The shader paints that with a handful of formulas evaluated
on the surface normal n (a unit vector, so n.y runs from −1 at the south pole to +1 at the north pole):
- Rotation.
nis turned around the vertical axis byuTime · 0.01radians — only about 13° over the whole scene, a slow drift — giving the pointqwhere the patterns are read. - Bands.
band = sin(n.y · 22 + turbulence · 3) · 0.5 + 0.5: a wave along latitude, seven cycles from pole to pole, blending two cream-brown colours. Bands are evenly spaced inn.y, which is the height on screen when seen from the side. - Turbulence.
fbm(q · 4)— fractal noise, a sum of five layers of smooth random values, each twice as detailed and half as strong — shifts the phase of the wave, so the bands wobble. - Storms. Where a second, finer fbm exceeds 0.6–0.8, the colour moves halfway toward a darker brown.
- Polar caps. Beyond
|n.y|= 0.8 the albedo darkens to 70 %.
Then light. The basic term is Lambert's cosine law (J. H. Lambert, 1760): a matte surface receives light in proportion to
n · L, the cosine of the angle between the normal and the direction to the sun — full at noon, zero at the
terminator line between day and night. Three artistic touches follow:
- Limb darkening:
col *= mix(0.6, 1.0, μ)withμ = n · (−rd), so the edges of the disc, seen at a grazing angle, are darker. Real planets and stars show this effect. - Rim glow: a pale blue
(1 − μ)³halo at the edge on the day side, a hint of atmosphere. - Ringshine: the night side is not black but very faintly lit (2.5 % of the sun) away from the equator — the sunlit rings reflect some light back onto it.
float ndl = dot(n, L); vec3 col = albedo * uSunColor * max(ndl, 0.0) * ringShadow(p, L); // Lambert, shadowed by the rings col += albedo * uSunColor * 0.025 * max(-ndl, 0.0) * smoothstep(0.0, 0.5, abs(n.y)); // ringshine at night float mu = max(dot(n, -rd), 0.0); col *= mix(0.6, 1.0, mu); // limb darkening col += vec3(0.55, 0.68, 0.95) * pow(1.0 - mu, 3.0) * smoothstep(-0.2, 0.4, ndl) * 0.6; // rim glow
6 · Shadows both ways
A point is in shadow if something blocks the straight line from it to the sun. General-purpose renderers answer that with shadow maps or extra rays; here the two blockers are so simple that each shadow is a short formula.
The planet's shadow on the rings (and on the ice). The sun is so far away that its rays are parallel, so the planet casts
a shadow shaped like an infinite cylinder of radius R, starting at the planet and pointing away from the sun.
For a point p: along = p · L is how far it lies toward the sun (positive = on the sunny side, never
shadowed); otherwise d = |p − L · along| is its distance from the cylinder's axis, and the light fades from 0 to 1 as
d goes from R − 2 to R + 2 — a soft edge, like a penumbra.
The rings' shadow on the planet. From a point on the planet, follow the ray toward the sun: it crosses the ring plane at
s = −p.y / L.y (the same ray–plane formula as section 3). If s > 0, the crossing is on the way to the
sun, and the light is dimmed by the ring opacity at that crossing's radius: 1 − opacity · 0.9. So each ring and gap
projects onto the planet as a stripe — dark under the dense rings, bright under the Cassini-like gap.
planetShadow (if the probe were a ring or ice point) and
ringShadow (if it were on the planet), and the dashed line follows its ray toward the sun. Right: a small
CPU ray tracer using the same two functions (simplified planet colours, no noise). Try: set the view elevation below zero
to look at the planet's southern half, where the rings' shadow stripes fall while the sun is above them; then lower the sun
elevation toward zero — the stripes widen, and the planet's shadow reaches across the whole ring system.float planetShadow(vec3 p, vec3 L) { float along = dot(p, L); if (along > 0.0) return 1.0; // on the sunward side float d = length(p - L * along); // distance from the shadow cylinder's axis return smoothstep(uPlanetRadius - 2.0, uPlanetRadius + 2.0, d); } float ringShadow(vec3 p, vec3 L) { if (abs(L.y) < 1e-4) return 1.0; float s = -p.y / L.y; // where the ray to the sun meets y = 0 if (s <= 0.0) return 1.0; return 1.0 - ringOpacity(length((p + L * s).xz)) * 0.9; }
7 · The dark side of the rings
A ring sheet has two faces, and only one faces the sun. The shader checks whether the camera is on the same side of the ring
plane as the sun: (ro.y > 0) == (L.y > 0).
Sunlit side. The ring is lit like a matte surface: albedo · sun · (0.25 + 0.75 · |L.y|). |L.y|
is the cosine of the angle between the sun and the ring's normal (straight up), so the rings are brightest with the sun high
above them; the 0.25 floor keeps them from going black at grazing light.
Dark side. Here light can only arrive through the ring. Two observations drive the formula. First, light gets
through only where the ring is thin, hence the factor (1 − opacity). Second, small particles scatter light mostly
forward, close to its original direction — which is why, seen from the far side, rings glow brightest when you look almost
toward the sun. The shader models that with a simple lobe, 0.15 + 1.4 · max(rd · L, 0)³, where rd · L is
the cosine of the angle between the view ray and the sun. (Physically based renderers use a phase function for this, such as
Henyey–Greenstein's from 1941; this cosine power is an artistic stand-in.)
The result is subtle and lovely: because the final pixel is mix(background, face, opacity), the dark side's
contribution is proportional to opacity · (1 − opacity) — zero where there is no ring, also nearly zero where
the ring is densest, and strongest in between. From below, dense rings turn dark and the thin ones light up.
Both faces are finally multiplied by planetShadow(p, L), so the part of the rings inside the planet's shadow
cylinder goes dark on either side. The sun itself is drawn into the sky in the same pass: a small disc
(smoothstep(0.006, 0.004, angle) · 60, about 0.3° across) plus a soft halo exp(−25 · angle) · 0.6.
8 · From sheet to ice chunks
From far away, a flat textured sheet is a perfect stand-in for billions of particles. Up close it would look like what it is:
a painted floor. So the shader fades the sheet out near the camera — opacity *= smoothstep(0.35 · 70, 70, tRing),
fully gone within 24.5 units and fully present beyond 70 — and the CPU fills that 70-unit neighbourhood with real 3D chunks.
Placement. The space around the camera is cut into a grid of square cells 2.5 units wide. For each cell within 70 units (horizontally) of the camera, a hash of the cell's integer coordinates gives the cell's random numbers — the same cell always gets the same numbers, so chunks stay put as the camera moves, and nothing has to be stored. Each cell gets one candidate chunk at a random spot in the cell and at a random height within ±1.2 of the ring plane; it is kept only if a random number is below the ring density at its radius (times 1.1 and the chunks density parameter). Dense rings get many chunks, gaps none. Sizes are 92 % small (0.08 to 0.6, skewed toward small) and 8 % large (0.8 to 2.5). Chunks too close to the camera's path are removed, so the camera never flies through one. Each chunk spins slowly around a random axis, at most half a radian per second.
Depth hand-off. The ice chunks are drawn after the planet pass, with the GPU's depth test: each pixel remembers the
distance of what it shows, and a new fragment is drawn only if it is nearer. A fullscreen ray tracer has no triangles, so it must
write that depth itself: it projects its hit point with the same view-projection matrix the meshes use and writes the result to
gl_FragDepth — for planet hits, and for ring hits with opacity above 0.5. A chunk behind the planet is then hidden, a
chunk in front of it is drawn, and chunks seen through thin ring parts stay visible.
// C++, each frame (RingsScene::buildChunks) if |camera.y| < 45: for each cell (ix, iz) of 2.5 units within 28 cells of the camera: rnd(k) = hash(ix, k, iz, seed 9157) // integer hash → [0, 1] p = ((ix + rnd(1)) · 2.5, (rnd(2) − ½) · 2.4, (iz + rnd(3)) · 2.5) skip if |p.xz − camera.xz| > 70 skip if rnd(4) > ringDensity(|p.xz|) · 1.1 · density size = rnd(5) < 0.92 ? mix(0.08, 0.6, rnd(6)²) : mix(0.8, 2.5, rnd(6)) skip if distance(p, camera path) < size + 1.5 axis = normalize(rnd(7, 8, 9) − ½); angle = rnd(10) · 2π + t · (rnd(11) − ½) tint = mix(icy blue-white, dusty beige, rnd(12) · 0.6) add instance (translate(p) · rotate(angle, axis) · scale(size), tint) to mesh variant rnd(13) · 4
9 · Shading a chunk of ice
The chunks are four rock-like meshes (an icosphere — a subdivided icosahedron — pushed in and out by noise, stretched,
and dented with craters; the same generator as the asteroid scene, at a lower level of detail). Every chunk is drawn with
instancing: one draw call per mesh variant, with a per-instance matrix and tint. The fragment shader, ice.frag,
adds up four kinds of light:
- Diffuse sunlight:
albedo · sun · max(N · L, 0) · planetShadow— Lambert again, and the same analytic planet shadow as the rings, so chunks go dark together with the sheet when they enter the shadow cylinder. - A sharp highlight: a normalized Blinn–Phong specular lobe (Phong 1975, Blinn 1977) with roughness 0.25, times a Schlick Fresnel term (Schlick 1994) with a base reflectance of 5 %, ×2. Fresnel makes surfaces more mirror-like at grazing angles, which gives icy edges their glint.
- Light from the planet: a weak warm fill (6 % of a beige colour) from the direction of the planet — the big lit globe next to the rings is itself a light source.
- Ambient: a tiny cold blue constant (1.5 %) so shadowed sides are never pitch black.
The albedo is the instance tint times a gentle noise. The surface is roughened with bump mapping: a noise "height" is
evaluated on the mesh, and the normal is tilted along its slope. The demo uses Morten Mikkelsen's technique (2010), which needs no
tangent vectors: it measures how the position and the height change between neighbouring pixels (dFdx,
dFdy) and tilts the normal accordingly. The height is scaled by the chunk's size, which keeps the bumps looking the
same on small and large chunks.
ice.frag's lighting on a sphere of the chunk's ice, evaluated per pixel (the bump
uses neighbour-pixel differences, like dFdx/dFdy). Right: the Fresnel term against the viewing
angle, and the highlight's shape for the chosen roughness. Try: swing the sun behind the chunk and watch the rim glint
grow; set the planet shadow to 0 to see the chunk lit only by the planet and the ambient term.The algorithm, step by step
The complete scene, in order, with each step linked to the idea above that explains it.
// CPU, once per frame s = smoothstep(0, 1, t / 23.04) camPos = mix((80, 40, 160), (−40, −30, 90), s) camera = lookAt(camPos, (0, 5, 0), focal 1.1) viewProj = perspective(2·atan(0.5/focal), aspect, 0.05, 5000) · view L = dir(az 58°, el 20°) sun = (1, 0.96, 0.9) · 2.2 |
The eased flight and the look-at camera (§1). The view-projection matrix is the one the meshes will use; the ray tracer
needs it too, to write matching depth (§8). The sun direction and colour are scene parameters (timeline sunaz,
sunel). |
// pass 1: planet.frag, fullscreen, depth test ALWAYS for each pixel: ro = camPos; rd = cameraRay(pixel) col = 0.001 + starSky(rd) · 0.7 a = angle(rd, L) col += sun · (smoothstep(0.006, 0.004, a) · 60 + exp(−25a) · 0.6) depth = 1 // far plane |
One ray per pixel (§1). The background is a procedural star field (hashed stars on the celestial sphere, shared with other scenes) plus the sun's disc and halo (§7). |
b = ro·rd; h = b² − (ro·ro − R²)
tPlanet = h > 0 ? −b − √h : −1
if tPlanet > 0:
p = ro + rd·tPlanet; n = p / R
col = planetColor(n, p, rd)
depth = project(p) |
Ray–sphere intersection: a quadratic, keep the nearer root (§2). The planet's colour is computed at the hit point and its depth is written for the meshes. |
// planetColor q = rotateY(n, t · 0.01) band = sin(n.y·22 + fbm(q·4)·3)·½ + ½ albedo = mix(tan, cream, band) albedo = mix(albedo, brown, storms(fbm(q·9 + 3))·½) albedo *= polarCap(|n.y|) col = albedo·sun·max(n·L, 0)·ringShadow(p, L) + albedo·sun·0.025·max(−n·L, 0)·… // ringshine col *= mix(0.6, 1, μ) // μ = n·(−rd) col += blue·(1 − μ)³·smoothstep(−0.2, 0.4, n·L)·0.6 |
Bands, turbulence, storms and caps make the albedo (§5). Lambert lighting, dimmed where the ray to the sun crosses the rings (§6), a faint ringshine on the night side, limb darkening and a blue rim (§5). |
tRing = −ro.y / rd.y
if tRing > 0 and (no planet or tRing < tPlanet):
p = ro + rd·tRing; r = |p.xz|
α = ringOpacity(r)
· smoothstep(24.5, 70, tRing)
if α > 0:
col = mix(col, ringColor(p, r, α), α)
if α > 0.5: depth = project(p)
gl_FragDepth = depth |
Ray–plane intersection and the annulus test; the ring is blended over the planet or sky only if it is in front (§3). Opacity is the radial profile (§4), faded out near the camera where the chunks take over (§8). Only fairly opaque ring pixels write depth. |
// ringColor albedo = ringAlbedo(r) if camera on the sun's side of the plane: face = albedo·sun·(0.25 + 0.75·|L.y|) else: face = albedo·sun·(1 − α) ·(0.15 + 1.4·max(rd·L, 0)³) return face · planetShadow(p, L) |
Front-lit or back-lit (§7), and dark inside the planet's shadow cylinder (§6). |
// CPU: ice chunk instances (|camera.y| < 45) for each 2.5-unit cell within 70: hashed position, height ±1.2 keep if rnd < density(r)·1.1·chunks size: 92 % small, 8 % large drop if near the camera path matrix = T(p)·R(spin(t))·S(size) |
Deterministic placement from a hash of the cell coordinates, with the ring's own density profile (§8). Rebuilt every
frame from scratch — the spin is a function of t — then uploaded to the GPU. |
// pass 2: ice chunks, instanced, depth test LESS for each covered pixel of a chunk: N = bump(normal, noise height · size · 0.05) sh = planetShadow(worldPos, L) col = albedo·sun·max(N·L, 0)·sh + sun·blinnPhong(0.25)·fresnel(0.05)·2·sh + albedo·warm·0.06·max(N·toPlanet, 0) + albedo·cold·0.015 |
Rasterized meshes, hidden behind the planet and dense ring parts by the depth that pass 1 wrote (§8). Diffuse, specular with Fresnel, planet fill and ambient (§9). |
// then, for every scene
bloom → chromatic aberration → ACES
→ vignette → grain → sRGB |
The HDR image goes through the demo's shared post chain (here: a lighter tone map and glow). |
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/rings/planet.frag fullscreen · ray tracer · writes depth
shaders/scenes/rings/rings.glsl shared · profile and shadows
shaders/scenes/rings/ice.frag fragment shader · ice chunks
shaders/common/mesh.vert vertex shader · instanced meshes
shaders/common/lighting.glsl — the helpers ice.frag uses (excerpt) bump, Fresnel, specular
src/scenes/rings/RingsScene.cpp C++ · whole file
Glossary
- Ray tracing
- Making an image by following, for each pixel, a line of sight from the eye into the scene and computing what it hits.
- Rasterization
- Making an image by projecting each triangle onto the screen and filling the pixels it covers. How the ice chunks are drawn.
- Ray
- A half-line
ro + t · rd: an origin, a unit direction, and a distancet ≥ 0along it. - Look-at camera
- A camera orientation built from its position and a target point: forward toward the target, right and up perpendicular to it.
- Focal length
- The distance from the eye to the image plane, here in screen heights. Larger = narrower view (zoom).
- Smoothstep
smoothstep(a, b, x): 0 belowa, 1 aboveb, a smooth S-curve between. Used for easing and soft edges.- Quadratic equation
t² + 2bt + c = 0, solved byt = −b ± √(b² − c); no real solution whenb² − c < 0.- Annulus
- A flat ring: the region between two concentric circles.
- Opacity / alpha
- How much a layer hides what is behind it, 0 (clear) to 1 (solid). Blending:
mix(behind, layer, α). - Albedo
- The base colour of a material: the fraction of light it reflects, before any lighting.
- Normal
- The unit vector pointing straight out of a surface. For a sphere at the origin,
normalize(p). - Lambert's cosine law
- A matte surface's brightness is proportional to
cos θ = N · L, the angle between its normal and the light. - Terminator
- The line between the day and night sides of a planet.
- Limb darkening
- The darkening toward the edge (limb) of a planet's or star's disc.
- Shadow cylinder
- With parallel sunlight, the region behind a sphere where the sun is hidden: a cylinder of the sphere's radius.
- Forward scattering
- Light deflected only slightly from its direction by small particles; it makes thin rings and dust glow when backlit.
- Value noise / fbm
- Smoothly interpolated random values on a grid; fbm sums several octaves of it for natural detail.
- Hash
- A deterministic scrambling function: same input, same "random-looking" output. Used instead of stored random numbers.
- Depth buffer / depth test
- A per-pixel record of the distance of what is shown; a new fragment is drawn only if it is nearer.
- gl_FragDepth
- A fragment shader output that overrides the pixel's depth value; lets a ray tracer take part in depth testing.
- View-projection matrix
- The 4×4 matrix that takes a world position to the screen (and depth), shared by the ray tracer and the meshes.
- Instancing
- Drawing one mesh many times in a single call, each copy with its own transform and colour.
- Icosphere
- A sphere approximated by repeatedly subdividing an icosahedron's 20 triangles.
- Bump mapping
- Faking small surface relief by tilting the normal along the slope of a height function, without changing the geometry.
- Blinn–Phong
- A simple model of glossy highlights based on the half vector between the light and view directions.
- Fresnel effect
- Surfaces reflect more light at grazing angles; Schlick's formula approximates it cheaply.
- HDR
- High dynamic range: keeping brightness values above 1.0 until a final tone-mapping step.
- Tone mapping (ACES)
- A curve compressing HDR brightness into the screen's range while keeping highlights pleasant.
- Bloom
- Letting very bright areas glow into their neighbourhood, like light scattering in a lens or eye.
Part of the Space Travel documentation. Direction & design: blacklizard · Code: Claude. The figures on this page are computed live in your browser; nothing is pre-rendered except the header image.
