Three.js 动画入门教学:从场景搭建到流畅动效

1. 前言:为什么要用 Three.js 做动画

在浏览器里做 3D 动画,过去意味着要写大量 WebGL 原生代码:着色器、缓冲区、矩阵变换,一个旋转的立方体就能劝退不少人。Three.js 把这些底层细节封装成直观的对象模型,让开发者用「场景 + 相机 + 渲染器」三件套就能跑起一个 3D 世界。

本文面向有基础 JavaScript 经验、想入门 3D 动画的读者,从零搭建一个可运行的 Three.js 动画页面,并逐步加入旋转、缓动、时间驱动、性能优化等实战技巧。读完后你将掌握:

  • Three.js 的核心对象模型与渲染循环
  • 如何用 requestAnimationFrame 驱动动画
  • 常见动画类型:旋转、位移、缩放、材质过渡
  • 缓动函数与时间归一化的写法
  • 动画性能与资源释放的注意事项

2. 环境准备

2.1 引入 Three.js

现代前端项目推荐用 npm 安装,配合 Vite 或 Webpack 使用:

bash 复制代码
npm install three

如果只是想快速验证,也可以用 CDN 的 ES Module 版本:

html 复制代码
<script type="importmap">
{
  "imports": {
    "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
    "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
  }
}
</script>

2.2 最小页面骨架

一个能显示 3D 内容的页面,只需要一个容器和一块画布:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>Three.js 动画教学</title>
  <style>
    html, body { margin: 0; height: 100%; overflow: hidden; }
    #app { width: 100vw; height: 100vh; }
  </style>
</head>
<body>
  <div id="app"></div>
  <script type="module" src="./main.js"></script>
</body>
</html>

3. 核心三件套:场景、相机、渲染器

Three.js 的一切都围绕这三个对象展开,理解它们的关系是写好动画的前提。

javascript 复制代码
import * as THREE from 'three';

const container = document.getElementById('app');

// 1. 场景:所有物体的容器
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x101418);

// 2. 相机:决定我们"看"到的视角
const camera = new THREE.PerspectiveCamera(
  45,                                   // 视野角度
  container.clientWidth / container.clientHeight, // 宽高比
  0.1,                                  // 近裁剪面
  1000                                  // 远裁剪面
);
camera.position.set(0, 2, 6);
camera.lookAt(0, 0, 0);

// 3. 渲染器:把场景画到画布上
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.appendChild(renderer.domElement);

三者关系可以用一张图概括:
#mermaid-svg-GJ51zZ9XlUJptmbP{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-GJ51zZ9XlUJptmbP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-GJ51zZ9XlUJptmbP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-GJ51zZ9XlUJptmbP .error-icon{fill:#552222;}#mermaid-svg-GJ51zZ9XlUJptmbP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-GJ51zZ9XlUJptmbP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-GJ51zZ9XlUJptmbP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-GJ51zZ9XlUJptmbP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-GJ51zZ9XlUJptmbP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-GJ51zZ9XlUJptmbP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-GJ51zZ9XlUJptmbP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-GJ51zZ9XlUJptmbP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-GJ51zZ9XlUJptmbP .marker.cross{stroke:#333333;}#mermaid-svg-GJ51zZ9XlUJptmbP svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-GJ51zZ9XlUJptmbP p{margin:0;}#mermaid-svg-GJ51zZ9XlUJptmbP .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-GJ51zZ9XlUJptmbP .cluster-label text{fill:#333;}#mermaid-svg-GJ51zZ9XlUJptmbP .cluster-label span{color:#333;}#mermaid-svg-GJ51zZ9XlUJptmbP .cluster-label span p{background-color:transparent;}#mermaid-svg-GJ51zZ9XlUJptmbP .label text,#mermaid-svg-GJ51zZ9XlUJptmbP span{fill:#333;color:#333;}#mermaid-svg-GJ51zZ9XlUJptmbP .node rect,#mermaid-svg-GJ51zZ9XlUJptmbP .node circle,#mermaid-svg-GJ51zZ9XlUJptmbP .node ellipse,#mermaid-svg-GJ51zZ9XlUJptmbP .node polygon,#mermaid-svg-GJ51zZ9XlUJptmbP .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-GJ51zZ9XlUJptmbP .rough-node .label text,#mermaid-svg-GJ51zZ9XlUJptmbP .node .label text,#mermaid-svg-GJ51zZ9XlUJptmbP .image-shape .label,#mermaid-svg-GJ51zZ9XlUJptmbP .icon-shape .label{text-anchor:middle;}#mermaid-svg-GJ51zZ9XlUJptmbP .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-GJ51zZ9XlUJptmbP .rough-node .label,#mermaid-svg-GJ51zZ9XlUJptmbP .node .label,#mermaid-svg-GJ51zZ9XlUJptmbP .image-shape .label,#mermaid-svg-GJ51zZ9XlUJptmbP .icon-shape .label{text-align:center;}#mermaid-svg-GJ51zZ9XlUJptmbP .node.clickable{cursor:pointer;}#mermaid-svg-GJ51zZ9XlUJptmbP .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-GJ51zZ9XlUJptmbP .arrowheadPath{fill:#333333;}#mermaid-svg-GJ51zZ9XlUJptmbP .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-GJ51zZ9XlUJptmbP .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-GJ51zZ9XlUJptmbP .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-GJ51zZ9XlUJptmbP .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-GJ51zZ9XlUJptmbP .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-GJ51zZ9XlUJptmbP .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-GJ51zZ9XlUJptmbP .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-GJ51zZ9XlUJptmbP .cluster text{fill:#333;}#mermaid-svg-GJ51zZ9XlUJptmbP .cluster span{color:#333;}#mermaid-svg-GJ51zZ9XlUJptmbP div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-GJ51zZ9XlUJptmbP .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-GJ51zZ9XlUJptmbP rect.text{fill:none;stroke-width:0;}#mermaid-svg-GJ51zZ9XlUJptmbP .icon-shape,#mermaid-svg-GJ51zZ9XlUJptmbP .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-GJ51zZ9XlUJptmbP .icon-shape p,#mermaid-svg-GJ51zZ9XlUJptmbP .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-GJ51zZ9XlUJptmbP .icon-shape .label rect,#mermaid-svg-GJ51zZ9XlUJptmbP .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-GJ51zZ9XlUJptmbP .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-GJ51zZ9XlUJptmbP .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-GJ51zZ9XlUJptmbP :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Scene 场景
Camera 相机
Renderer 渲染器
Canvas 画布
Mesh 网格物体
Light 灯光

