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
- 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).
- 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). - 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.
- Loading:
next/dynamic(..., { ssr: false }), gated byrequestIdleCallbackand/or IntersectionObserver so the scene chunk never contends with LCP; skip entirely underprefers-reduced-motion: reduceandnavigator.hardwareConcurrency <= 4. Don't rely onnavigator.connection/deviceMemoryas a sole gate — both are Chromium-only and silently no-op on all of Safari. - DPR ≤ 1.5, 1 for a full-viewport backdrop. FPS capped 30–60 (measured with 45). Resize measured via
ResizeObserver, never per-framegetBoundingClientRect. - Scroll-linking: one passive
{ passive: true }scroll listener that only writesscrollYinto 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. - Uniforms via refs, never rebuild the GL program on prop/scroll change.
- Pause = cancel, never skip.
cancelAnimationFrame(not a no-op draw) ondocument.hidden, when the canvas leaves the viewport, and under reduced motion. Aposition: fixed; inset: 0backdrop 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.mdrule 3). - Cleanup: dispose geometry/program/renderer,
WEBGL_lose_context.loseContext(), remove all listeners, on unmount. Handlewebglcontextlost/restored. - Reduced motion → exactly one static frame at current scroll progress, then stop. Never hide content.
- A11y:
aria-hidden="true" pointer-events-noneon the canvas (decorative); a poster/CSS-gradient fallback is always painted underneath so content and LCP never depend on WebGL succeeding. - One WebGL context per page, never instantiated inside
.map(). - 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.
- 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 |
Recommended architecture — paste-ready Next 16 code
SceneCanvas (client wrapper: poster, lazy import, impl-agnostic):
// 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:
// 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:
// 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:
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 webpBelow ~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.