Unity 动态网格地图的生成:基于Perlin Noise创建地形
Posted 心之凌儿
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Unity 动态网格地图的生成:基于Perlin Noise创建地形相关的知识,希望对你有一定的参考价值。
前言:
在前面的文章中,写了一个简单的网格地图生成脚本,不过是基于二维空间来完成的。为了更好的拓展该脚本,同时去了解学习大世界地图加载的一些知识,这段时间会通过一个系列的文章做一个类似于我的世界那样的开放世界地形加载案例
在这一过程中,我希望初入行业的学习者可以通过我的介绍来深入浅出理解一些常用算法的基本原理与实现方式,同时认识或了解到一些成熟的行业代码的雏形是什么,简单的来说,就是一个零基础但是相对深入一些的系列文章
本篇文章为该系列的第一篇文章,会完成世界地形的初次创建工作,达到的效果大致如图:
地图创建逻辑
一、创建区域网格地图:
类似与我的世界,在初次进入游戏时,先对地图进行初始化,然后将初始化的数据进行保存,这样就可以在后续的游戏过程中,进行持续化的存储与读取
区域网格地图创建逻辑:
在这个过程中第一个阶段,就是地图场景的初始化。如果将整个场景一次性的加载出来,显然是不可能的,无论是数据的加载、还是数据的存储计算、或者场景的渲染都难以实现。
所以我们需要制定一种动态加载的策略,像我的世界一样,只计算渲染玩家周围的地形数据信息。这样就既可以保证地形正确显示,同时又不会造成过大的性能压力。
下一步就需要思考如何动态加载,很显然,要在角色周围生成一个地图,不考虑Y
轴,圆形是最佳的选择,因为玩家旋转一周玩家视野刚好形成一个圆形,如图所示,圆形区域要比矩形区域更加的节省空间:
当然这是理想情况,可以设想一下,在实际的逻辑设计过程中采用圆形会面临哪些问题:
- 首先就是边缘计算问题,相比于矩形进行简单的加减法,圆形的计算就需要通过对于浮点数乘法来完成,这样在计算数据量很大的同时结果的精确度也不够
- 同时又因为整个网格地图的基本单元是正方形(在
X
轴与Y
轴的映射)这样就会产生边缘地形覆盖的问题,简单的来说就是会造成圆形边缘的正方形只有一部分被覆盖,这样又需要对其进行判断与处理,使得逻辑复杂度大大的提升
基于上面的原因,选择了正方形作为了网格地图的第二维度的加载单元
当我们确定好基本的地图加载区块的形状后,就可以通过玩家的初始位置计算生成一块地图。在设计初期,我们先不考虑地图区块大小的设定,先实现这块地图的创建
基本的方块单元构建:
首先创建一个基本单元的预制体,创建一个脚本命名为Item
,先添加一些基本的属性,后期有需求可以再次进行扩展,下面是我们预设需要实现的一些属性方法:
- 格子
ID
:用来表示地图中的每一块方格 - 格子类型枚举:类似于我的世界石头与泥土一样
- 格子的材质:通过格子类型来赋予不同的材质,获取不同的显示效果
- 鼠标点击事件:通过委托完成,方便后续点击破坏效果
- 封装一个改变材质方法:方便后期调用
简单的先写入一些后期可能用到的方法,当然现在不写也没有关系,后期使用的时候再添加也可以,具体的代码结构如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
public class Item : MonoBehaviour
{
//public Material material;
public MeshRenderer render;
public Action clickEvent;
[HideInInspector]
public ITEM_TYPE type;
[HideInInspector]
public Vector3Int itemID;
public void Register()
{
ChangeMaterial();
}
void ChangeMaterial()
{
render.sharedMaterial = GameTools.Instance.materials[(int)type];
}
public void OnMouseDown()
{
clickEvent?.Invoke();
}
}
在上面的脚本中我们调用了一个枚举对象ITEM_TYPE
,创建这个枚举对象是为了标识方块类型,在初期可以先简单的填入一两个值:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public enum ITEM_TYPE
{
Bedrock=0,
Ston=1,
Cold=2
}
写完上面俩脚本后,创建一个Cube
,并把Item
脚本拖上去,创建成为一个预制体即可:
局部网格创建逻辑:
接下来创建一个脚本命名为MeshMapCreate
,为了能获取一些可以调整的属性,方便后期调试,所以选择继承于MonoBehaviour
作为挂载脚本,先定义一些属性:
- 网格地图的大小范围
- 创建网格地图的中心位置
- 方块模板,方便实例化
- 实例化对象的父物体
注意,在上面的属性中,网格地图的中心位置我们只考虑由Z
轴与X
轴组成的平面,因为Y
轴的地形表现复杂且地形变化与Y轴方向正相关,以中心计算并不是很好的选择
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
public class MeshMapCreate : MonoBehaviour
{
[Header("地图大小范围:")]
public Vector3Int mapRange;
[Header("地图中心位置:")]
public Vector3Int startPos;
[Header("方块模板:")]
public Item item;
[Header("实例化对象父物体:")]
public Transform parentTran;
}
在上面的脚本中,使用了一个Vector3Int
类型的数据作为ID
,这样做的目的是可以将方块的ID
直接作为方块的位置坐标来使用,同时为了可以保证任何两块方块紧密贴合,全局使用整数类型来进行位置计算,来避免浮点数的精度偏差
为什么浮点数容易出现偏差:
Unity对于浮点数的处理策略为,当一个浮点数的小数位超过7位后, 会将其舍弃,虽然这种处理方式通常产生影响的很小,但是在大量计算后也会造成肉眼可见的数据偏差
接下来我们就需要在该脚本中实现创建一个简单的地图方法,即由方块组成一个平面,通过遍历给出的地图范围来实例化出一系列的方块,但是这里有一个问题,如果我们正常的去使用累加的方式从一个角来遍历生成地图,就会造成起始坐标点位于地图生成的起始角落的而不是地图中心的问题(这里的中心只考虑X
和Z
轴组成的平面)如图:
但是在编程实现中要如何确保角色位于创建的矩形网格中心呢
首先我们要了解,对于整个三维网格地图的创建,是对于这个三维网格的长宽高占有的格子数进行的遍历。由于我们使用的Cube
模板,长宽高刚好都为1,所以只需要对于先前定义的属性mapRange
进行x
、y
、z
轴上的遍历,就能得到所有位置上的Item
,具体代码结构如图:
for (int j = 0; j < range.y; j++)
{
for (int i = 0; i < range.x; i++)
{
for (int k = 0; k < range.z; k++)
{
}
}
}
在上面,我先对于Y
轴进行了遍历,是因为们知道地形结构往往是一种分层的结构,不同的深度有不同的地质,如下图(为了避免侵权,我只好自己画一张,有些抽象,但是还是容易理解的),即使你看不懂这张图,应该也知道我的世界平原的最上层往往是泥土,下面是石头,最下面一层是基岩,这里也是同样的道理:
理解创建后,就需要根据唯一的i
、j
、k
与中心位置startPos
来计算出来当前方块所在的位置,同时以该位置作为当前方块的唯一ID
,写一个求得坐标方法:
public Vector3Int GetItemID(int i, int j, int k)
{
int x = i - (mapRange.x + 1) / 2 + startPos.x;
int y = j + startPos.y;
int z = k - (mapRange.z + 1) / 2 + startPos.z;
return new Vector3Int(x, y, z);
}
实现方式很简单, 就是封装了一个重映射的方法,简单的可以理解为将0
到2n
的数映射到-n
到n
, 而映射完成的坐标位置是相对于中心坐标位于Vector3Int(0,0,0)
产生的,为了获取相对于中心坐标的方块坐标位置,需要再加上中心位置startPos
接下来就可以写入一个实例化格子对象的方法了,为了演示产生的过程,使用一个协程来完成地形的创建,同时需要定义两个属性:
- 一个字典来缓存地图网格的信息,记录的键为每一个方块的唯一
ID
,而值则为方块逻辑脚本item
- 一个委托:用来执行方块创建的方法,这个主要是为了后面有不同的地形时,可以执行不同的创建逻辑
// 缓存地图的方块信息
public Dictionary<Vector3Int, Item> items=new Dictionary<Vector3Int, Item>();
//写入一个Item创建时间的委托
private Action<Item> ItemCreateEvent;
//地形创建方法,传入参数为创建地图中心坐标点
IEnumerator CreateMap(Vector3Int range)
{
for (int j = 0; j < range.y; j++)
{
for (int i = 0; i < range.x; i++)
{
for (int k = 0; k < range.z; k++)
{
Vector3Int v3i = GetItemID(i, j, k);
CreateGrid(v3i);
yield return new WaitForSeconds(0.0002f);
}
}
}
}
//创建一个方块需要执行的方法
public void CreateGrid(Vector3Int v3i)
{
Item itemCo = Instantiate(item, parentTran);
itemCo.transform.position = v3i;
ItemCreateEvent?.Invoke(itemCo);
items.Add(v3i, itemCo);
}
在上面的代码中,我需要先定义好创建方块时的委托的方法,然后才能执行整个创建网格地图的方法,创建一个方法命名为CreateItemEvent
,相当于先预留一个接口,后期再接入需要的内容,同时添加一个Register
方法作为这里面的程序入口:
public void Register()
{
GetPerlinNoise(mapRange);
ItemCreateEvent = CreateItemEvent;
StartCoroutine(CreateMap(mapRange));
}
public void CreateItemEvent(Item item,Vector3Int v3i)
{
//T0:方块创建时数据初始化
}
进行到这里,先执行测试一下,看看创建的效果,写一个脚本命名为MainRun
用来作为程序执行的入口脚本:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class MainRun : MonoBehaviour
{
public MeshMapCreate mapCreate;
void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
mapCreate.Register();
}
}
}
开始执行,看一下效果:
可以看到地图根据红色的初始点成功创建了出来,但是现在由于没有地形的起伏,所以就是一个简单的地形块,下一布就是实现地形的起伏
二、创建起伏的地形
在我的世界中,为了实现一个可以起伏的地形,需要一定的随机性的同时有连续性(下图是一个没有连续性的地图效果),随机性相对来说比较容易实现,那如何解决地图的连续性呢
1、失败方案:
这个方案是本人根据自己的想法去设计实现的,最后的显示效果比较差,同时也没有办法进行后续场景的扩展。所以这里就简单的说一下,如果有兴趣的,可以看一下了解一下其他人思考问题的方式
该方案的地图加载策略是一层一层计算的,当程序走到某一个方块实例化创建逻辑时,就对于其周围方块存在与否进行判断,由于整个网格地图方块是某一角更新实现,所以更新到某一位置时,其周围最多有三个方块,如下图:
在更新红球所在位置的方块时,其周围有三个方块的位置已经进行过逻辑处理,所以其状态已经确定:有或者没有方块。我的这个方案就是根据这样的一个状态进行判断:
- 如果当前刷新位置处下面的位置没有方块,则该处直接处理为空状态,即也不允许存在方块,来避免空中方块乱飘的情况。
如果下面有方块,根据其周围方块的数量给与一个该处是否存在方块概率,具体的给与逻辑为:
- 一个都没有:则该处不存在方块的概率比较大
- 有一个方块:存在方块的概念适中
- 有两个方块:则该处存在方块的概率很大
在完成上述创建逻辑设计后,通过编程代码实现的结果如下图:
通过对于上面的图片内的地形分布可以明显的看出该方案说存在的问题:
- 整体上,是从一个角向其他角倾斜
- 不够平滑,随机性还是有点强
- 形成的地貌范围很小,且不可提升
- 边缘无法混合,后续扩展性相当于没有
总的来说,在开发初期,我个人认为是一个错误的思路,但是在后面突然看到一篇基于Cellular automaton
(细胞自动机)来创建洞穴的一篇文章。发现我的思路和Cellular automaton
的原理基本相同,本质上还是我执行该思路的代码逻辑存在问题,所以效果比较差。
关于Cellular automaton
的基本解释我在这里直接截取了大佬文章的内容:
他对于一个二维地形的创建流程的解释为(图片截取于知乎大佬
ShaVenZz
,仅限于学习使用):
Cellular automaton
生成随机地形与我的地形创建方案想法基本相同,都是通过周围方块状态去影响当前方块的状态,不过在于实现上有很大的差距(如果没有先了解到柏林噪声,我可能就会根据他的方案来实现地图的创建了),简单的分析一下我的方案的问题所在:
-
没有随机种子,地形从一个角落铺开
-
地形创建时机不对,应该独立执行地形创建逻辑,这样就可以避免由于地形实例化的单边限制,可以更大限度的根据周围的方块数量来判断
-
只对于地形执行一次遍历逻辑,这样就造成了,某一方块周围还未执行过逻辑处理方块的情况,出现幸存者偏差的情况
简单的解释一下最后一个问题。先举一个极端的例子,在创建第一个方块时,其周围的方块肯定都是不存在,显然会造成很大的误差。而当我们再次执行遍历后,场景中由于已经存在一些方块,这样就会使得误差减小。重复执行这一步骤来逐渐的修正误差,直到达到合适的效果
如果你想了解Cellular automaton
更多的细节,下面是这位知乎大佬ShaVenZz
原文章的地址:
2、基于Perlin noise(柏林噪声)创建地形
在自己尝试无果后,通过百度来需求解决方案,看到的最多的就是通过Perlin noise
来解决该问题,其基本实现原理可以理解为在一张噪声图中进行取样,获得的结果会根据取样的坐标位置得到不同的结果。简单的理解就是从预准备的样本库中获取数据来创建地图(实际还是通过算法计算所得),相比于通过代码逻辑创建,计算的数据量方面会少很多,取样的效果也相对更加平滑
关于柏林噪声:
先粘贴关于百度百科的一段话:
柏林噪声 (Perlin noise
)指由Ken Perlin
发明的自然噪声生成算法 。一个噪声函数基本上是一个种子随机发生器。它需要一个整数作为参数,然后根据这个参数返回一个随机数。如果两次都传同一个参数进来,它就会产生两次相同的数。这条规律非常重要,否则柏林函数只是生成一堆垃圾
但是百度百科中没有更多关于柏林噪声具体实现的步骤过程,而在维基百科有介绍到柏林噪声生成的大概的步骤:
整个过程简要的分为三部分:
- 选取随机点,给定随机值
- 给定点位的梯度(用插值函数标识)
- 根据随机点的值与梯度计算出为赋值位置的数值
在这一过程中Perlin Noise
使用一些特殊的处理策略保证生成的数据伪随机并且连续
简单演绎一维柏林噪声:
根据维基百科的步骤叙述,来演绎一下一维柏林噪声的创建过程:
首先创建一个坐标系,以X
轴作为一维坐标参考点位,Y
轴作为以为坐标的参考值,这样就创建了一个基本的坐标系
先在一个一维数组上选择一些位置,为他们随机的赋值,而在这些点位中间的位置,就需要通过一些插值算法来获取到值,最终获取到一条连续的曲线
在上面的过程中,有几个关键的点:
- 如何选择赋值的位置
- 如何对其进行赋值
- 采用哪种插值方式获取非整数点的数值
在一维的噪声生成案例中,我们可以根据X坐标轴来间隔取整获取这些点位,然后通过随机方法来为这些整数点赋值。接下来就可以在非整数点通过相邻的两个整数的数值插值计算出其对应的值,这样就可以得到一条连续的曲线,也就是一维的柏林噪声图,简单的写一个代码画出一个坐标轴,并基于Line Renderer
绘制一维柏林噪声:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PerlinNoise : MonoBehaviour
{
public int lineWight;
//public List<Vector3> points;
public GameObject posPre;
public Dictionary<int, Vector3> points = new Dictionary<int, Vector3>();
public LineRenderer line;
public int interIndexMax = 100;
private void Awake()
{
CreatePos();
CreateLine();
}
//画出整数点对应数值点位
void CreatePos()
{
for (int i = 0; i < lineWight; i++)
{
float num = Random.Range(0f, 4f);
Vector3 pointPos = new Vector3(i, num, 0);
GameObject go = Instantiate(posPre, this.transform);
go.transform.position = pointPos;
points.Add(i, pointPos);
}
}
//相邻两个整数点位之间插值获取其他位置数值
void CreateLine()
{
int posIndex = 0;
int interIndex;
line.positionCount= interIndexMax * (points.Count - 1);
for (int i = 1; i < points.Count; i++)
{
interIndex = 0;
while (interIndex< interIndexMax)
{
interIndex++;
float posY = points[i - 1].y + (points[i].y - points[i - 1].y) * (interIndex / (float)interIndexMax);
Vector3 pos = new Vector3(i - 1 + interIndex / (float)interIndexMax, posY, 0);
line.SetPosition(posIndex, pos);
posIndex++;
}
}
}
在上面的代码中,可以看出,相邻的两个整数点之间会进行一次线性插值,来求出两点之间的具体曲线,运行代码后可以看到下面的效果:
在上面的一维柏林噪声生成中,我们基于线性插值获取到了一条连续的折线,由于程序会在相邻两个整数点之间进行一次线性插值获取到中间点的坐标。结果得到一条直线,同时在一个整数点的左右两边使用了不同的插值区间,结果使得两边的曲线在整数点的斜率不同,最终造成了Perlin Noise
生成的一维曲线在整数点的不连续
为了避免上面的情况,使得得到的Perlin Noise
更加平滑自然,Ken Perlin
建议使用:
3
t
2
−
2
t
3
{\\displaystyle 3t^{2}-2t^{3}}
3t2−2t3 作为Perline Noise
的插值函数,而在最新版本算法该插值函数又被更换为
6
t
5
−
15
t
4
+
10
t
3
{\\displaystyle 6t^{5}-15t^{4}+10t^{3}}
6t5−15t4+10t3
为了更好理解这两个插值函数,先通过可视化代码看一下生成的曲线效果,首先是 3 t 2 − 2 t 3 {\\displaystyle 3t^{2}-2t^{3}} 3t2−2t3插值函数的显示效果,我们简单的修改一下求插值方法,更新画线插值处的代码:
//相邻两个整数点位之间插值获取其他位置数值
void CreateLine()
{
int posIndex = 0;
int interIndex;
line.positionCount = interIndexMax * (points.Count - 1);
for (int i = 1; i < points.Count; i++)
{
interIndex = 0;
while (interIndex< interIndexMax)
{
interIndex++;
float posY = Mathf.Lerp(points[i - 1].y, points[i].y, InterpolationCalculation(interIndex / (float)interIndexMax));
Vector3 pos = new Vector3(i - 1 + interIndex / (float)interIndexMax, posY, 0);
line.SetPosition(posIndex, pos);
posIndex++;
}
}
}
//插值函数的计算
float InterpolationCalculationunity中动态生成网格
游戏开发进阶Unity网格探险之旅(Mesh | 动态合批 | 骨骼动画 | 蒙皮 )