How a cathedral of light was written in JavaScript — every stone procedural, every color computed.
Glass Cathedral is a single WebGL scene: a gothic nave whose architecture is made entirely of light. There are no walls, no roof — only glowing ribs, stained-glass windows floating in fog, shafts of colored light, incense dust, and a floating octahedron prism that splits a white beam into seven blades of spectrum.
The design constraints were monastic on purpose: one dark fog color, one gold, one violet, a rainbow reserved only for glass edges. Reverent pacing — the camera never rushes, everything eases with long power3 curves, and the scroll does not "animate things" so much as walk you down the aisle.
Everything is procedural. No image files, no 3D models, no AI-generated assets — the rose window, the lancet windows and the floor are drawn at runtime on 2D canvases and used as textures.
Six named values. The nave color doubles as fog and clear color, so geometry dissolves into it seamlessly — that is what makes the space feel infinite instead of boxed.
Typography: Cinzel (engraved Roman capitals — the "carved in stone" register) for display and labels, Cormorant Garamond italic for the verses. Letter-spacing is used like incense: generously, and only upward.
index.html, style.css, main.js.Real refraction (MeshPhysicalMaterial with transmission and dispersion) is gorgeous but heavy, and reads poorly against a black void. Instead, every rib, column and the prism share one cheap trick: an additive fresnel shader whose rim color is sampled from a cosine spectrum. Glass in the dark is only visible at its edges — so we render only the edges, and let the edge color drift through a rainbow. The eye reads it as dispersion.
// IQ cosine palette → stained-glass rainbow
vec3 spectrum(float t) {
return 0.5 + 0.5 * cos(6.28318 * (t + vec3(0.0, 0.33, 0.67)));
}
void main() {
float fres = pow(1.0 - abs(dot(normalize(vNormal), normalize(vView))), 2.2);
float band = vWorld.y * 0.045 + uShift + uTime * 0.02;
vec3 col = spectrum(band + fres * 0.35);
col = mix(col, vec3(0.55, 0.45, 1.0), 0.25); // bias to violet
float d = distance(cameraPosition, vWorld);
float fogF = exp(-d * 0.02); // hand-rolled fog for additive blending
gl_FragColor = vec4(col * fres * uIntensity * fogF, fres * fogF);
}
Two details matter. The color band is keyed to world height (vWorld.y), so the rainbow climbs the architecture like light through a window. And because additive materials ignore scene fog, distance fade is computed manually with exp(-d * 0.02) — without it, far arches would glow as brightly as near ones and flatten all depth.
A gothic arch is two arcs that refuse to meet smoothly — the break at the apex is the whole point (literally). Each arch is two mirrored quadratic Béziers swept into TubeGeometry:
const curve = new THREE.QuadraticBezierCurve3(
new THREE.Vector3(side * 6.2, 9, z), // springing point (column top)
new THREE.Vector3(side * 4.6, 13.4, z), // shoulder control
new THREE.Vector3(0, 15.2, z) // apex — the gothic break
);
const rib = new THREE.Mesh(new THREE.TubeGeometry(curve, 24, 0.09, 6), glassMat);
Nine arch bays march down the z-axis eight units apart; diagonal ribs cross between neighboring bays to suggest a rib vault. The fog eats the far ones — perspective does the rest.
The rose window is a 1024×1024 canvas drawn once at load and used as a CanvasTexture on an unlit circle (unlit = it glows in a dark scene for free). The geometry is classic 12-fold rose symmetry:
#c9a24b tie every element together, the way lead lines do in real glass.// one petal of twelve
ctx.moveTo(cx + cos(a0)*R*0.55, cy + sin(a0)*R*0.55);
ctx.quadraticCurveTo(cx + cos(am)*R*1.02, cy + sin(am)*R*1.02, // outer tip
cx + cos(a1)*R*0.55, cy + sin(a1)*R*0.55);
ctx.quadraticCurveTo(cx + cos(am)*R*0.48, cy + sin(am)*R*0.48, // inner cusp
cx + cos(a0)*R*0.55, cy + sin(a0)*R*0.55);
A radial-gradient sprite (AdditiveBlending) sits half a unit behind it as a halo, its opacity breathing on a slow sine. The side lancet windows use the same recipe — a pointed-arch clip path, a random mosaic of panes, dark lead lines, gold frame — generated in three seeded variants.
The "volumetric" rays are a classic stage trick: slanted planes with a custom shader that fades along both UV axes, additively blended, gently flickering. Six pour from the side windows (alternating violet and gold), and one grand 10-unit-wide beam falls from the rose.
float edge = smoothstep(0.0, 0.35, vUv.x) * smoothstep(1.0, 0.65, vUv.x);
float fall = pow(vUv.y, 1.6); // bright at the source
float flicker = 0.75 + 0.25 * sin(uTime * 0.35 + uSeed + vUv.x * 4.0);
gl_FragColor = vec4(uTint, edge * fall * flicker * 0.35);
The dust motes complete the illusion. 650 points drift entirely on the GPU — the vertex shader displaces each particle by slow sines of uTime and a per-particle seed, so the CPU never touches the buffer after creation:
p.x += sin(uTime * 0.12 + aSeed) * 0.6;
p.y += sin(uTime * 0.08 + aSeed * 2.0) * 0.8;
gl_PointSize = 90.0 / -mv.z; // perspective-correct sparkle
Above the altar floats an octahedron (with a counter-rotating inner core) wearing a brighter variant of the glass shader. From it, seven thin blades — one per spectral color from #ff5a4d to #b877ff — fan downward, each a 0.22×9 plane in its own pivot group rotated by (i − 3) × 0.14 radians. Their shader fades along the blade length and shimmers out of phase, so the fan feels alive rather than mechanical.
The page is five 100vh panels of text over a fixed canvas. One ScrollTrigger scrubs a single progress value p ∈ [0,1]; the render loop derives everything from it:
gsap.to(cam, { p: 1, ease: 'none',
scrollTrigger: { trigger: '#scroll-space', start: 'top top',
end: 'bottom bottom', scrub: 0.9 } });
// in the render loop
const z = lerp(42, -14, p); // dolly down the nave
const y = lerp(8.5, 3.6, p); // descend to eye level
const sway = sin(p * PI * 2) * 1.1; // drift between the columns
camera.lookAt(0, lerp(5.5, 8.3, p), -34); // eyes rise to the rose
The scrub: 0.9 gives the camera 900ms of inertia — the difference between walking and teleporting. Mouse position adds a lerped ±1-unit parallax so the frame is never static. Each verse fades up with a staggered power3.out when its panel crosses 62% of the viewport, and reverses when you scroll back.
Sound starts only from a click on the toggle (browser policy and basic courtesy agree here). The drone is six oscillators on an A-major spread — sines for the 55/110Hz foundation, triangles above, one pair detuned by 0.6Hz for slow beating — through a lowpass filter whose cutoff breathes at 0.07Hz via an LFO. The cathedral acoustics are a convolver with a generated impulse response: four seconds of noise decaying by pow(1 − t, 2.8).
const impulse = ctx.createBuffer(2, rate * 4, rate);
for (let i = 0; i < d.length; i++)
d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / d.length, 2.8);
verb.buffer = impulse; // instant stone reverb, zero downloads
After the scroll journey ends, the site keeps going: three content chapters ("the crypt") sit beneath the walk, in the same reverent register — plus a sticky HUD with anchor links and a footer. Three interactive exhibits live there:
Chapter V explains the spectral fresnel trick next to a second, tiny WebGL renderer showing the exact same material on a rotating torus knot. Three sliders write straight into the shader uniforms — uPower (edge falloff), uShift (spectrum drift), uIntensity (radiance) — so the reader can hold the cathedral's glass in their hands. An IntersectionObserver creates the renderer lazily and pauses it off-screen, so the demo costs nothing until it is looked at.
Chapter VI documents the six oscillator stops (55–277.2 Hz) and adds a circular play button wired to the same drone as the corner toggle. While it sounds, an AnalyserNode (fftSize 8192) feeds a 2D-canvas visualiser — 72 bars sampled on a power curve so the low drone shelf fills the width; silent, it draws one slow gold sine, "a breath held".
Chapter VII lists four curated stations (West Door, Prism, Rose, Under the Vault). The clever part: the thumbnails are not images. At load, the main renderer is resized to 640×360, the camera visits each station, renders one frame, and canvas.toDataURL() becomes the card's background — the cathedral photographs itself. Clicking a card hides the crypt, and the render loop lerps the camera toward the station:
// in the render loop — view.blend is tweened 0 → 1 by GSAP
camera.position.lerp(stationPos, view.blend);
lookTarget.lerp(stationLook, view.blend);
Because the blend rides on top of the normal scroll-derived camera, entering and leaving a station is always a continuous flight, never a cut. Pointer parallax stays alive during the visit so the frame keeps breathing. Escape or the return button flies you back.
prefers-reduced-motion disables the camera sway and bob, dust, idle rotations, blur reveals and CSS loops; scroll scrubbing becomes direct (scrub: true) so position still follows the user's own gesture only.webglcontextlost is prevented and pauses rendering; webglcontextrestored resumes.devicePixelRatio capped at 2; all glow is additive geometry instead of post-processing bloom (no EffectComposer, one render pass); the dust animates in the vertex shader; canvas textures are drawn once.scene.fog, setClearColor and the page background to the same hex. This single decision creates the "infinite interior".