找回密码
 立即注册
查看: 595|回复: 1

[笔记] Unity 动态网格地图的生成:基于Perlin Noise创建地形

[复制链接]
发表于 2021-12-3 09:05 | 显示全部楼层 |阅读模式
前言:

在前面的文章中,写了一个简单的网格地图生成脚本,不过是基于二维空间来完成的。为了更好的拓展该脚本,同时去了解学习大世界地图加载的一些知识,这段时间会通过一个系列的文章做一个类似于我的世界那样的开放世界地形加载案例
在这一过程中,我希望初入行业的学习者可以通过我的介绍来深入浅出理解一些常用算法的基本原理与实现方式,同时认识或了解到一些成熟的行业代码的雏形是什么,简单的来说,就是一个零基础但是相对深入一些的系列文章
本篇文章为该系列的第一篇文章,会完成世界地形的初次创建工作,达到的效果大致如图:



地图创建逻辑

一、创建区域网格地图:

类似与我的世界,在初次进入游戏时,先对地图进行初始化,然后将初始化的数据进行保存,这样就可以在后续的游戏过程中,进行持续化的存储与读取
区域网格地图创建逻辑:
在这个过程中第一个阶段,就是地图场景的初始化。如果将整个场景一次性的加载出来,显然是不可能的,无论是数据的加载、还是数据的存储计算、或者场景的渲染都难以实现。
所以我们需要制定一种动态加载的策略,像我的世界一样,只计算渲染玩家周围的地形数据信息。这样就既可以保证地形正确显示,同时又不会造成过大的性能压力。
下一步就需要思考如何动态加载,很显然,要在角色周围生成一个地图,不考虑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;
        reurn 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原文章的地址:

  • 基于Cellular automaton(细胞自动机)的随机洞穴生成(一)---生成简易地图

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.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建议使用: 作为Perline Noise的插值函数,而在最新版本算法该插值函数又被更换为  
为了更好理解这两个插值函数,先通过可视化代码看一下生成的曲线效果,首先是  插值函数的显示效果,我们简单的修改一下求插值方法,更新画线插值处的代码:
        //相邻两个整数点位之间插值获取其他位置数值
    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.y, InterpolationCalculation(interIndex / (float)interIndexMax));
                Vector3 pos = new Vector3(i - 1 + interIndex / (float)interIndexMax, posY, 0);
                line.SetPosition(posIndex, pos);
                posIndex++;
            }
        }
    }

    //插值函数的计算
    float InterpolationCalculation(float num)
    {
        return 3*Mathf.Pow(num, 2)-2*Mathf.Pow(num,3);
    }
在上面的代码中,简单的封装了一个函数计算公式,然后通过线性插值做了一个从两个整数点的区间范围到(0,1)之间的映射,最终得到的曲线为:


与线性插值,整条曲线明显平滑了许多,并且由于插值函数${\displaystyle 3t^{2}-2t^{3}}$在x取值0和1时对应的坐标点斜率为0,所以最终求得的插值曲线在整数点呈连续状态, 但是在整数点看起来依旧尖锐,所以在最新Perlin Noise生成算法中插值函数被更换为  ,下图是两个曲线插值函数得到的噪声曲线对比:

  • 蓝色曲线:代表  获取的噪声曲线
  • 红色曲线:代表  获取的噪声曲线



由于取值太小,对比不是很明显, 所以我们放大X轴,就可以明显的看出,通过  插值求得的曲线在整数点附近的点位的斜率明显小于  插值函数,如下图所示:



