Draw Call从1388降到26:BIM模型的"瘦身四部曲"
一、先上效果:四步优化的"恐怖"数据
直接看我们最直观的对比:
| 阶段 | 核心操作 | Draw Calls | 体积 | 关键指标变化 |
|---|---|---|---|---|
| 原始 | 直接导出 | 1388 | 18.29MB | 每个构件独立渲染 |
| Step1 | 校验修复 | 1388 | 18.29MB | 去掉非法属性,为后续铺路 |
| Step2 | GPU实例化 | 922 | 15.17MB | 15万+顶点转为实例渲染 |
| Step3 | Feature ID绑定 | 922 | 16.85MB | 每个构件有了"身份证" |
| Step4 | Mesh合并 | 26 | 34.86MB* | 终极绝杀,Draw Call降98% |
*注:Step4体积暂时上涨是因为增加了顶点属性数据(Feature ID和颜色),下一篇用Draco压缩后会大幅回落,但Draw Call保持26不变。
1388 → 26,这就是我们要聊的"黑魔法"。
二、优化总览:四步"整容"流程
BIM模型(Revit导出)最大的病:每个构件都是独立的Mesh,几何双胞胎互不认识,CPU和GPU疯狂"传纸条"。
我们的解决方案分四步:
graph TD
A[原始模型<br>1388个Draw Calls] --> B
subgraph B [Step1: 清垃圾]
B1[修复后<br>1388个Draw Calls]
B2[删除非法属性<br>修复4字节对齐]
end
B --> C
subgraph C [Step2: 去重]
C1[实例化后<br>922个Draw Calls]
C2[EXT_mesh_gpu_instancing<br>相同几何变实例]
end
C --> D
subgraph D [Step3: 上户口]
D1[增加Feature后<br>922个Draw Calls]
D2[EXT_mesh_features<br>EXT_instance_features]
end
D --> E
subgraph E [Step4: 大合并]
E1[最终<br>26个Draw Calls]
E2[Mesh合并 + 顶点颜色<br>材质归类]
end
四个GLTF扩展的协作战术:
EXT_mesh_gpu_instancing:解决"重复构件"(如100扇相同的门)EXT_mesh_features:给非实例化构件上户口(顶点属性_Feature_ID)EXT_instance_features:给实例化构件上户口(每个实例一个ID)EXT_structural_metadata:建户口本(Property Table,存Element ID等业务数据)
三、详细拆解:一步步杀死Draw Call
Step 1:清垃圾 —— 修复GLTF校验错误
Revit导出的GLB总是带着"私货":Qid、mid、mapsymbolId...这些非标准属性会让Cesium验证器直接红温,甚至导致后续工具链报错。
核心操作:
# 给模型"洗澡":删除非法属性
for node in gltf_data['nodes']:
node.pop('Qid', None) # Revit的构件ID,但非标准
node.pop('index', None) # 非法属性
node.pop('mapsymbolId', None) # Cesium不认识
# 删除空的children数组(洁癖操作)
if node.get('children') == []:
del node['children']
还有4字节对齐强迫症治疗:GLTF规范要求所有buffer数据必须4字节对齐,Revit经常"歪着站",我们要把它"扶正",否则GPU读取会报错。
Step 2:GPU实例化 —— 几何去重(EXT_mesh_gpu_instancing)
效果:Draw Call 1388 → 922(降33%)
原理:100扇 identical 的门,传统方式是画100次;实例化后是画1次,传100个变换矩阵。
一键命令:
gltf-transform instance input.glb output.glb --min 10
这时候看截图里的Instanced vertices: 156312,意味着这15万+顶点是通过实例化渲染的——它们共享同一份几何数据,只传不同的translation/rotation/scale。
但有个问题:合并后怎么知道用户点了哪扇门?这就引出Step 3。
Step 3:Feature ID体系 —— 给构件"上户口"
这是最硬核的部分。我们要在WebGL的顶点数据里藏一个"暗号":_FEATURE_ID_0。
3.1 非实例化构件:EXT_mesh_features
对于没被实例化的几何(如异形楼板),直接在顶点属性里塞ID:
# 给每个顶点分配Feature ID(同一构件用相同ID)
feature_ids = np.full(vertex_count, property_table_row, dtype=np.uint16)
# 绑定到GLTF primitive
prim['attributes']['_FEATURE_ID_0'] = feature_acc_idx
prim['extensions']['EXT_mesh_features'] = {
'featureIds': [{
'featureCount': unique_feature_ids,
'attribute': 0, # 对应 _FEATURE_ID_0
'propertyTable': 0 # 查哪张表
}]
}
3.2 实例化构件:EXT_instance_features
对于那100扇门(实例化后只有一个Mesh),需要给每个实例一个ID:
# 每个实例一个Feature ID
feature_ids = np.arange(instance_count, dtype=np.uint32) + start_id
# 放到instancing的attributes里(和TRANSLATION/ROTATION同级)
attributes['_FEATURE_ID_0'] = feature_acc_idx
# 在node上声明
node['extensions']['EXT_instance_features'] = {
'featureIds': [{
'featureCount': instance_count,
'attribute': 0,
'propertyTable': 0
}]
}
3.3 建户口本:EXT_structural_metadata
光有ID不够,还要把ID映射到Element ID(Revit里的构件ID):
// Property Table:Feature ID → Element ID
{
"schema": {
"classes": {
"class_element": {
"properties": {
"elementID": {"type": "STRING"}, // 存"3579221"
"name": {"type": "STRING"} // 存"标准门"
}
}
}
},
"propertyTables": [{
"class": "class_element",
"count": 1000,
"properties": {
"elementID": {"values": 指向buffer的accessor索引}
}
}]
}
工作流:点击模型 → Cesium读_FEATURE_ID_0(比如42)→ 查Property Table第42行 → 得到elementID: "3579221" → 查业务库得知这是"三楼男厕所第二扇门"。
Step 4:Mesh合并 —— 终极杀招(降Draw Call核心)
效果:Draw Call 922 → 26(降97%!)
实例化解决了"重复构件",但还有大量独一无二的小构件(不同形状的梁、板、柱)。Mesh合并就是把相同材质的mesh全部"焊"在一起。
分类策略:
# 识别已被实例化的mesh(这些不能动!)
instanced_meshes = get_instanced_mesh_indices(gltf_data)
# 剩下的按材质分类
textured = [] # 有贴图的:按贴图合并
opaque_solid = [] # 不透明纯色:全部焊成一个mesh!
transparent = [] # 透明的:单独一个mesh(避免深度排序问题)
# 顶点数据直接拼接(注意要用世界矩阵变换,避免叠在原点)
merged_vertices = np.vstack([m['vertices'] for m in opaque_solid])
merged_indices = np.concatenate([m['indices'] + offset for m in opaque_solid])
为什么用顶点颜色(COLOR_0)?
如果不这么做,每个不同颜色的构件都要切分一个Material,切换Material又会产生新的Draw Call!把颜色绑在顶点上,一个Material走天下:
# 材质颜色 → 顶点颜色(RGBA)
colors = np.tile(base_color * 255, (vertex_count, 1))
prim['attributes']['COLOR_0'] = color_acc_idx # VEC4 UNSIGNED_BYTE normalized
26个Draw Call的构成:
- 若干贴图材质(每个贴图1个)
- 1个不透明纯色大mesh
- 1个透明mesh
- 若干实例化mesh(每个实例化对象1个)
四、关键认知:为什么体积暂时变大了?
注意到Step4后体积从16MB涨到了34MB?别慌,这是正常的中间态:
- 增加了
_FEATURE_ID_0:每个顶点+4字节(uint16对齐到4字节) - 增加了
COLOR_0:每个顶点+4字节(RGBA) - 存了Property Table:字符串数据(elementID等)
但这些数据是高度可压缩的:
- 几何数据(顶点位置、法线)用Draco压缩 → 能压掉70%
- 贴图用KTX2/Basis压缩 → 能压掉80%
- Feature ID是连续整数,压缩率极高
五、总结:优化的本质是"让CPU少说话"
记住这个公式:
优化的本质不是让GPU少干活,而是让CPU少说话(减少Draw Call)。
- Step1:解决"格式错误"(让工具链能处理)
- Step2:解决"重复几何"(100扇门画1次)
- Step3:解决"身份识别"(能拾取、能查询)
- Step4:解决"琐碎绘制"(把小碎块焊成大块)
GPU表示:"顶点数量你随便加(反正我并行计算快),但你别一直烦我(Draw Call太多)。"
六、Cesium实战:丝滑帧率 + 指哪打哪的拾取
前面所有优化都是"幕后工作",现在看看台前效果——当模型在Cesium里跑起来,帧率稳了,鼠标指哪就能选中哪根梁、哪扇窗。
6.1 帧率对比:从PPT到丝滑
为什么帧率提升这么明显?
Cesium的渲染管线中,Draw Call是头号性能杀手。1388次绘制意味着CPU每帧要准备1388次渲染状态、切换材质、上传数据;而26次绘制时,CPU基本在"摸鱼",GPU全力干活。
💡 冷知识:在WebGL里,CPU准备渲染状态的时间往往比GPU实际绘制时间还长。这就是为什么减少Draw Call比减少顶点数更能提升帧率。
6.2 拾取功能:终于能"指哪打哪"了
优化前的问题:点半天选不中一根柱子,或者选中了不知道是哪个构件(没有Element ID关联业务数据)。
优化后,基于Step 3绑定的Feature ID体系,我们可以实现像素级精准拾取。
拾取原理回顾
当用户在Cesium中点击模型时,发生了什么?
鼠标点击 → Cesium读取该像素深度 → 找到对应图元
↓
读取 _FEATURE_ID_0 值(比如 42)
↓
查 EXT_structural_metadata 的 Property Table
↓
第42行 → elementID: "3579221", name: "标准门"
↓
拿着3579221去查业务数据库 → 展示构件信息
对于实例化构件的特殊处理:
如果是那100扇门(实例化渲染),Cesium会自动处理EXT_instance_features,pickedFeature.featureId返回的是实例级别的ID(0-99),而不是网格级别的ID。这也是为什么我们在Step 3要给每个实例单独分配Feature ID。
Cesium中模型的解析原理,有机会会单独出一篇文章细说。