前言

使用Unity来制作一个类似Minecraft的游戏场景。这个系列的文章将记录我制作这个游戏场景的过程。

我们首先将建立一个体素引擎,用于生成游戏中的区块(chunk)

准备工作

首先,我们需要准备一些工具和资源。

在这个系列的文章中,我们将使用Unity 2020.3.48f1c1版本,以及一些免费的资源。

贴图资源Voxel Pack · Kenney的原始地址点此下载贴图包

导入贴图包

点击

资源->导入包->自定义包

选择下载的unity资源包进行导入

编写代码

首先在我们Untiy的Assets文件夹下创建一个**_Scripts**文件夹,用于存储我们的所有代码。

1
2
3
4
Assets
-- _Scripts //代码
-- _Textures //贴图
-- Scense //场景

定义方块类型

在**_Scripts下创建BlockType.cs**脚本,用于定义游戏中的方块类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

//定义了游戏中使用到的不同方块类型。
public enum BlockType
{
Nothing, // 表示无方块或空方块
Air, // 表示空气方块,没有物理碰撞和渲染的透明方块。
Grass_Dirt, // 表示带草皮的泥土方块
Dirt, // 表示泥土方块
Grass_Stone, // 表示带草皮的石头方块
Stone, // 表示普通的石头方块
TreeTrunk, // 表示树干方块
TreeLeafesTransparent, // 表示透明的树叶方块,用于生成树木的叶子部分,允许部分光线透过
TreeLeafsSolid, // 表示不透明的树叶方块
Water, // 表示水方块
Sand // 表示沙子方块
}

虽然定义了这么多类型的方块,但是暂时不会全部用到。


定义区块基础信息

在**_Scripts下创建ChunkData.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
37
38
39
// 用于管理和存储一个区块(Chunk)中的所有方块数据,以及区块的相关信息。
public class ChunkData
{
// 存储区块中的所有方块类型,每个元素对应一个方块。
public BlockType[] blocks;

// 区块的水平大小(X和Z方向),是一个正方形的尺寸(16*16)。
public int chunkSize = 16;

// 区块的垂直高度(Y方向),决定了区块的高度范围。
public int chunkHeight = 100;

// 对应的世界对象引用,用于访问世界相关的信息或功能,World目前还没创建。
public World worldReference;

// 区块在整个世界中的位置,使用三维整数向量表示。
public Vector3Int worldPosition;

// 标记区块是否被玩家修改过,用于判断是否保存区块信息,这在卸载区块的时候非常有用。
public bool modifiedByThePlayer = false;

// 构造函数,用于初始化一个新的ChunkData对象,并为其分配方块数组。
//
// chunkSize区块的水平大小
// chunkHeight区块的垂直高度
// world引用的世界对象
// worldPosition区块在世界中的位置
public ChunkData(int chunkSize, int chunkHeight, World world, Vector3Int worldPosition)
{
// 初始化区块的大小、高度和位置
this.chunkHeight = chunkHeight;
this.chunkSize = chunkSize;
this.worldReference = world;
this.worldPosition = worldPosition;

// 为该区块分配存储方块类型的数组,大小为chunkSize * chunkHeight * chunkSize
blocks = new BlockType[chunkSize * chunkHeight * chunkSize];
}
}

写完代码我们发现World并没有创建,为了暂时解决报错,我们在_Scripts下创建World.cs脚本。

这样报错就解决了。


MeshData数据类

在**_Scripts下创建MeshData.cs**脚本用于存储和管理一个区块的网格数据。

包括用于渲染的顶点、三角形和UV坐标,以及用于物理碰撞的独立碰撞体网格数据。还有水网格的独立管理。

在游戏中,Mesh 是所有 3D 对象的基础。通过调整 Mesh 的顶点和三角形,可以创建各种形状的物体。Mesh 数据可以通过 Unity 的渲染系统显示在屏幕上,并且可以通过物理系统进行碰撞检测。

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
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// 用于存储和管理一个区块的网格数据,包括顶点、三角形和UV坐标,
// 以及用于碰撞检测的独立网格数据。
public class MeshData
{
// 用于存储网格的顶点信息
public List<Vector3> vertices = new List<Vector3>();

// 用于存储网格的三角形索引信息,每三个索引构成一个三角形
public List<int> triangles = new List<int>();

// 用于存储网格的UV坐标,用于纹理映射
public List<Vector2> uv = new List<Vector2>();

// 用于存储网格的碰撞体顶点信息
public List<Vector3> colliderVertices = new List<Vector3>();

// 用于存储网格的碰撞体三角形索引信息
public List<int> colliderTriangles = new List<int>();

// 用于存储水网格的数据。
public MeshData waterMesh;

// 标识是否是主网格数据
private bool isMainMesh = true;

// 构造函数,用于初始化MeshData对象,如果是主网格,会同时初始化水网格数据。
public MeshData(bool isMainMesh)
{
this.isMainMesh = isMainMesh;

// 如果是主网格,初始化水网格数据
if (isMainMesh)
{
waterMesh = new MeshData(false);
}
}

// 添加一个顶点到网格数据,并根据需要将顶点添加到碰撞体数据中。
// vertex 要添加的顶点坐标
// vertexGeneratesCollider 是否为碰撞体生成顶点
public void AddVertex(Vector3 vertex, bool vertexGeneratesCollider)
{
vertices.Add(vertex);

// 如果需要生成碰撞体,将顶点添加到碰撞体顶点列表中
if (vertexGeneratesCollider)
{
colliderVertices.Add(vertex);
}
}

// 添加一个四边形(一个面)的三角形索引到网格数据,并根据需要将三角形添加到碰撞体数据中。
// quadGeneratesCollider 是否为碰撞体生成三角形
public void AddQuadTriangles(bool quadGeneratesCollider)
{
// 添加四边形的两个三角形到主网格
// 第一个三角形:顶点 0 -> 1 -> 2
triangles.Add(vertices.Count - 4);//4-4
triangles.Add(vertices.Count - 3);//4-3
triangles.Add(vertices.Count - 2);//4-2
// 第二个三角形:顶点 0 -> 2 -> 3
triangles.Add(vertices.Count - 4);
triangles.Add(vertices.Count - 2);
triangles.Add(vertices.Count - 1);

// 如果需要生成碰撞体,将四边形的两个三角形添加到碰撞体数据中
if (quadGeneratesCollider)
{
colliderTriangles.Add(colliderVertices.Count - 4);
colliderTriangles.Add(colliderVertices.Count - 3);
colliderTriangles.Add(colliderVertices.Count - 2);

colliderTriangles.Add(colliderVertices.Count - 4);
colliderTriangles.Add(colliderVertices.Count - 2);
colliderTriangles.Add(colliderVertices.Count - 1);
}
}
}

