Made by AI
Three dark dust pillars rising from a floor of dust, backlit by a bright star cluster
Space Travel · Interactive explainer · Scene 03

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.

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 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.

Try: pause, then drop steps to 16 — the pillars turn coarse and grainy but keep roughly the same brightness (§3, §8). Turn off per-pixel jitter at low step counts to see the bands it hides. Turn off self-shadowing and the pillars glow from inside: the dark cores, the light shafts and the silver linings all come from the light march (§5). Turn off energy-conserving steps to see the naive sum overshoot in the dense cores. Set render scale to 0.25 and switch off bilinear upscale to see the low-resolution volume's blocks (§9).
Port notes: the shaders are the demo's, with #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:

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.

Left: a vertical slice through the real dust density (the plane z = 0, from the demo's formulas), with a camera at the left edge. The ray takes fixed steps; each dot is a sample, larger where there is more dust. Right: what the march records along the ray — transmittance T falling as dust is crossed, and the light L collected (here every bit of dust simply glows evenly; real lighting comes in §5). Aim the ray through a pillar and watch T collapse and the loop stop early.
// 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):

T = e−σ · d     (and when σ varies along the path: T = e−∫σ dt)

σ (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.

Left: a beam crossing a slab of dust; each column of the beam is dimmed by the dust already crossed. Right: transmittance against distance into the slab, 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

∫0Δt S · e−σx dx = S · (1 − e−σΔt) / σ

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.

A uniform glowing slab of length 60, σ and S constant, so the exact answer is known: 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:

pHG(θ) = (1 − g²) / (1 + g² − 2g·cos θ)3/2    (× 1/4π in its normalized form)

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.

Left: polar plot. Light arrives from the left and hits a grain at the center; the curve's distance from the center in each direction is how much light leaves that way. Right: the same function against the scattering angle, for your 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).

lit = exp(−Σk dust(p + L·(k + ½)·s) · s · σdust · 0.6)

(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:

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.

Drag the star (gold handle) around the same slice as §1. Every point marches toward it through the dust; hover a point to see its light samples. In the scattered view, move the star to the right, behind the pillars: their edges light up (forward scattering) while their cores and the dust in their shadow stay dark. The right panel shows the hovered point's samples and their optical depth. Distance falloff is left out here. The comb-like stripes in the shadows are the light march's own coarse steps (up to 15 units each with 6 steps): raise light steps and they smooth out. In the demo they are blurred away by the 3D integration along the view ray.

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:

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.

hover the slice
Left: a vertical slice (choose its depth z) through the real field, computed with JS mirrors of the shader and of the noise volume. In the distance views, warm is inside, blue is outside, lines every 10 units, white is the surface (d = 0). Move the erosion sliders from 0 to see the smooth bent columns, then add clouds and billows. Right: how the final distance becomes density, and where the ionization rim of §5 lives.

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.

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.

Left: one slice of the R channel (value fbm). Middle: the G channel (inverted Worley fbm), or a single raw octave of distances with its feature points. Right: the chosen channel repeated 2 × 2: no seams. Computed with the exact JS port of 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.)

A glowing ball of dense fog, brighter toward its front, marched with the energy-conserving loop at a low step count. Left: every ray starts at t = 0 — bands. Right: each pixel starts at Δ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:

color = (stars + cluster glow) · T + Lvolume

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.)

Work per frame at 1920 × 1080, upper bounds (every step non-empty, no early exit). The real cost is lower — empty space is skipped and dense rays stop at T < 0.02 — but the proportions hold: halving the resolution divides the work by four, and the light steps multiply it.

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 − T is 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. min of 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.

← 02 · Parallax stars  ·  All scenes  ·  04 · Warp →