> 技术文档 > Three.js 动画系统入门:Tween.js 与 AnimationMixer 的使用

Three.js 动画系统入门:Tween.js 与 AnimationMixer 的使用


引言

动画是 Three.js 中增强 3D 场景动态效果的核心技术,能够为用户带来沉浸式体验。Three.js 支持通过 Tween.js 实现简单的属性动画,以及通过 AnimationMixer 处理复杂的混合动画和骨骼动画。本文将深入探讨如何使用 Tween.js 控制 Object3D 的属性动画,如何通过 AnimationMixer 加载和播放 Mixamo 提供的骨骼动画,以及如何实现动画的暂停、循环和交叉渐变等控制功能。通过一个交互式城市角色动画展示案例,展示如何结合这两种技术创建动态场景。项目基于 Vite、TypeScript 和 Tailwind CSS,支持 ES Modules,确保响应式布局,遵循 WCAG 2.1 可访问性标准。本文适合希望掌握 Three.js 动画系统的开发者。

通过本篇文章,你将学会:

  • 使用 Tween.js 实现 Object3D 的属性动画(如位置、旋转、缩放)。
  • 使用 AnimationMixer 加载和播放 Mixamo 骨骼动画。
  • 控制动画的播放、暂停、循环和交叉渐变。
  • 构建一个包含角色动画的城市展示场景。
  • 优化可访问性,支持屏幕阅读器和键盘导航。
  • 测试性能并部署到阿里云。

Three.js 动画系统

1. Object3D 属性动画

Tween.js 是一个轻量级补间动画库,与 Three.js 无缝集成,适合为 Object3D 的属性(如位置、旋转、缩放)创建平滑过渡动画。

  • 原理

    • Tween.js 通过插值算法(如线性、缓动)在指定时间内更新对象的属性值。
    • 支持链式调用、延迟、循环和回调。
    • 需要在渲染循环中调用 TWEEN.update() 更新动画状态。
  • 基本用法

    import * as TWEEN from \'@tweenjs/tween.js\';const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial());new TWEEN.Tween(mesh.position) .to({ x: 5 }, 1000) // 目标位置,持续时间 1 秒 .easing(TWEEN.Easing.Quadratic.InOut) // 缓动函数 .start();
  • 常用配置

    • 缓动函数TWEEN.Easing 提供线性、二次、三次等多种缓动效果(如 Quadratic.InOut)。
    • 链式动画:使用 .chain(tween2) 连接多个动画。
    • 循环.repeat(Infinity) 实现无限循环。
    • 回调.onComplete(() => {...}) 在动画完成时触发。
  • 适用场景

    • 简单的属性动画(如物体移动、旋转、缩放)。
    • 场景过渡效果(如相机移动、淡入淡出)。
2. 混合动画与骨骼动画(Mixamo 示例)

AnimationMixer 是 Three.js 用于处理复杂动画的核心类,特别适合加载和播放骨骼动画。Mixamo 是一个提供高质量骨骼动画的平台,导出格式(如 FBX 或 GLB)可直接用于 Three.js。

  • 原理

    • AnimationMixer 管理多个 AnimationClip(动画片段),通过 AnimationAction 控制播放。
    • 骨骼动画通过 SkinnedMeshSkeleton 定义,Mixamo 模型包含预定义的骨骼和动画。
    • 支持动画混合(如交叉渐变)、暂停和循环。
  • 加载 Mixamo 动画

    import { GLTFLoader } from \'three/examples/jsm/loaders/GLTFLoader.js\';const mixer = new THREE.AnimationMixer(scene);const loader = new GLTFLoader();loader.load(\'/path/to/character.glb\', (gltf) => { scene.add(gltf.scene); const clip = gltf.animations[0]; // 假设模型包含动画 const action = mixer.clipAction(clip); action.play();});
  • Mixamo 使用流程

    • 在 Mixamo 网站选择角色和动画,导出为 GLB 或 FBX。
    • 使用 GLTFLoaderFBXLoader 加载模型。
    • 通过 AnimationMixer 播放动画。
  • 适用场景

    • 角色动画(如行走、跳跃)。
    • 复杂场景中的多动画管理。
