关于 threejs-animation
这是一项使用 Three.js 为网页构建实时 3D 动态效果的技能,涵盖场景动画、相机移动、GLTF 动画片段、实例化、滚动联动和 React Three Fiber。也提供性能优化与 GPU 资源清理指导。
主要功能
- 01与帧率无关的动画
- 02播放和交叉淡化 GLTF 片段
- 03阻尼及滚动联动相机运动
- 04实例化渲染大量对象
- 05GPU 资源释放与泄漏检查
适用场景
- 01制作 3D 主视觉区动画
- 02播放并混合 GLTF 动画片段
- 03创建滚动驱动的相机移动
- 04管理 SPA 中的 GPU 资源
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ iart-ai/ webgl-animation-skills --skill threejs-animation安装后在 Claude Code 中输入/threejs-animation确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
README.mdreferences/r3f-and-perf.mdresource-disposal.md详细介绍
Three.js 动效(3D / WebGL)
为 Web 构建实时 3D 动效:动画场景、相机移动、GLTF 播放、实例化、滚动联动 3D 和 React Three Fiber。针对流畅的 60fps 和干净的资源释放进行优化。
适用场景
- 3D 首屏区域、产品查看器、交互式或滚动联动的 3D 背景。
- 播放和混合 GLTF 动画片段。
- 高效渲染大量对象(粒子场、瓷砖、森林)。
- 相机飞行穿越和滚动驱动的相机移动。
最小场景 + 渲染循环
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);
});
使用 clock.getDelta() 使动效不受帧率影响(将速率乘以 dt),并使用 setAnimationLoop(支持 WebXR,并会在标签页失焦时暂停),而不是直接使用 requestAnimationFrame。
GLTF 动画片段
加载模型,通过 AnimationMixer 播放动画片段,并且每帧都要使用增量时间更新混合器。
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);
在两个动画片段之间平滑交叉淡化:
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
}
对于单次播放的动画片段(例如跳跃),设置 action.setLoop(THREE.LoopOnce); action.clampWhenFinished = true;,并监听 mixer.addEventListener('finished', ...)。
相机线性插值和滚动联动动效
平滑跟随相机使用指数插值,逐渐接近目标。帧率校正阻尼:
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);
滚动联动相机:将 scrollY / maxScroll(0→1)映射到相机沿路径的位置。滚动时更新目标位置,再通过阻尼平滑过渡。
addEventListener('scroll', () => {
const p = scrollY / (document.body.scrollHeight - innerHeight);
target.set(0, p * -10, 5 - p * 3); // move down and in
});
使用 THREE.CatmullRomCurve3 和 curve.getPointAt(p) 实现曲线飞行穿越效果。
使用实例化渲染大量对象
通过一次绘制调用渲染数千个相同网格。设置每个实例的矩阵(以及可选的颜色)。
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.
如果每帧都要更新,请设置 inst.instanceMatrix.setUsage(THREE.DynamicDrawUsage)。设置每个实例的颜色:inst.setColorAt(i, color),然后设置 inst.instanceColor.needsUpdate = true。
React Three Fiber(R3F)
使用 useFrame 驱动动画;它会接收 state 和 delta。
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 <mesh ref={ref}><icosahedronGeometry /><meshStandardMaterial /></mesh>;
}
export default () => (
<Canvas dpr={[1, 2]} camera={{ position: [0, 0, 5] }}>
<ambientLight intensity={0.4} /><directionalLight position={[5, 5, 5]} />
<Spinner />
</Canvas>
);
dpr={[1, 2]} 可限制像素比。使用 useGLTF(drei)加载模型,使用 useAnimations 播放动画片段。弹簧动画可使用 @react-spring/three;滚动效果可使用 drei 的 ScrollControls + useScroll。
性能和清理(至关重要)
- 限制像素比:
Math.min(devicePixelRatio, 2)。这是对视网膜屏幕和移动设备最有效的优化。 - 复用几何体和材质;使用
BufferGeometryUtils.mergeGeometries合并静态几何体。 - 优先使用 matcap 或烘焙光照,避免使用大量实时灯光;每个投射阴影的灯光开销都很大。
- 材质或自发光动效(脉冲自发光、位移)的视觉效果往往更好,成本也低于移动大量几何体。
清理与 GPU 内存(释放)
GPU 资源(几何体、材质、纹理、渲染目标)存储在 GPU 上,而非 JS 堆中。垃圾回收器会释放 JS 对象,但无法释放它们上传的 WebGL 缓冲区和纹理。如果不显式调用 .dispose(),每次挂载都会导致 VRAM 增长,直到上下文丢失(黑色画布,WebGL: CONTEXT_LOST_WEBGL)。这就是为什么 renderer.info.memory 会在多次挂载/卸载后持续增长。
释放会分配 GPU 内存的资源;无需处理其他资源:
- 需要释放:
geometry.dispose()(VBO)、material.dispose()(着色器程序——但不包括其纹理)、texture.dispose()(每个贴图槽位:map、normalMap、roughnessMap、envMap等)、renderTarget.dispose(),以及在最终清理时调用renderer.dispose()+renderer.forceContextLoss()。 - 无需释放:
Mesh/Group/Scene(移除后由 GC 回收)、Vector3/Matrix4/Color(普通 JS 对象)、灯光/摄像机(阴影贴图渲染目标除外)。 scene.remove(mesh)只会将对象从场景图中移除——不会释放 GPU 上的任何资源。几何体/材质/纹理会一直驻留,直到显式释放。
遍历并清理——释放加载的模型或整个场景时,必须处理材质数组,以及每种材质的所有纹理贴图:
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 卸载清理——以声明方式在 JSX 中创建的资源会在卸载时自动释放(R3F 会遍历附加的对象并调用 .dispose())。泄漏发生在命令式创建的资源上(在钩子/引用/加载器中使用 new THREE.X() 创建),因为 R3F 从未附加这些资源。使用 useMemo 创建它们,并在对应的 useEffect 清理函数中释放:
const geometry = useMemo(() => new THREE.BufferGeometry(/* … */), [count]);
useEffect(() => () => geometry.dispose(), [geometry]); // disposes on unmount AND dep change
对于跨多次挂载重复使用的共享资源,请在 JSX 对象上设置 dispose={null},并仅在应用关闭时释放。使用 renderer.info.memory(R3F:useThree().gl.info.memory)验证:挂载/卸载 10 次;如果 geometries/textures 未恢复到基线,说明存在资源泄漏。
有关完整的释放工具(所有贴图槽位、着色器 uniforms、渲染目标、蒙皮网格)、EffectComposer/渲染目标释放、renderer.info 泄漏测试工具、R3F 清理方案(引用、纹理、GLTF 缓存清理、<DisposeOnUnmount>)以及 HMR 安全性,请参阅 references/resource-disposal.md。
交付与验证(独立 HTML)
打包的辅助脚本(
scripts/):scripts/seek-shot.sh anim.html 0 1.5 3会冻结?t=N测试框架,并为每个时刻截屏;scripts/contact-sheet.sh sheet.png frame-*.png会将截图拼成一张图,便于快速查看。参见scripts/README.md。
对于自包含的 3D 场景(主视觉、WebGL 背景、微型场景),交付内容应为一个可在浏览器中直接打开的 HTML 文件——通过 importmap 从 CDN 加载 Three.js,使用一个渲染循环,无需构建步骤。对于场景而言,单文件就是合适的方案;一个文件能搞定时,不要使用打包工具。
输出约定:
- 一个
.html:importmap 将three和three/addons固定到 CDN;场景和渲染循环都放在一个内联的<script type="module">中。 - 所有运动都由同一个真值源驱动——
clock.getElapsedTime()(mixer/摄像机也从中读取)。不要使用Date.now(),也不要让各对象单独读取系统时钟。 - 随机值(实例位置、抖动)都使用固定种子,确保帧可复现。
跳转/冻结测试框架——在固定时刻渲染一帧以供截屏。 ?t=N 会将经过时间强制设为 N 秒,让场景前进至该时刻,渲染一次后停止循环——得到确定性的静帧。
<script type="module">
// ... build scene, camera, renderer, clock, mixer ...
const t = new URLSearchParams(location.search).get("t");
function frame(elapsed) { // pure: scene state is a function of elapsed
mesh.rotation.y = elapsed * 0.5;
if (mixer) { mixer.setTime(elapsed); } // setTime is absolute, not delta
renderer.render(scene, camera);
}
if (t !== null) {
frame(parseFloat(t)); // one fixed frame, no loop
window.__ready = true;
} else {
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => frame(clock.getElapsedTime()));
}
</script>
验证流程——渲染 → 冻结 → 截屏 → 检查:在三个时刻打开文件——开始、中间、结束(动画片段/循环分别使用 ?t=0、?t=<mid>、?t=<end>)——每个时刻截屏,并检查保真度(是否符合需求)和异常:黑色画布 = 解析/初始化错误(检查控制台)、几何体被裁切/移出画面、位置为 NaN(对象消失)、模型/纹理缺失(CDN 404)。WebGL 需要 GPU 上下文;Playwright/Chromium 无头模式会提供该上下文(swiftshader)。
npx playwright screenshot --wait-for-timeout=600 "file://$PWD/scene.html?t=1.5" frame-mid.png
完成前:
- 画布确实完成渲染——不是空白画布,没有控制台/WebGL 错误,也没有 CDN 404。
?t=N能冻结可复现的帧(相同的 N → 相同的像素;随机值已设定种子)。- 已在开始/中间/结束时截屏——符合需求,没有裁切/NaN/黑屏。
- 如果嵌入 SPA,已完成资源释放且无泄漏(
setAnimationLoop(null)+ dispose;参见资源释放部分)。 - 遵循
prefers-reduced-motion设置——在适用时减慢/停止旋转或自动播放。
快速参考
| 目标 | API |
|---|---|
| 与帧率无关的运动 | 乘以 clock.getDelta() |
| 播放 GLTF 片段 | mixer.clipAction(clip).play() + mixer.update(dt) |
| 混合片段 | from.crossFadeTo(to, dur, false) |
| 平滑摄像机 | lerp(goal, 1 - exp(-lambda*dt)) |
| 大量对象 | InstancedMesh + setMatrixAt |
| 滚动 3D 场景 | 将滚动位置 0→1 映射到摄像机目标 |
| 限制 DPR | setPixelRatio(min(dpr, 2)) |
参考文件
references/r3f-and-perf.md— 完整的 AnimationMixer 交叉淡入淡出和单次播放处理、useFrame 模式(时钟、指针、lerp)、drei ScrollControls 滚动联动摄像机、带颜色的逐帧 InstancedMesh 动画、像素比率和自适应分辨率、完整的卸载时释放例程,以及适用于原生 Three 和 R3F 的后期处理(UnrealBloom、DepthOfField/散景)。references/resource-disposal.md— 完整的 GPU 资源释放:完整释放工具(所有 map 插槽、着色器 uniforms、渲染目标、环境贴图、蒙皮网格)、EffectComposer/渲染目标拆除、完整的渲染器拆除、R3F 泄漏案例和 useMemo+cleanup 模式、ref/纹理/GLTF 清理方案、<DisposeOnUnmount>辅助组件、共享资源和 InstancedMesh 策略、renderer.info泄漏测试框架,以及 HMR 安全性。