AddQuadTriangles方法所构成的面:

此外,我们的三角形是顺时针绘制出的,这将决定面的法线方向。法线方向又将决定绘制的面,从那个方向是可见的。

所以说绘制的顺序很重要。

Mash是什么

我们在Unity场景中,所有能被渲染出来的物体都会带有网格Mash。

从概念上讲,网格是图形硬件用来绘制复杂内容的构造。

它至少包含一组定义3D空间中点的顶点,以及一组连接这些点的三角形,实际上还包含法线、顶点颜色纹理坐标(uv)等信息,这些三角形构成了网格所代表的任何表面。

所以创建一个Mesh,就是new一个Mesh,给它塞入顶点坐标、UV坐标和三角形序列即可。再复杂的网格也可以通过这些步骤创建出来~

ChunkRenderer类

接下来我们将区块的数据转化为网格数据,然后渲染成3D对象。

_Scripts下创建ChunkRenderer.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
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
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine;

// 该脚本需要附加在包含 MeshFilter、MeshRenderer 和 MeshCollider 组件的 GameObject 上
[RequireComponent(typeof(MeshFilter))]
[RequireComponent(typeof(MeshRenderer))]
[RequireComponent(typeof(MeshCollider))]
public class ChunkRenderer : MonoBehaviour
{
// 私有字段,用于存储对 MeshFilter、MeshCollider 组件和 Mesh 的引用
MeshFilter meshFilter;
MeshCollider meshCollider;
Mesh mesh;

// 控制是否在编辑器中显示Gizmo
public bool showGizmo = false;

// 公开的属性,用于获取当前区块的数据
public ChunkData ChunkData { get; private set; }

// 反映区块是否被玩家修改过
public bool ModifiedByThePlayer
{
get
{
return ChunkData.modifiedByThePlayer;
}
set
{
ChunkData.modifiedByThePlayer = value;
}
}

// 在脚本被激活时调用,初始化 MeshFilter、MeshCollider 和 Mesh
private void Awake()
{
meshFilter = GetComponent<MeshFilter>();
meshCollider = GetComponent<MeshCollider>();
mesh = meshFilter.mesh;
}

// 初始化区块数据
public void InitializeChunk(ChunkData data)
{
this.ChunkData = data;
}

// 渲染区块的网格数据
private void RenderMesh(MeshData meshData)
{
// 清空当前网格数据
mesh.Clear();

// 设置子网格数量为2,用于区分普通网格和水面网格
mesh.subMeshCount = 2;

// 将普通网格和水面网格的顶点数据合并后赋值给 mesh
mesh.vertices = meshData.vertices.Concat(meshData.waterMesh.vertices).ToArray();

// 设置第一个子网格的三角形数据
mesh.SetTriangles(meshData.triangles.ToArray(), 0);

// 设置第二个子网格(水面网格)的三角形数据,顶点索引需要加上普通网格的顶点数量
mesh.SetTriangles(meshData.waterMesh.triangles.Select(val => val + meshData.vertices.Count).ToArray(), 1);

// 合并普通网格和水面网格的UV数据并赋值给 mesh
mesh.uv = meshData.uv.Concat(meshData.waterMesh.uv).ToArray();

// 重新计算法线,使光照效果正确
mesh.RecalculateNormals();

// 清空当前的碰撞网格,并创建一个新的碰撞网格
meshCollider.sharedMesh = null;
Mesh collisionMesh = new Mesh();

// 设置碰撞网格的顶点和三角形数据
collisionMesh.vertices = meshData.colliderVertices.ToArray();
collisionMesh.triangles = meshData.colliderTriangles.ToArray();

// 重新计算法线,确保碰撞检测准确
collisionMesh.RecalculateNormals();

// 将新创建的碰撞网格赋值给 meshCollider
meshCollider.sharedMesh = collisionMesh;
}

// 更新区块的渲染,获取区块的网格数据并渲染
public void UpdateChunk()
{
RenderMesh(Chunk.GetChunkMeshData(ChunkData));
}

// 使用传入的 MeshData 更新区块的渲染
public void UpdateChunk(MeshData data)
{
RenderMesh(data);
}

#if UNITY_EDITOR
// 在编辑器中绘制Gizmo,用于可视化区块边界
private void OnDrawGizmos()
{
if (showGizmo)
{
// 仅在游戏运行时绘制Gizmo,并且确保区块数据不为空
if (Application.isPlaying && ChunkData != null)
{
// 如果当前GameObject被选中,Gizmo颜色为绿色,否则为紫色
if (Selection.activeObject == gameObject)
Gizmos.color = new Color(0, 1, 0, 0.4f); // 绿色
else
Gizmos.color = new Color(1, 0, 1, 0.4f); // 紫色

// 绘制一个表示区块边界的立方体
Gizmos.DrawCube(
transform.position + new Vector3(ChunkData.chunkSize / 2f, ChunkData.chunkHeight / 2f, ChunkData.chunkSize / 2f),
new Vector3(ChunkData.chunkSize, ChunkData.chunkHeight, ChunkData.chunkSize)
);
}
}
}
#endif
}

