Skip to content

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

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

Performance notes

What I'd change in production

  1. Wrap PhysicsHero in dynamic(() => …, { 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.
  2. 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.
  3. Add prefers-reduced-data checks to bypass the physics hero entirely on metered connections — show a still poster instead.
  4. Bake a small .glb of the physics scene's lighting setup so we can drop the Environment preset entirely on slow connections.