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编写自定义着色器动画