前言
在 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 数量也不夸张:
- 约 106 个 node
- 约 52 个 mesh
- 约 12 张 image/texture
- 无骨骼动画或只有少量动画时也可能触发
这说明原始文件本身可以正常加载,风险发生在“加载后再次导出”的过程。导出器会重新遍历 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.userData 和 mesh.userData。这些对象非常适合运行时使用,但不适合交给 JSON 序列化。
所以轻量导出的关键不是“先 clone 再清理”,而是避免触发 Material.clone() 的 userData 序列化路径。
第四阶段:手工创建干净材质,同时保留贴图效果
一开始我们为了稳定导出,直接移除了所有贴图。速度确实快了,但导出的模型几乎变成白模,因为很多 GLB 的颜色来自 map、normalMap、metalnessMap、roughnessMap 等贴图通道。
最终方案是手工创建一个干净的 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,传给 GLTFExporter 的 animations 选项:
const options = {
binary: true,
trs: true,
onlyVisible: true,
maxTextureSize: 1024,
animations: getModelExportAnimations(model.id),
}
第六阶段:用户体验也要兜底
导出这类重任务时,不能只关注算法,还要处理 UI 状态。我们在顶部按钮中加入了导出阶段提示和运行时异常兜底:
准备导出 1/5:表示正在逐模型准备导出导出模型 1/5:表示当前模型正在编码正在打包...:表示多个 GLB 正在打包 zip- 捕获
RangeError: Invalid string length后释放按钮 loading 状态
最终按钮文案也改成了“导出GLB(轻量版)”,明确告诉用户这是为大模型稳定导出设计的路径。
最终方案总结
- 逐模型导出:降低单次
GLTFExporter的内存峰值。 - zip 打包:避免浏览器连续弹出多个下载。
- mesh 级兜底:单个模型过大时继续拆分。
- 禁用 exporter 的 userData 序列化:避免 extras 写入无关运行时数据。
- 手工创建轻量材质:避开
Material.clone()的JSON.stringify(userData)。 - 保留贴图通道:让导出结果保留用户调好的材质效果。
- 骨骼模型使用 SkeletonUtils.clone:保留
SkinnedMesh、skeleton 和 skin 绑定。 - 导出全部模型内置动画:不依赖编辑器中动画是否开启。
结语
Web 端 3D 导出最容易误判的问题,是把“文件能加载”理解为“文件能原样导出”。加载和导出是两条完全不同的链路:加载只需要解析模型,导出需要重新组织整个 Three.js 对象图、材质、贴图、动画和二进制数据。
这次优化的核心经验是:导出路径应该是专门设计的干净数据通道。运行时为了交互、恢复、联动而写入的状态,应该在导出前被清理或转换成真正属于 glTF 的结构。