一直以来,很多人都比较好奇《我的世界》中的地图是如何随机生成且还具有无限大小的,那么在这一篇文章中,我就以最简化的代码(300行左右)在Unity引擎中实现这一机制。
实现结果如下:
运行后,随机生成角色周围的地形,且随着角色的位置变化,动态加载。
在实现之前呢,我们可以先来简单分析一下这个需求:
我的世界的地图元素可以分为4个层次
World->Chunk->Block->Face
下面分别来解释一下这4个层次。
1.Face: 正方体的一个面
2.Block: 6个面组成的一个正方体
3.Chunk: N个正方体组成的一个地图块
4.World: 多个地图块组成的世界,就是“我的世界”啦。
我们可以看到这4个层次,其实有点类似俄罗斯套娃对吧,一层包含一层。
我们要生成World,那么就是要在这些层次中,一层一层的去处理生成的逻辑, 在World里动态加载Chunk, 在Chunk里生成Block, 在Block里生成Face。
OK 大概的思路我们已经说完了,接下来我们来拆解一下实现步骤
1.首先我们先实现Chunk的生成,内部会包含 Block的生成,这里会用到simplex noise(一种Perlin噪声的改进)
有关噪声的知识,如果读者没有接触过,可以自行网上找找相关资料看看
这里推荐一篇(小姐姐写的比较细致):http://blog.csdn.net/candycat1992/article/details/50346469
在这个部分我们会写一个类Chunk.cs, (大约200行代码)
2.接下来我们要通过玩家的位置信息来动态加载Chunk
这个部分我们会写一个类Player.cs (大约100行代码)
Chunk生成
首先新建一个Unity工程后,导入一些资源,资源包在这里下载:http://pan.baidu.com/s/1hszPgwc
接下来我们在场景中创建一个Cube
然后我们来创建一个Chunk类,并挂到这个Cube上。
打开刚才新建的Chunk.cs,我们来先声明好Chunk类里需要用到的成员变量
public class Chunk : MonoBehaviour
{
//Block的类型
public enum BlockType
{
//空
None = 0,
//泥土
Dirt = 1,
//草地
Grass = 3,
//碎石
Gravel = 4,
}
//存储着世界中所有的Chunk
public static List<Chunk> chunks = new List<Chunk>();
//每个Chunk的长宽Size
public static int width = 30;
//每个Chunk的高度
public static int height = 30;
//随机种子
public int seed;
//最小生成高度
public float baseHeight = 10;
//噪音频率(噪音采样时会用到)
public float frequency = 0.025f;
//噪音振幅(噪音采样时会用到)
public float amplitude = 1;
//存储着此Chunk内的所有Block信息
BlockType[,,] map;
//Chunk的网格
Mesh chunkMesh;
//噪音采样时会用到的偏移
Vector3 offset0;
Vector3 offset1;
Vector3 offset2;
MeshRenderer meshRenderer;
MeshCollider meshCollider;
MeshFilter meshFilter;
}
接下来,我们往这个类中加一些初始化的函数 如下:
void Start ()
{
//初始化时将自己加入chunks列表
chunks.Add(this);
//获取自身相关组件引用
meshRenderer = GetComponent<MeshRenderer>();
meshCollider = GetComponent<MeshCollider>();
meshFilter = GetComponent<MeshFilter>();
//初始化地图
InitMap();
}
void InitMap()
{
//初始化随机种子
Random.InitState(seed);
offset0 = new Vector3(Random.value * 1000, Random.value * 1000, Random.value * 1000);
offset1 = new Vector3(Random.value * 1000, Random.value * 1000, Random.value * 1000);
offset2 = new Vector3(Random.value * 1000, Random.value * 1000, Random.value * 1000);
//初始化Map
map = new BlockType[width, height, width];
//遍历map,生成其中每个Block的信息
for (int x = 0; x < width; x++)
{
for (int y = 0; y < height; y++)
{
for (int z = 0; z < width; z++)
{
map[x, y, z] = GenerateBlockType(new Vector3(x, y, z) + transform.position);
}
}
}
//根据生成的信息,Build出Chunk的网格
BuildChunk();
}
在上面这段代码中,我们需要注意两个点
1.这里的map存的是Chunk内每一个Block的信息
2.GenerateBlockType函数和BuildChunk函数,我们还没有实现
3.我们在Start函数被调用时,便将这个Chunk生成好了
在第二点中说的两个函数,便是我们接下来生成Chunk的两个核心步骤
1.生成map信息(每个Block的类型,以及地形的高度信息)
2.构建Chunk用来显示的网格
那么我们接下来分别看看如何实现这两步
1.GenerateBlockType
int GenerateHeight(Vector3 wPos)
{
//让随机种子,振幅,频率,应用于我们的噪音采样结果
float x0 = (wPos.x + offset0.x) * frequency;
float y0 = (wPos.y + offset0.y) * frequency;
float z0 = (wPos.z + offset0.z) * frequency;
float x1 = (wPos.x + offset1.x) * frequency * 2;
float y1 = (wPos.y + offset1.y) * frequency * 2;
float z1 = (wPos.z + offset1.z) * frequency * 2;
float x2 = (wPos.x + offset2.x) * frequency / 4;
float y2 = (wPos.y + offset2.y) * frequency / 4;
float z2 = (wPos.z + offset2.z) * frequency / 4;
float noise0 = Noise.Generate(x0, y0, z0) * amplitude;
float noise1 = Noise.Generate(x1, y1, z1) * amplitude / 2;
float noise2 = Noise.Generate(x2, y2, z2) * amplitude / 4;
//在采样结果上,叠加上baseHeight,限制随机生成的高度下限
return Mathf.FloorToInt(noise0 + noise1 + noise2 + baseHeight);
}
BlockType GenerateBlockType(Vector3 wPos)
{
//y坐标是否在Chunk内
if (wPos.y >= height)
{
return BlockType.None;
}
//获取当前位置方块随机生成的高度值
float genHeight = GenerateHeight(wPos);
//当前方块位置高于随机生成的高度值时,当前方块类型为空
if (wPos.y > genHeight)
{
return BlockType.None;
}
//当前方块位置等于随机生成的高度值时,当前方块类型为草地
else if (wPos.y == genHeight)
{
return BlockType.Grass;
}
//当前方块位置小于随机生成的高度值 且 大于 genHeight - 5时,当前方块类型为泥土
else if (wPos.y < genHeight && wPos.y > genHeight - 5)
{
return BlockType.Dirt;
}
//其他情况,当前方块类型为碎石
return BlockType.Gravel;
}
上面这两个函数实现了生成Block信息的过程
在上面这段代码中我们需要注意以下几点
1.GenerateHeight用于通过噪音来随机生成每个方块的高度,这种随机生成的方式相比其他方式更贴近我们想要的结果。普通的随机数得到的值都是离散的,均匀分布的结果,而通过simplex noise得到的结果,会是连续的。这样会获得更加真实,接近自然的效果。
2. GenerateHeight中那些数字字面量,没有特殊意义,就是经验数值,为了生成结果能够产生更多变化而已。可以自己调整试试看。
3.GenerateHeight中对多个噪声的生成结果进行了叠加,这是为了混合出理想的结果,具体可以网上检索查阅噪声相关资料。
4.GenerateBlockType内,会利用在指定位置随机生成的高度,来决定当前Block的类型。最内层是岩石,中间混杂着泥土,地表则是草地。
在我们有了地形元素的类型信息后,我们就可以来构建Chunk的网格,以来显示我们的Chunk了。
接下来我们实现BuildChunk函数
public void BuildChunk()
{
chunkMesh = new Mesh();
List<Vector3> verts = new List<Vector3>();
List<Vector2> uvs = new List<Vector2>();
List<int> tris = new List<int>();
//遍历chunk, 生成其中的每一个Block
for (int x = 0; x < width; x++)
{
for (int y = 0; y < height; y++)
{
for (int z = 0; z < width; z++)
{
BuildBlock(x, y, z, verts, uvs, tris);
}
}
}
chunkMesh.vertices = verts.ToArray();
chunkMesh.uv = uvs.ToArray();
chunkMesh.triangles = tris.ToArray();
chunkMesh.RecalculateBounds();
chunkMesh.RecalculateNormals();
meshFilter.mesh = chunkMesh;
meshCollider.sharedMesh = chunkMesh;
}
如上所示,BuildChunk函数内部遍历了Chunk内的每一个Block,为其生成网格数据,并在最后将生成的数据(顶点,UV, 索引)提交给了chunkMesh。
接下来我们实现BuildBlock函数
void BuildBlock(int x, int y, int z, List<Vector3> verts, List<Vector2> uvs, List<int> tris)
{
if (map[x, y, z] == 0) return;
BlockType typeid = map[x, y, z];
//Left
if (CheckNeedBuildFace(x - 1, y, z))
BuildFace(typeid, new Vector3(x, y, z), Vector3.up, Vector3.forward, false, verts, uvs, tris);
//Right
if (CheckNeedBuildFace(x + 1, y, z))
BuildFace(typeid, new Vector3(x + 1, y, z), Vector3.up, Vector3.forward, true, verts, uvs, tris);
//Bottom
if (CheckNeedBuildFace(x, y - 1, z))
BuildFace(typeid, new Vector3(x, y, z), Vector3.forward, Vector3.right, false, verts, uvs, tris);
//Top
if (CheckNeedBuildFace(x, y + 1, z))
BuildFace(typeid, new Vector3(x, y + 1, z), Vector3.forward, Vector3.right, true, verts, uvs, tris);
//Back
if (CheckNeedBuildFace(x, y, z - 1))
BuildFace(typeid, new Vector3(x, y, z), Vector3.up, Vector3.right, true, verts, uvs, tris);
//Front
if (CheckNeedBuildFace(x, y, z + 1))
BuildFace(typeid, new Vector3(x, y, z + 1), Vector3.up, Vector3.right, false, verts, uvs, tris);
}
bool CheckNeedBuildFace(int x, int y, int z)
{
if (y < 0) return false;
var type = GetBlockType(x, y, z);
switch (type)
{
case BlockType.None:
return true;
default:
return false;
}
}
public BlockType GetBlockType(int x, int y, int z)
{
if (y < 0 || y > height - 1)
{
return 0;
}
//当前位置是否在Chunk内
if ((x < 0) || (z < 0) || (x >= width) || (z >= width))
{
var id = GenerateBlockType(new Vector3(x, y, z) + transform.position);
return id;
}
return map[x, y, z];
}
BuildBlock内,我们分别去构建了一个Block中的每一个Face, 并通过CheckNeedBuildFace来确定,某一面Face是否需要显示出来,如果不需要,那么就不用去构建这面Face了。也就是说这个检测,会只把我们可以看到的面,显示出来,如下图这样。
(不做面优化)
(做了面优化)
我们的角色在地形上时,只能看到最外部的一层面,其实看不到内部的方块,所以这些看不到的方块,就没有必要浪费计算资源了。也正是这个原因,我们不能直接用正方体去随机生成,而是要像现在这样,以Face为基本单位来生成。实现这个功能的函数,便是CheckNeedBuildFace。
接下来让我们完成Chunk部分的最后一步
void BuildFace(BlockType typeid, Vector3 corner, Vector3 up, Vector3 right, bool reversed, List<Vector3> verts, List<Vector2> uvs, List<int> tris)
{
int index = verts.Count;
verts.Add (corner);
verts.Add (corner + up);
verts.Add (corner + up + right);
verts.Add (corner + right);
Vector2 uvWidth = new Vector2(0.25f, 0.25f);
Vector2 uvCorner = new Vector2(0.00f, 0.75f);
uvCorner.x += (float)(typeid - 1) / 4;
uvs.Add(uvCorner);
uvs.Add(new Vector2(uvCorner.x, uvCorner.y + uvWidth.y));
uvs.Add(new Vector2(uvCorner.x + uvWidth.x, uvCorner.y + uvWidth.y));
uvs.Add(new Vector2(uvCorner.x + uvWidth.x, uvCorner.y));
if (reversed)
{
tris.Add(index + 0);
tris.Add(index + 1);
tris.Add(index + 2);
tris.Add(index + 2);
tris.Add(index + 3);
tris.Add(index + 0);
}
else
{
tris.Add(index + 1);
tris.Add(index + 0);
tris.Add(index + 2);
tris.Add(index + 3);
tris.Add(index + 2);
tris.Add(index + 0);
}
}
这一步我们构建了正方体其中一面的网格数据,顶点,UV, 索引。这一步实现完后, 如果我们将这个组件挂在我们最初创建的Cube上,并运行,我们即会得到随机生成的一个Chunk。如下图所示:
2.在世界中动态加载多个Chunk
在实现第二部分之前,我们先在Chunk类中再添加一个函数
public static Chunk GetChunk(Vector3 wPos)
{
for (int i = 0; i < chunks.Count; i++)
{
Vector3 tempPos = chunks[i].transform.position;
//wPos是否超出了Chunk的XZ平面的范围
if ((wPos.x < tempPos.x) || (wPos.z < tempPos.z) || (wPos.x >= tempPos.x + 20) || (wPos.z >= tempPos.z + 20))
continue;
return chunks[i];
}
return null;
}
这个函数用于给定一个世界空间的位置,获取这个指定位置所在的Chunk对象。其中遍历了chunks列表,并找出对应的chunk返回。这个函数我们将在后面的代码中用到。
接下来由于动态加载是根据玩家位置的变化来进行的,所以我们首先添加一个Player类
新建一个C#代码文件:Player.cs,并在其中添加如下代码:
public class Player : MonoBehaviour
{
CharacterController cc;
public float speed = 20;
public float viewRange = 30;
public Chunk chunkPrefab;
private void Start()
{
cc = GetComponent<CharacterController>();
}
void Update ()
{
UpdateInput();
UpdateWorld();
}
void UpdateInput()
{
var h = Input.GetAxis("Horizontal");
var v = Input.GetAxis("Vertical");
var x = Input.GetAxis("Mouse X");
var y = Input.GetAxis("Mouse Y");
transform.rotation *= Quaternion.Euler(0f, x, 0f);
transform.rotation *= Quaternion.Euler(-y, 0f, 0f);
if (Input.GetButton("Jump"))
{
cc.Move((transform.right * h + transform.forward * v + transform.up) * speed * Time.deltaTime);
}
else
{
cc.SimpleMove(transform.right * h + transform.forward * v * speed);
}
}
}
这段代码中有几点需要注意
1.UpdateWorld我们还没有实现,这个函数将用来动态生成Chunk。
2.UpdateInput函数中,我们实现了一个最简单的处理玩家输入的小模块(但并不成熟,甚至都没有做视角的限制,感兴趣的可以自己加入更多的处理),其可以根据玩家的鼠标和键盘的输入来控制角色移动和旋转。
3.控制玩家移动的处理,我们使用了Unity内置的CharacterController组件,这个组件自身就又胶囊体碰撞盒。
在这一步中我们从Update函数中已经看出一些端倪了。这里会每一帧先处理玩家的输入,然后根据处理后的结果(更新后的玩家位置)来动态加载Chunk。
接下来我们添加最后一个函数UpdateWorld
void UpdateWorld()
{
for (float x = transform.position.x - viewRange; x < transform.position.x + viewRange; x += Chunk.width)
{
for (float z = transform.position.z - viewRange; z < transform.position.z + viewRange; z += Chunk.width)
{
Vector3 pos = new Vector3(x, 0, z);
pos.x = Mathf.Floor(pos.x / (float)Chunk.width) * Chunk.width;
pos.z = Mathf.Floor(pos.z / (float)Chunk.width) * Chunk.width;
Chunk chunk = Chunk.GetChunk(pos);
if (chunk != null) continue;
chunk = (Chunk)Instantiate(chunkPrefab, pos, Quaternion.identity);
}
}
}
这个函数 使用了我们刚才实现过的静态函数Chunk.GetChunk,来获取相应位置的chunk, 如果没有获取到的话,那么就通过chunkPrefab在相应位置生成一个新的chunk。 这个函数会通过这种方式来动态加载自身周围的chunk。 viewRange参数可以控制需要加载的范围。
到这里代码部分我们就全部实现完了。
接下来我们,添加一个角色对象,并在其上挂载一个CharacterController组件,以及我们的Player组件。
别忘了,还要加上相机哦。
然后是Chunk。
最后我们来看看我们的成果吧:
本期教程两个文件,总计大约300余行代码
本期教程工程源码:https://github.com/meta-42/Minecraft-Unity
————————————————————————————————————
游戏开发技术交流群:610475807
微信公众号:皮皮关
暂无关于此日志的评论。