Rays that walk, not intersect.
A raymarcher never solves a ray–surface equation. For every pixel it fires a ray from the camera and walks it forward. At each step it asks the scene a single question — how far is the nearest surface from where I stand? — and steps forward exactly that far. Never further, so it can never pass through anything. This is sphere tracing: each step is the radius of a sphere guaranteed to be empty.
When the answer gets tiny, the ray has landed. When the ray has travelled too far, it has missed everything and falls into the void. Chromasphere's march loop also accumulates a glow term on every step — rays that pass near the surface pick up halo light, which is what makes the sphere feel luminous rather than lit:
float t = 0.0, glow = 0.0, d = 0.0;
bool hit = false;
for(int i = 0; i < 160; i++){
if(float(i) >= uSteps) break; // quality governor can shorten the walk
vec3 p = ro + rd * t;
d = map(p); // "how far is the nearest surface?"
glow += exp(-abs(d) * 16.0) * 0.011; // near-misses accumulate halo light
if(d < 0.0012 * t){ hit = true; break; } // tolerance grows with distance
t += d; // the sphere-trace step
if(t > 9.0) break; // fell into the void
}
Two details are load-bearing. The hit tolerance scales with distance (0.0012 · t) — far surfaces don't need sub-pixel precision, and this alone saves dozens of steps. And the whole scene renders on a single triangle three times the size of the screen: one triangle means zero diagonal seam and one fewer vertex than a quad.
The world is one function.
The map() function is the entire universe. It returns a signed distance: positive outside matter, negative inside, zero at the skin. Sculpting means doing arithmetic on that number. Chromasphere's planet is a sphere with two kinds of weather added on top, and two moons folded in with a smooth union:
float map(vec3 p){
vec4 P = chParams(uChapter); // scroll picks the weather
float d = length(q) - 1.12; // the core: a sphere, one line
// gyroid interference — the storm of chapter 02
d += gyroid(q * P.y + flow) * P.x; // gyroid(p) = dot(sin(p), cos(p.zxy))
// fbm blossom — the bloom of chapter 03
d += (fbm(q * 2.3 + drift) - 0.5) * P.z * 2.0;
// two satellites, smooth-min'd so they melt into the core
d = smin(d, length(q - s1) - 0.13, 0.36);
d = smin(d, length(q - s2) - 0.085, 0.30);
return d * 0.72; // displaced SDFs lie — walk at 72% confidence
}
The smooth union
A plain min(a, b) unions two shapes with a hard crease. The polynomial smooth-min blends their skins like wet clay — it is the single most expressive operator in SDF sculpture:
float smin(float a, float b, float k){
float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
return mix(b, a, h) - k * h * (1.0 - h);
}
The final × 0.72 deserves its own sentence. Displacement breaks the distance guarantee — the gyroid can make the true surface closer than the sphere reports. Scaling every answer down makes the ray walk cautiously, trading a few extra steps for zero clipping artifacts. Every displaced raymarcher needs a number like this.
Scroll as a dimension
The scroll bar is a fourth axis. GSAP's ScrollTrigger scrubs a single uniform, uChapter ∈ [0, 3], and chParams() piecewise-mixes four parameter sets — gyroid amplitude, gyroid frequency, fbm amplitude, flow speed — so Genesis, Turbulence, Bloom and Stillness are just four points in weather-space that you glide between:
ScrollTrigger.create({
trigger: '#chapters', start: 'top bottom', end: 'bottom top', scrub: 0.6,
onUpdate(self){ S.chapter = self.progress * 3; } // → uChapter uniform
});
The click-shockwave works the same way: a timestamp uniform, and inside map() a ring — sin(24r − 9.5·age) damped by two exponentials, one for time, one for distance from the wavefront. State lives in JavaScript; the shader stays pure.
Every colour is one cosine.
There is no colour picker anywhere in this project. All of it — the violet genesis, the ember storm, the rose-gold bloom — comes from Iñigo Quílez's palette formula: colour(t) = a + b · cos(2π(c·t + d)). Chromasphere keeps a single spectral phase d = (0.00, 0.33, 0.67) — the classic rainbow — and each chapter claims a window of it: an offset, a range, and an amplitude:
// one universal spectral phase; chapters choose a WINDOW of it
vec3 pal(float t, float b){
return vec3(0.5) + b * cos(6.28318 * (t + vec3(0.0, 0.33, 0.67)));
}
// palette window per chapter: x hueOffset, y hueRange, z amplitude, w brightness
vec4 H = chHue(uChapter);
// the hue driver: view angle sweeps the chapter's window
float hue = H.x // where this chapter lives on the spectrum
+ fre * H.y // fresnel — colour turns with viewing angle
+ n.y * 0.06 // a little latitude
+ fbm(p * 3.1) // surface grain — widens during
* (0.07 + 0.16 * bloomW); // bloom: the oil-slick patches
vec3 irid = pal(hue, H.z);
Feeding fresnel into t is the thin-film trick: real iridescence — soap bubbles, oil slicks, beetle shells — shifts hue with viewing angle because path length through the film changes. One multiply fakes the whole phenomenon. Scrolling interpolates the window, so a season change is just four floats gliding:
UI colours were then sampled from the shader's output so chrome and canvas agree: void #050208, ink #e8e2f2, teal #7de3d0, rose #ffb8e0, gold #f2d38b, violet #8f7bff.
Sixty frames, or the dream breaks.
A raymarcher's cost is pixels × steps × map() price. Every trick below attacks one of those three factors:
The interesting part is the quality governor — a rolling FPS meter that trades resolution and step count in small increments, downgrading fast and upgrading reluctantly, so a weak GPU converges to a stable frame rate instead of oscillating:
const QUALITY = [
{ steps: 150, dprMul: 1.00 }, // full dream
{ steps: 112, dprMul: 0.85 },
{ steps: 84, dprMul: 0.70 },
{ steps: 60, dprMul: 0.55 }, // still beautiful, half-res
];
function govern(now){
S.frames.push(now); // rolling 1.4s window
while(S.frames.length && now - S.frames[0] > 1400) S.frames.shift();
const fps = S.frames.length / ((now - S.frames[0]) / 1000);
if(fps < 48) S.qLevel++; // downgrade fast
else if(fps > 57) S.qLevel--; // upgrade only after 5s of grace
}
- Grain and vignette live inside the same pass — one hash and one multiply at the end of main(), so there is no second render target and no compositor.
- Normals via the tetrahedron trick — four map() calls instead of six for central differences.
- AO is five taps along the normal, not a hemisphere — a 5-sample approximation that reads identically on a glowing subject.
- The shockwave branch is guarded — if(age < 3.0) means an idle scene never pays for the ripple math.
- prefers-reduced-motion freezes uTime and cancels the rAF loop entirely: the site becomes a still photograph that re-renders one frame per scroll event.
Luxurious darkness, precise chrome.
The rule was a contrast of temperaments: the shader is liquid and continuous, so the interface must be dry and exact. Corner-anchored HUD metadata, 1px rules, a thin scroll progress line, uppercase mono labels with wide tracking — instrumentation around a phenomenon.
Unbounded (200–900 variable) carries the display voice; its weight axis is animated — the hero title decays from 840 to 200 as you scroll, as if the word itself loses mass. Space Mono carries every label at 10px with 0.3em tracking. Copy is written as field-log entries: short declaratives, laboratory nouns, no adjectives that a spectrometer couldn't verify.
- Every animation eases on cubic-bezier(0.16, 1, 0.3, 1) — expo-out; nothing moves linearly except the scrubbed scroll bindings.
- Film grain is an SVG feTurbulence tile stepped through 4 positions at 0.9s — cheap, organic, and it unifies canvas and DOM.
- The cursor is a crosshair that only completes itself over hoverables — the interface tells you where it is listening.
Build your own in an evening.
One full-screen <canvas>, getContext('webgl2'), a single triangle at (−1,−1)(3,−1)(−1,3). Compile, link, draw. No libraries needed for this part.
map(p) = length(p) − 1. March it, shade hits white. If you see a disc, your loop works. Everything after this is decoration.
Tetrahedron normals, one key light, fresnel = pow(1 + dot(n, rd), 3). Add the five-tap AO. Keep the background nearly black — darkness is what makes the glow legible.
Displace with gyroid and fbm, scaled by parameters you can interpolate. Remember the confidence factor (× 0.7) or rays will tunnel through your surface.
Steal pal() verbatim, drive t with fresnel, and spend an hour only on the four phase vectors. This hour decides whether it looks cheap or expensive.
GSAP ScrollTrigger, scrub 0.6, progress × 3 → uChapter. Piecewise-mix your parameter sets. Pin a copy block per chapter and reveal words with staggered translateY.
Rolling FPS window, four quality levels over steps × resolution. Cap devicePixelRatio at 1.75. Downgrade fast, upgrade slow.
In-shader grain and vignette, a preloader that hides the shader warm-up, reduced-motion and no-WebGL fallbacks. The last 10% is the difference between a demo and a study.
Instruments used
- GSAP 3.12 + ScrollTrigger — vendored locally, the only JavaScript dependencies. gsap.com
- Unbounded & Space Mono — via Google Fonts, variable weight axis animated. fonts.google.com
- Raw WebGL2 / GLSL ES 3.00 — no engine, no build step, one HTML file.
- Reference reading — Iñigo Quílez on distance functions and palettes.