Joyco Engineering · GL Sync

Three.js views that follow the DOM

Local WebGPU canvases, a shared WebGL fallback, and one tracking system.

Demos

Concept

Each view pairs a regular HTML element with a Three.js scene and camera. With WebGPU, that element contains its own canvas. All views share one renderer and GPU device. When the renderer falls back to WebGL2, the same scenes draw into separate rectangles on one shared canvas.

Local view · cube rotates with scroll progress

The browser controls layout. The renderer follows each view’s size, keeping its camera framing intact as the page resizes. Content can extend beyond the view in 3D, but pixels stay inside its rectangle. Views outside the rendering area skip drawing until they return.

Create a ThreeDOM instance inside a React effect, then call its addViewmethod with the element’s ref. This example takes an initialized Three.js renderer, scene, and perspective camera; it owns the tracking setup and render loop.

"use client"

import { useEffect, useRef } from "react"
import { ThreeDOM } from "portalgl/three"
import type { WebGPURenderer, Scene, PerspectiveCamera } from "three/webgpu"

type Props = {
  renderer: WebGPURenderer // Already initialized with await renderer.init()
  scene: Scene
  camera: PerspectiveCamera
}

export default function TrackedView({ renderer, scene, camera }: Props) {
  const container = useRef<HTMLDivElement>(null)
  const element = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const views = new ThreeDOM({
      renderer, container: container.current!,
    })
    views.addView(element.current!, { scene, camera })

    renderer.setAnimationLoop(time => {
      views.advance(time)
      views.render()
    })

    return () => {
      renderer.setAnimationLoop(null)
      views.destroy()
    }
  }, [renderer, scene, camera])

  return (
    <div ref={container} className="relative">
      <div ref={element} className="h-64 w-full" />
    </div>
  )
}
Manual JavaScript: renderer setup

For an existing renderer, scene, and camera:

import { ThreeDOM } from "portalgl/three"

await renderer.init()
const views = new ThreeDOM({ renderer, container })
const view = views.addView(element, { scene, camera })
await views.prepare() // All sized views render in one batch.

renderer.setAnimationLoop(time => {
  views.advance(time)
  // Animate using view.tracked.rect and view.tracked.state.
  views.render()
})

Sticky content stays with its element

Scroll through the section below. Its sticky child pins in the viewport, and the cube continues animating with scroll progress. On WebGPU, the canvas moves and pins with the DOM itself. On WebGL2, tracking positions the view within the shared canvas.

Sticky view · the same scene on either backend

Compare the fallback

Open the Three.js Inspector using the FPS button, then choose Settings → Renderer → Force WebGL. The page reloads using one shared canvas. Turn the setting off to return to WebGPU when available. The view definitions and animations stay the same.

Press Alt + D to see the active presentation and toggle the grid. On WebGL2, the Dynamic Canvas controls also let you compare absolute and fixed positioning, overscan padding, and sticky prediction.

The shared canvas normally rides the page, with extra height above and below the viewport to cover fast scrolling. While a tracked element is pinned, it switches to fixed positioning. Sticky prediction can request that switch shortly before pinning; the scene still uses actual bounds. Local WebGPU canvases follow their hosts without those placement switches.

These are independent rectangular views. A scene that spans several elements or needs objects to cast shadows across them still belongs on a shared surface. The WebGL fallback also has a single DOM stacking position, so it cannot reproduce every CSS mask or overlap between views.

Outro view · an independent square camera frame

Preparing 3D views…