做超大规模3D仓储可视化,我第一次压测时盯着监控面板心里凉了半截:地图加载完,三万多个库位模型全部铺进去,帧率直接掉到个位数,鼠标随便拖一下场景,要等好几秒才回过神来。后面把渲染架构从“每个货架一个Mesh”改成Three.js + WebGPU这套方案,才把帧率拉回到流畅区间。这篇文章主要讲我在这个项目里踩过的坑和实际验证过的优化手段,如果你正在做仓储数字孪生、3D大屏、物流园区可视化,或者手头有个场景数据量大到WebGL明显扛不住,那这篇应该能帮你少走不少弯路。
先说明一点,我下面讲的不是纯理论,是项目里真正落地过的做法,包括渲染层的裁剪策略、实例化改造、数据层的序列化压缩,以及从WebGL切到WebGPU渲染管线时需要注意的差异。涉及具体版本和API,后面也会标注清楚,毕竟Three.js的WebGPU支持还在快速迭代,有些接口换了版本可能就不一样了。
1. 先给“超大规模3D仓储”定一个量级
1.1 十万级库位意味着什么
很多人对“超大规模”没有概念。我接手的这个项目,一个仓库园区有多个库房,每个库房按排、列、层拆出库位,整体算下来接近十万个库位格子,还要叠加库存货物、托盘、货架横梁、AGV小车、人员动线这些元素。这个量级放到Web前端里,已经不是“优化一下就能跑”的程度,而是整个渲染架构都要重新设计。
我把常见规模做了一张对照表,方便你判断自己项目处于哪个阶段:
| 规模等级 | 库位/货架数量 | 主要瓶颈 | 典型表现 |
|---|---|---|---|
| 常规仓库 | 2000 ~ 5000 | 模型加载时间 | 页面打开慢,但打开后基本流畅 |
| 大型仓库 | 1万 ~ 3万 | Draw Call 数量 | 旋转时卡顿,帧率不稳定 |
| 超大规模 | 5万以上 | CPU提交开销 + GPU内存 | 帧率个位数,操作明显冻结 |
这里的关键瓶颈其实不在GPU的绘制能力,而在CPU那一侧。WebGL的每次Draw Call都要经过CPU提交渲染状态,再同步给GPU,几十万物体意味着一帧里可能有数万次Draw Call,CPU光是提交状态就被拖死了。GPU反而没那么累,因为很多物体是简单几何体,顶点数不多。
理解了这一点,后面所有优化就都围绕两件事来展开:一是减少Draw Call次数,二是减少CPU和GPU之间的数据传输量。
1.2 为什么选 Three.js + WebGPU,而不是游戏引擎
项目前期也讨论过要不要直接用Unity或者UE的Web方案,最后都否了。原因很实在:WebAssembly包体太大,几十MB起步,客户那边打开页面的等待时间受不了;而且仓储数据来自后端接口,需要频繁增删改查,把数据从游戏引擎的托管内存里倒腾出来再灌回去,链路太长。
原生WebGL也有过考虑,但等于要从零维护渲染器、模型加载器、射线拾取,工程量完全不可控。Three.js社区生态足够成熟,GLTF/GLB加载、实例化网格、LOD这些都有现成方案,项目迭代速度快,出了问题也好排查。而WebGPU这边,Three.js近几个版本一直在推进WebGPURenderer和TSL(Three Shading Language),不管是Compute Shader还是Render Bundle,都已经能直接在工程里用,这套组合目前是Web端超大规模场景里性价比最高的路线。
2. 渲染层优化:把 GPU 从“疲劳驾驶”里救出来
2.1 裁剪两件套:视锥体剔除与LOD
我第一次优化时首先做的不是加什么高大上的技术,而是先把该裁的东西裁掉。
Three.js默认会对每个Object3D做视锥体剔除,但这里有个坑:当很多物体被放到同一个InstancedMesh里时,视锥体剔除只能针对整个实例网格做,网格的包围球一旦覆盖全仓库,那这个剔除就等于失效了。所以我把仓库按物理区域拆成了多个区块,每个区块对应一个独立的实例化网格。站在A区门口看B区,B区的实例网格整个被视锥体排除掉,CPU和GPU同时省了一口气。
在这个基础上再加LOD(Level of Detail)。仓储场景里的货架、货箱都是重复性很高的几何体,高模和低模的视觉差异在远景下根本看不出来。我给货架做了三档模型:近距离用带横梁和层板的完整模型,中距离用简化外壳,远距离直接用一个Box代替。对应到代码里就是:
const lod = new THREE.LOD(); lod.addLevel(detailedShelf, 0); lod.addLevel(middleShelf, 40); lod.addLevel(simpleShelf, 90);阈值根据相机距离来调,单位是Three.js的世界单位。这个方案对帧率提升非常明显,因为大部分库位在正常视角下都属于中远距离,真正跑到面前细看的只有一小块区域。
2.2 实例化渲染:让一万个货架只画一笔
这是整个优化里最核心的一步。
之前每个货架都是一个独立的Mesh对象,材质相同、几何体相同,但提交数量巨大。改成InstancedMesh之后,一万个货架合并成一次Draw Call,位置、旋转、缩放通过矩阵数组传给GPU。实际操作的时候,我会先从一个基础货架几何体出发,然后用后端返回的货架坐标数据批量设置矩阵:
const geometry = createShelfGeometry(); const material = new THREE.MeshStandardMaterial({ color: 0xcccccc }); const instancedMesh = new THREE.InstancedMesh( geometry, material, shelfData.length ); const dummy = new THREE.Object3D(); shelfData.forEach((item, index) => { dummy.position.set(item.x, item.y, item.z); dummy.rotation.set(0, item.rotateY, 0); dummy.scale.set(item.scaleX, item.scaleY, item.scaleZ); dummy.updateMatrix(); instancedMesh.setMatrixAt(index, dummy.matrix); }); instancedMesh.instanceMatrix.needsUpdate = true;后面发现货位状态(空闲、占用、锁定)需要颜色区分,InstancedMesh也天然支持每个实例单独着色:
instancedMesh.setColorAt(index, new THREE.Color(状态对应颜色)); instancedMesh.instanceColor.needsUpdate = true;这一步做完,Draw Call从三万多掉到了几十,帧率肉眼可见地恢复了。需要注意的是,实例矩阵只在数据变化时更新一次,千万不要每帧去遍历设置矩阵,那样CPU开销又会上来。
2.3 静态场景合批与遮挡策略
货架实例化解决的是重复物体的问题,但仓库里的地面、墙体、立柱、通道线这些静态元素还是一个个独立Mesh,Draw Call也不少。我的做法是在建模阶段就把它们整理好,然后在代码里用MergeGeometry合并成一个整体。
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'; const mergedGeo = mergeGeometries(staticGeometries); const staticMesh = new THREE.Mesh(mergedGeo, staticMaterial); scene.add(staticMesh);合批之后,整个静态场景只剩几个Mesh,对提交压力几乎没有影响。
遮挡剔除这块,Three.js不像游戏引擎那样有成熟的遮挡查询方案,但仓储场景里有很多固定遮挡关系:站在通道一侧时,另一侧货架后面的物体本来就看不见。我的做法是手动把区块可见性做成一个可配置表,根据相机所在区域动态开关区块,相当于粗略的遮挡剔除。这个方案算不上精致,但胜在简单可靠,而且收益非常直接。
3. 数据层优化:内存、序列化与按需加载
3.1 数据按区块加载,拒绝“开局全量”
十万个库位如果开局全部加载,页面卡死是必然的。仓储可视化和游戏场景不太一样,用户通常只关心当前视角附近的区域,没必要把所有数据一次性塞进内存。
我做的第一件事是把库位数据按区域分块。后端给每个区块单独一个数据接口,前端根据相机位置预加载附近区块,远处区块只保留一个极简占位数据。比如仓库有十二个区,开局只加载当前区以及相邻两个区的库位数据,其他区等用户切换视角时再懒加载。
这样做的额外好处是前端内存占用变小了。十万个库位如果用普通JS对象存储,光数据就已经非常可观;按区块加载之后,内存峰值大幅下降,页面长时间运行的稳定性也好很多。
3.2 Float32Array 与共享数据结构
Three.js的BufferGeometry本质上就是一堆TypedArray,position是Float32Array,attribute数据不会被JS虚拟机反复优化。很多同学习惯先用普通数组组织数据,再转成BufferGeometry,这在数据量小的时候没问题,但在超大规模场景下,中间那层普通数组就是巨大的内存浪费。
我在项目里直接把后端返回的坐标数据转成Float32Array,再塞进BufferGeometry:
const positions = new Float32Array(count * 3); const indices = new Uint32Array(count * 3); // 直接按索引写入数据 for (let i = 0; i < count; i++) { positions[i * 3] = x; positions[i * 3 + 1] = y; positions[i * 3 + 2] = z; } geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geometry.setIndex(new THREE.BufferAttribute(indices, 1));这么做的另一个好处是,当我需要在运行时修改某个货位状态时,直接修改对应的TypedArray元素,再标记attribute需要更新,操作粒度小、开销低。整个过程CPU和GPU之间传递的都是连续内存块,比传递大量小对象高效得多。
3.3 贴图压缩与资源复用
仓储场景里大量使用贴图来表现货架标识、地面引导线、货物包装纹理。这里遇到过一个很典型的问题:贴图尺寸太大,动辄2048甚至4096,一张图吃几十兆显存,几十张贴图下来GPU内存直接告急。
我的做法是统一压到512或者1024,并改用WebP格式。像地面引导线这种大面积铺贴的,可以用纹理图集把所有引导线合成一张Atlas,减少纹理切换次数。货架材质尽量共用一份材质和贴图,避免每个区块独立创建一份材质导致着色器重复编译。
实际测试下来,贴图全部压到512之后,如果不把眼睛凑到屏幕上仔细对比,几乎看不出区别,但GPU内存占用下降非常明显。
4. WebGPU 渲染管线接入实战
4.1 先让 WebGPURenderer 跑起来
WebGPU相比WebGL最大的优势在于:底层更贴近现代GPU架构,支持Compute Shader、存储缓冲、渲染打包这些特性,CPU提交开销明显低于WebGL。Three.js从r150+开始有WebGPU的实验支持,我用的版本是r16x,接口已经相对可用了。
接入方式比较直接:
import * as THREE from 'three'; import { WebGPURenderer } from 'three/webgpu'; const renderer = new WebGPURenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement);调用前先判断浏览器是否支持WebGPU:
if (!navigator.gpu) { // 降级到 WebGLRenderer 或提示用户升级浏览器 return; }WebGPURenderer初始化是异步的,需要先调用await renderer.init(),然后再开始渲染。这个和WebGLRenderer有本质区别,我第一次没加就发现画面黑屏,排查了半天才意识到是这个原因。
4.2 用 Compute Shader 做货位动态调度
WebGPU真正让我觉得值的是Compute Shader。仓储场景里,AGV小车路径规划、货位状态批量更新、货物位置插值动画,这些在WebGL里只能靠CPU逐帧计算,数据量大时CPU和渲染抢时间片。
我拿货位动态调度做了一次验证,把原来在CPU里用JS循环更新位置的逻辑,改成在GPU上用计算着色器批量处理。大致思路是:把货位位置写进存储缓冲,计算着色器按并发线程组并行更新,再用更新后的缓冲直接驱动渲染。
import { Fn, uniform, position } from 'three/tsl'; const time = uniform(0); const updateShelfPosition = Fn(() => { // 这里只是示意,实际逻辑会更复杂 position.x.addAssign(time.mul(0.01)); }).compute(instanceCount);基于TSL的compute节点可以很方便地挂进渲染流程。实际用下来,在几万实例的场景里,GPU并行更新的耗时只在毫秒级,CPU完全从这个循环里解放出来。不过TSL的API还在变,如果版本不一样,写法可能略有差别,建议以官方示例为准。
这里有个很关键的经验:Compute Shader适合处理“大量且互相独立”的计算,比如批量移动、批量状态刷新;不适合处理强关联的串行逻辑,比如依赖前一步结果的路径寻路。后者你还是老老实实用CPU算。
4.3 Render Bundle 与静态背景快照
WebGPU里还有一个WebGL没有的利器,叫Render Bundle,可以把一坨渲染指令提前录制好,然后在每帧渲染时直接重放,省掉重复提交状态的开销。
仓储项目里静态货架、地面、墙体这些不会变化的部分,正是Render Bundle的最佳使用场景。我的做法是在初始化阶段把静态区块的渲染指令录制到一个Bundle里,帧循环里只需要执行一次Bundle重放,内存和CPU开销都非常小。动态物体比如AGV、悬浮提示框、高亮选中框则走正常渲染流程。
要注意的是,Render Bundle里的资源绑定在录制时就固定了,如果某个材质Texture在运行时改了,需要重新录制。所以我的策略是把动态变化的东西和静态背景严格分开,别混在一个Bundle里。
5. 常见问题与排查实录
5.1 帧率个位数,先查哪三样
如果你也碰到帧率低得离谱的情况,我的排查顺序是这样:
第一,看Draw Call。在Three.js里直接输出renderer.info.render.calls,如果这个数字还在几千甚至上万,那说明渲染提交还没优化到位,先回去做实例化和合批。
第二,看是否开了不该开的重量级特性。实时阴影、高倍数像素比、后处理特效是最常见的三大杀手。尤其像素比,很多机器设备像素比是3,你又不主动限制,等于渲染了一部分九倍像素的代价。
第三,看纹理。GPU内存爆没爆最常见的就是贴图,把贴图尺寸降到512,统一用压缩格式,再观察是否改善。
我曾经遇到一个案例,场景里所有东西都优化完了,帧率还是上不去,最后发现是一棵树的叶子贴图用了4096分辨率,占了大量显存带宽,换掉之后立刻恢复流畅。
5.2 three.js 贴图开始不显示的问题
这是很多新手必踩的坑:材质上明明设了贴图,但画面里物体就是白色或者黑色。
大多数人遇到这个问题是因为贴图加载是异步的。TextureLoader的load方法是异步回调,如果你在回调完成前就开始渲染,有很长一段窗口期贴图还没到位,自然就不显示。正确做法是:
const loader = new THREE.TextureLoader(); loader.load('texture.jpg', (texture) => { material.map = texture; material.needsUpdate = true; });另外在WebGPU渲染器下面,renderer.init()没有完成之前,纹理上传可能也会出问题。我习惯把贴图加载和renderer.init()都放进一个异步流程里,确保渲染在就绪后才开始。还有一个容易忽略的细节:纹理的colorSpace属性。如果贴图是RGB颜色贴图,要设置texture.colorSpace = THREE.SRGBColorSpace,否则颜色会被错误解码,看起来发灰甚至接近不显示。
5.3 移动端性能优化与降级方案
移动端是另一个战场,主要挑战来自两点:GPU算力弱、显存有限。同样的场景在桌面端跑60帧,到手机上可能只有十几帧。
我的降级方案是分三档:
一是限制像素比,强制renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)),多数手机上2倍像素比收益已经很低了,却要付出四倍渲染代价。
二是降低LOD阈值,把高模的出现距离缩小一半,移动端完全不需要那么精细。
三是关掉体积光、环境光遮蔽这类后处理,保留最基本的阴影就行。
还有一个很典型的问题:打开的WebGL上下文过多。页面里同时挂着多个Three.js场景或者反复创建渲染器,会导致GPU进程崩溃或者整体变卡。一定要在页面销毁时手动调用renderer.dispose(),把几何体、材质、纹理全部释放掉。这一步如果不做,移动端每隔一段时间就会越来越卡,直到浏览器把整个标签页杀掉。
6. 项目收尾:数字、心得和一点提醒
做完这一整套优化之后,项目数据是这样的:优化前,三万库位全量加载,帧率个位数,内存峰值超过1GB;优化后,同场景帧率稳定在50~60,内存峰值控制在400MB以内,移动端也能保持30帧左右。如果只看核心收益,其实不是“WebGPU比WebGL快”这一句话能概括的,而是整个渲染架构从根上换了思路。
我个人在实际操作中最大的体会是:性能优化不是靠一两个大招,而是靠一层层把资源消耗“挤”出去。裁剪挤出无效渲染,实例化挤出重复提交,合批挤出碎小Draw Call,数据层挤掉冗余内存,到WebGPU这层再挤掉CPU调度开销。每一步单独看都算不上惊艳,但叠在一起就是量级的差距。
最后再分享一个小建议:不要在优化初期就执着于WebGPU的炫酷特性。先把WebGL下的实例化、合批、裁剪做扎实,数据层梳理干净,再迁移到WebGPURenderer,你会发现自己少踩一半的坑。尤其是Compute Shader,它对数据结构和后端起接口的要求更高,没有做好前期铺垫的话,直接用反而会拖慢进度。这套流程以后再做别的超大规模场景,也能直接复用。