在 Unity 中,MeshFilterMeshRendererMeshCollider 是与 3D 对象的渲染和物理交互密切相关的组件。

MeshFilter 提供了对象的几何数据。

MeshRenderer 负责将几何数据渲染到屏幕上,使其在游戏中可见。

MeshCollider 使用几何数据来进行物理碰撞检测,使对象能够与其他物理对象互动。

OK,我们发现Chunk目前还在报红

我们可以在_Scripts下创建Chunk.cs,并添加一下内容,暂时解决报错。

1
2
3
4
5
6
7
8
9
10
11
using System;
using System.Collections.Generic;
using UnityEngine;

public static class Chunk
{
internal static MeshData GetChunkMeshData(ChunkData chunkData)
{
throw new NotImplementedException();
}
}

Chunk

检查一下我们的代码,应该有以下几个文件:

现在新建一个名为Chunk的空对象,并将ChunkRenderer.cs附加到该对象上

如图,依次添加材质,并将Chunk设置为预设体。

Chunk类
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
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
using System;
using UnityEngine;

public static class Chunk
{
// 遍历 ChunkData 中的每个块,并对每个块执行指定的操作
public static void LoopThroughTheBlocks(ChunkData chunkData, Action<int, int, int> actionToPerform)
{
// 遍历 chunkData.blocks 数组的每个索引
for (int index = 0; index < chunkData.blocks.Length; index++)
{
// 根据索引获取块在 Chunk 中的三维坐标位置
var position = GetPostitionFromIndex(chunkData, index);
// 对该坐标位置执行传入的操作 actionToPerform
actionToPerform(position.x, position.y, position.z);
}
}

// 根据索引计算块在 Chunk 中的三维坐标位置
private static Vector3Int GetPostitionFromIndex(ChunkData chunkData, int index)
{
// 计算 x 坐标
int x = index % chunkData.chunkSize;
// 计算 y 坐标
int y = (index / chunkData.chunkSize) % chunkData.chunkHeight;
// 计算 z 坐标
int z = index / (chunkData.chunkSize * chunkData.chunkHeight);
// 返回三维坐标
return new Vector3Int(x, y, z);
}

// 检查给定的轴坐标是否在 Chunk 的范围内
private static bool InRange(ChunkData chunkData, int axisCoordinate)
{
// 如果轴坐标在范围外,返回 false
if (axisCoordinate < 0 || axisCoordinate >= chunkData.chunkSize)
return false;

// 在范围内,返回 true
return true;
}

// 检查给定的 y 坐标是否在 Chunk 高度范围内
private static bool InRangeHeight(ChunkData chunkData, int ycoordinate)
{
// 如果 y 坐标在范围外,返回 false
if (ycoordinate < 0 || ycoordinate >= chunkData.chunkHeight)
return false;

// 在范围内,返回 true
return true;
}

// 根据 Chunk 内部坐标获取对应的块类型
public static BlockType GetBlockFromChunkCoordinates(ChunkData chunkData, Vector3Int chunkCoordinates)
{
return GetBlockFromChunkCoordinates(chunkData, chunkCoordinates.x, chunkCoordinates.y, chunkCoordinates.z);
}

// 根据 x, y, z 坐标获取对应的块类型
public static BlockType GetBlockFromChunkCoordinates(ChunkData chunkData, int x, int y, int z)
{
// 如果坐标在 Chunk 范围内
if (InRange(chunkData, x) && InRangeHeight(chunkData, y) && InRange(chunkData, z))
{
// 计算索引并返回块类型
int index = GetIndexFromPosition(chunkData, x, y, z);
return chunkData.blocks[index];
}

// 如果不在范围内,抛出异常
throw new Exception("");
}

// 在 Chunk 内部坐标设置块类型
public static void SetBlock(ChunkData chunkData, Vector3Int localPosition, BlockType block)
{
// 如果坐标在 Chunk 范围内
if (InRange(chunkData, localPosition.x) && InRangeHeight(chunkData, localPosition.y) && InRange(chunkData, localPosition.z))
{
// 计算索引并设置块类型
int index = GetIndexFromPosition(chunkData, localPosition.x, localPosition.y, localPosition.z);
chunkData.blocks[index] = block;
}
else
{
// 如果不在范围内,抛出异常
throw new Exception("Need to ask World for appropriate chunk");
}
}

// 根据 x, y, z 坐标计算块在 Chunk 中的索引
private static int GetIndexFromPosition(ChunkData chunkData, int x, int y, int z)
{
// 计算索引值
return x + chunkData.chunkSize * y + chunkData.chunkSize * chunkData.chunkHeight * z;
}

// 将世界坐标转换为 Chunk 内部坐标
public static Vector3Int GetBlockInChunkCoordinates(ChunkData chunkData, Vector3Int pos)
{
return new Vector3Int
{
x = pos.x - chunkData.worldPosition.x,
y = pos.y - chunkData.worldPosition.y,
z = pos.z - chunkData.worldPosition.z
};
}

// 生成 Chunk 的 Mesh 数据
public static MeshData GetChunkMeshData(ChunkData chunkData)
{
MeshData meshData = new MeshData(true);

// 目前为空实现,后续可以在此添加生成 Mesh 数据的逻辑

return meshData;
}
}

