mcpField
T
threejs-animation

threejs-animation

github.com/iart-ai设计与 UI451.4k 次安装18 周前更新
代码仓库

关于 threejs-animation

这是一项使用 Three.js 为网页构建实时 3D 动态效果的技能,涵盖场景动画、相机移动、GLTF 动画片段、实例化、滚动联动和 React Three Fiber。也提供性能优化与 GPU 资源清理指导。

主要功能

  1. 01与帧率无关的动画
  2. 02播放和交叉淡化 GLTF 片段
  3. 03阻尼及滚动联动相机运动
  4. 04实例化渲染大量对象
  5. 05GPU 资源释放与泄漏检查

适用场景

  1. 01制作 3D 主视觉区动画
  2. 02播放并混合 GLTF 动画片段
  3. 03创建滚动驱动的相机移动
  4. 04管理 SPA 中的 GPU 资源

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/iart-ai/webgl-animation-skills --skill threejs-animation

安装后在 Claude Code 中输入/threejs-animation确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

README.md
references/
r3f-and-perf.md
resource-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

完成前:

  1. 画布确实完成渲染——不是空白画布,没有控制台/WebGL 错误,也没有 CDN 404。
  2. ?t=N 能冻结可复现的帧(相同的 N → 相同的像素;随机值已设定种子)。
  3. 已在开始/中间/结束时截屏——符合需求,没有裁切/NaN/黑屏。
  4. 如果嵌入 SPA,已完成资源释放且无泄漏(setAnimationLoop(null) + dispose;参见资源释放部分)。
  5. 遵循 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 映射到摄像机目标
限制 DPRsetPixelRatio(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 安全性。

评论

支持 Markdown