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

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


1. 为什么要压缩

问题现象

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

诊断方式

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

ts 复制代码
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}`)

诊断结果

yaml 复制代码
draw calls: 6, total triangles: 201496.3k, nodes: 153

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

原始模型数据

模型 GLB 文件路径 原始大小 估算三角面数
三通 public/models/三通.glb 78.4 MB ~130 万
冷却塔 public/models/冷却塔.glb 74.9 MB ~130 万
冷水机组 public/models/冷水机组.glb 78.7 MB ~130 万
水泵 public/models/水泵.glb 24.1 MB ~40 万

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

代码中的模型路径映射

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

ts 复制代码
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)

两阶段处理流程

scss 复制代码
原始 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 个子步骤

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

Meshopt 压缩原理

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


3. 操作步骤

工具

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

bash 复制代码
npx @gltf-transform/cli --version
# 4.3.0

第一阶段:减面

bash 复制代码
npx @gltf-transform/cli simplify \
  --ratio 0.03 \
  --error 0.01 \
  input.glb output_simplified.glb

参数说明:

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

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

第二阶段:全量压缩

bash 复制代码
npx @gltf-transform/cli optimize \
  --texture-compress webp \
  input_simplified.glb output_final.glb

批量处理脚本

bash 复制代码
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

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

bash 复制代码
npx @gltf-transform/cli optimize --texture-compress webp input.glb output.glb

4. 压缩结果

模型 原始大小 优化后 压缩率
三通 78.4 MB 903 KB 99%
冷却塔 74.9 MB 817 KB 99%
冷水机组 78.7 MB 966 KB 99%
水泵 24.1 MB 623 KB 97%

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


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

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

解码器配置

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

ts 复制代码
// 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

scss 复制代码
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()

模型文件加载链路

scss 复制代码
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,不手动释放会造成内存泄漏:

ts 复制代码
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-754 --- clearOverview() 清理场景时释放所有纹理

6. ratio 参数选择建议

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

7. 验证优化效果

命令行检查

bash 复制代码
# 查看模型概要
npx @gltf-transform/cli inspect output.glb

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

关注字段:

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

浏览器控制台检查

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

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

8. 新增模型的完整流程

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

css 复制代码
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 Meshopt :optimize 命令默认用 Meshopt,代码中两个解码器都已配置(useModelLoader.ts L60-65),两种格式都能正常加载
相关推荐
lovingsoft4 小时前
Chrome Lighthouse 详解
前端·chrome
Qlly4 小时前
全栈工程师怎么接需求?从一句「加个功能」聊到能开发、能验收
前端·后端
马可家的菠萝4 小时前
你的微信“文件传输助手”,是不是已经成了第二个网盘?
前端·后端·架构
略略略啦4 小时前
Vibe Coding 基于Spec DD和TDD的实践指南
前端
shmily麻瓜小菜鸡4 小时前
前端实现图片与PDF文字识别敏感信息打码方案
前端·pdf·状态模式
打工仔折腾 AI5 小时前
LangChain 工具调用实战:从 Tool 定义到 Agent 执行链路拆解
java·前端·人工智能·后端·python·langchain·ai agent 实战
汉堡大王95275 小时前
单次输出拉到100万Token:拆完 Gemini 4 Argon 的续写协议,才看懂谷歌这7个月在憋什么
前端·人工智能·后端
看浪的路人5 小时前
第9讲:大模型应用监控与可观测性——OpenTelemetry + Prometheus + Grafana 全链路追踪
java·前端·spring
内存过载5 小时前
用表格模拟甘特图:项目排期的土办法
前端·低代码
葡萄城技术团队5 小时前
用 SpreadJS 数据验证实现省市区三级联动
前端