GLB 模型压缩 — 完整流程与代码映射

2 阅读4分钟

本文档记录 3D 预览模块中 GLB 模型从"原始高精度"到"浏览器可用"的完整压缩流程,每个环节都标注对应的源文件和行号


1. 为什么要压缩

问题现象

移动视角和缩放时明显卡顿,帧率极低。

诊断方式

src/three-preview/composables/useThreeOverview.ts L105-116buildOverview 完成后打印场景统计:

let totalTris = 0, drawCalls = 0
overviewGroup.traverse((child) => {
  const mesh = child as THREE.InstancedMesh
  if (!mesh.isMesh && !mesh.isInstancedMesh) return
  const geo = (mesh as THREE.Mesh).geometry
  if (!geo?.index) return
  const tris = geo.index.count / 3
  const count = mesh.isInstancedMesh ? mesh.count : 1
  totalTris += tris * count
  drawCalls++
})
console.log(`[3D] draw calls: ${drawCalls}, total triangles: ${(totalTris/1000).toFixed(1)}k, nodes: ${nodeMapped.length}`)

诊断结果

draw calls: 6, total triangles: 201496.3k, nodes: 153

2 亿个三角面。153 个节点,每个模型约 130 万面。GPU 每帧需要处理 2 亿个三角形,任何显卡都无法流畅渲染。

原始模型数据

模型GLB 文件路径原始大小估算三角面数
三通public/models/三通.glb78.4 MB~130 万
冷却塔public/models/冷却塔.glb74.9 MB~130 万
冷水机组public/models/冷水机组.glb78.7 MB~130 万
水泵public/models/水泵.glb24.1 MB~40 万

这些精度对于 CAD 工程是必要的,但对于浏览器 3D 渲染完全不必要。

代码中的模型路径映射

src/three-preview/config/modelMapping.ts L7-28

export const MODEL_MAPPINGS: ModelMapping[] = [
  { nodeType: '冷却塔',   modelPath: '/models/冷却塔.glb',   scale: 1.0 },
  { nodeType: '冷水机组', modelPath: '/models/冷水机组.glb', scale: 1.0 },
  { nodeType: '水泵',     modelPath: '/models/水泵.glb',     scale: 1.0 },
  { nodeType: '三通',     modelPath: '/models/三通.glb',     scale: 1.0 },
]

压缩后的文件直接替换 public/models/ 下的同名文件,代码无需修改。


2. 压缩原理

两个瓶颈

瓶颈原因解决方案
几何体面数(顶点数量)GPU 顶点着色器对每个顶点运行一次,顶点越多每帧计算量越大减面(Simplification)
文件传输体积(贴图+几何体数据)原始 GLB 中贴图未压缩、几何体是原始浮点数组,体积大且占显存贴图压缩(WebP) + 几何体压缩(Meshopt)

两阶段处理流程

原始 GLB (78MB, 130万面)
    │
    ↓ 第一阶段:减面 (simplify)
    │   算法:QEM(二次误差度量)— 迭代折叠代价最小的边
    │   参数:--ratio 0.03(保留 3%)、--error 0.01
    │
中间 GLB (33MB, ~3.9万面)
    │
    ↓ 第二阶段:全量压缩 (optimize)
    │   包含 9 个子步骤(见下表)
    │
最终 GLB (0.9MB, ~3.9万面, Meshopt编码 + WebP贴图)

optimize 命令的 9 个子步骤

序号步骤作用
1dedup去除重复的网格、材质、贴图
2instance对场景内重复的网格自动转为 GPU instancing
3flatten展平场景层级,减少节点数量
4join合并使用相同材质的相邻 mesh
5weld焊接重叠顶点(去除 T 形接缝冗余顶点)
6simplify再次轻量减面(对前面未做减面的 mesh)
7prune删除场景中未被引用的节点、材质、贴图
8textureCompress贴图转为 WebP(比 PNG 小 70-80%)
9meshopt几何体数据用 Meshopt 压缩编码

Meshopt 压缩原理

原始几何体存储为浮点数组(每个顶点 3×4 字节)。Meshopt 先做 quantization(将浮点精度从 32bit 降至 16bit 或更低),再用 LZ4 变体算法压缩字节流。GPU 加载时由解码器实时还原,性能影响可忽略。


3. 操作步骤

工具

