Unity一键OBJ导出:编辑器脚本实现3D模型格式转换与批量处理
发布时间:2026/9/8 2:19:58 锦皓数字建站

简介这是一组面向Unity开发者的轻量级格式转换脚本用于在编辑器环境中处理.unity3d资源文件。Unity3D格式可将场景、模型、纹理、动画等资源打包为单一文件而这套脚本正是为整合、优化或格式转换提供自动化途径帮助开发者降低模型复杂度、压缩纹理、合并资源从而改善游戏加载速度与内存占用。压缩包内仅含3个文件包括两个JavaScript脚本和一个C#脚本整体体积仅5KB导入Assets/Editor目录后即可在编辑器菜单中调用不会打进发布包。已有333人学习下载。脚本基于Unity的AssetDatabase与序列化机制运行覆盖资源读取、处理、导出全流程能够减少手动重复操作Editor目录隔离设计也更适合需要自定义资源管线的初中级开发者快速上手或二次修改。 做Unity项目做到一定程度几乎都会碰到模型格式对不上号的情况。上周美术丢给我一个模型包里面全是.ma和.3ds还特别备注了一句“引擎要什么格式自己转一下”。以前遇到这种事要么开Blender手动转要么去找第三方转换工具来回折腾效率特别低。这次我直接在Unity工程里写了一个3D格式转换脚本把常用的网格转换逻辑封装成菜单命令一键导出OBJ连命令行批量导出都做完了全程都没离开Unity编辑器。实测下来简单有效省了大量重复劳动。这篇博文就把这个脚本的完整实现、原理和踩坑记录整理出来给还在跟格式问题较劲的朋友一个参考。1. 为什么我决定在Unity内部写脚本做格式转换1.1 从一次让人头大的模型交付说起上周美术丢给我一个模型包里面全是.ma和.3ds还特别备注了一句“引擎要什么格式自己转一下”。这种“格式不太对”的破事我已经不是第一次遇到以前的做法要么开Blender手动转要么在网上下一个格式转换工具来回折腾效率特别低。后来有一回美术给的模型在第三方工具里转出来跑进Unity之后法线全反了材质也丢了大半我想查一下原始导入的数据到底长什么样才发现转换工具根本没把Unity可用的资源形态暴露给我。那之后我就下决心直接在Unity工程里写一个3D格式转换脚本。思路很简单既然Unity已经帮我把外部模型资源解析成了Mesh、材质、贴图那我在编辑器里读取这些数据再用代码重新序列化成目标格式输出就行。整个转换过程不依赖任何外部软件也不会因为工具版本不一致出现结果偏差。实测下来这套方案简单有效虽然不能替代DCC软件做精细调参但解决日常的格式交换、资源交付、批量处理完全够用。1.2 对比三种常见转换方案动手之前我把市面上常见的方案快速过了一遍各有各的问题。第一种是找“万能转换器”。网上的转换工具品类很多但实际用下来问题不少要么支持格式有限遇到.ma、.3ds、.dae这些“冷门货”直接不认要么碰到几十万顶点的超大Mesh就卡死还有一批在线转换网站模型传上去之后贴图丢失是常态更不用说安全性。如果只是偶尔转一个模型无所谓一旦批量转换一个个点开工具选文件选参数重复劳动能占掉一上午。第二种是DCC软件中转。打开Blender或者3ds Max导入再导出这个方法很万能格式兼容性最好。但麻烦在于DCC软件里的显示效果和Unity最终渲染效果往往有差异转出来到了Unity里可能比例不对、法线方向变了、贴图路径断裂还得再调一轮。另外项目成员不一定都装了Blender你临时要转一个模型还得先花时间装环境。第三种就是本文要讲的Unity脚本方案。它直接把Unity已经导入解析好的资源再导出不依赖外部软件结果所见即所得。尤其是在“模型最终要放到Unity里跑”这个场景下这个方案没有任何多余环节。1.3 在Unity里做转换的真正优势所见即所得这一点最值钱。脚本跑的是Unity渲染时真正用的那一份Mesh数据不像DCC转完还可能二次走样。导出后的模型拿到Blender或者其他工具里打开大小比例、顶点顺序、材质分布基本和Unity里看到的一致省掉了反复比对的时间。第二个优势是能复用Unity的整个资源管线。Unity导入FBX时已经把网格、UV、法线、切线全解析好了脚本只需要从MeshFilter、SkinnedMeshRenderer上取数据。更进一步我还可以在AssetPostprocessor里写一个导入后自动转换的逻辑模型一拖进工程就自动输出OBJ。这种“导入即转换”的工作流外部工具链很难做到。第三个优势是没有额外依赖。脚本其实就是几个.cs文件扔到Editor文件夹下就能跑一台装了Unity的机器全流程搞定。我们项目里还有一批给微信小游戏做资源瘦身的场景需要大量对比高低模差异用这套脚本批量转OBJ再在Blender里快速看网格结构效率比人肉操作高了一个量级。2. OBJ格式的底细先搞清楚“文本结构”再动手2.1 OBJ文件到底长什么样写脚本前我先把OBJ文件的“底细”摸清了。OBJ是Wavefront定义的3D模型文本格式每一个顶点、每根法线、每个三角形都写成一行文本用行首的关键字区分类型。一个最简单的Cube打开OBJ文件内容大概是这样o Cube v 1.000000 1.000000 -1.000000 v 1.000000 -1.000000 -1.000000 v -1.000000 1.000000 -1.000000 v -1.000000 -1.000000 -1.000000 v 1.000000 0.999999 1.000000 v 1.000000 -1.000000 1.000000 vt 0.000000 0.000000 vt 0.000000 1.000000 vt 1.000000 0.000000 vt 1.000000 1.000000 vn 0.000000 1.000000 0.000000 ... f 1/1/1 3/2/2 5/3/3这些前缀的含义非常好理解o是object对象名v是顶点坐标vt是UV坐标vn是法线f是面索引。f后面每三个数一组分别代表三角形三个顶点的“顶点索引/UV索引/法线索引”注意索引从1开始。如果使用了材质文件里还会有mtllib xxx.mtl引用外部材质库。我第一次写导出脚本就是照着这几个字段逐行吐数据跑通最基础的Mesh导出只用了十几分钟。OBJ能成为各种工具之间通用的“中间格式”靠的就是这种极度简单的文本结构。2.2 从Unity Mesh到OBJ中间要做的三件事光会写这些字段还不够从Unity的Mesh对象到OBJ文本坑藏在三个地方。第一坐标系转换。Unity是左手坐标系X向右、Y向上、Z向屏幕内而OBJ规范采用右手坐标系X向右、Y向上、Z向屏幕外。如果直接把Unity的顶点坐标原样写进OBJ导出的模型会沿某个轴镜像法线和三角面朝向也救不回来。我的做法是导出时把X取反同时对三角形索引做一次反序保证面朝外。第二索引基数。Unity的Mesh.triangles是零基索引而OBJ文件中f后面的索引最小是1。每写一个三角形都要对每个索引做1处理。这个处理看似不起眼漏掉的话整个模型在软件里会错乱成一片。第三子网格的处理。一个GameObject上可能挂了多材质对应Mesh里的多个submesh。OBJ里遇到不同的submesh要切换usemtl指令否则所有面都会统一用最后一种材质最终结果就是材质错乱。2.3 为什么选OBJ而不是直接转FBX我承认FBX是现在DCC和引擎之间更通用的桥梁格式Unity官方后面也推出了FBX Exporter包。但在我这个“简单有效”的目标下OBJ有不可替代的好处。纯文本异常好调试。导出的文件用文本编辑器打开就能查问题FBX是二进制出错了你根本不知道是哪儿错了。几乎所有软件都认OBJ包括各种在线预览工具、3D打印切片软件甚至一些做AI三维重建的接口。脚本复杂度低。写一个OBJ导出器只需要处理字符串拼接不需要引用任何SDK。OBJ的缺点也很明确不支持蒙皮动画、不支持多动画剪辑、格式压缩率低。所以我在工具里给了两条路日常几何交换、给合作方或打印厂用OBJ要保留完整动画和材质就走官方FBX Exporter。后面第5节会讲到这个选择。3. 核心脚本实现把Mesh数据序列化成OBJ文本3.1 编辑器菜单入口和目录设计先看工程结构。我的脚本放在Assets/Editor/ModelTools/ObjExporter.csEditor目录是Unity约定好的这个目录下的脚本只会参与编辑器功能不会编译进游戏包。菜单入口用MenuItem特性注册public static class ObjExporter { [MenuItem(Tools/Model Convert/Export Selected To OBJ)] public static void ExportSelected() { GameObject[] selected Selection.gameObjects; if (selected.Length 0) { Debug.LogWarning(请先在场景中选中要导出的物体); return; } string path EditorUtility.SaveFilePanel(Export OBJ, , export.obj, obj); if (string.IsNullOrEmpty(path)) return; ExportGameObjectsToObj(selected, path); } }菜单名叫Tools/Model Convert/...放进Unity菜单栏的Tools分类顺眼也好找。ExportSelected处理用户从场景选中物体的场景后面第4节我会把它扩展成支持命令行调用的静态方法。3.2 核心的MeshToString方法这段是把一个Mesh实例序列化成OBJ文本的核心逻辑改进自社区流传的经典版本我在这基础上加了坐标转换、法线反置和多submesh支持。public static string MeshToString(Mesh mesh, string objectName, string mtlName) { StringBuilder sb new StringBuilder(); sb.Append(o ).Append(objectName).Append(\n); sb.Append(mtllib ).Append(mtlName).Append(.mtl\n); Vector3[] vertices mesh.vertices; Vector3[] normals mesh.normals; Vector2[] uvs mesh.uv; for (int i 0; i vertices.Length; i) { Vector3 v vertices[i]; sb.Append(v ).AppendFormat({0} {1} {2}\n, -v.x, v.y, v.z); } for (int i 0; i normals.Length; i) { Vector3 n normals[i]; sb.Append(vn ).AppendFormat({0} {1} {2}\n, -n.x, n.y, n.z); } for (int i 0; i uvs.Length; i) { Vector2 uv uvs[i]; sb.Append(vt ).AppendFormat({0} {1}\n, uv.x, uv.y); } for (int submesh 0; submesh mesh.subMeshCount; submesh) { int[] triangles mesh.GetTriangles(submesh); sb.Append(usemtl material_).Append(submesh).Append(\n); for (int i 0; i triangles.Length; i 3) { int a triangles[i] 1; int b triangles[i 1] 1; int c triangles[i 2] 1; // 将左手系的三角形顺序反转 sb.Append(f ).Append(a).Append(/).Append(a).Append(/).Append(a); sb.Append( ).Append(c).Append(/).Append(c).Append(/).Append(c); sb.Append( ).Append(b).Append(/).Append(b).Append(/).Append(b); sb.Append(\n); } } return sb.ToString(); }这里有几个关键点需要解释。第一顶点和法线的X取反是最核心的坐标转换Z轴方向其实可以不改只要X取反加三角形顺序反转模型从Unity的左手系变成OBJ的右手系视觉上就一致了。第二法线必须同步取反否则模型虽然顶点坐标对了但光照法线用的是原来的方向在别的软件里会看起来明暗不对。第三三角形索引我故意调成a, c, b的顺序。Unity三角形顶点顺序是顺时针而OBJ软件大多按逆时针识别正面反序之后面朝向才正确。第四每个submesh用material_0、material_1这样的命名切换材质和后面生成的MTL一一对应。3.3 带材质和贴图的MTL文件生成OBJ本身不存材质材质信息全部放在旁边同名MTL文件里。上面代码里我写了mtllib objName.mtl所以还得生成一个配套的MTL文件。项目里挂的是Standard材质或URP的Lit材质时我主要取三样东西_MainTex贴图、_Color颜色、_Metallic金属度。public static void WriteMtlFile(string materialFolder, ListMaterial materials) { StringBuilder sb new StringBuilder(); for (int i 0; i materials.Count; i) { Material mat materials[i]; sb.Append(newmtl material_).Append(i).Append(\n); sb.Append(Kd 1.000000 1.000000 1.000000\n); if (mat.HasProperty(_Color)) { Color c mat.color; sb.Append(Kd ).AppendFormat({0} {1} {2}\n, c.r, c.g, c.b); } Texture tex mat.HasProperty(_MainTex) ? mat.mainTexture : null; if (tex ! null) { string srcPath AssetDatabase.GetAssetPath(tex); string dstPath Path.Combine(materialFolder, Path.GetFileName(srcPath)); File.Copy(srcPath, dstPath, true); sb.Append(map_Kd ).Append(Path.GetFileName(srcPath)).Append(\n); } } File.WriteAllText(Path.Combine(materialFolder, export.mtl), sb.ToString()); }这里有一个细节值得注意写贴图路径时我刻意用的是相对路径只写文件名而不是E:\textures\xxx.png这种绝对路径。因为OBJMTL这套文件经常要给同事、给外包、给3D打印平台绝对路径到了别人机器上全断相对路径只要贴图跟OBJ在同一目录下就万事大吉。map_Kd对应的就是.mtl文件所在目录下的相对路径所有软件都能识别。3.4 遍历场景导出有了上面两个方法剩下的就是遍历场景里所有带MeshFilter或SkinnedMeshRenderer的对象public static void ExportGameObjectsToObj(GameObject[] roots, string outputPath) { string dir Path.GetDirectoryName(outputPath); ListMaterial materials new ListMaterial(); StringBuilder sb new StringBuilder(); foreach (GameObject root in roots) { MeshFilter[] filters root.GetComponentsInChildrenMeshFilter(true); foreach (MeshFilter f in filters) { Mesh mesh f.sharedMesh; if (mesh null) continue; Renderer renderer f.GetComponentRenderer(); if (renderer ! null) { foreach (Material mat in renderer.sharedMaterials) { if (mat ! null !materials.Contains(mat)) materials.Add(mat); } } sb.Append(MeshToString(mesh, f.gameObject.name, export)); } } File.WriteAllText(outputPath, sb.ToString()); WriteMtlFile(dir, materials); Debug.Log(Export complete: outputPath); }这里用的是GetComponentsInChildren(true)第二个参数传true是为了连未激活的物体也一起导出很多美术资源会临时隐藏一些分组你不传true就得来回捣鼓激活状态。另外我优先取sharedMesh而不是mesh因为mesh会触发Mesh实例化在批量处理几十个同源模型时每实例化一次都是内存和耗时用sharedMesh拿共享数据就够了而且不会改动原模型。运行方式很简单在场景里选好根物体点菜单Tools/Model Convert/Export Selected To OBJ再选一个保存路径脚本自动把整棵子物体树的Mesh全部打到一个OBJ文件里MTL和贴图也自动放好。4. 从点击菜单到无人值守批量导出命令行调用技巧4.1 把导出逻辑包成可被命令行调用的静态方法菜单按钮点着方便但项目里动辄几十上百个模型一个个选中导出还是太慢。我后来加了一个批量模式直接把第3节的导出方法再包一层做成无界面依赖的静态方法。public static void ExportAllFromCommandLine() { string inputDir GetArg(-inputDir, Assets/Models); string outputDir GetArg(-outputDir, ExportedOBJ); string filter GetArg(-filter, ); if (!Directory.Exists(inputDir)) { Debug.LogError([ObjExporter] input dir not found: inputDir); return; } Directory.CreateDirectory(outputDir); string[] assets Directory.GetFiles(inputDir, *.fbx, SearchOption.AllDirectories); foreach (string assetPath in assets) { if (!string.IsNullOrEmpty(filter) !assetPath.Contains(filter)) continue; string relativePath assetPath.Replace(\\, /); GameObject model AssetDatabase.LoadAssetAtPathGameObject(relativePath); if (model null) continue; string outFile Path.Combine(outputDir, Path.GetFileNameWithoutExtension(assetPath) .obj); ExportGameObjectsToObj(new[] { model }, outFile); } Debug.Log([ObjExporter] batch export finished. files: assets.Length); } private static string GetArg(string argName, string defaultValue) { string[] args System.Environment.GetCommandLineArgs(); for (int i 0; i args.Length; i) { if (args[i] argName i 1 args.Length) return args[i 1]; } return defaultValue; }这个方法用AssetDatabase.LoadAssetAtPath加载FBX资源再把FBX的根GameObject当成普通根节点扔给之前的导出函数这样FBX里如果有多个子Mesh也会自动全部展开导出。注意这里不能用Resources.Load或者Instantiate因为命令行模式下没有激活的场景上下文Instantiate出来的GameObject拿不到序列化数据。AssetDatabase.LoadAssetAtPath是编辑器API在Editor里加载静态资源最可靠。4.2 Windows和macOS下的命令行调用Unity Editor本身就支持批处理模式可以在不打开图形界面的情况下执行指定静态方法。Windows下我写了一个export.batecho off set UNITY_EXEC:\Program Files\Unity\Hub\Editor\2021.3.30f1\Editor\Unity.exe set PROJECT_PATHD:\MyProject %UNITY_EXE% -batchmode -projectPath %PROJECT_PATH% ^ -executeMethod ObjExporter.ExportAllFromCommandLine ^ -inputDir Assets/Models -outputDir D:/ExportOBJ ^ -quit -logFile D:/export_log.txtmacOS/Linux下对应写个export.shUNITY_EXE/Applications/Unity/Hub/Editor/2021.3.30f1/Unity.app/Contents/MacOS/Unity $UNITY_EXE -batchmode -projectPath /Users/me/MyProject \ -executeMethod ObjExporter.ExportAllFromCommandLine \ -inputDir Assets/Models -outputDir /Users/me/ExportOBJ \ -quit -logFile /Users/me/export_log.txt跑了之后Unity会启动、进入工程、执行静态方法、导出、退出全程无界面。如果你只想导出一部分通过参数-filter hero就能只处理带hero关键字的文件。加上-quit参数Unity会在方法执行完自动关闭不会挂在后台。4.3 实测效率和几个限制我拿一个真实项目的资源做了下测试120个带MeshFilter和标准材质的FBX模型平均几千顶点命令行批量导出到OBJ大概用时40多秒单个模型不到0.5秒比人肉在软件里一个个转快了不知道多少倍。如果你只要几十个小模型10秒内就能全部完成。但命令行模式有几个限制我踩出来过不要弹任何对话框。EditorUtility.SaveFilePanel、DisplayDialog这些在batchmode下会卡住进程脚本要改成直接用参数传路径。不要碰有帧依赖的编辑器API比如EditorApplication.delayCall批处理模式下很可能还没执行完就退出了。日志要留。-quit之后Unity关得很快如果不加-logFile参数出问题你根本看不到Debug.LogError打出来的内容。我习惯每次导出都生成独立的log文件排查效率高很多。5. 踩坑记录坐标镜像、蒙皮烘焙、材质路径等5.1 导出去镜像问题一次只做一半坐标转换的代价我第一次把导出的OBJ丢进Blender时模型整个沿轴镜像了仔细看法线也有问题。排查下来的元凶就是坐标转换只做了一半。后来我固定检查三个点这套标准反复用了很久顶点X是否取反法线X是否取反三角形索引顺序是否反序这三个点必须同时做缺一个模型就会歪。写代码的时候用注释把转换规则标清楚不然时隔半年再回来看脚本肯定忘。5.2 SkinnedMeshRenderer怎么导出当前姿态如果场景里有人物模型直接拿SkinnedMeshRenderer.sharedMesh导出的网格是T-Pose绑定期状态不是你在场景里看到的当前动画姿势。要在脚本里先烘焙一次SkinnedMeshRenderer smr GetComponentSkinnedMeshRenderer(); Mesh bakedMesh new Mesh(); smr.BakeMesh(bakedMesh); // 之后把bakedMesh交给MeshToString处理注意BakeMesh在编辑器下对非激活物体有时会不生效我处理的方式是临时激活一下再烘焙完事再恢复原状。另外烘焙出来的Mesh不包含当前材质材质还是要从smr.sharedMaterials获取。5.3 材质、贴图和SubMesh的对应关系很多人在写MTL文件时直接用Unity的材质名当材质名比如newmtl Standard (Instance)空格和括号混在文件名里导入到某些软件直接报错。我试过最省心的做法是不管是OBJ里的usemtl还是MTL里的newmtl统一用material_0、material_1这种序号式命名避免特殊字符和重名问题。贴图则统一拷贝到OBJ同级目录用纯文件名作为map_Kd路径保证跨平台不出错。Unity的Mesh支持多个submesh每个submesh对应一组三角形索引。导出时如果不按submesh分段输出而是把triangles整体当一组那么多材质模型的UV、法线和材质对应全乱。脚本里一定要做两层循环外层循环submesh内层循环该submesh的三角形并在切换时输出usemtl。看似多加了五六行代码实际上省了无数检查时间。5.4 大Mesh和特殊字符带来的性能与命名问题当Mesh顶点数到了几十万直接用StringBuilder一直Append最后一次性File.WriteAllText会占大量内存而且GC会拖慢编辑器。我实测过50万顶点的Mesh一次性拼接大概要多花300~400MB内存。建议改成StreamWriter逐行写文件using (StreamWriter sw new StreamWriter(outputPath)) { sw.Write(o ).Write(objectName).WriteLine(); foreach (Vector3 v in vertices) sw.WriteLine(string.Format(v {0} {1} {2}, -v.x, v.y, v.z)); // 其他字段同理 }StreamWriter底层有自己的缓冲逐行写字符串的开销比你想的低很多而且峰值内存能控制在几十MB以内处理大场景时不会卡到编辑器闪退。还有个容易被忽略的点游戏物体名字里经常有空格、斜杠、中文、括号这些字符在o对象名里还能忍但在文件名、MTL名里就会出问题。我在导出文件时统一做了清洗把非法文件名字符替换成下划线string safeName Regex.Replace(originalName, [\\/:*?\|], _);如果是给3D打印做模型要注意OBJ坐标和三角面方向之外还得确认导出尺寸。Unity单位是米Blender里默认单位也是米但很多切片软件以毫米显示你需要跟对方确认好单位否则打印出来的尺寸和预期差1000倍。最后再分享一个实际操作中的小细节。脚本写完不是终点真正让你省时间的是把它塞进项目工作流。我现在维护着一个“一键转换”菜单里面同时挂了OBJ导出、官方FBX Exporter、批量命令行三个入口平时把模型拖进工程按需求点按钮或跑脚本五分钟之内就能把资源转成合作方要的格式。每次在项目里跑完转换我都会拿导出的OBJ到Blender里简单叠一下网格确认没有法线翻转和丢材质再往下游走。这个习惯帮我躲过了不少坑。如果你的工作流里也经常遇到模型格式不兼容的问题这套思路可以直接拿去改花不了多少时间就能变成自己顺手的小工具。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。