Three.js 进阶:场景管理、性能与加载策略
本文目录
Three.js 把 WebGL 的复杂度包掉了,但"能跑起来"和"在生产环境长期稳定"之间隔着一整层工程问题:场景怎么组织、几何体和材质怎么复用、纹理和模型怎么加载、什么时候渲染、resize 和 DPR 怎么适配、卸载时内存怎么释放干净。这篇文章按"场景 → 渲染 → 加载 → 释放 → 降级"的顺序过一遍,代码基于 three 当前的 ESM 写法(three 与 three/addons),具体 API 以 threejs.org 文档为准。
场景图与对象生命周期
Three.js 的场景是一棵树:Scene → Group/Mesh/Lights。渲染器每帧遍历这棵树,更新每个对象的世界矩阵、剔除不可见的网格、收集绘制命令。组织场景的第一原则是按语义分组,而不是平铺几百个 mesh:
import * as THREE from 'three';
const scene = new THREE.Scene();
const city = new THREE.Group(); // 语义分组:可以整体显隐、整体定位
city.name = 'city';
city.visible = false; // visible=false 的子树整体被跳过
scene.add(city);
const road = new THREE.Mesh(
new THREE.PlaneGeometry(20, 4),
new THREE.MeshStandardMaterial({ color: 0x555b66 })
);
city.add(road);
两点认知决定后续所有决策:
- 树的结构影响遍历成本。
updateMatrixWorld、拾取遍历、渲染剔除都沿着树走,深而窄通常优于把全部对象摊平挂在Scene上,同时分组让"只对某一子树做操作"成为可能。 add/remove很便宜,但对象销毁不是。把 mesh 从场景移除只切断引用,GPU 侧的几何体和纹理还在,释放要显式dispose(见后文 dispose 体系一节)。
对象池与几何体复用
短生命周期对象(粒子、弹孔、伤害数字)反复 new + dispose 会制造 GC 压力和驱动开销,池化是标准解:
class MeshPool {
private free: THREE.Mesh[] = [];
constructor(
private factory: () => THREE.Mesh, // 几何体与材质在工厂里创建一次
private reset: (mesh: THREE.Mesh) => void,
private size = 16
) {
for (let i = 0; i < this.size; i++) this.free.push(this.factory());
}
acquire(): THREE.Mesh | null {
const mesh = this.free.pop();
if (!mesh) return null; // 池空:宁可不出现,也别现场 new
this.reset(mesh);
mesh.visible = true;
return mesh;
}
release(mesh: THREE.Mesh): void {
mesh.visible = false; // 复用靠显隐,资源不释放
this.free.push(mesh);
}
}
比池化更根本的是共享:100 个同样的立方体,几何体和材质只需要各一份:
| 资源 | 多个 Mesh 共享一份 | 风险 |
|---|---|---|
| Geometry | 推荐,省显存与上传 | dispose 时必须确认没有其他使用方 |
| Material | 常见,注意改一个全变 | 需要独立颜色时改用实例化或克隆 |
| Texture | 推荐,纹理是显存大户 | 生命周期归属必须明确到模块 |
由此得到一条纪律:资源创建集中在少数工厂/管理器里,Mesh 只持有引用。谁创建、谁释放、谁共享,写成注释级别的约定,否则 dispose 体系一定会出双重释放或泄漏。
Instancing:同几何体大量重复
一片森林、一场雪、一城的楼,都是"同一几何体重复 N 次"。InstancedMesh 把重复压成一次绘制调用:
const tree = new THREE.InstancedMesh(
treeGeometry, // 一份几何体
treeMaterial, // 一份材质
2000 // 实例数
);
const matrix = new THREE.Matrix4();
for (let i = 0; i < tree.count; i++) {
matrix.makeRotationY(i * 0.37);
matrix.setPosition(x[i], 0, z[i]);
tree.setMatrixAt(i, matrix); // 每个实例一个变换矩阵
}
tree.instanceMatrix.needsUpdate = true;
// 每帧动画的实例改用 DynamicDrawUsage 提示驱动:
// tree.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
| 能力 | 说明 |
|---|---|
| 每实例变换 | setMatrixAt 设置,动画场景每帧更新后置 needsUpdate |
| 每实例颜色 | setColorAt + instanceColor.needsUpdate,不需要拆材质 |
| 拾取 | raycast 支持,命中结果带 instanceId |
| 限制 | 实例之间不能有独立材质(换贴图不行);数量上限受矩阵纹理等实现细节约束 |
判断是否该用 instancing 的信号很简单:同款对象超过几百个,且逐个 Mesh 导致帧率下降、绘制调用数(可用 renderer.info.render.calls 观察)暴涨。
材质与灯光成本
材质和灯光决定片元着色器的工作量,这是移动端最先爆掉的预算:
| 要素 | 相对成本 | 建议 |
|---|---|---|
MeshBasicMaterial | 最低,不响应灯光 | UI 贴片、恒亮元素 |
MeshLambertMaterial | 低,逐顶点光照感 | 低端目标可用 |
MeshStandardMaterial(PBR) | 高,多光源叠加再乘 | 桌面主流、移动端限量 |
| 每个动态光源 | 光源越多着色器分支越重 | 控制在个位数,能用贴图"烘焙"就不用实时光 |
| 实时阴影 | 每个投影光源额外渲染一遍深度 | 静态场景用烘焙阴影贴图 |
"烘焙"指把光照结果预先画进纹理(离线工具完成,运行时只是普通贴图),静态场景里它把光源成本变成零。调试材质时用 MeshNormalMaterial 或线框模式能快速区分"逻辑错了"还是"材质太贵"。
纹理与模型加载策略
加载的第一原则:不要用 loader 各自为战,用 LoadingManager 统一进度与错误:
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const manager = new THREE.LoadingManager();
manager.onProgress = (url, loaded, total) => {
setProgress(loaded / total); // 进度条数据来自真实计数
};
manager.onLoad = () => {
scene.userData.ready = true; // 全部就绪再进入开场动画
};
manager.onError = (url) => {
showFallback(url); // 单资源失败要有降级出口
};
const gltfLoader = new GLTFLoader(manager);
gltfLoader.load('/models/robot.glb', (gltf) => {
scene.add(gltf.scene);
}, undefined, () => showFallback('/models/robot.glb'));
资源本身的策略按优先级排:
- 体积:glTF(.glb)是标准格式;网格用 Draco 压缩(
DRACOLoader,解码器走 Worker),纹理用 KTX2/Basis 压缩(GPU 直接消费,省内存与解码时间)。压缩收益以真实资源对比为准,别按目录里的旧文件直接估算。 - 色彩:r152 之后颜色管理默认开启,颜色贴图要设
texture.colorSpace = THREE.SRGBColorSpace,否则"颜色发灰/过饱和"先查这里。 - 时机:首屏只加载开场可见资源,其余按视口距离懒加载;加载中的体验用进度条或占位几何体兜住,不要白屏。
- 复用:同一贴图出现在多个模型里时,从缓存字典取,不要让 loader 各自请求并各占一份显存。
按需渲染 vs 连续渲染
renderer.setAnimationLoop(fn) 默认每帧渲染。但静态展台、产品 3D 预览这类场景,99% 的时间没有任何变化——空转渲染烧的是电量与 GPU。按需渲染把"渲染"变成"失效标记 + 有变化才画":
let needsRender = true;
let tweening = false;
export function invalidate(): void { needsRender = true; }
controls.addEventListener('change', invalidate); // 相机被用户拨动时失效
scene.addEventListener('pointerdown', invalidate); // 交互开始失效
function tick(): void {
if (tweening) {
camera.position.lerp(cameraTarget, 0.08); // 插值逼近目标
if (camera.position.distanceTo(cameraTarget) > 0.01) invalidate();
else tweening = false; // 收敛后停止失效
}
if (needsRender) {
needsRender = false;
renderer.render(scene, camera);
}
}
renderer.setAnimationLoop(tick);
OrbitControls 的 change 事件在相机改变时触发,是接按需渲染最自然的钩子;自动旋转属于"持续动画",直接回到连续渲染。注意插值思路:让目标值驱动、当前值渐进逼近,比在事件里一次性跳变平滑,也天然适配"既不是每帧渲染、又要平滑过渡"的形态。帧率数据用 renderer.info 与 Chrome 的 WebGL 帧分析观察,移动端发烫通常是连续渲染 + 高 DPR 的组合造成的。
resize 与 DPR 适配
canvas 尺寸和相机参数必须同步更新,且 DPR 要封顶:
const container = document.querySelector<HTMLElement>('.viewer')!;
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
const renderer = new THREE.WebGLRenderer({ antialias: true });
const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
if (width < 1 || height < 1) return; // 弹窗收起时别拿 0 初始化
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // 封顶 2
renderer.setSize(width, height, false); // false: 不改 style,尺寸全由布局控制
camera.aspect = width / height;
camera.updateProjectionMatrix(); // 改了 aspect 必须更新投影矩阵
invalidate(); // 尺寸变了要重画一帧
});
observer.observe(container);
漏掉 updateProjectionMatrix 的症状是画面拉伸变形;DPR 不封顶的症状是旗舰手机流畅、低端机卡死。antialias: true 在 DPR ≥ 2 时收益有限,移动端可以考虑关掉换性能。
相机与 Raycaster 拾取
点击/悬停选取 3D 对象走 Raycaster,输入是"事件坐标 → NDC → 世界空间射线":
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
viewerEl.addEventListener('pointermove', (event) => {
const rect = viewerEl.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; // NDC: [-1, 1]
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(pickables, false); // 只检测可交互集合
highlight(hits[0]?.object ?? null);
});
工程上的关键选择:
intersectObjects(scene.children, true)递归全场景在大场景里是性能陷阱;维护一个pickables数组,只对可交互对象开递归。- 拾取频率跟随
pointermove,合帧到 rAF 或做距离阈值节流,别每次事件都发射线。 - 命中结果按距离排序,
hits[0]是最近的;对InstancedMesh用hit.instanceId区分实例。 - 几何体越复杂、拾取目标越多,raycast 越贵;粗选中可以先用包围盒(
Box3相交)过滤。
dispose:内存释放体系
Three.js 不会自动释放 GPU 资源,切页面、销毁组件时漏掉 dispose 就是泄漏。完整的销毁是"遍历 + 分类释放":
function disposeObject3D(root: THREE.Object3D): void {
root.traverse((obj) => {
const mesh = obj as THREE.Mesh;
if (!mesh.isMesh) return;
mesh.geometry.dispose();
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
for (const material of materials) {
for (const value of Object.values(material)) {
const maybeTexture = value as THREE.Texture;
if (maybeTexture && maybeTexture.isTexture) maybeTexture.dispose(); // 纹理
}
material.dispose();
}
});
root.clear();
}
function disposeViewer(): void {
cancelAnimationFrame(loopId); // 停循环
observer.disconnect(); // 停尺寸监听
controls.dispose(); // 控制器内部也挂了事件
disposeObject3D(scene);
renderer.dispose(); // 释放 WebGL 上下文相关资源
renderer.forceContextLoss(); // 主动释放上下文,便于页面复用
renderer.domElement.remove();
}
| 资源 | 释放调用 | 说明 |
|---|---|---|
| Geometry | geometry.dispose() | 共享几何体只能由最后一个使用方释放 |
| Material | material.dispose() | 先释放其引用的纹理 |
| Texture | texture.dispose() | 显存大户,视频贴图还要停掉源 |
| Controls | controls.dispose() | 事件监听在它内部 |
| Renderer | renderer.dispose() + forceContextLoss() | 组件卸载的最后一步 |
另一个真实环境问题:浏览器在显存紧张时会主动杀掉 WebGL 上下文,webglcontextlost 事件需要 preventDefault() 并暂停渲染,在 webglcontextrestored 后重建资源——展台类页面长期挂着的,这一对事件属于必做项。
移动端降级策略
同一份代码从桌面旗舰到千元机,靠的是分级而不是碰运气:
| 档位 | 判定 | 措施 |
|---|---|---|
| 完整 | 桌面 GPU / 高端移动 | PBR 材质、2–4 盏灯、DPR 2、全量实例 |
| 基础 | devicePixelRatio、FPS 采样、renderer 能力检测 | DPR 1–1.5、Lambert 材质、烘焙阴影、实例数减半 |
| 静态 | 低端机 / 降级失败 | 用渲染好的图片/视频替身,3D 入口保留 |
分级判定不要用 UA 嗅探,用能力与实测:首 5 秒采样 FPS,低于阈值自动降一档并提示。运行时降级要可以回到上一档(用户从省电模式切回来),所以降级动作设计成可逆的参数切换,而不是砍代码路径。素材侧同步配合:同一模型出高清与精简两个 .glb,纹理出两档分辨率,按档位加载。
注意事项与边界
- 不要依赖 three 的默认导入全量打包;按需从
three/addons引入 loader/controls,配合打包器的 tree-shaking 检查产物体积。 - three 是快速迭代的库,破坏性变更常见;锁定版本 + 升级时对照官方迁移指南,不要混用网上旧版本示例代码。
- 生产环境建议显式处理 loader 失败路径:模型 404、纹理跨域被拒、解码器文件路径错误,都应有占位降级而不是空 canvas。
renderer.info的memory与render.calls是排查泄漏和绘制调用膨胀的第一现场,埋进调试面板比拍脑袋可靠。- 共享资源 + dispose 的组合最容易出"双重释放"或"提前释放"(画面突然全黑),用引用计数或集中管理器规避。
- Worker 里跑 three 渲染(OffscreenCanvas)可行但事件与 resize 协议复杂,没有性能证据前不要默认上。
- 移动端 WebGL 上下文数量有限(多个 viewer 同屏时可能拿不到上下文),一个页面一个 renderer 是安全默认。
总结
Three.js 生产化的主线是四条:场景按语义分组、资源集中创建与复用(共享/池化/instancing),渲染按需失效(变化才画、插值平滑过渡),销毁成体系(遍历 dispose + 上下文释放 + context lost 兜底)。加载侧用 LoadingManager 统一进度与错误,素材用压缩格式控制体积。移动端先定档位再定实现,用实测 FPS 驱动可逆降级。把这些做扎实,"演示很炫"才有可能变成"线上稳定"。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。