动画与渲染循环
🎯 引言
前面几篇的 renderer.render() 只调用了一次,拍的是静态照片。3D 场景的魅力在于“动”:旋转的产品、漂浮的粒子、巡场的镜头。学完这篇文章,你能用渲染循环让物体连续动起来,并用 Clock 解决不同设备上动画快慢不一致的问题。
🧱 渲染循环:连拍模式
renderer.render() 是拍一张照片。要动起来,就得让相机每秒连拍几十张,每张之间物体动一点点,连续播放就是动画。
浏览器提供的“连拍开关”是 requestAnimationFrame(简称 rAF):你告诉它“下一帧画什么”,浏览器会在每次屏幕刷新前调用你的函数,大约每秒 60 次。
const animate = () => {
requestAnimationFrame(animate); // 先预约下一帧,形成循环
cube.rotation.y += 0.01; // 每帧转一点点
renderer.render(scene, camera); // 拍当前这一帧
};
animate(); // 启动循环
理解成翻页动画书就很好懂:每页(帧)上的人物姿势差一点,快速翻页(每秒 60 帧)人物就动起来了。requestAnimationFrame(animate) 放在函数开头,意思是“画完这页立刻预约下一页”。
🛠 让立方体转起来
把第 1 篇的静态场景改造成动画,只需要加上渲染循环:
<template>
<div ref="containerRef" class="container"></div>
</template>
<script setup>
import { onMounted, ref } from 'vue';
import * as THREE from 'three';
const containerRef = ref(null);
onMounted(() => {
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, 320 / 240, 0.1, 1000);
camera.position.z = 5;
const cube = new THREE.Mesh(
new THREE.BoxGeometry(1.5, 1.5, 1.5),
new THREE.MeshStandardMaterial({ color: 0x44aa88 })
);
scene.add(cube);
// 标准材质需要灯光才能显出立体感
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5);
directionalLight.position.set(3, 5, 4);
scene.add(directionalLight);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(320, 240);
containerRef.value.appendChild(renderer.domElement);
// 渲染循环:每帧转一点再渲染
const animate = () => {
requestAnimationFrame(animate);
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
renderer.render(scene, camera);
};
animate();
});
</script>
<style scoped>
.container {
width: 320px;
height: 240px;
}
</style>
转动起来之后,立方体各个面的明暗跟着角度变化,立体感比静态时强很多。
🧱 Clock:让不同设备一样快
cube.rotation.y += 0.01 这种写法有个隐藏问题:“每帧加多少”不代表“每秒加多少”。
- 60Hz 屏幕:每秒 60 帧 × 0.01 ≈ 每秒转 0.6 弧度。
- 120Hz 屏幕:每秒 120 帧 × 0.01 ≈ 每秒转 1.2 弧度。
同一个动画,高刷屏上快了一倍!正确做法是按时间流逝驱动动画,THREE.Clock 就是干这个的:
const clock = new THREE.Clock();
const animate = () => {
requestAnimationFrame(animate);
const elapsed = clock.getElapsedTime(); // 从启动到现在过了多少秒
cube.rotation.y = elapsed * 0.6; // 每秒固定转 0.6 弧度,与帧率无关
renderer.render(scene, camera);
};
思路从“每帧加多少”变成“现在应该转到哪”:用总时间 × 速度直接算出当前角度。不管屏幕每秒刷 60 次还是 120 次,1 秒后的角度都是一样的。
🛠 综合示例:旋转 + 上下浮动
用 elapsed 配合三角函数,几行代码就能做出“边转边上下漂浮”的效果:
const clock = new THREE.Clock();
const animate = () => {
requestAnimationFrame(animate);
const elapsed = clock.getElapsedTime();
cube.rotation.y = elapsed * 0.8; // 匀速自转
cube.position.y = Math.sin(elapsed * 2) * 0.4; // 在 -0.4 ~ 0.4 之间来回浮动
renderer.render(scene, camera);
};
animate();
Math.sin() 的值在 -1 到 1 之间往复,正好拿来写“来回动”的动画:浮动、摆动、呼吸缩放都是这个套路。
下面是这个效果的真实渲染演示:
🧾 小节总结
- 动画 = 渲染循环:
requestAnimationFrame每秒调用约 60 次,每次更新状态再渲染。 requestAnimationFrame(animate)写在函数开头,循环才不会断。- 固定增量写法(
+= 0.01)在不同刷新率的设备上速度不一致。 - 正确姿势:
clock.getElapsedTime()拿到总时间,用“总时间 × 速度”算当前状态。 Math.sin()适合写一切“来回动”的动画。
❓ 知识问答
Q1:为什么不用 setInterval 做动画循环?
setInterval 不关心屏幕刷新时机,可能出现掉帧、画面撕裂,切到别的标签页还会空转浪费性能。requestAnimationFrame 和屏幕刷新同步,标签页不可见时自动暂停,是浏览器动画的标准方案。
Q2:组件销毁后,动画循环会自己停吗?
不会!循环还在后台空跑,白白消耗性能。必须用 cancelAnimationFrame 手动停止,具体写法在“Vue3 实战”篇里讲。
Q3:想让动画中途暂停怎么办?
简单做法:用一个变量当开关,if (paused) return 跳过后面的更新(循环本身继续跑,只是不改状态)。需要精确暂停时,可以用 clock.stop() 和 clock.start()。
Q4:场景里多个物体,要用多个 Clock 吗?
不需要。一个 Clock 拿到的 elapsed 是所有物体共用的时间轴,每个物体按自己的公式计算状态即可。
🧪 小练习
做一个“跳动的球”:
- 创建一个球体,用渲染循环让它绕 y 轴自转。
- 用
Math.sin()让球体上下跳动,幅度 0.5。 - 进阶:调整速度参数,让球体跳到高点时刚好转满整数圈,观察节奏感。
<template>
<div ref="containerRef" class="container"></div>
</template>
<script setup>
import { onMounted, ref } from 'vue';
import * as THREE from 'three';
const containerRef = ref(null);
onMounted(() => {
// 请在这里编写代码
});
</script>
<style scoped>
.container {
width: 320px;
height: 240px;
}
</style>
🎉 恭喜你已经让场景动起来了!下一篇我们给场景装上鼠标交互,让用户可以拖拽着看。
