三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Unity地形切割:免费脚本实现大型Terrain性能优化

Unity地形切割:免费脚本实现大型Terrain性能优化

1. 项目概述:为什么我们需要切割Unity Terrain?

在Unity中开发大型开放世界或高精度场景时,Terrain(地形)系统是构建世界的基础。然而,随着项目规模的扩大,一个巨大的、高分辨率的Terrain会迅速成为性能的“黑洞”。我见过太多项目卡在这里:场景加载慢、运行时卡顿、内存占用高,甚至导致某些流式加载或LOD(细节层次)系统失效。问题的根源往往在于,一个未经处理的、庞大的Terrain网格和纹理数据,对于GPU和内存来说负担太重了。

这时,很多开发者会去Asset Store寻找地形分割、流式加载的付费插件。这些插件功能强大,但价格不菲,从几十到几百美元不等,而且其内部逻辑对新手来说是个黑盒,一旦遇到定制化需求或兼容性问题,调试起来非常头疼。实际上,Terrain切割的核心逻辑并不复杂,其本质是对高度图、细节纹理、树木和草等数据进行一次“外科手术式”的网格重划分。

我们需要的,是一个轻量、透明、可完全掌控的解决方案。这就是今天要分享的免费脚本的由来。它的核心目标非常明确:将一个任意尺寸的Unity Terrain,自动切割成多个尺寸为2的N次幂(如32x32, 64x64, 128x128, 256x256等)的小块Terrain。选择2的N次幂并非随意,这是图形学和许多底层引擎(包括Unity的某些渲染路径)在处理纹理和网格数据时最友好的尺寸,能确保最佳的内存对齐、Mipmap生成效率以及一些后期处理效果的兼容性。

这个脚本不仅能帮你省下一笔插件费用,更重要的是,通过亲手实现或理解这个过程,你能彻底掌握Unity Terrain的数据结构,为后续的流式加载、动态合批、自定义LOD等高级优化打下坚实基础。接下来,我将从设计思路开始,带你一步步拆解这个脚本的每一个环节。

2. 核心设计思路与方案选型

在动手写代码之前,我们必须想清楚整个切割流程的骨架。你不能直接对场景中的Terrain GameObject进行“切割”,因为Unity的Terrain组件是一个封装好的整体。我们的策略是:“读取 -> 计算 -> 创建”

2.1 数据层:提取与映射首先,需要从源Terrain中完整提取出所有必要数据。这包括:

  • 高度图(Heightmap):一个二维浮点数组,定义了地形的起伏。这是地形的骨架。
  • 细节图层(Detail Layers):定义了草、灌木等细节物体的分布密度图。
  • 纹理图层(Splatmap/Alphamap):定义了不同地形纹理(如泥土、草地、岩石)的混合权重图。
  • 树木(Tree Instances)和细节物体(Detail Prototypes):它们的原型信息和在场景中的位置、缩放、旋转数据。
  • TerrainData的基本属性:如高度图分辨率、细节分辨率、大小(Terrain Size)等。

提取后,这些数据将作为我们“手术”的原材料。关键在于,切割后的每个小Terrain,其数据都必须正确地映射自源Terrain的对应区域。例如,位于源Terrain左上角的一个128x128的小块,它的高度图数据,就是源高度图左上角128x128像素区域的数据。

2.2 算法层:网格划分与索引计算这是脚本的核心算法部分。给定一个源Terrain的尺寸(假设是(width, length))和目标的小块尺寸(如chunkSize = 128),我们需要计算出在宽度和长度方向上,分别能划分出多少个小块。

这里有一个关键点:源Terrain的尺寸很可能不是chunkSize的整数倍。如何处理边缘多出来的、尺寸不足chunkSize的“边角料”?通常有两种策略:

  1. 舍弃边缘:只生成完整尺寸的小块,边缘部分直接丢弃。这会导致地形范围缩小,简单但可能不满足设计需求。
  2. 保留并补齐:为边缘的小块也创建Terrain,但其实际尺寸小于chunkSize。在创建TerrainData时,我们仍然按chunkSize来设置其高度图分辨率等属性,但只填充有效区域的数据,边缘无效区域保持平坦或特定值。这种方式能保留完整地形,是更通用的做法。我们的脚本将采用这种策略。