使用 @gltf-transform/cli,无需安装,直接用 npx 运行:

npx @gltf-transform/cli --version
# 4.3.0

第一阶段:减面

npx @gltf-transform/cli simplify \
  --ratio 0.03 \
  --error 0.01 \
  input.glb output_simplified.glb

参数说明:

参数含义
--ratio0.03保留原始面数的 3%。俯视全览场景模型较小,3% 即可保持可辨识轮廓
--error0.01允许的最大几何误差(相对于模型包围盒大小),值越小越保守

底层算法:meshoptimizer 的 QEM(Quadric Error Metrics,二次误差度量)。对每条边计算"折叠代价",优先折叠代价最小的边,迭代直到达到目标面数。

第二阶段:全量压缩

npx @gltf-transform/cli optimize \
  --texture-compress webp \
  input_simplified.glb output_final.glb

批量处理脚本

for model in 三通 冷却塔 冷水机组 水泵; do
  # 第一阶段:减面
  npx @gltf-transform/cli simplify \
    --ratio 0.03 --error 0.01 \
    "public/models/${model}.glb" \
    "public/models/${model}_opt.glb"

  # 第二阶段:全量压缩
  npx @gltf-transform/cli optimize \
    --texture-compress webp \
    "public/models/${model}_opt.glb" \
    "public/models/${model}_final.glb"

  # 备份原始文件,替换为优化版本
  mv "public/models/${model}.glb" "public/models/${model}_backup.glb"
  mv "public/models/${model}_final.glb" "public/models/${model}.glb"
  rm "public/models/${model}_opt.glb"
done

只压缩不减面(面数本身不多的模型)

npx @gltf-transform/cli optimize --texture-compress webp input.glb output.glb

4. 压缩结果

模型原始大小优化后压缩率
三通78.4 MB903 KB99%
冷却塔74.9 MB817 KB99%
冷水机组78.7 MB966 KB99%
水泵24.1 MB623 KB97%

总三角面数:2 亿 → 约 200 万,减少 99%。


5. 代码端解码配置 — 完整映射

压缩后的 GLB 文件使用 Meshopt 编码,浏览器端加载时需要配置解码器,否则会加载失败。

解码器配置

src/three-preview/composables/useModelLoader.ts L2-5(import)+ L57-65(配置)

// L2-5: 导入
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'

// L57-65: 配置加载器
const gltfLoader = new GLTFLoader()

// Draco 解码器(处理 draco 压缩的几何体)
const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.7/')
gltfLoader.setDRACOLoader(dracoLoader)

// Meshopt 解码器(处理 gltf-transform optimize 产生的压缩)
gltfLoader.setMeshoptDecoder(MeshoptDecoder)

MeshoptDecoder 来自 Three.js 自带的 examples/jsm/libs/,无需额外安装 npm 依赖。

加载流程

src/three-preview/composables/useModelLoader.ts L70-115

loadModel(path)
  ├─ L72-78: 查缓存 → 命中且未过期(10min) → return model.clone()
  ├─ L84-96: gltfLoader.load(path)
  │    └─ GLTFLoader 内部自动检测 GLB 是否使用 Meshopt/Draco 编码
  │       ├─ 若 Meshopt → 调用 MeshoptDecoder.decode() 还原几何体
  │       └─ 若 Draco → 调用 DRACOLoader.decode() 还原几何体
  ├─ L98-103: clone 存入缓存
  ├─ L106: disposeTextures() 释放原始 blob URL
  └─ L108: return cachedClone.clone()

模型文件加载链路

ToolBar 点击「3D预览」
  └─ src/views/flow/g6Graph/ToolBar/index.vue:90
     emitter.emit('toggle3DPanel')

→ flow/index.vue:186
  emitter.emit('request3DGraphData')

→ g6CanvasGraph.vue:4501-4505
  emitter.emit('response3DGraphData', graph.getData())

→ ThreeScene.vue:44-60
  overview.buildOverview(data, scene)

→ useThreeOverview.ts:92
  placeModelsMerged(nodeMapped)

→ useThreeOverview.ts:188-190
  for (const node of nodes) {
    const path = getModelPathByCompType(node.comptype)
    //                ↓
    // modelMapping.ts:60-69 — 模糊匹配 comptype → GLB 路径
    // 例如 "无变频开式冷却塔" → 包含 "冷却塔" → '/models/冷却塔.glb'
  }

