Goldilox Paradox
WebGL scrollytelling experience
Role
Designer & Developer
Stack
Astro 5, Three.js, GLSL (ES 3.0), SCSS
Year
2025–2026

01/The Constraint
Tell the story of planetary habitability — why Earth sits in the Goldilocks Zone while Venus burned and Mars froze — using scroll as the only input device. No clicks, no menus, no pagination. The scroll position is the narrative cursor. A 460-line Master Design Document defined the emotional arc (curiosity, dread, wonder, melancholy, humility), the canonical script, pacing controls, and scientific accuracy guardrails sourced from NASA mission data.
02/The Architecture
Astro serves a single static HTML page. The rendering pipeline uses Three.js as a thin WebGL2 wrapper — not its scene graph. An orthographic camera renders a fullscreen quad, and all planet geometry is computed entirely inside the fragment shaders via ray-sphere intersection. Scene switching is uniform-driven: when the user scrolls into a new chapter, the renderer swaps the fragment shader and uniform set, then disposes and recreates the material. A hand-rolled scrollytelling engine uses IntersectionObserver for chapter detection and requestAnimationFrame for scroll-position-mapped animations with custom easing functions. Hash-based navigation supports deep-linking to individual chapters.
03/The Shaders
Two GLSL ES 3.0 fragment shaders handle all planet rendering. The Earth shader composites five texture layers — daytime albedo, nightside city lights emission-mapped by sun angle, cloud cover with density control, specular ocean highlights via Phong reflectance, and a bump map that perturbs normals for terrain relief. The generic planet shader handles Venus and Mars with single-texture ray tracing, S-curve color grading, and luminance-derived bump mapping. Both share atmosphere glow computed from edge proximity, an equirectangular starfield that counter-rotates at one-twelfth planet speed, Reinhard tone mapping, and vignette post-processing. Texture resolution adapts to device — 8K starfield on desktop, 4K on mobile.

Source Code
earth.fragment.glslglsl
// Ray-sphere intersection — all geometry computed in the shader
float t = raySphereIntersect(ro, rd, PLANET_CENTER, PLANET_RADIUS);
if (t > 0.0) {
vec3 pos = ro + rd * t;
vec3 normal = normalize(pos - PLANET_CENTER);
// Five-layer texture compositing
vec2 uv = sphereUV(normal);
vec3 dayColor = texture(uEarthColor, uv).rgb;
vec3 nightColor = texture(uEarthNight, uv).rgb;
float clouds = texture(uEarthClouds, uv).r;
float specMask = texture(uEarthSpecular, uv).r;
float bump = texture(uEarthBump, uv).r;
// Perturb normal with bump map for terrain relief
normal = perturbNormal(normal, bump, uv);
// Blend day/night based on sun angle
float sunDot = dot(normal, uSunDirection);
vec3 surface = mix(nightColor * NIGHT_EMISSION,
dayColor, smoothstep(-0.1, 0.3, sunDot));
// Cloud layer + ocean specular + atmosphere
surface = mix(surface, vec3(1.0), clouds * CLOUD_DENSITY);
surface += specMask * phongSpecular(rd, normal, uSunDirection);
}