BlockDataSO

创建一个ScriptableObject 类,用于存储和管理块的纹理和碰撞相关数据。

通过 textureDataList 可以保存多个块的纹理信息。

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
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// [CreateAssetMenu] 允许在 Unity 编辑器中创建此类的实例作为资产
[CreateAssetMenu(fileName = "Block Data", menuName = "Data/Block Data")]
public class BlockDataSO : ScriptableObject
{
// 存储纹理的 X 轴和 Y 轴的大小
public float textureSizeX, textureSizeY;

// 存储每种块类型的纹理数据的列表
public List<TextureData> textureDataList;
}

// [Serializable] 使类可序列化,这样它可以在 Unity 编辑器中显示和保存
[Serializable]
public class TextureData
{
// 该纹理数据对应的块类型
public BlockType blockType;

// 定义块的不同面(上、下、侧面)的纹理坐标
public Vector2Int up, down, side;

// 指示块是否为实心块,默认值为 true
public bool isSolid = true;

// 指示是否为块生成碰撞器,默认值为 true
public bool generatesCollider = true;
}

然后我们新建一个BlockData给每个方块设置贴图:

点此下载Block Data Imported

Up、Down、Side就分别对应了方块的上面,下面和侧面的贴图在下面这张图片上的位置坐标。

BlockDataManager

负责管理和访问块的纹理数据。通过该类,可以在整个游戏中方便地获取和使用块的纹理信息。

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
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class BlockDataManager : MonoBehaviour
{
// 定义纹理偏移量,用于防止纹理边缘出现不明显的缝隙
public static float textureOffset = 0.001f;

// 定义纹理的 X 轴和 Y 轴的大小
public static float tileSizeX, tileSizeY;

// 字典,用于将每种块类型映射到对应的纹理数据
public static Dictionary<BlockType, TextureData> blockTextureDataDictionary = new Dictionary<BlockType, TextureData>();

// 引用包含块纹理数据的 ScriptableObject
public BlockDataSO textureData;

// 在脚本挂载的 GameObject 启动时调用
private void Awake()
{
// 遍历 ScriptableObject 中的纹理数据列表
foreach (var item in textureData.textureDataList)
{
// 如果字典中不包含此块类型的数据,则添加到字典中
if (blockTextureDataDictionary.ContainsKey(item.blockType) == false)
{
blockTextureDataDictionary.Add(item.blockType, item);
}
}

// 将 ScriptableObject 中的纹理大小赋值给静态变量,以便全局访问
tileSizeX = textureData.textureSizeX;
tileSizeY = textureData.textureSizeY;
}
}

然后再Unity中创建BlockDataManager添加BlockDataManager.cs脚本,并将贴图数据赋予Texture Data。

Direction

1
2
3
4
5
6
7
8
9
10
11
// 定义块的方向
public enum Direction
{
forward, // z+ 方向(前)
right, // x+ 方向(右)
backwards, // z- 方向(后)
left, // x- 方向(左)
up, // y+ 方向(上)
down // y- 方向(下)
};

Direction 枚举: 用于表示方块在三维空间中的方向。这在处理方块的相对方向或在三维空间中进行方向操作时非常有用。

DirectionExtensions

Direction 枚举提供了额外的功能:将方向转换为对应的 Vector3Int

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using System;
using UnityEngine;

// 定义方向枚举的扩展方法
public static class DirectionExtensions
{
// 扩展方法:根据方向枚举值获取对应的三维向量
public static Vector3Int GetVector(this Direction direction)
{
//根据方向返回对应的三维向量
return direction switch
{
Direction.up => Vector3Int.up, // 返回 (0, 1, 0) 对应的向量,表示向上的方向
Direction.down => Vector3Int.down, // 返回 (0, -1, 0) 对应的向量,表示向下的方向
Direction.right => Vector3Int.right, // 返回 (1, 0, 0) 对应的向量,表示向右的方向
Direction.left => Vector3Int.left, // 返回 (-1, 0, 0) 对应的向量,表示向左的方向
Direction.foreward => Vector3Int.forward, // 返回 (0, 0, 1) 对应的向量,表示向前的方向
Direction.backwards => Vector3Int.back, // 返回 (0, 0, -1) 对应的向量,表示向后的方向
_ => throw new Exception("Invalid input direction") // 如果方向无效,抛出异常
};
}
}

BlockHelper

