3D 编辑器 GLB 导出卡顿优化:从 Invalid string length 到轻量版导出

前言

在 SceneView 的 3D 编辑器中,我们最近集中处理了一类很典型的 Web 端导出问题:模型在编辑器里能正常加载、能正常预览,但点击导出 GLB 时浏览器明显卡顿,严重时直接报错:

Uncaught (in promise) RangeError: Invalid string length
    at JSON.stringify (<anonymous>)

THREE.Texture: Unable to serialize Texture.

这个问题的表象是导出慢,真正的根因分散在 GLTFExporter、材质贴图、userData、模型重复导出和骨骼动画绑定几个环节中。

为什么 GLB 也会走 JSON.stringify

很多人第一反应是:GLB 是二进制文件,为什么会触发 JSON.stringify?原因是 GLB 只是一个二进制容器,内部仍包含一段 JSON chunk。

GLB Header
JSON Chunk  // 场景结构、节点、材质、bufferView、accessor、动画描述
BIN Chunk   // 顶点、索引、图片等二进制数据

GLTFExporter 在生成 GLB 时,会先构造 glTF JSON 对象,再执行 JSON.stringify(json),最后把 JSON chunk 和 BIN chunk 拼成一个 .glb 文件。只要 JSON 对象中混入了超大的数据,或者大量重复结构,浏览器就可能触发字符串长度上限。

第一阶段:确认不是简单的文件体积问题

我们排查过一个约 82 MB 的 GLB 模型。它的原始 GLB JSON chunk 只有约 50 KB,节点和 mesh 数量也不夸张:

这说明原始文件本身可以正常加载,风险发生在“加载后再次导出”的过程。导出器会重新遍历 Three.js 场景对象,并把材质、贴图、动画、节点关系重新序列化。

第二阶段:逐模型导出,降低单次导出峰值

最初的导出逻辑是把整个场景塞进一个 THREE.Scene,再一次性交给 GLTFExporter

const exportScene = new THREE.Scene()
for (const model of models.value) {
  exportScene.add(modelGroups[model.id].clone(true))
}

const result = await exporter.parseAsync(exportScene, {
  binary: true,
  trs: true,
  onlyVisible: true,
})

这个方式对小场景足够简单,但一旦场景里有多个大模型,导出器会在一次任务中构造完整 JSON 和完整二进制 buffer,内存峰值很高。

优化后的策略是:每个模型单独导出一个 GLB,最后用 fflate 打成 zip。这样单次 GLTFExporter 只处理一个模型。

const files = []
for (const model of models.value) {
  const exportScene = new THREE.Scene()
  exportScene.add(cloneGroupForExport(modelGroups[model.id], { lightweight: true }))

  const data = await exportSingleScene(exportScene, getModelExportAnimations(model.id))
  files.push({ name: model.name, data })
}

downloadZip(files)

如果某个单模型仍然触发 RangeError,再进入 mesh 级拆分兜底,把一个大模型拆成多个 mesh GLB。

第三阶段:真正的坑在 Material.clone 和 userData

后续发现,即使逐模型导出,仍有模型在轻量模式下报错。原因并不只是贴图大,而是 Three.js 的 Material.clone() 会复制 userData

copy(source) {
  this.userData = JSON.parse(JSON.stringify(source.userData))
}

编辑器为了支持材质恢复、材质预设、贴图替换,会把一些原始材质信息、贴图对象或恢复信息放进 material.userDatamesh.userData。这些对象非常适合运行时使用,但不适合交给 JSON 序列化。

所以轻量导出的关键不是“先 clone 再清理”,而是避免触发 Material.clone() 的 userData 序列化路径。

第四阶段:手工创建干净材质,同时保留贴图效果

一开始我们为了稳定导出,直接移除了所有贴图。速度确实快了,但导出的模型几乎变成白模,因为很多 GLB 的颜色来自 mapnormalMapmetalnessMaproughnessMap 等贴图通道。

最终方案是手工创建一个干净的 MeshStandardMaterial,只复制必要参数和贴图引用,并清空贴图 userData

