本节摘要:绘制调用是 CPU 侧最大的开销项——每次调用都要付出状态切换与指令准备的成本,上千次调用足以吃光整帧预算。本节讲调用数的读数方法、合并与实例化与材质共享三板斧的收益边界,以及 GPU 侧像素开销的两条压缩路径。承接 1.2 节的三本账,是第5章优化手法的开篇。
优化第一律:没有读数就没有优化。绘制调用数在引擎的性能计数器里一行读出(5.4 节会展开仪表盘,这里先用起来):
// 开启性能计数器:第一行就是每帧绘制调用数 engine.enableOfflineSupport = false; // 顺手的卫生习惯 scene.skipPointerMovePicking = true; // 装饰场景顺手关掉悬停拾取(4.2 节的红线) console.log(scene.getActiveMeshes().length); // 本帧实际参与渲染的网格数
有一个经验量级值得记在脑子里:中端手机上,绘制调用超过 300 次、桌面超过 1500 次,CPU 侧就开始紧张。为什么一次调用这么贵?每次调用前 CPU 要准备好顶点缓冲、绑定材质、切换纹理与着色器,GPU 也要跟着换状态——单次也许只有几十微秒,乘以一千次就是几毫秒,账就是这么吃光的。

第一板斧,合并(2.4 节的机理这里直接收割):静态建筑按街区合并,120 栋楼从 120 次调用变 6 次。第二板斧,实例化:800 棵同款树共享一份顶点数据,调用量按批次合并。第三板斧最容易被忽视——材质共享。两个网格材质不同,就算几何合并了也要分两次调用;材质实例每盏灯一个,等于强制拆分调用。整理成代码习惯:
// 材质共享:同一种质感全场景只造一次,挂到一个字典里按需取 const materialPool = new Map(); function getMaterial(name) { if (!materialPool.has(name)) { materialPool.set(name, createMaterialByName(name)); // 首次创建 } return materialPool.get(name); // 之后全场景复用 } lamp.material = getMaterial("metalDark"); // 300 盏灯同一实例:可合并 bench.material = getMaterial("metalDark"); // 长椅同款金属:白捡的合并机会
三板斧的收益边界也要说清:合并不是越多越好——合并后的网格共享一个包围盒,剔不掉就全画;正确的粒度是"按街区合并"而不是"全城焊死",让 2.3 节的视锥剔除仍然有活干。实例化的边界在 2.4 节说过:要独立换装的部分退回克隆。优化的艺术是找粒度,不是把旋钮拧到底。
CPU 账救完,GPU 账的超支长另一个样:帧率卡在某个数,怎么删网格都不动,一降分辨率立刻回升(1.3 节讲过的指纹)。两条路径压缩:第一条,像素数——硬件缩放系数是最粗暴也最有效的开关,1.3 节的自适应降质就是它;全屏后期链(第6章)每加一层,每个像素就多算一遍,层数要按预算裁。第二条,着色复杂度——纹理尺寸过大、各向异性级别过高、PBR 材质过多,都在逐像素层加价;3.3 节的移动端级别红线(各向异性不超过 4)在此兑现。
// 分辨率的两级开关:先全局档位,再动态自适应 engine.setHardwareScalingLevel(1.25); // 1.25 即渲染 80% 分辨率再放大:中端手机的保底 // 纹理按设备分档加载:手机要 512 的,别硬塞 2048
⚠️ 常见坑:优化后"偶发卡顿"照旧。合并与实例化只治持续性的 CPU 超支;偶发尖刺是纹理上传与着色器编译,解法是 2.4 节的加载期预热,别在三板斧里瞎找。
💡 关键直觉:调用数与可见网格数都在镜头转向时波动,所以量调用数要看"最坏朝向"——镜头对准最密处再读数,按最好朝向优化等于没优化。
三板斧的完整实战。背景:一个数字孪生园区,设备台账全部单独成网格,读数 1500 次调用,桌面 40 帧、手机 18 帧,降分辨率无效——CPU 超支的指纹。操作:第一步分类,把 1500 个网格拆成三类——1200 株植被与路灯是重复体、300 段护栏是静态拼接体、其余是必须独立的单体建筑。第二步按类动手:重复体走薄实例,一份顶点数据画全部;护栏按街区合并成 8 段;材质从 200 多个实例收敛到 6 个。第三步复测最坏朝向:镜头对准园区中心,调用数从 1500 落到 96,桌面满 60 帧,手机 34 帧,剩余瓶颈转移到 GPU 着色,交给像素路径处理。解读:收益的九成来自"先分类再动手"——重复体、静态体、单体的处方完全不同,顺序颠倒就会对着单体建筑做无意义的实例化。变式:若复测时发现调用量随镜头朝向剧烈波动,说明合并粒度过粗、包围盒吃不到剔除红利,回到 2.3 节的包围盒话题重新切分。
// 薄实例:一份顶点数据画一千棵树 const tree = BABYLON.MeshBuilder.CreateCylinder("tree", { height: 2 }, scene); const matrices = new Float32Array(count * 16); // 每实例一个 4x4 矩阵 for (let i = 0; i < count; i++) { const m = BABYLON.Matrix.Translation(x[i], 0, z[i]); m.copyToArray(matrices, i * 16); // 写入实例矩阵缓冲 } tree.thinInstanceSetBuffer("matrix", matrices, 16); // 一次提交按批次绘制 tree.material = getMaterial("foliage"); // 共享材质:可合批的前提
调用数压下来后,另一颗炸弹通常开始显形——纹理内存。下一节算清这笔最容易超支的隐形账。