Skip to content

Reference · motion

3D scenes: scroll-linked WebGL that stays fast

motion/references/3d-scenes.md320 linesupdated 16 Sept 2026

Tier T4 in the motion effect tiers. Use at most one scene per page, as the page's single signature effect. Measured 2026-09-17 on Next 16.3.5 with the void CLI (research: ~/code/sandbox/void/ui/v3/3d-web.md).

Hard rules

  1. Library: OGL + procedural geometry only, for a scroll-linked marketing hero. Never three.js for this use case (131KB+ gzip vs OGL's 14KB, for zero measured smoothness benefit). Never react-three-fiber/drei for a single hero (adds 100–140KB of reconciler glue on top of three itself — same cost/benefit failure the project already flags in React Bits' Silk/Hyperspeed/LiquidEther). Never Spline (~520KB+ gzip floor before any scene loads, proprietary license). Never model-viewer for a lightweight hero (140–282KB for a web component).
  2. Zero assets by default. Hand-author geometry + shader displacement. A GLB is a last resort, budgeted ≤150KB after gltf-transform meshopt+quantize+KTX2, sourced CC0 (Poly Haven/Kenney).
  3. Budget math, stated explicitly in the PR: Next framework ≈135KB gzip + OGL scene chunk ≈15KB gzip ≈ 150–160KB total — fits the ≤170KB marketing gate. Any three.js-class approach (≈267KB+) fails the gate outright; don't ship it without an explicit, owner-approved budget exception.
  4. Loading: next/dynamic(..., { ssr: false }), gated by requestIdleCallback and/or IntersectionObserver so the scene chunk never contends with LCP; skip entirely under prefers-reduced-motion: reduce and navigator.hardwareConcurrency <= 4. Don't rely on navigator.connection/deviceMemory as a sole gate — both are Chromium-only and silently no-op on all of Safari.
  5. DPR ≤ 1.5, 1 for a full-viewport backdrop. FPS capped 30–60 (measured with 45). Resize measured via ResizeObserver, never per-frame getBoundingClientRect.
  6. Scroll-linking: one passive { passive: true } scroll listener that only writes scrollY into a ref; all math (progress, damping/lerp) happens once per rAF tick, never in the scroll handler. Native scroll is never hijacked/pinned by JS.
  7. Uniforms via refs, never rebuild the GL program on prop/scroll change.
  8. Pause = cancel, never skip. cancelAnimationFrame (not a no-op draw) on document.hidden, when the canvas leaves the viewport, and under reduced motion. A position: fixed; inset: 0 backdrop never leaves the viewport, so also render on demand: stop the loop once damped values settle and restart on scroll, pointer or resize. Don't pause on user inactivity while motion is still settling (webgl-canvas.md rule 3).
  9. Cleanup: dispose geometry/program/renderer, WEBGL_lose_context.loseContext(), remove all listeners, on unmount. Handle webglcontextlost/restored.
  10. Reduced motion → exactly one static frame at current scroll progress, then stop. Never hide content.
  11. A11y: aria-hidden="true" pointer-events-none on the canvas (decorative); a poster/CSS-gradient fallback is always painted underneath so content and LCP never depend on WebGL succeeding.
  12. One WebGL context per page, never instantiated inside .map().
  13. Skip WebGPU/TSL for now — measured +84% bundle cost (241KB vs 131KB gzip for an identical scene) for zero visual gain on a simple scroll-linked object. Revisit only for compute-heavy particle work that WebGL genuinely can't do.
  14. Skip @react-three/offscreen — unmaintained (0.0.8, last published 2023), unresolved Safari worker-WebGL rough edges. Not needed: a capped-rAF, DPR-limited OGL scene is already cheap enough for the main thread that OffscreenCanvas buys little.

Decision

Use OGL (Unlicense, ~14KB gzip) with hand-authored procedural geometry — never a GLTF fetch, never three.js, never react-three-fiber, never Spline — for a scroll-linked marketing hero. This is T4 in void's existing tier table, and it is the only WebGL library option that fits inside the site's JS budget at all:

Approach Measured gzip (scene code only) Real first-load total (with Next framework) Fits ≤170KB gate?
OGL + procedural geometry ~14–15.4 KB ~151–159 KB Yes, with room to spare
three.js (tree-shaken, no loaders) ~131–132 KB ~266–274 KB No — 1.6× over gate
three + GLTFLoader/Draco/meshopt ~151–162 KB ~285–300 KB No
react-three-fiber + three (no drei) ~237–243 KB ~370–380 KB No
r3f + drei (2 helpers) ~262–269 KB ~395–405 KB No
three/webgpu (WebGPURenderer) ~241 KB (+84% vs WebGL for identical scene) ~375 KB No, and no visual gain
@splinetool/runtime ~520 KB+ floor before any scene asset loads; proprietary license n/a No, disqualified outright
@google/model-viewer ~140–282 KB (ESM vs UMD) ~275–415 KB No
raw WebGL2, no library ~0.7 KB ~136 KB Yes (but you write everything by hand)

