Field record 001 — appendix

THE GUIDE

how the pilgrimage was built, and how to build your own

01 — Concept

One object. One walk. Nothing else.

The brief was restraint as the flex: a single monolith, and a scroll that does not move the page — it moves you. The document body is just a 720vh empty spacer; the real content is a fixed WebGL canvas where scroll progress drives a camera along a pre-authored path around the stone. Text appears in six short "movements", each timed to a leg of the walk, written like fragments of a recovered expedition log.

The emotional arc is a pilgrimage: approach from far away and low (humility), read the proportions from the flank, get uncomfortably close, cross behind it into the backlight, ascend, and finally look down at it from altitude — the only moment the stars are visible.

The camera is the protagonist. The monolith never moves. Only your position — and therefore your relationship to it — changes.
02 — Art direction

A dying ember behind black basalt.

Dark scenes die when they are flat #000. This one gets depth from four layers of light: an ember glow low on the horizon behind the monolith (so most views render it as a silhouette), a faint cold counter-light on the viewer's side, exponential fog that swallows the ground line, and a fresnel rim on the stone itself so its edge never fully disappears into the night.

Ink
#080a0d
Fog
#11141a
Mist
#3c4552
Bone
#e7e2d6
Ember
#c8a86a

Typography: Cormorant Garamond (300) for the whispers — a serif with enough contrast to feel carved — against IBM Plex Mono (300) for the expedition apparatus: labels, coordinates, movement numerals. The serif is always large and quiet; the mono is always tiny and letter-spaced (letter-spacing: .3–.6em). That gap in scale is the whole typographic system.

The proportions are a citation. The slab is 2 × 8 × 18 — exactly 1 : 4 : 9, the squares of the first three integers, the dimensions of the monolith in 2001: A Space Odyssey. Movement II points at it without naming it.

03 — Scene anatomy

Seven things in the dark.

