---
name: threejs-animation
description: This skill should be used when the user asks to "build a 3D hero section or WebGL background", "animate a Three.js scene", "play or crossfade GLTF animation clips", "move/lerp the camera or link it to scroll", "render thousands of objects with InstancedMesh", "set up React Three Fiber (R3F) motion", "add bloom/DOF post-processing", or "keep a Three.js/R3F animation smooth and leak-free (dispose geometries/materials/textures, free GPU memory on unmount)". Covers scenes, render loops, camera moves, GLTF AnimationMixer, instancing, scroll-linked 3D, R3F, and animation performance/disposal.
version: 0.1.0
---
# Three.js Motion (3D / WebGL)
Build real-time 3D motion for the web: animated scenes, camera moves, GLTF playback, instancing, scroll-linked 3D, and React Three Fiber. Optimize for smooth 60fps and clean disposal.
## When to use
- 3D hero sections, product viewers, interactive/scroll-linked 3D backgrounds.
- Playing and blending GLTF animation clips.
- Rendering many objects efficiently (particle fields, tiles, forests).
- Camera fly-throughs and scroll-driven camera moves.
## Minimal scene + render loop
```js
import * as THREE from 'three';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 0, 5);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); // cap DPR — critical for retina perf
document.body.appendChild(renderer.domElement);
const mesh = new THREE.Mesh(
new THREE.IcosahedronGeometry(1, 0),
new THREE.MeshStandardMaterial({ color: 0x44aaff, flatShading: true })
);
scene.add(mesh, new THREE.DirectionalLight(0xffffff, 2).translateZ(5),
new THREE.AmbientLight(0xffffff, 0.4));
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
const dt = clock.getDelta();
mesh.rotation.y += dt * 0.5; // frame-rate independent
renderer.render(scene, camera);
});
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
```
Use `clock.getDelta()` to make motion frame-rate independent (multiply rates by `dt`), and `setAnimationLoop` (works with WebXR and pauses on tab blur) instead of raw `requestAnimationFrame`.
## GLTF animation clips
Load a model, play clips through an `AnimationMixer`, and **update the mixer with delta time** every frame.
```js
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
let mixer, actions = {};
new GLTFLoader().load('model.glb', (gltf) => {
scene.add(gltf.scene);
mixer = new THREE.AnimationMixer(gltf.scene);
gltf.animations.forEach((clip) => { actions[clip.name] = mixer.clipAction(clip); });
actions['Idle']?.play();
});
// In the loop: if (mixer) mixer.update(dt);
```
Crossfade between two clips smoothly:
```js
function crossfade(fromName, toName, dur = 0.4) {
const from = actions[fromName], to = actions[toName];
to.reset().setEffectiveWeight(1).play();
from.crossFadeTo(to, dur, false); // warping=false: linear weight ramp
}
```
For one-shot clips (e.g. a jump), set `action.setLoop(THREE.LoopOnce); action.clampWhenFinished = true;` and listen for `mixer.addEventListener('finished', ...)`.
## Camera lerp and scroll-linked motion
Smooth camera follow uses exponential interpolation toward a target. Frame-rate-correct damping:
```js
const target = new THREE.Vector3(0, 0, 5);
function dampVec(current, goal, lambda, dt) {
current.lerp(goal, 1 - Math.exp(-lambda * dt)); // lambda ~ 3..8
}
// loop: dampVec(camera.position, target, 5, dt); camera.lookAt(0, 0, 0);
```
Scroll-linked camera: map `scrollY / maxScroll` (0→1) to camera position along a path. Update the goal on scroll, let damping smooth it.
```js
addEventListener('scroll', () => {
const p = scrollY / (document.body.scrollHeight - innerHeight);
target.set(0, p * -10, 5 - p * 3); // move down and in
});
```
Use `THREE.CatmullRomCurve3` and `curve.getPointAt(p)` for curved fly-throughs.
## Instancing for many objects
One draw call for thousands of identical meshes. Set per-instance matrices (and optional colors).
```js
const count = 5000;
const geo = new THREE.BoxGeometry(0.1, 0.1, 0.1);
const mat = new THREE.MeshStandardMaterial();
const inst = new THREE.InstancedMesh(geo, mat, count);
const dummy = new THREE.Object3D();
for (let i = 0; i < count; i++) {
dummy.position.set((Math.random()-0.5)*20, (Math.random()-0.5)*20, (Math.random()-0.5)*20);
dummy.updateMatrix();
inst.setMatrixAt(i, dummy.matrix);
}
inst.instanceMatrix.needsUpdate = true;
scene.add(inst);
// Animate: update dummy per instance, setMatrixAt, then needsUpdate = true each frame.
```
Set `inst.instanceMatrix.setUsage(THREE.DynamicDrawUsage)` if updating every frame. Per-instance color: `inst.setColorAt(i, color)` then `inst.instanceColor.needsUpdate = true`.
## React Three Fiber (R3F)
Drive animation with `useFrame`; it receives `state` and `delta`.
```jsx
import { Canvas, useFrame } from '@react-three/fiber';
import { useRef } from 'react';
function Spinner() {
const ref = useRef();
useFrame((state, delta) => {
ref.current.rotation.y += delta * 0.5;
ref.current.position.y = Math.sin(state.clock.elapsedTime) * 0.3;
});
return ;
}
export default () => (
);
```
`dpr={[1, 2]}` caps pixel ratio. Load models with `useGLTF` (drei), animate clips with `useAnimations`. For springs use `@react-spring/three`; for scroll use drei `ScrollControls` + `useScroll`.
## Performance and cleanup (critical)
- Cap pixel ratio: `Math.min(devicePixelRatio, 2)`. This is the single biggest win on retina/mobile.
- Reuse geometries and materials; merge static geometry with `BufferGeometryUtils.mergeGeometries`.
- Prefer matcaps or baked lighting over many real-time lights; each shadow-casting light is expensive.
- Material/emissive motion (pulsing emissive, displacement) often reads better and is cheaper than moving lots of geometry.
## Cleanup & GPU memory (disposal)
GPU resources (geometries, materials, textures, render targets) live on the *GPU*, not the JS heap. The garbage collector frees the JS objects but **cannot free the WebGL buffers/textures they uploaded**. Without explicit `.dispose()`, VRAM grows on every mount until the context is lost (black canvas, `WebGL: CONTEXT_LOST_WEBGL`). This is why `renderer.info.memory` keeps rising across mount/unmount cycles.
**Dispose what allocates GPU memory; ignore what does not:**
- Dispose: `geometry.dispose()` (VBOs), `material.dispose()` (shader program — but NOT its textures), `texture.dispose()` (per map slot: `map`, `normalMap`, `roughnessMap`, `envMap`, …), `renderTarget.dispose()`, and on final teardown `renderer.dispose()` + `renderer.forceContextLoss()`.
- No disposal needed: `Mesh`/`Group`/`Scene` (GC reclaims after removal), `Vector3`/`Matrix4`/`Color` (plain JS), lights/cameras (except shadow map render targets).
- `scene.remove(mesh)` only detaches from the graph — it frees **nothing** on the GPU. Geometry/material/textures stay resident until explicitly disposed.
**Teardown traversal** — disposing a loaded model or whole scene must handle material *arrays* and *every* texture map per material:
```js
function disposeObject(root) {
root.traverse((obj) => {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const m of mats) {
for (const key in m) { // dispose any texture property
const v = m[key];
if (v && v.isTexture) v.dispose();
}
m.dispose();
}
}
});
root.parent?.remove(root);
}
// SPA route teardown: renderer.setAnimationLoop(null); disposeObject(scene);
// renderer.dispose(); renderer.forceContextLoss?.();
```
**R3F unmount cleanup** — resources created **declaratively in JSX are auto-disposed** on unmount (R3F walks attached objects and calls `.dispose()`). **Leaks happen with imperative resources** (`new THREE.X()` in hooks/refs/loaders) that R3F never attached. Create them with `useMemo` and dispose in a matching `useEffect` cleanup:
```jsx
const geometry = useMemo(() => new THREE.BufferGeometry(/* … */), [count]);
useEffect(() => () => geometry.dispose(), [geometry]); // disposes on unmount AND dep change
```
For shared resources reused across mounts, set `dispose={null}` on the JSX object and dispose only at app shutdown. Verify with `renderer.info.memory` (R3F: `useThree().gl.info.memory`): mount/unmount 10x; if `geometries`/`textures` does not return to baseline, something leaked.
See `references/resource-disposal.md` for the full disposal utility (all map slots, shader uniforms, render targets, skinned meshes), EffectComposer/render-target disposal, a `renderer.info` leak-test harness, R3F cleanup recipes (refs, textures, GLTF cache clearing, ``), and HMR safety.
## Deliver & verify (standalone HTML)
> **Packaged helper** (`scripts/`): `scripts/seek-shot.sh anim.html 0 1.5 3` freezes the `?t=N` harness and screenshots each moment; `scripts/contact-sheet.sh sheet.png frame-*.png` tiles them for one-glance review. See `scripts/README.md`.
For a self-contained 3D scene (hero, WebGL background, micro-scene) the deliverable is **one HTML file that opens directly in a browser** — Three.js pulled from a CDN via an importmap, one render loop, no build step. A single file is the right tier for a scene; don't reach for a bundler when one file does the job.
**Output contract:**
- One `.html`: importmap pins `three` + `three/addons` to a CDN; your scene and the render loop in one inline `
```
**Verify loop — render → freeze → screenshot → check:** open the file at three instants — start, mid, end (`?t=0`, `?t=`, `?t=` for an animated clip/loop) — screenshot each, and check both **fidelity** (matches the brief) and **artifacts**: a **black canvas = parse/init error** (check the console), clipped/off-frame geometry, NaN positions (objects vanish), missing model/texture (CDN 404). WebGL needs a GPU context; Playwright/Chromium supplies one (swiftshader) headless.
```bash
npx playwright screenshot --wait-for-timeout=600 "file://$PWD/scene.html?t=1.5" frame-mid.png
```
**Before you finish:**
1. Canvas actually renders — not blank, no console/WebGL errors, no CDN 404s.
2. `?t=N` freezes a reproducible frame (same N → same pixels; randomness seeded).
3. Screenshotted at start / mid / end — matches the brief, no clipping/NaN/black.
4. Disposed and leak-free if embedded in an SPA (`setAnimationLoop(null)` + dispose; see disposal section).
5. `prefers-reduced-motion` honored — slow/halt rotation or auto-play where relevant.
## Quick reference
| Goal | API |
|------|-----|
| Frame-independent motion | multiply by `clock.getDelta()` |
| Play GLTF clip | `mixer.clipAction(clip).play()` + `mixer.update(dt)` |
| Blend clips | `from.crossFadeTo(to, dur, false)` |
| Smooth camera | `lerp(goal, 1 - exp(-lambda*dt))` |
| Many objects | `InstancedMesh` + `setMatrixAt` |
| Scroll 3D | map scroll 0→1 to camera target |
| Cap DPR | `setPixelRatio(min(dpr, 2))` |
## Reference files
- `references/r3f-and-perf.md` — Full AnimationMixer crossfade and one-shot handling, useFrame patterns (clock, pointer, lerp), drei ScrollControls scroll-linked camera, InstancedMesh per-frame animation with color, pixel-ratio and adaptive resolution, complete dispose-on-unmount routine, and postprocessing (UnrealBloom, DepthOfField/bokeh) for both vanilla Three and R3F.
- `references/resource-disposal.md` — Complete GPU resource disposal: full disposal utility (all map slots, shader uniforms, render targets, env maps, skinned meshes), EffectComposer/render-target teardown, full renderer teardown, R3F leak cases and the useMemo+cleanup pattern, ref/texture/GLTF cleanup recipes, `` helper, shared-resource and InstancedMesh strategies, `renderer.info` leak-test harness, and HMR safety.