Draw Call从1388降到26:模型的"瘦身四部曲"

126 阅读7分钟

Draw Call从1388降到26:BIM模型的"瘦身四部曲"

一、先上效果:四步优化的"恐怖"数据

image.png

直接看我们最直观的对比:

阶段核心操作Draw Calls体积关键指标变化
原始直接导出138818.29MB每个构件独立渲染
Step1校验修复138818.29MB去掉非法属性,为后续铺路
Step2GPU实例化92215.17MB15万+顶点转为实例渲染
Step3Feature ID绑定92216.85MB每个构件有了"身份证"
Step4Mesh合并2634.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总是带着"私货":QidmidmapsymbolId...这些非标准属性会让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?别慌,这是正常的中间态

  1. 增加了_FEATURE_ID_0:每个顶点+4字节(uint16对齐到4字节)
  2. 增加了COLOR_0:每个顶点+4字节(RGBA)
  3. 存了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到丝滑

image.png

为什么帧率提升这么明显?

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_featurespickedFeature.featureId返回的是实例级别的ID(0-99),而不是网格级别的ID。这也是为什么我们在Step 3要给每个实例单独分配Feature ID。

Cesium中模型的解析原理,有机会会单独出一篇文章细说。