只羊的博客

记录游戏开发历程

策略游戏地图制作(一)-Terrain生成-上

一个策略游戏中最重要的部分就是它的地图,地图包含游戏中重要的战略信息,玩家的几乎所有操作都需要与地图的交互进行。而且,身为策略游戏玩家,表现效果丰富的策略地图,也能很好地满足大家指点江山、统筹全局(机枪往前移五米)的愿望。

以下举例一些策略游戏内的地图

image
图:《欧陆风云4》游戏地图
image
图:《文明6》游戏地图
image
图:《全战三国》游戏地图

本系列的开发日志,记录本人制作一个适用于策略游戏开发的地图包,最终目的是开发一个即插即用的地图包,适用于实现策略游戏的地图构建。

1.编辑器界面搭建

在开始我们的地图开发之前,需要先做一些基建上的准备,比如编辑器。

众所周知,Editor内的工作是面向开发人员的,更要求易用性,同时因为并非GamePlay向,对性能效率的要求往往会低些(可以堆垃圾代码了)。如果你认为直接在Runtime下生成地图内容更方便,也可以跳过本节。但考虑到地图开发是一个很漫长的过程,Editor的辅助也是很重要的,毕竟可视化界面一直是最直观的。
Odin编辑器是一个unity编辑器扩展,功能非常强大。本系列的地图开发过程中,使用odin编辑器做Unity内的地图编辑器。可以从Unity商店里的Odin编辑器中获取到资源:
https://assetstore.unity.com/packages/tools/utilities/odin-inspector-and-serializer-89041

以下对编辑器做个简单的封装。
继承OdinMenuEditorWindow,继承该类后可以创建编辑器窗口,具备分页效果(示意图可以在后面看到)

1
2
3
4
5
/// <summary>
/// 编辑器的 Root,用于初始化编辑器配置
/// </summary>
public class RootMapEditor : OdinMenuEditorWindow {
}

建立编辑器的入口,之后可以在Unity内上方的ToolBar部分的GameMap项打开

1
2
3
4
5
6
[MenuItem("GameMap/OpenMapEditor")]
private static void OpenMapEditor() {
RootMapEditor window = GetWindow<RootMapEditor>("MapEditor");
window.minSize = new Vector2(720, 320);
window.Show();
}

做完上面的操作后,通过GameMap-OpenMapEditor打开的编辑器是一片空白,只有外壳,需要再加上各种子页面。

所以我们添加一个子页面的基类:

1
2
3
public abstract class BaseMapEditor: ScriptableObject{
}

之后的开发中继承实现该类即可添加子页面内容,例如:

1
2
3
4
5
6
7
public class TerrainEditor : BaseMapEditor{
}
public class HeightMapEditor : BaseMapEditor{
}
public class LandformEditor : BaseMapEditor{
}

同时添加用于初始化地图编辑器的方法,在这个方法中创建各种子页面,并且加入到顶部索引,现在需要通过顶部的GameMap – InitMapEditor来初始化编辑器配置,再打开。(下方代码中的各种文件路径需要自行替换)

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
[MenuItem("GameMap/InitMapEditor")]
private static void InitMapEditor() {
if (!AssetDatabase.IsValidFolder(MapStoreEnum.WarGameMapRootPath)) {
string folderName = AssetsUtility.GetInstance().GetFolderFromPath(MapStoreEnum.WarGameMapRootPath);
AssetDatabase.CreateFolder(MapStoreEnum.WarGameMapRootPath, folderName);
Debug.Log(string.Format("create file folder : {0}", MapStoreEnum.MapWindowPath));
}
string terrainPath = MapStoreEnum.MapWindowPath + "/" + MapEditorClass.TerrainClass;

if (!AssetDatabase.IsValidFolder(terrainPath)) {
AssetDatabase.CreateFolder(MapStoreEnum.MapWindowPath, MapEditorClass.TerrainClass);
}
// get HashSet(window objs name) in folders
HashSet<string> terrainFileNames = GetFileNames(terrainPath);
// create window scriptableObject
if (!terrainFileNames.Contains(MapEditorEnum.TerrainEditor)) {
CreateWindowObj<TerrainEditor>(MapEditorClass.TerrainClass);
}
if (!terrainFileNames.Contains(MapEditorEnum.LandformEditor)) {
CreateWindowObj<LandformEditor>(MapEditorClass.TerrainClass);
}
if (!terrainFileNames.Contains(MapEditorEnum.HeightMapEditor)) {
CreateWindowObj<HeightMapEditor>(MapEditorClass.TerrainClass);
}
if (!terrainFileNames.Contains(MapEditorEnum.HexMapEditor)) {
CreateWindowObj<HexmapEditor>(MapEditorClass.TerrainClass);
}
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log("成功初始化 MapEditor, 现在可以打开编辑器!");
}