function createLightweightExportMaterial(material) {
  const clonedMaterial = new THREE.MeshStandardMaterial()
  clonedMaterial.name = material.name || ''
  if (material.color) clonedMaterial.color.copy(material.color)
  if (material.emissive) clonedMaterial.emissive.copy(material.emissive)
  clonedMaterial.emissiveIntensity = material.emissiveIntensity ?? 0
  clonedMaterial.metalness = material.metalness ?? 0
  clonedMaterial.roughness = material.roughness ?? 0.5
  clonedMaterial.opacity = material.opacity ?? 1
  clonedMaterial.transparent = material.transparent || clonedMaterial.opacity < 1
  clonedMaterial.side = material.side ?? THREE.FrontSide
  copyExportTextureMaps(material, clonedMaterial)
  clonedMaterial.userData = {}
  return clonedMaterial
}
function copyExportTextureMaps(sourceMaterial, targetMaterial) {
  const textureKeys = ['map', 'normalMap', 'metalnessMap', 'roughnessMap', 'emissiveMap', 'alphaMap', 'aoMap']
  for (const key of textureKeys) {
    const texture = sourceMaterial[key]
    if (texture?.isTexture) {
      texture.userData = {}
      targetMaterial[key] = texture
    }
  }
}

这样导出的 GLB 仍保留用户调好的材质效果,同时避开了 userData 被深度 JSON 序列化的问题。

第五阶段:骨骼动画必须保留 SkinnedMesh 和 skeleton

普通 mesh 可以手工复制几何、矩阵和材质,但骨骼蒙皮动画不能这样做。如果把 SkinnedMesh 降级为普通 Mesh,导出的模型会失去 skin 绑定,动画也无法正确驱动骨骼。

因此骨骼模型使用 SkeletonUtils.clone 保留骨架结构,但在 clone 前临时清空对象 userData,clone 后再恢复原场景数据:

function cloneSkinnedGroupForLightweightExport(group) {
  return withClearedObjectUserData(group, () => SkeletonUtils.clone(group))
}

function withClearedObjectUserData(root, callback) {
  const records = []
  root.traverse(obj => {
    records.push([obj, obj.userData])
    obj.userData = {}
  })

  try {
    return callback()
  } finally {
    for (const [obj, userData] of records) {
      obj.userData = userData
    }
  }
}

动画导出则使用模型加载时记录的 AnimationClip,传给 GLTFExporteranimations 选项:

const options = {
  binary: true,
  trs: true,
  onlyVisible: true,
  maxTextureSize: 1024,
  animations: getModelExportAnimations(model.id),
}

第六阶段:用户体验也要兜底

导出这类重任务时,不能只关注算法,还要处理 UI 状态。我们在顶部按钮中加入了导出阶段提示和运行时异常兜底:

最终按钮文案也改成了“导出GLB(轻量版)”,明确告诉用户这是为大模型稳定导出设计的路径。

最终方案总结

  1. 逐模型导出:降低单次 GLTFExporter 的内存峰值。
  2. zip 打包:避免浏览器连续弹出多个下载。
  3. mesh 级兜底:单个模型过大时继续拆分。
  4. 禁用 exporter 的 userData 序列化:避免 extras 写入无关运行时数据。
  5. 手工创建轻量材质:避开 Material.clone()JSON.stringify(userData)
  6. 保留贴图通道:让导出结果保留用户调好的材质效果。
  7. 骨骼模型使用 SkeletonUtils.clone:保留 SkinnedMesh、skeleton 和 skin 绑定。
  8. 导出全部模型内置动画:不依赖编辑器中动画是否开启。

结语

Web 端 3D 导出最容易误判的问题,是把“文件能加载”理解为“文件能原样导出”。加载和导出是两条完全不同的链路:加载只需要解析模型,导出需要重新组织整个 Three.js 对象图、材质、贴图、动画和二进制数据。

这次优化的核心经验是:导出路径应该是专门设计的干净数据通道。运行时为了交互、恢复、联动而写入的状态,应该在导出前被清理或转换成真正属于 glTF 的结构。