根据块的类型和相邻块的类型,生成块的网格数据。

负责为体素生成正确的几何形状和纹理坐标,并确定哪些面应该被渲染。

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
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public static class BlockHelper
{
// 定义了一个包含所有可能方向的数组,用于遍历块的六个面
private static Direction[] directions =
{
Direction.backwards, // -z 方向
Direction.down, // -y 方向
Direction.foreward, // +z 方向
Direction.left, // -x 方向
Direction.right, // +x 方向
Direction.up // +y 方向
};

// 根据块的类型生成块的网格数据
public static MeshData GetMeshData(ChunkData chunk, int x, int y, int z, MeshData meshData, BlockType blockType)
{
// 如果块是空气或者什么都没有,则直接返回现有的网格数据
if (blockType == BlockType.Air || blockType == BlockType.Nothing)
return meshData;

// 遍历所有方向,检查该方向是否需要渲染面
foreach (Direction direction in directions)
{
// 计算相邻块的坐标
var neighbourBlockCoordinates = new Vector3Int(x, y, z) + direction.GetVector();
// 获取相邻块的类型
var neighbourBlockType = Chunk.GetBlockFromChunkCoordinates(chunk, neighbourBlockCoordinates);

// 如果相邻块存在且不为固体,则生成对应的面
if (neighbourBlockType != BlockType.Nothing && BlockDataManager.blockTextureDataDictionary[neighbourBlockType].isSolid == false)
{
// 特殊处理水块类型
if (blockType == BlockType.Water)
{
// 仅当相邻块是空气时,才为水块生成面
if (neighbourBlockType == BlockType.Air)
meshData.waterMesh = GetFaceDataIn(direction, chunk, x, y, z, meshData.waterMesh, blockType);
}
else
{
// 生成普通块的面
meshData = GetFaceDataIn(direction, chunk, x, y, z, meshData, blockType);
}
}
}

return meshData;
}

// 为指定方向的块面生成网格数据
public static MeshData GetFaceDataIn(Direction direction, ChunkData chunk, int x, int y, int z, MeshData meshData, BlockType blockType)
{
// 获取面对应的顶点
GetFaceVertices(direction, x, y, z, meshData, blockType);
// 添加四边形的三角形数据
meshData.AddQuadTriangles(BlockDataManager.blockTextureDataDictionary[blockType].generatesCollider);
// 添加UV坐标
meshData.uv.AddRange(FaceUVs(direction, blockType));

return meshData;
}

// 根据方向获取面对应的顶点,并添加到网格数据中
public static void GetFaceVertices(Direction direction, int x, int y, int z, MeshData meshData, BlockType blockType)
{
var generatesCollider = BlockDataManager.blockTextureDataDictionary[blockType].generatesCollider;
// 根据方向添加面对应的顶点坐标
switch (direction)
{
case Direction.backwards: // -z 方向的面
meshData.AddVertex(new Vector3(x - 0.5f, y - 0.5f, z - 0.5f), generatesCollider);
meshData.AddVertex(new Vector3(x - 0.5f, y + 0.5f, z - 0.5f), generatesCollider);
meshData.AddVertex(new Vector3(x + 0.5f, y + 0.5f, z - 0.5f), generatesCollider);
meshData.AddVertex(new Vector3(x + 0.5f, y - 0.5f, z - 0.5f), generatesCollider);
break;
case Direction.foreward: // +z 方向的面
meshData.AddVertex(new Vector3(x + 0.5f, y - 0.5f, z + 0.5f), generatesCollider);
meshData.AddVertex(new Vector3(x + 0.5f, y + 0.5f, z + 0.5f), generatesCollider);
meshData.AddVertex(new Vector3(x - 0.5f, y + 0.5f, z + 0.5f), generatesCollider);
meshData.AddVertex(new Vector3(x - 0.5f, y - 0.5f, z + 0.5f), generatesCollider);
break;
case Direction.left: // -x 方向的面
meshData.AddVertex(new Vector3(x - 0.5f, y - 0.5f, z + 0.5f), generatesCollider);
meshData.AddVertex(new Vector3(x - 0.5f, y + 0.5f, z + 0.5f), generatesCollider);
meshData.AddVertex(new Vector3(x - 0.5f, y + 0.5f, z - 0.5f), generatesCollider);
meshData.AddVertex(new Vector3(x - 0.5f, y - 0.5f, z - 0.5f), generatesCollider);
break;

case Direction.right: // +x 方向的面
meshData.AddVertex(new Vector3(x + 0.5f, y - 0.5f, z - 0.5f), generatesCollider);
meshData.AddVertex(new Vector3(x + 0.5f, y + 0.5f, z - 0.5f), generatesCollider);
meshData.AddVertex(new Vector3(x + 0.5f, y + 0.5f, z + 0.5f), generatesCollider);
meshData.AddVertex(new Vector3(x + 0.5f, y - 0.5f, z + 0.5f), generatesCollider);
break;
case Direction.down: // -y 方向的面
meshData.AddVertex(new Vector3(x - 0.5f, y - 0.5f, z - 0.5f), generatesCollider);
meshData.AddVertex(new Vector3(x + 0.5f, y - 0.5f, z - 0.5f), generatesCollider);
meshData.AddVertex(new Vector3(x + 0.5f, y - 0.5f, z + 0.5f), generatesCollider);
meshData.AddVertex(new Vector3(x - 0.5f, y - 0.5f, z + 0.5f), generatesCollider);
break;
case Direction.up: // +y 方向的面
meshData.AddVertex(new Vector3(x - 0.5f, y + 0.5f, z + 0.5f), generatesCollider);
meshData.AddVertex(new Vector3(x + 0.5f, y + 0.5f, z + 0.5f), generatesCollider);
meshData.AddVertex(new Vector3(x + 0.5f, y + 0.5f, z - 0.5f), generatesCollider);
meshData.AddVertex(new Vector3(x - 0.5f, y + 0.5f, z - 0.5f), generatesCollider);
break;
default:
break;
}
}

// 获取面对应的UV坐标,确定纹理的显示位置
public static Vector2[] FaceUVs(Direction direction, BlockType blockType)
{
Vector2[] UVs = new Vector2[4];
var tilePos = TexturePosition(direction, blockType);

// 根据方向和块类型确定纹理的位置,并生成UV坐标
UVs[0] = new Vector2(BlockDataManager.tileSizeX * tilePos.x + BlockDataManager.tileSizeX - BlockDataManager.textureOffset,
BlockDataManager.tileSizeY * tilePos.y + BlockDataManager.textureOffset);

UVs[1] = new Vector2(BlockDataManager.tileSizeX * tilePos.x + BlockDataManager.tileSizeX - BlockDataManager.textureOffset,
BlockDataManager.tileSizeY * tilePos.y + BlockDataManager.tileSizeY - BlockDataManager.textureOffset);

UVs[2] = new Vector2(BlockDataManager.tileSizeX * tilePos.x + BlockDataManager.textureOffset,
BlockDataManager.tileSizeY * tilePos.y + BlockDataManager.tileSizeY - BlockDataManager.textureOffset);

UVs[3] = new Vector2(BlockDataManager.tileSizeX * tilePos.x + BlockDataManager.textureOffset,
BlockDataManager.tileSizeY * tilePos.y + BlockDataManager.textureOffset);

return UVs;
}

// 根据方向和块类型确定纹理在图集中对应的位置
public static Vector2Int TexturePosition(Direction direction, BlockType blockType)
{
return direction switch
{
Direction.up => BlockDataManager.blockTextureDataDictionary[blockType].up, // 向上方向的纹理
Direction.down => BlockDataManager.blockTextureDataDictionary[blockType].down, // 向下方向的纹理
_ => BlockDataManager.blockTextureDataDictionary[blockType].side // 其他方向的纹理
};
}
}