因此,计算小块数量的公式是:chunksX = Mathf.CeilToInt(terrainWidth / chunkSize)chunksZ = Mathf.CeilToInt(terrainLength / chunkSize)Mathf.CeilToInt是向上取整,确保覆盖整个地形。

接下来,对于每一个小块索引(i, j),我们需要计算出它在源Terrain数据数组中的起始坐标和尺寸范围,用于数据拷贝。

2.3 实现层:批量创建与场景组织算法计算好之后,就是体力活了:在Unity场景中循环创建GameObject,挂载Terrain和Terrain Collider组件,并为每个新Terrain创建对应的TerrainData资产文件(.asset)。我们必须处理好资产的生命周期,防止内存泄漏和资产残留。

同时,为了场景整洁和后续管理方便,最好将生成的所有小块Terrain放在一个共同的父节点下,并按照它们的网格位置(如Terrain_Chunk_X_Z)进行命名。生成完毕后,原始的、庞大的Terrain就可以选择性地禁用或删除了。

这个方案的优势在于完全自主可控,不依赖任何第三方插件,所有代码开源可见。缺点是它是一次性的预处理操作,不适合需要动态加载卸载的极端情况(但生成的标准化小块正是实现动态加载的完美基础)。接下来,我们进入最关键的实操部分。

3. 脚本核心代码解析与实操要点

我将把完整的脚本拆解成几个关键函数,并逐一解释其实现细节和注意事项。你可以将这些代码片段整合到一个C#脚本中,例如TerrainSplitter.cs,并挂载到一个空的GameObject上运行。

3.1 数据提取:获取源Terrain的所有家当首先,我们需要一个方法来收集源Terrain的所有数据。这里要注意,某些数据(如TreeInstances)是引用类型,直接赋值可能会带来问题,我们需要进行深拷贝或重新创建。

using UnityEngine; using UnityEditor; // 因为涉及创建Asset文件,需要在Editor环境下运行 using System.IO; using System.Collections.Generic; public class TerrainSplitter : MonoBehaviour { public Terrain sourceTerrain; // 拖拽赋值:需要被切割的原始地形 public int chunkSize = 128; // 目标小块的尺寸(2的N次幂,如128) public string outputPath = "Assets/SplitTerrains"; // 生成资产的保存路径 // 存储提取的源数据 private TerrainData sourceData; private float[,] sourceHeightmap; private float[,,] sourceAlphamaps; private int[,] detailLayerTemplate; // 以第一个细节层为例 private List<TreeInstance> sourceTrees; private Vector3 terrainSize; void ExtractSourceData() { if (sourceTerrain == null) { Debug.LogError("请先指定源Terrain!"); return; } sourceData = sourceTerrain.terrainData; terrainSize = sourceData.size; // 1. 提取高度图 int heightmapResolution = sourceData.heightmapResolution; sourceHeightmap = sourceData.GetHeights(0, 0, heightmapResolution, heightmapResolution); // 2. 提取Alpha贴图(纹理混合图) int alphamapResolution = sourceData.alphamapResolution; int alphamapLayers = sourceData.alphamapLayers; sourceAlphamaps = sourceData.GetAlphamaps(0, 0, alphamapResolution, alphamapResolution); // 3. 提取细节层(以第一层为例) if (sourceData.detailPrototypes.Length > 0) { detailLayerTemplate = sourceData.GetDetailLayer(0, 0, sourceData.detailWidth, sourceData.detailHeight, 0); } // 4. 提取树木实例(注意:需要深拷贝或重新创建) sourceTrees = new List<TreeInstance>(sourceData.treeInstances); } }

注意GetHeightsGetAlphamaps获取的是数据的拷贝,对它们的修改不会直接影响源Terrain,这正合我们意。但treeInstances是数组,直接赋值给List是浅拷贝,如果后续要修改,需要注意引用问题。在我们的切割场景中,主要是读取位置信息,问题不大。

