第七篇:动画基础:requestAnimationFrame循环
引言
动画是赋予3D场景生命力的关键。Three.js提供了多种动画实现方式,从基础的旋转动画到复杂的骨骼系统。本文将深入解析动画核心原理,并通过Vue3实现一个功能完备的动画编辑器,让你掌握3D动态效果的实现技巧。
1. 动画循环原理
1.1 requestAnimationFrame
浏览器原生动画API,以60FPS(约16.7ms/帧)为基准:
function animate() {
// 更新动画逻辑
updateScene();
// 渲染场景
renderer.render(scene, camera);
// 循环调用
requestAnimationFrame(animate);
}
animate();
1.2 动画时间控制
const clock = new THREE.Clock();
let deltaTime = 0;
function animate() {
deltaTime = clock.getDelta(); // 获取上一帧耗时
// 基于时间的动画(帧率无关)
cube.rotation.y += 0.5 * deltaTime;
requestAnimationFrame(animate);
}
1.3 性能优化策略
graph TD
A[动画循环] --> B{场景变化?}
B -->|无变化| C[跳过渲染]
B -->|有变化| D[执行渲染]
2. 基础动画实现
2.1 旋转动画
function animate() {
cube.rotation.x += 0.01;
cube.rotation.y += 0.02;
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
2.2 位移动画
const startPos = new THREE.Vector3(0, 0, 0);
const endPos = new THREE.Vector3(5, 3, -2);
const duration = 3; // 秒
function animateMove() {
const elapsed = clock.getElapsedTime();
const progress = Math.min(elapsed / duration, 1);
// 线性插值
cube.position.lerpVectors(startPos, endPos, progress);
if (progress < 1) {
requestAnimationFrame(animateMove);
}
}
2.3 缩放动画(脉动效果)
function pulse() {
const scale = 1 + Math.sin(clock.getElapsedTime() * 2) * 0.2;
cube.scale.set(scale, scale, scale);
requestAnimationFrame(pulse);
}
3. 高级动画库:GSAP
3.1 安装与配置
npm install gsap
3.2 基础动画示例
<script setup>
import gsap from 'gsap';
onMounted(() => {
// 位移动画
gsap.to(cube.position, {
x: 5,
duration: 2,
repeat: -1, // 无限重复
yoyo: true, // 往返运动
ease: "power2.inOut"
});
// 颜色渐变动画
gsap.to(cube.material.color, {
r: 1, g: 0, b: 0, // 红色
duration: 3,
onComplete: () => console.log("颜色变化完成")
});
});
</script>
3.3 动画时间线
const timeline = gsap.timeline({
repeat: -1,
repeatDelay: 1
});
timeline
.to(cube.position, { x: 5, duration: 2 })
.to(cube.scale, { x: 2, y: 2, z: 2, duration: 1 }, "-=0.5") // 与前一动画重叠0.5秒
.to(cube.rotation, { y: Math.PI * 2, duration: 1.5 }, "+=0.5"); // 延迟0.5秒
4. 骨骼动画系统
4.1 加载GLTF骨骼模型
<script setup>
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const loader = new GLTFLoader();
loader.load('model.gltf', (gltf) => {
const model = gltf.scene;
scene.add(model);
// 获取动画混合器
mixer = new THREE.AnimationMixer(model);
// 播放所有动画
gltf.animations.forEach((clip) => {
mixer.clipAction(clip).play();
});
});
</script>
4.2 动画混合器更新
const mixer = null;
function animate() {
const delta = clock.getDelta();
if (mixer) mixer.update(delta);
requestAnimationFrame(animate);
}
4.3 动画控制
// 获取动画动作
const walkAction = mixer.clipAction(gltf.animations[0]);
const runAction = mixer.clipAction(gltf.animations[1]);
// 配置动画
walkAction
.setEffectiveTimeScale(0.8) // 80%速度
.setLoop(THREE.LoopRepeat)
.play();
// 动画过渡
function transitionToRun() {
gsap.to(walkAction, {
timeScale: 0,
duration: 0.3,
onComplete: () => walkAction.stop()
});
runAction
.setEffectiveTimeScale(1.2)
.fadeIn(0.3)
.play();
}
5. 变形动画(Morph Targets)
5.1 创建变形目标
const geometry = new THREE.BoxGeometry(1, 1, 1);
// 添加变形目标(缩放效果)
geometry.morphAttributes.position = [
new Float32Array([
0,0,0, 0,0,0, 0,0,0, // 原始顶点
// 目标形状:Y方向拉伸
0,2,0, 0,2,0, 0,2,0
])
];
const material = new THREE.MeshBasicMaterial({
morphTargets: true,
wireframe: true
});
const cube = new THREE.Mesh(geometry, material);
5.2 控制变形权重
// 设置权重(0-1)
cube.morphTargetInfluences[0] = 0.5;
// 动画变化
gsap.to(cube.morphTargetInfluences, {
0: 1,
duration: 2,
yoyo: true,
repeat: -1
});
6. Vue3实战:动画编辑器
6.1 项目结构
src/
├── components/
│ ├── AnimationEditor.vue // 主编辑器
│ ├── TimelineControl.vue // 时间线
│ ├── KeyframeEditor.vue // 关键帧编辑
│ └── AnimationPreview.vue // 3D预览
└── App.vue
6.2 动画编辑器核心
<!-- AnimationEditor.vue -->
<script setup>
import { ref, reactive } from 'vue';
import gsap from 'gsap';
const animations = ref([]);
const currentAnim = ref(null);
const timeline = ref(null);
// 动画类型选项
const ANIM_TYPES = {
POSITION: 'position',
ROTATION: 'rotation',
SCALE: 'scale',
COLOR: 'color',
MORPH: 'morph'
};
// 添加新动画
function addAnimation(type, target) {
const anim = {
id: Date.now(),
type,
target,
duration: 2,
ease: 'power2.inOut',
keyframes: [
{ time: 0, value: getCurrentValue(target, type) },
{ time: 2, value: getDefaultEndValue(type) }
]
};
animations.value.push(anim);
currentAnim.value = anim;
}
// 应用动画到场景
function applyAnimation() {
if (!currentAnim.value) return;
timeline.value && timeline.value.kill();
timeline.value = gsap.timeline();
animations.value.forEach(anim => {
const target = scene.getObjectById(anim.target.id);
if (!target) return;
const vars = {
duration: anim.duration,
ease: anim.ease
};
switch(anim.type) {
case ANIM_TYPES.POSITION:
timeline.value.to(target.position, {
x: anim.keyframes[1].value.x,
y: anim.keyframes[1].value.y,
z: anim.keyframes[1].value.z,
...vars
}, 0);
break;
case ANIM_TYPES.COLOR:
timeline.value.to(target.material.color, {
r: anim.keyframes[1].value.r,
g: anim.keyframes[1].value.g,
b: anim.keyframes[1].value.b,
...vars
}, 0);
break;
// 其他类型处理...
}
});
}
</script>
<template>
<div class="animation-editor">
<div class="animation-list">
<button v-for="anim in animations"
:class="{ active: anim === currentAnim }"
@click="currentAnim = anim">
{{ anim.type }} ({{ anim.duration }}s)
</button>
<button @click="addAnimation(ANIM_TYPES.POSITION, selectedObject)">
+ 添加动画
</button>
</div>
<div v-if="currentAnim" class="animation-detail">
<KeyframeEditor :animation="currentAnim" />
<div class="controls">
<button @click="applyAnimation">播放</button>
<button @click="timeline.pause()">暂停</button>
<button @click="timeline.seek(0)">重置</button>
</div>
</div>
</div>
</template>
6.3 关键帧编辑器
<!-- KeyframeEditor.vue -->
<script setup>
import { computed } from 'vue';
const props = defineProps(['animation']);
const emit = defineEmits(['update']);
// 当前选中的关键帧
const selectedKeyframe = ref(0);
// 添加关键帧
function addKeyframe() {
const newTime = prompt('输入时间(秒):');
if (newTime && !isNaN(newTime)) {
const newFrame = {
time: parseFloat(newTime),
value: getCurrentValue(props.animation.target, props.animation.type)
};
props.animation.keyframes.push(newFrame);
sortKeyframes();
}
}
// 删除关键帧
function deleteKeyframe(index) {
if (props.animation.keyframes.length > 2) {
props.animation.keyframes.splice(index, 1);
}
}
// 按时间排序
function sortKeyframes() {
props.animation.keyframes.sort((a, b) => a.time - b.time);
props.animation.duration = Math.max(...props.animation.keyframes.map(k => k.time));
}
// 更新关键帧值
function updateKeyframeValue(index, newValue) {
props.animation.keyframes[index].value = { ...newValue };
emit('update');
}
</script>
<template>
<div class="keyframe-editor">
<div class="keyframe-list">
<div v-for="(kf, i) in animation.keyframes"
:class="{ selected: i === selectedKeyframe }"
@click="selectedKeyframe = i">
<span>时间: {{ kf.time.toFixed(2) }}s</span>
<button @click.stop="deleteKeyframe(i)">×</button>
</div>
<button @click="addKeyframe">+ 添加关键帧</button>
</div>
<div v-if="selectedKeyframe !== null" class="keyframe-detail">
<ParamRange
label="时间"
v-model="animation.keyframes[selectedKeyframe].time"
:min="0" :max="animation.duration" :step="0.1"
/>
<!-- 根据动画类型显示不同控件 -->
<template v-if="animation.type === 'position'">
<VectorControl
v-model="animation.keyframes[selectedKeyframe].value"
:labels="['X', 'Y', 'Z']"
@change="updateKeyframeValue(selectedKeyframe, $event)"
/>
</template>
<template v-else-if="animation.type === 'color'">
<ColorPicker
v-model="animation.keyframes[selectedKeyframe].value"
@change="updateKeyframeValue(selectedKeyframe, $event)"
/>
</template>
</div>
</div>
</template>
6.4 动画预览组件
<!-- AnimationPreview.vue -->
<script setup>
import { onMounted, ref } from 'vue';
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const canvasRef = ref(null);
const scene = new THREE.Scene();
const clock = new THREE.Clock();
// 初始化场景
onMounted(() => {
const renderer = new THREE.WebGLRenderer({ canvas: canvasRef.value });
const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000);
camera.position.z = 5;
const controls = new OrbitControls(camera, renderer.domElement);
// 添加测试物体
const cube = new THREE.Mesh(
new THREE.BoxGeometry(),
new THREE.MeshStandardMaterial({ color: 0x00ff00 })
);
scene.add(cube);
// 动画循环
const animate = () => {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
};
animate();
});
// 接收外部动画指令
function playAnimation(timeline) {
timeline.seek(0);
timeline.play();
}
</script>
<template>
<div class="animation-preview">
<canvas ref="canvasRef" width="600" height="400"></canvas>
</div>
</template>
7. 物理动画与碰撞
7.1 集成Cannon.js
npm install cannon
<script setup>
import * as CANNON from 'cannon';
import * as THREE from 'three';
// 创建物理世界
const world = new CANNON.World();
world.gravity.set(0, -9.82, 0);
// 创建物理物体
const sphereBody = new CANNON.Body({
mass: 5,
shape: new CANNON.Sphere(0.5)
});
sphereBody.position.set(0, 10, 0);
world.addBody(sphereBody);
// 同步Three.js物体
const sphereMesh = new THREE.Mesh(
new THREE.SphereGeometry(0.5),
new THREE.MeshStandardMaterial()
);
scene.add(sphereMesh);
// 物理更新循环
function physicsStep() {
world.step(1/60); // 60FPS更新
sphereMesh.position.copy(sphereBody.position);
sphereMesh.quaternion.copy(sphereBody.quaternion);
}
7.2 碰撞检测
// 碰撞事件监听
sphereBody.addEventListener('collide', (e) => {
console.log('碰撞强度:', e.contact.getImpactVelocityAlongNormal());
});
// 射线检测
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
function checkIntersection(x, y) {
mouse.set(
(x / window.innerWidth) * 2 - 1,
-(y / window.innerHeight) * 2 + 1
);
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children);
if (intersects.length > 0) {
// 处理点击物体
}
}
8. 动画状态机
8.1 状态机实现
class AnimationStateMachine {
constructor() {
this.states = {};
this.currentState = null;
}
addState(name, onEnter, onUpdate, onExit) {
this.states[name] = { onEnter, onUpdate, onExit };
}
setState(name) {
if (this.currentState && this.states[this.currentState].onExit) {
this.states[this.currentState].onExit();
}
this.currentState = name;
if (this.states[name].onEnter) {
this.states[name].onEnter();
}
}
update(delta) {
if (this.currentState && this.states[this.currentState].onUpdate) {
this.states[this.currentState].onUpdate(delta);
}
}
}
// 角色动画状态机
const characterAnim = new AnimationStateMachine();
characterAnim.addState('idle',
() => idleAction.play(),
(delta) => mixer.update(delta),
() => idleAction.stop()
);
characterAnim.addState('walk',
() => walkAction.play(),
(delta) => mixer.update(delta),
() => walkAction.stop()
);
// 切换状态
characterAnim.setState('walk');
8.2 状态过渡
// 混合状态
characterAnim.addState('walk_to_run', () => {
walkAction.fadeOut(0.2);
runAction.fadeIn(0.2).play();
}, (delta) => {
mixer.update(delta);
}, () => {
runAction.stop();
});
9. 性能优化技巧
9.1 动画帧率控制
const targetFPS = 30;
const interval = 1000 / targetFPS;
let lastTime = 0;
function animate(timestamp) {
const deltaTime = timestamp - lastTime;
if (deltaTime > interval) {
// 执行更新
updateAnimations(deltaTime / 1000);
renderer.render(scene, camera);
lastTime = timestamp;
}
requestAnimationFrame(animate);
}
9.2 不可见物体暂停
function animate() {
// 检查物体是否在视锥内
if (camera.frustumIntersectsObject(cube)) {
cube.rotation.y += 0.01;
}
requestAnimationFrame(animate);
}
9.3 Web Workers 计算
// 主线程
const worker = new Worker('physics-worker.js');
function updatePhysics() {
worker.postMessage({
type: 'step',
dt: clock.getDelta(),
bodies: getBodyStates()
});
worker.onmessage = (e) => {
applyBodyStates(e.data);
};
}
// physics-worker.js
self.onmessage = (e) => {
if (e.data.type === 'step') {
// 执行物理计算
world.step(e.data.dt);
// 返回结果
postMessage(getBodyStates());
}
};
10. 常见问题解答
Q1:动画卡顿不流畅怎么办?
- 检查FPS(Stats.js)
- 减少场景复杂度(几何体/光源/阴影)
- 使用requestAnimationFrame时间戳
- 避免在动画循环中创建新对象
Q2:如何实现角色行走动画?
// 混合行走和空闲动画
const walkWeight = controls.speed / maxSpeed;
idleAction.setEffectiveWeight(1 - walkWeight);
walkAction.setEffectiveWeight(walkWeight);
Q3:动画结束后如何释放资源?
// GSAP动画
tl.eventCallback("onComplete", () => {
cube.geometry.dispose();
cube.material.dispose();
scene.remove(cube);
});
// 骨骼动画
mixer.addEventListener('finished', (e) => {
mixer.uncacheRoot(e.action.getRoot());
});
11. 总结
通过本文,你已掌握:
- requestAnimationFrame动画循环原理
- GSAP高级动画库应用
- 骨骼动画与变形动画技术
- 物理动画与碰撞检测实现
- 动画状态机管理系统
- Vue3实现功能完备的动画编辑器
- 动画性能优化策略
核心原理:Three.js动画系统基于时间驱动,通过更新对象属性(位置/旋转/缩放)或变形权重,结合渲染循环实现动态效果。
下一篇预告
第八篇:交互入门:鼠标拾取物体
你将学习:
- Raycaster射线检测原理
- 鼠标点击/悬停交互实现
- 3D物体拖拽与变换控制
- 碰撞检测与物理交互
- GUI控制面板集成
- Vue3实现交互式3D展厅
准备好让你的3D场景响应用户操作了吗?让我们进入交互式3D开发的世界!