← RETURN TO THE NAVE
✦ ✦ ✦

THE GUIDE

How a cathedral of light was written in JavaScript — every stone procedural, every color computed.

I THE CONCEPT

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.

II THE PALETTE

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.

NAVE#060714
ABYSS#0a0c1a
GOLD#c9a24b
VIOLET#7c6cff
ICE#a8c4ff
BONE#efe9ff

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.

III THE STACK

IV THE GLASS — A SPECTRAL FRESNEL SHADER

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.

V THE ARCHITECTURE — POINTED ARCHES FROM BÉZIERS

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.

VI THE ROSE WINDOW — SACRED GEOMETRY ON A 2D CANVAS

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:

  1. Twelve petals — each a closed path of two quadratic curves between radius 0.55R and 1.02R, filled from a six-color stained-glass array.
  2. Twelve roundels at 0.42R, offset half a petal, color-shifted by three indices.
  3. A hexafoil — six circles at 0.16R alternating violet and cobalt.
  4. Gold tracery — radial spokes and rim strokes in #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.

VII LIGHT YOU CAN ALMOST TOUCH

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

VIII THE PRISM — DISPERSION MADE LITERAL

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.

IX THE WALK — SCROLL CHOREOGRAPHY

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.

X THE DRONE — AN ORGAN WITH NO SAMPLES

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

XI THE CRYPT — SUBSTANCE BENEATH THE JOURNEY

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:

THE LIVE SHADER PANEL

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.

THE ORGAN CONSOLE

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

THE WINDOWS GALLERY — CAMERA FLIGHTS

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.

XII CRAFT NOTES

No AI-generated assets were used. Every visual is procedural code; the only "prompts" involved were written to a rendering API, in GLSL.

XIII REPLICATE IT

  1. Copy the three files; serve statically (any host — all paths are relative).
  2. Start with the fog: set scene.fog, setClearColor and the page background to the same hex. This single decision creates the "infinite interior".
  3. Build one gothic arch from two Béziers, then array it down z. Add the fresnel-spectrum shader before adding any more geometry — it defines the entire look.
  4. Draw your rose window on canvas — start with 12 petals and gold spokes; asymmetry is the enemy, seeds are your friend.
  5. Add god-ray planes and dust last; they are cheap and do 40% of the atmosphere.
  6. Tie one scrubbed scalar to scroll and derive the camera from it. Resist the urge to animate anything else with scroll.