☰
Unity场景导出OBJ:网格、Terrain与跨工具链实践
2026/10/14 19:19:04 网站建设 项目流程

简介:面向Unity开发者的场景与FBX导出插件,主要解决将场景内游戏对象、地形或FBX模型快速转换为OBJ文件的跨软件协作需求,适合需要在寻路网格、三维建模或外部工具链中复用场景资源的工程师。资源包共10个文件,以4张界面截图、3份说明文档、1个核心编辑器脚本为主,另含配置与忽略文件,压缩包仅540KB,整体轻巧便于快速试用;其中脚本负责编辑器侧的功能集成,文档和截图则能辅助理解操作流程。已有1630人学习使用。插件支持自定义裁剪区域与自动裁剪,可准确控制导出范围并精简多余三角面,同时支持按需选取对象导出和FBX模型转换;配合包内配置文件,可通过包管理器或Git导入2018.3及以上版本的Unity项目,降低接入成本。对需要频繁交换场景数据、制作关卡地形或准备寻路网格数据的开发与美术团队而言,它是一份轻量而直接的工具补充。

1. ExportSceneToObj 到底解决什么事:把 Unity 场景交给外部工具链的那一步

ExportSceneToObj 这个标题,看起来只在讲一件事:导出。但真正被这个问题卡住过的人都知道,Unity 编辑器里没有一个“导出场景为 obj”的菜单,导出 FBX 又常常让美术那边的人皱眉——材质名对不上、面朝向反了、Terrain 根本导不出去。我最早遇到它是在一个跨团队协作项目里:某个模拟项目X 的场景需要送进 Blender 做光照预演,结果我折腾了一下午,才发现问题根本不是“导出”,而是少了一套能统一处理普通对象和 Terrain、又能把 FBX 模型一并转成 .obj 的脚本。这篇文章要讲的,就是这类导出器的数据原理、实现路径和正式项目里最容易翻车的几个细节,适合要在 Unity 和 Blender、Maya 或 CAD 之间来回递资产的开发者。

2. 导出前必懂:OBJ 格式、Unity 网格结构与导出器架构选型

2.1 OBJ 文件到底长什么样:顶点、法线与面的文本结构

OBJ 是一种纯文本的几何描述格式,不包含动画、物理材质或场景树,最常见的用途是静态网格交换。一个最小的 OBJ 文件长下面这样,使用v开头声明顶点坐标,vn声明法线,vt声明 UV 坐标,f声明三角面或四边形面,面的索引从 1 开始计数:

# 一个简单的立方体片段 v 1.000000 1.000000 -1.000000 v 1.000000 -1.000000 -1.000000 v 1.000000 1.000000 1.000000 vn 0.0000 1.0000 0.0000 vt 0.0000 0.0000 f 1/1/1 3/2/1 2/3/1

逻辑说明:f 1/1/1 3/2/1 2/3/1表示由第 1、3、2 个顶点组成一个三角面,斜杠后面的数字分别是 UV 索引和法线索引。要注意,OBJ 的索引基数不是从 0 开始,而是从 1 开始,这和 Unity 的Mesh.triangles数组习惯完全相反,写导出逻辑时务必对每个索引加 1。

参数说明:v行和vn行的坐标可以是绝对精度的小数,一般保留 6 位足够,太多会让文本体积膨胀;f行对于纯三角网格可以直接三个索引一组,不需要额外处理。

2.2 Unity 网格数据与导出器的代码骨架:一个 Editor 菜单拉起整个导出流程

Unity 里的任何可见网格对象,最后都落在MeshFilter或SkinnedMeshRenderer上。MeshFilter.sharedMesh里有vertices、normals、uv和triangles四个核心数组,这正好和 OBJ 的v/vn/vt/f一一对应。所以导出器的核心工作就是:找出场景里所有带网格的 GameObject,把它们的局部坐标变换到世界坐标,再把数组内容转成 OBJ 文本。