World

通过 GenerateWorld 方法,可以一次性生成整个地图,并将每个区块对象添加到场景中。

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
100
101
102
103
104
105
106
107
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class World : MonoBehaviour
{
// 世界生成相关的参数
public int mapSizeInChunks = 6; // 世界地图由多少个区块组成(每行每列区块数)
public int chunkSize = 16, // 每个区块的大小(x和z方向)
chunkHeight = 100; // 每个区块的高度(y方向)
public int waterThreshold = 50; // 水的高度阈值(低于这个值的是水)
public float noiseScale = 0.03f; // 噪声缩放比例,用于生成地形的高度
public GameObject chunkPrefab; // 区块的预制件,用于实例化区块对象

// 用于存储区块数据和区块渲染器的字典
Dictionary<Vector3Int, ChunkData> chunkDataDictionary = new Dictionary<Vector3Int, ChunkData>();
Dictionary<Vector3Int, ChunkRenderer> chunkDictionary = new Dictionary<Vector3Int, ChunkRenderer>();

// 生成世界的方法
public void GenerateWorld()
{
// 清空字典和销毁已有的区块对象
chunkDataDictionary.Clear();
foreach (ChunkRenderer chunk in chunkDictionary.Values)
{
Destroy(chunk.gameObject);
}
chunkDictionary.Clear();

// 遍历每个区块的位置
for (int x = 0; x < mapSizeInChunks; x++)
{
for (int z = 0; z < mapSizeInChunks; z++)
{
// 创建区块数据对象
ChunkData data = new ChunkData(chunkSize, chunkHeight, this, new Vector3Int(x * chunkSize, 0, z * chunkSize));
// 生成区块中的体素(方块)
GenerateVoxels(data);
// 将生成的区块数据存入字典
chunkDataDictionary.Add(data.worldPosition, data);
}
}

// 生成区块的网格并创建区块对象
foreach (ChunkData data in chunkDataDictionary.Values)
{
// 获取区块的网格数据
MeshData meshData = Chunk.GetChunkMeshData(data);
// 实例化区块对象
GameObject chunkObject = Instantiate(chunkPrefab, data.worldPosition, Quaternion.identity);
// 获取区块渲染器组件
ChunkRenderer chunkRenderer = chunkObject.GetComponent<ChunkRenderer>();
// 将区块渲染器存入字典
chunkDictionary.Add(data.worldPosition, chunkRenderer);
// 初始化区块数据
chunkRenderer.InitializeChunk(data);
// 更新区块的网格
chunkRenderer.UpdateChunk(meshData);
}
}

// 生成区块中的体素
private void GenerateVoxels(ChunkData data)
{
// 遍历区块的每个位置
for (int x = 0; x < data.chunkSize; x++)
{
for (int z = 0; z < data.chunkSize; z++)
{
// 使用Perlin噪声生成地形高度
float noiseValue = Mathf.PerlinNoise((data.worldPosition.x + x) * noiseScale, (data.worldPosition.z + z) * noiseScale);
int groundPosition = Mathf.RoundToInt(noiseValue * chunkHeight);

// 遍历区块的高度,设置每个体素的类型
for (int y = 0; y < chunkHeight; y++)
{
BlockType voxelType = BlockType.Dirt; // 默认块类型为土块

if (y > groundPosition) // 如果当前y值高于地面高度
{
if (y < waterThreshold) // 如果y值低于水阈值,则为水
{
voxelType = BlockType.Water;
}
else // 否则为空气
{
voxelType = BlockType.Air;
}
}
else if (y == groundPosition && y < waterThreshold) // 如果y值等于地面高度且低于水阈值,则为沙
{
voxelType = BlockType.Sand;
}
else if (y == groundPosition) // 否则如果y值等于地面高度,则为草块
{
voxelType = BlockType.Grass_Dirt;
}

// 设置该位置的体素类型
Chunk.SetBlock(data, new Vector3Int(x, y, z), voxelType);
}
}
}
}
}

