本文档记录 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.tsL7-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.tsL2-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.tsL70-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.tsL27-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.tsL106 --- 每次加载新模型后释放原始 scene 的纹理useThreeOverview.tsL728-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. 注意事项
- 备份原始文件 :减面是有损操作,务必保留
_backup.glb - 视觉验收:优化后需在浏览器中目测效果,ratio 过低可能导致模型严重变形
- ratio 是目标比例,不是保证值 :meshoptimizer 会在不超过
--error限制的前提下尽量接近 ratio,实际结果可能略高 - WebP 兼容性 :现代浏览器均支持 WebP,如需兼容旧浏览器可改用
--texture-compress jpeg - 重新处理 :如需调整 ratio,从
_backup.glb重新处理,不要对已优化的文件再次减面 - Draco vs Meshopt :
optimize命令默认用 Meshopt,代码中两个解码器都已配置(useModelLoader.tsL60-65),两种格式都能正常加载