首页 / Three.js 入门教程 / 手动动画与 Clock

Three.js 入门教程

手动动画与 Clock

本教程共 40 篇 · 第 25 篇 · 更新于 2026-08-14 · 约 8 分钟阅读

Three.js动画Clock手动动画插值缓动sin

本节目标:掌握不借助动画系统的”手动动画”写法——用 Clock 计时、用 sin/cos 做往复运动、用插值与缓动控制变化节奏。学完能自己写出平滑的浮动、摇摆、呼吸灯和淡入淡出。

手动动画:每帧改属性

动画循环每帧执行一次(第 7 章讲过)。手动动画就是在每一帧里,直接修改物体的 positionrotationscale 或材质属性,然后渲染。改一点、渲染一帧,连续起来就是动画。

这种做法适合简单的运动:旋转、浮动、颜色渐变。复杂角色动画(走路、挥手)交给第 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()
  • 属性 elapsedTimerunning 可以读取当前状态。
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.TimergetDelta/getElapsedTime 用法一致)。本书为兼容多数老教程仍用 Clock。

用 sin 和 cos 做往复运动

Math.sin(t) 的值在 -1 到 1 之间来回摆动,天然适合做往复运动:

// 每秒上下浮动两次,幅度 0.8,基准高度 2
cube.position.y = 2 + Math.sin(elapsed * 2) * 0.8;

sincos 相位差 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”的过渡动画,优先想清楚能不能写成时间式,再考虑手动维护累加值。