4. 添加第一个动画物体

4.1 创建几何体与材质

javascript 复制代码
const geometry = new THREE.BoxGeometry(1.5, 1.5, 1.5);
const material = new THREE.MeshStandardMaterial({
  color: 0x4f9dff,
  roughness: 0.35,
  metalness: 0.6,
});
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

// 灯光:Standard 材质必须有光源才能看见
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambient);

const dirLight = new THREE.DirectionalLight(0xffffff, 2);
dirLight.position.set(3, 5, 4);
scene.add(dirLight);

4.2 渲染循环:动画的心脏

动画的本质是「每帧改变一点状态,然后重新渲染」。Three.js 官方推荐用 renderer.setAnimationLoop,它内部封装了 requestAnimationFrame,并且兼容 WebXR。

javascript 复制代码
renderer.setAnimationLoop(() => {
  cube.rotation.x += 0.01;
  cube.rotation.y += 0.015;
  renderer.render(scene, camera);
});

到这里,一个持续旋转的立方体就跑起来了。但要注意:这种写法与帧率绑定,在 144Hz 屏幕上会比 60Hz 快 2.4 倍。下面我们用时间驱动来修正它。

5. 用时间驱动动画

5.1 基于 delta 的更新

javascript 复制代码
const clock = new THREE.Clock();

renderer.setAnimationLoop(() => {
  const delta = clock.getDelta(); // 距上一帧的秒数

  cube.rotation.x += 1.2 * delta; // 每秒转 1.2 弧度
  cube.rotation.y += 1.8 * delta;

  renderer.render(scene, camera);
});

这样无论设备帧率多少,旋转速度都保持一致。

5.2 用 elapsedTime 做周期动画

对于「来回摆动」「呼吸灯」这类周期性效果,用累计时间更自然:

javascript 复制代码
renderer.setAnimationLoop(() => {
  const t = clock.getElapsedTime();

  cube.position.y = Math.sin(t * 2) * 0.5;      // 上下浮动
  cube.scale.setScalar(1 + Math.sin(t * 3) * 0.1); // 呼吸缩放

  renderer.render(scene, camera);
});

6. 缓动函数与补间动画

线性变化看起来机械,真实世界的运动往往有加速和减速。缓动函数(easing)就是用来描述这种节奏的。

6.1 手写一个缓动

javascript 复制代码
// 三次缓出:起步快,结尾慢
function easeOutCubic(x) {
  return 1 - Math.pow(1 - x, 3);
}

const duration = 2; // 秒
let elapsed = 0;

renderer.setAnimationLoop(() => {
  const delta = clock.getDelta();
  elapsed = Math.min(elapsed + delta, duration);

  const progress = elapsed / duration;      // 0 → 1
  const eased = easeOutCubic(progress);     // 缓动后的进度

  cube.position.x = THREE.MathUtils.lerp(-3, 3, eased);

  renderer.render(scene, camera);
});

6.2 使用 Tween.js 简化补间

复杂动画建议引入补间库,代码更清晰:

bash 复制代码
npm install @tweenjs/tween.js
javascript 复制代码
import * as TWEEN from '@tweenjs/tween.js';

new TWEEN.Tween(cube.position)
  .to({ x: 3, y: 1 }, 1500)
  .easing(TWEEN.Easing.Quadratic.InOut)
  .yoyo(true)
  .repeat(Infinity)
  .start();

renderer.setAnimationLoop((time) => {
  TWEEN.update(time);
  renderer.render(scene, camera);
});

