Nebula Crossing
Towering pillars of dust, backlit by a cluster of young stars. No meshes, no textures painted by hand: just a fog that the GPU walks through, one small step at a time, asking "how much dust is here, and how much starlight reaches it?" — explained from first principles, with the real shaders running in your browser.
The scene
The nebula crossing lasts 48 beats of the 125 BPM soundtrack — 23.04 seconds, starting 23.04 s into the demo with a two-beat crossfade from the parallax stars. Three dark columns of dust rise from a dusty floor. Behind them a cluster of hot young stars blazes, and the gas around it glows teal near the light and gold further out. The camera starts low, in front of the pillars, rises between them and ends level with their heads, looking straight into the light. On the way, the dust's eroded edges catch silver linings, the cores stay dark, and shafts of light cut through the gaps. The image is a nod to Hubble's famous 1995 photograph of the "Pillars of Creation" in the Eagle Nebula.
Everything you see is a participating medium: a volume of material that light passes through, rather than bouncing off a surface. There is no pillar geometry. For every pixel, a ray leaves the camera and takes up to 96 fixed steps into the nebula. At each step it asks three questions: how dense is the dust here (a distance function, eroded by 3D noise), how much starlight reaches this point (a short second march toward the cluster), and how much of that light is redirected toward the camera (a phase function). It adds the answers up with a formula that is exact within each step if density and source light stay constant across it, while keeping track of how much of the background is still visible. Sampling the varying nebula still introduces error, especially with large steps. Shadows, light shafts and rims are not separate effects: they all fall out of that one loop. This page builds it up one idea at a time.
Live: the real shaders
This is the actual nebula scene: the demo's volume.frag and composite.frag, ported to WebGL2, fed by
the same 128³ noise volume (built here by a fragment-shader port of noise3d.comp) and driven by a JavaScript mirror of
PillarsScene::render() — same camera spline, same light, same defaults. Like in the demo, the volume is rendered at a
reduced resolution and then stretched to full size; stars and the cluster glow are drawn at full resolution. Drag time to
scrub: every frame is a pure function of time.
#version 300 es, precision
qualifiers and the #included helpers inlined. noise3d.comp is a compute shader (WebGL2 has none), so here
it runs as a fragment shader drawn once per Z slice of the 3D texture, at the demo's 128³ RG8 size, no mipmaps (the demo has
none). The noise lookups in volume.frag use textureLod(…, 0.0) instead of
texture(…) — identical on a one-level texture, but friendlier to WebGL's Direct3D backend. Four explainer-only switches (jitter, energy-conserving steps, self-shadowing, sky) are marked in the shader. The demo's
post chain (bloom, chromatic aberration, ACES, vignette, grain) is replaced by ACES tone mapping and a simple mip glow; the beat
pulse is computed from the scene's own clock (the scene starts on a beat). Pixel density is capped at 1× for speed. In the sky,
the stars of sky.glsl are sized in pixels, so here they are sized for the demo's 1080-line frame instead of this
smaller canvas.Background
Nine ideas make up the whole effect. The first five are the physics of light in fog — the same theory that renders clouds, smoke and atmospheres in films and games. The last four are about shapes, noise and making it fast.
1 · Marching through fog
A normal 3D renderer asks, for each pixel, "which surface does this ray hit first?" and colors the pixel with that surface. Fog, smoke and dust clouds have no surface. Light enters them, is partly absorbed, partly scattered in other directions, and the part that continues is dimmed. Graphics people call such material a participating medium, because it takes part in the light's journey along the whole path, not just at one point.
The medium is described by a density at every point in space: zero in empty space, larger where there is more dust. To
find the color of a pixel we follow its ray from the camera and march: take a step of fixed length Δt, look at
the density there, account for the light that point sends toward us, move on. Two running totals are kept:
L— the light collected so far (starts at 0);T— the transmittance, the fraction of light from further away that still gets through everything in front (starts at 1 = nothing in the way).
Each step adds its own light, dimmed by the current T (dust in front of it hides it), and then lowers T
by the dust it contains. When T drops below 2 % nothing behind can matter any more, so the loop stops early. At the
end, L is the color and 1 − T tells the next pass how much of the sky behind is hidden. In the demo the
ray covers 520 units in uSteps = 96 steps, so Δt ≈ 5.4 units — the pillars are 20 to 30 units in
radius.
// the skeleton of every volume renderer T = 1; L = 0; t = start for i in 0 .. steps−1, while T > 0.02: p = rayOrigin + rayDir · t σ = extinction(p) // how much this point blocks light (§2) S = source(p) // light it sends toward the camera (§4, §5) L += T · S · (1 − e−σΔt) / σ // §3 T *= e−σΔt t += Δt output (L, 1 − T)
2 · Beer–Lambert: how fog dims light
How much light makes it through a slab of dust? Imagine cutting the slab into very thin slices. Each slice blocks the same small fraction of whatever light reaches it — not the same amount. Losing a fixed fraction per unit of distance is exactly what exponential decay is, and it gives the Beer–Lambert law (found by Bouguer in 1729, written down by Lambert in 1760 and extended to solutions by Beer in 1852):
σ (sigma) is the extinction coefficient: how much the material blocks per unit length. d is the
distance travelled. The product τ = σ·d is the optical depth. Two lovely properties make it ideal for marching:
the transmittance through two slabs is the product of their transmittances, because their optical depths simply
add; so a march can multiply T by e−σΔt at every step. And 1 / σ is the
mean free path: the typical distance a bit of light travels before it is stopped.
In the demo, dust at full density has σ = kDustSigma × uDust = 0.22 per unit: a mean free path of about 4.5 units.
A pillar 50 units thick has an optical depth around 11, so T ≈ 0.00002 — that is why the pillars' cores are black.
The glowing gas uses σ = 0.004 × density: you can see 250 units into it.
e−σx. Halve σ or halve the thickness and you
get the same result: only the optical depth τ matters. With two slabs, the gap changes nothing — τ₁ + τ₂ is all that counts.3 · Adding light up honestly
Each point of the medium also sends light toward the camera: starlight it scatters our way, or its own glow. Call that
the source S, light per unit length. The obvious way to add it up is a plain sum: each step contributes
T · S · Δt. That works when the steps are tiny. With big steps through dense dust it is badly wrong: it assumes the whole
step is as visible as its front edge, but the dust inside the step hides its own far part. With σ = 0.22 and Δt = 5.4, σΔt ≈ 1.2 —
the back of a single step is only 30 % visible from its front.
The fix is to integrate exactly within the step, assuming σ and S are constant across it. The
light from a step of length Δt, seen through its own dust, is
which is what volume.frag adds (times the T in front). For thin dust it reduces to S·Δt;
for very thick dust it tends to S / σ — the light of one mean free path, which is all you can ever see of an opaque
glowing material. The demo uses the plain dt fallback only when σ is essentially zero, to avoid dividing by
zero. This form (popularized for real-time use by Sébastien Hillaire's 2015 talk on physically based volumetrics at Frostbite) removes
the plain sum's coarse-step brightness bias for constant σ and S. The art book's "results don't depend on
the step size" describes that uniform case. In the actual nebula, density, lighting and shadows vary between samples, so changing
steps can still change the brightness and detail.
L = S(1 − e−60σ)/σ (white line). Left: per-step contributions of the naive sum (pink) and the
energy-conserving step (cyan) for the chosen number of steps. Right: the total as a function of the number of steps. The
energy-conserving version is exact at any step count here; the naive sum overshoots when steps are coarse and the
medium dense.4 · Which way light bounces
When a dust grain scatters a photon, the new direction is not uniformly random. Grains about as large as the light's wavelength
scatter mostly forward, deflecting light by small angles. A phase function p(θ) describes this: given the
angle θ between the light's original direction and the new one, how much light goes that way. In 1941 the astronomers Louis Henyey and
Jesse Greenstein proposed a one-parameter formula to model exactly this — the scattering of starlight by interstellar dust — and
the Henyey–Greenstein function has been the workhorse of cloud and smoke rendering ever since:
The parameter g is the average cosine of the deflection: 0 scatters equally everywhere, positive values favor
forward scattering, negative values backward. The demo drops the 1/4π so that isotropic scattering is exactly 1 (the
constant is absorbed in the artistic light intensity) and uses g = 0.35. In the shader, θ is the angle between the view
ray and the direction to the light: cos θ = dot(rd, L). Looking toward the cluster, dust lit from behind sends
us up to 3.2× the isotropic amount; looking away, only 0.36×. That is the physics of silver linings: thin dust between you and a light
source shines.
g (violet), the demo's 0.35 (gold) and isotropic g = 0 (dim). Hover the polar plot to probe a direction.float phaseHG(float cosTheta, float g) { // volume.frag float g2 = g * g; return (1.0 - g2) / pow(1.0 + g2 - 2.0 * g * cosTheta, 1.5); } float phase = phaseHG(dot(rd, L), 0.35);
5 · Shadows inside a cloud
The source S at a point depends on how much starlight arrives there. Light from the cluster has to cross
dust too, and Beer–Lambert applies again: lit = e−optical depth toward the light. So at every sample of
the main march, the shader starts a second, short march toward the light: uLightSteps = 6 steps covering the
distance to the cluster but at most 90 units, summing the dust density it meets (sampled at the middle of each step).
(The 0.6 lets light reach a little deeper than the view ray's extinction would — an artistic tuning value.) This one
number creates most of the scene's drama:
- Self-shadowing: the far side of a pillar is dark because its light has crossed the pillar's whole width.
- Light shafts: the gaps between pillars stay lit, the dust behind each pillar sits in its shadow — the "god rays".
- Silver linings: the thin, eroded edges see the light almost unobstructed (
lit≈ 1) and, seen from the camera against the light, get the strong forward-scattering boost of §4.
Two more factors shape the light. A soft falloff with distance to the cluster, 1 / (1 + (d / 250)²) — an
inverse square law that doesn't explode near the source. And an ionization rim: on real pillars, ultraviolet light from the
young stars makes their surfaces glow. The demo imitates it with dust · (1 − dust) · 4, which is 0 in empty space and in
the dense core and peaks at 1 halfway up the density ramp — a thin skin around the eroded surface — multiplied by lit,
so only the lit side glows. It is a stylization, not a physical model.
6 · Pillars from distances
Where is the dust? The pillars start as smooth shapes described by a signed distance function (SDF): a function that
returns, for any point, the distance to the shape's surface — positive outside, negative inside, zero on the surface. SDFs are
convenient because shapes combine with simple math: the min of two distances is the union of the two shapes.
shapeDistance(p) is the union of a dust floor (p.y + 72: everything below y = −72) and three
pillars. Each pillar is a vertical column built from a few numbers — base position, height, base and top radius, a phase:
- its axis wanders with height:
axis = base.xz + (sin(h·0.018 + phase)·9, cos(h·0.013 + phase)·6), so the columns bend gently, each differently; - its radius tapers linearly from base to top: 30 → 13, 24 → 10 and 20 → 9 units;
- below the top, the distance is just the horizontal distance to the axis minus the radius (an infinite cylinder); above the top it is the distance to a point on the axis with the height difference scaled by 1.3 — a rounded, slightly squashed head.
Smooth columns would look like plastic. So dustDensity erodes the distance with two noise lookups before
turning it into density: large soft clouds (value noise, ±15 units) push the surface in and out, and a billowy cellular pattern
(inverted Worley noise, ±7 units, slowly drifting upward with time) carves cauliflower lumps. The eroded distance becomes a density
ramp, clamp(−d · 0.12, 0, 1): zero outside, rising to full density about 8 units inside the surface. That soft ramp is
what makes edges wispy rather than hard. Points more than 26 units outside the smooth shape can never be reached by the erosion, so
the function returns 0 there without touching the noise — a cheap early-out that skips most of empty space.
The glowing gas uses the same noise volume at much lower frequencies (p · 0.0032: the texture repeats only every ~310 units)
passed through a
smoothstep to cut it into separate clouds. It is confined to a sphere around the nebula center (0, 40, −120)
and to the far side (z below 60, fading by −80) so the camera's side of the scene stays clear. Its color is the
"Hubble palette" look: teal (the shader's comment calls it oxygen) within about 220 units of the cluster, blending to gold-red
(hydrogen/sulfur) by 420 units. The gas is thin (σ = 0.004) and glows mainly by its own emission,
gasColor · gas · 0.004 · (0.4 + 0.6·lit); it scatters little, an albedo of about 0.15.
7 · The noise volume
Evaluating fractal noise at every one of the hundreds of samples per pixel would be far too slow. Instead the demo computes noise
once, at startup, into a 3D texture: a 128 × 128 × 128 grid of voxels with two 8-bit channels, shared by every scene that needs
it (Resources::noiseVolume, a compute shader, noise3d.comp). Sampling it is a single hardware texture fetch
with trilinear filtering (blending the 8 nearest voxels), and it tiles: the texture wraps around (GL_REPEAT)
so any coordinate is valid.
- R — value-noise fbm. Random values at the corners of a grid, blended smoothly (the same value noise as in the warp scene, here with an integer PCG hash). Five octaves with periods 4, 8, 16, 32 and 64 cells across the volume, each half the amplitude of the previous: soft, cloudy. Ken Perlin's 1985 gradient noise is its better-known cousin; value noise is the simpler version.
- G — inverted Worley fbm. Steven Worley's 1996 cellular noise scatters one random feature point in each grid
cell and returns the distance to the nearest one (checking the 3×3×3 neighboring cells). That distance is small near the points
and grows toward the boundaries between them; inverted (
1 − distance) it becomes round puffy bumps — the "billows" that make clouds look like cauliflower. Three octaves (periods 4, 8, 16, weights 0.625, 0.25, 0.125).
The trick that makes the volume tileable: every octave's grid period divides the volume, and cell coordinates are wrapped with
((cell % period) + period) % period before hashing, so the cells at the right edge are the same as those at the left.
The texture's far face continues seamlessly into its near face.
noise3d.comp.How the pillars read it: texture(uNoise, p * 0.011).r repeats every 1 / 0.011 ≈ 91 units and
texture(uNoise, p * 0.029 + …).g every ≈ 34 units. The pillars are larger than that, but with two different
frequencies mixed together the repetition is hard to spot.
8 · Jitter: bands become grain
Fixed steps sample the world at the same distances Δt, 2Δt, 3Δt… for every pixel. Neighboring pixels then "see"
a surface at the same quantized depths, and smooth shapes show contour lines, like the rings of a tree: banding. Our eyes
are very good at spotting these regular structures.
The cure is one line: start every ray at a different random fraction of a step,
t = Δt · hash12(gl_FragCoord.xy). Each pixel now samples a slightly different set of depths. The error doesn't go
away, but it changes character: instead of coherent bands it becomes uncorrelated per-pixel noise, which reads as film grain
— and the demo's post-processing grain and bloom blur hide it further. (Engines that render many frames often also vary the offset
over time and average frames; this demo doesn't, so it stays a pure function of time.)
Δt · hash12(pixel), as in
volume.frag — grain. Raise the steps and both converge to the same image.9 · Two passes, two resolutions
The volume is the expensive part. Each pixel takes up to 96 steps; each non-empty step evaluates the dust (a distance function
and two texture fetches), the gas (two more fetches) and a 6-step light march (six more dust evaluations). That is hundreds of
texture fetches per pixel. But a nebula is soft: it has no sharp detail that needs every pixel. So the scene renders it in a
separate pass at a fraction of the screen's resolution — scale = 0.5 by default, a quarter of the pixels — into an
RGBA16F texture: rgb = light from the volume, a = 1 − transmittance.
The second pass, composite.frag, runs at full resolution. It draws what must stay crisp — the stars
(starSky from sky.glsl, two hashed layers of point stars sized to one pixel) and the cluster's glow,
10·e−60·angle + 0.8·e−12·angle (a hot core and a wide halo, angle in radians from the cluster) —
then reads the volume texture with bilinear filtering, which smoothly stretches the low-resolution result, and combines them:
The sky is behind the nebula, so it is dimmed by the transmittance; the volume's own light is added on top. Because the
camera ray is computed from pixel coordinates normalized by height (cameraRay in camera.glsl), both passes
agree on directions at any resolution. (The project also has a general upscale.frag that copies color and depth for
scenes mixing low-resolution volumes with meshes; the pillars don't need depth, so the composite does the bilinear upscale itself.)
The algorithm, step by step
Here is the complete scene, in order, with each step linked to the idea above that explains it.
// CPU, once per frame (PillarsScene::render) progress = smoothstep(0, 1, t / 23.04) camPos = catmullRom(path, progress) camAim = catmullRom(aims, progress) camera = lookAt(camPos, camAim, focal 1.1) lightPos = (10, 200, −380) light = (0.85, 0.92, 1) · 2.5 · (1 + 0.15 · e−4 · sinceBeat) |
The camera flies along a Catmull–Rom spline (Catmull and Rom, 1974): a smooth curve through four control points, from
(−20, −30, 260), low and in front of the pillars, up to (10, 95, 40) at the height of their heads, while its aim point climbs toward the cluster.
smoothstep eases the start and end. The cluster's light pulses 15 % on every beat. |
// pass 1: volume, at scale × resolution for each pixel: ro = camPos; rd = cameraRay(pixel) Δt = 520 / steps // 5.42 at 96 steps t = Δt · hash12(pixel) // jitter L = 0; T = 1 |
A ray per pixel of the reduced-resolution target (§9). The fixed step length covers the 520-unit far distance; the random start offset turns banding into grain (§8). |
repeat steps, while T > 0.02: p = ro + rd · t d = shapeDistance(p) dust = d > 26 ? 0 : clamp(−0.12 · (d + 30 · (R(p·0.011) − ½) − 14 · (G(p·0.029) − ½)), 0, 1) gas = cloud(R(p·0.0032), G(p·0.009)) · sphereMask · behindMask · uGas if dust + gas < 0.002: skip |
The dust is the pillars' signed distance, eroded by the two channels of the noise volume and mapped to a density ramp (§6, §7). The gas is low-frequency noise confined behind the pillars. Empty samples skip all lighting work. |
Lp = normalize(lightPos − p)
s = min(|lightPos − p|, 90) / lightSteps
depth = Σk dust(p + Lp·(k + ½)·s)
lit = e−depth · s · 0.22 · uDust · 0.6
falloff = 1 / (1 + (dist / 250)²)
phase = HG(dot(rd, Lp), 0.35) |
A short march toward the cluster gives the light that reaches p: self-shadowing, light shafts (§5, Beer–Lambert
from §2). The phase function boosts light scattered forward, toward a camera looking at the cluster (§4). |
σ = dust · 0.22 · uDust + gas · 0.004 S = light · lit · falloff · phase · (dust · scatter · (0.55, 0.4, 0.3) + gas · 0.0006) + gasColor(p) · gas · 0.004 · (0.4 + 0.6·lit) + (1, 0.7, 0.45) · 0.05 · rim · dust(1−dust)·4 · lit · falloff |
Extinction from both materials. The source sums starlight scattered by the reddish dust and the thin gas, the gas's own Hubble-palette glow, and the stylized ionization rim on lit dust edges (§5, §6). The constants are artistic tuning values. |
Ts = e−σΔt
L += T · S · (σ > 1e−5 ? (1 − Ts) / σ : Δt)
T *= Ts
t += Δt
out = (L, 1 − T) |
The energy-conserving step integral (§3) and Beer–Lambert transmittance (§2). The loop ends after all steps or as soon as less than 2 % of the background can show through (§1). |
// pass 2: composite, full resolution for each pixel: rd = cameraRay(pixel) vol = bilinear(volumeTexture, pixel) sky = (0.002, 0.001, 0.004) + starSky(rd, pixelAngle) · 0.6 + light · (10·e−60a + 0.8·e−12a) out = sky · (1 − vol.a) + vol.rgb |
Crisp stars and the cluster's glow behind the nebula, dimmed by the volume's transmittance, plus the upscaled volume light
(§9). a is the angle between the pixel's ray and the direction to the cluster. The result is HDR; the demo's post chain
(bloom, ACES tone mapping, grain…) finishes it. |
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/pillars/volume.frag fullscreen · reduced resolution · the ray marcher
shaders/scenes/pillars/composite.frag fullscreen · stars, cluster glow, upscale
shaders/common/noise3d.comp compute · the shared 128³ noise volume
shaders/common/camera.glsl, noise.glsl (excerpt), math.glsl (excerpt), sky.glsl included helpers
src/scenes/pillars/PillarsScene.cpp — excerpt C++ · per frame
Glossary
- Participating medium
- Material that light travels through and interacts with along the whole path: fog, smoke, dust, gas.
- Ray marching
- Following a ray in steps and evaluating a function at each sample, instead of computing an intersection with a surface.
- Extinction coefficient σ
- How strongly a medium blocks light per unit length (absorption plus out-scattering). Its inverse is the mean free path.
- Optical depth τ
- The integral of σ along a path. Optical depths of consecutive segments add up.
- Transmittance T
- The fraction of light that survives a path:
e−τ(Beer–Lambert law).1 − Tis the opacity. - Source term S
- Light a point of the medium sends toward the camera per unit length: scattered light plus emission.
- Scattering / albedo
- Light redirected by the medium instead of absorbed; the albedo is the scattered fraction of the extinction.
- Emission
- Light a medium produces itself, like glowing gas.
- Phase function
- How scattered light is distributed over directions, as a function of the angle between incoming and outgoing directions.
- Henyey–Greenstein
- A one-parameter phase function (1941);
g> 0 favors forward scattering. - Self-shadowing / light march
- Computing how much light reaches a point by marching from it toward the light and accumulating optical depth.
- Silver lining
- The bright edge of a cloud seen against a light behind it: thin, well-lit material plus forward scattering.
- Light shafts (god rays)
- Visible beams of light in a medium, separated by the shadows of occluders.
- Signed distance function (SDF)
- A function giving the distance to a shape's surface, negative inside.
minof two SDFs is their union. - Value noise / fbm
- Smoothly interpolated random values on a grid; fractal Brownian motion sums several octaves of it.
- Worley (cellular) noise
- Distance to the nearest of randomly placed feature points (Worley, 1996); inverted, it gives billowy bumps.
- 3D texture / voxel
- A volume of values on a 3D grid; each cell is a voxel. Sampled with trilinear filtering.
- Tileable
- A texture whose opposite edges match, so it can repeat without visible seams.
- Banding
- Visible contour lines caused by quantization, here of the sample depths.
- Jitter
- A small random offset per pixel that trades coherent banding for less objectionable noise.
- Hash
- A deterministic scrambling function: same input, same "random-looking" output. Here both float hashes and the integer PCG hash are used.
- Catmull–Rom spline
- A smooth curve passing through its control points, using each point's neighbors to set the tangents.
- Uniform
- A value set by the CPU before a draw and shared by every pixel of that draw (camera, light, step counts…).
- Fragment shader
- A program run once per pixel; both passes of this scene are fullscreen fragment shaders.
- Compute shader
- A GPU program not tied to drawing; the demo uses one to fill the noise volume.
- 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 neighborhood, like light scattering in a lens or eye.
- Bilinear upscaling
- Enlarging a low-resolution image by blending the four nearest texels for each output pixel.
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.