Monolith
BoxGeometry 8×18×2 with a custom ShaderMaterial — near-black basalt, vertical striations, fresnel rim, warm key from behind, cold fill from the front.
Ground
A 320-radius disc with a shader: dark mottled terrain, a contact shadow at the base, faint concentric rings "engraved" around the stone, and a warm cast on the far side.
Ember
Two additive sprites (a wide wash + a hot core) using canvas-generated radial gradients, parked at z = −120 so the monolith is backlit for most of the walk.
Counter-light
A dim cold sprite on the viewer's side (+z) so the final aerial shots aren't pitch black.
Dust
420 additive points drifting slowly upward inside a 140×40×160 volume, wrapping at the top. The only perpetual motion in the scene.
Stars
700 points on an upper-hemisphere shell, opacity 0 at ground level, fading in only when the camera climbs past y = 12. Altitude gets its own reward.
Fog
FogExp2(#11141a, 0.0205) — the single most important line. It eats the horizon, scales the mood, and makes the far approach read as "distance" instead of "small box".
04 — The camera walk

Scroll is a position on a spline.

Two CatmullRomCurve3 splines are authored by hand — one for the camera position, one for its look-at target. Scroll progress (0→1) is smoothed with an exponential lerp and sampled on both curves every frame. Centripetal parameterisation avoids kinks at the tight close-pass keyframe:

// seven keyframes: approach → flank → close pass → far side → ascent → altitude
const positions = new THREE.CatmullRomCurve3([
  new THREE.Vector3(  0, 2.6,  64),  // far, low, humble
  new THREE.Vector3( 24, 3.2,  42),
  new THREE.Vector3( 21, 4.6,  11),  // the ratio reads
  new THREE.Vector3(6.5, 3.0, 7.5),  // intimate, craning up
  new THREE.Vector3(-11, 6.0, -14),  // into the ember light
  new THREE.Vector3( -4,  26, -21),
  new THREE.Vector3(  7,  46, -36)   // the shape below
], false, 'centripetal', 0.25);

// every frame:
smoothP += (targetP - smoothP) * 0.065;   // scroll inertia
positions.getPoint(smoothP, camera.position);
lookAts.getPoint(smoothP, look);
camera.lookAt(look);

Three small cruelties make it feel filmed rather than computed: a handheld breath (two slow sines added to position), a faint roll through the turn (rotation.z += sin(p·2π)·0.016), and a focal squeeze — the FOV tightens from 42° to 36° as the camera nears the stone, the way a documentary operator instinctively zooms when something is important.

0MONOLITH — title cardp 0.00–0.09 · far frontal, y=2.6
IApproachp 0.13–0.26 · drifting to the flank
IIThe ratiop 0.31–0.44 · side profile, 1:4:9 legible
IIIThe surfacep 0.49–0.60 · 7 units out, looking up
IVThe far sidep 0.65–0.76 · behind, backlit
VAltitudep 0.81–0.90 · y=26 and climbing, stars in
VIReturnp 0.94–1.00 · y=46, gazing down

Each verse is a fixed-position HTML element with data-a/data-b progress windows. Opacity is min(smoothstep-in, smoothstep-out) with a 0.028-wide fade band, plus a small vertical drift so text settles into place instead of switching on.

05 — The stone shader

Black that is never black.

The monolith's material is where the restraint pays off. It is a single fragment shader, four lighting terms added to a near-black base. The striations are value-noise fbm stretched 100:1 so they read as vertical brushing; the fresnel rim is tinted warm on the ember side and cold on the viewer side by mixing on -N.z:

// near-black basalt + brushed striations
vec3 base = vec3(0.045, 0.050, 0.058);
float stri = fbm(vec2(vUv.x * 140.0, vWp.y * 1.4));
base += (stri - 0.5) * 0.022;

// warm key from the dying horizon behind (-z)
vec3 Lkey = normalize(vec3(0.12, 0.30, -1.0));
vec3 key  = max(dot(N, Lkey), 0.0) * vec3(0.78, 0.62, 0.38) * 0.34;

// the edge that refuses to be black
float fr = pow(1.0 - clamp(dot(N, V), 0.0, 1.0), 3.5);
vec3 rim = mix(vec3(0.55, 0.63, 0.74),      // cold, viewer side
               vec3(0.86, 0.72, 0.46),      // warm, ember side
               0.5 + 0.5 * -N.z) * fr * 0.55;

gl_FragColor = vec4(base + key + fill + sky + rim, 1.0);
#include <fog_fragment>

Both custom materials set fog: true and splice Three's fog_pars/fog_vertex/fog_fragment chunks, with UniformsLib.fog cloned into their uniforms — so hand-written GLSL still participates in the scene fog. The ground shader works the same way; its rings are smoothstep(0.90, 1.0, sin(r·0.85)) faded by two opposing smoothsteps so they exist only in a band around the base.

06 — Atmosphere & film

Fog, dust, grain, vignette.

The "recovered footage" feel is three cheap layers on top of the render:

  1. Film grain — a 160×160 canvas of random luminance, converted to a data-URI, tiled fine at background-size: 96px on a 300%-sized fixed element, jittered by a 4-step CSS keyframe animation at mix-blend-mode: overlay, opacity 0.06. It sits above everything — scene, verses, even the field notes — so the whole document reads as one piece of footage. Zero JS per frame.
  2. Vignette — a fixed radial-gradient overlay, transparent center to rgba(4,5,7,.55) at the corners. Pulls every composition toward the middle.
  3. Tone mappingACESFilmicToneMapping at exposure 1.05, which rolls the additive ember glow off filmically instead of clipping.

All glow sprites are canvas-generated radial gradients (no image assets anywhere on the site — every texture is procedural). No AI-generated assets were used.

07 — Performance & care

60fps, and polite about it.

  1. devicePixelRatio capped at 2; a single render pass, no postprocessing chain — grain and vignette are DOM, which the compositor handles for free.
  2. Only ~1,120 particles total (420 dust + 700 stars); dust updates are simple array writes, stars are static.
  3. prefers-reduced-motion: scroll smoothing snaps 1:1, handheld sway / dust drift / grain jitter / loader choreography are all disabled. The pilgrimage still works — it just walks calmly.
  4. WebGL context loss is intercepted (webglcontextlost → preventDefault()) so the page survives GPU resets.
  5. The loader is a bone-white slab that rises like the monolith itself, then the whole overlay fades — no flash of unstyled content, and a 5s failsafe so it can never trap the page.
08 — The afterword

The document resumes.

The pilgrimage no longer ends at the terminus — it ends where documents end: in appendices. Past the 720vh spacer the page becomes an ordinary scrolling document again. Scroll progress for the camera is now measured against the spacer alone (p = scrollY / (spacer.height − innerHeight)), and a second value, over, ramps 0→1 across half a viewport past the spacer's end — fading out the verse layer, the movement counter and the coordinates as the notes slide up over the fixed scene.

  1. Appendix A — Field notes. Eight numbered observations in small type, each a hairline-ruled row, revealed one by one with a staggered IntersectionObserver (opacity + 16px translate, nothing else). The copy keeps the expedition-log register: compasses that disagree, rings the dust refuses to enter, numbers that are all the same number.
  2. Appendix B — Colophon. A six-row table crediting the scene, camera, atmosphere, film, type and motion. It doubles as a summary of this guide in six sentences.
  3. A minimal footer — record number, year, two links, and one last line of copy.
  4. A three-link nav in the top-right chrome (Notes · Colophon · Guide) that glides via scrollIntoView — the site's only concession to being a website.

Three late touches deepen the idle life: the stars drift (a slow rotation of the shell, one revolution every ~20 minutes), the ember breathes on a ~30-second tide even when nothing happens, and the monolith carries one near-subliminal microinteraction — the fresnel rim leans toward the cursor (rim *= 1.0 + 0.9 · uLean · N.x, where uLean is the smoothed horizontal pointer offset). You cannot point at it and say what changed. That is the point.

09 — Replicate it

The recipe, in seven lines.

  1. Fix a full-viewport canvas; give the document a tall empty spacer (height: 720vh). Scroll progress = scrollY / (scrollHeight − innerHeight), smoothed with an exponential lerp.
  2. Build one hero object and give it a custom shader with a fresnel rim — the rim is what keeps a dark object legible against a dark sky.
  3. Backlight it: put your brightest light behind the subject so most of the journey renders a silhouette. Add a dim counter-light so nothing is ever 100% black.
  4. Author two Catmull-Rom splines by hand — position and look-at. Seven keyframes is plenty. Centripetal parameterisation, tension ≈ 0.25.
  5. Time your copy to progress windows, fade with opposed smoothsteps, and keep it to a whisper — six short lines beat six paragraphs.
  6. Add exponential fog tuned so the object almost vanishes at your farthest keyframe. Fog is the scale cue.
  7. Finish with film: grain, vignette, filmic tone mapping, a handheld breath on the camera. These four cost almost nothing and carry the entire "footage" illusion.