二维柏林噪声的演绎
基于一维柏林噪声,来思考二维Perlin Noise的生成,同样我们需要选取坐标的整数点来给以随机值,然后根据这些整数点的取值采取相应的插值策略来获取那些非整数值的数值,整个过程分为三部分:
类似于一维Perlin Noise,二维Perlin Noise会通过分别对于X轴与Y轴取整数点
在场景中创建一个三维坐标系,以X轴与Z轴组成的平面作为二维柏林噪声坐标点,而Y轴则代表每一个坐标的取值:
public int posNumber;

    public GameObject posPre;

    public LineRenderer line1;
    public LineRenderer line2;
    public LineRenderer line3;
    private void Awake()
    {
        Coordinate();
    }

    void Coordinate()
    {

        line1.positionCount=posNumber;
        line2.positionCount=posNumber;
        line3.positionCount = posNumber;
        for (int i = 0; i < posNumber; i++)
        {
            GameObject goX = Instantiate(posPre, this.transform);
            goX.transform.position = new Vector3(i, 0, 0);
            line1.SetPosition(i, new Vector3(i, 0, 0));

            GameObject goY = Instantiate(posPre, this.transform);
            goY.transform.position = new Vector3(0, i, 0);
            line2.SetPosition(i, new Vector3(0, i, 0));

            GameObject goZ = Instantiate(posPre, this.transform);
            goZ.transform.position = new Vector3(0, 0, i);
            line3.SetPosition(i, new Vector3(0, 0, i));
        }
    }
执行代码,显示的效果如图:



实现一个坐标系的创建后,通过对于坐标系X轴与Y轴整数点来的选取Perlin Noise的基本点,为了避免与坐标系重叠,避开x=0与z=0的坐标点
在完成整数点的选取后,可以通过随机函数获取Y值,这样就可以在三维坐标系中确定唯一的位置:
//获取整数点的数值,并实例化一个物体在该位置
    void CreatePoints()
    {
        for (int i = 0; i < v2.x; i++)
        {
            for (int j = 0; j < v2.y; j++)
            {
                float nub = UnityEngine.Random.Range(0, MaxNoise);
                GameObject go = Instantiate(itemPre, parent);
                go.transform.position = new Vector3(i+1, nub, j+1);
                items[i, j] = nub;

            }
        }
    }
执行上面的代码,就可以在场景中生成整数点对应的点位,这些点位可以作为基准点位来作为后面插值操作的基数点:
自由角度:


俯视角度:



完成上面的准备工作,就来到了二维Perlin Noise的重点,如何通过插值获取非整数点的y值
不同于一维柏林噪声只需要执行一次插值操作即可求得对应的数字,二维柏林噪声需要根据距离其周围最近的四个整数点的数字来得到最后的结果,所以需要我们采取某种插值策略将四个数字联系起来
类似于双线性插值,Perlin Noise是通过三次插值来得到最终的结果的,在之前提到的维基百科中有介绍到:


简单的说就是,对于一个点在其最近四个坐标点组成的矩形内,如下图,对于E点,需要根据A 、B、C、D四个点的数值来插值获取,插值的逻辑是首先通过A点与D点的数值通过插值函数计算出F点的值,然后通过B点与C点的值计算出G点的数值,最后通过G与F两点的值插值获取到最终的数值。


相比于一维Perlin Noise对于中间值的计算,,二维Perlin Noise只是多进行了两次插值操作,核心代码进行简单的改变:
    //计算四个相邻整数点组成的矩阵的点位的插值
        public void CreateGrid(int x,int y)
    {
        for (int i = 0; i < 11; i++)
        {
            for (int j = 0; j < 11; j++)
            {
                float interX = Mathf.Lerp(items[x, y], items[x + 1, y], InterpolationCalculation1(i/10f));
                float interY = Mathf.Lerp(items[x, y+1], items[x + 1,y+1], InterpolationCalculation1(i / 10f));
                float inter = Mathf.Lerp(interX, interY, InterpolationCalculation1(j/ 10f));
                GameObject go = Instantiate(itemPre, parent);
                go.transform.position = new Vector3(x+ i /10f+1, inter, y+ j/10f+1);

            }
        }
    }

    //插值函数的计算
    float InterpolationCalculation1(float num)
    {
        return 6 * Mathf.Pow(num, 5) - 15 * Mathf.Pow(num, 4) + 10 * Mathf.Pow(num, 3);
    }