Loading strategy: next/dynamic(..., { ssr: false }) behind IntersectionObserver + requestIdleCallback, skipped entirely under prefers-reduced-motion: reduce and on navigator.hardwareConcurrency <= 4 (the only cross-browser-reliable device-capability signal — navigator.connection/deviceMemory are Chromium-only and silently no-op on all of Safari). A static CSS-gradient (or server-rendered AVIF/SVG) poster is always in the DOM underneath the canvas so LCP and content are never gated on WebGL.

Asset budget: zero assets. Procedural geometry + a custom vertex/fragment shader (displacement + normal shading) beats any GLB fetch on cost, and avoids the whole gltf-transform pipeline for a simple hero shape. If a real model is ever needed, budget ≤150KB GLB after gltf-transform meshopt + quantize + uastc/etc1s KTX2 textures (commands below), and prefer Poly Haven/Kenney CC0 sources.


Measured library costs (2026-09-17, bun 1.2.20 + esbuild 0.28.2, minify + gzip -9)

Approach Package @ version Gzip KB Notes
raw WebGL2 (no lib) 0.67 KB hand-written shader setup; size floor
OGL minimal ogl@1.0.11 13.75–14.0 KB Renderer/Camera/Transform/Program/Mesh/Geometry — matches the design system's existing "ogl 10–14KB" figure
three minimal three@0.186.0 131.0–131.9 KB Scene/Camera/Renderer/Mesh/Geometry/StandardMaterial/light, tree-shaken named imports, no loaders
three + GLTFLoader three@0.186.0 151.3–152.5 KB +~20KB over minimal
three + DRACOLoader + MeshoptDecoder three@0.186.0 160.8–162.0 KB +~10KB glue; decoder .wasm (Draco ~300KB+ raw) fetched separately only if a Draco asset loads
three/webgpu (WebGPURenderer) three@0.186.0 241.0 KB +84% vs WebGL path for an identical simple scene
r3f + three (no drei) @react-three/fiber@9.7.0 237.5–243.2 KB reconciler/scheduler shim adds ~106–112KB over bare three (react/react-dom assumed already on page)
r3f + drei (2 helpers) @react-three/drei@10.7.8 262.3–268.6 KB untree-shaken drei alone is ~500KB gzip — the same "import everything" trap the project already flags in React Bits

Tree-shaking three barely helps once WebGLRenderer is pulled in: bundlephobia's full (non-tree-shaken) three@0.186.0 is 184.9KB gzip, and a minimal named-import scene still lands at 131KB — the renderer's internal shader-chunk/material system is the dominant, largely unavoidable cost.

Spline (@splinetool/runtime@2.0.54, no OSS license): the entry chunk alone is 36.5KB gzip, but it has 55 static (non-lazy) imports pulling ~484KB more gzip before anything renders — ~520KB gzip floor, confirmed via npm pack. Optional feature chunks (hana-ui GUI 1.07MB gzip, physics.wasm 573KB gzip) are lazy, but the floor alone disqualifies it.

model-viewer (@google/model-viewer@4.3.1, Apache-2.0): 139.68KB gzip (ESM) / 281.78KB gzip (UMD) for the web component alone.

Licenses

Package Version License
ogl 1.0.11 Unlicense
three 0.186.0 MIT
@react-three/fiber 9.7.0 MIT
@react-three/drei 10.7.8 MIT
@react-three/offscreen 0.0.8 MIT, but last published 2023-05-11 — 3+ years stale, unmaintained
@splinetool/runtime 2.0.54 proprietary
@google/model-viewer 4.3.1 Apache-2.0
meshoptimizer 1.2.0 MIT
draco3dgltf 1.5.7 Apache-2.0

SceneCanvas (client wrapper: poster, lazy import, impl-agnostic):

tsx
// src/components/scene-canvas.tsx
"use client";

import dynamic from "next/dynamic";

const SceneGL = dynamic(() => import("./scene-gl"), { ssr: false });

/** Fixed full-viewport 3D backdrop. Poster (CSS gradient here; swap for a
 *  server-rendered AVIF/SVG matching the shader's average color in production)
 *  is always painted first so LCP/content never depend on WebGL. */
