做Unity地形编辑器拓展,一开始我并没有把它当成一个“项目”来做。那时候项目里十六块地形摆在一起,美术组天天刷草刷到吐,只要换一个地块就得重新调一遍细节层密度和树间距;程序组想批量清空所有草丛、从灰度图快速生成一块山区,结果发现内置Terrain面板里根本没有这类按钮。我作为负责工具链的人,被问得最多的一个问题就是:“Unity地形编辑器,到底能不能再往上加功能?”
能,但你要自己动手。Unity的地形编辑能力从来就不是“内置即终点”,它更像是给了你一套完整的数据接口和绘制上下文,真正的生产力取决于你愿不愿意写编辑器拓展。这篇文章就围绕“Unity-地形编辑器-编辑器拓展功能类”这个方向,完整拆解一套可落地、可复现的地形工具集:为什么需要它、核心API怎么用、代码怎么写、坑在哪里。如果你正在为地形工作流发愁,或者刚准备入坑编辑器工具开发,这篇应该能省下你不少弯路。
1. 为什么必须做地形编辑器拓展:先看内置工具够不够用
1.1 内置Terrain能干什么,又卡在哪里
Unity内置的地形面板功能其实不弱:Raise/Lower能雕高度,Paint Texture能刷多张材质,Paint Trees和Paint Details能种树撒草,Set Height可以整块抬升。单块地形、单次手动操作,这套工具是能跑的,应付原型和小型Demo完全没问题。
但你把场景放大到多地块、多人协作、程序化生成和批量修改的层面,它的短板就暴露得很彻底。
首先,内置笔刷本质上是一套“手动档”工具,一次只能处理一个地块,一次也只能用一种笔刷。你想一次性把所有Terrain的细节层清空?没有。想把十六块地形统一成同一个Base Map距离和阴影距离?没有。想按地形坡度自动刷草,只让20°到45°的南坡长草?也没有。你只能在编辑器里逐块打开、逐层设置、逐笔刷去画,十六块地下来,半天就没了。
更麻烦的是,这种重复劳动极度容易出错。我见过美术同事在调第十块地形时忘记某块地形的Tree Distance没改,结果运行时远处树木突然“闪现”,观感非常割裂。人去做重复参数操作,一定会漏,这不是态度问题,是流程设计问题。
所以我的判断是:内置地形工具适合“单点操作”,不适合“批量策略”。一旦你的地形管理从“画地形”上升到“维护地形资产”,就必须引入编辑器拓展,用代码去承载那些重复、可量化、可复算的规则。这才是这个项目标题里“拓展功能类”的意义——它不是去替换内置地形绘制,而是补齐Terrain在流程化生产里缺失的那块拼图。
1.2 为什么选编辑器拓展,而不是运行时工具
很多初学者会问:地形相关功能我写一个运行时脚本,挂在相机上或者做成游戏内呼出的面板不也一样?差别很大,而且方向是反的。
地形编辑器拓展运行在Editor命名空间下,代码只在编辑器环境里执行,不会被打进发布包。这意味着你可以随意调用AssetDatabase、Undo系统、Selection、Inspector重绘这些Unity专门为编辑器准备的能力,而运行时工具完全拿不到这套机制。比如批量改完十六块地形后统一保存资源、操作前注册Undo、操作后用进度条反馈——这些都是编辑器API的强项,运行时脚本做起来要么没有,要么得自己造轮子。
另一个决定性理由是安全。地形数据是显式资源,属于开发期资产,根本不需要进入玩家安装包。用运行时逻辑去改地形数据,等于把开发工具暴露给了运行环境,不仅增包、多一层权限风险,还会让“引擎在玩家设备上写地形数据”成为一个非常尴尬的架构决策。
当然,编辑器拓展也分形态。最简单的是写一个静态方法,用[MenuItem]挂到菜单栏;复杂一点的是做一个EditorWindow面板,把所有地形工具聚合在一个窗口里;再进阶就是写CustomEditor,直接扩展Terrain组件的Inspector。我的建议是:菜单+窗口优先,CustomEditor谨慎。因为扩展内置Terrain的Inspector会让美术同事原有的面板被覆盖,一旦你的绘制接口和内置操作冲突,出问题后很难快速切回原始流程。
1.3 工具集整体架构:别上来就写一个巨无霸窗口
我见过一些地形工具,所有功能堆在同一个类里,一千多行代码,窗口里放了二十个按钮,看着很“全”,但用起来就是一场灾难。某个功能崩了,你连定位都难;想给另一条产品线复用其中一个批处理逻辑,又得把这个巨无霸拖过去。
我这套工具集一开始就按模块拆开,目录结构长这样:
Assets/Editor/TerrainTools/ ├── TerrainToolsWindow.cs ├── Core/ │ ├── TerrainAccess.cs │ └── TerrainProgress.cs ├── Batch/ │ └── TerrainBatchTools.cs ├── Procedural/ │ ├── TerrainNoiseGenerator.cs │ └── TerrainHeightmapImporter.cs └── Painting/ └── TerrainRulePainter.cs思路很简单:Core只放所有模块共用的数据访问和进度显示逻辑;Batch管批量参数统一、清空细节层;Procedural管噪声生成、灰度图导入;Painting管规则刷草这类按条件写细节层的工具。窗口本身很薄,只负责画控件、调模块方法。
好处是:每一个工具类都可以独立测试,任何一个模块出问题,直接看对应代码;后续新地形工具继续加新文件夹就行,不会把窗口越拖越肿。如果你的项目团队里有专门的技术美术,这种按场景拆分的模块结构,也更容易让不是写引擎出身的人看懂和接手。
2. 动手前必须搞懂的TerrainData与编辑器API
2.1 TerrainData才是地形操作的核心
你在地形Inspector面板上看到的东西,绝大多数都存储在Terrain组件挂着的TerrainData资源里。高度、材质、树、草、细节层,全部是它的字段。想写地形编辑器拓展,第一件事就是忘掉“操作Terrain组件”这个直觉,把思维切到“操作TerrainData资源”上。
高度图是最常用的入口。terrainData.heightmapResolution决定了高度图的分辨率,而world尺寸由terrainData.size决定。注意一个关键点:GetHeights和SetHeights返回、接收的都是一个float[,]数组,数值范围固定是0到1,代表高度比例,不是世界高度。想要获得真实海拔,需要乘以terrainData.size.y。
很多新手第一次写高度修改工具,直接用世界坐标去设置高度数组,结果山体飞出了半空,就是这个归一化没搞懂。举个实际例子:地形尺寸是1000x1000,高度是300,你想在中心区域抬升50米,就应该先算中心对应的高度图坐标,再把对应区域的逻辑高度从(50/300)改成(100/300),而不是直接把50填进数组。
修改完高度图后还有一个高频坑:SetHeights不会立刻反映在场景里。你需要在代码里调用terrain.ApplyDelayedHeightmapModification(),或者至少在操作完成后标记场景Dirty,否则会看到数据变了但地形Mesh纹丝不动。这个步骤漏掉,工具就会给人“点了没反应”的印象。
2.2 Alpha贴图、细节层与树:三个高频数据块
地形贴图在底层是Splatmap(Alpha贴图),每个像素上有多层权重,Unity用它们混合出最终的地表材质。在工具代码里,这个数据对应terrainData.GetAlphamaps和SetAlphamaps,返回float[,,]数组,三个维度的含义分别是yz平面坐标和层索引。
批量替换材质、清空某一层地皮、把草地层统一降到某个权重,都属于对这个三维数组的操作。实际操作时,有一个更容易被忽略的点:terrainData.terrainLayers这个数组顺序决定alpha map第二维的层索引,如果你先动了TerrainLayer列表的顺序,再按记忆中的层号去改alpha map,颜色就会张冠李戴。
细节层(Detail Layer)是草丛这类地表网格的载体。terrainData.detailResolution决定密度,GetDetailLayer返回int[,,],存的是每个格子里该细节层的个体数量。对细节层的批量重置,本质就是拿一个同尺寸的int数组去替换原值。树相比草丛简单一些,treeInstances是一个实例数组,但批量改树涉及树的坐标、旋转、缩放,我一般只在有明确批处理需求时才专门碰它。
这三个数据块的读法不同,但写工具的套路完全一致:拷贝原数据、按规则修改、写回、注册Undo。熟练之后,你能用一套模板快速套用到各种“按规则批量修改地形数据”的场景。
2.3 编辑器侧的关键注入点
写地形工具,你会高频遇到下面这四个编辑器API:
[MenuItem]是最简单的入口。写在静态方法上,编译后菜单栏就会多出一个项目。很多工具一行代码都不需要,直接就干完活。我给工具菜单统一加了前缀“Tools/Terrain Tools/”,方便按关键字搜索,也避免和第三方工具的菜单混在一起。
EditorWindow是你的主力界面容器。用它做参数面板比直接在MenuItem里弹Dialog要灵活得多,Slider、Toggle、曲线控件都能摆,还能分页签隔离不同功能的参数。
Undo是编辑器工具的生命线。用户用了你的工具,点撤销没反应,那这个工具在美术团队里基本会被拉黑。地形工具必须明确区分Undo.RecordObject和Undo.RegisterCompleteObjectUndo的差别,后面我会专门讲这个坑。
EditorApplication.update是一个编辑器的持续回调,做分帧处理、长任务循环时会用到。当然,大部分地形成批操作还是一次性循环更直观,但你要做“拆帧处理”降低卡顿感时,这个API就是基础。
3. 从零搭建地形增强工具集:批量、噪声与规则刷草完整实操
3.1 先做一个统一入口窗口,把功能分页挂进去
直接把所有功能代码堆在EditorWindow里是最容易想到的做法,但不建议。我更推荐让窗口只做排布和转发,具体逻辑放到不同的工具类。
先看窗口的最小骨架:
using UnityEditor; using UnityEngine; public class TerrainToolsWindow : EditorWindow { private int currentTab; private readonly string[] tabs = { "批量调整", "程序化生成", "规则刷草" }; [MenuItem("Tools/Terrain Tools/打开工具主窗口")] public static void Open() { var window = GetWindow<TerrainToolsWindow>(); window.titleContent = new GUIContent("地形增强工具"); window.minSize = new Vector2(480, 360); } private void OnGUI() { currentTab = GUILayout.Toolbar(currentTab, tabs); EditorGUILayout.Space(); switch (currentTab) { case 0: DrawBatchTab(); break; case 1: DrawProceduralTab(); break; case 2: DrawPainterTab(); break; } } private void DrawBatchTab() { if (GUILayout.Button("统一场景内所有 Terrain 的基础参数")) { TerrainBatchTools.UnifyAllBasics(); } if (GUILayout.Button("清空所有细节层(草)")) { TerrainBatchTools.ClearAllDetails(); } } }这个窗口只做了两件事:用GUILayout.Toolbar做了页签切换,把按钮点击转发给TerrainBatchTools。后续加功能时,你只需要在对应页签里继续加控件,不用动窗口的整体结构。如果你想把某一类工具独立弹出,也可以从MenuItem再开一个子窗口,但一个聚合窗口更方便形成“工作台”习惯。
3.2 批量统一参数与清空细节层:一次搞定十六块地形
批量操作是地形工具里性价比最高的模块,因为它能立刻解决团队里最痛的重复劳动。先看统一基础参数的实现:
using UnityEditor; using UnityEngine; public static class TerrainBatchTools { public static void UnifyAllBasics() { var allTerrains = Object.FindObjectsByType<Terrain>(FindObjectsSortMode.None); if (allTerrains.Length == 0) { EditorUtility.DisplayDialog("提示", "场景里没有 Terrain", "OK"); return; } Undo.SetCurrentGroupName("Unify Terrain Basics"); int group = Undo.GetCurrentGroup(); foreach (var terrain in allTerrains) { Undo.RecordObject(terrain, "Unify Terrain Basics"); terrain.heightmapPixelError = 5f; terrain.basemapDistance = 2000f; terrain.treeDistance = 2000f; terrain.detailObjectDistance = 120f; } Undo.CollapseUndoOperations(group); EditorUtility.DisplayDialog("完成", $"已统一 {allTerrains.Length} 个 Terrain 的基础参数", "OK"); } }这里有几个细节值得说。Undo.SetCurrentGroupName和CollapseUndoOperations配合使用,可以把多个对象的多次记录压成一个撤销步骤,否则用户点一下撤销只能取消半个操作,体验非常奇怪。Object.FindObjectsByType是近几个Unity版本的写法,如果你还在用2019或2020,改成Object.FindObjectsOfType ()就行,逻辑一致。
清空细节层的代码模块更贴近TerrainData的核心操作,重点是注册Undo时要记录TerrainData本体:
public static void ClearAllDetails() { var allTerrains = Object.FindObjectsByType<Terrain>(FindObjectsSortMode.None); int totalDetail = 0; for (int i = 0; i < allTerrains.Length; i++) { var td = allTerrains[i].terrainData; Undo.RegisterCompleteObjectUndo(td, "Clear All Details"); int layers = td.detailPrototypes.Length; for (int layer = 0; layer < layers; layer++) { int res = td.detailResolution; int[,] empty = new int[res, res]; td.SetDetailLayer(0, 0, layer, empty); } totalDetail += layers; EditorUtility.DisplayProgressBar("清理细节层", $"{allTerrains[i].name}", (float)i / allTerrains.Length); } EditorUtility.ClearProgressBar(); EditorUtility.DisplayDialog("完成", $"共清理 {allTerrains.Length} 个 Terrain,{totalDetail} 个细节层", "OK"); }注意SetDetailLayer的层参数从0开始,它对应detailPrototypes数组里的下标。批量操作数据库时,这个下标很容易因为团队调整草种顺序而变化,如果你做的是“清空特定某一种草”,最好让用户在工具界面里从弹出下拉框选Prototype,而不是手填一个数字。
3.3 程序化生成:噪声高度图与灰度图导入
程序化地形是“地形编辑器拓展功能类”里最有想象力的方向。这里我给出一套基于分形柏林噪声的高度图生成方案,它足以生成有起伏的山地轮廓,再配合后续的规则刷草,能比较快地搭出基础地形原型。
public static void GenerateNoiseHeightmap(Terrain terrain, float scale, float amplitude, int octaves, float persistence, float lacunarity, int seed) { var td = terrain.terrainData; Undo.RegisterCompleteObjectUndo(td, "Generate Noise Heightmap"); int res = td.heightmapResolution; float[,] heights = new float[res, res]; var prng = new System.Random(seed); float offsetX = prng.Next(-100000, 100000); float offsetZ = prng.Next(-100000, 100000); float maxNoiseHeight = float.MinValue; float minNoiseHeight = float.MaxValue; for (int y = 0; y < res; y++) { for (int x = 0; x < res; x++) { float amplitudeSum = 1f; float frequency = 1f; float noiseHeight = 0f; for (int o = 0; o < octaves; o++) { float px = (x - res * 0.5f) / res * scale * frequency + offsetX; float pz = (y - res * 0.5f) / res * scale * frequency + offsetZ; float perlin = Mathf.PerlinNoise(px, pz) * 2f - 1f; noiseHeight += perlin * amplitudeSum; amplitudeSum *= persistence; frequency *= lacunarity; } heights[y, x] = noiseHeight * amplitude; maxNoiseHeight = Mathf.Max(maxNoiseHeight, heights[y, x]); minNoiseHeight = Mathf.Min(minNoiseHeight, heights[y, x]); } } for (int y = 0; y < res; y++) { for (int x = 0; x < res; x++) { heights[y, x] = Mathf.InverseLerp(minNoiseHeight, maxNoiseHeight, heights[y, x]); } } td.SetHeights(0, 0, heights); terrain.ApplyDelayedHeightmapModification(); EditorUtility.DisplayDialog("完成", "噪声高度图生成完成", "OK"); }这段代码其实就是一个多倍频叠加的柏林噪声,octaves控制细节层次的层数,persistence控制每一层噪声的衰减幅度,lacunarity控制频率增长倍数。为什么最后要加一次InverseLerp归一化?因为多倍频叠加后噪声值会偏移到某个区间,不归一化的话地形会整体偏高或偏低,归一化之后再把GetHeights的值当成0到1的比例,世界高度就自然匹配Terrain的size.y了。
再补一个从灰度图导入高度图的版本。灰度图导入在项目里非常常用,美术用其他软件画好灰度图,导入Unity就能快速得到一个山体轮廓。这里有一个稳健的读取方式:
public static void ImportHeightmapFromImage(Terrain terrain, Texture2D source, float heightMin, float heightMax) { var td = terrain.terrainData; Undo.RegisterCompleteObjectUndo(td, "Import Heightmap From Image"); int res = td.heightmapResolution; float[,] heights = new float[res, res]; string path = AssetDatabase.GetAssetPath(source); Texture2D tex = new Texture2D(2, 2, TextureFormat.RGBA32, false, true); tex.LoadImage(System.IO.File.ReadAllBytes(path)); tex.filterMode = FilterMode.Bilinear; var pixels = tex.GetPixels(); int srcRes = tex.width; for (int y = 0; y < res; y++) { for (int x = 0; x < res; x++) { float u = Mathf.InverseLerp(0, res - 1, x); float v = Mathf.InverseLerp(0, res - 1, y); int px = Mathf.FloorToInt(u * (srcRes - 1)); int py = Mathf.FloorToInt(v * (srcRes - 1)); float gray = pixels[py * srcRes + px].r; heights[y, x] = Mathf.Lerp(heightMin, heightMax, gray); } } Object.DestroyImmediate(tex); td.SetHeights(0, 0, heights); terrain.ApplyDelayedHeightmapModification(); }这里我特意用File.ReadAllBytes加LoadImage来读取,而不是直接source.GetPixels。原因是很多美术导入的灰度图没有开Read/Write,直接GetPixels会报错;LoadImage重新生成一张可读纹理则绕开了资产导入设置问题,并且我把新纹理的linear参数设为true,让灰度值在线性空间下读取,避免sRGB曲线造成的高度偏差。这段代码的坐标映射就是普通最近邻采样,如果你的灰度图分辨率远高于地形高度图分辨率,建议改成双线性插值,否则会看到明显的锯齿台阶。
3.4 规则刷草:坡度、海拔驱动的细节层重建
“只在特定坡度和海拔刷草”是美术团队极想要的一类规则类工具。Unity内置笔刷只能手动画,没法根据地形几何体自动判断“哪里该长草”。我这套实现思路是:把整个地块的细节层重新读出来,逐格计算所在位置的地形法线和高度,不满足规则就置0,满足规则就拉到指定密度。
public static void RebuildDetailsByRules(Terrain terrain, int layerIndex, float minSlope, float maxSlope, float maxHeight, int densityPerCell) { var td = terrain.terrainData; Undo.RegisterCompleteObjectUndo(td, "Rebuild Details By Rules"); int detailRes = td.detailResolution; int hres = td.heightmapResolution; int[,] detail = new int[detailRes, detailRes]; for (int y = 0; y < detailRes; y++) { int hy = Mathf.RoundToInt((float)y / (detailRes - 1) * (hres - 1)); for (int x = 0; x < detailRes; x++) { int hx = Mathf.RoundToInt((float)x / (detailRes - 1) * (hres - 1)); Vector3 normal = td.GetInterpolatedNormal(hx, hy); float slopeAngle = Vector3.Angle(normal, Vector3.up); float height = td.GetInterpolatedHeight(hx, hy); if (slopeAngle >= minSlope && slopeAngle <= maxSlope && height <= maxHeight) { detail[y, x] = densityPerCell; } } } td.SetDetailLayer(0, 0, layerIndex, detail); terrain.ApplyDelayedHeightmapModification(); EditorUtility.DisplayDialog("完成", "细节层按规则重建完成", "OK"); }为什么用GetInterpolatedNormal而不是直接读高度图算斜率?一个是省事,另一个是插值法线自带平滑效果,不会因为高度图单点跳变导致规则结果出现大量碎斑。坡度在这里用Vector3.Angle(normal, Vector3.up)计算,得到的是0到90度的坡角。你这个工具输入参数可以给最小坡度、最大坡度和最大海拔,美术同事只要听过一次需求就能自己填。
这段逻辑不涉及SceneView里的笔刷交互,属于整块重刷。想做成鼠标画到哪刷到哪,需要在SceneView里监听鼠标事件,再用交叉区域做局部Detail Rebuild,复杂度会高一整个级别。我的经验是:先做整块规则重建,跑通后再考虑局部笔刷,因为局部笔刷的精度和覆盖逻辑很容易在处理地块边缘时产生“画到一半突然被抹平”的奇怪结果。
4. 我踩过的坑:性能、Undo、接缝与颜色空间
4.1 一跑就卡死:性能优化要做的三件事
我第一次写噪声生成工具时,直接在1024分辨率的Terrain上跑双循环加八倍频Perlin噪声,编辑器硬生生卡了三四分钟,期间整个Unity窗口完全无响应。原因不光是循环量大,还有SetHeights内部会触发高度图纹理上传和mip重建,频繁调用代价极高。
后来我总结出三个性能经验。
第一,批量操作时不要边算边写,应该先把整个float[,]算完,最后只调用一次SetHeights;同理,细节层重建也把所有计算做完再一次性SetDetailLayer。第二,计算量大于一百万次时,编辑器里必须给用户一个进度反馈,否则用户根本不知道是卡了还是在运行。用EditorUtility.DisplayProgressBar就够,记得在结束时ClearProgressBar。第三,如果你在EditorApplication.update里做长任务,要么拆帧处理,要么主动加“取消按钮”,让用户能中断工具,不能让它一口气跑到底。
另外还有一个细节:不要在OnGUI里做重计算。EditorWindow的OnGUI会在鼠标移动等高频事件里疯狂调用,你把几千次噪声计算写在这里,窗口本身就会变得非常卡。正确做法是点击按钮时开一个“后台任务状态变量”,OnGUI只负责渲染和根据状态显示进度。
4.2 Undo回滚错乱:只记录Terrain组件是不够的
地形工具最容易被美术吐槽的问题就是撤销。点一次撤销,Unity没反应;再点一次,Terrain组件上的属性变了,但高度、草、树全没回来,甚至出现地形数据被清空一半的诡异状态。
问题出在Undo的记录对象上。Undo.RecordObject只记录对象引用层级的字段修改,而TerrainData里的高度图、Alpha贴图、细节层是内部的资源数据,并不直接暴露为Terrain组件的可序列化字段。你只RecordObject(terrain),等于告诉Unity“记住这个组件的状态”,但TerrainData这个大资源本体根本没被纳入撤销系统。
正确做法是:只要操作涉及TerrainData,就用Undo.RegisterCompleteObjectUndo(terrainData, "操作名")。这个API会把TerrainData的历史完整记录一份,撤销时可以整体恢复。如果同时改了Terrain组件上的属性又改了TerrainData,那就要两个都记录,但要注意把多个操作并入同一个Undo Group,否则用户撤销一步只会撤一半。
另外,地形数据本身是资源文件,就算有Undo,内存里的恢复也可能和磁盘保存状态不一致。我的建议是所有批量修改工具在操作完成后不自动SaveAssets,由使用者确认结果后再统一保存,这个习惯能有效避免“工具一跑,磁盘上半个项目的地形资源全被改了”的惨剧。
4.3 多地块接缝、TerrainData版本与资源保存
多地块地形最常见的视觉效果问题就是地块间高度接缝。出现接缝的典型原因是:每块Terrain用了不同的噪声种子、不同的生成参数,或者同一块相邻边界由不同的人手动刷过高度。解决方向有两个,一是在生成时让相邻地块共享同一个随机种子和相同比例尺,保证边界处高度连续;二是生成错误后用工具做邻域平均,把相邻边界的n行像素取平均平滑掉台阶。
TerrainData版本兼容也是一个容易被忽略的点。不同Unity主版本之间,TerrainData资源格式大体兼容,但地形Shader、TerrainLayer引用列表偶尔会出问题。表现是打开旧项目后地形材质全部变粉,或者细节层数据丢失。遇到这种情况不要急着重刷,先看Console报的Asset序列化错误,把丢失引用的TerrainLayer重新挂一下往往就能恢复。
资源保存也有讲究。如果工具每跑完一步就AssetDatabase.SaveAssets(),一旦代码里写错了某块数据且用户没检查就关上Unity,坏数据就直接进了磁盘。我更喜欢把“保存全部修改”做成一个显式按钮,让用户在看清楚场景效果之后再手动触发。这样一来,版本管理里你也能清楚看到“哪一次提交把地形资源改了”。
4.4 灰度图颜色空间:Linear和sRGB的隐蔽坑
灰度图导入看起来简单,但颜色空间坑得很。Unity纹理默认处在sRGB空间,采样时Unity会把sRGB值转成线性值再给到计算管线。你从灰度图里读R通道当高度,如果纹理使用sRGB格式,读到的数值其实是被Gamma曲线提亮过的结果。
反映到高度图上,就是明明原图里128这个中灰色应该对应一半高度,导进去之后却比一半明显偏高或者偏低,山脉明暗关系和原图对不上。我之前排查了好久才发现是纹理创建时没有设置线性空间。
解决方式是在创建Texture2D时指定linear=true,也就是我灰度图导入代码里用的那个构造函数重载。如果你用的是项目里已有的纹理资产,建议让美术在导入设置里把Texture Type设为Single Channel,并勾选Bypass sRGB Sampling。只要这两处没有统一,你的高度工具在不同机器上跑出来的结果就可能天差地别。
5. 从能用做到好用:团队协作与进阶细节
5.1 耗时预估与取消逻辑不能少
工具跑起来很稳,但等待过程仍然难受。我后来在长任务里加了一个“数据量预估”,用heightmapResolution的平方算总格数,再乘以循环里要做的采样次数,大致估算出耗时区间。不用特别精确,只需要在按钮下方显示“预计处理4096个高度格点,可能耗时数十秒”这种提示,用户心里有数,就不会一直怀疑工具卡死。
长任务还应该支持取消。最省事的做法是在循环里每处理一定数量就检查一个bool变量,这个变量由窗口的“取消”按钮置位。置位后工具记录当前进度,把已经算好的数据SetHeights写回,然后结束循环,这样用户至少能看到“部分完成”的结果,而不是等一个不可能结束的任务。老实说,很多工具不是功能不行,而是缺少这个“逃生通道”导致体验崩盘。
5.2 用ScriptableObject固化工具预设
地形工具的参数量其实不少:噪声种子、octaves、persistence、lacunarity、坡度阈值、海拔阈值、密度上限。每次打开窗口都重新填一遍,不光烦,还特别容易填错值。我后来把所有参数包到一个ScriptableObject里,工具窗口读取这个资产作为默认值,并提供一个“保存为预设”“加载预设”的按钮。
实现思路很简单,写一个带CreateAssetMenu的ScriptableObject,字段和工具参数一一对应;工具运行时把窗口控件绑定到资产实例上,用户调整完参数后通过AssetDatabase.SaveAssets保存。好处是,同一个项目里不同区域可以有不同的预设:山地区域用大噪声尺度、低树密度;平原区域用小尺度噪声、高草密度。团队可以共享预设文件,避免每个人自己填一套参数,生成结果五花八门。
5.3 版本管理协作:地形二进制文件的保存策略
最后说一个看起来不技术、实际非常影响团队协作的问题:TerrainData是二进制资源,多人同时修改同一块地形数据,冲突几乎是必然的。
我的经验是制定一个简单的约定:地形数据的原始编辑权集中在技术美术手里,其他同事不允许直接拖拽修改地形资源文件;所有地形工具默认不自动保存,修改后由技术美术统一SaveAssets并提交。同时,项目中只要涉及地形资源,建议用支持二进制大文件管理的版本控制方案,或者至少做好定期备份。
如果你不做这个约定,就会遇到一个场景:程序A跑了一次批量工具,程序B跑了一次噪声生成,两人都提交了同一个地形资源,版本库合并时直接冲突,最后只能用其中一版覆盖,另一版的工作量全部白费。这种问题不是代码能解决的,是工作流设计问题。工具集只是加速生产,规则和权限才是团队协作的地基。
我个人实际做下来的体会是:地形编辑器拓展这类工具,真的不在于功能数量,而在于它能不能替团队把重复劳动吃下来。最常用的东西往往很小:一个“统一所有Terrain参数”的按钮,一个灰度图导入工具,一个规则刷草。先把这批最痛的实现掉,再考虑可视化、预设、局部笔刷那些进阶方向。每次大改地形数据前,先把TerrainData备份一份,相信我,这能救你不止一次。