两个辅助方法,用于获取完整的文件名称、创建子页面物体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static HashSet<string> GetFileNames(string folderPath) {
string[] guids = AssetDatabase.FindAssets("", new[] { folderPath });
HashSet<string> terrainFileNames = new HashSet<string>();
foreach (var guid in guids) {
string path = AssetDatabase.GUIDToAssetPath(guid);
string fileName = System.IO.Path.GetFileNameWithoutExtension(path);
terrainFileNames.Add(fileName);
}
return terrainFileNames;
}

static void CreateWindowObj<MapEditor>(string windowClass) where MapEditor : BaseMapEditor {
string rootWindowPath = MapStoreEnum.MapWindowPath + "/" + windowClass + "/";
MapEditor asset = ScriptableObject.CreateInstance<MapEditor>();
asset.name = asset.EditorName + ".asset";
string path = rootWindowPath + asset.name;
Debug.Log("create path: " + path);
AssetDatabase.CreateAsset(asset, path);
}

添加上各种功能后的编辑器效果,如下图所示(下图是填充了部分内容的,如果你没有在Window内添加内容,那么依然会显示一片空白)。

    image    
图:填充内容后的MapEditor

看起来还挺完整的,那么第一步就成功迈出了

如果你依然对Odin编辑器的其他部分感兴趣,可以看看以下的教程/文档:
https://github.com/su9257/Odin-Inspector-Chinese-Tutorial
https://odininspector.com/documentation

2.Terrain数据组织

在Unity中有内置的Terrain系统,它是Unity官方提供的一套地形系统,可以直接在Inspector中刷地形。支持URP和HDRP,源码在C++层中,意味着个人开发者无法定制Terrain的细节。与Mesh的渲染不同,Terrain新开了一个Pass去处理,做了很多优化与适配。Terrain在移动端的性能表现并不好,在移动端制作地形依然偏向使用Mesh的方案,Mesh是适用性更广的工作流,突出一个思想简单,所以此处会使用Mesh实现地形。

    image    
图:使用Unity内置的Terrain系统

2.1.地块分块处理

2.1.1.为什么要分块

在大地图游戏中,对地图的分块处理是基操。在超大规模的地图里,通常将地图数据分为一个个Chunk,根据玩家的位置加载周围的Chunk。对地图分块,利于我们进行动态加载、剔除等操作,实现内存等方面的优化,更好管理地图数据。

以《艾尔登法环》为例,法环的地图总面积约为100平方千米,游戏中划分的最小网格单元为256m×256m,也会有更大一些的比如1024m×1024m的网格划分,这样可以对游戏内地图的总体网格数进行适当性的控制,同时保持远景的加载质量。

    image    
图:《艾尔登法环》在2022年GDC中的汇报

而在策略游戏中,因为策略游戏一般是2.5D制作,地图往往是平面的,相比法环之类的大制作,在地图上考虑的东西要更少些,比如不需要考虑纵深结构。

我们在此简单地将地图块,划分成两层:世界地形会由多个Cluster组成,一个Cluster内置多个Tile,如下图所示:

    image    
图:地形分块的简单模型

2.1.2.地块分块模型

在此简单阐述下地图模块中Terrain的分块模型:
-TerrainCluster:对应一个TIF文件,即一个现实中的地块
-TerrainTile:在Cluster的基础上进一步的划分,可以用于后续实现更细致的LOD切换

以下是两个地块单位的关键部分代码(注:以下关于LOD的字段可以先添加,后续建LOD的时候再使用):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class TerrainCluster {
public int idxX { get; private set; }
public int idxY { get; private set; }
public int longitude { get; private set; }
public int latitude { get; private set; }
Vector3Int terrainClusterSize;
int tileSize;
TDList<TerrainTile> terrainTileList;
}
public class TerrainTile {
public int tileIdxX { get; private set; }
public int tileIdxY { get; private set; }
public int longitude { get; private set; }
public int latitude { get; private set; }
public int curLODLevel { get; private set; }
Vector3 clusterStartPoint;
int tileSize;
int[] LODLevels;
MeshData[] LODMeshes;
public Vector3 tileCenterPos { get; private set; }
private MeshFilter meshFilter;
}

2.2.建立地块类

在完成上述的地块数据建模之后,我们可以开始构建地形Mesh了,下面代码用于建立TerrainCluster,缺失的字段直接补充即可