export function SceneCanvas() {
  return (
    <div aria-hidden="true" className="pointer-events-none fixed inset-0 -z-10 isolate">
      <div
        className="absolute inset-0"
        style={{ background: "radial-gradient(60% 60% at 50% 40%, #23264a 0%, #0c0d14 55%, #08090c 100%)" }}
      />
      <SceneGL />
    </div>
  );
}
// Mount once, e.g. in the marketing route's layout — gate the import itself
// (not just ssr:false) behind requestIdleCallback/IntersectionObserver if the
// hero isn't the very first thing in the viewport.

Shared scroll driver — one passive listener, math done once per rAF tick, never in the scroll handler:

ts
// src/lib/scroll-progress.ts
export type ScrollState = { y: number };
const state: ScrollState = { y: 0 };
let listenerCount = 0;

function onScroll() { state.y = window.scrollY; }

export function subscribeScroll(): ScrollState {
  if (typeof window === "undefined") return state;
  if (listenerCount === 0) {
    state.y = window.scrollY;
    window.addEventListener("scroll", onScroll, { passive: true });
  }
  listenerCount++;
  return state;
}

export function unsubscribeScroll() {
  listenerCount = Math.max(0, listenerCount - 1);
  if (listenerCount === 0 && typeof window !== "undefined") window.removeEventListener("scroll", onScroll);
}

export function getScrollProgress(y: number): number {
  const max = document.documentElement.scrollHeight - window.innerHeight;
  return max <= 0 ? 0 : Math.min(1, Math.max(0, y / max));
}

SceneGL — the OGL scene (measured ~15.4KB gzip as a lazy chunk). This is the reference implementation: DPR cap, fps cap, IO + visibility + reduced-motion pause, context loss, full dispose, no per-frame allocation, uniforms updated via refs:

tsx
// src/components/scene-gl.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { Camera, Color, Mesh, Program, Renderer, Torus, Transform } from "ogl";
import { getScrollProgress, subscribeScroll, unsubscribeScroll } from "@/lib/scroll-progress";

const TARGET_FPS = 45;                         // 30-60fps range per webgl-canvas.md
const FRAME_INTERVAL_MS = 1000 / TARGET_FPS;
const MAX_DPR = 1;                              // 1 for full-viewport backdrops; ≤1.5 for smaller hero stages
const DAMPING = 0.08;

const vertex = /* glsl */ `
  attribute vec3 position;
  attribute vec3 normal;
  uniform mat4 modelViewMatrix;
  uniform mat4 projectionMatrix;
  uniform mat3 normalMatrix;
  uniform float uTime;
  uniform float uProgress;
  varying vec3 vNormal;
  varying float vDisp;
  void main() {
    vec3 pos = position;
    float wave = sin(pos.x * 4.0 + uTime * 0.6) * cos(pos.y * 4.0 + uTime * 0.4 + uProgress * 6.28318);
    float disp = wave * (0.05 + uProgress * 0.05);
    pos += normal * disp;
    vDisp = disp;
    vNormal = normalize(normalMatrix * normal);
    gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
  }
`;

const fragment = /* glsl */ `
  precision highp float;
  uniform vec3 uColorA;
  uniform vec3 uColorB;
  uniform float uProgress;
  varying vec3 vNormal;
  varying float vDisp;
  void main() {
    vec3 light = normalize(vec3(0.4, 0.6, 0.8));
    float diff = max(dot(normalize(vNormal), light), 0.0);
    vec3 base = mix(uColorA, uColorB, uProgress);
    gl_FragColor = vec4(base * (0.35 + diff * 0.85) + vDisp * 1.6, 1.0);
  }
`;