7. 动画状态机:让多个动画有序衔接

当页面里有多个动画阶段(入场 → 待机 → 交互反馈),用状态机管理比堆 if 更清晰。

javascript 复制代码
const states = {
  idle: { speed: 0.5, color: 0x4f9dff },
  active: { speed: 2.0, color: 0xff7a45 },
};

let current = 'idle';
const target = { speed: states.idle.speed, color: new THREE.Color(states.idle.color) };

function switchState(name) {
  current = name;
  target.speed = states[name].speed;
  target.color.set(states[name].color);
}

renderer.setAnimationLoop(() => {
  const delta = clock.getDelta();

  // 平滑过渡到目标状态
  cube.rotation.y += target.speed * delta;
  material.color.lerp(target.color, 1 - Math.pow(0.001, delta));

  renderer.render(scene, camera);
});

// 交互触发
container.addEventListener('click', () => {
  switchState(current === 'idle' ? 'active' : 'idle');
});

8. 响应式与性能优化

8.1 窗口尺寸自适应

javascript 复制代码
window.addEventListener('resize', () => {
  const w = container.clientWidth;
  const h = container.clientHeight;

  camera.aspect = w / h;
  camera.updateProjectionMatrix();
  renderer.setSize(w, h);
});

8.2 性能要点

  • 限制像素比 :setPixelRatio(Math.min(devicePixelRatio, 2)),高分屏上 3 倍像素比收益极低但开销翻倍。
  • 复用几何体与材质 :多个相同物体共享同一个 geometry 和 material,减少显存占用。
  • 按需渲染 :静态场景不必每帧渲染,只在状态变化时调用 renderer.render。
  • 及时释放资源 :移除物体时调用 geometry.dispose()、material.dispose()、texture.dispose(),避免内存泄漏。
  • 页面不可见时暂停 :监听 visibilitychange,隐藏时停止动画循环。
javascript 复制代码
document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    renderer.setAnimationLoop(null);
  } else {
    renderer.setAnimationLoop(animate);
  }
});

9. 完整示例代码

把上面的内容整合成一个可直接运行的 main.js:

javascript 复制代码
import * as THREE from 'three';

const container = document.getElementById('app');

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x101418);

const camera = new THREE.PerspectiveCamera(
  45,
  container.clientWidth / container.clientHeight,
  0.1,
  1000
);
camera.position.set(0, 2, 6);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.appendChild(renderer.domElement);

const geometry = new THREE.BoxGeometry(1.5, 1.5, 1.5);
const material = new THREE.MeshStandardMaterial({
  color: 0x4f9dff,
  roughness: 0.35,
  metalness: 0.6,
});
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const dirLight = new THREE.DirectionalLight(0xffffff, 2);
dirLight.position.set(3, 5, 4);
scene.add(dirLight);

const clock = new THREE.Clock();

function animate() {
  const delta = clock.getDelta();
  const t = clock.getElapsedTime();

  cube.rotation.x += 1.2 * delta;
  cube.rotation.y += 1.8 * delta;
  cube.position.y = Math.sin(t * 2) * 0.4;

  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

window.addEventListener('resize', () => {
  const w = container.clientWidth;
  const h = container.clientHeight;
  camera.aspect = w / h;
  camera.updateProjectionMatrix();
  renderer.setSize(w, h);
});

10. 小结

Three.js 动画的核心可以浓缩成一句话:在渲染循环里,用时间驱动对象状态的变化 。掌握场景、相机、渲染器三件套,理解 delta 与 elapsedTime 的区别,再配合缓动函数和状态管理,就能做出流畅自然的 3D 动效。

下一步可以尝试的方向:

  • 用 GLTFLoader 加载带骨骼动画的模型
  • 用 AnimationMixer 播放模型自带的动画片段
  • 结合 OrbitControls 做可交互的展示页
  • 用 ShaderMaterial 编写自定义着色器动画
相关推荐
福大大架构师每日一题2 小时前
excelize v2.11.1发布:Go 1.26.0起步,16项安全修复与嵌入对象能力全面升级
开发语言·安全·golang
Sarvartha2 小时前
集合概述 & Collection 接口
开发语言
lhbweilai2 小时前
QT项目(串口助手)
开发语言·qt
Hilaku2 小时前
CORS 到底在保护谁?90% 的前端理解错了跨域的本质
前端·javascript·程序员
eggwyw2 小时前
C++相交链表和反转链表详解
开发语言·c++
Wang's Blog3 小时前
Java 项目实战:交友项目 - 保存访客记录
java·开发语言·交友
阿琳a_3 小时前
b-map-viewer:面向 Vue 3 的地图可视化 SDK,从接入到展示再到交互分析
前端·javascript·vue.js·gis·数据可视化·地图
Raas1003 小时前
AI网关如何接入?MAI Gateway部署实操教程
java·开发语言·人工智能·gateway·ai网关·mai gateway·企业级产品
小小龙学IT3 小时前
Go 语言 pprof 性能剖析与调优深度解析:把“卡在哪“变成一张可执行的数据图
开发语言·后端·golang