执行代码,可以看到二维柏林噪声的可视化效果:




通过上面的图片可以看出基于二维Perlin Noise创建的图形与自然地形非常的接近,这也是为什么可以在地形创建上应用的原因
完善柏林噪声
前面也提到过,柏林噪声是一种伪随机的算法,每一次创建的地形应该在同一坐标点表现相同,但是在上面的演示中,每一个整数点的数值都是通过随机函数生成的,即每一次创建的地形都是完全随机的
在前面的演示中,完全随机影响到地形的因素只有整数点对应的数值,如果我们有一种方法,可以在某一状态下给定相同的数值,那么最终计算出来的地形也应该是相同的为了避免这样的问题,Perlin Noise提出了一种解决方法,给定整数点的初始值即可:


虽然已经给定了数组,但是并不是直接去使用其中的数值,而是经过一定规则的转换最终映射到一组范围比较小的梯度里面,关于梯度这部分比较复杂难懂,所以在上面并没有介绍到,只是简化思想的去演绎,整个具体的算法思想在后面有介绍,整个过程还是挺有意思的,有兴趣可以看看
小知识 在我的世界中,通过一串简单的种子数据就可以得到一个唯一的地形数据,最底层的那部分逻辑类似于柏林噪声的预设值处理

柏林噪声的算法思想
在上面的演示过程,为了避免晦涩难懂的数学知识,只是在应用层面上做出的处理。而若想要从理论知识上来理解,可以通过Ken Perlin给出的Java版本的源码来理解一下整个过程:
public final class ImprovedNoise
{
    static public double noise(double x, double y, double z)
    {
        int X = (int)Math.floor(x) & 255,                  // FIND UNIT CUBE THAT
            Y = (int)Math.floor(y) & 255,                  // CONTAINS POINT.
            Z = (int)Math.floor(z) & 255;
        x -= Math.floor(x);                                // FIND RELATIVE X,Y,Z
        y -= Math.floor(y);                                // OF POINT IN CUBE.
        z -= Math.floor(z);
        double u = fade(x),                                // COMPUTE FADE CURVES
               v = fade(y),                                // FOR EACH OF X,Y,Z.
                w = fade(z);
        int A = p[X] + Y, AA = p[A] + Z, AB = p[A + 1] + Z,      // HASH COORDINATES OF
            B = p[X + 1] + Y, BA = p[B] + Z, BB = p[B + 1] + Z;      // THE 8 CUBE CORNERS,

        return lerp(w, lerp(v, lerp(u, grad(p[AA], x, y, z),  // AND ADD
                                     grad(p[BA], x - 1, y, z)), // BLENDED
                             lerp(u, grad(p[AB], x, y - 1, z),  // RESULTS
                                     grad(p[BB], x - 1, y - 1, z))),// FROM  8
                     lerp(v, lerp(u, grad(p[AA + 1], x, y, z - 1),  // CORNERS
                                     grad(p[BA + 1], x - 1, y, z - 1)), // OF CUBE
                             lerp(u, grad(p[AB + 1], x, y - 1, z - 1),
                                     grad(p[BB + 1], x - 1, y - 1, z - 1))));
    }
    static double fade(double t) { return t * t * t * (t * (t * 6 - 15) + 10); }
    static double lerp(double t, double a, double b) { return a + t * (b - a); }
    static double grad(int hash, double x, double y, double z)
    {
        int h = hash & 15;                      // CONVERT LO 4 BITS OF HASH CODE
        double u = h < 8 ? x : y,                 // INTO 12 GRADIENT DIRECTIONS.
               v = h < 4 ? y : h == 12 || h == 14 ? x : z;
        return ((h & 1) == 0 ? u : -u) + ((h & 2) == 0 ? v : -v);
    }
    static final int p[] = new int[512], permutation[] = { 151,160,137,91,90,15,
   131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,142,8,99,37,240,21,10,23,
   190, 6,148,247,120,234,75,0,26,197,62,94,252,219,203,117,35,11,32,57,177,33,
   88,237,149,56,87,174,20,125,136,171,168, 68,175,74,165,71,134,139,48,27,166,
   77,146,158,231,83,111,229,122,60,211,133,230,220,105,92,41,55,46,245,40,244,
   102,143,54, 65,25,63,161, 1,216,80,73,209,76,132,187,208, 89,18,169,200,196,
   135,130,116,188,159,86,164,100,109,198,173,186, 3,64,52,217,226,250,124,123,
   5,202,38,147,118,126,255,82,85,212,207,206,59,227,47,16,58,17,182,189,28,42,
   223,183,170,213,119,248,152, 2,44,154,163, 70,221,153,101,155,167, 43,172,9,
   129,22,39,253, 19,98,108,110,79,113,224,232,178,185, 112,104,218,246,97,228,
   251,34,242,193,238,210,144,12,191,179,162,241, 81,51,145,235,249,14,239,107,
   49,192,214, 31,181,199,106,157,184, 84,204,176,115,121,50,45,127, 4,150,254,
   138,236,205,93,222,114,67,29,24,72,243,141,128,195,78,66,215,61,156,180
   };
    static { for (int i=0; i< 256 ; i++) p[256 + i] = p = permutation; }
} 整个过程与上面的可视化演示差不多,但是在对于原理上的理解省略了一些知识,这里补充上,便于读者来学习理解,从代码出发,主要分为下面这几个步骤:

  • 对于坐标的处理转换
  • 基于给定数组获取整数点的哈希值
  • 根据其周围的整数点的哈希值获取梯度
  • 通过插值获取当前点位对应值
