Unity导出OBJ:高效衔接RecastNavigation导航烘焙的工程实践
简介面向 Unity 开发者的场景互转工具包可将当前场景中的 GameObject、地形 Terrain 与 .fbx 模型统一导出为 .obj便于在 Recast Navigation、DCC 工具或其他引擎中继续处理。插件提供自定义裁剪区域、自动裁剪与局部选中导出能力兼顾整场景交付与局部资产提取。资源共 10 个文件、约 540KB包含 1 个 C# 编辑器脚本、3 份 Markdown 说明、4 张截图及 JSON 配置清单可直接放入 Assets 目录或通过 Package Manager 使用。已有 1632 人学习下载。配套的说明文档、变更记录与许可证文本可以帮助快速理解命令入口、裁剪参数、版本演进和二次使用边界适合需要批量导出地形/物件或为 NavMesh 流程准备数据的 Unity 开发者。1. 导出一个能用的 obj:Unity 场景到 Recast 之间的第一道坎做导航烘焙时,第一道坎往往不是算法,而是数据格式。Unity 场景本身不能直接喂给 RecastNavigation,得先把场景里的对象、Terrain、外部 FBX 统一导成 obj 这类通用网格格式。Unity 自带的 Fbx Exporter 能导 FBX,但 Recast 那套工具链解析 FBX 还要额外处理依赖和单位换算,不如 obj 来得干净。这套导出工具把场景中的 GameObject、Terrain 地形和 FBX 文件统一输出为 .obj,顺手把坐标系翻转、单位对齐这些脏活处理掉。适合正在做导航网格预处理、模型几何交换,或者只想把 Unity 场景快速变成通用网格数据的人。2. 工具思路拆解:三条导出路径如何统一到 obj2.1 三个入口,一条输出管线这套导出工具的代码组织方式很值得先看清楚,因为它不是三个独立脚本各写各的,而是三个入口共用一套输出管线。入口分别是场景导出、FBX 导出、Terrain 导出,最终都汇聚到同一个“把 Mesh 数据写成 obj 文本”的函数。核心流程可以用四步概括:先从来源拿到 Mesh 数据;把局部坐标乘以 Transform 的世界矩阵,得到世界坐标;把世界坐标系从 Unity 的左手系翻到 obj 的右手系;最后按 obj 规范把顶点、UV、法线、面索引依次写出。这样设计最大的好处是,多个 Mesh 写进同一个 obj 文件时,面索引的偏移计算只在一处维护,不容易写乱。资源包里真正需要关心的就是这几个脚本之间的分工:一个负责菜单入口和路径选择,一个负责收集对象,一个负责把 Mesh 序列化成 obj 文本,还有一个专门处理 Terrain 的高度图采样。只要理解了这条管线,后面不管是改采样精度还是加自定义过滤条件,都有明确的下手位置。2.2 obj 文件格式与 Unity 坐标系:先对齐再导出obj 格式本身不复杂,整份文件就是一行一行的文本。顶点以 v 开头,纹理坐标以 vt 开头,法线以 vn 开头,三角面以 f 开头。f 行里记录的三个数字,是顶点、UV、法线在各自列表里的索引,并且从 1 开始计数,不是从 0 开始。这个细节几乎每个第一次做导出的人都会踩一次。obj 块含义Unity 侧的对应关系v x y z顶点坐标Mesh.vertices,注意坐标系翻转vt u vUV 坐标Mesh.uv,通常直接输出vn x y z顶点法线Mesh.normals,需要乘世界矩阵f v/vt/vn三角面索引Mesh.triangles,索引从 1 开始且要翻转绕序Unity 是左手坐标系,Y 轴向上;obj 文件传统约定是右手坐标系,Y 轴向上。两个坐标系之间最直观的差异就在 Z 轴方向上。如果直接把 Unity 的顶点坐标写进 obj,放到 Recast 里看到的地形会左右镜像,三角形面的朝向也会反掉。常见的处理方式是把顶点和法线的 Z 值取反,同时把三角面的顶点顺序交换后两位。也就是说一个三角形原本是 (i0, i1, i2),写出时改成 (i0, i2, i1)。这一步不改,后续生成的导航网格面朝向就是反的,代理寻路时会直接穿透。2.3 为什么选 obj 而不是继续啃 FBXFBX 是 Unity 的原生交换格式,但它的结构太复杂了。ASCII FBX 有节点树、骨骼、动画、材质引用,Recast 那套解析器大多只关注几何网格,拿到 FBX 后还要剥掉一大堆用不上的容器节点。obj 就简单得多,顶点、法线、UV、面,几行文本就描述完了,解析成本几乎为零。代价也很明确:obj 不保存材质属性、不保存动画、不保存节点层级,一个文件里只有纯粹的几何数据。所以这套工具的定位是几何数据中转,不是完整资产交换。如果目标是美术管线回传,应该用 FBX;如果目标是给导航烘焙、碰撞分析、离线网格处理提供输入,obj 是更省事的选择。3. 场景与 FBX 导出:遍历、序列化与坐标系翻转3.1 场景导出入口:从收集 MeshFilter 开始场景导出这一步,资源包里的做法是在编辑器菜单上注册一个入口,点击后扫描当前场景里所有带 MeshFilter 的对象,再把它们合并写进同一个 obj 文件。using UnityEditor; using UnityEngine; using System.IO; public static class SceneObjExporter { [MenuItem(Tools/Export Scene to Obj)] public static void ExportScene() { // 找到场景中所有 MeshFilter,包含隐藏对象 var filters Object.FindObjectsOfTypeMeshFilter(true); string path EditorUtility.SaveFilePanel(Export Scene Obj, , scene, obj); if (string.IsNullOrEmpty(path)) return; using (var writer new StreamWriter(path)) { writer.WriteLine(# exported from Unity scene); int vertexOffset 0; foreach (var filter in filters) { WriteMesh(writer, filter.transform, filter.sharedMesh, ref vertexOffset); } } AssetDatabase.Refresh(); Debug.Log(export done: path); } }这里有几个参数要解释清楚。FindObjectsOfTypeMeshFilter(true)的第二个参数为 true 时会把场景里隐藏的对象也包含进来,如果不想导出隐藏的临时几何体,就要改成 false 或者在外面加一层activeInHierarchy过滤。SaveFilePanel用户取消时返回空字符串,这个判断不能省,否则后续 StreamWriter 会在无效路径上抛异常。脚本用的是sharedMesh而不是mesh,因为实例化网格会复制一份数据,在编辑器工具里完全没有必要。3.2 顶点、UV、法线怎么按行写出拿到 MeshFilter 之后,下一步是把 Unity 的 Mesh 数据转成 obj 文本。这一步的核心不在写字符串,而在坐标变换。private static void WriteMesh(StreamWriter writer, Transform tf, Mesh mesh, ref int vertexOffset) { // 局部坐标转世界坐标,子物体必须乘这个矩阵 Matrix4x4 localToWorld tf.localToWorldMatrix; Vector3[] vertices mesh.vertices; Vector3[] normals mesh.normals; Vector2[] uvs mesh.uv; for (int i 0; i vertices.Length; i) { Vector3 v localToWorld.MultiplyPoint(vertices[i]); // 左手系转右手系,Z 取反 writer.WriteLine(v {0} {1} {2}, v.x, v.y, -v.z); if (normals ! null i normals.Length) { Vector3 n localToWorld.MultiplyVector(normals[i]); writer.WriteLine(vn {0} {1} {2}, n.x, n.y, -n.z); } if (uvs ! null i uvs.Length) { writer.WriteLine(vt {0} {1}, uvs[i].x, uvs[i].y); } } int[] triangles mesh.triangles; for (int i 0; i triangles.Length; i 3) { // obj 索引从 1 开始,需要加上当前网格的偏移 int i0 triangles[i] vertexOffset 1; int i1 triangles[i 1] vertexOffset 1; int i2 triangles[i 2] vertexOffset 1; // Z 翻转后绕序要反,交换后两个索引 writer.WriteLine(f {0}/{0}/{0} {2}/{2}/{2} {1}/{1}/{1}, i0, i1, i2); } vertexOffset vertices.Length; }顶点用MultiplyPoint是因为它完整处理了平移、旋转、缩放;法线只能用MultiplyVector,否则会把平移量错误地叠加到法线上,导出后光照方向全部漂移。Z 取反这步,顶点和法线都要做,只翻顶点不翻法线,这个 obj 在任何三维软件里看起来都是“阴阳脸”。UV 缺失时资源包的写法是直接跳过vt行,后续 obj 解析器会用默认 UV 顶上,比写 (0,0) 更安全。vertexOffset累积的是当前网格之前所有顶点的数量,因为 obj 的面索引是全局计数。3.3 面索引与绕序:两个最容易错的位置面索引这块有两个典型错误。第一个是忘记加vertexOffset,如果场景里有多个 Mesh,第二个 Mesh 的三角形会引用第一个 Mesh 的顶点,结果就是模型零件互相穿插、拓扑完全错乱。第二个是 obj 索引从 1 开始,Unity 的triangles数组从 0 开始,不加 1 会让所有面整体错位一个顶点。绕序问题通常被忽视。Unity 的三角形正面是顺时针方向,obj 在右手坐标系里正面是逆时针方向。由于前面把 Z 翻转过,Z 翻转本身会改变绕序,所以代码里把i1和i2交换输出。如果你在某个软件里看到模型外表面透明、内表面发光,基本都是这一步没做。3.4 FBX 导出:走同一套写文件逻辑FBX 导出和场景导出的差异只在数据来源,后续的坐标变换和序列化完全复用。[MenuItem(Tools/Export FBX to Obj)] public static void ExportFbx() { string fbxPath EditorUtility.OpenFilePanel(Select FBX, , fbx); if (string.IsNullOrEmpty(fbxPath)) return; // 绝对路径转 Asset 相对路径,AssetDatabase 不认绝对路径 string relativePath Assets fbxPath.Substring(Application.dataPath.Length); GameObject prefab AssetDatabase.LoadAssetAtPathGameObject(relativePath); if (prefab null) { Debug.LogError(failed to load fbx: relativePath); return; } // 取 FBX 内部所有层级下的 MeshFilter var filters prefab.GetComponentsInChildrenMeshFilter(true); if (filters.Length 0) { Debug.LogWarning(fbx contains no mesh: relativePath); return; } string outPath EditorUtility.SaveFilePanel(Export FBX Obj, , prefab.name, obj); if (string.IsNullOrEmpty(outPath)) return; using (var writer new StreamWriter(outPath)) { int vertexOffset 0; foreach (var filter in filters) { WriteMesh(writer, filter.transform, filter.sharedMesh, ref vertexOffset); } } AssetDatabase.Refresh(); Debug.Log(fbx export done: outPath); }这里有个常见的认知误区:FBX 导入 Unity 时,如果导入设置里的 Scale Factor 是 0.01,很多人会以为导出时要乘回 100 才能还原尺寸。实际上 Unity 在导入时已经把缩放应用到了顶点数据上,sharedMesh里的顶点坐标就是 Unity 世界单位下的尺寸,不需要再额外换算。唯一要注意的是不同项目可能改过 Scale Factor,稳妥做法是先把 FBX 拖进场景看一眼根节点 Scale 是否为 1,再执行导出。4. Terrain 导出:从高度图到三角网格的关键参数4.1 Terrain 没有 Mesh 可读,必须先采样生成Terrain 组件和普通模型不一样,它没有 MeshFilter,数据以高度图形式存在 TerrainData 里。所以要导 Terrain,第一步是把高度图采样成一片顶点网格,再交回给前面那条 obj 输出管线。资源包里生成地形网格的核心方法是按步长遍历高度图,每个采样点生成一个顶点,再按二维网格的行列关系连接成三角形。步长 step 是控制导出规模最重要的参数,没有之一。高度图分辨率step 取值导出顶点数适用场景512x5121约 26 万全量精度,调试用512x5122约 6.5 万常规导航烘焙512x5124约 1.6 万大世界预览直接取 step1 会把高度图分辨率的全部顶点都导出来。一个 512x512 的 Terrain 高度图对应 26 万个顶点,生成的 obj 文件动辄几十 MB,加载慢、烘焙慢,而且 Recast 做体素化时根本用不到这么高的顶点密度。4.2 高度图采样:从 GetHeight 到顶点列表private static ListVector3 SampleTerrainVerts(Terrain terrain, int step) { TerrainData data terrain.terrainData; int res data.heightmapResolution; Vector3 size data.size; ListVector3 verts new ListVector3(res * res / (step * step)); for (int y 0; y res; y step) { for (int x 0; x res; x step) { // GetHeight 的参数是高度图坐标,不是世界坐标 float height data.GetHeight(x, y); // 把高度图坐标映射到地形实际尺寸 Vector3 worldPos new Vector3( x / (float)(res - 1) * size.x, height, y / (float)(res - 1) * size.z ); verts.Add(worldPos); } } return verts; }heightmapResolution表示高度图边长上的采样点数,size才是地形的实际世界尺寸,两者不能混为一谈。GetHeight(x, y)返回的是该点高度,单位是米,不是高度图灰度值。映射到世界坐标时要除以res - 1,而不是res,否则地形边缘会多出零点几个网格单位的压缩,拼接多个地形块时能看出明显的缝隙。4.3 法线生成与三角形索引:让地形立体起来地形网格没有现成法线,资源包一般用 TerrainData 自带的GetInterpolatedNormal接口直接取,这个接口返回的是该点的归一化法线,省去手算平均法线的步骤。private static void BuildTerrainTris(Listint tris, int cols, int rows) { // cols 是单行采样点数,rows 是行数 for (int y 0; y rows - 1; y) { for (int x 0; x cols - 1; x) { int idx y * cols x; int i0 idx; int i1 idx 1; int i2 idx cols; int i3 idx cols 1; tris.Add(i0); tris.Add(i1); tris.Add(i2); tris.Add(i2); tris.Add(i1); tris.Add(i3); } } }三角形索引的规律是idx row * cols col,如果 cols 计算错了,生成的地形网格会出现对角线撕裂。上面代码把每个四角网格拆成两个三角形,拆法是固定沿左上到右下对角线,这样所有三角形绕序一致,不会出现个别面朝向相反。Terrain 对象的实际尺寸由TerrainData.size决定,采样时只在平面上做规则网格采样,面积越大、step 越小,顶点量就越大。一个比较实用的参数策略是:先按 step8 出一版看地形整体走势,确认烘焙区域没问题后,再用 step2 出细版用于最终导航烘焙。5. 常见问题排查:导出前后五个必踩的坑5.1 场景导出后模型左右反了现象:obj 在多数三维软件里看着正常,但放进 Recast 后地面朝向不对,或者导航区域镜像了。原因:Unity 左手系和 obj 右手系在 Z 轴方向相反,只处理顶点不处理法线和面索引,三角形正面朝向就变了。解决:顶点和法线都做 Z 取反,同时把三角形的索引顺序改为(i0, i2, i1)输出。检查方法很简单,导出后用文本编辑器打开 obj,随便找一组 f 行,对照 v 行里三个顶点的 Z 值,确认最大 Z 对应的顶点在三角形里是逆时针排列。5.2 Terrain 导出后文件体积几百 MB现象:step 设成 1,512 高度图导出的 obj 打开都要好几秒,烘焙时内存暴涨。原因:把高度图分辨率当成了必须全量输出的精度指标。解决:先确认 Recast 那边实际用的体素精度。导航烘焙一般 cellSize 设在 0.2 到 0.5 米,地形顶点密度再高也只是给体素化提供采样输入,没必要超过这个精度太多。按 step 2 或者 4 重导一次,文件体积能缩小四分之一到十六分之一,导航网格质量几乎不受影响。5.3 法线丢失导致表面明暗错乱现象:某些三维软件里模型一面正常一面全黑,或者烘焙出来的网格出现异常遮挡。原因:写到 obj 时根本没有 vn 行,或者法线用的是 MultiplyPoint 而不是 MultiplyVector,导致法线方向被平移量污染。解决:normal 输出前必须乘localToWorldMatrix.MultiplyVector,如果该 Mesh 没有法线数据,用默认向上向量或者不输出 vn。后续解析器对缺失 vn 的 obj 通常会重新计算法线,但计算结果不一定符合原始模型设计。5.4 带大量子物体的组合模型全部堆在原点现象:导出的 obj 里零件位置全错,多层嵌套的子物体都挤在根节点位置。原因:直接读取了mesh.vertices,这是模型局部坐标,没乘父级 Transform 的世界矩阵。解决:遍历时保留每个 MeshFilter 的 transform,写入顶点前先调用localToWorldMatrix.MultiplyPoint。如果根节点有 rotation 和 scale,这一步会自动处理,不要手动分拆旋转与缩放再乘,容易丢精度。5.5 FBX 导出尺寸差 100 倍现象:同一个模型用 FBX 导入 Unity 是 2 米高,导出成 obj 后变成了 0.02 或者 200。原因:FBX 文件内部单位是厘米,Unity 导入时按 Scale Factor 0.01 缩放过一次,但有些项目的导入预设被改过,或者模型的根节点另有缩放。解决:导出前先在场景里检查该 FBX 的根节点 Transform Scale 是否为 1,然后对比Mesh.bounds.size和实际预期的长宽高。不一致时回到 FBX 导入面板重新设置 Scale Factor,等场景里的尺寸对了再执行导出脚本。6. 把 obj 喂给 RecastNavigation:验证数据是否合格6.1 用 RecastDemo 做最终验收obj 导出来不算完,真正合格的标准是 Recast 能基于它生成可用的导航网格。我一般会在 RecastDemo 里加载导出的 obj,重点看三处:体素化后地面是否连续、墙体边缘是否有空洞、代理能否在生成的网格上正常寻路。如果这三个都没问题,这个 obj 才是真能用。6.2 cellSize 与 cellHeight 怎么设参数常规取值说明cellSize0.2 ~ 0.5 米越小体素越细,烘焙越慢cellHeight跟随 cellSize,建议 2 倍左右纵向体素高度agentHeight1.6 ~ 2.0 米角色高度,按需agentRadius0.3 ~ 0.6 米角色半径这些参数和 obj 顶点密度没有直接关系,obj 只是体素化的输入源。顶点太密不会让导航网格更光滑,体素参数才是最终精度的决定者。6.3 我的固定检查清单现在每次做导航预处理,我都会强制走一遍检查:第一步,用文本编辑器确认第一行 v 的 Z 值符号;第二步,检查顶点数量是否和场景规模匹配;第三步,用一个已知正确的地形块单独导出一份,对比尺寸;最后才把完整场景交给 Recast。这四步走完,基本能在五分钟内定位到问题。从那以后我每次导出前都强制走一遍这套流程,再没在导航数据格式上返过工,希望帮到你。本文还有配套的精品资源点击获取