手动动画与 Clock
本教程共 40 篇 · 第 25 篇 · 更新于 2026-08-14 · 约 8 分钟阅读
本节目标:掌握不借助动画系统的”手动动画”写法——用 Clock 计时、用 sin/cos 做往复运动、用插值与缓动控制变化节奏。学完能自己写出平滑的浮动、摇摆、呼吸灯和淡入淡出。
手动动画:每帧改属性
动画循环每帧执行一次(第 7 章讲过)。手动动画就是在每一帧里,直接修改物体的 position、rotation、scale 或材质属性,然后渲染。改一点、渲染一帧,连续起来就是动画。
这种做法适合简单的运动:旋转、浮动、颜色渐变。复杂角色动画(走路、挥手)交给第 26 章的动画系统,但理解手动动画是基础,后面所有动画都是”每帧改点什么”。
两种驱动方式:增量式与时间式
改属性有两种思路,先想清楚用哪种,代码会干净很多。
增量式:在当前位置上累加。
cube.position.y += 0.5 * delta; // 每秒上升 0.5 个单位
时间式:每帧用当前时间重新计算位置。
const t = clock.getElapsedTime();
cube.position.y = Math.sin(t * 2) * 0.8; // 位置由时间唯一决定
时间式更好控制:暂停就是停住时间,倒放就是让时间往回走,想回到某一刻直接把时间设回去。增量式做不到这些,状态全堆积在物体上,只能一路加下去。
Tip能写成
值 = f(时间)的动画,就不要写成值 += 增量。时间式代码可预测、可调试,这是多年实践下来的经验。
Clock:计时器全解
Clock 在第 7 章出现过,这里把它的完整用法讲完:
getDelta():返回距上次调用的秒数。每帧开头调用一次,得到上一帧耗时,用来做与帧率无关的运动。getElapsedTime():返回时钟启动以来的总秒数。适合做周期运动,也是时间式动画的时间源。start()/stop():手动控制计时。start()会把elapsedTime重置为 0;stop()后getDelta不再计时,恢复计时需重新start()。- 属性
elapsedTime、running可以读取当前状态。
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta(); // 上一帧耗时,每帧只调一次
const elapsed = clock.getElapsedTime(); // 启动以来的总秒数
renderer.render(scene, camera);
}
Note
getDelta在一帧里只能调用一次,同一帧再调会返回接近 0 的值。另外 r183 起整个 Clock 类标记弃用,r185 下new THREE.Clock()会在控制台打印弃用警告(不影响运行)。新代码推荐THREE.Timer(getDelta/getElapsedTime用法一致)。本书为兼容多数老教程仍用 Clock。
用 sin 和 cos 做往复运动
Math.sin(t) 的值在 -1 到 1 之间来回摆动,天然适合做往复运动:
// 每秒上下浮动两次,幅度 0.8,基准高度 2
cube.position.y = 2 + Math.sin(elapsed * 2) * 0.8;
sin 和 cos 相位差 90 度,组合起来就是圆周运动:
const r = 2; // 半径
cube.position.x = Math.cos(elapsed) * r;
cube.position.z = Math.sin(elapsed) * r;
材质属性也能这么驱动,比如呼吸灯:
material.transparent = true;
material.opacity = 0.3 + 0.7 * (0.5 + 0.5 * Math.sin(elapsed * 3));
0.5 + 0.5 * sin(t) 把值域从 [-1, 1] 映射到 [0, 1],再乘幅度加偏移,就能得到任意区间内的平滑摆动。这个”映射区间”的小技巧很常用。
插值:让值从 A 平滑到 B
插值(lerp)是动画的基础概念:t 从 0 变到 1,结果从 A 线性变到 B。
// t = 0 时返回 a,t = 1 时返回 b
const value = THREE.MathUtils.lerp(a, b, t);
向量也有插值方法,做位置过渡很方便:
const start = new THREE.Vector3(0, 0, 0);
const end = new THREE.Vector3(3, 0, 0);
tmp.lerpVectors(start, end, t); // 从 start 插到 end
t 从哪来?常见做法是用时间算:
const t = THREE.MathUtils.clamp((elapsed - startTime) / duration, 0, 1);
clamp 把 t 限制在 [0, 1] 之间,动画播完就停在终点,不会越界。
颜色也能插值
颜色动画同样常见:材质从红变蓝、灯光强度变化。Color 自带 lerp 方法:
const c1 = new THREE.Color(0xff0000); // 红
const c2 = new THREE.Color(0x0000ff); // 蓝
material.color.copy(c1).lerp(c2, t); // 按 t 从红渐变到蓝
t 同样是 0~1 的进度值。注意 .lerp 会修改调用它的颜色对象,所以先 copy(c1) 重置再插值,避免颜色漂移。光照强度是普通数字,直接用 MathUtils.lerp(0.5, 2, t) 就行。
动画可以组合:位置、旋转、颜色、透明度同时驱动,互不干扰。但同一个属性只能有一个”来源”——要么手动改,要么交给第 26 章的动画系统,两个同时上会互相覆盖。
缓动:改变变化节奏
线性插值匀速变化,机械感强。缓动(easing)就是把 t 重新映射一下,让变化”先慢后快”或”两头慢中间快”。最常用的三次缓动:
function easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
three.js 的 MathUtils 还提供了几个现成的实用函数:
damp(current, target, lambda, delta):指数趋近目标值,自带平滑感,做相机跟随、数字滚动非常好用。smoothstep(x, min, max):0/1 之间平滑过渡。mapLinear(x, a1, a2, b1, b2):把 x 从一个区间线性映射到另一个区间。pingpong(x, length):让递增的值在区间内来回反弹。
可运行示例:浮动的小方块
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>25. 手动动画与 Clock</title>
<style>
* { margin: 0; padding: 0; }
body { overflow: hidden; background: #1a1a2e; }
canvas { display: block; }
#info {
position: fixed; left: 12px; top: 12px;
color: #fff; font: 14px/1.6 sans-serif;
background: rgba(0, 0, 0, 0.45); padding: 8px 12px; border-radius: 6px;
}
</style>
</head>
<body>
<div id="info">浮动 + 旋转 + 呼吸灯:全部基于 Clock 时间,与帧率无关</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.185.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.185.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 1.5, 5);
camera.lookAt(0, 1, 0);
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const dirLight = new THREE.DirectionalLight(0xffffff, 1.5);
dirLight.position.set(2, 4, 3);
scene.add(dirLight);
const material = new THREE.MeshStandardMaterial({ color: 0x4dabf7 });
material.transparent = true;
const cube = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), material);
scene.add(cube);
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(10, 10),
new THREE.MeshStandardMaterial({ color: 0x2b2b4a })
);
ground.rotation.x = -Math.PI / 2;
scene.add(ground);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.append(renderer.domElement);
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
const elapsed = clock.getElapsedTime();
// 时间式:上下浮动
cube.position.y = 1 + Math.sin(elapsed * 2) * 0.8;
// 增量式:匀速旋转
cube.rotation.y += THREE.MathUtils.degToRad(45) * delta;
// 呼吸灯:透明度在 0.3 ~ 1 之间摆动
material.opacity = 0.3 + 0.7 * (0.5 + 0.5 * Math.sin(elapsed * 3));
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>
运行后,方块上下浮动、匀速旋转、材质忽明忽暗。三种运动分别演示了时间式、增量式和区间映射三种写法。
相机也能参与手动动画,比如绕场景巡视:
const angle = elapsed * 0.3;
camera.position.x = Math.cos(angle) * 8;
camera.position.z = Math.sin(angle) * 8;
camera.lookAt(0, 1, 0);
想确认动画是否掉帧,可以临时在页面上显示帧率(第 24 章的 stats.js 就是干这个的)。动画不流畅时,先找循环里最重的操作——模型克隆、几何体重建这类”每帧 new 对象”的写法是头号嫌疑。
做完动画,记得用第 7 章的方法自测一遍:把浏览器 CPU 降速 4 倍,动画速度应该保持不变——这是”时间式”写法是否到位的检验。
经验与提醒
动画循环跑在浏览器主线程,每帧预算约 16 毫秒。循环里不要创建几何体、材质等新对象,不要做重计算,否则整帧卡顿。需要”从 A 变到 B”的过渡动画,优先想清楚能不能写成时间式,再考虑手动维护累加值。