在Unity中使用柏林噪声生成基础2D地形
本博客旨在介绍如何使用U2D和柏林噪声生成简单的地形,包含最基础的地形要素,如群系、起伏、树木、地层、洞穴、矿物等
一、效果概览
- 如下图所示就是通过柏林噪声(Perlin Noise)生成的基本2D地形,包含了群系分布、地表起伏、大地分层、洞穴空洞、矿物分布等基本地形生成要素
本博客中仅以2D的地形为例,对地形中不同地生成元素各使用了一个柏林噪声,仅达成了较初级的生成效果,但这种工具方法可以被类似地应用到更高维度地形的生成、或是更多不同层次的内容要素的生成,这就需要视Gameplay而具体设计了
本博客中用于展示的工程已开源至此仓库:https://github.com/WhythZ/SandboxTerrain2D
二、瓦片类
2.1 TileObject实现
TileObject
是用于控制游戏内生成的瓦片GameObject的脚本的抽象基类,其派生其它具体子类瓦片(此处也可以使用Scriptable Object来实现)
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
public enum TileType
{
Air = 0,
Stone = 1,
Dirt = 2,
DirtGrass = 3,
TreeLog = 4,
TreeLeaf = 5,
Coal = 6,
Iron = 7,
Gold = 8,
Diamond = 9,
Sand = 10,
Snow = 11
}
public abstract class TileObject : MonoBehaviour
{
[Header("Type")]
[SerializeField] protected TileType type;
public TileType Type { get => type; }
[Header("Texture")]
[SerializeField] protected Sprite[] textures;
protected virtual void Start()
{
//初始化瓦片材质为任意一种
GetComponentInParent<SpriteRenderer>().sprite = textures[UnityEngine.Random.Range(0, textures.Length)];
}
}
- 由于仅测试地形,所以各子类瓦片仅需种类标记和纹理贴图即可
1
2
3
4
5
6
7
8
9
public class _00_Air : TileObject
{
}
public class _01_Stone : TileObject
{
}
//...
2.2 瓦片预制体配置
- 将子类瓦片脚本挂载到对应瓦片预制体上,然后设置瓦片种类、添加贴图即可
- 注意这些瓦片预制体后续都需要按照TileType的编码顺序添加到
TilemapManager
管理器的对应列表中的索引位置,否则在实际生成瓦片的时候无法找到对应的正确瓦片预制体
三、管理器类
3.1 Manager单例抽象基类
- 可继承的管理器单例抽象基类,泛型约束为该类的子类(MonoBehavior必须挂载在GameObject上,而无法被new实例化,所以不应使用new约束)
1
2
3
4
5
6
7
8
9
10
11
12
13
public abstract class Manager<T> : MonoBehaviour where T : Manager<T>
{
//外部通过此公开字段访问该管理器单例
public static T instance;
protected virtual void Awake()
{
if (instance != null)
Destroy(instance.gameObject); //确保只有一个管理器单例
else
instance = (T)this; //因无法new,故强转为子类管理器类型T
}
}
3.2 TilemapManager功能实现
3.2.1 成员变量
TilemapManager
类被用于单独控制瓦片GameObject的逐区块生成的逻辑,下面给出了该类所需用到的成员变量,以及一个瓦片类型的枚举类TileType
该管理器限制了瓦片地图世界生成的尺寸、区块的尺寸、生成的树木的尺寸,并将所有区块GameObject(块内瓦片的Empty Parent)以及所有瓦片存储在列表中以备使用
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
public class TilemapManager : Manager<TilemapManager>
{
#region Convenience
private int chunkNumSqrt;
#endregion
[Header("Tile Prefabs")]
[SerializeField] private List<GameObject> tilePrefabList; //所有种类的瓦片的预制体
[Header("Map Scale")]
[SerializeField] private int worldLength = 128; //正方形世界的边长(同时也是对应噪声材质图像的边长)
public int WorldLength { get => worldLength; }
[SerializeField] private int chunkLength = 16; //正方形区块的边长
[Header("Map Tiles")]
[SerializeField] private GameObject[] chunks; //存放所有区块GameObject
private TileType[] tileTypesBeforePlacing; //存放所有预放置的瓦片类型
[SerializeField] private GameObject[] tiles; //存放所有瓦片GameObject
[Header("Tree Settings")]
[SerializeField] private float treeChance = 0.07f; //树木在地表草地上生成的概率
[SerializeField] private int maxTreeHeight = 7; //树干的最大高度
[SerializeField] private int minTreeHeight = 4; //树干的最小高度
//...
}
3.2.2 成员方法
- 该类提供以下的公开方法,用于在
TerrainManager
中被调用以生成地形InitTileMap
:用于初始化记录区块和瓦片生成相关信息的容器PreSetTileAt
:把在特定坐标位置处生成的瓦片的类型记录在tileTypesBeforePlacing
中,以便最终在GenerateTilemap
依据该列表中的瓦片类型来生成瓦片地图GenerateTilemap
:在预设置好了所有坐标处的瓦片类型后,在所有坐标处实例化其对应类型的瓦片Prefab
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
public class TilemapManager : Manager<TilemapManager>
{
//...
public void InitTilemap()
{
#region Chunks
//被除数确保结果向上舍入,开辟列表空间用于存放区块GameObject,注意实际区块总数为平方
chunkNumSqrt = (worldLength + chunkLength - 1) / chunkLength;
chunks = new GameObject[chunkNumSqrt * chunkNumSqrt];
//建立区块并确立父子关系
for (int i = 0; i < chunkNumSqrt * chunkNumSqrt; i++)
{
chunks[i] = new GameObject();
chunks[i].name = i.ToString();
chunks[i].transform.parent = this.transform;
}
#endregion
#region Tiles
tileTypesBeforePlacing = new TileType[worldLength * worldLength];
tiles = new GameObject[worldLength * worldLength];
#endregion
}
public void GenerateTilemap()
{
for (int _y = 0; _y < worldLength; _y++)
{
for (int _x = 0; _x < worldLength; _x++)
{
PlaceTileAt(tileTypesBeforePlacing[_x + _y * worldLength], _x, _y);
}
}
}
public void PreSetTileAt(TileType _type, int _x, int _y)
{
//计算传入坐标对应在线性列表中对应的索引
int _tileIdx = _x + _y * WorldLength;
//若设置在该位置上的瓦片类型与原有的不同,则可能需要进行覆盖
if (_type != tileTypesBeforePlacing[_tileIdx])
{
//以下情况下无需进行覆盖
if (_type == TileType.Air) return;
if (_type == TileType.TreeLeaf && tileTypesBeforePlacing[_tileIdx] == TileType.TreeLog) return;
}
//执行覆盖
tileTypesBeforePlacing[_tileIdx] = _type;
#region DerivingStructures
//在生成草皮(_y位置)时按概率在其上方生成树(_y+1位置)
if (_type == TileType.DirtGrass) DeriveTreeAt(_x, _y + 1);
#endregion
}
//...
}
- 该类实现以下的私有方法,用于各类型瓦片的实际实例化的控制
PlaceTileAt
:底层被调用于实例化特定瓦片Prefab的方法DeriveTreeAt
:调用多个PreSetTileAt
来生成一颗完整的树结构
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
public class TilemapManager : Manager<TilemapManager>
{
//...
private void DeriveTreeAt(int _x, int _y)
{
//树的生成是考究概率的
int _chance = Mathf.RoundToInt(treeChance * 100);
int _random = UnityEngine.Random.Range(0, 100);
if (_random >= _chance) return;
//预设置树干
int _height = UnityEngine.Random.Range(minTreeHeight, maxTreeHeight);
for (int i = 0; i < _height; i++)
PreSetTileAt(TileType.TreeLog, _x, _y + i);
//预设置树叶
PreSetTileAt(TileType.TreeLeaf, _x, _y + _height);
PreSetTileAt(TileType.TreeLeaf, _x - 1, _y + _height);
PreSetTileAt(TileType.TreeLeaf, _x + 1, _y + _height);
PreSetTileAt(TileType.TreeLeaf, _x, _y + _height + 1);
PreSetTileAt(TileType.TreeLeaf, _x - 1, _y + _height + 1);
PreSetTileAt(TileType.TreeLeaf, _x + 1, _y + _height + 1);
PreSetTileAt(TileType.TreeLeaf, _x, _y + _height + 2);
}
private void PlaceTileAt(TileType _type, int _x, int _y)
{
//若放置位置超出了世界范围,则不予生成
if (_x >= worldLength || _y >= worldLength) return;
#region Instantiation
//实例化对应类型瓦片的预制体并命名
GameObject _newTile = Instantiate(tilePrefabList[_type.GetHashCode()]);
_newTile.name = _type.ToString();
//以离散的整数_x与_y为世界坐标,设置实例化瓦片对象的位置
_newTile.transform.position = new Vector2(_x, _y);
#endregion
#region AddToChunksArray
//计算该瓦片所在的区块编号的索引值
int _chunkIdx = (_y / chunkLength) * chunkNumSqrt + (_x / chunkLength);
//将瓦片挂载在正确的区块上以便管理
_newTile.transform.parent = chunks[_chunkIdx].transform;
#endregion
#region AddToTilesList
tiles[_x + _y * worldLength] = _newTile;
#endregion
}
}
3.2.3 编辑器内效果
- 最终将该管理器脚本挂载到空对象上,配置相关参数即可,记得将瓦片预制体存入列表中
3.3 TerrainManager功能实现
3.3.1 成员变量与序列化参数配置
- 本文中的
TerrainManager
类使用柏林噪声控制世界瓦片地图中所有坐标位置处的瓦片类型,影响地形生成中的瓦片类型的因素如下- 地质分层:使用各层的高度控制
- 地表起伏:使用柏林噪声控制波动曲线
- 洞穴分布:使用柏林噪声生成黑白材质图(白色是洞穴空洞,生成空气瓦片,黑色是正常生成地层瓦片和矿物)
- 生态群系:使用柏林噪声生成彩色材质图(彩色代表各种群系,无色则采取默认群系类型)
- 其中对于不同的生态群系,其各自拥有不同的多种矿物的生成概率与大小
- 煤矿
- 铁矿
- 金矿
- 钻石
- 其中对于矿物相关属性配置我们将其设计为一个单独的可序列化的类,将其作为成员包装在生态群系的可序列化类中,这样我们就可以在
TerrainManager
中通过多个BiomeSettings
成员来对应不同的群系,并使得在Unity编辑器中能以较为美观的方式进行数值配置,其中的各种Texture2D
成员是用于存放对应生成要素的柏林噪声纹理图的
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
enum BiomeType
{
Grass = 0,
Desert = 1,
Snow = 2
}
[System.Serializable]
class BiomeSettings
{
[Header("Biome Type")]
//public BiomeType type; //群系的种类
public Color color; //该群系显示在噪声图中的颜色
[Header("Biome Spread")]
public float biomeSize = 0.3f; //群系的大小
[Header("Ore Settings")]
public OreSettings ores; //群系的矿物分布
}
[System.Serializable]
class OreSettings
{
[Header("Ore Spread")]
public Texture2D coalSpreadTex; //煤矿生成的分布噪声图
public Texture2D ironSpreadTex; //铁矿生成的分布噪声图
public Texture2D goldSpreadTex; //金矿生成的分布噪声图
public Texture2D diamondSpreadTex; //钻矿生成的分布噪声图
[Header("Coal")]
public float coalRarity = 0.2f; //煤矿稀缺度
public float coalSize = 0.18f; //煤矿块大小
[Header("Iron")]
public float ironRarity = 0.18f; //铁矿稀缺度
public float ironSize = 0.16f; //铁矿块大小
[Header("Gold")]
public float goldRarity = 0.13f; //金矿稀缺度
public float goldSize = 0.11f; //金矿块大小
[Header("Diamond")]
public float diamondRarity = 0.12f; //钻矿稀缺度
public float diamondSize = 0.02f; //钻矿块大小
}
- 如下是
TerrainManager
的实现,先观察成员变量,其中seed
用于为柏林噪声的生成提供随机度,同一个种子可以哈希同样的噪声生成结果,至于其余的成员变量则请观察具体方法实现中的使用
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
public class TerrainManager : Manager<TerrainManager>
{
[Header("Perlin Noise Seed")]
[SerializeField] private int seed; //随机生成的随机种子
[Header("Terrain Shape")]
[SerializeField] private float terrainRelief = 0.05f; //与地形地起伏相关的柏林噪声频率
[SerializeField] private int heightMultiplier = 35; //为地形增加[0,~]内的随机厚度增量
[SerializeField] private int heightAddition = 50; //地形的基础厚度
[SerializeField] private int dirtLayerHeight = 5; //泥土层的厚度
[Header("Cave Settings")]
[SerializeField] private Texture2D caveSpreadTex; //存储生成的地图洞穴的噪声图
[SerializeField] private bool isGenerateCaves = false; //是否生成洞穴
[SerializeField] private float caveFreq = 0.08f; //与空洞出现的频率正相关的柏林噪声频率
[SerializeField] private float caveSize = 0.2f; //该值越大,越能体现caveFreq(洞穴多)
[Header("Biome Settings")]
[SerializeField] private Texture2D biomeMapTex; //生物群系的分布噪声图
public float biomeFreq = 0.5f; //群系的频率
[SerializeField] private BiomeSettings[] biomes; //设置各群系的属性
public void GenerateTerrain(int _seed)
{
//...
}
private TileType GetTileTypeByBiomeAt(int _bTypeIdx, int _x, int _y, float _height)
{
//...
}
private void GenerateAllTextures()
{
//...
}
private void DrawBiomeTextures(int _seed, BiomeType _biomeType)
{
//...
}
private void DrawPerlinNoiseTexture(int _seed, ref Texture2D _noiseTex, float _freq, float _size)
{
//...
}
}
- 如下是编辑器内将该管理器脚本挂载到空对象上的效果
3.3.2 绘制柏林噪声纹理图
其中
DrawPerlinNoiseTexture
函数用于在传入的引用纹理上存储柏林噪声纹理图,具体生成过程就是通过传入频率和种子,遍历纹理图的每一个像素点并设置rgba颜色,此处的需要生成的要素是二元性的,只有生成和不生成两个概念,故而使用Color.white
表示生成,Color.black
表示不生成比如对于洞穴的噪声纹理图,纹理图的像素尺寸对应世界的瓦片地图尺寸,每个像素点对应一个瓦片,白色像素点表示瓦片地图中对应位置的瓦片需要生成空气,其余黑色像素点位置则正常生成地形瓦片
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private void DrawPerlinNoiseTexture(int _seed, ref Texture2D _noiseTex, float _freq, float _size)
{
//按照世界地形的边长初始化噪声图形
_noiseTex = new Texture2D(TilemapManager.instance.WorldLength, TilemapManager.instance.WorldLength);
//逐像素计算噪声值
for (int x = 0; x < _noiseTex.width; x++)
{
for(int y = 0; y < _noiseTex.height; y++)
{
//依据位置(x,y)、随机种子、频率,使用柏林噪声生成一个在[0,1]间的噪声值(作为rgb的话越大越接近白色)
float _p = Mathf.PerlinNoise((x + _seed) * _freq, (y + _seed) * _freq);
//以某个[0,1]范围内的阈值对噪声值_p以划分界限,白色作为洞穴、矿石等小块空缺
if (_p <= _size)
_noiseTex.SetPixel(x, y, Color.white);
else
_noiseTex.SetPixel(x, y, Color.black);
}
}
//更新材质纹理,使更改生效
_noiseTex.Apply();
}
函数
DrawBiomeTextures
和DrawPerlinNoiseTexture
类似,但后者绘制的噪声纹理图仅有黑白两种颜色,而前者则在每次被调用时绘制不同颜色代表不同的群系,同时还调用DrawPerlinNoiseTexture
绘制控制对应群系矿物生成的噪声纹理图函数
GenerateAllTextures
负责多次调用DrawBiomeTextures
来绘制完成生态群系彩色噪声纹理,并同时为各生态群系完成矿物生成
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
private void DrawBiomeTextures(int _seed, BiomeType _biomeType)
{
#region BiomeSettings
int _bIdx = _biomeType.GetHashCode();
DrawPerlinNoiseTexture(_seed, ref biomes[_bIdx].ores.coalSpreadTex, biomes[_bIdx].ores.coalRarity, biomes[_bIdx].ores.coalSize);
DrawPerlinNoiseTexture(_seed, ref biomes[_bIdx].ores.ironSpreadTex, biomes[_bIdx].ores.ironRarity, biomes[_bIdx].ores.ironSize);
DrawPerlinNoiseTexture(_seed, ref biomes[_bIdx].ores.goldSpreadTex, biomes[_bIdx].ores.goldRarity, biomes[_bIdx].ores.goldSize);
DrawPerlinNoiseTexture(_seed, ref biomes[_bIdx].ores.diamondSpreadTex, biomes[_bIdx].ores.diamondRarity, biomes[_bIdx].ores.diamondSize);
#endregion
#region BiomeSpread
for (int x = 0; x < biomeMapTex.width; x++)
{
for (int y = 0; y < biomeMapTex.height; y++)
{
float _p = Mathf.PerlinNoise((x + _seed) * biomeFreq, (y + _seed) * biomeFreq);
if (_p <= biomes[_biomeType.GetHashCode()].biomeSize)
biomeMapTex.SetPixel(x, y, biomes[_biomeType.GetHashCode()].color);
}
}
biomeMapTex.Apply();
#endregion
}
private void GenerateAllTextures()
{
//洞穴分布噪声纹理的生成
DrawPerlinNoiseTexture(seed, ref caveSpreadTex, caveFreq, caveSize);
//采用通过seed衍生的不同种子(因为使用的是相同的频率),生成不同群系的分布噪声纹理
biomeMapTex = new Texture2D(TilemapManager.instance.WorldLength, TilemapManager.instance.WorldLength);
DrawBiomeTextures(2 * seed, BiomeType.Grass);
DrawBiomeTextures(3 * seed, BiomeType.Desert);
DrawBiomeTextures(4 * seed, BiomeType.Snow);
}
3.3.3 按照纹理图生成地图瓦片
GetTileTypeByBiomeAt
首先依据地层厚度来对瓦片地图进行y轴上的分层,然后依据不同的生态群系来分析得出对应群系在这个地层内的(x,y)
位置上应当生成的瓦片种类,然后将这个种类返回GenerateTerrain
则是负责地形生成的顶层函数,其遍历整个瓦片地图,调用GetTileTypeByBiomeAt
函数获取对应坐标位置应当生成的瓦片种类,然后使用TilemapManager
的PreSetTileAt
公共方法对该位置进行预设置
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
public void GenerateTerrain(int _seed)
{
#region NoisesGeneration
//先设置种子,后生成地图中各种元素的噪声纹理
seed = _seed;
GenerateAllTextures();
#endregion
//在实际生成地形前先初始化区块
TilemapManager.instance.InitTilemap();
#region TilesPreSetting
//取用noiseTexture坐标系的函数y=PerlinNoise(f(x))曲线的下方部分作为地形
for (int _y = 0; _y < TilemapManager.instance.WorldLength; _y++)
{
for (int _x = 0; _x < TilemapManager.instance.WorldLength; _x++)
{
//用_x对用于截取整个地图的曲线引入一个[0,1]范围的的柏林噪声值,在此基础上增加一些计算参数,以生成需要的凹凸不平的地形
float _height = Mathf.PerlinNoise((_x + seed) * terrainRelief, seed * terrainRelief) * heightMultiplier + heightAddition;
//获取当前生物群系种类,对于无群系,的使用0作为默认群系种类
int _bTypeIdx = 0;
Color _col = biomeMapTex.GetPixel(_x, _y);
for (int i = 0; i < biomes.Length; i++)
{
if (biomes[i].color == _col)
{
_bTypeIdx = i;
break;
}
}
//依据高度和群系种类,设置层级的瓦片种类
TileType _tileType = GetTileTypeByBiomeAt(_bTypeIdx, _x, _y, _height);
//控制是否生成洞穴
if (isGenerateCaves && caveSpreadTex.GetPixel(_x, _y) == Color.white)
_tileType = TileType.Air;
//最终设置该点处的瓦片
TilemapManager.instance.PreSetTileAt(_tileType, _x, _y);
}
}
#endregion
//实际根据上述预设置的瓦片类型生成瓦片地图
TilemapManager.instance.GenerateTilemap();
}
private TileType GetTileTypeByBiomeAt(int _bTypeIdx, int _x, int _y, float _height)
{
//设置岩石层(含矿石)
if (_y < _height - dirtLayerHeight)
{
//注意此处的先后优先顺序,最稀缺的最优先被生成
if (biomes[_bTypeIdx].ores.diamondSpreadTex.GetPixel(_x, _y) == Color.white)
return TileType.Diamond;
else if (biomes[_bTypeIdx].ores.goldSpreadTex.GetPixel(_x, _y) == Color.white)
return TileType.Gold;
else if (biomes[_bTypeIdx].ores.ironSpreadTex.GetPixel(_x, _y) == Color.white)
return TileType.Iron;
else if (biomes[_bTypeIdx].ores.coalSpreadTex.GetPixel(_x, _y) == Color.white)
return TileType.Coal;
else
{
if (_bTypeIdx == BiomeType.Desert.GetHashCode())
return TileType.Sand;
else if (_bTypeIdx == BiomeType.Snow.GetHashCode())
return TileType.Snow;
else
return TileType.Stone;
}
}
//设置泥土层
else if (_y < _height - 1)
{
if (_bTypeIdx == BiomeType.Desert.GetHashCode())
return TileType.Sand;
else if (_bTypeIdx == BiomeType.Snow.GetHashCode())
return TileType.Snow;
else
return TileType.Dirt;
}
//设置草地层
else if (_y < _height)
{
if (_bTypeIdx == BiomeType.Desert.GetHashCode())
return TileType.Sand;
else if (_bTypeIdx == BiomeType.Snow.GetHashCode())
return TileType.Snow;
else
return TileType.DirtGrass;
}
//设置空气层
else
return TileType.Air;
}
3.4 GameManager功能实现
- 该管理器此处仅作为此展示工程的入口点,用于生成随机数种子然后调用地形生成
1
2
3
4
5
6
7
8
9
10
11
12
public class GameManager : Manager<GameManager>
{
void Start()
{
#region TerrainGeneration
//随机生成一个种子,使得每次生成的噪声材质图像不同
int _seed = UnityEngine.Random.Range(-10000, 10000);
//根据种子生成地形
TerrainManager.instance.GenerateTerrain(_seed);
#endregion
}
}
四、开始生成
4.1 场景设置
4.2 柏林噪声纹理图生成
- 编辑器内开始游戏后,所有噪声纹理图均被生成,如下图所示依次是洞穴、群系、群系内煤矿的生成纹理
- 在这些噪声纹理的作用下,整个瓦片地图的生成效果如下所示
4.3 区块瓦片生成效果
- 所有瓦片在
TilemapManager
的控制下按照区块进行生成
- 区块中包含着相同数量的瓦片