我相信不少人在做地形相关项目的时候都撞过一面墙:Unity的Terrain用起来确实方便,画几笔就是一座山,刷几下就是一片草地,但一旦这东西要离开Unity——比如交给美术在Blender或Maya里微调、导给其他引擎协同、或者做数字孪生管线——马上就会卡壳。原因很简单:Terrain在Unity内部本质上并不是常规的网格模型,而是一套由高度图和splat图层实时生成的程序化地形系统,FBX这类通用交换格式只认真正的Mesh数据。所以“把Terrain转成FBX”这个需求,说白了就是一次从地形组件到“网格加贴图”的格式翻译。
这篇博文围绕Unity中Terrain地形导出FBX的完整流程展开,覆盖两种主流做法:一是用现成工具快速出结果,二是自己写编辑器脚本可控导出。无论你是要交给外部软件继续编辑,还是要做跨引擎资源传递,这篇都能给你一套可直接落地的方案。
1. 先把思路捋清楚:为什么Terrain不能直接存成FBX
很多人第一次做这个需求时,第一反应是找Unity有没有“Export Terrain”按钮,翻遍菜单也没找到,于是怀疑是自己版本不对。其实根本原因是Unity压根没打算让Terrain以传统模型的方式被导出,这里需要先把Terrain的数据结构理解透。
1.1 Terrain的数据本质:高度图加纹理层,不是顶点列表
Unity的Terrain组件底层数据是TerrainData,里面存的核心是:
- 高度图:一张灰度图,像素值决定地形高度。默认分辨率可能是1025×1025甚至更高,每个像素对应一个地形网格顶点的高度偏移。
- Splat贴图:控制地表纹理的混合权重,每一层(比如草地、泥土、岩石)都有一张权重图,最终颜色由权重叠加算出来。
- 树和细节:树是GameObject实例,草和花是GPU实例化绘制的DetailPrototype,这些在Terrain系统里是“绘制出来的东西”,不是模型结构的一部分。
而FBX要求的是:顶点数组、三角形索引数组、UV、法线,最多再加材质引用。这两种数据结构之间没有直接映射关系,所以Unity不会给你一个一键导出按钮,这就是为什么我们必须自己动手把高度图“网格化”。
1.2 从高度图到Mesh的三步思维模型
把所有花哨工具和代码剥开,核心就是这三步:
- 采样高度图:按一定的水平间距(比如每1米采一个点),把高度图的灰度值换算成世界坐标下的Y值,得到一堆顶点。
- 连三角形:把相邻四个顶点连成两个三角形,形成地面网格。顶点数量决定网格精度,也决定文件大小和性能。
- 贴图映射:把Terrain的splat控制贴图或整张Albedo图烘焙出来,作为FBX材质球的漫反射贴图,让模型看起来和原来的Terrain一致。
理解了这个模型,后面无论是用插件还是写脚本,你都能看懂它到底在做什么,遇到问题也能快速定位是哪个环节出了差错。
1.3 方案对比:用插件、用代码、还是用外部工具
市面上的做法大致可以分成三类,各有各的适用场景:
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| Asset Store插件(Terrain To Mesh等) | 快速、可视化、几乎零代码 | 付费、定制性差、版本兼容有风险 | 偶尔导一次,不关心细节控制 |
| 自己写编辑器脚本 | 完全可控、免费、可按需求定制 | 需要一点代码能力,前期要调试 | 频繁导出、有批量需求、管线集成 |
| 外部工具(Blender/Maya插件导入) | 模型编辑能力强 | 流程割裂、贴图传递麻烦 | 需要在地形模型基础上继续雕刻 |
我实际工作中绝大部分情况都选方案二。原因不只是免费,更关键的是灵活性:比如导出时把树林实例化到FBX里、把细节对象单独导出成Prefab列表、或者把4层splat合成一张二维贴图,这些需求插件经常满足不了,但脚本都能做。
2. 准备工作与工具选型:环境、包和关键参数
动手之前先把环境备齐,这个步骤虽然基础,但版本不对会浪费大量时间,而且报错信息往往很有误导性。
2.1 你需要准备什么
首先明确Unity版本。2020.3及以上的长期支持版本都验证过下面的流程,2021、2022、Unity 6也都没有问题,API基本没变化。主要涉及两个包:
- FBX Exporter:Unity官方出品,在Package Manager里直接搜索“FBX Exporter”就能安装,免费。它的作用是提供高保真的FBX导出能力,支持Mesh、材质、动画、甚至BlendShape。我们调用它的
ModelExporter.ExportObjects方法来完成最终写盘。 - Terrain的默认内置模块:不需要额外安装,TerrainData的接口都在UnityEngine里,直接用
GetInterpolatedHeight采样高度即可。
另外建议在项目里建一个Editor文件夹,把导出脚本放在这里面。这样脚本只在编辑器下运行,不会打进游戏包,也符合Unity的规范。
2.2 核心参数怎么定:分辨率越高越好吗
采样分辨率是影响整个导出质量、文件大小和卡顿感的第一因素。有人觉得分辨率拉满最保险,结果导出时编辑器直接假死10分钟,生成一个几个GB的FBX,导入到其他软件也卡成幻灯片。这里的计算逻辑不复杂:
- 如果地形尺寸是1000×1000米,高度图分辨率是1025×1025,按全精度采样,顶点数大约是1025×1025≈105万个,三角形数约210万个,这个数据量对FBX来说已经算偏大了。
- 如果只是想给外部软件做一个底模参考,分辨率可以降到512甚至256点,顶点数只有6.5万个左右,完全够用。
真正决定你该用多少分辨率的,是“目标场景要拿这个模型干什么”:只是看个轮廓,256够;要用来做碰撞烘焙,512比较均衡;要做最终渲染级别的地形资产,1024值得等。这跟Unity里画地形的思路一样——远处看不见的细节没必要挨个生成。
2.3 坐标系和轴方向:最容易忽视的小坑
Unity使用左手坐标系(X向右,Y向上,Z向前),FBX在多数DCC软件里默认是右手坐标(比如Blender是Z轴向上)。直接导出的FBX在Blender里打开,地形会整体“躺倒”,看起来像被旋转了90度。
我建议导出时统一把Transform旋转到(0, 0, 0),不要在地形根节点上做额外旋转,把坐标系转换交给DCC软件导入时处理(Blender导入FBX时可以设置轴方向为-Y Forward、Z Up,或者直接让Unity导出前包一层空节点并设置旋转)。这一步看似小,却是我见过翻车率最高的问题之一,后面排查章节会专门讲。
3. 快速路径实操:用现成工具3分钟内出结果
如果你只是临时用一次,不想写代码,这里给出一个可以“抄作业”的快速流程。我以Asset Store上比较常用的Terrain To Mesh插件为例说明思路,换成其他同类工具流程大同小异。
3.1 操作步骤记录
- 在Asset Store里搜索并下载Terrain To Mesh(或者类似的Terrain转换工具),导入工程。
- 在Hierarchy面板选中目标Terrain对象。
- 在菜单栏找到插件入口(一般是Tools/Terrain To Mesh),打开转换面板。
- 设置采样分辨率。插件会显示当前Terrain尺寸和推荐分辨率,我一般先填512,预览一下网格效果,再决定要不要调高。
- 勾选“Export Materials”选项,让插件自动生成使用splat贴图的材质。
- 点击生成,插件会在场景中创建一个带MeshFilter的GameObject,此时地形已经成功变成网格。
- 再配合官方FBX Exporter包,选中这个网格对象,通过菜单Assets/Export To FBX导出。
3.2 这套流程的局限在哪
插件方案体验上的“顺滑”是有代价的。第一个是版本兼容问题:很多这类插件停留在Unity 2019或2020时代,在Unity 2022和Unity 6的LTS版本里会报API过时警告,甚至有安全漏洞提示,虽然多数不影响使用,但看着心里发毛。
第二个是可定制性有限:它把所有地形层混合成一张贴图的做法是写死的,你想保留4层独立的splat纹理做后期编辑,插件一般做不到。第三个是批量处理能力弱:项目里有几十个地形区块时,你不可能一个个打开面板手动点击。
所以我的建议是:熟悉流程、验证想法、或者只需要一次性导出时,用插件没问题;但如果地形导出会成为你项目的一个固定环节,后面还会反复调整地形内容,不如下面这种脚本方案,一劳永逸。
4. 手写编辑器工具:完全可控的Terrain转FBX脚本
接下来是这篇博文的核心,我把自己在实际项目里验证过的一个脚本思路完整拆给你。你不需要完全照抄,理解每一段在干什么、为什么这么写,就能灵活改造出自己的导出工具。
4.1 第一步:把TerrainData变成Mesh
制作地形网格是整个流程的基础,代码逻辑很直白:遍历采样点,用GetInterpolatedHeight取高度,建立顶点数组;再按照行列顺序连接三角形。下面是最核心的生成函数:
public static Mesh GenerateTerrainMesh(TerrainData terrainData, int resolution) { int width = terrainData.heightmapResolution; int height = terrainData.heightmapResolution; float[,] heights = terrainData.GetHeights(0, 0, width, height); Vector3 size = terrainData.size; float xStep = width / (float)resolution; float zStep = height / (float)resolution; // 顶点数和UV int verticesPerSide = resolution + 1; Vector3[] vertices = new Vector3[verticesPerSide * verticesPerSide]; Vector2[] uv = new Vector2[vertices.Length]; int vertexIndex = 0; for (int z = 0; z <= resolution; z++) { int zPos = Mathf.FloorToInt(z * zStep); for (int x = 0; x <= resolution; x++) { int xPos = Mathf.FloorToInt(x * xStep); float h = heights[zPos, xPos]; vertices[vertexIndex] = new Vector3( x * xStep * size.x / width, h * size.y, z * zStep * size.z / height ); uv[vertexIndex] = new Vector2((float)x / resolution, (float)z / resolution); vertexIndex++; } } // 索引数组 int[] triangles = new int[resolution * resolution * 6]; int triangleIndex = 0; for (int z = 0; z < resolution; z++) { for (int x = 0; x < resolution; x++) { int i = z * verticesPerSide + x; // 两个三角形拼成一个四边形 triangles[triangleIndex++] = i; triangles[triangleIndex++] = i + verticesPerSide; triangles[triangleIndex++] = i + 1; triangles[triangleIndex++] = i + 1; triangles[triangleIndex++] = i + verticesPerSide; triangles[triangleIndex++] = i + verticesPerSide + 1; } } Mesh mesh = new Mesh(); mesh.name = terrainData.name + "_GeneratedMesh"; mesh.vertices = vertices; mesh.triangles = triangles; mesh.uv = uv; mesh.RecalculateNormals(); mesh.RecalculateBounds(); return mesh; }注意几个细节:GetHeights读出来的是一个值在0到1之间的二维数组,乘上terrainData.size.y才能得到真实高度;x和z的换算不能直接用像素坐标,要乘上尺寸和宽度的比值,否则地形会变形;RecalculateNormals是必要的,它根据三角形朝向自动算好光照需要的法线,不然后面导出到DCC里模型是黑的。
4.2 第二步:给UV和法线加上最合适的处理
UV这块,我踩过不少坑。上面的代码已经给了最简单的做法:UV的x和y都映射到0到1区间,这对应整张地形使用一张漫反射贴图,可以用。
但如果你希望FBX导入到Blender或C4D后,能有不少于地形原有的贴图分辨率,建议用世界坐标等比例生成UV。做法是把上面的采样点x * xStep * size.x / width和z * zStep * size.z / height直接作为UV的值除以一个像素密度系数(比如每20米一个UV重复单元)。这样贴图会像瓷砖一样在模型表面重复铺贴,适合配合可平铺纹理使用。
法线方面,RecalculateNormals在Unity里计算的是平坦着色的平均值,对轮廓平滑的地形来说效果不错,但如果你打算在DCC软件里做高精度雕刻,后续还需要一个“高模重拓扑”的过程,这时候建议导出的分辨率高一些,给后期留出空间。一个容易被忽视的点是:如果地形上有悬崖或垂直的坡面,生成的法线在陡峭区域可能显得生硬,更稳妥的做法是把原始Terrain的Normal数据烘焙到网格上——具体可以用terrainData.GetInterpolatedNormal逐顶点采样后覆盖到mesh.normals,这个接口在Unity 2019.4以上可用。
4.3 第三步:贴图和材质,让FBX不白模导出去
FBX格式本身只保留材质引用,是否把贴图一起复制过去,取决于你使用的导出方式。官方FBX Exporter有两种处理策略:
- 只导出材质引用(适合对方已有同名贴图的情况)
- 同时复制贴图到FBX同目录下的Textures文件夹
推荐在Editor脚本里通过ExportOptions设置开启贴图复制。关键代码片段如下:
var options = new ExportOptions(); options.ExportFormat = ExportFormat.Binary; options.ExportGeometry = true; options.ExportMaterials = true; options.MaterialMap = ExportMaterialMap.None; // 生成一个带MeshFilter和MeshRenderer的临时对象 GameObject holder = new GameObject(terrainData.name + "_Exported"); MeshFilter mf = holder.AddComponent<MeshFilter>(); MeshRenderer mr = holder.AddComponent<MeshRenderer>(); mf.sharedMesh = mesh; mr.sharedMaterial = BuildCombinedMaterial(terrainData); // 调用官方导出接口 ModelExporter.ExportObjects(outputPath, new GameObject[] { holder }, options); Object.DestroyImmediate(holder);这里的BuildCombinedMaterial是负责把splat纹理层合成一张贴图的关键函数。Unity的Terrain层贴图分散在四张splatAlpha通道里,合成原理是:用splat控制图(alphaMap)做混合权重,把每张层纹理按权重叠加出最终颜色。实际代码里可以用TerrainLayer[]读每一层的diffuse纹理,再配合alphaMap在CPU上做逐像素混合,工作量不大但效果很直观。
如果你不想写这个合成逻辑,还有一个更快的办法:直接用Unity的渲染管线照相机把地形从上往下拍一张正射图,存成PNG再贴上去。思路是在Terrain正上方架一个正交相机,设置正交投影的Size正好覆盖地形范围,用RenderTexture读取像素再EncodetoPNG。这招适合画了多层复杂贴图、但不想逐层拆开的情况,缺点是会产生采样锯齿,需要后期修一下。
4.4 第四步:调用官方FBX Exporter,落盘到任意目录
脚本的最后一步是调用FBX Exporter的API,把刚刚生成的Mesh和材质写入磁盘。整个过程要在EditorApplication.delayCall里执行,避免在场景加载过程中报错。
官方接口的调用方式在Unity文档里其实没写得太细,我直接给你一个能跑通的模板:
[MenuItem("Tools/Export Terrain To FBX")] public static void ExportCurrentTerrain() { Terrain terrain = Selection.activeGameObject?.GetComponent<Terrain>(); if (terrain == null) { Debug.LogError("请先选中一个带Terrain组件的物体"); return; } string outputPath = EditorUtility.SaveFilePanel( "导出FBX", Application.dataPath, terrain.name + ".fbx", "fbx" ); if (string.IsNullOrEmpty(outputPath)) return; int resolution = EditorUtility.DisplayDialog( "选择精度", "分辨率越高细节越多,但FBX和贴图体积也会变大。推荐512。", "512", "1024" ) ? 512 : 1024; Mesh mesh = GenerateTerrainMesh(terrain.terrainData, resolution); Material mat = BuildCombinedMaterial(terrain.terrainData); GameObject holder = CreateHolderObject(terrain.name, mesh, mat); ExportOptions options = new ExportOptions(); options.ExportFormat = ExportFormat.Binary; options.ExportGeometry = true; options.ExportMaterials = true; ModelExporter.ExportObjects(outputPath, new GameObject[] { holder }, options); Object.DestroyImmediate(holder); AssetDatabase.Refresh(); Debug.Log($"地形已导出:{outputPath}"); }这个菜单项挂在Tools下,选中场景里的Terrain对象后点一下就能弹窗保存FBX。如果你要批量导出多块地形,在外面套一层循环遍历所有Terrain对象即可,我项目里就写过一个遍历FindObjectsOfType<Terrain>()再逐个调用的批处理版本。
4.5 批量导出与工程化扩展思路
上面这份脚本prompt已经可以解决单个地形的导出问题。实际落地时我还有几个经验可以分享:
- 在导出文件名里加上日期或版本号,方便回滚对比,比如
Terrain_20250611_v3.fbx。 - 导出后自动打开文件夹,把FBX和贴图放在一起,避免对方只收到一个模型缺了材质贴图。
- 如果项目有多个Terrain,建议做成一个“导出配置”类ScriptableObject,分辨率、贴图是否合成、是否导出树等参数全装在里面,一键批量导。
5. 常见问题与排查技巧:哪些坑我替你踩过了
这部分内容全是从真实操作中积累的,每一个问题我都见过不止一次。做成速查表方便你对照排查。
5.1 导出后问题速查清单
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 在Blender里模型躺倒 | 坐标轴约定不同 | 导入时选Z轴向上;或导出前包个旋转空节点 |
| FBX是纯白色/没有贴图 | 材质没勾选复制或路径不对 | 打开ExportOptions的材质复制开关,确认贴图和FBX同目录 |
| 地形表面出现大量锯齿状凸起 | 采样分辨率太低或高度图精度丢失 | 调高resolution,检查GetHeights的读取范围是否正确 |
| 法线异常、光照发黑 | RecalculateNormals后顶点法线不匹配 | 改用GetInterpolatedNormal逐顶点采样覆盖 |
| 导出时编辑器卡死几分钟 | 分辨率太高或场景里有大量树和细节 | 先用512测一次,把Terrain的Detail对象临时隐藏 |
| FBX导入后在UE里缩放不正确 | 单位制不一致 | 导出前统一设置单位比例,FBX Exporter默认参数通常没问题 |
| 树和草全没了 | Terrain的Detail和Tree不是标准Mesh | 树需要单独导出为Mesh对象,草考虑烘焙成贴图或用Prefab替代 |
5.2 踩坑实录:一个曲面塌陷的诡异Bug
说一个印象特别深的排查经历。有次导出一个小岛地形,分辨率选了1024,结果生成出来的FBX在局部区域出现明显的塌陷,像是方块状的台阶。排查了很久,最后发现是GetHeights返回的数组下标顺序和地形坐标不匹配:我应该按heights[zPos, xPos]取值,结果写成了heights[xPos, zPos],导致X和Z轴交换,在非对称地形上就出现了这种奇怪的阶梯状塌陷。
这个问题的教训是:Unity里二维数组的行列顺序和屏幕坐标习惯不一样,在涉及地形坐标取值的代码里,一定要反复确认哪个轴对应行、哪个轴对应列。最简单的自检方法是导出一个完全对称的金字塔形地形,如果导出的网格不是对称的,说明坐标取反了。
5.3 关于阴影和渲染包围盒的两个提醒
导出后的FBX在Unity里重新导入时,经常会出现“阴影显示异常”或“物体被剔除”。这两个其实是同一个根源:导入生成的Mesh包围盒(Bounds)不准确。Unity烘焙Mesh时如果没有正确计算RecalculateBounds,包围盒会是一个极度缩小的范围,导致摄像机在稍远距离就直接把整个模型剔除了,阴影自然也看不到。
解决的方式分两层:代码里生成Mesh后务必调用mesh.RecalculateBounds();如果导入的是外部生成的FBX,就在Unity的导入设置里点击“Recalculate Bounds”按钮或者检查模型是否有合法几何体。我在脚本里其实已经加了这个调用,但如果你用了别人写的老插件或者手工修改了FBX源文件,导入后还是要留意一下检查。
6. 一点补充:数字孪生项目里的Terrain协作实践经验
最后一个想单独聊聊的方向是数字孪生。在做智慧城市、园区可视化这类项目时,地形的交接非常频繁,很多团队用Cesium for Unity或者自定义地图系统,但地形数据往往来自GIS软件,需要统一转成Unity能用的格式。这个过程里Terrain导出为FBX成了一种有效的“中间交换”手段:在Unity里调好地形后导出,交给后端的GIS或Cesium管线做切片处理,再回到Unity里显示。
这个场景下有两个额外注意点:一是贴图分辨率要格外高,因为最终可能会被放大到城市级别观看;二是要保留地形的准确坐标信息。建议在地形根节点上用一个自定义Component记录中心经纬度、海拔基准等元数据,导出时把信息输出到一个JSON或CSV文件里一起交付。这不算什么高深技术,但能省掉无数“你这是哪块地”的沟通成本。
我个人的习惯是:把这段生成网格的代码封装成独立的Editor工具类,还顺手写了菜单里的快捷键,几乎每天都会用到。也不止一次遇到做完地形又要改的情况,改完再跑一次导出就行,整个过程连3分钟都用不到。回到标题说的时间——当你把脚本用顺了,从选中地形到FBX出现在磁盘上,真的只要3分钟。希望这篇分享能帮你在同样的事情上少走一段弯路。