Made by AI
The ringed giant, with ice chunks in front of the ring sheet
Space Travel · Interactive explainer · Scene 07

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.

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.

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.

Try: scrub to 11–14 s to watch the ring sheet hand over to the chunks as the camera crosses the ring plane. Move the sun elevation below zero: the sun is now under the rings, so the side we start on becomes the dark side. Turn on orbit camera and drag the image to look around the planet: find the planet's shadow on the rings and the rings' shadow on the planet (then switch shadows off to compare).

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:

rd = normalize(forward · focal + right · u + up · v)

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.

Left: seen from above — the straight path, the planet, the rings (brighter = denser) and the sun's direction. Right: a cut through the planet's axis and the camera: horizontal is the distance from the axis, vertical is height above the ring plane. The wedge is the camera's vertical field of view. The small inset plots the eased progress 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):

t² + 2b·t + c = 0,    where   b = ro · rd,   c = |ro|² − R²

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"
Drag the camera (cyan) and the aim point (gold). Left: the ray and the planet; the dashed line is the ray's closest approach to the centre. Right: the quadratic 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:

tRing = −ro.y / rd.y

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.

A cut through the planet's centre, with a fan of rays from the camera (one per "pixel row"). Drag the camera (cyan) and the aim point (gold). Each ray is coloured by what it shows: tan = planet, white = ring, grey = sky. A filled dot marks a ring hit that is used, a hollow one a ring hit hidden behind the planet. The strip at the bottom is the resulting column of pixels. Hover over the strip to inspect one ray.
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.

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.

Left: a quarter of the ring system seen from above, coloured with 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):

  1. Rotation. n is turned around the vertical axis by uTime · 0.01 radians — only about 13° over the whole scene, a slow drift — giving the point q where the patterns are read.
  2. 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 in n.y, which is the height on screen when seen from the side.
  3. 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.
  4. Storms. Where a second, finer fbm exceeds 0.6–0.8, the colour moves halfway toward a darker brown.
  5. 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:

Left: the planet's colour, computed per pixel with JavaScript copies of the shader's formulas (ring shadows are left out here; they are section 6). Right: the band value along latitude: dashed without turbulence, thin lines with turbulence at three longitudes. Try: turn off lighting to see the pure albedo, then switch the terms on one by one. Swing the sun behind the planet to see the rim glow and the faint ringshine.
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
All the numbers here — 22 for the band frequency, 3 for the turbulence, the colours, 0.025 for the ringshine — are artistic tuning values, chosen by eye, not physical constants.

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.

Left: a vertical cut containing the sun direction. The dark band is the planet's shadow cylinder. Drag the probe (cyan): the readout gives 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.

Left, top: the ring profile from 80 to 150 as seen from the sunlit side and from the dark side, computed with the shader's formulas over a black sky. Left, bottom: the light each side adds as a function of opacity. Right: the dark side's forward-scattering lobe — brightness by angle between the view ray and the sun. Try: move the view–sun angle toward 0° to look into the sun through the rings.

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.

Left: the camera's neighbourhood seen from above, 160 × 160 units. The shading is the ring density; the grid is the 2.5-unit cells; dots are the chunks kept, drawn to scale; the white circle is the 70-unit reach and the dashed line the camera's path. Right, top: along a view ray, how the sheet fades in (cyan) where the chunks stop (pink). Right, bottom: the sizes of the chunks kept. The chunk count uses the demo's exact integer hash, so it should match the number the demo's debug panel reports at the same time (up to float rounding in the density test).
// 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:

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.

Left: 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 distance t ≥ 0 along 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 below a, 1 above b, a smooth S-curve between. Used for easing and soft edges.
Quadratic equation
t² + 2bt + c = 0, solved by t = −b ± √(b² − c); no real solution when b² − 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.

← 06 · Asteroids  ·  All scenes  ·  08 · Canyon →