注:以下代码是较早的版本,查看最新版本代码请到:
https://github.com/huayuxingtguiyujing/WarGameMap

注:建议直接跳过本节的TerrainCluster与TerrainTile构建,到下一节的地形Mesh构建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void InitHeightCons(Vector3Int terrainSize, Vector3Int clusterSize, int tileSize, int LODLevel, List<HeightDataModel> heightDataModels) {
this.terrainSize = terrainSize;
this.clusterSize = clusterSize;
this.tileSize = tileSize;
this.LODLevel = LODLevel;
clusterWidth = terrainSize.x;
clusterHeight = terrainSize.z;
clusterList = new TDList<TerrainCluster>(clusterWidth, clusterHeight);
for (int i = 0; i < clusterHeight; i++) {
for(int j = 0; j < clusterWidth; j++) {
GameObject clusterGo = CreateHeightCluster(i, j);
clusterList[i, j].InitTerrainCluster(……);
}
}
}

上面的代码中,创建地形Mesh数据会分配大量内存,如果地形过大可能会炸,同时因为过程是同步的,如果创建的地块数量太多,会卡很久。所以这里建议使用懒初始化去构建地形,方法很简单,在需要这块地块的时候再创建地块的Mesh即可,此处暂时不记。

TerrainCluster中的InitTerrainCluster初始化方法:

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 void InitTerrainCluster(int idxX, int idxY, int longitude, int latitude, Vector3Int terrainClusterSize, int tileSize, int LODLevel, GameObject clusterGo) {
this.idxX = idxX;
this.idxY = idxY;
this.longitude = longitude;
this.latitude = latitude;
this.terrainClusterSize = terrainClusterSize;
this.tileSize = tileSize;
this.LODLevel = LODLevel;
this.clusterGo = clusterGo;
Vector3 startPoint = new Vector3(terrainClusterSize.x * idxX, 0, terrainClusterSize.z * idxY);
InitTerrainCluster(startPoint);
}
private void InitTerrainCluster(Vector3 clusterStartPoint) {
int tileNumPerLine = terrainClusterSize.x / tileSize;
terrainTileList = new TDList<TerrainTile>(tileNumPerLine, tileNumPerLine);
for (int i = 0; i < tileNumPerLine; i++) {
for (int j = 0; j < tileNumPerLine; j++) {
MeshFilter meshFilter = CreateHeightTile(i, j);
terrainTileList[i, j].InitTileMeshData(i, j, longitude, latitude, clusterStartPoint, meshFilter, lodLevels);
}
}
// generate mesh data
int vertexNumFix = (int)Mathf.Pow(2, LODLevel );
for (int i = 0; i < tileNumPerLine; i++) {
for (int j = 0; j < tileNumPerLine; j++) {
terrainTileList[i, j]. InitTerrainTile( tileSize, vertexNumFix, terrainClusterSize);
}
}
curLODLevel--;
}

CreateHeightTile:用于创建一个游戏物体,代表一个Cluster,可以放置Mesh渲染组件,获取到MeshFilter作为Cluster的字段

1
2
3
4
5
6
7
8
private MeshFilter CreateHeightTile(int idxX, int idxY) {
GameObject tileGo = new GameObject();
tileGo.transform.parent = clusterGo.transform;
tileGo.name = string.Format("heightTile_{0}_{1}", idxX, idxY);
MeshFilter meshFilter = tileGo.AddComponent<MeshFilter>();
tileGo.AddComponent<MeshRenderer>();
return meshFilter;
}

TerrainTile中的InitTileMeshData初始化方法:

1
2
3
4
5
6
7
8
9
10
11
public void InitTileMeshData(int idxX, int idxY, int longitude, int latitude, Vector3 startPoint, MeshFilter meshFilter,int[] lODLevels) {
this.clusterStartPoint = startPoint;
this.meshFilter = meshFilter;
tileIdxX = idxX;
tileIdxY = idxY;
this.longitude = longitude;
this.latitude = latitude;
curLODLevel = -1; // init as -1
LODLevels = lODLevels;
LODMeshes = new MeshData[lODLevels.Length];
}

2.3.地形Mesh构建

2.3.1.MeshData数据类

最后是关键的构建Mesh的部分代码,我们为地形Tile专门写一个Mesh数据类,以下是它需要包含的字段

1
2
3
4
5
6
7
8
9
10
11
12
class MeshData {
Vector3[] vertexs = new Vector3[1];
Vector3[] fixedVertexs = new Vector3[1]; // use to fix LOD seam
Vector3[] outofMeshVertexs = new Vector3[1];
Vector3[] fixedOutMeshVertexs = new Vector3[1]; // use to fix LOD seam
int[,] vertexIndiceMap = new int[1, 1]; // map the (x, y) to index in vertexs/outofMeshVertexs
Vector3[] normals = new Vector3[1];
Vector2[] uvs = new Vector2[1];
Color[] colors = new Color[1];
int[] triangles = new int[1];
int[] outOfMeshTriangles = new int[1];
}

