Build log · Deep Field
Brief
The closing chapter — a WebGL-led "field record" treated like an installation page. Aesthetic target is lusion.co + Active Theory + the cinematic-photography wing of Awwwards: dense near-black canvas, a green spectral accent, real GLSL on the page, and motion that earns its bundle. The reviewer should land here and feel the file size is doing real work.
Patterns used
clippath-page-transitions— polygon reveal on entry (shared shape with Cinematic, deliberate)webgl-physics-hero— R3F + Rapier rigid bodies, pointer-impulse, camera follows pointerripple-distortion-shader— custom GLSL shader on a textured plane, pointer drives displacementwebgl-cursor-trail— additive Canvas2D point trail with mix-blend-mode screenscroll-jacked-scene-sequence— GSAP ScrollTrigger pin + scrub controlling horizontal scenesfade-scale-scroll-reveal— slow filmic enter (1.6 s, scale 1.08 → 1)film-grain-overlay— fixed SVGfeTurbulencelayer with mix-blend-mode overlay
Decisions
One Canvas per pattern, not one per page
Three.js can render multiple Canvases per page if each is hosted in its own <Canvas> instance.
We do exactly that — PhysicsHero owns one Canvas, each of the three RippleImage cards owns
its own. The cost is a few extra WebGL contexts, the gain is total isolation: the physics scene
can crash, recover, or get unmounted independently of the ripple shaders. For an installation
page where each piece is the demo, this is the right tradeoff.
WebGLCursorTrail is canvas-2D, not WebGL
A proper GPU FBO-pingpong cursor trail is the textbook lusion.co implementation. We did not ship
one. The 2D canvas version uses globalCompositeOperation: "lighter" plus radial gradients with
a per-frame translucent fill, which gives the same visual signature (additive trail with
exponential decay) at ~80 lines of code instead of ~400. The pattern is named WebGL because it's
the family it belongs to in the library; the implementation is honest about its medium. Reviewer
who wants the full shader can wire one up later.
Pin + scrub, not scroll-snap
The scene sequence pins the section for 300vh of scroll and scrubs a xPercent translate
across three child scenes. Scroll-snap would do this with less code but loses the connection
between scroll velocity and translate velocity — feeling synthetic. The scrub-controlled version
matches the reader's actual scroll speed, which is the point: this chapter is meant to feel
motion-coupled.
Film grain is SVG, not a PNG
feTurbulence is GPU-evaluated and tiles forever without seams. A PNG noise tile would either
repeat visibly at high zoom or weigh kilobytes on every page. We composite the grain layer with
mix-blend-mode: overlay at opacity: 0.10, which lifts mid-tones without crushing shadows.
The reduced-motion media query hides the grain entirely (it doesn't animate, but it does flicker
perceptually for some readers).
Camera rig follows pointer with a damped lerp
PhysicsHero's CameraRig reads pointer from R3F's frame state and lerps the camera's
position toward pointer.x * 1.5, 3 + pointer.y * 0.6. The 0.05 lerp factor is the magic
number — anything higher feels twitchy, anything lower feels broken. The camera also calls
lookAt(0, 1.2, 0) every frame so it stays focused on the floor regardless of where it's
panned to.
Ripple shader uses a coarse planeGeometry(64, 64)
Higher subdivision = smoother distortion at the cost of geometry. 64 × 64 is the floor below which the wave looks blocky. We tried 128 × 128 — visually identical, twice the geometry. Stayed at 64.
Tradeoffs rejected
- A custom post-processing pipeline (EffectComposer + UnrealBloom) on the physics hero. Beautiful but adds 100 KB and a render pass per frame. Not justified for a chapter-bound effect.
- Rapier's instance physics for many small spheres. Tried 30 spheres; performance held but the visual got chaotic. Five is the readable count.
- HDR environment map for
<Environment>. Drei'spreset="city"ships a pre-baked HDR that's 1 KB on the wire. A custom HDR would have been 1–4 MB. - Lottie-driven scene transitions. Considered for the scene sequence; rejected because the chapter is already a heavy bundle and Lottie would add another 60 KB for an effect we can do with GSAP.
Performance notes
- Canvas chapter total client JS is around 320 KB gzipped — heavy by general standards, modest
for a WebGL chapter. Code-split to
/canvasonly. PhysicsHeroruns at 60 fps on M1; on a 2019 MacBook Air it dropped to ~45 fps with all five spheres in motion. Acceptable.RippleImagemounts only when its containingFadeScaleRevealenters the viewport, which defers the texture load. TheuseLoaderhook caches each texture; if you swap to the same image elsewhere, it's reused.- The cursor trail is bounded —
maxPoints: 60andpointSize: 18cap the per-frame work at ~60 radial-gradient fills, which the GPU eats.
What I'd change in production
- Wrap
PhysicsHeroindynamic(() => …, { ssr: false })to avoid serialising the Canvas tree on the server. Currently we rely on"use client"at the component level; explicit dynamic import is cleaner. - Replace the canvas-2D cursor trail with a real WebGL FBO pingpong implementation as a v2 of the pattern, with this version preserved as the lightweight fallback.
- Add
prefers-reduced-datachecks to bypass the physics hero entirely on metered connections — show a still poster instead. - Bake a small
.glbof the physics scene's lighting setup so we can drop theEnvironmentpreset entirely on slow connections.