如果理解了HeightMap,对一块地形某一块区域的地势更改将会是一件很容易的事,但由于需要实现跨多块地图,四块地图之间的修改就会比较麻烦。从这一篇开始的几篇文章,会逐步完善一个地形编辑工具类TerrainUtility及其他相关扩展。代码我已经上传到了我的Github上,需要的话可以直接去下载https://github.com/xdedzl/RunTimeTerrainEditor,里面有一个TerrainModilfyDemo的场景,我做了一个简单的UI用来测试,工程版本目前使用的是2019.2,但2018.3之后的版本应该都没问题,但Unity貌似不支持从2019回滚到2018,需要新建工程后将资源复制过去。注意编译环境需要时.net4.x,用3.5会报错。
一、关键步骤
1.获取地图高度数据
public float[,] GetHeights(int xBase, int yBase, int width, int height);
这是TerrainData的获取高度数据的函数,传入起始索引和对应的宽高,返回一个高度数据
2.修改高度数据
根据需要修改返回的二维数组,在这里可以根据自己的算法给地形以不同的形状,后面会讲到通过自定义笔刷绘制任意形状
3.重新设置地形高度
public void SetHeights(int xBase, int yBase, float[,] heights);
这是TerrainData的设置地形高度的函数,传入起始索引和包含高度数据的二维数组设置高度
在这里要注意的一点是设置和获取的高度数据的二维数组第一维对应Terrain的z轴,第二维对应的是x轴,不要想当然弄反了
二、获取相邻的地图块
由于涉及到多地图块之间的编辑,寻找相邻地图块就必不可少了,Terrain本身提供了这种查询,下面是Terrain类里的四个属性
public Terrain leftNeighbor { get; }
public Terrain rightNeighbor { get; }
public Terrain topNeighbor { get; }
public Terrain bottomNeighbor { get; }
注意,这是Unity2018中才有的,在Unity2017中我只看到了SetNieghbors的方法,Unity2018的Terrain系统相比2017强大了很多,可以在编辑器中跨地形编辑,可以直接创建地形的邻居,2018.3为地形系统GPU实例渲染路径,官方宣称能减少50%的CPU消耗。为了方便编辑工具在2017中的使用,我给Terrain类写了个扩展方法,用发射射线的方法获取邻居。
这里我建立一个用于写拓展方法的类ExternFun,后面还会其他的拓展方法会也会写在这个类里,利用预处理机制将Unity2018和其他版本区分,是Unity2018直接返回对应邻居,不是的话则利用射线寻找,并且虽然这里有上下左右四个邻居,但实际上只用到了Top和Right两个。
using System;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
using UnityEngine.UI;
public static class ExtenFun
{
#region Terrain
/// <summary>
/// 右边的地形块
/// </summary>
/// <param name="terrain"></param>
/// <returns></returns>
public static Terrain Right(this Terrain terrain)
{
#if UNITY_2018
return terrain.rightNeighbor;
#else
Vector3 rayStart = terrain.GetPosition() + new Vector3(terrain.terrainData.size.x * 1.5f, 1000, terrain.terrainData.size.z * 0.5f);
RaycastHit hitInfo;
Physics.Raycast(rayStart, Vector3.down, out hitInfo, float.MaxValue, LayerMask.GetMask("Terrain"));
return hitInfo.collider?.GetComponent<Terrain>();
#endif
}
/// <summary>
/// 上边的地形块
/// </summary>
/// <param name="terrain"></param>
/// <returns></returns>
public static Terrain Up(this Terrain terrain)
{
#if UNITY_2018
return terrain.topNeighbor;
#else
Vector3 rayStart = terrain.GetPosition() + new Vector3(terrain.terrainData.size.x * 0.5f, 1000, terrain.terrainData.size.z * 1.5f);
RaycastHit hitInfo;
Physics.Raycast(rayStart, Vector3.down, out hitInfo, float.MaxValue, LayerMask.GetMask("Terrain"));
return hitInfo.collider?.GetComponent<Terrain>();
#endif
}
/// <summary>
/// 左边的地形块
/// </summary>
/// <param name="terrain"></param>
/// <returns></returns>
public static Terrain Left(this Terrain terrain)
{
#if UNITY_2018
return terrain.leftNeighbor;
#else
Vector3 rayStart = terrain.GetPosition() + new Vector3(-terrain.terrainData.size.x * 0.5f, 1000, terrain.terrainData.size.z * 0.5f);
RaycastHit hitInfo;
Physics.Raycast(rayStart, Vector3.down, out hitInfo, float.MaxValue, LayerMask.GetMask("Terrain"));
return hitInfo.collider?.GetComponent<Terrain>();
#endif
}
/// <summary>
/// 下边的地形块
/// </summary>
/// <param name="terrain"></param>
/// <returns></returns>
public static Terrain Down(this Terrain terrain)
{
#if UNITY_2018
return terrain.bottomNeighbor;
#else
Vector3 rayStart = terrain.GetPosition() + new Vector3(terrain.terrainData.size.x * 0.5f, 1000, -terrain.terrainData.size.z * 0.5f);
RaycastHit hitInfo;
Physics.Raycast(rayStart, Vector3.down, out hitInfo, float.MaxValue, LayerMask.GetMask("Terrain"));
return hitInfo.collider?.GetComponent<Terrain>();
#endif
}
#endregion
}
三、跨地图
假设我们有一个由四块Terrain组成的地图,那么跨地图的编辑大致有四种情况
按照上面所说的三个步骤,我们只在第一步和第三步中对跨地图的事情做处理,第二部只负责管理修改区域的形状和高度,任务区分开来就好做了。当然了,除了上面四种情况,还有修改区域在整个地图边缘的情况也需要我们在代码中做处理。这样一来,高度编辑的整个流程就清晰了。由上图可知,在处理跨地形的问题时我们需要将多个地形的HeightMap进行融合,也需要将一个二维数组拆分成多个HeightMap,所以,这里再写几个拆分和融合的拓展函数加入到ExternFun类里面。
#region Collection
public static T[,] Concat0<T>(this T[,] array_0, T[,] array_1)
{
if (array_0.GetLength(0) != array_1.GetLength(0))
{
Debug.LogError("两个数组第一维不一致");
return null;
}
T[,] ret = new T[array_0.GetLength(0), array_0.GetLength(1) + array_1.GetLength(1)];
for (int i = 0; i < array_0.GetLength(0); i++)
{
for (int j = 0; j < array_0.GetLength(1); j++)
{
ret[i, j] = array_0[i, j];
}
}
for (int i = 0; i < array_1.GetLength(0); i++)
{
for (int j = 0; j < array_1.GetLength(1); j++)
{
ret[i, j + array_0.GetLength(1)] = array_1[i, j];
}
}
return ret;
}
public static T[,] Concat1<T>(this T[,] array_0, T[,] array_1)
{
if (array_0.GetLength(1) != array_1.GetLength(1))
{
Debug.LogError("两个数组第二维不一致");
return null;
}
T[,] ret = new T[array_0.GetLength(0) + array_1.GetLength(0), array_0.GetLength(1)];
for (int i = 0; i < array_0.GetLength(0); i++)
{
for (int j = 0; j < array_0.GetLength(1); j++)
{
ret[i, j] = array_0[i, j];
}
}
for (int i = 0; i < array_1.GetLength(0); i++)
{
for (int j = 0; j < array_1.GetLength(1); j++)
{
ret[i + array_0.GetLength(0), j] = array_1[i, j];
}
}
return ret;
}
public static T[,] GetPart<T>(this T[,] array, int base_0, int base_1, int length_0, int length_1)
{
if (base_0 + length_0 > array.GetLength(0) || base_1 + length_1 > array.GetLength(1))
{
Debug.Log(base_0 + length_0 + ":" + array.GetLength(0));
Debug.Log(base_1 + length_1 + ":" + array.GetLength(1));
Debug.LogError("索引超出范围");
return null;
}
T[,] ret = new T[length_0, length_1];
for (int i = 0; i < length_0; i++)
{
for (int j = 0; j < length_1; j++)
{
ret[i, j] = array[i + base_0, j + base_1];
}
}
return ret;
}
#endregion
四、射线及高斯模糊工具
射线是项目开发中比较常用的功能,这里建立一个工具类Utility用来发射射线,然后再加上一个之前写过的高斯模糊方法,高斯模糊用来对地形做平滑处理,相关类容可参考高斯模糊。高斯模糊的方法我在这里用到了异步。
// ==========================================
// 描述:
// 作者: HAK
// 时间: 2018-10-24 16:26:10
// 版本: V 1.0
// ==========================================
using System.Collections.Generic;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
using System.Threading.Tasks;
using UnityEngine;
/// <summary>
/// 使用工具类
/// </summary>
public static class Utility
{
/// <summary>
/// 发射射线并返回RaycastInfo
/// </summary>
public static RaycastHit SendRay(int layer = -1)
{
RaycastHit hitInfo;
if (Physics.Raycast(Camera.main.ScreenPointToRay(Input.mousePosition), out hitInfo, float.MaxValue, layer))
{
return hitInfo;
}
else
{
return default(RaycastHit);
}
}
public static RaycastHit SendRayDown(Vector3 start,int layer = -1)
{
RaycastHit hitInfo;
start.y += 10000;
if (Physics.Raycast(start,Vector3.down, out hitInfo, float.MaxValue, layer))
{
return hitInfo;
}
else
{
return default(RaycastHit);
}
}
/// <summary>
/// 对二维数组做高斯模糊
/// </summary>
/// <param name="array">要处理的数组</param>
/// <param name="dev"></param>
/// <param name="r">高斯核扩展半径</param>
/// <param name="isCircle">改变形状是否是圆</param>
public async static Task GaussianBlur(float[,] array, float dev, int r = 1,bool isCircle = true)
{
// 构造半径为1的高斯核
int length = r * 2 + 1;
float[,] gaussianCore = new float[length, length];
float k = 1 / (2 * Mathf.PI * dev * dev);
for (int i = 0; i < length; i++)
{
for (int j = 0; j < length; j++)
{
float pow = -((j - r) * (j - r) + (i - r) * (i - r)) / (2 * dev * dev);
gaussianCore[i, j] = k * Mathf.Pow(2.71828f, pow);
}
}
// 使权值和为1
float sum = 0;
for (int i = 0; i < length; i++)
{
for (int j = 0; j < length; j++)
{
sum += gaussianCore[i, j];
}
}
for (int i = 0; i < length; i++)
{
for (int j = 0; j < length; j++)
{
gaussianCore[i, j] /= sum;
}
}
// 对二维数组进行高斯模糊处理
int circleR = array.GetLength(0) / 2;
await Task.Run(async() =>
{
for (int i = r, length_0 = array.GetLength(0) - r; i < length_0; i++)
{
await Task.Run(() =>
{
for (int j = r, length_1 = array.GetLength(1) - r; j < length_1; j++)
{
if (isCircle && (i - circleR) * (i - circleR) + (j - circleR) * (j - circleR) > (circleR - r) * (circleR - r))
continue;
// 用高斯核处理一个值
float value = 0;
for (int u = 0; u < length; u++)
{
for (int v = 0; v < length; v++)
{
if ((i + u - r) >= array.GetLength(0) || (i + u - r) < 0 || (j + v - r) >= array.GetLength(1) || (j + v - r) < 0)
Debug.LogError("滴嘟滴嘟的报错");
else
value += gaussianCore[u, v] * array[i + u - r, j + v - r];
}
}
array[i, j] = value;
}
});
}
});
}
}
Utility和ExternFun两个会在后面继续添加一些函数以方便TerrainUtility的调用,现在正式开始TerrainUtility类的开发
五、TerrainUtility之高度编辑
UnityEgine的命名空间下有一个Vector2Int类,可以用来存储高度图索引
直接上代码,主要的两个方法是GetHeightMap和SetHeightMap,在这两步之间更改获取到的HeightMap就可以了,ChangeHeight方法提供的是一个圆形的高度修改,后面会讲到怎么使用自定义笔刷修改地形。
using UnityEngine;
using System.Collections.Generic;
using System;
using System.Reflection;
using System.Linq;
using System.Threading.Tasks;
/**
* Terrain的HeightMap坐标原点在左下角
* z
* ↑
* 0 → x
*/
/// <summary>
/// Terrain工具
/// terrainData.GetHeights和SetHeights的参数都是 值域为[0,1]的比例值
/// </summary>
public static class TerrainUtility
{
/// <summary>
/// 用于修改高度的单位高度
/// </summary>
private static float deltaHeight;
/// <summary>
/// 地形大小
/// </summary>
private static Vector3 terrainSize;
/// <summary>
/// 高度图分辨率
/// </summary>
private static int heightMapRes;
/// <summary>
/// 静态构造函数
/// </summary>
static TerrainUtility()
{
deltaHeight = 1 / Terrain.activeTerrain.terrainData.size.y;
terrainSize = Terrain.activeTerrain.terrainData.size;
heightMapRes = Terrain.activeTerrain.terrainData.heightmapResolution;
}
#region 高度图相关
/// <summary>
/// 返回Terrain上某一点的HeightMap索引。
/// </summary>
/// <param name="terrain">Terrain</param>
/// <param name="point">Terrain上的某点</param>
/// <returns>该点在HeightMap中的位置索引</returns>
private static Vector2Int GetHeightmapIndex(Terrain terrain, Vector3 point)
{
TerrainData tData = terrain.terrainData;
float width = tData.size.x;
float length = tData.size.z;
// 根据相对位置计算索引
int x = (int)((point.x - terrain.GetPosition().x) / width * tData.heightmapWidth);
int z = (int)((point.z - terrain.GetPosition().z) / length * tData.heightmapHeight);
return new Vector2Int(x, z);
}
/// <summary>
/// 返回地图Index对应的世界坐标系位置
/// </summary>
/// <param name="terrain"></param>
/// <param name="x"></param>
/// <param name="z"></param>
/// <returns></returns>
public static Vector3 GetIndexWorldPoint(Terrain terrain, int x, int z)
{
TerrainData data = terrain.terrainData;
float _x = data.size.x / (data.heightmapWidth - 1) * x;
float _z = data.size.z / (data.heightmapHeight - 1) * z;
float _y = GetPointHeight(terrain, new Vector3(_x, 0, _z));
return new Vector3(_x, _y, _z) + terrain.GetPosition();
}
/// <summary>
/// 返回GameObject在Terrain上的相对(于Terrain的)位置。
/// </summary>
/// <param name="terrain">Terrain</param>
/// <param name="go">GameObject</param>
/// <returns>相对位置</returns>
public static Vector3 GetRelativePosition(Terrain terrain, GameObject go)
{
return go.transform.position - terrain.GetPosition();
}
/// <summary>
/// 返回Terrain上指定点在世界坐标系下的高度。
/// </summary>
/// <param name="terrain">Terrain</param>
/// <param name="point">Terrain上的某点</param>
/// <param name="vertex">true: 获取最近顶点高度 false: 获取实际高度</param>
/// <returns>点在世界坐标系下的高度</returns>
public static float GetPointHeight(Terrain terrain, Vector3 point, bool vertex = false)
{
// 对于水平面上的点来说,vertex参数没有影响
if (vertex)
{
// GetHeight得到的是离点最近的顶点的高度
Vector2Int index = GetHeightmapIndex(terrain, point);
return terrain.terrainData.GetHeight(index.x, index.y);
}
else
{
// SampleHeight得到的是点在斜面上的实际高度
return terrain.SampleHeight(point);
}
}
/// <summary>
/// 返回Terrain的HeightMap的一部分
/// 场景中有多块地图时不要直接调用terrainData.getheights
/// 这个方法会解决跨多块地形的问题
/// </summary>
/// <param name="terrain">Terrain</param>
/// <param name="xBase">检索HeightMap时的X索引起点</param>
/// <param name="yBase">检索HeightMap时的Y索引起点</param>
/// <param name="width">在X轴上的检索长度</param>
/// <param name="height">在Y轴上的检索长度</param>
/// <returns></returns>
public static float[,] GetHeightMap(Terrain terrain, int xBase = 0, int yBase = 0, int width = 0, int height = 0)
{
// 如果后四个均为默认参数,则直接返回当前地形的整个高度图
if (xBase + yBase + width + height == 0)
{
width = terrain.terrainData.heightmapWidth;
height = terrain.terrainData.heightmapHeight;
return terrain.terrainData.GetHeights(xBase, yBase, width, height);
}
TerrainData terrainData = terrain.terrainData;
int differX = xBase + width - (terrainData.heightmapResolution - 1); // 右溢出量级
int differY = yBase + height - (terrainData.heightmapResolution - 1); // 上溢出量级
float[,] ret;
if (differX <= 0 && differY <= 0) // 无溢出
{
ret = terrain.terrainData.GetHeights(xBase, yBase, width, height);
}
else if (differX > 0 && differY <= 0) // 右边溢出
{
ret = terrain.terrainData.GetHeights(xBase, yBase, width - differX, height);
float[,] right = terrain.Right()?.terrainData.GetHeights(0, yBase, differX, height);
if (right != null)
ret = ret.Concat0(right);
}
else if (differX <= 0 && differY > 0) // 上边溢出
{
ret = terrain.terrainData.GetHeights(xBase, yBase, width, height - differY);
float[,] up = terrain.Top()?.terrainData.GetHeights(xBase, 0, width, differY);
if (up != null)
ret = ret.Concat1(up);
}
else // 上右均溢出
{
ret = terrain.terrainData.GetHeights(xBase, yBase, width - differX, height - differY);
float[,] right = terrain.Right()?.terrainData.GetHeights(0, yBase, differX, height - differY);
float[,] up = terrain.Top()?.terrainData.GetHeights(xBase, 0, width - differX, differY);
float[,] upRight = terrain.Right()?.Top()?.terrainData.GetHeights(0, 0, differX, differY);
if (right != null)
ret = ret.Concat0(right);
if (upRight != null)
ret = ret.Concat1(up.Concat0(upRight));
}
return ret;
}
/// <summary>
/// 初始化地形高度图编辑所需要的参数
/// 后四个参数需要在调用前定义
/// </summary>
/// <param name="center">目标中心</param>
/// <param name="radius">半径</param>
/// <param name="mapIndex">起始修改点在高度图上的索引</param>
/// <param name="heightMap">要修改的高度二维数组</param>
/// <param name="mapRadius">修改半径对应的索引半径</param>
/// <param name="limit">限制高度</param>
/// <returns></returns>
private static Terrain InitHMArg(Vector3 center, float radius, ref Vector2Int mapIndex, ref float[,] heightMap, ref int mapRadius, ref int mapRadiusZ, ref float limit)
{
Vector3 leftDown = new Vector3(center.x - radius, 0, center.z - radius);
// 左下方Terrain
Terrain terrain = Utility.SendRayDown(leftDown, LayerMask.GetMask("Terrain")).collider?.GetComponent<Terrain>();
// 左下至少有一个方向没有Terrain
if (terrain != null)
{
// 获取相关参数
mapRadius = (int)(terrain.terrainData.heightmapResolution / terrain.terrainData.size.x * radius);
mapRadiusZ = (int)(terrain.terrainData.heightmapResolution / terrain.terrainData.size.z * radius);
mapRadius = mapRadius < 1 ? 1 : mapRadius;
mapRadiusZ = mapRadiusZ < 1 ? 1 : mapRadiusZ;
mapIndex = GetHeightmapIndex(terrain, leftDown);
heightMap = GetHeightMap(terrain, mapIndex.x, mapIndex.y, 2 * mapRadius, 2 * mapRadiusZ);
//limit = heightMap[mapRadius, mapRadius];
}
return terrain;
}
/// <summary>
/// 改变地形高度
/// </summary>
/// <param name="center"></param>
/// <param name="radius"></param>
/// <param name="opacity"></param>
/// <param name="amass"></param>
public static void ChangeHeight(Vector3 center, float radius, float opacity, bool isRise = true, bool amass = true)
{
int mapRadius = 0;
int mapRadiusZ = 0;
Vector2Int mapIndex = default(Vector2Int);
float[,] heightMap = null;
float limit = 0;
Terrain terrain = InitHMArg(center, radius, ref mapIndex, ref heightMap, ref mapRadius, ref mapRadiusZ, ref limit);
if (terrain == null) return;
if (!isRise) opacity = -opacity;
// 修改高度图
for (int i = 0, length_0 = heightMap.GetLength(0); i < length_0; i++)
{
for (int j = 0, length_1 = heightMap.GetLength(1); j < length_1; j++)
{
// 限制范围为一个圆
float rPow = (i - mapRadiusZ) * (i - mapRadiusZ) + (j - mapRadius) * (j - mapRadius);
if (rPow > mapRadius * mapRadiusZ)
continue;
float differ = 1 - rPow / (mapRadius * mapRadiusZ);
if (amass)
{
heightMap[i, j] += differ * deltaHeight * opacity;
}
else if (isRise)
{
heightMap[i, j] = heightMap[i, j] >= limit ? heightMap[i, j] : heightMap[i, j] + differ * deltaHeight * opacity;
}
else
{
heightMap[i, j] = heightMap[i, j] <= limit ? heightMap[i, j] : heightMap[i, j] + differ * deltaHeight * opacity;
}
}
}
// 重新设置高度图
SetHeightMap(terrain, heightMap, mapIndex.x, mapIndex.y);
}
/// <summary>
/// 平滑地形
/// </summary>
/// <param name="center"></param>
/// <param name="radius"></param>
/// <param name="dev"></param>
/// <param name="level"></param>
public async static void Smooth(Vector3 center, float radius, float dev, int level = 1)
{
center.x -= terrainSize.x / (heightMapRes - 1) * level;
center.z -= terrainSize.z / (heightMapRes - 1) * level;
radius += terrainSize.x / (heightMapRes - 1) * level;
int mapRadius = 0;
int mapRadiusZ = 0;
Vector2Int mapIndex = default(Vector2Int);
float[,] heightMap = null;
float limit = 0;
Terrain terrain = InitHMArg(center, radius, ref mapIndex, ref heightMap, ref mapRadius, ref mapRadiusZ, ref limit);
if (terrain == null) return;
await Utility.GaussianBlur(heightMap, dev, level);
SetHeightMap(terrain, heightMap, mapIndex.x, mapIndex.y);
}
/// <summary>
/// 设置Terrain的HeightMap
/// 有不只一块地形的场景不要直接调用terrainData.SetHeights
/// 这个方法会解决跨多块地形的问题
/// </summary>
/// <param name="terrain">Terrain</param>
/// <param name="heights">HeightMap</param>
/// <param name="xBase">X起点</param>
/// <param name="yBase">Y起点</param>
public static void SetHeightMap(Terrain terrain, float[,] heights, int xBase = 0, int yBase = 0)
{
TerrainData terrainData = terrain.terrainData;
int length_1 = heights.GetLength(1);
int length_0 = heights.GetLength(0);
int differX = xBase + length_1 - (terrainData.heightmapResolution - 1);
int differY = yBase + length_0 - (terrainData.heightmapResolution - 1);
if (differX <= 0 && differY <= 0) // 无溢出
{
terrain.terrainData.SetHeights(xBase, yBase, heights);
}
else if (differX > 0 && differY <= 0) // 右溢出
{
terrain.terrainData.SetHeights(xBase, yBase, heights.GetPart(0, 0, length_0, length_1 - differX + 1)); // 最后的 +1是为了和右边的地图拼接
terrain.Right()?.terrainData.SetHeights(0, yBase, heights.GetPart(0, length_1 - differX, length_0, differX));
}
else if (differX <= 0 && differY > 0) // 上溢出
{
terrain.terrainData.SetHeights(xBase, yBase, heights.GetPart(0, 0, length_0 - differY + 1, length_1)); // 最后的 +1是为了和上边的地图拼接
terrain.Top()?.terrainData.SetHeights(xBase, 0, heights.GetPart(length_0 - differY, 0, differY, length_1));
}
else // 右上均溢出
{
terrain.terrainData.SetHeights(xBase, yBase, heights.GetPart(0, 0, length_0 - differY + 1, length_1 - differX + 1)); // 最后的 +1是为了和上边及右边的地图拼接
terrain.Right()?.terrainData.SetHeights(0, yBase, heights.GetPart(0, length_1 - differX, length_0 - differY + 1, differX));
terrain.Top()?.terrainData.SetHeights(xBase, 0, heights.GetPart(length_0 - differY, 0, differY, length_1 - differX + 1));
terrain.Top()?.Right().terrainData.SetHeights(0, 0, heights.GetPart(length_0 - differY, length_1 - differX, differY, differX));
}
}
#endregion
}