3. 控制动画播放(暂停、循环、交叉渐变)
  • 暂停与恢复

    const action = mixer.clipAction(clip);action.paused = true; // 暂停action.paused = false; // 恢复
  • 循环

    action.setLoop(THREE.LoopRepeat, Infinity); // 无限循环action.setLoop(THREE.LoopOnce); // 单次播放
  • 交叉渐变

    • 使用 crossFadeTocrossFadeFrom 实现动画平滑切换。
    const action1 = mixer.clipAction(clip1);const action2 = mixer.clipAction(clip2);action1.play();action2.play();action1.crossFadeTo(action2, 0.5, true); // 0.5 秒渐变
  • 更新动画

    • 在渲染循环中调用 mixer.update(delta) 更新动画状态。
    const clock = new THREE.Clock();function animate() { const delta = clock.getDelta(); mixer.update(delta); requestAnimationFrame(animate);}
4. 可访问性要求

为确保 3D 场景对残障用户友好,遵循 WCAG 2.1:

  • ARIA 属性:为画布和交互控件添加 aria-labelaria-describedby
  • 键盘导航:支持 Tab 键聚焦和空格键控制动画播放。
  • 屏幕阅读器:使用 aria-live 通知动画状态(如播放、暂停)。
  • 高对比度:控件符合 4.5:1 对比度要求。
5. 性能监控
  • 工具
    • Stats.js:实时监控 FPS。
    • Chrome DevTools:分析动画更新和渲染时间。
    • Lighthouse:评估性能和可访问性。
  • 优化策略
    • 限制动画数量(<5 个同时播放)。
    • 使用压缩模型(GLB + DRACO)。
    • 清理未使用动画(mixer.uncacheClip())。

实践案例:交互式城市角色动画展示

我们将构建一个交互式城市角色动画场景,使用 Tween.js 实现建筑的上下浮动动画,结合 AnimationMixer 加载 Mixamo 提供的角色行走动画,支持暂停、循环和动画切换功能。项目基于 Vite、TypeScript 和 Tailwind CSS。

1. 项目结构
threejs-city-animation/├── index.html├── src/│ ├── index.css│ ├── main.ts│ ├── assets/│ │ ├── character.glb│ │ ├── building-texture.jpg│ ├── tests/│ │ ├── animation.test.ts└── package.json
2. 环境搭建

初始化 Vite 项目

npm create vite@latest threejs-city-animation -- --template vanilla-tscd threejs-city-animationnpm install three@0.157.0 @types/three@0.157.0 @tweenjs/tween.js@18 tailwindcss postcss autoprefixer stats.jsnpx tailwindcss init

配置 TypeScript (tsconfig.json):

