The Giant Star
A white flash, and we drop out of hyperspace beside a giant star. Its boiling surface, the flames streaming off its edge and the loops of plasma arching above it are not textures, meshes or particles: every pixel is computed from one ray, one sphere and a few noise functions. Explained from first principles, with the real shaders running right here in your browser.
The scene
The giant star is the fifth scene of the demo. It starts at beat 112 (53.76 s into the demo) and lasts thirty-two beats — 15.36 seconds at 125 BPM. It enters with the timeline's flash transition: over one beat (0.48 s) a white HDR flash swells, peaks exactly at the cut from the warp's tunnel to the star, and fades — the bloom floods the whole screen, so the switch is invisible. When the glare clears, the camera is patrolling: it orbits about a quarter of the way around the star while slowly closing in from 3.6 to 2.35 star radii (closest at about 9.5 s, then drifting back out), and it aims a little past the star so the disc drifts across the frame like a fly-by.
The star is a perfect sphere — a mathematical one, not a mesh of triangles. For every pixel, the shader sends a ray from the camera through that pixel and asks three questions: does the ray hit the sphere? (then paint the boiling surface); how close does it pass? (then paint the corona's flames); does it cross one of five plasma loops? (then add their glow). Noise functions supply all the detail, and the time variable makes it move. This page builds those pieces up one at a time.
Live: the real shaders
This is the actual scene: the demo's two fragment shaders — star.frag, which draws the star into a reduced-resolution
layer, and composite.frag, which paints the star sky at full resolution and lays the star layer on top — ported to WebGL2.
The camera path, the star's rotation and the music pulses come from a JavaScript copy of the scene's C++ render().
Drag the time slider to scrub: every frame is a pure function of time.
#version
line, precision qualifiers and inlining the common/ helpers it includes — plus one line: the sky's stars are sized for the
demo's 1080-line frame rather than this smaller canvas, so they keep the demo's apparent size. On some systems the star shader takes a
few seconds to compile; the readout shows it. The demo's post chain (bloom, chromatic aberration,
vignette, grain) is replaced by ACES tone mapping and a glow with a hard threshold of 1.0 on blurred mip samples. The demo's soft knee
starts admitting light above a brightness of 0.5 at that threshold. During the first half of the flash the demo still shows the
warp's last frame; here it shows the star. The music pulses are computed from the placeholder song's pattern data (first pass of
the demo, audio latency ignored).Background
Nine ideas make up the scene. The first two are geometry, the next three paint the surface, then the corona, the prominences, and finally how the layers, the music and the transition come together.
1 · Rays from an orbiting camera
The warp scene projected points onto the screen: given a star, find its pixel. This scene works the other way round — given a pixel, find what it sees. That is ray casting (Arthur Appel described it in 1968): for every pixel, build the straight line that leaves the camera through that pixel, and follow it into the scene.
The camera is described by a position and three perpendicular unit vectors: forward (where it looks), right
and up. Picture the screen as a small window placed at distance focal in front of the camera. A pixel's
position on that window, measured in screen heights from the center, is uv; the ray direction is just "go forward by
focal, then sideways by uv":
Dividing by the height (not by the width) keeps pixels square, and makes uv independent of the resolution: a
half-resolution image and a full-resolution image compute the same ray for the same point of the screen. That detail matters in §9.
The scene's focal length is 1.1, a vertical field of view of 2 · atan(0.5 / 1.1) ≈ 49°.
The camera itself is placed by a function of time, cameraAt(t), in C++. It orbits in a circle around the star
(angle grows by 0.11 rad/s), bobs up and down (elevation ±0.28 rad), and its distance swings between 3.6 and 2.35 star radii
with a slow cosine. It does not aim at the star's center: the target point slides sideways and up and down, so the star drifts
across the frame. A slight roll (±0.08 rad) completes the hand-held feel. From position and target, lookAt builds the
three vectors: forward = normalize(target − position), right = normalize(forward × worldUp),
up = right × forward.
// C++ (src/scenes/corona/CoronaScene.cpp), t = scene time × orbit speed angle = 0.4 + 0.11 · t elevation = 0.28 · sin(0.17 · t + 0.8) approach = 0.5 − 0.5 · cos(0.33 · t) // 0 = far, 1 = closest (at t = π / 0.33 ≈ 9.5 s) distance = mix(3.6, 2.35, approach) position = distance · (cos(elev)·cos(angle), sin(elev), cos(elev)·sin(angle)) side = normalize(cross(−position, worldUp)) aim = side · 0.55·sin(0.21·t + 0.3) + (0, 0.15·sin(0.13·t), 0) camera = lookAt(position, aim, focal = 1.1, roll = 0.08 · sin(0.19 · t))
2 · Hitting a sphere
The star is a sphere of radius 1 at the origin. A ray is every point p(t) = ro + t · rd: start at the camera
ro, walk a distance t along the unit direction rd. The ray touches the sphere where
|p(t)|² = 1. Expanding gives an ordinary quadratic equation in t:
If h is negative there is no real solution: the ray misses. Otherwise the smaller root −b − √h is where
the ray enters the star — the visible point — and, because the radius is 1, the surface normal there (the direction straight
out of the surface) is simply that point: n = ro + rd · tHit.
The same numbers give something the corona needs. Halfway between the two roots, at t = −b, the ray passes
closest to the star's center. That closest distance is called the impact parameter — a term from scattering physics —
and a little algebra shows h = 1 − impact²: the ray hits exactly when it passes closer than one radius.
alpha fall from 1 to 0 over a pixel or two.A soft edge. If each pixel simply asked "hit or miss?", the disc's outline would be a staircase of pixels. The shader instead
measures how fast the impact parameter changes from one pixel to the next — fwidth(impact), which the GPU computes by
comparing neighboring pixels — and fades the disc's coverage over 1.5 of those steps around radius 1:
Pixels in that thin fade zone that just miss the sphere still need a surface color, so they use the limb point under the closest
approach, normalize(ro − rd · b). The coverage is kept as the layer's alpha, which §9 uses to put the star over the sky.
3 · A boiling surface: domain warping
The real Sun's visible surface is covered with granules: the tops of convection cells, where hot plasma wells up in bright centers and sinks back, cooler, along darker edges. They constantly appear, drift and dissolve. The shader imitates the look with noise.
Noise on a sphere. The demo's fbm is fractal value noise: random values on a 3D grid, smoothly blended, summed
over five octaves of doubling frequency and halving amplitude (see the warp page, §9). Because it is
defined everywhere in 3D, the shader can sample it directly at the surface point n — no 2D texture wrapped around a ball,
so no seam and no pinching at the poles. Before sampling, n is turned by −uRotation into the star's own
frame, so the pattern spins with the star (0.03 rad per second).
Domain warping — a trick popularized in the shader community by Inigo Quilez — means evaluating a noise not at
p but at p pushed around by another noise: fbm(p + k · warp(p)). Straight blobs get
bent into swirls, filaments and folds — very much like turbulent fluid. Here, three fbm fields at frequency 3 (different offsets, so
independent) form a 3D displacement vector, each drifting slowly with time; the granulation fbm, three times finer, is looked up at
the displaced point. As the displacement drifts, the granules churn.
// surface(n): p = n turned into the star's frame, t = time warp = ( fbm(p·3 + (0, 0, 0.05t)), fbm(p·3 + (7.1, 0, −0.04t)), fbm(p·3 + (0, 13.7, 0.03t)) ) gran = fbm(p·9 + warp·2.2 + 0.04t) // warped granulation cells = valueNoise(p·30 + warp·4 + 0.1t) // one octave of fine grain spots = smoothstep(0.66, 0.74, fbm(p·1.7 + 4)) // large, slow sunspots (no time term) heat = (0.75·gran + 0.25·cells) · (1 − 0.85·spots)
4 · From heat to color
A hot object glows, and its color tells its temperature: a blackbody (Max Planck derived its spectrum in 1900) goes from
dull red through orange and yellow to white as it heats up, and gets much brighter on the way. The shader borrows that idea, without
the physics: the single number heat is turned into a color by two blends — deep red to orange, then orange to a pale
yellow-white — each switched on by a smoothstep over a range of heat. The colors and ranges are artistic tuning values,
not temperatures.
col = mix((0.8, 0.18, 0.02), (1.0, 0.62, 0.2), smoothstep(0.25, 0.6, heat)) // red → orange col = mix(col, (1.0, 0.93, 0.75), smoothstep(0.55, 0.85, heat)) // → white-hot
Sunspots multiply the heat by 0.15, which drops them to the deep red end — on the real Sun, spots are cooler regions where strong
magnetic fields hold back the convection, so they look dark next to the surface around them. Finally the color is multiplied by
uSurfaceIntensity = 5 (and by up to 25 % more on each beat, §9). That pushes most of the disc far above 1.0, "whiter than
white" in the HDR image; tone mapping squeezes it back to near white and bloom spreads the excess as glare. That is why, in the
capture, the disc looks white-hot with only faint structure.
5 · Limb darkening
Photographs of the Sun show a disc that is brightest in the middle and noticeably darker and redder near its edge, the
limb. Looking at the center, our line of sight goes straight down into the hot, deeper layers; near the edge it enters the gas at a
grazing angle and only reaches higher, cooler layers before the gas becomes opaque. Astronomers describe it with simple laws in
μ, the cosine of the angle between the line of sight and the surface normal (1 at the center, 0 at the limb). The common
linear law is I(μ) = 1 − u · (1 − μ), with u about 0.6 for the Sun in visible light.
The shader uses its own artistic curve:
The exponent below 1 keeps most of the disc bright and concentrates the darkening near the edge; the 0.3 floor keeps the limb from going black. Besides realism, limb darkening does an important job: without it a sphere lit from within looks like a flat coin. The fall-off is what tells the eye "this is round".
6 · The corona: one sample per ray
The Sun's corona is its outer atmosphere: an extremely hot, very thin gas that reaches far into space and is seen with the naked eye only during a total eclipse, as a pearly glow with long streamers. Thin glowing gas is a volume: every point along a ray that crosses it adds a little light, and a faithful renderer would add up many samples along the ray.
The shader takes a shortcut. The corona's brightness depends mostly on height above the surface, and falls off quickly. A
ray that passes the star is at its lowest height — where the gas is brightest — at its point of closest approach (§2). So the shader
evaluates the corona once per ray, at that point, with height = impact − 1. One sample instead of dozens.
glow = exp(−3 · height / reach), reach = uCoronaReach · (0.35 + 1.3 · flames) // flame tongues rim = exp(−30 · height) // thin bright ring at the limb halo = 0.2 · exp(−1.2 · height) // faint wide halo col = mix(gold, red, smoothstep(0, 1.2, height)) corona = col · ((glow · (0.6 + 1.4·flames) + 1.5·rim) · (1 + uFlare) + halo) · uCoronaIntensity
flames (0 to 1, from noise — next section) does double duty: it stretches the glow's reach and boosts its brightness, so
where the noise is high a long bright tongue licks outward and where it is low the corona hugs the star. The music's snare
(uFlare, §9) brightens glow and rim but not the halo. If the closest approach lies behind the camera (tc < 0),
the corona is zero. For rays that hit the disc the corona is still computed, then replaced by the surface according to the coverage
alpha — that is why it never shows in front of the star.
flames, and below them the resulting color after tone mapping. Try: sweep flames from 0 to 1 to
see how a noise value becomes a short or a long flame.7 · Flames that flow outward
Where does flames come from? From two fbm lookups, on the direction of the closest point from the star's
center (a point on the unit sphere) — and on a single extra number that mixes height and time:
Two things happen. Stretching: going around the star, the noise changes quickly (frequency 5 on the direction); going outward, slowly (0.9 per star radius). Features are therefore several times longer than wide and all point away from the star: streamers. It is the same trick as the warp's tunnel (warp page, §8).
Flowing: a given feature of the noise sits at one fixed value of flow. If height − 0.12 · t must stay constant
while t grows, the feature's height grows at 0.12 star radii per second. Every flame streams outward, forever, yet nothing is
moving and nothing is stored: the shader just slides the noise's input. Both layers — the coarse one and the fine one — move at that same speed.
(The direction is also turned by half the star's rotation angle; note that it turns in the opposite sense to the surface, a difference that is hard
to notice with only 0.46 rad of total spin over the scene.)
corona() with a switch for
flow.8 · Prominences: glowing tubes
Prominences are huge loops of plasma that rise above the Sun's surface, following magnetic field lines whose two ends are
anchored in the surface. The shader draws five of them, each one a circle standing on the star: the circle's center is a point on the
surface, so half of it is buried inside the star and the other half arches above it. Each arc has its own frame — a surface point
center and two directions u, w — built from hashes of its index, so the arcs are scattered
"randomly" but identically every frame.
Distance to a tube. For a point p, write it in the arc's frame: local = (p·u, p·center, p·w) relative to
the arc's foot point. In the plane of the circle, the distance to the circle line is |local.xy| − height; adding the out-of-plane
part gives the distance to the circle's center line, and subtracting the tube's thickness gives the distance to the tube's skin:
That is the classic signed distance function of a torus (a doughnut): negative inside, positive outside. The radius
height (0.18 to 0.38 star radii, from a hash) breathes slowly between 70 % and 100 % of that value, and the thickness (0.022) is modulated by
sin(9 · angle + 2t), so knots of brightness travel along each arc.
Ray marching. There is no neat formula for "how much glow does this ray collect from five thin tubes", so the shader
marches: it clips the ray to a bounding sphere of radius 1.5 (another quadratic, as in §2), stops it at the star's surface
tHit (which hides buried halves and arcs behind the star), and takes 48 equal steps. At each step it finds the distance
d to the nearest tube and adds a glow:
This is emission only: the glowing gas adds light but absorbs nothing (unlike a smoke or dust volume, where the Beer–Lambert law would dim what lies behind). The sharp term gives a bright core, the wide term a soft aura. The soft fall-off also matters for sampling: a step through the bounding sphere can be up to 3 / 48 ≈ 0.06 long, wider than the 0.044-wide tube, so a sample can land beside a tube rather than inside it. Because the glow decays smoothly with distance, neighboring samples still pick it up. Compare with sphere tracing (John C. Hart, 1996), which steps by the distance itself to find a surface: here the goal is to collect glow, so fixed steps are used.
9 · Two layers, the music and the flash
Two resolutions. Everything above runs for each pixel of the star layer, and the surface alone calls fbm five times (25 octaves of noise) and the prominence march computes 48 × 5 tube distances. To stay fast, the star is rendered at a render scale of 0.5 — half the width and half the height, a quarter of the pixels — into a 16-bit floating-point layer. A second pass runs at full resolution: it paints the sky — two faint nebula tints from fbm and a field of stars — and puts the star layer, smoothly upscaled, on top. Glowing gas and a bright disc lose nothing at half resolution; the sky's one-pixel stars would. That is why both passes compute rays the same resolution-independent way (§1): the two images line up exactly.
Premultiplied alpha. The star layer stores rgb = the light it emits and a = how much of what is behind it
the disc hides. Compositing is then a single line, the "over" operator of Thomas Porter and Tom Duff (1984):
The disc (a = 1) hides the sky; corona and prominences have a = 0 and simply add their light; the antialiased
rim of §2 blends between the two.
The sky's stars come from the shared starSky helper: directions are scaled into a grid of 3D cells, a hash picks about one cell
in ten to hold a star at a random spot, and each pixel checks the 2 × 2 × 2 nearest cells, so a star is never cut off at a cell border. Each star is a
tiny Gaussian dot sized in pixels, so it stays sharp at any resolution.
The music. Two values follow the tune. uFlare jumps to 0.8 on every snare hit (instrument 2) and decays as
e−4s; it multiplies the corona's glow and rim by (1 + flare). uPulse jumps to 1 on every beat and decays
as e−6s; it brightens the surface by up to 25 %. Both are computed from the song's timing alone, so they are as
scrubbable as everything else.
The flash. During the first beat the timeline's transition adds (1, 0.95, 0.9) · 6 · e−((progress − 0.5) · 5)²
to the image — a bell-shaped HDR flash peaking at the cut. Before the peak the screen shows the warp, after it the star; at the peak no one can
tell.
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
camera = cameraAt(t · orbit)
rotation = 0.03 · t
flare = snarePulse(decay 4) · 0.8
pulse = beatPulse(decay 6)
layer = screenSize · renderScale (0.5) |
The camera path is a pure function of time (§1). The star's spin and the two music pulses (§9) are the other values that change; everything is sent to the GPU as uniforms. |
// pass 1: star layer, reduced resolution for each pixel: ro = camPos; rd = cameraRay(pixel) col = corona(ro, rd, out impact) b = ro·rd; h = b² − (|ro|² − 1) e = 1.5 · fwidth(impact) alpha = 1 − smoothstep(1 − e, 1 + e, impact) |
One ray per pixel (§1). The corona is evaluated first, at the ray's closest approach, which also yields the impact parameter (§6).
The quadratic's h tells hit or miss, and the impact parameter gives an antialiased coverage (§2). |
tHit = ∞
if alpha > 0:
if h > 0: tHit = −b − √h; n = ro + rd·tHit
else: n = normalize(ro − rd·b)
col = mix(col, surface(n, rd), alpha)
col += prominences(ro, rd, tHit)
out = (col, alpha) // premultiplied |
On the disc the surface replaces the corona; at the very rim, pixels that just miss use the limb point. Prominences are added on
top, marched only up to the surface (§8). The layer keeps light in rgb and coverage in a (§9). |
// surface(n, rd) p = rotateY(n, −rotation) warp = 3 × fbm(p·3 + offsets ± time drifts) gran = fbm(p·9 + warp·2.2 + 0.04t) cells = valueNoise(p·30 + warp·4 + 0.1t) spots = smoothstep(0.66, 0.74, fbm(p·1.7 + 4)) heat = (0.75·gran + 0.25·cells)·(1 − 0.85·spots) col = ramp(heat) // red → orange → white μ = max(n·(−rd), 0) col ×= 0.3 + 0.7·μ^0.55 return col · 5 · (1 + 0.25·pulse) |
Noise sampled in 3D on the star's own frame; domain warping makes it churn (§3). Heat becomes a blackbody-like color, darkened in sunspots (§4); limb darkening rounds the disc (§5). The intensity of 5 makes it HDR-bright. |
// corona(ro, rd) tc = −ro·rd; c = ro + rd·tc; impact = |c| if tc < 0: return 0 dir = rotateY(c / impact, 0.5·rotation) height = max(impact − 1, 0) flow = height − 0.12t flames = 0.7·fbm(dir·5 + flow·0.9) + 0.3·fbm(dir·11 + flow·1.7 + 3.1) reach = 0.6 · (0.35 + 1.3·flames) glow = e^(−3·height/reach); rim = e^(−30·height) halo = 0.2·e^(−1.2·height) return mix(gold, red, height) · ((glow·(0.6 + 1.4·flames) + 1.5·rim) · (1 + flare) + halo) · 1.5 |
One sample per ray, at the closest approach (§6). Sliding the noise's input by height − time turns it into streamers
that flow outward at 0.12 radii per second (§7). The flame value sets both the reach and the brightness; the snare flares glow and rim. |
// prominences(ro, rd, tEnd) [t0, t1] = ray ∩ sphere(1.5), t1 = min(t1, tEnd) for i in 0..4: // arc frames center = normalize(hash3(i) − 0.5) u, w = perpendiculars, spun by hash(i)·2π height = mix(0.18, 0.38, hash(i)) · (0.85 + 0.15·sin(0.6t + 1.7i)) for 48 steps p (into the star frame): d = min over arcs of |(|local.xy| − height, local.z)| − thick density += (e^(−70d) + 0.08·e^(−12d))·Δt return (1, 0.32, 0.12) · density · 18 |
Five rings standing on the surface, each in its own hashed frame (§8). The march accumulates emission from the torus distance
field; the beads come from the thickness 0.022·(0.75 + 0.25·sin(9·angle + 2t + i)). |
// pass 2: full resolution for each pixel: rd = cameraRay(pixel) sky = nebulaTints(fbm(rd)) + starSky(rd) star = texture(starLayer, pixel / size) out = sky · (1 − star.a) + star.rgb |
Same rays at full resolution, so the layers line up. The sky stays pixel-sharp; the star layer is upscaled with bilinear filtering and composited with the "over" operator (§9). |
// post (whole demo) + first beat of this scene
hdr += (1, 0.95, 0.9) · 6 · e^(−((p − 0.5)·5)²)
bloom → chromatic aberration → ACES → vignette, grain |
The flash transition hides the cut from the warp (§9); the demo's post chain turns the HDR image into what you see. |
The real source
These are the files as they are in the demo's repository. The browser version above runs the same two shaders, with the helpers
from shaders/common/ pasted in place of their #include lines.
shaders/scenes/corona/star.frag fullscreen · reduced resolution · the star
shaders/scenes/corona/composite.frag fullscreen · full resolution · sky + layer
src/scenes/corona/CoronaScene.cpp C++ · the whole scene
shaders/common/camera.glsl included · camera rays
shaders/common/sky.glsl included · star sky
shaders/transitions/flash.frag timeline transition · first beat
src/audio/SyncState.h — pulses (excerpt) C++ · music sync
Glossary
- Fragment shader
- A program the GPU runs once per pixel (fragment); it decides the pixel's color. Both of this scene's passes are fragment shaders drawn over the whole screen.
- Uniform
- A value set by the CPU before a draw and shared by every pixel of that draw (time, camera, rotation, pulses…).
- Ray casting
- Computing each pixel by following the straight line from the camera through that pixel and finding what it meets.
- Ray–sphere intersection
- Solving the quadratic equation
|ro + t·rd|² = r²fort; no real solution means a miss. - Impact parameter
- The smallest distance between a ray and the sphere's center, reached at
t = −ro·rd. Here it decides hit/miss, the corona's height and the antialiasing. - Normal
- The unit direction pointing straight out of a surface. On a unit sphere it is the point itself.
- fwidth
- How much a value changes between neighboring pixels, computed by the GPU from 2 × 2 pixel blocks. Used to make edges exactly about a pixel soft.
- Antialiasing
- Avoiding staircase edges by giving edge pixels partial coverage instead of all-or-nothing.
- Value noise / fbm
- Smoothly interpolated random values on a grid; fbm sums several octaves of it for natural detail.
- Domain warping
- Evaluating a noise at a position displaced by another noise,
f(p + k·g(p)), to get swirling, fluid-looking patterns. - Granulation
- The cellular pattern of the Sun's surface: tops of convection cells, bright where hot gas rises, dark where it sinks.
- Sunspot
- A cooler, darker region of a star's surface, caused by strong magnetic fields.
- Blackbody
- An idealized glowing object whose color depends only on its temperature: red, then orange, yellow and white as it heats.
- Limb darkening
- A star's disc looking darker toward its edge (limb), because grazing lines of sight only reach cooler, higher layers.
- Corona
- A star's hot, thin outer atmosphere, extending far into space in streamers.
- Prominence
- A loop of plasma rising from a star's surface along magnetic field lines.
- Signed distance function (SDF)
- A function giving the distance from any point to a shape's surface, negative inside. Here: a torus.
- Ray marching
- Following a ray in steps and evaluating something at each one; here, adding up the glow of the prominences.
- Emission
- Light given off by glowing gas. An emission-only volume adds light along a ray and absorbs none.
- Render scale
- The fraction of the screen resolution at which an expensive pass is rendered before being upscaled.
- Premultiplied alpha
- Storing color already multiplied by coverage, so a layer is composited with
below · (1 − a) + rgband can also hold pure added light (a = 0). - 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.
- Pulse
- A music-sync value that jumps to 1 on a beat or a drum hit and decays exponentially,
e−decay · seconds. - Transition
- A short shader that blends the previous scene into the next one on the timeline; here a white HDR flash.
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.
← 04 · Inside the Warp · All scenes · 06 · Asteroid chase →
