One canvas & multiple three.js scenes with Lit
Single-page sites with rich WebGL often need more than one Three.js scene — a hero canvas, an interactive letter-repulsion area, a spatial image viewer further down the page. The naive approach is one <canvas> + one WebGLRenderer per scene, but that multiplies GPU memory, driver overhead, and render-loop complexity. WebGPU renderers are particularly expensive to create.
This post walks through an alternative: a single shared renderer that composites many scenes, where each scene lives in its own Lit web component, registers itself via a reactive controller, and is rendered into the correct screen rectangle using scissor tests and camera view offsets.
Architecture overview
Three pieces coordinate:
| Piece | Responsibility |
|---|---|
scene-registry.ts | Singleton Set of {host, setupFn, drawFn} entries |
SceneController | Lit ReactiveController — registers on connect, unregisters + disposes on disconnect |
scene-canvas | Lit element that owns the shared WebGPURenderer, iterates the registry, and composites each scene into the correct screen rect every frame |
A scene component (e.g. <intro-scene>) declares its setupFn (async — loads GLTF, builds scene/camera) and drawFn (per-frame updates), then creates a SceneController in its constructor:
new SceneController(
{ host: this, setupFn, drawFn },
() => this.dispose()
);The controller calls sceneRegistry.register(entry) when the host connects, and sceneRegistry.unregister(entry) + the dispose callback when the host disconnects. This makes scenes fully self-contained — drop a <intro-scene> into any template and it auto-joins the shared render loop.
The shared renderer
<scene-canvas> renders a single <canvas> that fills the viewport (positioned absolutely and pinned with translateY(scrollY) so its buffer-space maps 1:1 to screen space). On mount it:
- Creates one
THREE.WebGPURendereragainst that canvas - Iterates
sceneRegistry.entries, scoping to the active Barba container for SPA navigation - Calls each entry's
setupFnto get{scene, camera}, then stores the entry - Sets an animation loop
Compositing with scissor + viewport
Each frame the loop:
- Clears the full canvas
- For each scene entry, computes the host element's
getBoundingClientRect()relative to the pinned canvas - Clips to in-bounds region (WebGPU rejects out-of-bounds viewports)
- Sets
renderer.setViewport(left, top, width, height)andrenderer.setScissor(left, top, width, height) - Calls
camera.setViewOffset(fullW, fullH, offsetX, offsetY, width, height)so the scene content renders at the correct alignment rather than being squashed into the clipped viewport
camera.setViewOffset(fullW, fullH, offsetX, offsetY, width, height);This is the key insight: setViewOffset repositions the camera frustum so that the portion of the scene visible through the scissor rect matches what you'd see if the scene had its own full-size renderer. Without it, clipping a perspective camera's output to a sub-rectangle would crop the center of the frame rather than revealing the correct edge-aligned content.
Both PerspectiveCamera and OrthographicCamera support this.
Visibility-based loop management
Running a render loop when no scenes are visible wastes battery and GPU. <scene-canvas> uses an IntersectionObserver on each scene's host element and a visibilitychange listener:
private updateLoopState() {
const shouldRun = this.visibleHosts.size > 0 && !document.hidden;
if (shouldRun && !this.running) {
this.running = true;
this.renderer.setAnimationLoop(this.frame);
} else if (!shouldRun && this.running) {
this.running = false;
this.pauseStartedAt = Date.now();
this.renderer.setAnimationLoop(null);
}
}Paused duration is accumulated into pausedAccumMs so that elapsed and delta remain continuous across pause/resume cycles — animations don't jump.
Barba.js SPA navigation
Since the site uses Barba.js for page transitions, both the old and new page can coexist in the DOM during a swap. To avoid the incoming canvas triggering async setup on outgoing scene hosts (which would re-fetch GLTFs and clobber render contexts), scene discovery is scoped:
const myContainer = this.closest('[data-barba="container"]');
sceneRegistry.entries.forEach((entry) => {
if (entry.host.closest('[data-barba="container"]') !== myContainer) return;
// ...
});This also means multiple <scene-canvas> elements on different pages share the same registry but only act on their own scenes.
Lifecycle and disposal
Each scene component owns a dispose callback that traverses its scene graph with disposeObject3D, removing geometries, materials, and textures from GPU memory. The SceneController calls it from hostDisconnected, which fires when the element is removed from the DOM (e.g. during a Barba page transition).
The canvas element itself disconnects the IntersectionObserver, stops the animation loop, clears its scene list, and calls renderer.dispose() to release the WebGPU device and swapchain.
Putting it together
The result is a system where:
- Components are fully independent — each
<intro-scene>or<about-scene>works in isolation, handles its own GLTF loading, mouse tracking, springs, and disposal - The renderer is a shared resource — one WebGPU device, one swapchain, one rAF loop
- Compositing is precise — scissor rects + camera view offsets produce pixel-perfect rendering regardless of layout
- Resource management is automatic — scenes stop rendering when offscreen and clean up memory when unmounted
This pattern is particularly well-suited to content sites that mix WebGL-heavy hero sections with traditional scrollable content. Each scene remains a decoupled, testable component, while the rendering stays efficient.