我一般会把编辑器菜单和网格序列化拆成两个类,菜单类负责交互,序列化类负责纯数据转换。下面是一份可以直接挂到 Editor 目录下的骨架代码:

using UnityEngine; using UnityEditor; using System.IO; using System.Text; public class ExportSceneToObjEditor : EditorWindow { [MenuItem("Tools/Export Scene To OBJ")] static void ExportCurrentScene() { // 收集场景中所有 MeshFilter 对象 MeshFilter[] filters = Object.FindObjectsOfType<MeshFilter>(); if (filters.Length == 0) { EditorUtility.DisplayDialog("导出提示", "场景中没有可导出的网格对象", "确定"); return; } string path = EditorUtility.SaveFilePanel("选择 OBJ 保存路径", "", "scene_export", "obj"); if (string.IsNullOrEmpty(path)) return; // 用一个 StringBuilder 累积 OBJ 文本,避免频繁 IO StringBuilder sb = new StringBuilder(); foreach (MeshFilter filter in filters) { Mesh mesh = filter.sharedMesh; if (mesh == null) continue; // 写入组名,方便在 Blender 里分层查看 sb.AppendLine("o " + filter.gameObject.name); WriteMeshToObj(mesh, filter.transform, sb); } File.WriteAllText(path, sb.ToString()); AssetDatabase.Refresh(); EditorUtility.DisplayDialog("导出完成", "OBJ 文件已写入:" + path, "确定"); } static void WriteMeshToObj(Mesh mesh, Transform transform, StringBuilder sb) { Vector3[] vertices = mesh.vertices; Vector3[] normals = mesh.normals; Vector2[] uvs = mesh.uv; for (int i = 0; i < vertices.Length; i++) { Vector3 worldPos = transform.TransformPoint(vertices[i]); sb.AppendLine(string.Format("v {0:F6} {1:F6} {2:F6}", worldPos.x, worldPos.y, worldPos.z)); } // 法线和 UV 的写入逻辑省略,见 3.3 } }

逻辑说明:这个骨架版本把场景里所有MeshFilter收集起来,弹窗选择保存路径,然后用StringBuilder把每个网格的顶点先写成v。遍历时用的是FindObjectsOfType,可以一次性拿到场景里所有对象,但在大型场景中它会有不小的开销,正式项目里建议根据导出的目标层级做二次过滤。

参数说明:SaveFilePanel的默认文件名是scene_export.obj,你可以把默认名改成当前场景名,方便多场景导出时不冲突。F6是顶点坐标的小数位数,对绝大多数建模软件足够,也可以按需改到F8,但文件体积会明显变大。

2.3 为什么用 OBJ 而不是 FBX 或 glTF:选型里的兼容性与调试性

很多人会问,Unity 不是能导 FBX 吗,为什么还要自己写 OBJ 导出?因为 Unity 的 FBX 导出多数依赖第三方 SDK,下载、授权和配置都是一层额外成本,而且 FBX 是二进制或大文本格式,出了问题不好排查。OBJ 文件可以直接用记事本打开,某个顶点飞出去、某个面缺失,肉眼就能定位。

反过来,OBJ 的弱点也很明显:它不支持材质 PBR 参数,导出后通常只有一张附带的 .mtl 文件,颜色信息多半丢失。所以选型逻辑很清晰:如果对方只需要几何和 UV,OBJ 是成本最低的路径;如果对方需要完整材质或动画,那就应该走 FBX 或 glTF。ExportSceneToObj 这类工具的价值,就在于它能把“只要几何”这个场景做得干干净净,不把 FBX 那些复杂依赖带进来。

3. 场景对象导出实操:收集 GameObject、变换到世界坐标并写出 .obj

3.1 收集场景对象:递归遍历、过滤空物体与重复导出

直接在场景里用FindObjectsOfType<MeshFilter>()是最快的方法,但它会捞到所有东西,包括隐藏在场景里只是用来当占位符的网格。更稳的做法是先从场景根部递归遍历,按名字或标签过滤。下面这段代码在原骨架基础上增加了递归收集函数,避免把空 GameObject 和重复的实例导出两遍:

static void CollectExportTargets(Transform root, List<MeshFilter> targets) { MeshFilter filter = root.GetComponent<MeshFilter>(); if (filter != null && filter.sharedMesh != null) { targets.Add(filter); } foreach (Transform child in root) { CollectExportTargets(child, targets); } }

逻辑说明:递归函数每进入一个节点,先检查自己身上有没有MeshFilter,有了就加入列表,再继续往下钻子节点。这个写法比GetComponentsInChildren多了一点可控性,你可以方便地加判断,比如跳过name.StartsWith("Collider")的对象。

参数说明:root参数决定导出的起点。如果从场景根节点开始,效果等同于全场景导出;如果只选中某个父物体,传入Selection.activeTransform,就能只导出选中的子树。这个选择在编辑器工具里价值很大,因为美术经常只想导出某个建筑或某段路面,而不是整个场景。

3.2 坐标转换与索引偏移:最容易翻车的 transform 处理

OBJ 的世界坐标要求所有顶点都是世界坐标,而 Unity 的mesh.vertices是模型局部坐标。如果一个物体没有旋转缩放还好,一旦挂在了多层嵌套的节点下面,直接用局部坐标导出,就会出现物体分散在原点附近或者方向错乱的问题。

我见过最典型的翻车现场是:一个物体套了三层父子节点,导出后跑到坐标原点,美术还以为模型丢了。正确的做法是使用transform.TransformPoint转换顶点,法线则用transform.TransformDirection再做一次normalized,因为法线方向不应受非等比缩放影响而产生错误拉伸:

Vector3 worldPos = transform.TransformPoint(vertices[i]); Vector3 worldNormal = transform.TransformDirection(normals[i]).normalized;

逻辑说明:TransformPoint会同时处理父子节点的位置、旋转和缩放。转出来的worldPos就是 OBJ 里应该记录的坐标。TransformDirection只处理方向不处理位置,用normalized确保法线长度为 1,避免在 DCC 软件里出现高光异常。

参数说明:如果模型有非等比缩放(比如 X 轴拉长到 2 倍),法线变换后必须归一化,否则光照计算会歪。另外,如果原网格本身不带法线(mesh.normals长度为 0),导出端应该根据三角面叉积自动算法线,而不是直接跳过法线段。

3.3 写出 .obj:从 Mesh 数据到文本行的完整映射

把顶点、法线、UV 和三角索引按 OBJ 格式组合时,有一个极其容易错的地方:索引偏移动。当多个 GameObject 合并进同一个 OBJ 文件时,后面的物体不能从 0 开始引索,必须在前面已经写入的顶点数基础上累加。下面这段代码是完整的单物体写出逻辑:

static int vertexOffset; static void WriteMeshData(Mesh mesh, Transform transform, StringBuilder sb) { Vector3[] vertices = mesh.vertices; Vector3[] normals = mesh.normals; Vector2[] uvs = mesh.uv; int[] triangles = mesh.triangles; int baseIndex = vertexOffset; for (int i = 0; i < vertices.Length; i++) { Vector3 wp = transform.TransformPoint(vertices[i]); sb.AppendLine($"v {wp.x:F6} {wp.y:F6} {wp.z:F6}"); } for (int i = 0; i < normals.Length; i++) { Vector3 wn = transform.TransformDirection(normals[i]).normalized; sb.AppendLine($"vn {wn.x:F6} {wn.y:F6} {wn.z:F6}"); } for (int i = 0; i < uvs.Length; i++) { sb.AppendLine($"vt {uvs[i].x:F6} {uvs[i].y:F6}"); } for (int i = 0; i < triangles.Length; i += 3) { int i0 = triangles[i] + 1 + baseIndex; int i1 = triangles[i + 1] + 1 + baseIndex; int i2 = triangles[i + 2] + 1 + baseIndex; sb.AppendLine($"f {i0}/{i0}/{i0} {i1}/{i1}/{i1} {i2}/{i2}/{i2}"); } vertexOffset += vertices.Length; }

逻辑说明:这里把f行的三个索引都以“顶点索引/UV索引/法线索引”完全相同的值填写,前提是三个数组一一对应。因为 Unity 的triangles索引指向的是顶点数组,而法线和 UV 通常也是同序,所以这种写法在大多数网格上成立。baseIndex是导出器里最关键的变量,它保存了此前所有物体的顶点总数,没有它,第二个物体就会引用第一个物体的顶点。

参数说明:如果某个网格的uv数组为空,直接写vt会报错,应该先判断uvs.Length == vertices.Length再决定是否写 UV。另外,triangles数组长度必须是 3 的倍数,如果遇到非三角剖分网格,需要在导出前用mesh.GetTriangles(0)或重新 triangulate 处理。

4. Terrain 地形导出:高度采样、网格生成与分辨率取舍

4.1 Terrain 的本质:高度图数据与 Mesh 的对应关系

Unity 的 Terrain 不是一个普通 Mesh,它是基于高度图动态生成的。没有MeshFilter可拿,常规做法是把 TerrainData 按指定分辨率重新采样,生成一张新的 Mesh。这张 Mesh 是一块网格平面,每个网格点的高度取自地形高度。

采样前需要弄清两个坐标系统:TerrainData 的高度图分辨率(heightmapResolution)和世界坐标尺寸(size)。高度图分辨率决定最高精度的采样点网格,而 world size 决定地形在世界中的长宽高。导出时我们不会把高度图分辨率全部用上,而是按一个可调的细分步长重新切网格,避免生成几百万个三角形把 OBJ 文件撑爆。

4.2 高度采样与网格生成:GetHeight 和 GetInterpolatedHeight 怎么选

GetHeight(x, y)接收的是高度图数组坐标,离散且不经过插值;GetInterpolatedHeight(x, z)接收的是地形局部坐标(单位米),会在高度图采样点之间做平滑插值。导出地形时,如果用GetHeight,在细分网格没有对齐高度图原生分辨率时,会出现明显的阶梯状锯齿。

所以我一般用GetInterpolatedHeight,这样无论导出分辨率怎么改,地形表面都是平滑的。下面是从 TerrainData 生成顶点的核心代码:

public static Mesh GenerateTerrainMesh(TerrainData data, int subdivision) { Mesh mesh = new Mesh(); Vector3 size = data.size; int resolution = subdivision + 1; Vector3[] vertices = new Vector3[resolution * resolution]; Vector2[] uvs = new Vector2[resolution * resolution]; int[] triangles = new int[subdivision * subdivision * 6]; for (int z = 0; z < resolution; z++) { for (int x = 0; x < resolution; x++) { float px = (float)x / subdivision * size.x; float pz = (float)z / subdivision * size.z; float height = data.GetInterpolatedHeight(px, pz); vertices[z * resolution + x] = new Vector3(px, height, pz); uvs[z * resolution + x] = new Vector2((float)x / subdivision, (float)z / subdivision); } } int index = 0; for (int z = 0; z < subdivision; z++) { for (int x = 0; x < subdivision; x++) { int topLeft = z * resolution + x; int topRight = topLeft + 1; int bottomLeft = topLeft + resolution; int bottomRight = bottomLeft + 1; triangles[index++] = topLeft; triangles[index++] = bottomLeft; triangles[index++] = topRight; triangles[index++] = topRight; triangles[index++] = bottomLeft; triangles[index++] = bottomRight; } } mesh.vertices = vertices; mesh.uv = uvs; mesh.triangles = triangles; mesh.RecalculateNormals(); return mesh; }

逻辑说明:这里先用subdivision把地形平面切成等间距的网格,再用GetInterpolatedHeight取每个网格点的高度。三角形输出两个三角面组成一个 quad,注意绕序是逆时针(从 Y 轴上方往下看),保证法线朝向正确。最后调用RecalculateNormals(),让 Unity 根据三角面的叉积自动生成法线,省去手动计算的复杂度。

参数说明:subdivision决定导出网格的精细度。对 1024x1024 的原生地形,我用 128 作为默认导出值,生成的 OBJ 文件大约几万面,平衡了细节和文件大小。如果再大比如 512,适合地形起伏复杂的场景,但 Blender 里旋转视角会明显卡顿。

4.3 法线、UV 与布线方向:让地形在 DCC 里不露破绽

上面代码用RecalculateNormals生成的法线是平滑法线,和 Unity 地形默认的法线视觉比较接近。但有一点需要注意:RecalculateNormals会忽略 UV 裂缝的影响,如果相邻的两个 terrain chunk 导出时边界顶点没有精确对齐,就会出现一圈明显的接缝暗部。

UV 方面,直接把x/subdivision和z/subdivision作为 UV 值,等价于把整个地形纹理平铺到 OBJ 上。这个做法的好处是任何原始 TerrainLayer 纹理在这个 UV 下都是标准 0 到 1 分布,不进 Blender 也能看清接缝位置。如果要在 Blender 里重新赋予地表材质,这个 UV 也足够用,只是不能保留 Unity 里混合多张纹理的 SplatMap 信息。

布线方向也要留意:Unity 地形的 XZ 平面里,X 向右、Z 向下(屏幕坐标系习惯),而 OBJ 没有固定朝向,到了 Blender 后地形可能翻转成镜像。处理方式是在导出时增加一个“轴转换开关”,一旦发现模型整体绕 Y 轴旋转了 180 度,就把顶点坐标的 X 和 Z 做一次交换再输出。这个开关我建议默认打开,但提供配置项,因为有的 DCC 软件导入 OBJ 时自己也做了一次轴转换,两边都转就出问题了。

5. 导出避坑实录:FBX 反复转换时的 5 个常见问题与排查

5.1 导出到 Blender 后模型“消失”或黑脸

现象:OBJ 文件能在文本编辑器里看到数据,但导入 Blender 后什么都看不见,或者只能看到一团黑影。

原因:最常见的是 OBJ 里只有顶点没有面,或者面的法线方向反了。另一种情况是模型在 Unity 里用了双面渲染的 Shader,但 OBJ 本身不记录渲染状态,DCC 软件默认单面显示,背面直接剔除。

解决:导出前先确认mesh.triangles不为空;导入 Blender 后检查材质设置里的 Backface Culling,暂时关闭它。如果确定是法线反了,在导出循环里把三角面的三个索引逆序输出即可。

5.2 顶点“错位”和模型糊成一片:世界坐标与局部坐标混用

现象:多个物体导出到一个 OBJ,第一个物体正常,后面的物体要么堆在原点上,要么位置乱跳。

原因:这是索引偏移忘加,或者顶点坐标用了局部坐标没做TransformPoint。第二个物体复用了第一个顶点数组的索引值,面指向了错误的顶点,模型自然糊掉。

解决:把vertexOffset的累加逻辑放到每个物体导出完成后统一处理,并在地形导出里也复用同一段累加代码。我自己后来为了防呆,直接在单个网格导出完后打印baseIndex和当前物体顶点数,核对日志和 OBJ 里的v数量是否一致。

5.3 地形导出后出现锯齿接缝:离散采样害的

现象:地形导出到外部软件后,表面能看到明显的锯齿状隆起,尤其在坡度变化平缓的区域。

原因:使用了TerrainData.GetHeight(x, y),它只采样高度图离散点,而导出网格的顶点位置不会刚好落在这些离散点上,导致采样值出现阶梯跳变。

解决:改用GetInterpolatedHeight就没有这个问题。另外,地形边界上的顶点必须严格对齐地形的四边,如果外边界的顶点高度采样不到,会出现边缘塌陷,需要手动把边界采样点钳制到0或size范围内。

5.4 FBX 导入导出后法线异常:左手系与右手系的隐性矛盾

现象:同一批 FBX 模型,从 Unity 导出 OBJ 后,法线方向看起来是对了,但高光位置和原模型完全不一致,部分面出现镜面翻转。

原因:FBX 在被 Unity 导入时已经做了一次坐标系转换,Mesh 数据已经处于 Unity 的左手坐标系;再次导出 OBJ 到别的软件,对方可能默认右手坐标系。顶点坐标转换后,法线没有同步做镜像轴处理,就会出现“模型对、光线错”的情况。

解决:在导出前给用户一个“轴系统”选项。默认 Unity 左手系直接导出,选中需要兼容右手系软件时,把所有顶点的 X 或 Z 方向取反,同时法线取反,再用RecalculateNormals重新计算一次。不要只反转顶点坐标而保持法线不变。

5.5 大批量场景导出让编辑器卡死:同步导出不是唯一解

现象:一个场景里有上千个物体时,导出操作会让编辑器卡住十几秒,甚至未响应。

原因:所有网格的顶点遍历、字符串格式化、文件写入都在主线程同步执行,场景越大阻塞越久。

解决:导出逻辑可以拆成两步:第一步把所有网格数据整理成二进制中间缓存,第二步用异步写入把 OBJ 文本落盘。最省事的做法是先用StringBuilder攒大块文本,一次性File.WriteAllText,避免每行一次文件写入。如果场景大到几百 MB,再用 Editor Coroutine 或者子线程分批写。

6. 批处理与验证:让导出流程在正式项目里真正可信

6.1 批处理:一条菜单命令导出整个场景列表

单场景导出方便,但正式项目里常有一批场景要交付。我一般会在导出工具里加一个“场景列表”配置,把需要导出的场景路径写进一个文本资产,然后遍历调用EditorApplication.OpenScene并重复导出。下面是批处理入口的示意:

[MenuItem("Tools/Export Batched Scenes To OBJ")] static void ExportBatchedScenes() { string[] scenePaths = AssetDatabase.FindAssets("t:Scene", new[] { "Assets/Scenes" }); foreach (string guid in scenePaths) { string path = AssetDatabase.GUIDToAssetPath(guid); EditorApplication.OpenScene(path); ExportCurrentScene(); } }

逻辑说明:AssetDatabase.FindAssets按类型查找场景资产,再通过 GUID 转路径。每次打开场景后调用前面已经写好的单场景导出入口,就能把项目里所有场景都生成一份 OBJ 快照。这里有一个问题:ExportCurrentScene内部弹窗保存路径,批处理时会被中断,正式脚本里要把手动弹窗改成固定的输出目录参数。

参数说明:Assets/Scenes是场景目录,按你的项目结构调整。批处理模式建议配合命令行参数运行,我一般用一个-exportObjTo参数指定输出根目录,不加任何弹窗和确认框,直接后台跑完打日志。

6.2 验证:用顶点数、面数和包围盒核对导出结果

导出完成不等于导出正确。我每次导出后都会做三件事:先看 OBJ 文件大小是否在合理范围;再在 Blender 里导入,用统计面板对比顶点数和三角面数;最后用 Blender 的“全局包围盒”属性核对模型尺寸是否和 Unity 世界坐标尺寸一致。这一步能快速发现漏导出的子树或坐标变换错误。

如果项目里没有 Blender,也可以在 Unity 里写个验证脚本:把导出的 OBJ 再作为文本读回来,统计v和f的行数,和原始场景中所有 Mesh 的顶点数、三角形数比对,不一致就输出具体差异。

我现在导出任何场景,都会习惯性地先导出一个只有几十个顶点的小测试物体,确认索引偏移和坐标方向没问题,再跑全场景。这个习惯帮我躲过了好几次“辛辛苦苦导完,美术却告诉我模型是反的”这种尴尬场面。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询