export default function SceneGL() {
  const wrapperRef = useRef<HTMLDivElement | null>(null);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const [ready, setReady] = useState(false);

  useEffect(() => {
    const wrapper = wrapperRef.current;
    const canvas = canvasRef.current;
    if (!wrapper || !canvas) return;

    const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
    const renderer = new Renderer({ canvas, dpr: Math.min(window.devicePixelRatio || 1, MAX_DPR), alpha: false, antialias: true, powerPreference: "low-power" });
    const gl = renderer.gl;
    gl.clearColor(0.03, 0.035, 0.047, 1);

    const camera = new Camera(gl, { fov: 35 });
    camera.position.set(0, 0, 6);
    const scene = new Transform();
    const geometry = new Torus(gl, { radius: 1.1, tube: 0.42, radialSegments: 48, tubularSegments: 96 });
    const program = new Program(gl, {
      vertex, fragment,
      uniforms: { uTime: { value: 0 }, uProgress: { value: 0 }, uColorA: { value: new Color("#7c9cff") }, uColorB: { value: new Color("#ff8fd6") } },
    });
    const mesh = new Mesh(gl, { geometry, program });
    mesh.setParent(scene);

    const resize = (w: number, h: number) => { renderer.setSize(w, h); camera.perspective({ aspect: w / h }); };
    resize(window.innerWidth, window.innerHeight);
    const resizeObserver = new ResizeObserver(([entry]) => {
      if (!entry) return;
      const { width, height } = entry.contentRect;
      if (width > 0 && height > 0) resize(width, height);
    });
    resizeObserver.observe(wrapper);                       // measured on resize only, never per-frame getBoundingClientRect

    const scrollState = subscribeScroll();
    let dampedProgress = getScrollProgress(scrollState.y);

    let isIntersecting = true;
    const io = new IntersectionObserver(([entry]) => { isIntersecting = entry?.isIntersecting ?? true; syncLoop(); }, { threshold: 0 });
    io.observe(wrapper);
    const onVisibility = () => syncLoop();
    document.addEventListener("visibilitychange", onVisibility);

    let rafId: number | null = null;
    let lastFrameTime = 0;

    function renderFrame(now: number) {
      const targetProgress = getScrollProgress(scrollState.y);
      dampedProgress += (targetProgress - dampedProgress) * DAMPING;   // damped, not scroll-jacked — native scroll untouched
      program.uniforms.uTime.value = now / 1000;                       // uniforms via refs, never rebuild the program
      program.uniforms.uProgress.value = dampedProgress;
      mesh.rotation.y = dampedProgress * Math.PI * 3 + now * 0.00008;
      mesh.rotation.x = dampedProgress * Math.PI * 0.6;
      mesh.position.y = (dampedProgress - 0.5) * 1.4;
      const scale = 0.85 + dampedProgress * 0.5;
      mesh.scale.set(scale, scale, scale);
      renderer.render({ scene, camera });
    }

    function loop(now: number) {
      rafId = requestAnimationFrame(loop);
      if (now - lastFrameTime < FRAME_INTERVAL_MS) return;             // fps cap
      lastFrameTime = now;
      renderFrame(now);
    }
    function startLoop() { if (rafId === null) rafId = requestAnimationFrame(loop); }
    function stopLoop() { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } }  // cancel, never "skip the draw"
    function syncLoop() {
      const shouldRun = isIntersecting && !document.hidden && !reducedMotionQuery.matches;
      shouldRun ? startLoop() : stopLoop();
    }
    const onReducedMotionChange = () => { if (reducedMotionQuery.matches) { stopLoop(); renderFrame(performance.now()); } else syncLoop(); };
    reducedMotionQuery.addEventListener("change", onReducedMotionChange);

    if (reducedMotionQuery.matches) { renderFrame(performance.now()); } else { renderFrame(performance.now()); syncLoop(); }  // one static frame under RM, then stop
    setReady(true);

    const onContextLost = (e: Event) => { e.preventDefault(); stopLoop(); };
    const onContextRestored = () => syncLoop();
    canvas.addEventListener("webglcontextlost", onContextLost, false);
    canvas.addEventListener("webglcontextrestored", onContextRestored, false);

    return () => {
      stopLoop();
      resizeObserver.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
      reducedMotionQuery.removeEventListener("change", onReducedMotionChange);
      canvas.removeEventListener("webglcontextlost", onContextLost);
      canvas.removeEventListener("webglcontextrestored", onContextRestored);
      unsubscribeScroll();
      geometry.remove();
      program.remove();
      (renderer.getExtension("WEBGL_lose_context") as WEBGL_lose_context | null)?.loseContext();
    };
  }, []);

  return (
    <div ref={wrapperRef} className="absolute inset-0">
      <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none h-full w-full transition-opacity duration-500" style={{ opacity: ready ? 1 : 0 }} />
    </div>
  );
}

Full prototype (both OGL and three.js variants, plus the 5-section test page): /home/parth/code/sandbox/void/ui/v3/3d-scratch/.


Asset pipeline (only if a GLB is ever actually needed)

Prefer procedural geometry (above) — zero network requests, zero decoder wasm. If a real model is required:

bash
gltf-transform inspect model.glb                                        # geometry- vs texture-heavy?
gltf-transform meshopt model.glb model.opt.glb                          # geometry/morph/animation compression, lighter decoder than Draco
gltf-transform draco model.glb model.opt.glb                            # alternative: Draco geometry compression
gltf-transform quantize model.opt.glb model.opt.glb                     # quantize attribute precision
gltf-transform uastc model.opt.glb model.final.glb                      # textures → KTX2/Basis (uastc = quality, etc1s = smaller)
# or the one-shot bundle:
gltf-transform optimize model.glb model.final.glb --compress meshopt --texture-compress webp

Below ~1MB of geometry, WASM decoder overhead can outweigh savings — meshopt's decoder is much lighter than Draco's, prefer it for small hero objects. Target ≤150KB final GLB. CC0 sources: Poly Haven (polyhaven.com/models) and Kenney (kenney.nl/assets?q=3d) — both no-login, CC0.