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

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


1. 为什么要压缩

问题现象

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

诊断方式

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

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 Meshoptoptimize 命令默认用 Meshopt,代码中两个解码器都已配置(useModelLoader.ts L60-65),两种格式都能正常加载
相关推荐
毛骗导演11 小时前
Cladue Code 源码解析-键盘事件与 Vim 模式:parse-keypress 解析状态机
前端·架构
疯狂成瘾者11 小时前
Prompt分层策略
前端·数据库·prompt
kyriewen11 小时前
你的数据该在哪儿拿?Next.js三种姿势一次讲清
前端·javascript·next.js
前端AI充电站11 小时前
第 7 篇:让 RAG 答案可追溯:展示知识库引用来源
前端·人工智能·前端框架
MY_TEUCK11 小时前
【AI 应用】前端接口联调工程化:把 Swagger 接入沉淀成可复用 Skill
前端·人工智能·uni-app·状态模式
kyriewen11 小时前
别再乱装图片插件了!我手写了一个,能扒光整个网页(含背景/iframe/Shadow DOM)
前端·chrome·浏览器
rrr211 小时前
【前端开发】|GUI 基本概念和框架基础
前端·qt
方安乐11 小时前
前端“硬核”性能优化
前端
前端AI充电站11 小时前
第 9 篇:让 AI 助手记住会话:示例问题点击发送与 localStorage 持久化
前端·人工智能·前端框架