首先对于坐标点的处理,就是要根据当前传入的坐标的整数部分得到其周围整数点的坐标值,而小数位则作为一个基本单位里面插值定位的参数值:
                int X = (int)Math.floor(x) & 255,                  // FIND UNIT CUBE THAT
            Y = (int)Math.floor(y) & 255,                  // CONTAINS POINT.
            Z = (int)Math.floor(z) & 255;
        x -= Math.floor(x);                                // FIND RELATIVE X,Y,Z
        y -= Math.floor(y);                                // OF POINT IN CUBE.
        z -= Math.floor(z);上面的代码,通过floor方法(向下取整)与位运算获取了两组数据:

  • (X,Y,Z):向下取整除256取余,用于后续获取整数点的哈希值
  • (x,y,z):减去向下取整的自身,简单来说就是对于最近的方块单位向0到1的映射
这里简单的介绍一下位运算,本质上就是转换成二进制来进行对位的操作,在Perlin Noise使用&的位运算本质上是为了求余,由于是位与位之间的操作,所以运行效率很高,具体的二进制计算为:
&代表与运算,其计算规则为:
1&1=1 1&0 =0  0&1 = 0 0&0 = 0所以在本运算中,一个整数x与255 进行&运算,假设整数为257,则计算过程为:


在上面的过程中,超出255的位经过与运算归零,而小于等于 255的与运算后等于其自身,达到求余的效果