构建一个Mesh最基本的要素就是:
-Vertexs:顶点数组,最基本的要素
-Normals:法线数组,用于shader操作,实现正确的地图阴影和光照
-Uvs:纹理坐标,用于后续实现地形纹理在地形上的映射
-Colors:顶点颜色数组,如果shader不使用color的话可以不做设置,此处作为占位符

所以MeshData包含了上述字段,而值得注意的是这里使用了outofMeshVertexs数组来记录地形Mesh外的数据,这是因为每个地块边缘的顶点都需要额外地计算外面一层的片元,否则边缘顶点的法线计算会出现问题(因为只计算了本地块的三角型对法线的贡献),不同地块的的衔接处会出现明显的界限。

如下图所示,边缘虚线的顶点存储在outofMeshVertex中,不会用于构建Tile地块,也会存储额外的边缘三角型信息

    image    
图:存储额外的顶点用于构建Mesh

2.3.2.添加顶点

往MeshData中添加顶点和片元索引的方法。会根据一个外部的传入参数vertexIndex来决定要添加的顶点是Tile内的,还是在OutofMesh的,外部参数在后面的构建Mesh部分可见。三角型片元一样需要判断该片元是内部的还是外部的

此处参考了一个开源的游戏地形构建方案,可以结合此链接学习:
https://github.com/SebLague/Procedural-Landmass-Generation

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
public void AddVertex(Vector3 vertexPosition, Vector2 uv, int vertIndex) {
if (vertIndex < 0) {
outofMeshVertexs[- vertIndex - 1] = vertexPosition;
} else {
vertexs[vertIndex] = vertexPosition;
uvs[vertIndex] = uv;
normals[vertIndex] = new Vector3(0, 1, 0);
colors[vertIndex] = GetColorByHeight(vertexPosition.y);
}
}
public void AddTriangle(int a, int b, int c, int i = 0, int j = 0) {
if (a < 0 || b < 0 || c < 0) {
if (outOfMeshTriangleIndex + 1 > outOfMeshTriangles.Length - 1) {
Debug.LogError(string.Format("triangle idx : {0}, {1} !", i, j));
Debug.LogError(string.Format("out of bound! cur idx : {0}, cur a : {1}, cur b : {2}, cur c : {3}, length : {4}", outOfMeshTriangleIndex, a, b, c, outOfMeshTriangles.Length));
}
outOfMeshTriangles[outOfMeshTriangleIndex] = a;
outOfMeshTriangles[outOfMeshTriangleIndex + 1] = b;
outOfMeshTriangles[outOfMeshTriangleIndex + 2] = c;
outOfMeshTriangleIndex += 3;
} else {
triangles[triangleIndex] = a;
triangles[triangleIndex + 1] = b;
triangles[triangleIndex + 2] = c;
triangleIndex += 3;
}
}

2.3.3.构建Mesh