→ useThreeOverview.ts:198
  const { model } = await modelLoader.loadModel(modelPath)
  //                        ↓
  // useModelLoader.ts:70-115 — GLTF 加载 + Meshopt/Draco 自动解码

→ useThreeOverview.ts:199
  autoScaleModel(model, MODEL_SIZE=3)
  // 统一缩放到 3 单位大小

Blob URL 纹理释放

src/three-preview/composables/useModelLoader.ts L27-49

GLTFLoader 加载时会将贴图转为 blob URL,不手动释放会造成内存泄漏:

function disposeTextures(object: Object3D) {
  object.traverse((child) => {
    // 遍历 11 种纹理类型
    const textures = [
      mat.map, mat.normalMap, mat.roughnessMap, mat.metalnessMap,
      mat.aoMap, mat.emissiveMap, mat.alphaMap, mat.envMap,
      mat.lightMap, mat.bumpMap, mat.displacementMap,
    ]
    for (const tex of textures) {
      if (tex?.image?.src?.startsWith('blob:')) {
        URL.revokeObjectURL(tex.image.src)    // 释放浏览器 blob 引用
      }
      tex?.dispose()                          // 释放 GPU 纹理
    }
  })
}

调用位置:

  • useModelLoader.ts L106 — 每次加载新模型后释放原始 scene 的纹理
  • useThreeOverview.ts L728-754clearOverview() 清理场景时释放所有纹理

6. ratio 参数选择建议

使用场景推荐 ratio说明
大场景全览(本项目)0.02 ~ 0.05模型占屏幕面积小,轮廓可识别即可
中等距离展示0.1 ~ 0.2需要保留主要结构细节
近距离单模型展示0.3 ~ 0.5保留较多细节,仍比原始小很多
高精度展示0.8+接近原始,仅做压缩不做减面

7. 验证优化效果

命令行检查

# 查看模型概要
npx @gltf-transform/cli inspect output.glb

# 查看每个 mesh 的详细信息
npx @gltf-transform/cli inspect --verbose output.glb

关注字段:

  • renderVertexCount:渲染顶点数,越小越好
  • uploadVertexCount:上传到 GPU 的顶点数

浏览器控制台检查

打开 3D 面板后查看控制台输出(由 useThreeOverview.ts L116 打印):

[3D] draw calls: 5, total triangles: 12.3k, nodes: 28
  • 优化前:201496.3k 三角面
  • 优化后:12.3k 三角面(以 28 个节点为例)

8. 新增模型的完整流程

当需要新增一种设备类型的 3D 模型时:

1. 获取原始 GLB 文件(从 3ds Max / Maya / Rhino 导出)

2. 压缩处理
   npx @gltf-transform/cli simplify --ratio 0.03 --error 0.01 原始.glb 中间.glb
   npx @gltf-transform/cli optimize --texture-compress webp 中间.glb 最终.glb

3. 放置文件
   将 最终.glb 命名为 设备名.glb,放入 public/models/

4. 注册映射 — modelMapping.ts L7-28
   在 MODEL_MAPPINGS 数组添加一项:
   { nodeType: '设备名', modelPath: '/models/设备名.glb', scale: 1.0 }

   在 AVAILABLE_MODELS 数组添加一项(如有模型选择面板):
   { label: '设备名', path: '/models/设备名.glb' }

5. 验证
   打开 3D 面板,检查控制台 [3D] draw calls 和 triangles 输出
   目视检查模型外观是否可接受

9. 注意事项

  1. 备份原始文件:减面是有损操作,务必保留 _backup.glb
  2. 视觉验收:优化后需在浏览器中目测效果,ratio 过低可能导致模型严重变形
  3. ratio 是目标比例,不是保证值:meshoptimizer 会在不超过 --error 限制的前提下尽量接近 ratio,实际结果可能略高
  4. WebP 兼容性:现代浏览器均支持 WebP,如需兼容旧浏览器可改用 --texture-compress jpeg
  5. 重新处理:如需调整 ratio,从 _backup.glb 重新处理,不要对已优化的文件再次减面
  6. Draco vs Meshoptoptimize 命令默认用 Meshopt,代码中两个解码器都已配置(useModelLoader.ts L60-65),两种格式都能正常加载