300行代碼實現Minecraft(我的世界)大地圖生成

我的世界的大地圖生成一直以來很多人都比較好奇其地圖是如何隨機生成且還具有無限大小的,那麼這一期教程,我就以最簡化的代碼(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雜訊的改進)

有關雜訊的知識,如果讀者沒有接觸過,可以自行網上找找相關資料看看

這裡推薦一篇(小姐姐寫的比較細緻):【圖形學】談談雜訊 - candycat - CSDN博客

在這個部分我們會寫一個類Chunk.cs, (大約200行代碼)

2.接下來我們要通過玩家的位置信息來動態載入Chunk

這個部分我們會寫一個類Player.cs (大約100行代碼)

Chunk生成

首先新建一個Unity工程後,導入一些資源,資源包在這裡下載:pan.baidu.com/s/1hszPgw

接下來我們在場景中創建一個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餘行代碼

本期教程工程源碼:meta-42/Minecraft-Unity

————————————————————————————————————

對遊戲開發感興趣的同學,歡迎圍觀我們:【皮皮關遊戲開發教育】 ,會定期更新各種教程乾貨,更有別具一格的線下小班教育~

我們的官網地址:levelpp.com/

我們的遊戲開發技術交流群:610475807

我們的微信公眾號:皮皮關

推薦閱讀:

【Unity】工具類系列教程—— 代碼自動化生成!
幻影坦克架構指南(二)
GPU Gems 基於物理模型的水面模擬 學習筆記 (一)
Unity特效(1) 夢幻旋屏
手游逆向分析<二>: Unity內還原《鎮魔曲》角色渲染效果

TAG:游戏开发 | Unity游戏引擎 | 我的世界Minecraft |