GenerateWorld 方法:

  • 负责生成整个世界。它先清空已有的数据和区块对象,然后为每个区块生成体素数据,并实例化对应的区块对象。每个区块数据都存储在 chunkDataDictionary 中,而对应的渲染器则存储在 chunkDictionary 中。

GenerateVoxels 方法:

  • 生成每个区块中的体素数据。通过 Perlin 噪声生成地形的高度,并根据高度和水阈值确定每个体素的类型(如土块、草块、沙块、水、空气等)。

chunkDataDictionarychunkDictionary:

  • 前者存储生成的区块数据,后者存储生成的区块渲染器对象。这些字典方便管理和访问生成的世界数据和区块对象。

PerlinNoise:

  • 使用 Perlin 噪声生成的地形高度模拟了自然地形的起伏,生成逼真的地形效果。

完善Chunk

在上面的代码中使用了GetChunkMeshData方法,如果你还记得该方法的实现,你会发现实现并不完整。

1
2
// 获取区块的网格数据
MeshData meshData = Chunk.GetChunkMeshData(data);

现在我们来完善它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static MeshData GetChunkMeshData(ChunkData chunkData)
{
// 创建一个新的 MeshData 对象,用于存储生成的网格数据。
MeshData meshData = new MeshData(true);

// 遍历块数据中的所有块,并为每个块生成网格数据。
// 这里使用了一个委托,传递给 LoopThroughTheBlocks 方法,在遍历每个块时调用。
LoopThroughTheBlocks(chunkData, (x, y, z) => meshData =
// 获取当前块的类型,并根据块的类型生成网格数据。
lockHelper.GetMeshData(chunkData, x, y, z, meshData,
// 调用 BlockHelper.GetMeshData 生成网格数据,并将其存储在 meshData 对象中。
chunkData.blocks[GetIndexFromPosition(chunkData, x, y, z)]));

return meshData;
}

然后,还有`GetBlockFromChunkCoordinates没有实现。

1
2
 // 获取相邻块的类型
var neighbourBlockType = Chunk.GetBlockFromChunkCoordinates(chunk, neighbourBlockCoordinates);

我们进继续在Chunk类中完善:

1
2
3
4
5
6
7
8
9
10
11
public static BlockType GetBlockFromChunkCoordinates(ChunkData chunkData, int x, int y, int z)
{
// 检查给定的块坐标是否在当前 Chunk 的范围内
if (InRange(chunkData, x) && InRangeHeight(chunkData, y) && InRange(chunkData, z))
{
int index = GetIndexFromPosition(chunkData, x, y, z);
return chunkData.blocks[index];
}
// 如果不在范围内,则向世界对象请求获取该块类型
return chunkData.worldReference.GetBlockFromChunkCoordinates(chunkData, chunkData.worldPosition.x + x, chunkData.worldPosition.y + y, chunkData.worldPosition.z + z);
}

完善World

继续实现上面代码中的`GetBlockFromChunkCoordinates方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
internal BlockType GetBlockFromChunkCoordinates(ChunkData chunkData, int x, int y, int z)
{
// 计算指定块坐标对应的 Chunk 的世界位置
Vector3Int pos = Chunk.ChunkPositionFromBlockCoords(this, x, y, z);

// 用于存储查找到的 ChunkData
ChunkData containerChunk = null;

// 从字典中查找包含指定块坐标的 Chunk
chunkDataDictionary.TryGetValue(pos, out containerChunk);

// 如果未找到对应的 Chunk,返回 BlockType.Nothing 表示无块
if (containerChunk == null)
return BlockType.Nothing;

// 将块的世界坐标转换为 Chunk 内部的局部坐标
Vector3Int blockInChunkCoordinates = Chunk.GetBlockInChunkCoordinates(containerChunk, new Vector3Int(x, y, z));

// 返回该块的类型
return Chunk.GetBlockFromChunkCoordinates(containerChunk, blockInChunkCoordinates);
}

完善Chunk