在Tile中构建Mesh逻辑如下,代码放置到TerrainTile中。根据传入的LOD级别(下一节会讲)、Tile的size,每个Tile地形一行应该有的Vertex数目,来共同构建一个TileMesh

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
public void SetMeshData(int curLODLevel, int tileSize, int vertexNumFix, Vector3Int terrainClusterSize) {
this.tileSize = tileSize;
// caculate tile's start point
float startX = tileIdxX * tileSize;
float startZ = tileIdxY * tileSize;
Vector3 startPoint = new Vector3(startX, 0, startZ) + this.clusterStartPoint; //
tileCenterPos = startPoint + new Vector3(tileSize / 2, 0, tileSize / 2);
int gridNumPerLine = tileSize / vertexNumFix;
int gridSize = tileSize / gridNumPerLine;
int vertexPerLine = tileSize / vertexNumFix + 1;
int gridNumPerLineFixed = gridNumPerLine + 2;
int vertexPerLineFixed = vertexPerLine + 2;
LODMeshes[curLODLevel] = new MeshData();
MeshData meshData = LODMeshes[curLODLevel];
meshData.InitMeshData(gridNumPerLine, gridNumPerLineFixed, vertexPerLine, vertexPerLineFixed);
int curInVertIdx = 0;
int curOutVertIdx = -1;
Vector3 offsetInMeshVert = new Vector3(gridSize, 0, gridSize);
for(int i = 0; i < vertexPerLineFixed; i++) {
for (int j = 0; j < vertexPerLineFixed; j++) {
bool isVertOutOfMesh = (i == 0) || (i == vertexPerLineFixed - 1) || (j == 0) || (j == vertexPerLineFixed - 1);
Vector3 vert = new Vector3(gridSize * i, 0, gridSize * j) + startPoint - offsetInMeshVert;
//float height = 0;
vert.y = height;
Vector2 uv = new Vector2(vert.x / terrainClusterSize.x, vert.z / terrainClusterSize.z);
if (isVertOutOfMesh) {
meshData.AddVertex(vert, uv, curOutVertIdx);
meshData.SetIndiceInMap(i, j, curOutVertIdx);
//vertexIndiceMap[i, j] = curOutVertIdx;
curOutVertIdx --;
} else {
meshData.AddVertex(vert, uv, curInVertIdx);
meshData.SetIndiceInMap(i, j, curInVertIdx);
//vertexIndiceMap[i, j] = curInVertIdx;
curInVertIdx ++;
}
}
}

int curGridIdx = 0;
for (int i = 0; i < gridNumPerLineFixed; i++) {
for (int j = 0; j < gridNumPerLineFixed; j++) {
// i, j 是当前遍历到的 grid 的 index
int cur_w = curGridIdx % gridNumPerLineFixed;
int cur_h = curGridIdx / gridNumPerLineFixed;
int next_w = cur_w + 1;
int next_h = cur_h + 1;
int a = meshData.GetIndiceInMap(cur_w, cur_h);
int b = meshData.GetIndiceInMap(cur_w, next_h);
int c = meshData.GetIndiceInMap(next_w, next_h);
int d = meshData.GetIndiceInMap(next_w, cur_h);
meshData.AddTriangle(a, b, c, i, j);
meshData.AddTriangle(a, c, d, i, j);
curGridIdx++;
}
}
meshData.RecaculateNormal();
}

2.3.4.计算法线

先前一直在强调,要记录OutOfMesh即Tile地块外部的,这一操作就是为了正确地计算法线,计算法线的代码如下:
此处代码源自(伟大的作者!):
https://github.com/SebLague/Procedural-Landmass-Generation/blob/master/Proc%20Gen%20E21/Assets/Scripts/MeshGenerator.cs

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
public void RecaculateNormal() {
int triangleCount = triangles.Length / 3;
for (int i = 0; i < triangleCount; i++) {
int normalTriangleIndex = i * 3;
int vertexIndexA = triangles[normalTriangleIndex];
int vertexIndexB = triangles[normalTriangleIndex + 1];
int vertexIndexC = triangles[normalTriangleIndex + 2];
Vector3 triangleNormal = SurfaceNormalFromIndices(vertexIndexA, vertexIndexB, vertexIndexC);
//Vector3 triangleNormal = SurfaceNormalFromIndices_Fixed(vertexIndexA, vertexIndexB, vertexIndexC);
normals[vertexIndexA] += triangleNormal;
normals[vertexIndexB] += triangleNormal;
normals[vertexIndexC] += triangleNormal;
}
// border triangle, caculate their value to normal
int borderTriangleCount = outOfMeshTriangles.Length / 3;
for (int i = 0; i < borderTriangleCount; i++) {
int normalTriangleIndex = i * 3;
int vertexIndexA = outOfMeshTriangles[normalTriangleIndex];
int vertexIndexB = outOfMeshTriangles[normalTriangleIndex + 1];
int vertexIndexC = outOfMeshTriangles[normalTriangleIndex + 2];
Vector3 triangleNormal = SurfaceNormalFromIndices(vertexIndexA, vertexIndexB, vertexIndexC);
//Vector3 triangleNormal = SurfaceNormalFromIndices_Fixed(vertexIndexA, vertexIndexB, vertexIndexC);
if (vertexIndexA >= 0) {
normals[vertexIndexA] += triangleNormal;
}
if (vertexIndexB >= 0) {
normals[vertexIndexB] += triangleNormal;
}
if (vertexIndexC >= 0) {
normals[vertexIndexC] += triangleNormal;
}
}
for (int i = 0; i < normals.Length; i++) {
normals[i].Normalize();
}
}

在联通了TerrainCluster与TerrainTile的执行逻辑后,通过odin编辑器或者其他途径生成每个Tile的Mesh,此处的编辑器代码暂不提供,因为关键的逻辑已经写完,如何调用这些方法都是次要的了;

此时我们已经构建了一个基础地形,如果上述步骤全部通过,它在scene中会是这样的平面:

    image    
图:生成的地形Mesh

还远远不够!