3.2 计算切割网格与数据映射这是算法的核心。我们需要根据chunkSize和源地形实际尺寸(以世界单位计,terrainSize.xterrainSize.z),计算出每个小块对应的数据索引范围。

void CalculateAndSplit() { ExtractSourceData(); // 先提取数据 // 计算在宽度和长度方向上需要多少个小块 int chunksX = Mathf.CeilToInt(terrainSize.x / chunkSize); int chunksZ = Mathf.CeilToInt(terrainSize.z / chunkSize); // 获取源数据的分辨率 int heightmapRes = sourceData.heightmapResolution; int alphamapRes = sourceData.alphamapResolution; int detailRes = sourceData.detailResolution; // 计算每个“单位世界坐标”对应多少数据像素/点 float heightmapPixelPerUnit = (heightmapRes - 1) / terrainSize.x; // -1是因为高度图顶点数比分辨率少1 float alphamapPixelPerUnit = alphamapRes / terrainSize.x; float detailPixelPerUnit = detailRes / terrainSize.x; // 创建父物体用于组织所有小块 GameObject parentGO = new GameObject($"SplitTerrainParent_{chunkSize}"); parentGO.transform.position = sourceTerrain.transform.position; // 双层循环,遍历每一个小块 for (int x = 0; x < chunksX; x++) { for (int z = 0; z < chunksZ; z++) { // 计算当前小块在世界空间中的起始坐标和实际尺寸 float startXWorld = x * chunkSize; float startZWorld = z * chunkSize; float actualChunkSizeX = Mathf.Min(chunkSize, terrainSize.x - startXWorld); float actualChunkSizeZ = Mathf.Min(chunkSize, terrainSize.z - startZWorld); // **关键步骤:将世界坐标转换为源数据中的像素索引** // 计算在源高度图中的起始像素和采样宽度 int heightmapStartX = Mathf.RoundToInt(startXWorld * heightmapPixelPerUnit); int heightmapStartZ = Mathf.RoundToInt(startZWorld * heightmapPixelPerUnit); int heightmapWidth = Mathf.RoundToInt(actualChunkSizeX * heightmapPixelPerUnit) + 1; // +1保证覆盖边缘 int heightmapLength = Mathf.RoundToInt(actualChunkSizeZ * heightmapPixelPerUnit) + 1; // 对AlphaMap和DetailMap进行类似计算... int alphamapStartX = Mathf.RoundToInt(startXWorld * alphamapPixelPerUnit); int alphamapStartZ = Mathf.RoundToInt(startZWorld * alphamapPixelPerUnit); int alphamapWidth = Mathf.RoundToInt(actualChunkSizeX * alphamapPixelPerUnit); int alphamapLength = Mathf.RoundToInt(actualChunkSizeZ * alphamapPixelPerUnit); int detailStartX = Mathf.RoundToInt(startXWorld * detailPixelPerUnit); int detailStartZ = Mathf.RoundToInt(startZWorld * detailPixelPerUnit); int detailWidth = Mathf.RoundToInt(actualChunkSizeX * detailPixelPerUnit); int detailLength = Mathf.RoundToInt(actualChunkSizeZ * detailPixelPerUnit); // 确保索引不越界 heightmapWidth = Mathf.Min(heightmapWidth, heightmapRes - heightmapStartX); heightmapLength = Mathf.Min(heightmapLength, heightmapRes - heightmapStartZ); alphamapWidth = Mathf.Min(alphamapWidth, alphamapRes - alphamapStartX); alphamapLength = Mathf.Min(alphamapLength, alphamapRes - alphamapStartZ); detailWidth = Mathf.Min(detailWidth, detailRes - detailStartX); detailLength = Mathf.Min(detailLength, detailRes - detailStartZ); // 调用创建单个小块的函数 CreateTerrainChunk(x, z, heightmapStartX, heightmapStartZ, heightmapWidth, heightmapLength, alphamapStartX, alphamapStartZ, alphamapWidth, alphamapLength, detailStartX, detailStartZ, detailWidth, detailLength, startXWorld, startZWorld, actualChunkSizeX, actualChunkSizeZ, parentGO.transform); } } Debug.Log($"地形切割完成,共生成 {chunksX * chunksZ} 个小块。"); }

实操心得:坐标转换是这里最容易出错的地方。务必记住,高度图、AlphaMap、DetailMap各有自己的分辨率,它们与世界单位(米)的换算比例不同。heightmapResolution指的是网格顶点的数量,而alphamapResolution是纹理像素的数量。计算时,heightmap的采样范围需要+1来确保获取到边缘的顶点数据,否则切割出来的地形块在边界处会出现“悬崖”而非平滑过渡。

4. 创建单个地形小块的全流程实现

有了数据索引,我们就可以为每一个小块创建独立的Terrain GameObject和TerrainData资产了。这是最繁琐但也是最体现细节的一步。

4.1 创建TerrainData资产并填充数据我们需要为每个小块创建一个新的TerrainData实例,并为其设置正确的尺寸、分辨率,然后填充从源数据中截取出来的那部分数据。

void CreateTerrainChunk(int indexX, int indexZ, int hStartX, int hStartZ, int hWidth, int hLength, int aStartX, int aStartZ, int aWidth, int aLength, int dStartX, int dStartZ, int dWidth, int dLength, float worldStartX, float worldStartZ, float chunkSizeX, float chunkSizeZ, Transform parent) { // 1. 创建新的TerrainData TerrainData newData = new TerrainData(); // 2. 设置基本尺寸。注意:这里设置的是TerrainData的“大小”,即它在世界中的缩放。 // 我们让每个小块Data的size.x和size.z都等于chunkSize(标准尺寸),但实际地形只填充其中一部分。 // 这样能保证所有小块在Inspector中看起来尺寸一致,便于管理。 newData.size = new Vector3(chunkSize, terrainSize.y, chunkSize); // 高度保持不变 // 3. 设置分辨率。这里统一使用“标准”分辨率,即基于chunkSize计算。 // 高度图分辨率:通常希望顶点密度与源地形保持一致。 int targetHeightmapRes = Mathf.RoundToInt((chunkSize / terrainSize.x) * sourceData.heightmapResolution); // 确保是2的N次幂+1,这是Unity Terrain的要求 targetHeightmapRes = Mathf.ClosestPowerOfTwo(targetHeightmapRes) + 1; targetHeightmapRes = Mathf.Clamp(targetHeightmapRes, 33, 4097); // Unity的限制范围 newData.heightmapResolution = targetHeightmapRes; // AlphaMap分辨率 int targetAlphamapRes = Mathf.RoundToInt((chunkSize / terrainSize.x) * sourceData.alphamapResolution); targetAlphamapRes = Mathf.ClosestPowerOfTwo(targetAlphamapRes); targetAlphamapRes = Mathf.Clamp(targetAlphamapRes, 16, 2048); newData.alphamapResolution = targetAlphamapRes; // 细节分辨率 newData.SetDetailResolution(Mathf.RoundToInt((chunkSize / terrainSize.x) * sourceData.detailResolution), 8); // 4. 复制地形纹理原型(SplatPrototypes) newData.splatPrototypes = sourceData.splatPrototypes.Clone() as SplatPrototype[]; // 5. 复制细节和树木原型 newData.detailPrototypes = sourceData.detailPrototypes.Clone() as DetailPrototype[]; newData.treePrototypes = sourceData.treePrototypes.Clone() as TreePrototype[]; // 6. 填充高度图数据 // 从源数据中截取对应区域 float[,] sourceHeightBlock = sourceData.GetHeights(hStartX, hStartZ, hWidth, hLength); // 创建一个新的、全尺寸的高度图数组(初始为0,即最低点) float[,] newHeightmap = new float[targetHeightmapRes, targetHeightmapRes]; // 计算缩放比例,将截取的数据“贴”到新高度图的正确位置 float scaleX = (float)(targetHeightmapRes - 1) / (hWidth - 1); float scaleZ = (float)(targetHeightmapRes - 1) / (hLength - 1); // 使用双线性插值填充,保证地形平滑过渡(这里简化,使用最近邻插值示例) for (int i = 0; i < targetHeightmapRes; i++) { for (int j = 0; j < targetHeightmapRes; j++) { // 将新高度图像素坐标映射回源数据块坐标 float srcX = i / scaleX; float srcZ = j / scaleZ; int srcXi = Mathf.FloorToInt(srcX); int srcZi = Mathf.FloorToInt(srcZ); srcXi = Mathf.Clamp(srcXi, 0, hWidth - 2); // 防止越界 srcZi = Mathf.Clamp(srcZi, 0, hLength - 2); // 双线性插值(此处为简化版,实际应处理四个顶点) // 更健壮的实现应使用完整的插值算法或Unity的Texture缩放功能 newHeightmap[i, j] = sourceHeightBlock[srcXi, srcZi]; } } newData.SetHeights(0, 0, newHeightmap); // 7. 填充AlphaMap数据(纹理混合权重) // 原理同上,从sourceAlphamaps中截取aStartX, aStartZ开始,尺寸为aWidth, aLength的区域 // 然后缩放并填充到新的尺寸为[targetAlphamapRes, targetAlphamapRes, layerCount]的数组中 // 代码较长,逻辑与高度图类似,需注意三层数组的索引顺序是[y, x, layer]。 // 8. 填充细节层数据 // 从sourceData.GetDetailLayer(dStartX, dStartZ, dWidth, dLength, layerIndex)获取 // 然后缩放并填充到新的细节层。 // 9. 处理树木:遍历所有sourceTrees,判断其位置(TreeInstance.position是归一化的0-1坐标) // 是否落在当前小块的世界坐标范围内。如果是,则为其创建一个新的TreeInstance, // 并调整其position为相对于当前小块的归一化坐标,然后添加到newData。 List<TreeInstance> newTrees = new List<TreeInstance>(); foreach (TreeInstance tree in sourceTrees) { Vector3 treeWorldPos = Vector3.Scale(tree.position, terrainSize) + sourceTerrain.transform.position; if (treeWorldPos.x >= worldStartX && treeWorldPos.x < worldStartX + chunkSizeX && treeWorldPos.z >= worldStartZ && treeWorldPos.z < worldStartZ + chunkSizeZ) { TreeInstance newTree = tree; // 重新计算相对于当前小块的归一化位置 newTree.position = new Vector3( (treeWorldPos.x - worldStartX) / chunkSizeX, tree.position.y, // 高度方向归一化坐标不变 (treeWorldPos.z - worldStartZ) / chunkSizeZ ); newTrees.Add(newTree); } } newData.treeInstances = newTrees.ToArray(); // 10. 将TerrainData保存为Asset文件 string dataPath = Path.Combine(outputPath, $"TerrainData_{indexX}_{indexZ}.asset"); Directory.CreateDirectory(outputPath); // 确保文件夹存在 AssetDatabase.CreateAsset(newData, dataPath); AssetDatabase.SaveAssets(); // 11. 在场景中创建Terrain GameObject GameObject terrainGO = Terrain.CreateTerrainGameObject(newData); terrainGO.name = $"Terrain_Chunk_{indexX}_{indexZ}"; terrainGO.transform.parent = parent; // 设置其世界位置:源地形位置 + 当前小块的起始偏移 terrainGO.transform.position = new Vector3( sourceTerrain.transform.position.x + worldStartX, sourceTerrain.transform.position.y, sourceTerrain.transform.position.z + worldStartZ ); // 可选:添加Terrain Collider TerrainCollider tc = terrainGO.GetComponent<TerrainCollider>(); if (tc == null) tc = terrainGO.AddComponent<TerrainCollider>(); tc.terrainData = newData; }

关键技巧与避坑指南

  1. 分辨率计算:新TerrainData的分辨率设置至关重要。我选择基于chunkSize与源地形尺寸的比例来缩放原始分辨率,并利用Mathf.ClosestPowerOfTwo对齐到2的N次幂附近,再根据Unity的规则调整(高度图需+1)。这能在保持视觉精度的同时满足引擎规范。
  2. 数据填充与插值:直接从源数据截取一块,然后简单拉伸填充到新分辨率,会导致地形细节模糊或出现锯齿。对于高度图和AlphaMap这类连续数据,必须使用插值算法(如双线性或双三次插值)。上面示例中的循环是简化版,在实际项目中,建议将截取的数据块先转换成Texture2D,然后利用Graphics.ScaleCopyTexture2D.Get/SetPixels配合缩放进行高质量重采样,这是保证切割后地形接缝处平滑的关键。
  3. 资产管理:一定要使用AssetDatabase.CreateAsset将每个TerrainData保存为独立的.asset文件。如果不保存,这些数据只会存在于内存中,场景关闭后就会丢失。同时,创建完毕后调用AssetDatabase.SaveAssets()AssetDatabase.Refresh()来确保资产被写入磁盘并被Unity项目识别。
  4. 树木与细节物体:处理TreeInstance时,其position是相对于TerrainData大小(terrainData.size)的归一化坐标(0到1)。在判断和转换坐标时,务必先将其转换到世界坐标进行计算,再转换回新小块的归一化坐标。细节层(草)的处理类似,但它是密度图,直接拷贝和缩放密度数据即可。

5. 完整脚本集成与编辑器扩展

将上述所有函数整合,并添加一个编辑器按钮,可以让操作更加方便。我们需要使用[ExecuteInEditMode][CustomEditor]特性。

// TerrainSplitter.cs (完整脚本框架) using UnityEngine; #if UNITY_EDITOR using UnityEditor; #endif using System.IO; using System.Collections.Generic; [ExecuteInEditMode] // 允许在编辑器模式下运行 public class TerrainSplitter : MonoBehaviour { public Terrain sourceTerrain; public int chunkSize = 128; public string outputPath = "Assets/SplitTerrains"; // 将所有步骤整合到一个公开方法中 public void ExecuteSplit() { // 参数校验 if (sourceTerrain == null) { Debug.LogError("Source Terrain is not assigned."); return; } if (chunkSize <= 0) { Debug.LogError("Chunk size must be positive."); return; } if (!IsPowerOfTwo(chunkSize)) { Debug.LogWarning($"Chunk size ({chunkSize}) is not power of two. This may cause performance or compatibility issues."); } // 1. 提取数据 // 2. 计算网格 // 3. 循环创建小块 // (具体实现代码就是前面章节的整合,此处略去以节省篇幅) CalculateAndSplit(); Debug.Log("Terrain splitting process completed."); } private bool IsPowerOfTwo(int x) { return (x & (x - 1)) == 0; } } #if UNITY_EDITOR [CustomEditor(typeof(TerrainSplitter))] public class TerrainSplitterEditor : Editor { public override void OnInspectorGUI() { DrawDefaultInspector(); // 绘制默认的public字段 TerrainSplitter splitter = (TerrainSplitter)target; GUILayout.Space(10); if (GUILayout.Button("Split Terrain", GUILayout.Height(30))) { if (EditorUtility.DisplayDialog("Split Terrain", $"This will split the terrain '{splitter.sourceTerrain.name}' into chunks of size {splitter.chunkSize}. This operation cannot be easily undone. Make sure you have backed up your project. Continue?", "Yes, Split", "Cancel")) { // 在编辑器运行时执行切割 splitter.ExecuteSplit(); } } GUILayout.Label("注意:此操作会生成大量新的Terrain Data资产文件,并修改当前场景。", EditorStyles.helpBox); } } #endif

将这段代码保存后,在Unity编辑器中创建一个空物体,挂载TerrainSplitter脚本,将你的大型Terrain拖拽赋值,设置好chunkSize(建议128或256)和输出路径,点击“Split Terrain”按钮,等待控制台输出完成信息即可。

6. 实战后的常见问题、排查技巧与优化建议

即使代码逻辑正确,在实际操作中你仍可能会遇到一些棘手的问题。以下是我在多次使用和迭代这个脚本过程中积累的经验。

6.1 接缝问题(Seams)这是最常见的问题。切割后,相邻的小块Terrain在边界处出现高度或纹理不连续,形成明显的“缝”。

  • 原因1:数据采样边界错误。在计算源数据索引hStartX, hWidth等时,没有处理好边界像素的包含关系。确保采样范围有轻微重叠(例如高度图多采1个像素),或者在填充新数据时,边界采用相邻两个小块的加权平均值。
  • 原因2:缺乏插值或插值质量差。如第4节所述,简单的最近邻采样会导致锯齿。务必使用双线性插值来处理高度图和AlphaMap的缩放。一个实用的技巧是:将截取的数据块float[,]通过Texture2D.SetPixels转换成一张临时纹理,然后使用Texture2D.GetPixelsBilinear来读取缩放后的颜色(即高度或权重),再转换回数组。
  • 解决方案:在CreateTerrainChunk函数中,实现一个高质量的插值函数。或者,更省事的办法是,在切割完成后,选中所有相邻的Terrain小块,在Inspector中微调它们的位置(Position),确保它们在世界坐标系中严格对齐,没有微小的浮点误差间隙。

6.2 性能与内存问题切割一个4097x4097分辨率的大型地形可能会让编辑器暂时卡住甚至无响应。

  • 预处理:在点击按钮前,务必保存场景和项目。可以考虑先禁用场景中不必要的物体。
  • 分帧处理:对于超大型地形,可以将CreateTerrainChunk的循环改造为协程(Coroutine),每帧只处理1-2个小块,并在编辑器界面显示进度条。这能防止编辑器卡死。
  • 资产清理:脚本运行后,会在指定路径生成大量.asset文件。如果切割结果不满意,需要手动删除这些文件以及场景中生成的GameObject。可以在脚本中增加一个“清理”功能,但操作资产删除务必谨慎。

6.3 树木、细节草丢失或错位

  • 原因:判断树木是否属于当前小块的坐标范围时,浮点数精度问题可能导致边缘的树木被遗漏或重复分配到两个小块。对于细节草,如果缩放算法太粗暴,会导致密度图在边界突变。
  • 解决方案:在判断树木归属时,引入一个很小的容差(epsilon),例如0.001f。对于边缘的树木,可以强制指定其归属规则(如偏左上的归左边块)。对于细节草,在插值缩放密度图后,可以再对边界区域进行一次平滑滤波(Blur),使过渡更自然。

6.4 后期优化与扩展方向生成标准化的小块只是第一步,真正的价值在于后续的优化:

  • 流式加载(Streaming):现在每个小块都是独立的Terrain,你可以轻松编写脚本,根据摄像机距离动态加载和卸载它们。Unity自带的Terrain组件也支持有限的drawDistancebasemapDistance,可以配合使用。
  • LOD Group:虽然Terrain有自己的LOD系统,但你也可以将多个相邻的小块Terrain组合成一个GameObject,并为这个父对象添加Unity的LODGroup组件,在更远的距离用更低模的代理网格(Proxy Mesh)代替,实现更大跨度的性能优化。
  • 烘焙导航网格(NavMesh):切割后,你需要为每个小块单独烘焙导航网格,或者使用Unity的NavMesh分割烘焙功能。注意处理好小块边界的导航网格连接。
  • 自定义Shader与全局纹理:如果你使用了自定义的地形Shader,并且依赖一张全局的大纹理,切割后需要将这张大纹理也分割成图集(Texture Atlas),并修改Shader以支持从图集中采样。这是一个高级话题,但却是实现无缝超大地形的终极方案之一。

这个免费脚本的价值,远不止于省下几十美元。它强迫你去理解Terrain数据的本质,去处理坐标转换、数据采样、资产序列化这些底层问题。当你亲手解决了接缝、搞定了流式加载,你会对Unity引擎的地形系统有一个脱胎换骨的认识。这比任何付费插件带来的黑盒魔法,都要踏实和强大得多。

← 返回列表