最后实现完成最后一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
internal static Vector3Int ChunkPositionFromBlockCoords(World world, int x, int y, int z)
{
// 创建一个新的 Vector3Int 对象,用于存储 Chunk 的世界位置
Vector3Int pos = new Vector3Int
{
// 计算给定 x 坐标所在的 Chunk 的世界位置
x = Mathf.FloorToInt(x / (float)world.chunkSize) * world.chunkSize,

// 计算给定 y 坐标所在的 Chunk 的世界位置
y = Mathf.FloorToInt(y / (float)world.chunkHeight) * world.chunkHeight,

// 计算给定 z 坐标所在的 Chunk 的世界位置
z = Mathf.FloorToInt(z / (float)world.chunkSize) * world.chunkSize
};

// 返回计算得到的 Chunk 世界位置
return pos;
}

运行一下

最后,我们创建一个名为World的空对象,并添加World脚本,

设置Chunk Prefab 添加Chunk 预设体。

然后创建一个按钮。如下图所示,添加一个点击事件。

点击运行,再次点击按钮,你应该在游戏运行窗口看到以下画面:

下一步我们将生成更大更复杂的地形。

下面有请ChatGPT做总结

类与调用关系概述

在项目中定义了多个核心类和脚本,这些类和脚本共同协作以实现 Minecraft 风格的体素世界生成与渲染。以下是每个类的作用及其相互之间的调用关系。

1. BlockDataImported

  • 作用: 这个类或结构体通常用于加载和管理从外部资源(如 JSON 文件或其他数据格式)导入的方块数据。它是数据的输入来源。
  • 调用关系: 通过 BlockDataManager 进行管理,并将数据加载到游戏内。

2. BlockDataManager

  • 作用: 管理所有方块的数据,包括每个方块的纹理信息、碰撞检测、是否是固体等属性。它使用 BlockDataSO 来存储这些数据。
  • 调用关系: 被 BlockHelperChunk 等类调用,用于获取特定方块的数据,比如纹理、是否生成碰撞体等。

3. BlockDataSO

  • 作用: ScriptableObject 用于在 Unity 编辑器中管理和配置方块数据,允许设计师在编辑器中直观地配置方块属性。
  • 调用关系: 被 BlockDataManager 使用以统一管理方块数据。

4. BlockHelper

  • 作用: 提供与方块相关的辅助方法,例如获取方块的网格数据、计算相邻方块的位置、生成特定方向的面、计算 UV 坐标等。
  • 调用关系: 被 Chunk 类调用,用于生成每个块的网格数据。

5. BlockType

  • 作用: 枚举(enum)或类,用于定义游戏中所有可用的方块类型,如空气、草地、石头、水等。
  • 调用关系: 通过 ChunkBlockHelper 被大量使用,定义每个块的类型,并决定其外观和行为。

6. Chunk

  • 作用: 表示世界中的一个区块,包含一个块的集合,管理区块内的块数据,生成并返回区块的网格数据。
  • 调用关系: 负责调用 BlockHelper 生成区块的网格数据,并与 ChunkRenderer 协作渲染区块。

7. ChunkData

  • 作用: 存储特定区块的所有数据,包括每个块的类型、位置、以及区块的世界位置等。
  • 调用关系: Chunk 使用 ChunkData 来访问和修改区块的具体数据。World 类生成世界时,也使用 ChunkData 来管理每个区块的数据。

8. ChunkRenderer

  • 作用: 负责渲染区块,管理区块的 Mesh 组件,将生成的网格数据应用到 Unity 的 Mesh 上以进行渲染。
  • 调用关系: World 生成世界时,会实例化 ChunkRenderer,并使用它来显示生成的区块。

9. Direction

  • 作用: 枚举类,用于定义可能的方向,如上、下、左、右、前、后,表示方块的六个面。
  • 调用关系: 被 BlockHelperChunk 使用,用于确定方块的方向并生成相应的面。

10. DirectionExtensions

  • 作用: 提供扩展方法,用于从 Direction 枚举中获取方向向量,帮助确定方块在某个方向上的相邻方块位置。
  • 调用关系: 被 BlockHelper 调用,用于计算相邻方块的位置。

11. MeshData

  • 作用: 存储生成的网格数据,包括顶点、三角形、UV 坐标等,最终用于构建区块的 Mesh。
  • 调用关系: 被 BlockHelperChunk 使用,生成方块和区块的网格数据。

12. World

  • 作用: 管理整个体素世界的生成、维护和渲染,生成多个区块,并组织它们形成完整的世界。
  • 调用关系: 生成并管理 ChunkChunkRenderer,负责大规模的世界生成逻辑,并与 BlockDataManagerChunkData 协作处理方块和区块的数据。

调用关系图(概念化)

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
                     +-------------------+
| BlockDataImported |
+-------------------+
|
+-------------------+
| BlockDataManager |
+-------------------+
/ \
+-------------+ +-----------------+
| BlockDataSO | | BlockDataHelper|
+-------------+ +-----------------+
|
+-------+
| BlockType |
+-------+
|
+--------------------------------------------+
| |
+--------+ +--------+
| Chunk | | World |
+--------+ +--------+
| |
+-------------+ +-------------+
| ChunkData | | ChunkRenderer|
+-------------+ +-------------+
|
+----------+
| MeshData |
+----------+

这些类和脚本的设计紧密合作以实现体素世界的生成和渲染。在这个系统中,World 类负责管理整个世界的生成和维护;ChunkChunkData 管理区块的数据与生成;BlockHelperMeshData 则负责具体的网格数据生成和处理;而 BlockDataManagerBlockType 则集中管理方块的具体属性和行为。每个类各司其职,共同协作构建一个复杂且可扩展的体素世界系统。