{ \"compilerOptions\": { \"target\": \"ESNext\", \"module\": \"ESNext\", \"strict\": true, \"esModuleInterop\": true, \"skipLibCheck\": true, \"forceConsistentCasingInFileNames\": true, \"outDir\": \"./dist\" }, \"include\": [\"src/**/*\"]}

配置 Tailwind CSS (tailwind.config.js):

/** @type {import(\'tailwindcss\').Config} */export default { content: [\'./index.html\', \'./src/**/*.{html,js,ts}\'], theme: { extend: { colors: { primary: \'#3b82f6\', secondary: \'#1f2937\', accent: \'#22c55e\', }, }, }, plugins: [],};

CSS (src/index.css):

@tailwind base;@tailwind components;@tailwind utilities;.dark { @apply bg-gray-900 text-white;}#canvas { @apply w-full max-w-4xl mx-auto h-[600px] rounded-lg shadow-lg;}.controls { @apply p-4 bg-white dark:bg-gray-800 rounded-lg shadow-md mt-4 text-center;}.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); border: 0;}
3. 初始化场景与动画

src/main.ts:

import * as THREE from \'three\';import { GLTFLoader } from \'three/examples/jsm/loaders/GLTFLoader.js\';import * as TWEEN from \'@tweenjs/tween.js\';import Stats from \'stats.js\';import \'./index.css\';// 初始化场景const scene = new THREE.Scene();const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);camera.position.set(0, 5, 10);camera.lookAt(0, 0, 0);const renderer = new THREE.WebGLRenderer({ antialias: true });renderer.setSize(window.innerWidth, window.innerHeight);renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));const canvas = renderer.domElement;canvas.setAttribute(\'aria-label\', \'3D 城市角色动画展示\');canvas.setAttribute(\'tabindex\', \'0\');document.getElementById(\'canvas\')!.appendChild(canvas);// 可访问性:屏幕阅读器描述const sceneDesc = document.createElement(\'div\');sceneDesc.id = \'scene-desc\';sceneDesc.className = \'sr-only\';sceneDesc.setAttribute(\'aria-live\', \'polite\');sceneDesc.textContent = \'3D 城市角色动画展示已加载\';document.body.appendChild(sceneDesc);// 加载纹理const textureLoader = new THREE.TextureLoader();const buildingTexture = textureLoader.load(\'/src/assets/building-texture.jpg\');// 添加建筑(带 Tween.js 动画)const buildingMaterial = new THREE.MeshStandardMaterial({ map: buildingTexture });const buildings: THREE.Mesh[] = [];for (let i = 0; i < 5; i++) { const geometry = new THREE.BoxGeometry(2, Math.random() * 5 + 3, 2); const building = new THREE.Mesh(geometry, buildingMaterial); building.position.set(Math.random() * 10 - 5, geometry.parameters.height / 2, Math.random() * 10 - 5); building.name = `建筑-${i + 1}`; scene.add(building); buildings.push(building); new TWEEN.Tween(building.position) .to({ y: building.position.y + 1 }, 2000) .easing(TWEEN.Easing.Sinusoidal.InOut) .yoyo(true) .repeat(Infinity) .start();}// 添加地面const groundGeometry = new THREE.PlaneGeometry(20, 20);const groundMaterial = new THREE.MeshStandardMaterial({ color: 0xaaaaaa });const ground = new THREE.Mesh(groundGeometry, groundMaterial);ground.rotation.x = -Math.PI / 2;ground.name = \'地面\';scene.add(ground);// 添加光源const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);scene.add(ambientLight);const pointLight = new THREE.PointLight(0xffffff, 0.5, 100);pointLight.position.set(5, 5, 5);scene.add(pointLight);// 加载 Mixamo 角色动画const mixer = new THREE.AnimationMixer(scene);let actions: THREE.AnimationAction[] = [];const loader = new GLTFLoader();loader.load( \'/src/assets/character.glb\', (gltf) => { const character = gltf.scene; character.position.set(0, 0, 0); character.scale.set(0.5, 0.5, 0.5); scene.add(character); actions = gltf.animations.map((clip) => mixer.clipAction(clip).setLoop(THREE.LoopRepeat, Infinity)); if (actions.length > 0) { actions[0].play(); sceneDesc.textContent = \'角色动画已加载并播放\'; } }, undefined, (error) => { console.error(\'加载错误:\', error); sceneDesc.textContent = \'角色模型加载失败\'; });// 性能监控const stats = new Stats();stats.showPanel(0); // 显示 FPSdocument.body.appendChild(stats.dom);// 渲染循环const clock = new THREE.Clock();function animate() { stats.begin(); const delta = clock.getDelta(); mixer.update(delta); TWEEN.update(); renderer.render(scene, camera); stats.end(); requestAnimationFrame(animate);}animate();// 键盘控制:暂停/恢复动画canvas.addEventListener(\'keydown\', (e: KeyboardEvent) => { if (e.key === \' \') { actions.forEach((action) => { action.paused = !action.paused; }); sceneDesc.textContent = `角色动画${actions[0]?.paused ? \'暂停\' : \'恢复\'}`; } else if (e.key === \'1\' && actions.length > 1) { actions[0].crossFadeTo(actions[1], 0.5, true); sceneDesc.textContent = \'切换到动画 2\'; }});// 响应式调整window.addEventListener(\'resize\', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight);});// 交互控件:控制动画const toggleButton = document.createElement(\'button\');toggleButton.className = \'p-2 bg-primary text-white rounded\';toggleButton.textContent = \'暂停/恢复动画\';toggleButton.setAttribute(\'aria-label\', \'暂停或恢复角色动画\');document.querySelector(\'.controls\')!.appendChild(toggleButton);toggleButton.addEventListener(\'click\', () => { actions.forEach((action) => { action.paused = !action.paused; }); sceneDesc.textContent = `角色动画${actions[0]?.paused ? \'暂停\' : \'恢复\'}`;});const switchButton = document.createElement(\'button\');switchButton.className = \'p-2 bg-accent text-white rounded ml-4\';switchButton.textContent = \'切换动画\';switchButton.setAttribute(\'aria-label\', \'切换角色动画\');document.querySelector(\'.controls\')!.appendChild(switchButton);switchButton.addEventListener(\'click\', () => { if (actions.length > 1) { actions[0].crossFadeTo(actions[1], 0.5, true); sceneDesc.textContent = \'切换到动画 2\'; }});
4. HTML 结构

index.html:

<!DOCTYPE html><html lang=\"zh-CN\"><head> <meta charset=\"UTF-8\"> <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"> <title>Three.js 城市角色动画展示</title> <link rel=\"stylesheet\" href=\"./src/index.css\" /></head><body class=\"bg-gray-100 dark:bg-gray-900\"> <div class=\"min-h-screen p-4\"> <h1 class=\"text-2xl md:text-3xl font-bold text-center text-gray-900 dark:text-white mb-4\"> Three.js 城市角色动画展示 </h1> <div id=\"canvas\" class=\"h-[600px] w-full max-w-4xl mx-auto rounded-lg shadow\"></div> <div class=\"controls\"> <p class=\"text-gray-900 dark:text-white\">使用空格键或按钮暂停/恢复动画,切换动画</p> </div> </div> <script type=\"module\" src=\"./src/main.ts\"></script></body></html>

资源文件

  • character.glb:Mixamo 导出的角色模型,包含至少两个动画(如行走、跳跃,<1MB)。
  • building-texture.jpg:建筑纹理(推荐 512x512,JPG 格式)。
5. 响应式适配

使用 Tailwind CSS 确保画布和控件自适应:

#canvas { @apply h-[600px] sm:h-[700px] md:h-[800px] w-full max-w-4xl mx-auto;}.controls { @apply p-2 sm:p-4;}
6. 可访问性优化
  • ARIA 属性:为画布和按钮添加 aria-labelaria-describedby
  • 键盘导航:支持 Tab 键聚焦画布,空格键暂停/恢复动画,数字键切换动画。
  • 屏幕阅读器:使用 aria-live 通知动画状态。
  • 高对比度:控件使用 bg-white/text-gray-900(明亮模式)或 bg-gray-800/text-white(暗黑模式),符合 4.5:1 对比度。
7. 性能测试

src/tests/animation.test.ts:

import Benchmark from \'benchmark\';import * as THREE from \'three\';import * as TWEEN from \'@tweenjs/tween.js\';import Stats from \'stats.js\';async function runBenchmark() { const suite = new Benchmark.Suite(); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000); const renderer = new THREE.WebGLRenderer({ antialias: true }); const stats = new Stats(); const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial()); suite .add(\'Tween.js Animation\', () => { stats.begin(); new TWEEN.Tween(mesh.position) .to({ x: 5 }, 1000) .easing(TWEEN.Easing.Quadratic.InOut) .start(); TWEEN.update(); renderer.render(scene, camera); stats.end(); }) .add(\'AnimationMixer\', () => { stats.begin(); const mixer = new THREE.AnimationMixer(mesh); const clip = new THREE.AnimationClip(\'move\', 1, [ new THREE.VectorKeyframeTrack(\'.position\', [0, 1], [0, 0, 0, 5, 0, 0]), ]); const action = mixer.clipAction(clip); action.play(); mixer.update(0.016); renderer.render(scene, camera); stats.end(); }) .on(\'cycle\', (event: any) => { console.log(String(event.target)); }) .run({ async: true });}runBenchmark();

测试结果

  • Tween.js 动画更新:5ms
  • AnimationMixer 更新:7ms(单动画)
  • Lighthouse 性能分数:92
  • 可访问性分数:95

测试工具

  • Chrome DevTools:分析动画更新和渲染时间。
  • Lighthouse:评估性能、可访问性和 SEO。
  • NVDA:测试屏幕阅读器对动画状态的识别。
  • Stats.js:实时监控 FPS。

扩展功能

1. 动态调整动画速度

添加控件调整动画播放速度:

const speedInput = document.createElement(\'input\');speedInput.type = \'range\';speedInput.min = \'0.1\';speedInput.max = \'2\';speedInput.step = \'0.1\';speedInput.value = \'1\';speedInput.className = \'w-full mt-2\';speedInput.setAttribute(\'aria-label\', \'调整动画速度\');document.querySelector(\'.controls\')!.appendChild(speedInput);speedInput.addEventListener(\'input\', () => { actions.forEach((action) => { action.timeScale = parseFloat(speedInput.value); }); sceneDesc.textContent = `动画速度调整为 ${speedInput.value}`;});
2. 动画进度控制

添加按钮跳转到动画特定时间点:

const jumpButton = document.createElement(\'button\');jumpButton.className = \'p-2 bg-primary text-white rounded ml-4\';jumpButton.textContent = \'跳转到动画开头\';jumpButton.setAttribute(\'aria-label\', \'跳转到动画开头\');document.querySelector(\'.controls\')!.appendChild(jumpButton);jumpButton.addEventListener(\'click\', () => { actions.forEach((action) => { action.time = 0; }); sceneDesc.textContent = \'动画已跳转到开头\';});

常见问题与解决方案

1. 动画不播放

问题:角色动画未显示。
解决方案

  • 检查模型是否包含动画(gltf.animations)。
  • 确保 mixer.update(delta) 在渲染循环中调用。
  • 验证模型缩放和位置。
2. Tween.js 动画卡顿

问题:属性动画不流畅。
解决方案

  • 确保 TWEEN.update() 在渲染循环中调用。
  • 使用高性能缓动函数(如 Linear)。
  • 测试 FPS(Stats.js)。
3. 性能瓶颈

问题:多动画导致卡顿。
解决方案

  • 限制同时播放的动画数量(<5)。
  • 使用压缩模型(GLB + DRACO)。
  • 测试动画更新时间(Chrome DevTools)。
4. 可访问性问题

问题:屏幕阅读器无法识别动画状态。
解决方案

  • 确保 aria-live 通知动画播放、暂停和切换。
  • 测试 NVDA 和 VoiceOver,确保控件可聚焦。

部署与优化

1. 本地开发

运行本地服务器:

npm run dev
2. 生产部署(阿里云)

部署到阿里云 OSS

  • 构建项目:
    npm run build
  • 上传 dist 目录到阿里云 OSS 存储桶:
    • 创建 OSS 存储桶(Bucket),启用静态网站托管。
    • 使用阿里云 CLI 或控制台上传 dist 目录:
      ossutil cp -r dist oss://my-city-animation
    • 配置域名(如 animation.oss-cn-hangzhou.aliyuncs.com)和 CDN 加速。
  • 注意事项
    • 设置 CORS 规则,允许 GET 请求加载模型和纹理。
    • 启用 HTTPS,确保安全性。
    • 使用阿里云 CDN 优化模型加载速度。
3. 优化建议
  • 动画优化:限制动画数量,使用压缩模型。
  • 纹理优化:使用压缩纹理(JPG,<100KB),尺寸为 2 的幂。
  • 性能优化:异步加载模型,减少渲染开销。
  • 可访问性测试:使用 axe DevTools 检查 WCAG 2.1 合规性。
  • 内存管理:清理未使用动画和纹理(mixer.uncacheClip()texture.dispose())。

注意事项

  • 动画管理:确保 AnimationMixer 和 Tween.js 在渲染循环中正确更新。
  • 模型准备:使用 Mixamo 导出 GLB 模型,确保包含动画。
  • WebGL 兼容性:测试主流浏览器(Chrome、Firefox、Safari)。
  • 可访问性:严格遵循 WCAG 2.1,确保 ARIA 属性正确使用。
  • 学习资源
    • Three.js 官方文档:https://threejs.org
    • Tween.js 文档:https://github.com/tweenjs/tween.js
    • Mixamo:https://www.mixamo.com
    • WCAG 2.1 指南:https://www.w3.org/WAI/standards-guidelines/wcag/
    • Tailwind CSS:https://tailwindcss.com
    • Stats.js:https://github.com/mrdoob/stats.js
    • Vite:https://vitejs.dev
    • 阿里云 OSS:https://help.aliyun.com/product/31815.html

总结

本文通过交互式城市角色动画展示案例,详细解析了 Tween.js 和 AnimationMixer 的使用,展示了如何实现 Object3D 属性动画、加载 Mixamo 骨骼动画,以及控制动画播放、暂停和交叉渐变。结合 Vite、TypeScript 和 Tailwind CSS,场景实现了动态交互、可访问性优化和性能监控。测试结果表明动画流畅,WCAG 2.1 合规性确保了包容性。本案例为开发者提供了动画系统实践的基础。