动画基础
动画是 Three.js 开发的核心技能,掌握动画原理和实现方法可以创建生动的 3D 交互体验。
概述
Three.js 中的动画主要通过在渲染循环中不断更新对象属性来实现。本章节介绍动画的基本原理、实现方法和常用技术。
架构概览
code
Three.js 动画系统架构
├── 基础动画(手动控制)
│ ├── 渲染循环
│ │ ├── requestAnimationFrame
│ │ └── Clock 类
│ └── 属性动画
│ ├── 变换动画(位置、旋转、缩放)
│ ├── 材质动画(颜色、透明度)
│ └── 纹理动画(UV 偏移)
│
├── 关键帧动画系统
│ ├── AnimationMixer(动画混合器)
│ ├── AnimationClip(动画片段)
│ ├── AnimationAction(动画动作)
│ └── KeyframeTrack(关键帧轨道)
│
└── 高级动画技术
├── 骨骼动画(Skeletal Animation)
├── 变形动画(Morph Target Animation)
└── 粒子动画渲染循环
requestAnimationFrame
创建平滑动画的基础。浏览器会在下一次重绘之前调用指定的回调函数,通常每秒 60 次(60 FPS)。
javascript
import * as THREE from 'three';
// 场景、相机、渲染器
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// 创建立方体
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
// 动画循环
function animate() {
requestAnimationFrame(animate);
// 更新对象
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
// 渲染场景
renderer.render(scene, camera);
}
animate();TIP
requestAnimationFrame 会自动适配显示器的刷新率,并且当页面不可见时会自动暂停,节省资源。
时间控制
javascript
// 使用时间控制动画速度
let previousTime = 0;
function animate(currentTime) {
requestAnimationFrame(animate);
// 计算时间差(秒)
const deltaTime = (currentTime - previousTime) / 1000;
previousTime = currentTime;
// 使用 deltaTime 控制动画速度
cube.rotation.x += deltaTime;
cube.rotation.y += deltaTime;
renderer.render(scene, camera);
}
animate(0);Clock 类
Clock 是 Three.js 提供的时间管理工具类。
Clock API 参考
| 方法/属性 | 类型 | 说明 |
|---|---|---|
constructor(autoStart = true) | 构造函数 | 创建 Clock 实例,autoStart 控制是否自动开始计时 |
start() | 方法 | 启动计时 |
stop() | 方法 | 停止计时 |
getElapsedTime() | 方法 | 返回从启动到现在的总时间(秒),保留小数 |
getDelta() | 方法 | 返回距上次调用 getDelta() 的时间差(秒),并重置计时 |
running | 属性 | 是否正在运行(只读) |
autoStart | 属性 | 是否自动开始计时 |
javascript
// 使用 Clock 类管理时间
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
// 获取经过的时间
const elapsedTime = clock.getElapsedTime();
// 获取时间差
const deltaTime = clock.getDelta();
// 使用时间创建动画
cube.rotation.y = elapsedTime;
cube.position.x = Math.sin(elapsedTime);
cube.position.y = Math.cos(elapsedTime);
renderer.render(scene, camera);
}
animate();WARNING
getDelta() 每次调用会重置计时器,同一帧内多次调用会导致后续调用返回 0。建议每帧只调用一次并保存结果。
基础动画类型
旋转动画
javascript
// 持续旋转
function animate() {
requestAnimationFrame(animate);
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
cube.rotation.z += 0.01;
renderer.render(scene, camera);
}
// 正弦旋转
function animate() {
requestAnimationFrame(animate);
const time = clock.getElapsedTime();
cube.rotation.x = Math.sin(time) * Math.PI;
cube.rotation.y = Math.cos(time) * Math.PI;
renderer.render(scene, camera);
}移动动画
javascript
// 直线移动
function animate() {
requestAnimationFrame(animate);
cube.position.x += 0.01;
// 循环移动
if (cube.position.x > 5) {
cube.position.x = -5;
}
renderer.render(scene, camera);
}
// 圆周运动
function animate() {
requestAnimationFrame(animate);
const time = clock.getElapsedTime();
const radius = 3;
cube.position.x = Math.cos(time) * radius;
cube.position.z = Math.sin(time) * radius;
renderer.render(scene, camera);
}
// 弹跳运动
function animate() {
requestAnimationFrame(animate);
const time = clock.getElapsedTime();
cube.position.y = Math.abs(Math.sin(time * 2)) * 2;
renderer.render(scene, camera);
}缩放动画
javascript
// 脉冲缩放
function animate() {
requestAnimationFrame(animate);
const time = clock.getElapsedTime();
const scale = 1 + Math.sin(time * 3) * 0.2;
cube.scale.set(scale, scale, scale);
renderer.render(scene, camera);
}
// 呼吸效果
function animate() {
requestAnimationFrame(animate);
const time = clock.getElapsedTime();
const scaleX = 1 + Math.sin(time) * 0.3;
const scaleY = 1 + Math.cos(time) * 0.3;
cube.scale.set(scaleX, scaleY, 1);
renderer.render(scene, camera);
}颜色动画
javascript
// 颜色渐变
function animate() {
requestAnimationFrame(animate);
const time = clock.getElapsedTime();
// HSL 颜色变化
const hue = (time * 0.1) % 1;
cube.material.color.setHSL(hue, 1, 0.5);
renderer.render(scene, camera);
}
// 闪烁效果
function animate() {
requestAnimationFrame(animate);
const time = clock.getElapsedTime();
const intensity = (Math.sin(time * 10) + 1) / 2;
cube.material.emissive.setHSL(0, 1, intensity * 0.5);
renderer.render(scene, camera);
}透明度动画
javascript
// 淡入淡出
const material = new THREE.MeshStandardMaterial({
color: 0x00ff00,
transparent: true,
opacity: 1
});
function animate() {
requestAnimationFrame(animate);
const time = clock.getElapsedTime();
material.opacity = (Math.sin(time) + 1) / 2;
renderer.render(scene, camera);
}TIP
启用透明度(transparent: true)会增加渲染开销,仅在需要时启用。
相机动画
相机旋转
javascript
function animate() {
requestAnimationFrame(animate);
const time = clock.getElapsedTime();
// 相机绕 Y 轴旋转
camera.position.x = Math.sin(time) * 5;
camera.position.z = Math.cos(time) * 5;
camera.lookAt(0, 0, 0);
renderer.render(scene, camera);
}相机跟随
javascript
function animate() {
requestAnimationFrame(animate);
// 移动物体
const time = clock.getElapsedTime();
cube.position.x = Math.sin(time) * 3;
cube.position.z = Math.cos(time) * 3;
// 相机跟随
const cameraOffset = new THREE.Vector3(0, 2, 5);
camera.position.copy(cube.position).add(cameraOffset);
camera.lookAt(cube.position);
renderer.render(scene, camera);
}光源动画
javascript
const pointLight = new THREE.PointLight(0xff0000, 1, 100);
scene.add(pointLight);
function animate() {
requestAnimationFrame(animate);
const time = clock.getElapsedTime();
// 光源移动
pointLight.position.x = Math.sin(time) * 5;
pointLight.position.z = Math.cos(time) * 5;
// 光源强度变化
pointLight.intensity = 1 + Math.sin(time * 2) * 0.5;
// 光源颜色变化
const hue = (time * 0.1) % 1;
pointLight.color.setHSL(hue, 1, 0.5);
renderer.render(scene, camera);
}缓动函数
缓动函数使动画更自然、更有表现力。
常用缓动函数类型
code
缓动函数曲线示意
线性(Linear)
┌────────────┐
│ / │
│ / │
│ / │
│ / │
│ / │
│ / │
│ / │
└────────────┘
缓入(Ease In)- 开始慢,结束快
┌────────────┐
│ / │
│ / │
│ / │
│ / │
│ / │
│ / │
│/ │
└────────────┘
缓出(Ease Out)- 开始快,结束慢
┌────────────┐
│ / │
│ / │
│ / │
│ / │
│ / │
│ / │
│/ │
└────────────┘
缓入缓出(Ease In Out)- 两端慢,中间快
┌────────────┐
│ _/ │
│ _/ │
│ _/ │
│ _/ │
│_/ │
│ │
│ │
└────────────┘缓动函数实现
javascript
// 常用缓动函数
const Easing = {
// 线性
Linear: t => t,
// 二次
QuadIn: t => t * t,
QuadOut: t => t * (2 - t),
QuadInOut: t => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
// 三次
CubicIn: t => t * t * t,
CubicOut: t => (--t) * t * t + 1,
CubicInOut: t => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
// 正弦
SineIn: t => 1 - Math.cos(t * Math.PI / 2),
SineOut: t => Math.sin(t * Math.PI / 2),
SineInOut: t => -(Math.cos(Math.PI * t) - 1) / 2,
// 弹性
ElasticIn: t => t === 0 ? 0 : t === 1 ? 1 : -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * (2 * Math.PI) / 3),
ElasticOut: t => t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * (2 * Math.PI) / 3) + 1,
// 弹跳
BounceOut: t => {
const n1 = 7.5625;
const d1 = 2.75;
if (t < 1 / d1) return n1 * t * t;
if (t < 2 / d1) return n1 * (t -= 1.5 / d1) * t + 0.75;
if (t < 2.5 / d1) return n1 * (t -= 2.25 / d1) * t + 0.9375;
return n1 * (t -= 2.625 / d1) * t + 0.984375;
}
};
// 使用缓动函数
function animate() {
requestAnimationFrame(animate);
const duration = 2000; // 毫秒
const time = (Date.now() % duration) / duration;
// 应用缓动
const easedTime = Easing.CubicInOut(time);
cube.position.x = -5 + easedTime * 10; // 从 -5 移动到 5
renderer.render(scene, camera);
}缓动函数速查表
| 缓动类型 | 效果描述 | 适用场景 |
|---|---|---|
| Linear | 匀速运动 | 机械运动、匀速旋转 |
| QuadIn | 开始慢,逐渐加速 | 启动动画、蓄力效果 |
| QuadOut | 开始快,逐渐减速 | 停止动画、刹车效果 |
| QuadInOut | 两端慢,中间快 | 自然移动、开门关门 |
| CubicIn/Out/InOut | 比二次更明显 | 需要更强加速度的场景 |
| SineIn/Out/InOut | 柔和的加速减速 | 平滑过渡、UI动画 |
| ElasticIn/Out | 弹性效果 | 弹跳、强调效果 |
| BounceOut | 弹跳效果 | 落地、弹球效果 |
粒子动画
javascript
// 创建粒子系统
const particleCount = 1000;
const positions = new Float32Array(particleCount * 3);
const velocities = [];
for (let i = 0; i < particleCount; i++) {
positions[i * 3] = Math.random() * 20 - 10;
positions[i * 3 + 1] = Math.random() * 20 - 10;
positions[i * 3 + 2] = Math.random() * 20 - 10;
velocities.push({
x: (Math.random() - 0.5) * 0.1,
y: (Math.random() - 0.5) * 0.1,
z: (Math.random() - 0.5) * 0.1
});
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const material = new THREE.PointsMaterial({
color: 0xff0000,
size: 0.1
});
const particles = new THREE.Points(geometry, material);
scene.add(particles);
function animate() {
requestAnimationFrame(animate);
const positions = particles.geometry.attributes.position.array;
for (let i = 0; i < particleCount; i++) {
positions[i * 3] += velocities[i].x;
positions[i * 3 + 1] += velocities[i].y;
positions[i * 3 + 2] += velocities[i].z;
// 边界检测
if (Math.abs(positions[i * 3]) > 10) velocities[i].x *= -1;
if (Math.abs(positions[i * 3 + 1]) > 10) velocities[i].y *= -1;
if (Math.abs(positions[i * 3 + 2]) > 10) velocities[i].z *= -1;
}
particles.geometry.attributes.position.needsUpdate = true;
renderer.render(scene, camera);
}TIP
大量粒子动画建议使用 GPU Shader 实现,可显著提升性能。
形变动画
Morph Targets
javascript
// 创建多个形态目标
const geometry = new THREE.BoxGeometry(1, 1, 1);
// 目标形态 1
const morphTarget1 = new THREE.BoxGeometry(1, 2, 1);
// 目标形态 2
const morphTarget2 = new THREE.BoxGeometry(2, 1, 1);
// 设置形态目标
geometry.morphAttributes.position = [
new THREE.Float32BufferAttribute(morphTarget1.attributes.position.array, 3),
new THREE.Float32BufferAttribute(morphTarget2.attributes.position.array, 3)
];
const material = new THREE.MeshStandardMaterial({
color: 0x00ff00,
morphTargets: true
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
// 动画
function animate() {
requestAnimationFrame(animate);
const time = clock.getElapsedTime();
// 形态目标权重
mesh.morphTargetInfluences[0] = (Math.sin(time) + 1) / 2;
mesh.morphTargetInfluences[1] = (Math.cos(time) + 1) / 2;
renderer.render(scene, camera);
}纹理动画
UV 动画
javascript
const texture = textureLoader.load('water.jpg');
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
const material = new THREE.MeshStandardMaterial({
map: texture
});
function animate() {
requestAnimationFrame(animate);
// UV 偏移
texture.offset.x += 0.01;
texture.offset.y += 0.005;
renderer.render(scene, camera);
}纹理混合动画
javascript
const texture1 = textureLoader.load('texture1.jpg');
const texture2 = textureLoader.load('texture2.jpg');
const material = new THREE.ShaderMaterial({
uniforms: {
texture1: { value: texture1 },
texture2: { value: texture2 },
mixRatio: { value: 0 }
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D texture1;
uniform sampler2D texture2;
uniform float mixRatio;
varying vec2 vUv;
void main() {
vec4 color1 = texture2D(texture1, vUv);
vec4 color2 = texture2D(texture2, vUv);
gl_FragColor = mix(color1, color2, mixRatio);
}
`
});
function animate() {
requestAnimationFrame(animate);
const time = clock.getElapsedTime();
material.uniforms.mixRatio.value = (Math.sin(time) + 1) / 2;
renderer.render(scene, camera);
}动画工具函数
值插值
javascript
// 线性插值
function lerp(start, end, t) {
return start + (end - start) * t;
}
// 使用 lerp 创建平滑移动
const targetPosition = new THREE.Vector3(5, 0, 0);
function animate() {
requestAnimationFrame(animate);
// 平滑移动到目标位置
cube.position.x = lerp(cube.position.x, targetPosition.x, 0.1);
cube.position.y = lerp(cube.position.y, targetPosition.y, 0.1);
cube.position.z = lerp(cube.position.z, targetPosition.z, 0.1);
renderer.render(scene, camera);
}角度插值
javascript
// 角度归一化到 -PI 到 PI
function normalizeAngle(angle) {
while (angle > Math.PI) angle -= Math.PI * 2;
while (angle < -Math.PI) angle += Math.PI * 2;
return angle;
}
// 角度插值(最短路径)
function lerpAngle(start, end, t) {
start = normalizeAngle(start);
end = normalizeAngle(end);
const diff = normalizeAngle(end - start);
return start + diff * t;
}性能优化
减少计算
javascript
// 不推荐:每次都计算
function animate() {
requestAnimationFrame(animate);
cube.rotation.x = Math.sin(Date.now() * 0.001) * Math.PI;
cube.rotation.y = Math.cos(Date.now() * 0.001) * Math.PI;
renderer.render(scene, camera);
}
// 推荐:缓存计算
function animate() {
requestAnimationFrame(animate);
const time = clock.getElapsedTime();
const sinTime = Math.sin(time);
const cosTime = Math.cos(time);
cube.rotation.x = sinTime * Math.PI;
cube.rotation.y = cosTime * Math.PI;
renderer.render(scene, camera);
}降低更新频率
javascript
// 某些动画不需要每帧更新
let frameCount = 0;
function animate() {
requestAnimationFrame(animate);
frameCount++;
// 每 3 帧更新一次
if (frameCount % 3 === 0) {
// 更新某些属性
}
renderer.render(scene, camera);
}使用 Object3D.frustumCulled
javascript
// 对于始终在视野内的对象,禁用视锥剔除检测
cube.frustumCulled = false;动画类型对比
| 动画类型 | 实现方式 | 适用场景 | 性能影响 |
|---|---|---|---|
| 属性动画 | 直接修改属性 | 简单变换、颜色变化 | 低 |
| 关键帧动画 | AnimationMixer | 复杂动画、模型动画 | 中 |
| 骨骼动画 | SkinnedMesh | 角色动画 | 高 |
| 变形动画 | Morph Targets | 表情动画、形态变化 | 中-高 |
| 粒子动画 | Points + BufferGeometry | 特效、大量小物体 | 中-高 |
| Shader 动画 | 自定义着色器 | 高性能特效 | 低(GPU计算) |
常见问题解答(FAQ)
Q: 为什么动画在不同设备上速度不一致?
A: 不同设备的刷新率可能不同(60Hz、120Hz 等)。使用 deltaTime 来控制动画速度,而不是固定增量值:
javascript
// 错误:固定增量
cube.rotation.x += 0.01;
// 正确:基于时间
const delta = clock.getDelta();
cube.rotation.x += delta; // 每秒旋转 1 弧度Q: 如何让动画暂停和恢复?
A: 使用 Clock 的 start/stop 方法或自定义暂停逻辑:
javascript
let isPaused = false;
const clock = new THREE.Clock();
let elapsedTime = 0;
function animate() {
requestAnimationFrame(animate);
if (!isPaused) {
const delta = clock.getDelta();
elapsedTime += delta;
// 使用 elapsedTime 进行动画
cube.rotation.y = elapsedTime;
}
renderer.render(scene, camera);
}
// 暂停/恢复
function togglePause() {
isPaused = !isPaused;
if (!isPaused) {
clock.start(); // 恢复时重新启动时钟
}
}Q: 如何实现平滑的相机过渡?
A: 使用 lerp 插值:
javascript
const targetPosition = new THREE.Vector3(5, 5, 5);
function animate() {
requestAnimationFrame(animate);
// 平滑过渡相机位置
camera.position.lerp(targetPosition, 0.05);
renderer.render(scene, camera);
}Q: 动画卡顿如何排查?
A: 检查以下几点:
- 使用
requestAnimationFrame而非setInterval - 减少 draw call 数量(合并几何体)
- 检查是否有内存泄漏(及时释放资源)
- 使用 Chrome DevTools 的 Performance 面板分析
- 对于复杂场景,考虑使用 LOD 技术
Q: 如何创建循环动画但每次播放有间隔?
A: 使用 AnimationAction 的 repetitions 属性,或在动画完成事件中添加延迟:
javascript
function animateWithDelay(delay) {
const duration = 2000; // 动画持续时间
const totalTime = duration + delay;
const time = (Date.now() % totalTime) / duration;
if (time < 1) {
// 播放动画
cube.position.x = Easing.CubicInOut(time) * 5;
}
}完整示例
javascript
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// 初始化
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
camera.position.z = 5;
const clock = new THREE.Clock();
// 创建多个动画对象
const objects = [];
for (let i = 0; i < 10; i++) {
const geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5);
const material = new THREE.MeshStandardMaterial({
color: new THREE.Color().setHSL(i / 10, 1, 0.5)
});
const mesh = new THREE.Mesh(geometry, material);
mesh.position.x = (i - 5) * 1.2;
objects.push({
mesh,
rotationSpeed: {
x: Math.random() * 0.02,
y: Math.random() * 0.02,
z: Math.random() * 0.02
},
bouncePhase: Math.random() * Math.PI * 2
});
scene.add(mesh);
}
// 光源
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 5, 5);
scene.add(light);
const ambientLight = new THREE.AmbientLight(0x404040);
scene.add(ambientLight);
// 动画循环
function animate() {
requestAnimationFrame(animate);
const time = clock.getElapsedTime();
// 更新每个对象
objects.forEach((obj, i) => {
obj.mesh.rotation.x += obj.rotationSpeed.x;
obj.mesh.rotation.y += obj.rotationSpeed.y;
obj.mesh.rotation.z += obj.rotationSpeed.z;
// 弹跳
obj.mesh.position.y = Math.sin(time * 2 + obj.bouncePhase) * 0.5;
});
controls.update();
renderer.render(scene, camera);
}
animate();