How many WebGL Canvases does this site have? One. The hero portrait, the skills field, project previews, the backdrop and the dust — they all live inside a single R3F Canvas mounted once at layout level. This post covers the architecture behind that decision.
Why one Canvas?
A Canvas per section looks clean at first: every component manages its own scene. In practice every Canvas is a separate WebGL context — separate memory, separately compiled shaders, context limits on mobile and white flashes between sections. With one persistent Canvas the scene never dies; sections only change the scene's state.
Scroll flows from a single source
The chain is short and one-directional: Lenis smooths the scroll, ScrollTrigger syncs to Lenis, everything feeds off those two.
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));No component listens to native scroll events. Scroll progress is normalized (0..1) and written to shared state; the canvas side reads it every frame. DOM and WebGL look at the same number, so there is no sync drift.
Camera stops are data, not components
Where the camera goes is written in no component. choreography.ts holds a list of stops:
export const cameraStops: CameraStop[] = [
{ at: 0.0, position: [0, 0, 6], accent: "#58a6ff" }, // hero
{ at: 0.2, position: [0.7, -2.6, 6.5], accent: "#7c6cff" }, // about
// ...
];sampleChoreography(progress) interpolates smoothly between two stops; the camera rig only calls this sampler. Adding a section = adding a stop to the list. The sampler is length-agnostic, so no code changes.
Scroll windows
Every effect has a "window": the portrait decode lives between 0.10–0.19, the skills field in its own range. The windows live in the same config file. Each effect component computes its own intensity with smoothstep(start, end, progress) — sections know nothing about each other, yet they all dance on the same scale.
Lessons
- When choreography is data, rehearsal is cheap: tuning stops is a config change, not a code change; a trial-and-error loop takes seconds.
- The R3F trap — uniforms don't update through the memoized object: textures and values must always bind through
materialRef.current.uniforms. I learned this lesson three times. - No allocations inside
useFrame: vectors and colors are allocated once outside and reused every frame; GC pauses are the number-one enemy of scroll smoothness.