之后就需要得到整数点计算出的哈希值,采用这一步的目的是为了对于二维或者更高维的坐标点做一个单项的排列,使得坐标点矩形单位可以对应单项数组:
        int A = p[X] + Y, AA = p[A] + Z, AB = p[A + 1] + Z,      // HASH COORDINATES OF
            B = p[X + 1] + Y, BA = p[B] + Z, BB = p[B + 1] + Z;      // THE 8 CUBE CORNERS,


    static final int p[] = new int[512], permutation[] = { 151,160,137,91,90,15,
   131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,142,8,99,37,240,21,10,23,
   190, 6,148,247,120,234,75,0,26,197,62,94,252,219,203,117,35,11,32,57,177,33,
   88,237,149,56,87,174,20,125,136,171,168, 68,175,74,165,71,134,139,48,27,166,
   77,146,158,231,83,111,229,122,60,211,133,230,220,105,92,41,55,46,245,40,244,
   102,143,54, 65,25,63,161, 1,216,80,73,209,76,132,187,208, 89,18,169,200,196,
   135,130,116,188,159,86,164,100,109,198,173,186, 3,64,52,217,226,250,124,123,
   5,202,38,147,118,126,255,82,85,212,207,206,59,227,47,16,58,17,182,189,28,42,
   223,183,170,213,119,248,152, 2,44,154,163, 70,221,153,101,155,167, 43,172,9,
   129,22,39,253, 19,98,108,110,79,113,224,232,178,185, 112,104,218,246,97,228,
   251,34,242,193,238,210,144,12,191,179,162,241, 81,51,145,235,249,14,239,107,
   49,192,214, 31,181,199,106,157,184, 84,204,176,115,121,50,45,127, 4,150,254,
   138,236,205,93,222,114,67,29,24,72,243,141,128,195,78,66,215,61,156,180
   };
    static { for (int i=0; i< 256 ; i++) p[256 + i] = p = permutation; }在代码中列出了三维案例中的四个点,同时在后面的计算中换算得到后面的四个点,通过代码可以看到一个整数点对应的数组值是通过坐标的三个轴进行的哈希换算
假设有一个坐标点A的坐标为(x,y,z),则通过哈希转换,从数组中拿到数据的代码结构为:
int posA=p[p[p[x]+y]+z];同时由于数组中的数值为0到255不重复的数字,如果只这样进行下标的加法,明显会造成数组的越界。所以Ken Perlin选择将数组扩容至两倍来避免该问题
在从数组中拿到对应的数值后,就需要得到出整数点对应的梯度值与距离向量的点积,在这一过程中,需要了解整数点对应的梯度向量并不是计算而来,而是基于之前在数组中拿到的数字从给定的一些梯度中选择对应的梯度



那么如何通过给定的数字来求得其对应的唯一梯度呢,Ken Perline在算法中使用了位翻转的操作来获取到最终的梯度,在代码案例中:
static double grad(int hash, double x, double y, double z)
    {
        int h = hash & 15;                      // CONVERT LO 4 BITS OF HASH CODE
        double u = h < 8 ? x : y,                 // INTO 12 GRADIENT DIRECTIONS.
               v = h < 4 ? y : h == 12 || h == 14 ? x : z;
        return ((h & 1) == 0 ? u : -u) + ((h & 2) == 0 ? v : -v);
    }通过对于之前哈希转换获取的值与所求点位的小数坐标换算来得出所求整数点在当前梯度点的权重
在这一部分有一个数学概念:梯度
梯度表示某一函数在该点处的方向导数沿着该方向取得最大值,简单的来说,在三维空间内可以通过一个二维函数来表示一个曲面,如果指定y不变,可以得到一个伴随x变化而变化的曲线。这条曲线的函数就是二维函数的偏导数,而这条曲线上任何的一点的斜率,都可以通过一个向量表示,方向代表正负,长度代表大小。基于这样的想法,同样可以在该点相对于y的偏导数的斜率的向量,而这两个向量相加就是该点的梯度向量:
关于梯度向量更详细的内容,可以看一下这个视频:点击前往


最后一部就是对于当前点周围所有整数点梯度做一个插值处理,插值使用的函数是一个在0处为1,在1处为0,在0.5处为0.5的连续单调函数,这也是为什么选择  与  作为插值函数的原因,如图所示:



