说个挺现实的事Unity里导FBX这件事被问得最多的恰恰不是“编辑器里怎么导”而是“游戏跑起来了玩家在编辑器里搭了个东西我怎么把它存成FBX给用户带走”。Unity运行时导出FBX模型官方并没有暴露一个一行代码就能搞定的接口网上能找到的资料又大多停留在“编辑器扩展菜单里导出”的层面真到了运行时、到了移动端能用的方案得自己拼。这篇文章就是把我自己实现过的一套方案完整拆开从选型、数据采集、格式生成到验证兜底把能跑的路径给出来适合正在做UGC工具、程序化建模、或者需要把运行时Mesh交给外部DCC工具继续编辑的Unity开发者参考。1. 运行时导出FBX先搞清楚你要导的到底是什么1.1 三个特别典型的“导出到一半发现不对劲”的场景第一个场景是用户生成内容。我曾接过一个类似沙盒建造的需求玩家用预制体拼出房子、地形、装饰物点一下就希望导出成一个模型文件发到社区里其他人导入Maya或者3D打印前还能继续改。这种需求在编辑器模式下很好做但玩家在的是客户端不是Unity编辑器。第二个场景是程序化建模的成果落盘。有些项目里模型不是美术做的而是运行时根据参数生成的比如管道路径、桁架结构、地形Mesh。生成的Mesh放在内存里没问题可一旦要交给下游工程软件或者存档分享就得有个通用格式。第三个场景是角色姿势快照。运行时角色被动画驱动到了特定姿势你想把当前这一帧的模型“冻结”下来导出比如做游戏内截图、动作对比、模型摆拍。这个在编辑器导出流程里很别扭但运行时用SkinnedMeshRenderer.BakeMesh再把烘焙结果导出反而是顺理成章的做法。这三个场景都有一个共同点导出动作必须发生在Runtime不能依赖UnityEditor命名空间。1.2 为什么Unity没给你一条现成的运行时导出API很多人会疑惑Unity能导入FBX是不假编辑器里通过“Export Package”或者第三方工具也能导FBX怎么到运行时就不行了原因在于FBX的解析和导出核心是Autodesk的C SDKUnity引擎内部集成的是导入侧能力而且是通过原生插件完成的。导出侧能力在编辑器里要么是SDK封装、要么是Asset Store工具基于编辑器API实现这些统统不能直接用在Runtime。更麻烦的是Autodesk FBX SDK的许可和部署问题。你要在发布包里带上SDK动态库要处理多平台的原生库还要考虑授权协议是否允许你分发。这一套成本对大多数中小项目来说太重了所以运行时导出FBX才会变成一个“看起来应该有、实际只能自己搞”的问题。1.3 我这次只解决这三件事写导出器之前先得把需求边界框住否则会掉进FBX完整格式的无底洞。以最常见的“把当前场景里的模型对象导出给外部工具继续编辑”为例子我建议第一版只做三件事静态网格导出顶点、法线、UV、三角形索引必须完整这是所有后续功能的地基。Transform层级导出父节点、子节点、每个节点的局部位置/旋转/缩放至少让层级关系在Maya、Blender里保持原样。简单材质映射能导出漫反射颜色作为FBX内的Phong材质即可。至于骨骼动画、多UV、PBR材质、顶点色、变形目标这些先把静态链路跑通再去扩展。我见过不少人在第一步就非要支持全骨骼动画结果写了一个月还没导出一个能看的Cube这是典型的投入节奏错误。2. 技术选型三条路线我都试过说说取舍2.1 官方FBX SDK血统最正代价也最重Autodesk官方提供C FBX SDK也是Unity编辑器里很多FBX插件的底层实现。用它做导出功能覆盖最完整几何体、骨骼、动画、相机、灯光、PBR材质几乎不会遇到“格式写不对”的问题。问题在于部署。SDK体积大原生依赖多移动端还需要对应架构的编译产物。许可方面也不是你写个工具就能随意打包分发的需要仔细核对条款。维护上SDK版本更新、Unity升级导致的原生桥接问题都够喝一壶的。结论是如果你的需求是做一个大型商业软件并且有能力承担原生SDK的集成和分发成本那用官方SDK是合理的。但如果只是游戏内的一个导出功能我不推荐。2.2 手写ASCII FBX偏门但真的可行FBX文件格式有二进制和ASCII两种。二进制格式是闭源的即便有人逆向出结构稳定性也堪忧但ASCII格式是文本化的结构相对清晰而且FBX官方SDK、Blender、Maya全都认。很多开源工具导出FBX时走的其实就是生成ASCII FBX的路线或者在内部构建同样的数据结构后再编码成binary。手写ASCII FBX的优势在于没有外部依赖发布包里不需要塞动态库。生成逻辑全部可控能按需裁剪。代码量也就是几百行适合集成进Unity的Runtime管线。缺点也明确不支持FBX全部特性动画和复杂材质要自己逐步加生成的文本文件比二进制大不少格式细节必须抠得很细一个字段写错导入端就可能直接忽略节点。但我的判断是绝大多数“运行时导出静态模型”的需求手写ASCII FBX就是最优解。2.3 Assimp这类通用模型库导入是强项导出不是AssimpOpen Asset Import Library在导入侧确实万能能读几十种格式。但它的FBX导出能力其实比较有限生成的FBX经常被Blender以外的工具挑毛病。而且它本质是native库Unity里要用只能走Native Plugin又绕回了多平台原生依赖的问题。移动端包体积、AOT问题、Unity生命周期管理代价不小。如果你想做的其实是“模型格式转换”后台跑一个服务用Assimp没问题但你要是想给Unity运行时里塞一个稳定、轻量的FBX导出能力这条路不是不能走只是性价比不高。2.4 我的选择手写然后把这个能力封装成一个独立模块做个对比表你就能理解我为什么最后选了手写维度官方FBX SDK手写ASCII FBXAssimp导出功能完整性极完整静态模型够用逐步扩展有限依赖版本发布包体积大原生库多几乎为零中多平台原生库授权风险需要核对分发条款格式本身可生成无运行时依赖宽松但动态库分发需注意移动端兼容麻烦无特殊负担需要专门适配上手调试成本高中格式可见可打印中手写方案其实没有大家想象的那么神秘FBX ASCII的骨架就五大块接下来我会逐一拆开看并对应到Unity里的数据源。3. Unity数据源与FBX格式的逐项对应3.1 FBX文件骨架五大块一个标准的ASCII FBX文件核心结构只有五部分。理解了这五块你就不怕面对FBX格式了FBXHeaderExtension版本号、生成工具、时间戳之类。GlobalSettings坐标轴方向、单位比例、时间相关设置。Definitions声明文件里会出现哪些对象类型以及个数。Objects真正的数据本体Geometry、Model、Material都在这里。Connections对象之间的父子关系、绑定关系全部用ID关联。这五块的顺序不能乱但某些字段缺失时导入器也能容忍。最佳实践是把这五块全部写出来哪怕字段少一点也比缺块强。3.2 Unity Mesh到FBX Geometry的字段映射Mesh是导出的数据核心对应的FBX对象叫Geometry。用得最频繁的映射关系如下Unity侧FBX侧字段需要做的事mesh.verticesVertices乘以变换矩阵转成世界坐标mesh.normalsLayerElementNormal乘以法线矩阵再归一化mesh.uvLayerElementUV基本可以原样拷贝mesh.tangentsLayerElementTangent前三位是方向w是副切线方向符号mesh.triangles/GetTriangles(i)PolygonVertexIndex每个三角形最后一个索引要做负数编码mesh.subMeshCountLayerElementMaterialMaterials多子网格时记录材质索引这里最容易忽略的前提是mesh.isReadable必须为true。如果你导出的是运行时动态生成的Mesh默认没问题如果是从AssetBundle加载或者启动时UploadMeshData(false)释放过CPU数据顶点数组可能读不回来代码直接抛异常。所以在导出入口处要做一次可读性检查并且用日志明确提示。3.3 Transform层级到Model节点FBX里场景中的每个节点包括空物体、网格物体、骨骼都是Model对象。Model和Geometry是分开的一个Model可以挂一个Geometry通过Connections里的OO连接绑定。对于层级关系FBX用Connections维护父子链。Unity侧递归遍历Transform子树时把每个Transform映射成一个Model节点每个Model记录唯一ID节点名子节点列表该节点对应的Geometry ID如果有Mesh对应的Material ID如果有渲染器这里有个策略选择要么把Mesh顶点直接烘焙到世界坐标Model节点Transform全部写单位矩阵要么保留Mesh局部坐标Model节点写TRS。我在MVP阶段强烈建议选前者因为省掉了欧拉角顺序的麻烦也避免导入DCC后层级变换叠加出诡异结果。代价是Maya里没法再单独拖动某个子物体因为顶点已经是最终坐标了但对于“导出静态成品”这个需求这个代价完全值得。3.4 材质和贴图先做最小映射FBX的Material对象支持phong、lambert等经典模型。Unity这边一个Material要完整映射到FBX的PBR属性说实话挺累而且不同Shader的属性名五花八门。第一版我通常只映射一个漫反射颜色Color diffuse Color.white; if (mat.HasProperty(_BaseColor)) diffuse mat.GetColor(_BaseColor); else if (mat.HasProperty(_Color)) diffuse mat.GetColor(_Color);然后写到FBX的DiffuseColor字段里。这看起来简陋但导入Maya、Blender后至少物体有正确的基色不黑不粉。后续如果确实要贴图路径再按需加Texture对象和FileName字段不要在MVP阶段铺开。3.5 别忘了SkinnedMeshRenderer的BakeMesh如果你想导出的是当前姿势下的角色模型直接用sharedMesh是不行的那是绑定姿势而且没有蒙皮后的效果。正确操作是先建一个临时Mesh调用SkinnedMeshRenderer.BakeMesh(tempMesh)把当前骨骼姿态下的顶点位置烘焙出来然后再走常规的Mesh导出流程。Mesh baked new Mesh(); skinnedMeshRenderer.BakeMesh(baked); // 之后把baked当成普通Mesh处理即可这个操作之所以重要是因为它把蒙皮计算和FBX导出解耦了。你不用为第一版就去实现FBX的Skin、Cluster、BindPose那一整套东西也能满足“当前姿势快照导出”的一大批真实需求。4. 手写一个最小可用的ASCII FBX导出器4.1 收集阶段建场景树、分配ID、统计数量写导出器之前先别急着写字符串。需要先做一次完整的收集因为FBX的Definitions里要写清楚有哪些对象类型和个数这要求你提前知道整个树有多少Geometry、多少Model、多少Material。我常用的做法是定义两个数据结构class FbxNodeData { public long fbxId; public string name; public Transform transform; public Mesh mesh; public string materialName; public Color diffuseColor; public ListFbxNodeData children new ListFbxNodeData(); } class FbxExportContext { public ListFbxNodeData nodes new ListFbxNodeData(); public int geometryCount; public int modelCount; public int materialCount; public HashSetstring materialNames new HashSetstring(); }递归遍历根节点每个Transform生成一个FbxNodeData。碰到MeshFilter MeshRenderer就取sharedMesh碰到SkinnedMeshRenderer就BakeMesh。同时把材质去重给每个不同颜色/名字的材质分配一个ID。遍历的同时给每个节点发ID从1000开始自增即可。这里有一个我建议从一开始就养成的习惯不要用Unity的GetInstanceID直接当FBX ID。实例ID在运行时是唯一的但它可能是负数而且如果你在导出过程中动态创建/销毁过对象实例ID可能复用更麻烦的是你无法保证它稳定大于0。用自己自增的long最省心。4.2 输出Geometry顶点、法线、UV、三角面Geometry是文件里最核心的部分也是格式细节最密集的地方。我直接用代码片段来说明这段代码实际可跑private static void WriteGeometry(FbxExportContext ctx, FbxNodeData node, FbxTextBuffer buf) { Mesh mesh node.mesh; if (mesh null) return; Vector3[] vertices mesh.vertices; Vector3[] normals mesh.normals; Vector2[] uvs mesh.uv; long geoId node.geometryId; buf.WriteLine($\tGeometry: {geoId}, \Geometry::{node.name}\, \Mesh\ {{); // Vertices buf.WriteLine($\t\tVertices: *{vertices.Length * 3} {{); buf.Write(\t\t\ta: ); int total vertices.Length; int last total - 1; for (int i 0; i total; i) { Vector3 v node.bakedWorldMatrix.MultiplyPoint3x4(vertices[i]); buf.Write(p(v.x)); buf.Write(, ); buf.Write(p(v.y)); buf.Write(, ); buf.Write(p(v.z)); if (i ! last) buf.Write(, ); } buf.WriteLine(); buf.WriteLine(\t\t}); // PolygonVertexIndex每个三角形的最后一个索引写成负数 int subMeshCount mesh.subMeshCount; int polygonCount 0; for (int s 0; s subMeshCount; s) { int[] tris mesh.GetTriangles(s); polygonCount tris.Length / 3; } buf.WriteLine($\t\tPolygonVertexIndex: *{polygonCount * 3} {{); buf.Write(\t\t\ta: ); bool first true; for (int s 0; s subMeshCount; s) { int[] tris mesh.GetTriangles(s); for (int t 0; t tris.Length; t 3) { int i0 tris[t]; int i1 tris[t 1]; int i2 tris[t 2]; if (!first) buf.Write(, ); first false; buf.Write(i0.ToString(CultureInfo.InvariantCulture)); buf.Write(, ); buf.Write(i1.ToString(CultureInfo.InvariantCulture)); buf.Write(, ); // 关键最后一个索引用负数编码规则是 -(i1) buf.Write((-i2 - 1).ToString(CultureInfo.InvariantCulture)); } } buf.WriteLine(); buf.WriteLine(\t\t}); // 法线、UV等LayerElement代码类似省略具体数组 WriteNormals(mesh, node, buf); WriteUVs(mesh, uvs, buf); buf.WriteLine(\t}); }上面代码里的p(float)是一个格式化辅助函数就是value.ToString(R, CultureInfo.InvariantCulture)。三角形索引的负数编码是这个功能最容易翻车的地方后面我会单独讲。4.3 输出Model与变换属性如果采用烘焙到世界坐标的方案Model的Transform可以全部写成单位值buf.WriteLine($\tModel: {node.fbxId}, \Model::{node.name}\, \Mesh\ {{); buf.WriteLine(\t\tVersion: 232); buf.WriteLine(\t\tProperties70: {); buf.WriteLine(\t\t\tP: \Lcl Translation\, \Lcl Translation\, \\, \A\, 0,0,0); buf.WriteLine(\t\t\tP: \Lcl Rotation\, \Lcl Rotation\, \\, \A\, 0,0,0); buf.WriteLine(\t\t\tP: \Lcl Scaling\, \Lcl Scaling\, \\, \A\, 1,1,1); buf.WriteLine(\t\t}); buf.WriteLine(\t\tShading: T); buf.WriteLine(\t\tCulling: \CullingOff\); buf.WriteLine(\t});这里node.bakedWorldMatrix是transform.localToWorldMatrix在收集阶段就要算好并缓存下来。如果后续你想改成不烘焙、保留局部TRS的方式那就要把transform.localPosition/localRotation/localScale写进Lcl Translation/Rotation/Scaling并且必须处理欧拉角顺序。我建议第一版老实烘焙因为FBX的欧拉角顺序和Unity不一样写错之后物体在Maya里转得乱七八糟排查起来非常痛苦。4.4 Connections子在前父在后的坑Connections是很多初学者写反的地方。FBX的规则是子对象ID在前父对象ID在后。buf.WriteLine(Connections: {); foreach (var node in ctx.nodes) { long parentId node.parent null ? 0 : node.parent.fbxId; buf.WriteLine($\tC: \OO\, {node.fbxId}, {parentId}); if (node.mesh ! null) buf.WriteLine($\tC: \OO\, {node.geometryId}, {node.fbxId}); if (!string.IsNullOrEmpty(node.materialName)) buf.WriteLine($\tC: \OO\, {node.materialId}, {node.fbxId}); } buf.WriteLine(});注意根节点不是某个Model的ID而是固定用0表示。也就是说最顶层的Model连接时写的是C: OO, rootModelId, 0。我在第一次实现时把这层连接写反了结果Maya里整个场景没有根所有物体散落在Outliner里排查了一晚上。5. 三个容易翻车的细节精度、坐标系、法线矩阵5.1 ToString不改文化小数点变逗号这是所有手写文本格式的通用陷阱。如果你的操作系统区域设置是德语、法语等float.ToString()默认会把小数点输出成逗号。FBX解析器可不管你是什么文化a: 0,5和a: 0, 5会被解析成两个数字整个文件就直接废了。正确做法是统一使用private static string F(float v) { return v.ToString(R, CultureInfo.InvariantCulture); }R格式表示往返格式能保证float转成字符串再转回来数值不变。代价是文件里小数位数偏多但正确性优先。如果想控制文件体积可以自己封装一个G7格式能保留约7位有效数字绝大多数建模软件完全够用。5.2 单位、Y轴与GlobalSettingsUnity默认单位是米且是Y轴向上的左手坐标系。FBX内部最通用的约定是厘米制、Y轴向上。这一节处理好模型导入Maya才不会突然变大100倍或者躺倒。我的做法是双保险导出的顶点数据在烘焙时乘以100同时把UnitScaleFactor写成1。这样FBX文件里的数值单位就是厘米和Maya、3ds Max的默认习惯一致。一个2米高的柜子顶点数值是200而不是2。GlobalSettings里需要写入的关键参数buf.WriteLine(GlobalSettings: {); buf.WriteLine(\tVersion: 1000); buf.WriteLine(\tProperties70: {); buf.WriteLine(\t\tP: \UpAxis\, \int\, \Integer\, \\, 1); buf.WriteLine(\t\tP: \UpAxisSign\, \int\, \Integer\, \\, 1); buf.WriteLine(\t\tP: \FrontAxis\, \int\, \Integer\, \\, 2); buf.WriteLine(\t\tP: \FrontAxisSign\, \int\, \Integer\, \\, 1); buf.WriteLine(\t\tP: \CoordAxis\, \int\, \Integer\, \\, 0); buf.WriteLine(\t\tP: \CoordAxisSign\, \int\, \Integer\, \\, 1); buf.WriteLine(\t\tP: \UnitScaleFactor\, \double\, \Number\, \\, 1); buf.WriteLine(\t}); buf.WriteLine(});这段配置的意思就是Y轴向上、Z轴向前、单位是厘米。如果你的项目坐标约定不同就要改这几个轴的参数和烘焙时对应的轴交换矩阵。对绝大多数Unity项目来说这一段直接抄就行。5.3 非均匀缩放下法线要乘转置逆矩阵烘焙法线时很多人会顺手用变换顶点的那同一个矩阵去变换法线。如果物体只有旋转和等比缩放这没问题一旦Transform上有非均匀缩放比如scale是(1,2,3)法线方向就会算错导入后光照、高光和着色会明显不对。正确做法是使用法线矩阵即模型矩阵逆矩阵的转置。在Unity代码里实现时注意只取旋转和缩放部分不要带平移Matrix4x4 m Matrix4x4.TRS(Vector3.zero, transform.rotation, transform.lossyScale); Matrix4x4 normalMatrix m.inverse.transpose; Vector3 worldNormal normalMatrix.MultiplyVector(mesh.normals[i]).normalized;同理切线方向向量应该用原矩阵的旋转部分变换tangent.w副切线方向符号保持不变。如果忘了这一步最典型的表现是一个长方体拉伸之后顶面的光照方向看起来歪了但你又说不清哪里不对。另外要警惕镜像缩放也就是lossyScale里有负数的情况。这会引入手性翻转法线矩阵处理起来更麻烦UV也可能镜像。我建议第一版在导出入口直接检测lossyScale是否有负分量有就明确报错或警告不要静默产出错误模型。6. 验证流程与问题排查导出完不等于完事6.1 立刻导回Unity做数据对照导出的FBX文件想要快速验证最直接的办法是把它放进Unity工程的Assets目录让Unity自带的FBX导入器处理然后对比导入后的模型和原始模型。重点看三项顶点数和三角形数是否一致。模型尺寸/方向是否正确。材质虽然可能是默认的但至少物体不黑不烂。如果Unity导入后模型方向不对大概率就是GlobalSettings里的轴设置和烘焙矩阵不一致。如果模型被放大了100倍多半是单位换算那步出了岔子。6.2 Blender导入是最廉价的第三方验证同一个FBX用Unity导入验证有点“自己人查自己人”的意思。我强烈建议装一个Blender不参与工作流也没关系纯当验证工具。Blender对FBX的解析相对严格而且能看到Outliner层级、法线方向、UV展开信息比Unity导入器更直观。实际排查的时候我会在Blender里做两件事按下N键打开侧边栏查看物体Transform是否正常。进入Edit Mode全选网格看法线方向是否朝外。如果Unity导入没问题、Blender导入有问题通常说明你的FBX文件本身就有不严谨的地方只是Unity的导入器比较宽容。这种情况反而值得高兴因为Blender帮你提前暴露了真实兼容性问题。6.3 高频异常排查表下面这个表是我自己在项目里常用的排查清单直接收藏用就行症状最可能的原因排查方向导入后模型变大或变小100倍单位换算不一致检查顶点是否乘以100、UnitScaleFactor是否为1模型旋转或轴向不对GlobalSettings轴设置与烘焙矩阵不匹配核对UpAxis/FrontAxis/CoordAxis光照方向、高光位置异常非均匀缩放时没用转置逆矩阵检查法线矩阵实现三角面破面、扭曲PolygonVertexIndex负数编码错误验证最后一个顶点索引写成-(index1)材质看不到或全黑没有写Material或材质颜色为默认黑检查DiffuseColor字段UV丢失、贴图乱缺少LayerElementUV或映射类型写错确认UV层导出和MappingInformationType6.4 我几个真实的实操心得第一个心得是分阶段提交别一把梭。第一版先导出一个Cube确认顶点和三角面第二版加完整层级第三版加多子网格材质第四版再上BakeMesh和单位换算。每阶段都用Blender导一次这样出问题时定位范围极小。第二个心得是文件名里尽量别带中文和特殊字符。FBX作为一个有年代感的格式很多下游工具对非ASCII文件名很敏感。导出时可以在节点名和处理时做个清洗把空格换成下划线把中文重命名为“Object_N”这种形式。虽然大部分时候没事但碰上第三方工具链时能少一个变量。第三个心得是写文件的时候不要一个超大字符串一把梭。我第一版直接用StringBuilder把整个FBX拼到内存里10万顶点的网格轻松吃掉几十MB内存手机上直接有卡顿感。后面改成流式写入一个内部缓冲区累积到64KB就刷一次StreamWriter内存峰值降了一个量级速度反而更快。这个改法代码量也就多十几行属于必须做的优化。7. 想做骨骼和动画先看看这个成本再说最后聊一点扩展。很多人跑通静态模型导出后马上会问“能不能把动画也导出去”。从技术原理上讲FBX导出蒙皮模型需要额外写Skin对象和Cluster对象每个骨骼一个Cluster里面要有绑定矩阵、骨骼权重、关联的顶点索引然后通过Connections把Skin挂到Model上把每个Cluster挂到对应骨骼的Model节点上。这套数据的正确性验证比静态网格麻烦得多而且一旦Unity的骨骼层级跟FBX的绑定姿势对不上导入后模型就是“烂的”——蒙皮权重看起来在但骨骼一动网格就爆开。我的建议很直接除非产品需求非常明确要求“带骨骼绑定的FBX”否则不要在第一版做骨骼导出。如果只是需要当前姿势的模型用BakeMesh把姿势烘焙成静态网格既满足需求又避开了石头。如果确实要动画优先考虑离线方式在编辑器里用成熟工具导出动画运行时只负责触发下载真要运行时生成动画并导出那就得认真规划一个FBX AnimStack的写入模块这块工作量和静态网格不是一个量级的。给一个我个人的经验我做过几次运行时导出FBX的需求凡是第一版就把“完整骨骼动画”作为验收条件的项目交付周期至少翻倍。相反先做静态网格、再逐步叠加能力的项目最后都平稳落地了。所以别把“FBX支持”理解成一个开关它是层层累加的能力优先级排序很重要。
阅读完成 · 觉得有帮助?