Three.js 大场景渲染优化:从 Draw Call 合并到 LOD 分级
面向 Web 3D 开发者的性能优化实践笔记。以一个约 20 万三角形、800 个独立物体的城市街区测试场景为例,记录从 22 FPS 回到稳定 60 FPS 的完整优化路径。
一、先搞清楚瓶颈到底在哪
很多人优化 Web 3D 的第一反应是"三角形太多了,得减面"。但在绝大多数场景里,瓶颈不是 GPU 的填充率,而是 CPU 提交渲染指令的开销。
每次 draw call 在浏览器里要走完这条链路:JS 侧组织参数 → WebGL 绑定校验 → uniform 上传 → 命令写入 command buffer → 驱动层提交。物体数量上千之后,这条链路的 CPU 时间会把主线程吃掉,帧率断崖式下跌,而 GPU 占用率可能还不到 30%。
判断方法很直接,读渲染器自己的计数器:
// 每帧结束后打印(renderer.info 默认每帧自动重置)
function logRenderInfo(renderer) {
const { calls, triangles } = renderer.info.render;
const { geometries, textures } = renderer.info.memory;
console.log(`draw calls=${calls} triangles=${triangles} geo=${geometries} tex=${textures}`);
}
判断标准(经验值,随设备浮动):
| 指标 | 舒适区 | 需警惕 | 危险 |
|---|---|---|---|
| draw calls(桌面) | < 300 | 300–800 | > 1000 |
| draw calls(移动端) | < 100 | 100–200 | > 300 |
| 单帧 JS 渲染耗时 | < 6 ms | 6–12 ms | > 12 ms |
测试场景优化前的读数:draw calls=1180, triangles=201k,帧率 22 FPS。三角形数量并不夸张,问题完全出在 draw call 上。
二、优化一:合并静态几何体
场景里大量物体是静态且共享材质的——路面、护栏、路灯基座、建筑外墙。它们每帧的位置都不变,却各自占一次 draw call。
思路:把同材质、同属性的几何体烘焙成一个大 BufferGeometry,一次提交。
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
/**
* 把一组 Mesh 按材质合并成少量 Mesh
* @param {THREE.Mesh[]} meshes 待合并的静态物体
* @returns {THREE.Mesh[]} 合并后的结果
*/
function batchByMaterial(meshes) {
const groups = new Map();
for (const mesh of meshes) {
// 关键:先把每个几何体变换到世界坐标,否则合并后位置全错
const geo = mesh.geometry.clone();
mesh.updateWorldMatrix(true, false);
geo.applyMatrix4(mesh.matrixWorld);
const key = mesh.material.uuid;
if (!groups.has(key)) groups.set(key, { material: mesh.material, geos: [] });
groups.get(key).geos.push(geo);
}
const result = [];
for (const { material, geos } of groups.values()) {
const merged = mergeGeometries(geos, false);
if (!merged) {
console.warn('合并失败:几何体属性不一致', geos.map((g) => Object.keys(g.attributes)));
continue;
}
merged.computeBoundingSphere();
result.push(new THREE.Mesh(merged, material));
geos.forEach((g) => g.dispose()); // 及时释放,避免显存泄漏
}
return result;
}
这一步把 1180 次 draw call 压到了 约 260 次。
两个必须注意的坑:
1. mergeGeometries 会静默返回 null。 它要求所有几何体的属性集合完全一致——都有 position、normal、uv,且都是 indexed 或都是 non-indexed。混合了 BoxGeometry(有 uv)和自定义几何体(没有 uv)就会失败。统一处理:
function normalizeAttributes(geo) {
if (!geo.attributes.uv) {
// 补齐缺失的 uv,值全 0 即可(纯色材质用不到)
const count = geo.attributes.position.count;
geo.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(count * 2), 2));
}
return geo;
}
2. 合并后无法单独控制某个物体。 一旦合并,就不能再单独移动某盏路灯、单独改它的颜色。所以只合并真正静态的部分,动态物体(车、行人、可交互对象)必须排除在外。
三、优化二:InstancedMesh 处理重复物体
场景里 300 多棵行道树、200 多个窗户构件,几何体完全相同,只是位置和旋转不同。这类"同一模型大量复制"的情况,合并几何体不是最优解——它会让顶点数据膨胀(同一个树模型被复制 300 份顶点)。正确做法是实例化渲染:一次 draw call,GPU 内部循环 300 次。
function createInstancedTrees(treeGeometry, treeMaterial, transforms) {
const mesh = new THREE.InstancedMesh(treeGeometry, treeMaterial, transforms.length);
const matrix = new THREE.Matrix4();
const quaternion = new THREE.Quaternion();
const scale = new THREE.Vector3();
const position = new THREE.Vector3();
transforms.forEach((t, i) => {
position.set(t.x, t.y, t.z);
quaternion.setFromEuler(new THREE.Euler(0, t.rotationY, 0));
scale.setScalar(t.scale ?? 1);
matrix.compose(position, quaternion, scale);
mesh.setMatrixAt(i, matrix);
});
mesh.instanceMatrix.needsUpdate = true;
// 手动设置包围球,否则视锥剔除会失效(默认包围球只覆盖单个实例)
mesh.computeBoundingSphere();
// 若实例分布极广,直接关闭剔除反而更稳,避免整批被误剔除
// mesh.frustumCulled = false;
return mesh;
}
如果需要每个实例颜色不同(比如每片树叶色差),用 instanceColor:
const colors = new Float32Array(transforms.length * 3);
transforms.forEach((t, i) => {
const c = new THREE.Color().setHSL(0.25 + Math.random() * 0.05, 0.5, 0.4);
colors.set([c.r, c.g, c.b], i * 3);
});
mesh.instanceColor = new THREE.InstancedBufferAttribute(colors, 3);
材质需要开启对应支持,否则颜色不生效:material.vertexColors = true,或使用支持 USE_INSTANCING_COLOR 的自定义着色器。
这一步:260 → 约 90 次 draw call。
四、优化三:LOD 分级,按距离换精度
draw call 降下来之后,瓶颈转移到三角形数量上。远处建筑明明只有几个像素,却还在用完整的高模渲染。
Three.js 内置了 LOD,按相机距离自动切换细节层级:
function buildLOD(levels, position) {
const lod = new THREE.LOD();
for (const { geometry, material, distance } of levels) {
lod.addLevel(new THREE.Mesh(geometry, material), distance);
}
lod.position.copy(position);
return lod;
}
// 使用:0m 内高模,40m 内中模,120m 内低模
const building = buildLOD([
{ geometry: geoHigh, material: mat, distance: 0 },
{ geometry: geoMid, material: mat, distance: 40 },
{ geometry: geoLow, material: mat, distance: 120 },
], new THREE.Vector3(10, 0, -5));
scene.add(building);
LOD 的层级切换由渲染器在渲染循环中自动完成(依赖相机),无需手动调用 update。关键参数是切换距离的设定:如果高模和低模的视觉差异在切换瞬间能被看出来,就会产生"跳变"。经验做法是让层级之间的三角形数量差控制在 3–4 倍内,且切换距离设在物体屏幕占比小于约 5% 的位置。
还有一个收益常被忽略:低模不只是顶点少,纹理 mipmap 层级也会更粗,显存带宽压力同步下降。
五、优化四:别忽略材质与纹理
Three.js 不会自动批处理。两个 MeshStandardMaterial 实例即使参数完全相同,只要不是同一个对象引用,就是两次 draw call。
// 错误:循环里每次 new 一个材质 → 永远无法合并
objects.forEach(() => new THREE.MeshStandardMaterial({ color: 0x888888 }));
// 正确:材质池,相同参数复用同一实例
const materialPool = new Map();
function getMaterial(params) {
const key = JSON.stringify(params);
if (!materialPool.has(key)) materialPool.set(key, new THREE.MeshStandardMaterial(params));
return materialPool.get(key);
}
同理,纹理图集(Texture Atlas)能把"多张小图 → 多材质 → 多次 draw call"压成"一张大图 → 一个材质 → 一次 draw call"。对 UI 元素、路面贴花这类小图密集的场景收益尤其明显。
另外几个容易踩的开关:
// 1. 静态场景关闭矩阵自动更新,省掉每帧的 matrixWorld 计算
scene.matrixWorldAutoUpdate = false;
// 2. 只改颜色不改光照时,避免每帧重算阴影
renderer.shadowMap.autoUpdate = false;
// 需要更新时手动触发一次
renderer.shadowMap.needsUpdate = true;
// 3. 像素比封顶,高分屏上 3x DPR 是最常见的隐形杀手
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
六、优化结果
| 阶段 | draw calls | triangles | FPS |
|---|---|---|---|
| 优化前 | 1180 | 201k | 22 |
| 静态几何合并 | 260 | 201k | 38 |
| 重复物体实例化 | 90 | 152k | 52 |
| LOD 分级 + 材质池 | 88 | 96k | 60(稳定) |
draw call 降低 92%,三角形减少 52%,帧率从 22 提升到稳定 60。
七、几条经验总结
- 先测再改。 用
renderer.info读数是成本最低的诊断手段。draw call 高就合并、实例化;三角形多就上 LOD;都正常还卡,才去查着色器和带宽。 - 合并和实例化解决的是不同问题。 静态且各不相同的物体 → 合并几何体;同模型大量复制 →
InstancedMesh。用错方向收益会打对折。 - LOD 的模型要提前准备。 这是唯一需要美术配合的环节,纯代码优化替代不了。项目早期就要约定 LOD0/LOD1/LOD2 的面数预算。
- 别在渲染循环里创建对象。
new THREE.Vector3()、new THREE.Matrix4()写在animate里会持续触发 GC,造成周期性掉帧。复用模块级变量。 material.dispose()是必须的。 切换场景时只remove不dispose,显存会一路涨到浏览器崩溃,而 draw call 指标看起来完全正常——这类问题最难排查。
优化不是把画面变丑,而是把算力花在真正看得见的地方。远处一个像素的建筑不值得 5000 个三角形,近处的主角模型值得。
- 点赞
- 收藏
- 关注作者
评论(0)