手里有一个已经在 three 里跑通的 3D 场景想把它变成一份能发给别人、能存进文件、能被别的软件打开的东西——这个念头一旦冒出来基本就绕不开 GLTFExporter 了。它不是什么玄乎的转换工具本质上是 three 官方生态里提供的一个导出器模块作用是把内存中的 Object3D 场景树序列化成符合 glTF 2.0 规范的 JSON 或者二进制 GLB。你在页面上拼出来的模型、调好的材质、烘焙好的贴图、做好的骨骼动画都可以经由它落成一个文件。这东西的价值在于“把运行时状态固化下来”可视化大屏要存档、模型编辑器要让用户下载成果、内容平台要让设计师上传预览、离线场景要交付给下游渲染管线。适合看这篇的人是已经把 three 跑起来、但卡在“导出来不对”这个环节的前端和图形开发也适合做 3D 内容管线、需要理解导出边界的产品和美术同学。下面我把这套东西从设计取舍到参数细节到踩坑实录按我自己的实操顺序摊开讲。1. 先把“导出”这件事的边界想清楚1.1 导出器不是转换器它只负责一次忠实搬运很多人对 GLTFExporter 的期待是“点一下什么格式都能出来”但它的定位其实窄得多。它只认 three 的场景图结构只输出 glTF 2.0 这一个规范版本中间不做任何几何简化、材质烘焙或者拓扑修复。你在场景里放的是什么它就原样搬什么材质不支持的字段直接丢掉、贴图拿不到就写成空引用全程几乎不报错只在控制台丢一行 warn。我第一次用它的时候就吃过这个亏一个用了自定义着色器做流光效果的模型导出后打开是一块纯灰因为 ShaderMaterial 不在支持列表里。把这一点想明白之后后面的很多设计选择就顺了。GLTFExporter 在整条链路里的位置是“运行时状态到交付文件的最后一公里”它前面的工作——模型减面、贴图压缩、材质归一到标准 PBR、动画轨道整理——都应该在导出之前完成。指望导出器帮你补方向就错了。这里有个容易被忽略的影响面导出器输出的产物决定了下游能接住多少。glTF 2.0 是当前通用性最好的运行时格式之一主流引擎、桌面建模软件、在线预览工具基本都能读但各家对扩展的支持参差不齐。你用了KHR_materials_transmission做玻璃A 工具能显示B 工具可能就是一块不透明的板。所以导出前最好先明确下游是谁再决定用多少扩展。1.2 为什么交付格式选 glTF 而不是别的这个选择背后是三个现实因素。第一是自描述性glTF 用 JSON 描述场景结构几何数据、材质参数、节点层级全是可读字段出问题时能直接打开文件看比二进制格式好排查太多。第二是 PBR 材质模型是规范内建的不用像 OBJ 那样靠 MTL 这种半野生约定材质和贴图的关系是明确的。第三是 GLB 单文件形态特别适合网络分发几何、贴图、动画全打进一个二进制块一次请求拿全不用处理一堆相对路径。代价也很明确glTF 是运行时格式不带建模历史不带参数化修改器导出去就回不来了。想保留可编辑性得另外存一份工程文件。另外 glTF 的坐标系约定是 Y 轴向上、右手系、-Z 为前单位是米这和 three 的默认约定是一致的所以导出前后模型朝向不会翻但导入到 Z 轴向上的桌面软件里就会看到模型躺着这是软件在做坐标转换不是导出出错。1.3 动手前先明确你要导出什么粒度同一份场景导出成“一个大 GLB”和导出成“若干独立部件”是两种完全不同的产物。整包导出简单一次 parse 就完事但后续想单独替换某个零件就得重新导分部件导出需要在导出前把场景树拆好每个部件单独走一次导出流程好处是便于增量更新和按需加载坏处是共享的纹理会被重复打包。我的经验是如果这个文件是给终端用户看的最终成品整包 GLB 最省事如果是给内容管理系统做素材库按“模型 材质 贴图”分离存储更灵活。这个决定要在写代码之前定下来因为它直接决定了你后面是操作一个 Scene还是操作一个 Object3D 数组。2. 导出前的场景体检三类必须先处理干净的数据2.1 几何体把“能渲染”和“能导出”区分开能渲染不代表能干净导出这是最容易翻车的地方。GLTFExporter 在遍历几何体时只认position、normal、uv、uv2、color、skinIndex、skinWeight这几类标准属性以及 morph 相关的属性。你要是塞了自定义 attribute 去做顶点动画导出时它会被静默忽略模型回到静止姿态。所以体检的第一步是把所有几何体的属性表打出来看一眼。function auditGeometry(root) { const report []; root.traverse((obj) { if (!obj.isMesh || !obj.geometry) return; const g obj.geometry; const attrs Object.keys(g.attributes); const unknown attrs.filter( (k) ![position, normal, uv, uv2, color, skinIndex, skinWeight].includes(k) ); const index g.index; const verts g.attributes.position ? g.attributes.position.count : 0; const tris Math.round((index ? index.count : verts) / 3); report.push({ name: obj.name, verts, tris, attrs, unknown }); }); return report; }除了属性还要看几何体是不是共享的。多个 Mesh 引用同一个 BufferGeometry 是常规优化导出器会自动去重这部分不用操心。真正要操心的是drawRange如果你靠它做了同一份几何的分段绘制导出时默认会按照drawRange截取没被覆盖到的顶点直接没了看起来就像模型被削掉一块。遇到这种情况把truncateDrawRange设成 false 就能保住完整数据。2.2 纹理跨域、编码和尺寸三件事纹理是导出失败率最高的一环因为 GLTFExporter 的编码方式很朴素——它把每张纹理画到一个离屏 canvas 上再用toDataURL转成 base64 塞进 JSON 或者二进制块。这条路一旦碰上被污染的 canvas就直接抛SecurityError整个导出中断。而 canvas 被污染的最常见原因就是纹理图片来自没有返回跨域头的第三方域名。所以贴图加载阶段就得把crossOrigin设上同时确认服务端确实返回了允许跨域的响应头const loader new THREE.TextureLoader(); loader.setCrossOrigin(anonymous); const tex loader.load( https://example-cdn.invalid/wood_diffuse.jpg, (t) { t.colorSpace THREE.SRGBColorSpace; }, undefined, (e) console.error(贴图加载失败, e) );拿不到跨域头的情况也不是没救。可以在加载阶段先把图片取成 Blob再转 ImageBitmap 或者画进一个自己创建的 canvas用这个干净的源替换掉texture.image导出器就只认这个源不会碰原始跨域图片。这个思路在需要从图床拉素材的场景里非常实用。尺寸这块容易被低估。一张 4096 的贴图转 base64 之后体积会膨胀约三分之一几张叠上去 GLB 轻松过百兆。maxTextureSize就是干这个的设成 2048 或者 1024导出器会自动等比缩小视觉上在移动端几乎看不出差别但体积能掉一大半。我一般默认给 2048明确知道是移动端场景就压到 1024。2.3 动画、骨骼与变形绑不上就等于没有动画导出有个隐性的前提animations数组里的每个 AnimationClip它所有 track 指向的目标对象必须真的在你要导出的那棵场景树里而且name要唯一。GLTFExporter 是靠名字去定位节点的场景里有两个都叫Arm的对象绑定就会错乱动画要么不播要么播到别的部件上。这个坑我在一个批量命名的角色场景里踩过排查花了很久因为报错信息完全没有提示。骨骼这边同理。SkinnedMesh 必须完成bind而且 Skeleton 的骨骼节点要挂在场景树里不能是一个游离的对象。如果骨骼是单独建出来、只做了引用没加进场景导出时会因为找不到对应的节点而丢掉蒙皮信息出来的模型是一堆炸开的碎片。Morph 变形则要确保morphTargetInfluences有值、morphAttributes里存的是相对位置而不是绝对位置这两点错了表现是模型在第一帧就变形。3. 核心参数逐个拆parse 的四组选项3.1 binary 与文本格式的取舍binary是最该先定的一个。设成 true 输出 ArrayBuffer也就是 GLB几何和贴图打进同一块二进制设成 false 输出的是一个 JSON 对象贴图以 base64 内嵌。两者信息量相同差别在体积和解析方式GLB 通常比等价的 JSON 小 20% 到 30%因为省掉了大量 base64 膨胀和 JSON 转义JSON 的好处是能直接肉眼读节点结构调试阶段特别好用。我的做法是调试期先出 JSON用编辑器打开确认节点树、材质引用、纹理索引都对得上没问题再切 binary 出正式产物。这个习惯帮我省过好几次返工因为 GLB 出了问题你得先解包才能看多一道工序。// 调试模式出 JSON直接看结构 const debugExporter new GLTFExporter(); debugExporter.parse(scene, (json) { console.log(JSON.stringify(json, null, 2).slice(0, 5000)); }, console.error, { binary: false, embedImages: true });3.2 变换、可见性与绘制范围trs控制节点变换的写法。默认 false导出器会把位置、旋转、缩放合成一个 4x4 矩阵写进matrix字段设成 true 则拆成translation、rotation、scale三个字段。规范里两种都合法但有些老一点的解析器对matrix和 TRS 混用的处理不一致尤其是节点既有 matrix 又有动画目标的时候容易出怪事。为了兼容性我在需要交付给不确定下游的场景里一律开trs: true代价是文件大一点点值得。onlyVisible默认 true隐藏对象不参与导出。这是把双刃剑做“编辑态隐藏、导出时还原”的交互很方便但如果你的场景里有靠visible false做分组的对象导出来就会莫名其妙少东西。我一般会在导出前跑一遍统计把不可见对象列出来确认一遍function listHidden(root) { const hidden []; root.traverse((o) { if (!o.visible) hidden.push(o.name || o.type); }); return hidden; }truncateDrawRange上一节说过了默认 true遇到分段绘制要关掉。这个参数不常改但一旦踩到就是致命问题值得在代码里留个注释标记。3.3 贴图内嵌与尺寸裁剪的组合策略embedImages默认 true把图像数据内嵌进文件。设成 false 时导出器会保留纹理引用但需要你自己保证图像 URI 能被下游解析到这在实际项目里极少用因为难点在于路径管理。我见过的唯一合理场景是 CDN 上已经有全套贴图、只想要一个轻量 JSON 指向它们这种情况下你得自己在导出后补齐images[].uri。maxTextureSize前面提过补充一个细节它是按长边等比缩放的不会破坏宽高比。另外它不影响已经内嵌为 ImageBitmap 的纹理尺寸判断如果你在加载阶段就做过了降采样这个参数就只是双保险。3.4 动画数组与自定义扩展的接入animations传的是 AnimationClip 数组不传就一个动画都不导出。这里有个很实用的技巧同一个场景可以有多个动画走路、待机、攻击一并传进去导出后它们会作为多个animation条目并存下游用名称索引切换。这比导出多份文件要优雅得多。exporter.parse(scene, onDone, onError, { binary: true, animations: [idleClip, walkClip, attackClip], trs: true, maxTextureSize: 2048 });includeCustomExtensions配合object.userData.gltfExtensions使用可以把业务侧的元数据写进扩展字段。我一般用它挂一些下游需要的标识比如构件编号、构件类型这样模型文件本身就带了语义信息不用再配一份映射表。注意这只对支持自定义扩展的解析器有意义单纯的预览工具会忽略它不影响正常显示。4. 完整实操从场景到可交付 GLB4.1 搭一个能复现问题的最小场景排查导出问题最有效的手段是把问题场景削减到最小。我习惯先搭一个包含四类要素的基准场景一个带贴图的立方体、一个带骨骼的简单网格、一段位移动画、一盏平行光。只要这四样能正确导出并在下游打开剩下的问题基本都是特例。import * as THREE from three; function buildBaselineScene() { const scene new THREE.Scene(); // 带贴图的立方体 const geo new THREE.BoxGeometry(1, 1, 1); const mat new THREE.MeshStandardMaterial({ color: 0x88aaff, roughness: 0.6 }); const box new THREE.Mesh(geo, mat); box.name BoxA; scene.add(box); // 灯光会被导出为 KHR_lights_punctual const light new THREE.DirectionalLight(0xffffff, 2); light.position.set(3, 5, 2); light.name KeyLight; scene.add(light); return { scene, box }; }这个场景跑通之后再把真实模型一点点塞进去哪一步出问题立刻就能定位。这个方法论比对着报错猜要高效得多尤其是在多材质多骨骼的复杂场景里。4.2 把导出流程封装成可复用的 Promise原生parse是回调式的混在 async 流程里很难看第一件事就是包一层 Promise。顺便把编码分支、文件名、体积统计都收进去以后所有导出都走这一个口子。function exportGLB(input, options {}) { return new Promise((resolve, reject) { const exporter new GLTFExporter(); exporter.parse( input, (result) { const blob new Blob([result], { type: model/gltf-binary }); resolve(blob); }, (error) reject(error), { binary: true, trs: true, onlyVisible: true, truncateDrawRange: true, embedImages: true, maxTextureSize: 2048, animations: [], ...options } ); }); } // 使用 const blob await exportGLB(scene, { animations: [clip] }); console.log(导出体积, (blob.size / 1024 / 1024).toFixed(2), MB);封装的时候有个细节值得说不要把 exporter 实例挂在全局复用。虽然它设计上是无状态的但内部有临时缓存多次复用偶尔会出现纹理引用串号的情况尤其是在连续导出多个不同场景时。每次新建一个实例成本可以忽略稳定性提升明显。4.3 产物验证三道关卡别省导出成功不等于导出正确这一步千万别跳。我的验证流程是三关第一关用规范校验工具跑一遍它会把结构性问题、扩展声明不一致、访问器越界这些硬错误全列出来第二关用在线 3D 预览工具打开看材质和动画这一关主要靠眼睛能发现贴图丢失、朝向不对、动画抽搐这类软问题第三关丢进桌面建模软件里导入一次重点看层级结构和顶点数是否和预期一致。校验工具有个很实用的用法是配合命令行批处理把一整个目录的模型全跑一遍输出成清单哪几个有问题一目了然。这个流程我在做素材批量入库的时候跑过上千个文件能提前拦截掉 90% 的问题。体积优化这一步也放在验证之后做因为优化会改变文件结构先验证原始产物能过再做压缩出问题容易切分责任。常见的后处理包括合并重复材质、量化顶点属性、把贴图转成更省空间的编码。这些都不是 GLTFExporter 的职责得靠外部工具链完成。# 通用的 glTF 后处理工具先看体检报告再做优化 npx gltf-transform inspect model.glb npx gltf-transform optimize model.glb model.opt.glb --texture-compress webpinspect输出的报告特别有用它会把顶点数、三角形数、纹理数、每种纹理的尺寸、动画数量和时长全列出来。很多时候你觉得模型“不该这么大”看一眼报告就明白了——往往是某张 4096 的贴图或者一段冗余的动画轨道占了绝大部分体积。4.4 无浏览器环境的导出思路偶尔会有服务端批量导出的需求比如用户提交配置后生成模型文件。这时候没有 DOM没有 canvasGLTFExporter 里画纹理那一步会直接崩。解决办法有两个方向一是给运行环境补齐 canvas 能力让它能正常绘制和编码二是干脆不在服务端导出改用无头浏览器把页面跑起来在页面里执行导出逻辑再取回结果。第一种方案体积轻但对带纹理的场景兼容性要自己验证尤其是涉及图片解码的部分。第二种方案重一些但保真度最高因为跑的就是真实的浏览器渲染环境导出的结果和用户本地点的“下载”完全一致。我倾向于第二种除非导出频率高到扛不住资源消耗那就得考虑把任务排队做异步处理。服务端导出还有个容易被忘的点内存。导出大型场景时纹理编码会临时占用大量内存几个 G 的峰值并不罕见容器内存限制设小了会被直接杀掉而且往往没有像样的报错日志只看到进程消失。给容器留足余量或者对单次导出的模型规模做限制都是必要的防护。5. 常见问题速查与排查路径5.1 空文件、黑模、贴图丢失导出结果是个几百字节的小文件打开啥都没有——这种情况九成是传进去的对象不是 Object3D。传了数组、传了 null、传了一个已经被销毁的场景导出器会走完流程但产出空场景。检查输入的isObject3D属性是否为 true是最快的定位方式。黑模的表现是模型在、但全黑。原因通常是材质转换失败或者灯光缺失。glTF 的 PBR 材质在没有环境光和环境贴图的情况下纯靠平行光会显得很暗这是正常的物理行为不是 bug。可以在导出的场景里补一张环境贴图或者让下游准备好 IBL 环境。如果是 ShaderMaterial那必然是黑的得先替换成标准材质。贴图丢失分两种一种是纹理对象在但图像数据没编进去表现为纯色另一种是整个纹理引用都断了。前者多半是 canvas 被污染导致toDataURL抛异常被吞掉后者是纹理挂在了一个不会被导出的槽位上。排查时把所有材质上的贴图槽位打出来逐个确认image是否存在、是否可读。5.2 动画不播、骨骼错位、尺寸异常动画不播的第一嫌疑是命名冲突第二嫌疑是 track 的目标路径写错。three 的 track 名格式是对象名.属性名比如BoxA.position对象名必须和场景树里的name完全一致差一个空格都不行。批量生成的对象如果都用默认名导出后几乎必定错乱所以在建模阶段就把命名规范定下来比事后救火划算太多。骨骼错位通常发生在骨骼节点没有加入场景树的情况。检查方式是遍历场景确认所有 Skeleton 的 bones 都能在树里找到对应节点。另外蒙皮网格的bindMatrix如果在导出前被手动改过也会导致姿态错乱这类改动最好在导出后撤销。尺寸异常基本都跟单位有关。glTF 约定单位是米three 里没有单位概念你场景里一个立方体边长设为 1就是 1 米。如果建模时用的是厘米为单位、导出前又做了缩放很容易出两米高的椅子。我的做法是在项目入口定一个“1 单位 1 米”的约定所有资产导入时统一换算导出时不再二次缩放。5.3 体积、精度与坐标轴的取舍体积过大最常见的三个原因贴图分辨率过高、动画关键帧过密、几何体没有做减面。三个都能通过inspect报告一眼看出来。处理优先级我一般按“贴图 动画 几何”来因为贴图的收益最高、视觉损失最小。精度问题容易被忽略。顶点位置用单精度浮点存储坐标值越大精度越差如果你把模型放在离原点几万米远的位置导出来的顶点会有肉眼可见的抖动。解决办法很简单导出前把模型挪回原点附近位置偏移放到节点变换上不要烘进顶点数据。坐标轴这块glTF 和 three 都是 Y 轴向上所以导出前后不会变。但导入到 Z 轴向上的软件里会看到模型侧躺这是软件在导入时自动做转换不是文件错了。验证方式是看同一个文件在浏览器预览和桌面软件里是不是同一个朝向逻辑——如果桌面软件里躺着说明转换没生效或者被重复转换了这才需要处理。6. 我在实际项目里踩出来的几条经验头一条先做减法再导出。绝大多数导出失败根源都在场景里存在导出器不认识的东西自定义着色器、非标准几何属性、游离的对象引用。与其在导出环节打补丁不如在资产入库阶段就定一套约束清单不符合规范的资产直接拦在门口。这个思路我用了两年多导出失败率从最初的接近一半降到了个位数。第二条永远给导出加一层超时和规模保护。一个几百万面的场景导出过程可能卡住主线程好几秒甚至更久用户会以为页面死了。导出前先算一下三角形总数和纹理总像素超过阈值就提示用户先做简化或者按照分片导出。我一般把阈值定在五十万面和四千万像素这两个数字在上千次实测里基本没出过错。第三条把导出参数集中配置别散落在各处。trs、maxTextureSize、onlyVisible这几个一旦在不同页面取值不同排查问题会非常痛苦因为你永远不确定看到的现象是场景问题还是参数问题。我现在的做法是维护一份导出配置按场景类型大屏、编辑器、素材库分几档预设代码里只引用档位不写魔法数字。第四条导出的产物一定要留一份基准样本做回归。模型结构、动画时长、材质列表这些关键指标存成快照每次升级 three 版本或者改动导出逻辑后跑一次对比能提前发现行为变化。这个习惯帮我抓到过一次版本升级导致的纹理色彩空间处理变化——导出文件看起来正常但颜色整体偏了一档不对比根本发现不了。最后分享一个排查小技巧如果下游打开模型发现某个部件位置偏移但 three 里看是对的先在导出前的场景里把所有节点的matrixAutoUpdate强制设成 true 并调用一次updateMatrixWorld(true)。很多位置异常是因为矩阵没及时刷新导出器读到的是旧数据。这一行代码救过我至少三次。
阅读完成 · 觉得有帮助?