0%

运用Perlin noise创建无限地图

Perlin Noise对于随机样式的创建有多大的贡献不必多言,网路上随便一搜就知道。游戏中地形的生成是Perlin Noise的一项重要应用,我们所熟知的大名鼎鼎的Minecraft的地图创建就依赖于这种方法。这回就在Unity中来实作一下,用Perlin Noise来生成无限地图。

实现Perlin Noise

Perlin Noise的原理其实并不复杂,只不过要解释清楚也是需要不少篇幅的,我不认为我有讲得比各种百科更透彻的能力,于是这里就不展开讲了。直接来看看在Unity中怎样利用Perlin Noise来生产随机地形。Perlin Noise的功能Mathf里其实已经集成好了,我们需要做的是先创建一个plane作为地块,对于这个地块上的每一个顶点,我们将其position的x和z值传入PerlinNoise函数作为参数,生成的随机值作为这个顶点新的高度值y。顶点我们可以从gameObject中的mesh里获得,然后遍历mesh。那么代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GenerateTerrain : MonoBehaviour
{
public int heightScale = 5;
public float detailScale = 5.0f;
// Start is called before the first frame update
void Start()
{
Mesh mesh = this.GetComponent<MeshFilter>().mesh;
Vector3[] vertices = mesh.vertices;
for (int v = 0; v < vertices.Length; v++)
{
vertices[v].y = Mathf.PerlinNoise((vertices[v].x + this.transform.position.x)/detailScale,
(vertices[v].z + this.transform.position.z)/detailScale)*heightScale;
}

mesh.vertices = vertices;
mesh.RecalculateBounds();
mesh.RecalculateNormals();
this.gameObject.AddComponent<MeshCollider>();
}

// Update is called once per frame
void Update()
{

}
}

值得注意的是,由于顶点位置被拔高,平面的形状也会随之改变,我们需要使用mesh.RecalculateBounds();和mesh.RecalculateNormals();这两行,来保证边缘不变并且重置法线,否则会产生相当奇怪的结果。

产生无限地形

将以上代码挂到plane上,如此一来我们便有了第一块地形。如同Minecraft里堆叠立方体产生地形那样,我们要使用这样的地形块来拼接得到大地图(或许某种程度上讲更类似于2d里的tile map)。于是将其先制作成prefab。
由于Perlin Noise的特性,我们拼接得到的地图必定是平滑无缝连接并且随机的。(尽管是伪随机,也就是输入固定的值就会生成固定的输出,所以在空间中固定位置生成的地形每次都会是一样的)
生成大地图,首先第一步我们以角色为中心向半径为5的四周铺上地块,然后根据角色移动产生的相对位置,来决定接下来产生地形的方向。譬如,我向x正方向走过了一个地块的距离,那么在x正方向当前的地图边缘我就要延展出新的一排地块,来保证我永远不会走到头。而至于我们背后,即x负方向的方块,我们就可以将其消除,来保持资源总数不会突破天际。具体的做法就是在每一次生成周围地块的时候,为其添加一个时间戳(或者更新现有的时间戳),我们可以用一个哈希表来存储这些地形块,然后我们检查哈希表里的每一个地形块,如果不为当前时间,就将其删除,因为已经超出角色周围的范围了。于是有代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

class Tile
{
public GameObject theTile;
public float creationTime;

public Tile(GameObject t, float ct)
{
theTile = t;
creationTime = ct;
}
}

public class GenerateInfinite : MonoBehaviour
{
public GameObject plane;
public GameObject player;

int planeSize = 10;
int halfTileX = 5;
int halfTileZ = 5;

Vector3 startPos;

Hashtable tiles = new Hashtable();
// Start is called before the first frame update
void Start()
{
this.gameObject.transform.position = Vector3.zero;
startPos = Vector3.zero;
float updateTime = Time.realtimeSinceStartup;
for (int x = -halfTileX; x < halfTileX; x++)
{
for (int z = -halfTileZ; z < halfTileZ; z++)
{
Vector3 pos = new Vector3((x*planeSize + startPos.x),0,(z*planeSize + startPos.z));
GameObject t = (GameObject) Instantiate(plane,pos,Quaternion.identity); //object,position,rotation
string tilename = "Tile_" + ((int)(pos.x)).ToString() + "_" + ((int)(pos.z)).ToString();
t.name = tilename;
Tile tile = new Tile(t,updateTime);
tiles.Add(tilename, tile);
}
}
}

// Update is called once per frame
void Update()
{
int xMove = (int)(player.transform.position.x - startPos.x);
int zMove = (int)(player.transform.position.z - startPos.z);

if (Mathf.Abs(xMove) >= planeSize || Mathf.Abs(zMove) >= planeSize)
{
float updateTime = Time.realtimeSinceStartup;

int playerX = (int)(Mathf.Floor(player.transform.position.x/planeSize)*planeSize);
int playerZ = (int)(Mathf.Floor(player.transform.position.z/planeSize)*planeSize);

for (int x = -halfTileX; x < halfTileX; x++)
{
for (int z = -halfTileZ; z < halfTileZ; z++)
{
Vector3 pos = new Vector3((x*planeSize + playerX),0,(z*planeSize + playerZ));
string tilename = "Tile_" + ((int)(pos.x)).ToString() + "_" + ((int)(pos.z)).ToString();

if (!tiles.ContainsKey(tilename))
{
GameObject t = (GameObject) Instantiate(plane,pos,Quaternion.identity);
t.name = tilename;
Tile tile = new Tile(t, updateTime);
tiles.Add(tilename,tile);
}
else
{
(tiles[tilename] as Tile).creationTime = updateTime;
}
}
}

Hashtable newTerrain = new Hashtable();
foreach (Tile tls in tiles.Values)
{
if(tls.creationTime != updateTime)
{
Destroy(tls.theTile);
}
else
{
newTerrain.Add(tls.theTile.name,tls);
}
}
tiles = newTerrain;
startPos = player.transform.position;
}
}
}

亿点细节

其实做到这里基本的Perlin Noise的应用就做完了。可以看到在上面的代码里我们用了height scale 和 detail scale两个值来控制地形的变化。前者控制高度,后者控制起伏。但是可以看到虽然地形都是随机的,但是总体的样式是很相似的,也就是当前还没有办法生成更多样的地形比如山峰峡谷,对此我们可以将不同频率和振幅的Perlin Noise多次叠加,于是如果恰好波峰遇上波峰,就会生成更高的峰,这样山就形成了。不过这一部分我还没有实作的打算,暂时打上一个TODO tag吧。
1
2
但是现在由于我们的周围只有5x5的地图大小,所以有时候还是可以看到边缘并且挺不自然的,所以接下去就是添加贴图和雾效,雾效可以有效的帮助我们隐藏地图边缘,在许多开放世界游戏中也是这么做的。
对于贴图,我找到了一个不错的网站TextureHaven,在这里可以下载到最高8k的免费材质图,我们将其用在地形prefab的材质球上即可。
至于雾效,直接在菜单栏windows下找到rendering中的lighting setting,把里面fog一项打勾并设置好恰当的颜色和浓度值,把相机从skybox改为solid color,颜色与雾的颜色一致。这里我遇到了一点小小障碍,打上fog的钩之后并没有雾出现,即使将浓度调到最大颜色调到最深也是一样。求助于万能的StackOverflow,发现需要把相机的rendering path设置为forward(原先是deffered)。

于是大功告成,后面也许会在地图上添加随机资源,插个flag先,不一定有时间。