两种在0到1之间的曲线基本相同,不过  在接近端点时的曲线更加平缓
地形创建逻辑代码
在前面通过大量的文字介绍了Perlin Noise的基本原理与代码结构后,很简单的就可以明白如何将其用在地形创建方面。
而对于Unity而言,更简单的是已经封装好一个二维Perlin Noise的生成方法,我们在使用时只需要调用即可,当然这样没有自己去写那么灵活,不过对于初学者而言可以更快的上手使用:


如图,只需要传入坐标值,就可以得到对应的值。但是注意该方法的循环范围很小,如果直接传入比较大的坐标,很容易造成地形的重复,所以我们在使用时尽量做一个范围的扩大映射,具体的地形高度数据生成代码为:
//通过地形范围获取高度数据集
    public void GetPerlinNoise(Vector3Int range)
    {
        
        for (int i = 0; i < range.x; i++)
        {
            for (int k = 0; k < range.z; k++)
            {
                Vector3Int v3i = GetItemID(i, 0, k);
                float sizle = Mathf.PerlinNoise(v3i.x * perlinSizle, v3i.z * perlinSizle)* terrainCom;
                int height = startPos.y+(int)(range.y * sizle);
                terrainLimitNums.Add(new Vector2Int(v3i.x, v3i.z), height);
            }
        }

    }
有了高度数据,就可以根据高度数据先执行一个简单的创建逻辑,来生成一个大概的地形结构,修改CreateMap 方法与CreateGrid的方法:
        public void 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);                  
                }
            }
               
        }
    }
    public void CreateGrid(Vector3Int v3i)
    {            
        if (v3i.y <= terrainLimitNums[new Vector2Int(v3i.x,v3i.z)])
        {
                GridInst(v3i,false);            
        }
        else
        {
                GridInst(v3i);
        }                  
    }
在上面的代码中,首先会对整个区域的方格执行遍历,然后对每一个格子的坐标Y轴数值与高度数据进行对比,如果小于该点高度,则传入参数false执行后面的判断,而如果大于该点高度,则将该点制空,不实例化物体,后面的处理逻辑为:
    public void GridInst(Vector3Int v3i,bool isNull=true)
    {      
        if (isNull)
        {
            items.Add(v3i, null);
            return;
        }
        Item itemCo = Instantiate(item, parentTran);
        itemCo.transform.position = v3i;      
        ItemCreateEvent?.Invoke(itemCo,v3i);
        items.Add(v3i, itemCo);      
    }
在上面的代码中写入了委托事件ItemCreateEvent用来对于格子状态的初始化,可以在地图创建前写入关于格子创建的脚本,这里先写入对方块材质的改变:
    //根据传入高度数据集写一个逻辑来创建一个简单的分层地形结构
    public void CreateItemEvent(Item item,Vector3Int v3i)
    {
        item.itemID = v3i;

        if (v3i.y == startPos.y)
        {
            item.type = ITEM_TYPE.Bedrock;
        }
        else if (v3i.y == terrainLimitNums[new Vector2Int(v3i.x, v3i.z)])
        {
            item.type = ITEM_TYPE.Cold;
        }
        else
        {
            item.type = ITEM_TYPE.Ston;

        }
        item.Register();
    }
这样就完成了一个简易版本的地图生成脚本,完成代码的执行调用部分的编写后在编辑器中执行,可以看到生成的地形大概形状,如图:
单面地形的侧视图:



自由角度地形形状:



同时可以通过控制perlinSizle的大小来控制地形的平缓度,比如当减小perlinSizle时,地形的形状为:



总结

本篇文章中,通过Perlin Noise算法简单的做出了一个地形的效果。整个代码结构很简单,主要是帮助初学者了解学习Perlin Noise的基本原理,后面我也会努力更新,希望可以做出一个完整的动态地图加载的案例
如果你恰巧有学习的想法,我会在下一篇文章中介绍关于地图数据的本地化存储与查询相关的内容

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
发表于 2021-12-3 09:10 | 显示全部楼层
太长了,
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-11-24 13:02 , Processed in 0.069868 second(s), 24 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表