自定义地形数据
噪声优化地形理论
1 地形背后的算法-CLOD简介
CLOD是continuous level of detail的缩写-连续细节级别构造算法构建地形网格。给terrain中需要更多细节的地方更多的三角形,而平坦的地方就减少三角形的数量。同时这个过程是动态的、连续的,所以称为CLOD。因此可以优化地形网格存储。
地形高低由被称为高度图或灰度图:heightMap,来决定。颜色范围[0,1] = [黑,白]。最好用双精度浮点数。
举例[0,1]映射为[0,1000],如果一点高度值为0.5,那么该点地形高度就是500,该点的顶点坐标Y就是250,
注意分辨率:mesh网格size最好为2的幂次,高度图size为2的幂次+1。
分层图:splatMap、splatAlpha、AlphaMap,格式RGBA32。Unity提供了TerrainData接口供我们读写,它类似一个Tuti矩阵float[,,],
2 地形设计原则
Contrast:鲜明对比,吸引观众注意的特殊元素。
Repetition:重复。降低加工需求;玩家潜意识的预测。
Alignment:对齐。通常与重复并行。元素位置可能影响其他元素展示,冲击、震撼、吸引。
Proximity:临近。相互关联的元素最好在一起。
Conherence:连贯。尽量使用风格相近的元素。树林包、建筑包、贴花包。
3 自定义TerrainData
地形数据需要设置许多参数:高度height、分层splat、贴图texture、细节表现等等
3.1 设置高度数据
高度数据来源是.r16或.r8或raw格式的数据,是二进制格式它有大小端顺序。//注意Windows字节序,//注意右手系转换到左手系
比如从WM中导出一张分辨率为1024*1024的heightmap,格式存储为.r16,这样每个高度2byte,共2048kb,查看一下.r16文件大小果然是这样。,那么使用.NET平台的库函数可以很方便地将这些字节读取到内存中,存储为一个字节数组。
3.1.1 手动计算无缝高度图
无缝纹理创建两种方法,1、图像上下边缘没有明显区别,则可以将其镜像翻转并拼接,缺点是中心点看起来很怪.2、图像水平或垂直一切为二,然后从相反方向拼接(上拼下),缺点太费手工。
在unity用噪声算法生成splat:相邻图块边缘颜色相同,但是也会出现问题:边缘像素的连续性会被中断。解决办法就是在图块中将边缘像素与整个纹理混合,这就要考虑该像素在纹理上的位置距离。计算该像素与相邻像素的噪声值再相加,然后在纹理中的位置缩放。如果该像素更靠近边缘,则会混合偏向相邻图块中的噪声值;如果更靠近中心,变化则很小。
float u = (float)x / (float)w; float v = (float)y / (float)h; float noise00 = Utils.fBM((x + perlinOffsetX) * perlinXScale, (y + perlinOffsetY) * perlinYScale, perlinOctaves, perlinPersistance) * perlinHeightScale; float noise01 = Utils.fBM((x + perlinOffsetX) * perlinXScale, (y + perlinOffsetY + h) * perlinYScale, perlinOctaves, perlinPersistance) * perlinHeightScale; float noise10 = Utils.fBM((x + perlinOffsetX + w) * perlinXScale, (y + perlinOffsetY) * perlinYScale, perlinOctaves, perlinPersistance) * perlinHeightScale; float noise11 = Utils.fBM((x + perlinOffsetX + w) * perlinXScale, (y + perlinOffsetY + h) * perlinYScale, perlinOctaves, perlinPersistance) * perlinHeightScale; float noiseTotal = u * v * noise00 + u * (1 - v) * noise01 + (1 - u) * v * noise10 + (1 - u) * (1 - v) * noise11; float value = (int)(256 * noiseTotal) + 50; float r = Mathf.Clamp((int) noise00,0,255); float g = Mathf.Clamp(value, 0, 255); float b = Mathf.Clamp(value + 50, 0, 255); float a = Mathf.Clamp(value + 100, 0, 255); pValue = (r + g + b) / (3 * 255.0f);
。
3.1.2 噪声理论与高度数据
有一篇文章第8节介绍了振幅函数,与噪声类似。
使用噪声算法创建起伏的地形最大好处是它具有平滑度和可预测性,是创建山峦的最佳方法。
假如地形是2x2的,那么就有4个高度值存在二维数组;假如是1000x1000,就有100,0000个高度值。这里用一个简单公式可以表示上述的二维数组:
Height(y) = Cos(x) + Sin(Z) * 16
如果增量越小曲线就平滑,频率较低。增量越大曲线越陡峭,频率越高。也可以组合多个噪声。
//简单版本
Mathf.PerlinNoise(x * frequency, y * frequency)
//布朗运动算法 //xy坐标,octave阶梯,连续性距离 public static float fBM(float x, float y, int oct, float persistance) { float total = 0; float frequency = 1;//频率 float amplitude = 1;//振幅 float maxValue = 0; for (int i = 0; i < oct; i++) { total += Mathf.PerlinNoise(x * frequency, y * frequency) * amplitude; maxValue += amplitude; amplitude *= persistance; frequency *= 2; } return total / maxValue; }
3.1.3 沃罗诺伊分布(Voronoi Tessellation)
每个区域都有一个种子,按照种子所在的位置,将空间划分成不同的区域。如果该区域被限制在一个有限的域中(如一个正方形),那么这些划分的区域全部都是封闭区域。
public void Voronoi() { //获取高度图 float[,] heightMap = GetHeightMap(); //要生成的个数 for (int p = 0; p < voronoiPeaks; p++) { //在地形size内随机一个点 Vector3 peak = new Vector3(UnityEngine.Random.Range(0, terrainData.heightmapWidth), UnityEngine.Random.Range(voronoiMinHeight, voronoiMaxHeight), UnityEngine.Random.Range(0, terrainData.heightmapHeight) ); //测试已存在的与随机的高度,避免突然凹陷 if (heightMap[(int)peak.x, (int)peak.z] < peak.y) heightMap[(int)peak.x, (int)peak.z] = peak.y; else continue; //得到山顶坐标 Vector2 peakLocation = new Vector2(peak.x, peak.z); float maxDistance = Vector2.Distance(new Vector2(0, 0), new Vector2(terrainData.heightmapWidth, terrainData.heightmapHeight)); for (int y = 0; y < terrainData.heightmapHeight; y++) { for (int x = 0; x < terrainData.heightmapWidth; x++) { if (!(x == peak.x && y == peak.z)) { //与山顶的距离 float distanceToPeak = Vector2.Distance(peakLocation, new Vector2(x, y)) / maxDistance; float h; if (voronoiType == VoronoiType.Combined) { //直线下降 + 曲线下降 h = peak.y - distanceToPeak * voronoiFallOff - Mathf.Pow(distanceToPeak, voronoiDropOff); //combined } else if (voronoiType == VoronoiType.Power) { //曲线下降:高-距离的幂次 * 斜率,其中幂数>1为凸曲线,<1为凹曲线,=1为直线 h = peak.y - Mathf.Pow(distanceToPeak, voronoiDropOff) * voronoiFallOff; //power } else if (voronoiType == VoronoiType.SinPow) { //波形下降,3和2也参与控制陡峭程度 h = peak.y - Mathf.Pow(distanceToPeak*3, voronoiFallOff) - Mathf.Sin(distanceToPeak*2*Mathf.PI)/voronoiDropOff; //sin pow } else { //直线下降:高-距离*斜率 h = peak.y - distanceToPeak * voronoiFallOff; //linear } //测试已存在高度必须小于随机的高度,才能正确下降 if (heightMap[x,y] < h) heightMap[x, y] = h; } } } } terrainData.SetHeights(0, 0, heightMap); }
liner、power、combined、sin pow
3.1.4 中点位移法
基本思想就是
先矩形区域中心点高度:四个角高度之和再取平均。
然后计算四条边中心点的高度:
public void MidPointDisplacement() { float[,] heightMap = GetHeightMap(); int width = terrainData.heightmapWidth - 1; int squareSize = width; float heightMin = MPDheightMin; float heightMax = MPDheightMax; //阻尼器 float heightDampener = (float)Mathf.Pow(MPDheightDampenerPower, -1 * MPDroughness); int cornerX, cornerY; int midX, midY; int pmidXL, pmidXR, pmidYU, pmidYD; //这段代码,是随机在地形选取一矩形区域 /* heightMap[0, 0] = UnityEngine.Random.Range(0f, 0.2f); heightMap[0, terrainData.heightmapHeight - 2] = UnityEngine.Random.Range(0f, 0.2f); heightMap[terrainData.heightmapWidth - 2, 0] = UnityEngine.Random.Range(0f, 0.2f); heightMap[terrainData.heightmapWidth - 2, terrainData.heightmapHeight - 2] = UnityEngine.Random.Range(0f, 0.2f);*/ while (squareSize > 0) { for (int x = 0; x < width; x += squareSize) { for (int y = 0; y < width; y += squareSize) { //计算区域右上角坐标 cornerX = (x + squareSize); cornerY = (y + squareSize); //中点坐标 midX = (int)(x + squareSize / 2.0f); midY = (int)(y + squareSize / 2.0f); //得到区域中心点的高度 //高度就是四个角相加,再取平均值,再加上一个随机高度 heightMap[midX, midY] = (float)((heightMap[x, y] + heightMap[cornerX, y] + heightMap[x, cornerY] + heightMap[cornerX, cornerY]) / 4.0f + UnityEngine.Random.Range(heightMin, heightMax)); } } for (int x = 0; x < width; x += squareSize) { for (int y = 0; y < width; y += squareSize) { cornerX = (x + squareSize); cornerY = (y + squareSize); midX = (int)(x + squareSize / 2.0f); midY = (int)(y + squareSize / 2.0f); pmidXR = (int)(midX + squareSize); pmidYU = (int)(midY + squareSize); pmidXL = (int)(midX - squareSize); pmidYD = (int)(midY - squareSize); //地形边界检查 if (pmidXL <= 0 || pmidYD <= 0 || pmidXR >= width - 1 || pmidYU >= width - 1) continue; //分别计算四条边的中点高度 //计算矩形的低边中点高度 heightMap[midX, y] = (float)((heightMap[midX, midY] + heightMap[x, y] + heightMap[midX, pmidYD] + heightMap[cornerX, y]) / 4.0f + UnityEngine.Random.Range(heightMin, heightMax)); //计算矩形的顶边中点高度 heightMap[midX, cornerY] = (float)((heightMap[x, cornerY] + heightMap[midX, midY] + heightMap[cornerX, cornerY] + heightMap[midX, pmidYU]) / 4.0f + UnityEngine.Random.Range(heightMin, heightMax)); //计算矩形的左边中点高度 heightMap[x, midY] = (float)((heightMap[x, y] + heightMap[pmidXL, midY] + heightMap[x, cornerY] + heightMap[midX, midY]) / 4.0f + UnityEngine.Random.Range(heightMin, heightMax)); //计算矩形的右边边中点高度 heightMap[cornerX, midY] = (float)((heightMap[midX, y] + heightMap[midX, midY] + heightMap[cornerX, cornerY] + heightMap[pmidXR, midY]) / 4.0f + UnityEngine.Random.Range(heightMin, heightMax)); } } //向内收缩 squareSize = (int)(squareSize / 2.0f); //阻尼 heightMin *= heightDampener; heightMax *= heightDampener; } terrainData.SetHeights(0, 0, heightMap); }
3.1.5 基于模糊的平滑算法
通过上面几种方法生成高度后,需要做一次平滑处理。
基于模糊的平滑算法:
给定一点,求该点及附近点的高度和的平均值
height = (height(x,y) + height(x-1,y+1) + height(x,y+1) + height(x+1,y+1) + height(x-1,y) + height(x+1,y) + height(x-1,y-1) + height(x,y-1) + height(x+1,y-1)) / 9; //高度会均匀下降
3.2 设置地形颜色数据Splatmap
Splatmap也被称为weightmap、alphamap;TerrainData给出的接口是SetAlphamaps,rgb通道和Splatmap的r+b+g+r<=1
设置完毕后,增加细节贴图,TerrainDaTa接口:
3.2.1 手动计算splatmap
for (int y = 0; y < terrainData.alphamapHeight; y++) { for (int x = 0; x < terrainData.alphamapWidth; x++) { //terrainLayer float[] splat = new float[terrainData.alphamapLayers]; for (int i = 0; i < splatHeights.Count; i++) { //用噪声优化边界锯齿,增加混合效果 float noise = Mathf.PerlinNoise ( x * splatHeights[i].splatNoiseXScale, y * splatHeights[i].splatNoiseYScale ) * splatHeights[i].splatNoiseScaler; float offset = splatHeights[i].splatOffset + noise; //start - stop,是在一个高度范围条带内设置相同纹理,用噪声优化了边界 float thisHeightStart = splatHeights[i].minHeight - offset; float thisHeightStop = splatHeights[i].maxHeight + offset; /* float thisHeightStart = splatHeights[i].minHeight; float thisHeightStop = splatHeights[i].maxHeight;*/ /*手写坡度值:分别计算该点与上一点在x、y轴上的增量,取模 * |dy .(nx,ny) * | * |______dx *(x,y) */ /*float steepness = GetSteepness(heightMap, x, y, terrainData.heightmapWidth, terrainData.heightmapHeight);*/ //自带坡度值 float steepness = terrainData.GetSteepness ( y / (float)terrainData.alphamapHeight, x / (float)terrainData.alphamapWidth ); //测试是否处于同一高度范围内及给定坡度范围内 if ((heightMap[x, y] >= thisHeightStart && heightMap[x, y] <= thisHeightStop) && (steepness >= splatHeights[i].minSlope && steepness <= splatHeights[i].maxSlope)) { splat[i] = 1; } } //要满足r+g+b+r=1,每个通道对应一张细节图。一个splatmap最多对应4张细节图 NormalizeVector(splat); for (int j = 0; j < splatHeights.Count; j++) { splatmapData[x, y, j] = splat[j]; } } } terrainData.SetAlphamaps(0, 0, splatmapData);
如何用很少的细节贴图,增强表现地形变化?
每块区域有一定高度、坡度,从高度与坡度数值结合铺开纹理,即使高度都在同一范围内也可用坡度数值限定。同时也可使用tileOffset参数参与显示。
3.2.2 外部导入splatmap
从world machine生成splat:
Color col = splatmapColors[((w - 1) - x) * w + y]; Color col_b = splatmapColors_b[((w - 1) - x) * w + y]; float sum = col.r+col.g+col.b; if (sum>1.0f) { splatmapData[x, y, 0] = col.r / sum; splatmapData[x, y, 1] = col.g / sum; splatmapData[x, y, 2] = col.b / sum; splatmapData[x, y, 3] = 0.0f; } else{ splatmapData[x, y, 0] = col.r; splatmapData[x, y, 1] = col.g; splatmapData[x, y, 2] = col.b; splatmapData[x, y, 3] = 1.0f - sum; }
细节图设置:
SplatPrototype[] newSplatPrototypes; newSplatPrototypes = new SplatPrototype[splatHeights.Count]; int spindex = 0; foreach (SplatHeights sh in splatHeights) { newSplatPrototypes[spindex] = new SplatPrototype(); newSplatPrototypes[spindex].texture = sh.texture; newSplatPrototypes[spindex].tileOffset = sh.tileOffset; newSplatPrototypes[spindex].tileSize = sh.tileSize; newSplatPrototypes[spindex].texture.Apply(true); spindex++; } //splatPrototypes在新版本被标记过时了,要用terrainLayer数据 terrainData.splatPrototypes = newSplatPrototypes;