找回密码
 立即注册
查看: 845|回复: 20

[笔记] 300行代码实现Minecraft(我的世界)大地图生成

[复制链接]
发表于 2022-1-4 10:16 | 显示全部楼层 |阅读模式
我的世界的大地图生成一直以来很多人都比较好奇其地图是如何随机生成且还具有无限大小的,那么这一期教程,我就以最简化的代码(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工程后,导入一些资源,资源包在这里下载: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.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;
        }
        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

————————————————————————————————————
对游戏开发感兴趣的同学,欢迎围观我们:【皮皮关游戏开发教育】 ,会定期更新各种教程干货,更有别具一格的线下小班教育~
我们的官网地址:http://levelpp.com/
我们的游戏开发技术交流群:610475807
我们的微信公众号:皮皮关

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
发表于 2022-1-4 10:25 | 显示全部楼层
我还是。。。做一只僵尸吧
发表于 2022-1-4 10:32 | 显示全部楼层
大佬啊
发表于 2022-1-4 10:34 | 显示全部楼层
那篇噪声的文写的真好
发表于 2022-1-4 10:42 | 显示全部楼层
...我还是当一条咸鱼好了
发表于 2022-1-4 10:52 | 显示全部楼层
给大佬敬茶
发表于 2022-1-4 10:56 | 显示全部楼层
已经收藏,希望以后能帮助到
 楼主| 发表于 2022-1-4 10:59 | 显示全部楼层
作为基础科普文,很不错了。然而,传闻minecraft方块都是glbegin.glend怼出来的,你这个太高端了(滑稽
发表于 2022-1-4 11:02 | 显示全部楼层
高级
发表于 2022-1-4 11:09 | 显示全部楼层
好厉害
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-5-12 02:21 , Processed in 0.110617 second(s), 27 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表