Voxel World Generator (Minecraft)

Made by Jaimy Berlemon

Table of content

  1. Introduction
  2. Approach
    1. Plan of action
    2. Blocks
    3. Chunks
    4. World
    5. Building
    6. World generation
      1. Landscape
      2. Caves
      3. Water
      4. Trees
  3. The result
  4. Future work
  5. Sources

1. Introduction

Figure 1: Minecraft Vanilla (PlanetMinecraft, 2017)

Who would have thought that I wanted to replicate Minecraft world generation. I have barely worked with meshes before, let alone Mesh Generation. During the Mesh Generation workshop I learned a lot about how Minecraft is made and optimized, and became very excited to try this on my own.

My goal is to learn how to combine mesh rendering with random world generation while the performance is still acceptable. A simple player should be able to explore the world while new chunks load and the older ones unload. With a minimum framerate of 30 fps. I want more than just a landscape, I want my world to look similar to Minecraft itself. Think of biomes, caves, water, trees, but also the blocks on the surface such as grass, stone and sand. Even though I have no experience in this subject at all, I really like to take on this challenge.

The world will be completely generated in code, objects such as trees as well. I want to avoid prefabs as much as possible.


2. Approach

2.1 Plan of action

Figure 2: Chunk in Minecraft (Gamepedia)

Before I start programming, I need to start working on a plan. Minecraft isn’t made out blocks, but out chunks. A chunk in Minecraft has a height of 256 blocks tall and a width and length of 16 blocks. This means that a chunk has a collection of  65,536 blocks. The reason why chunks are being used is for performance. It would cost a lot more CPU if every block had to be rendered individually.

The world that I am going to make will be divided in 3 parts. A block, a chunk and the world itself. A chunk will be a collection of blocks, but instead of having a chunk size of 16x256x16, I’m going to work with chunks of 16x16x16. The reason behind this has something to do with Unity’s CombineMeshes method. This method can’t combine meshes with a size bigger than 16x16x16. The blocks will be stored in a 3 dimensional array.

Not every block in the chunk should be rendered, so it needs to check if the neighboring block can be seen by the player. This should also happen between chunks. All the blocks combined in a chunk will become one mesh.

The world will be a collection of chunks. This class is going to manage the (un)loading of the chunks based on the player’s position.

After creating all the base classes, it’s going to be time to start generating an environment. I’m going to use Perlin Noise to generate hills and mountains. Perlin Noise allows us to randomly generate a realistic landscape. Caves will be similar, but it’s going to be generated with Perlin Noise 3D. Trees are going to require a preset. While generating, only one block of the tree is going to placed. The block will generate the tree afterwards.


2.2 Blocks

Let’s start with the object what this game is all about, blocks. A block is made of 6 quads. Each quad is constructed from 2 triangles. Why triangles you may wonder? Triangles are the most efficient way to save data. A triangle consists of only 3 vertices. A vertex is a coordinate in a 3D space with a value of x, y and z. A triangle also has a normal value. The normal is used to decide on which side the texture needs to be rendered. A quad is only rendered one way. So if you turn the quad 180 degrees, you won’t be able to see, like it’s not even there.

Figure 3: The layout of a block

The data of a block is stored in 5 arrays:

  • Vertex Array: Stores all the corner positions of the block.
  • Normal Array: Stores the normal vector for the corresponding vertex.
  • UV Array: Maps the location of a texture to each vertex.
  • SUV Array: Extra texture layer used for block crack texture.
  • Triangle Array: Stores a list of vertices in groups of three defining each triangle.

In my first version of the game, I used the Unity CombineMeshes method to combine all quads into a singular block. I also had to do the same with chunks. This is why I was forced to use chunks with a size of 16x16x16. I realized that this is very heavy on performance, so I decided to optimize the code. I will go into more detail in Chunks.

I wrote a method to create a quad for every side of the block. This method has to be called 6 times if a block has to be drawn completely. The code of creating a quad can be found below.

void CreateQuad(Cubeside side, List<Vector3> vertices, List<Vector3> normals, List<Vector2> uvs, List<Vector2> suvs, List<int> triangles)
    {
        Mesh mesh = new Mesh();
        mesh.name = "ScriptedMesh" + side.ToString();
 
        //all possible UVs
        Vector2 uv00;
        Vector2 uv10;
        Vector2 uv01;
        Vector2 uv11;
 
        //Set all UVs
        SetUVs(side, out uv00, out uv10, out uv01, out uv11);
 
        //Used for breaking blocks
        //set cracks
        suvs.Add(crackUVs[(int)health, 3]);
        suvs.Add(crackUVs[(int)health, 2]);
        suvs.Add(crackUVs[(int)health, 0]);
        suvs.Add(crackUVs[(int)health, 1]);
 
        //all possible vertices 
        Vector3 p0, p1, p2, p3, p4, p5, p6, p7;
        GetAllPossibleVertices(out p0, out p1, out p2, out p3, out p4, out p5, out p6, out p7);
 
        // Fill normals, vertices and triangles based on the side of the cube
        int trioffset = vertices.Count;
        switch (side)
        {
            case Cubeside.BOTTOM:
                vertices.Add(p0); vertices.Add(p1); vertices.Add(p2); vertices.Add(p3);
                normals.Add(World.allNormals[(int)World.NDIR.DOWN]);
                normals.Add(World.allNormals[(int)World.NDIR.DOWN]);
                normals.Add(World.allNormals[(int)World.NDIR.DOWN]);
                normals.Add(World.allNormals[(int)World.NDIR.DOWN]);
                uvs.Add(uv11); uvs.Add(uv01); uvs.Add(uv00); uvs.Add(uv10);
                triangles.Add(3 + trioffset); triangles.Add(1 + trioffset); triangles.Add(0 + trioffset); triangles.Add(3 + trioffset); triangles.Add(2 + trioffset); triangles.Add(1 + trioffset);
 
                break;
            case Cubeside.TOP:
                vertices.Add(p7); vertices.Add(p6); vertices.Add(p5); vertices.Add(p4);
                normals.Add(World.allNormals[(int)World.NDIR.UP]);
                normals.Add(World.allNormals[(int)World.NDIR.UP]);
                normals.Add(World.allNormals[(int)World.NDIR.UP]);
                normals.Add(World.allNormals[(int)World.NDIR.UP]);
                uvs.Add(uv11); uvs.Add(uv01); uvs.Add(uv00); uvs.Add(uv10);
                triangles.Add(3 + trioffset); triangles.Add(1 + trioffset); triangles.Add(0 + trioffset); triangles.Add(3 + trioffset); triangles.Add(2 + trioffset); triangles.Add(1 + trioffset);
 
                break;
            case Cubeside.LEFT:
                vertices.Add(p7); vertices.Add(p4); vertices.Add(p0); vertices.Add(p3);
                normals.Add(World.allNormals[(int)World.NDIR.LEFT]);
                normals.Add(World.allNormals[(int)World.NDIR.LEFT]);
                normals.Add(World.allNormals[(int)World.NDIR.LEFT]);
                normals.Add(World.allNormals[(int)World.NDIR.LEFT]);
                uvs.Add(uv11); uvs.Add(uv01); uvs.Add(uv00); uvs.Add(uv10);
                triangles.Add(3 + trioffset); triangles.Add(1 + trioffset); triangles.Add(0 + trioffset); triangles.Add(3 + trioffset); triangles.Add(2 + trioffset); triangles.Add(1 + trioffset);
 
                break;
            case Cubeside.RIGHT:
                vertices.Add(p5); vertices.Add(p6); vertices.Add(p2); vertices.Add(p1);
                normals.Add(World.allNormals[(int)World.NDIR.RIGHT]);
                normals.Add(World.allNormals[(int)World.NDIR.RIGHT]);
                normals.Add(World.allNormals[(int)World.NDIR.RIGHT]);
                normals.Add(World.allNormals[(int)World.NDIR.RIGHT]);
                uvs.Add(uv11); uvs.Add(uv01); uvs.Add(uv00); uvs.Add(uv10);
                triangles.Add(3 + trioffset); triangles.Add(1 + trioffset); triangles.Add(0 + trioffset); triangles.Add(3 + trioffset); triangles.Add(2 + trioffset); triangles.Add(1 + trioffset);
 
                break;
            case Cubeside.FRONT:
                vertices.Add(p4); vertices.Add(p5); vertices.Add(p1); vertices.Add(p0);
                normals.Add(World.allNormals[(int)World.NDIR.FRONT]);
                normals.Add(World.allNormals[(int)World.NDIR.FRONT]);
                normals.Add(World.allNormals[(int)World.NDIR.FRONT]);
                normals.Add(World.allNormals[(int)World.NDIR.FRONT]);
                uvs.Add(uv11); uvs.Add(uv01); uvs.Add(uv00); uvs.Add(uv10);
                triangles.Add(3 + trioffset); triangles.Add(1 + trioffset); triangles.Add(0 + trioffset); triangles.Add(3 + trioffset); triangles.Add(2 + trioffset); triangles.Add(1 + trioffset);
 
                break;
            case Cubeside.BACK:
                vertices.Add(p6); vertices.Add(p7); vertices.Add(p3); vertices.Add(p2);
                normals.Add(World.allNormals[(int)World.NDIR.BACK]);
                normals.Add(World.allNormals[(int)World.NDIR.BACK]);
                normals.Add(World.allNormals[(int)World.NDIR.BACK]);
                normals.Add(World.allNormals[(int)World.NDIR.BACK]);
                uvs.Add(uv11); uvs.Add(uv01); uvs.Add(uv00); uvs.Add(uv10);
                triangles.Add(3 + trioffset); triangles.Add(1 + trioffset); triangles.Add(0 + trioffset); triangles.Add(3 + trioffset); triangles.Add(2 + trioffset); triangles.Add(1 + trioffset);
                break;
        }
    }

I use inheritance to create a new block type. The reason behind this is to avoid a collection of methods aimed at a specific type of block, which are useless to the others. A good examples for this are trees. Trees have their own methods to construct the tree.

Down below is an example of a class inheriting the Block class.

public class Grass : Block
{
    public Vector2[,] myUVs = { 
    /*TOP*/     {new Vector2( 0.125f, 0.375f ), new Vector2( 0.1875f, 0.375f),
                    new Vector2( 0.125f, 0.4375f ),new Vector2( 0.1875f, 0.4375f )},
    /*SIDE*/    { new Vector2(0.1875f,0.9375f),  new Vector2(0.25f,0.9375f),
                    new Vector2(0.1875f,1f), new Vector2(0.25f,1f)},
    /*BOTTOM*/  {new Vector2( 0.125f, 0.9375f ), new Vector2( 0.1875f, 0.9375f),
                    new Vector2( 0.125f, 1.0f ),new Vector2( 0.1875f, 1.0f )}};
 
    public Grass(Vector3 pos, GameObject p, Chunk o) : base(pos, p, o)
    {
        isSolid = true;
        blockUVs = myUVs; 
        maxHealth = 6;
        currentHealth = maxHealth;
    }
}

This is the result of rendering a single block.

Figure 4: A singular block

2.3 Chunks

Now that we can generate blocks, it’s time to start generating chunks. As said before, a chunk has a size of 16x16x16. Currently the chunk is just filled with one block purely to test if meshes are generated as they are supposed to.

I tried to draw the chunk as efficient as possible, because having to combine all the meshes of every quad and block takes too much times. To improve the performance, I decided to start filling the list with the block mesh data instead of creating a mesh for each block and combining them afterwards. You can see the code that I use below. Note that all the lists such as vertices and normal are added as a parameter to the Draw method. The lists get filled with each block data. After receiving the data, the mesh get’s created. No combining is needed.

public void DrawChunk()
    {
        //draw blocks
        Verts.Clear();
        Norms.Clear();
        UVs.Clear();
        SUVs.Clear();
        Tris.Clear();
 
        //Fill all Lists with the block's values
        for (int z = 0; z < World.chunkSize; z++)
            for (int y = 0; y < World.chunkSize; y++)
                for (int x = 0; x < World.chunkSize; x++)
                {
                    chunkData[x, y, z].Draw(Verts, Norms, UVs, SUVs, Tris);
                }
 
        //Create a mesh with all block values
        Mesh mesh = new Mesh();
 
        mesh.vertices = Verts.ToArray();
        mesh.normals = Norms.ToArray();
        mesh.uv = UVs.ToArray();
        mesh.SetUVs(1, SUVs);
        mesh.triangles = Tris.ToArray();
 
        mesh.RecalculateBounds();
 
        MeshFilter meshFilter = chunk.AddComponent<MeshFilter>();
        meshFilter.mesh = mesh;
 
        MeshRenderer renderer = chunk.AddComponent<MeshRenderer>();
        renderer.material = cubeMaterial;
 
        MeshCollider collider = chunk.gameObject.AddComponent(typeof(MeshCollider)) as MeshCollider;
        collider.sharedMesh = meshFilter.mesh;
 
        status = ChunkStatus.DONE;
    }

In the block class I added another method to make sure that unnecessary quads are not getting drawn. The method checks if the neighboring block is solid or has the same type as the current block. There is also an extra check in there that checks if the blocks are the same type. This is used for liquid blocks such as water that will be discussed later. This method get’s called for every block side when it is being drawn.

public void Draw(List<Vector3> vertices, List<Vector3> normals, List<Vector2> uvs, List<Vector2> suvs, List<int> triangles)
    {
        if (this is Airreturn;
 
        //Check for each quad if it has to be drawn or not
        if (!HasSolidNeighbour((int)position.x, (int)position.y, (int)position.+ 1))
            CreateQuad(Cubeside.FRONT, vertices, normals, uvs, suvs, triangles);
        if (!HasSolidNeighbour((int)position.x, (int)position.y, (int)position.- 1))
            CreateQuad(Cubeside.BACK, vertices, normals, uvs, suvs, triangles);
        if (!HasSolidNeighbour((int)position.x, (int)position.+ 1, (int)position.z))
            CreateQuad(Cubeside.TOP, vertices, normals, uvs, suvs, triangles);
        if (!HasSolidNeighbour((int)position.x, (int)position.- 1, (int)position.z))
            CreateQuad(Cubeside.BOTTOM, vertices, normals, uvs, suvs, triangles);
        if (!HasSolidNeighbour((int)position.- 1, (int)position.y, (int)position.z))
            CreateQuad(Cubeside.LEFT, vertices, normals, uvs, suvs, triangles);
        if (!HasSolidNeighbour((int)position.+ 1, (int)position.y, (int)position.z))
            CreateQuad(Cubeside.RIGHT, vertices, normals, uvs, suvs, triangles);
    }
/// <summary>
    /// Check if the neighboring block is solid or not, or is the same type of block.
    /// </summary>
    /// <param name="x"></param>
    /// <param name="y"></param>
    /// <param name="z"></param>
    /// <returns></returns>
    public bool HasSolidNeighbour(int x, int y, int z)
    {
        try
        {
            Block b = GetBlock(x, y, z);
 
            if (b != null)
                return b.isSolid || b.GetType() == GetType();
        }
        catch (System.IndexOutOfRangeException) { }
 
        return false;
    }

Here you can see a chunk being generated, while ignoring quads you won’t be able to see. I used a yield return null to slow down the process to make this gif. The size is also 6x6x6.

Figure 5: A chunk being rendered

2.4 World

The world manages all the chunks. This script decided whenever a chunk gets (un)loaded. The chunks are saved in a dictionary. The key of the chunk in the dictionary is an automatically generated string based on the chunk’s position. This way, it’s much easier to find a chunk when needed.

public static string BuildChunkName(Vector3 pos)
    {
        return (int)pos.+ "_" + (int)pos.+ "_" + (int)pos.z;
    }

In the BuildChunkAt method, a new chunks gets created and added to the list. This method will be used to create chunks around the player. Chunks can not go higher than 256 blocks high or lower than 0. The chunk position also gets automatically calculated based on the chunk size. The chunk size here has a value of 16.

void BuildChunkAt(int x, int y, int z)
    {
        Vector3 chunkPosition = new Vector3(x * chunkSize, y * chunkSize, z * chunkSize);
 
        //Avoid building chunks outside the boundaries
        if (chunkPosition.< 0 || chunkPosition.> chunkHeightLimit)
            return;
 
        string name = BuildChunkName(chunkPosition);
        Chunk chunk;
 
        //Chunk shouldn't exists yet
        //Create new chunk and add it to the dictionary
        if (!chunks.TryGetValue(name, out chunk))
        {
            chunk = new Chunk(chunkPosition, textureAtlas, fluidTextureAtlas);
            chunk.chunkGameObject.transform.parent = transform;
            chunks.TryAdd(chunk.chunkGameObject.name, chunk);
        }
    }

Chunks are getting generated in a recursive method. This method gets called in the World Update method. It doesn’t happen every frame. The script keeps track of the last building position. If the player moved 2 chunks, new chunks will generate and chunks outside the radius gets destroyed.

IEnumerator BuildRecursiveWorld(int x, int y, int z, int radius)
    {
        radius--;
        if (radius <= 0)
            yield break;
 
        //Chunk forwards
        BuildChunkAt(x + 1, y, z);
        queue.Run(BuildRecursiveWorld(x + 1, y, z, radius));
 
        //Chunk backwards
        BuildChunkAt(x - 1, y, z);
        queue.Run(BuildRecursiveWorld(x - 1, y, z, radius));
 
        //Chunk up
        BuildChunkAt(x, y + 1, z);
        queue.Run(BuildRecursiveWorld(x, y + 1, z, radius));
 
        //Chunk below
        BuildChunkAt(x, y - 1, z);
        queue.Run(BuildRecursiveWorld(x, y - 1, z, radius));
 
        //Chunk right
        BuildChunkAt(x, y, z + 1);
        queue.Run(BuildRecursiveWorld(x, y, z + 1, radius));
 
        //Chunk left
        BuildChunkAt(x, y, z - 1);
        queue.Run(BuildRecursiveWorld(x, y, z - 1, radius));
    }

There are two methods to draw and remove chunks. In the DrawChunks method, I check whenever a chunk still has to be drawn or not. Generating and drawing chunks are done separately. Chunks are being generated once they are created, after generating the chunk data, its ready to be drawn. More about generating chunks can be found in 2.6 World Generation. The same method also checks if the chunk is outside the player radius. If that is the case, the chunk key gets added to the ToRemove list.

The RemoveOldChunks method is very straightforward. Chunks that are in the ToRemove list are being destroyed and removed from the chunk dictionary.

IEnumerator DrawChunks()
    {
        foreach (KeyValuePair<stringChunk> c in chunks)
        {
            //Draw chunks that finished generating
            if (c.Value.status == Chunk.ChunkStatus.DRAW)
                c.Value.DrawChunk();
 
            //Check if chunk is outside the radius
            if (c.Value.chunkGameObject && Vector3.Distance(player.transform.position, c.Value.chunkGameObject.transform.position) > radius * chunkSize)
                toRemove.Add(c.Key);
 
            yield return null;
        }
    }
 
    IEnumerator RemoveOldChunks()
    {
        for (int i = 0; i < toRemove.Count; i++)
        {
            string n = toRemove[i];
            Chunk c;
            if (chunks.TryGetValue(n, out c))
            {
                Destroy(c.chunkGameObject);
                chunks.TryRemove(n, out c);
                yield return null;
            }
        }
    }

2.5 Building

To build and destroy blocks, I created a script that checks the player’s input. There are 2 types of interactions: Break and Build. The Interact method checks which block needs to be edited. Each block type has its own health value. As shown above in 2.2 Blocks, The SUV values are being set in the CreateQuad method based on its current health value. Once the health reaches 0, the block gets replaced with air. Building a block checks if the block that I am trying to replace is air. If that is not the case, nothing will happen.

private void Interact(Interaction interaction)
    {
        RaycastHit hit;
        if (Physics.Raycast(camera.transform.position, camera.transform.forward, out hit, buildDistance))
        {
            Chunk hitc;
            //Check if chunk exists
            if (!World.chunks.TryGetValue(hit.collider.gameObject.name, out hitc)) return;
 
            Vector3 hitBlock;
            //Get position of target block based on interaction
            switch (interaction)
            {
                case Interaction.Break:
                    hitBlock = hit.point - hit.normal / 2.0f;
                    break;
                case Interaction.Build:
                    hitBlock = hit.point + hit.normal / 2.0f;
                    break;
                default:
                    return;
            }
 
            Block b = World.GetWorldBlock(hitBlock);
            hitc = b.owner;
 
            bool update;
            switch (interaction)
            {
                case Interaction.Break:
                    Target = b;
                    update = b.HitBlock();
                    break;
                case Interaction.Build:
                    //Check if the player is trying to build where he's standing
                    if (b == World.GetWorldBlock(camera.transform.position) || b == World.GetWorldBlock(camera.transform.position + Vector3.down))
                        return;
 
                    //Replace block, returns true if build succesfully
                    update = b.BuildBlock(new Dirt(b.position, b.parent, b.owner));
                    break;
                default:
                    return;
            }
 
            if (update)
            {
                //Check if nearby chunks needs to be updated
                List<string> updates = GetChunksToUpdate(hitc, b);
 
                foreach (string cname in updates)
                {
                    Chunk c;
                    if (World.chunks.TryGetValue(cname, out c))
                        c.Redraw();
                }
            }
        }
    }

Each block has its own class, so replacing them wasn’t that easy. I had to check if the block that I’m trying to create is actually a block and if that is the case, the old block gets replaced with a new block type that uses the old block values such as position and parent.

internal void ReplaceBlock(Block currentBlock, object newBlock)
    {
        //Check if new block class inherits the block class
        if (!(newBlock is Block))
            return;
 
        Vector3 blockPos = currentBlock.position;
        var blockType = newBlock.GetType();
 
        //Try to create a new Block based on its class type
        chunkData[(int)blockPos.x, (int)blockPos.y, (int)blockPos.z] = Activator.CreateInstance(blockType, currentBlock.position, currentBlock.parent, currentBlock.owner) as Block;
    }
Figure 6: Building and destroying blocks

2.6 World Generation

In this section, I will explain how I generated my world. Before I can get into more detail, I need to explain how chunk data is being generated. The chunk constructor calls a method called BuildChunk. This method fills the chunk data with blocks based on different aspects such as the height of the block. The generation code isn’t very optimal. It has to check if a block meets the specific requirements to become a certain block such as grass, stone or dirt. A good example is Bedrock. In Minecraft, the bottom of the world is filled with Bedrock. To fill the bottom with Bedrock, the script needs to check if the y position is equal to 0. If it’s not, the code has to check further.

void BuildChunk()
    {
        chunkData = new Block[World.chunkSize, World.chunkSize, World.chunkSize];
 
        Vector3 chunkTransformPosition = chunkGameObject.transform.position;
 
        //Apply threading for chunk generation 
        Parallel.For(0World.chunkSize,
                z =>
                {
                    //create blocks
                    for (int y = 0; y < World.chunkSize; y++)
                        for (int x = 0; x < World.chunkSize; x++)
                        {
                            Vector3 pos = new Vector3(x, y, z);
                            int worldX = (int)(x + chunkTransformPosition.x);
                            int worldY = (int)(y + chunkTransformPosition.y);
                            int worldZ = (int)(z + chunkTransformPosition.z);
 
                            //Get all the heights with Perlin noise
                            int stoneHeight = Utils.GenerateHeight(worldX, worldZ, stoneMaxHeight, stoneSmooth, stoneOctaves);
                            int surfaceHeight = Utils.GenerateHeight(worldX, worldZ, surfaceMaxHeight, surfaceSmooth, surfaceOctaves);
 
                            GenerateBlock(x, y, z, pos, worldX, worldY, worldZ, stoneHeight, surfaceHeight);
 
                            GenerateCaves(z, y, x, pos, worldX, worldY, worldZ, stoneHeight);
 
                            status = ChunkStatus.DRAW;
                        }
                });
    }

2.6.1 Landscape

The height of the blocks is determined by Perlin Noise. Every time a block is being determined, the max height of the x and z position needs to be calculated. With these values, I can check if a block is above, below, or on the same level as the height generated with Perlin Noise. So blocks that are on the same level as the surface height become grass and block below that become dirt.

public static int GenerateHeight(float x, float y, float maxHeight, float smooth, int octaves)
    {
        float height = Map(0, maxHeight, 01, fBM(x * smooth, y * smooth, octaves, persistance));
        return (int)height;
    }
 
    private static float fBM(float x, float z, int oct, float pers)
    {
        float total = 0;
        float frequency = 1;
        float amplitude = 1;
        float maxValue = 0;
 
        for (int i = 0; i < oct; i++)
        {
            total += Mathf.PerlinNoise((x + offset) * frequency, (z + offset) * frequency) * amplitude;
 
            maxValue += amplitude;
 
            amplitude *= pers;
            frequency *= 2;
        }
 
        return total / maxValue;
    }
Figure 7: Terrain generated with Perlin Noise (this is from an older version)

2.6.2 Caves

Caves are very similar to landscape. The only big difference is that caves use the 3D version of Perlin Noise. The criteria for a cave to get generated is: The block that is getting replaced with air isn’t water (This avoids caves being generated in water), the cave is beneath stone level and above bedrock level. The 3D noise method uses the same method as landscapes, but in a 3D variant.

private void GenerateCaves(int z, int y, int x, Vector3 pos, int worldX, int worldY, int worldZ, int stoneHeight)
    {
        //Create cave using 3D perlin noise
        //Don't create caves in water
        if (!(chunkData[x, y, z] is Water&& Utils.fBM3D(worldX, worldY, worldZ, cavesSmooth, cavesOctaves) < 0.42f && worldY > 1 && worldY <= stoneHeight)
            chunkData[x, y, z] = new Air(pos, chunkGameObject.gameObject, this);
    }
public static float fBM3D(float x, float y, float z, float sm, int oct)
    {
        float XY = fBM(x * sm, y * sm, oct, persistance);
        float YZ = fBM(y * sm, z * sm, oct, persistance);
        float XZ = fBM(x * sm, z * sm, oct, persistance);
 
        float YX = fBM(y * sm, z * sm, oct, persistance);
        float ZY = fBM(z * sm, y * sm, oct, persistance);
        float ZX = fBM(z * sm, x * sm, oct, persistance);
 
        return (XY + YZ + XZ + YX + ZY + ZX) / 6.0f;
    }
Figure 8: Caves generated with Perlin Noise 3D (this is from an older version)

2.6.3 Water

Water is unique, because it is a liquid. That means that you’re supposed to see the blocks around it. The water also has to be transparent with no collider. I created a new GameObject for water, called fluid. After drawing every block, I start drawing the fluid blocks. This means that I have two separate draw calls. The only difference is that this only draws the fluids, use a transparent texture, and doesn’t create a collider.

Here is an example of how water gets generated. Whenever the block is below 40, it gets filled with water. This happens after we have been through every block possibility! You might think that caves are filled with water now, but that’s not true. Caves aren’t generated yet at this point, which means that there is nothing to fill.

else if (worldY < waterHeight)
            //Fill blocks below 40 with water
            chunkData[x, y, z] = new Water(pos, chunkGameObject.gameObject, this);
        else
            chunkData[x, y, z] = new Air(pos, chunkGameObject.gameObject, this);

2.6.4 Trees

I have added two types of trees to my game. Normal trees and Birch trees. A tree is build out of 3 components: The WoodBase, wood and leaves. Trees don’t get generated in the BuildChunk method, because it happens a little bit different. The BuildChunk method only generates the WoodBase blocks at a random position. While placing the block, the code checks if there are no other WoodBase blocks around to avoid trees being too close to each other.

The moment when the chunk starts to draw, trees get generated. WoodBase objects have their own method called BuildTree. This method replaces the block around them with tree components such a wood and leaves.

public override void BuildTree(Vector3 pos)
    {
        int x = (int)pos.x;
        int y = (int)pos.y;
        int z = (int)pos.z;
 
        for (int w = 1; w <= 6; w++)
        {
            //Place wood
            Block t = GetBlock(x, y + w, z);
            if (t != null)
            {
                owner.ReplaceBlock(t, new Birch(t.position, t.parent, t.owner));
            }
 
            //Middle part of the tree
            if (w == 3 || w == 4)
            {
                for (int i = -2; i <= 2; i++)
                    for (int j = -2; j <= 2; j++)
                    {
                        if (j == -2 && i == -2 && UnityEngine.Random.Range(0100< leavesSpawnChance)
                            continue;
 
                        if (j == -2 && i == 2 && UnityEngine.Random.Range(0100< leavesSpawnChance)
                            continue;
 
                        if (j == 2 && i == -2 && UnityEngine.Random.Range(0100< leavesSpawnChance)
                            continue;
 
                        if (j == 2 && i == 2 && UnityEngine.Random.Range(0100< leavesSpawnChance)
                            continue;
 
                        t = GetBlock(x + i, y + w, z + j);
                        if (t != null)
                        {
                            //Fill center with wood, otherwise place leaves
                            if (i == 0 && j == 0)
                                owner.ReplaceBlock(t, new Birch(t.position, t.parent, t.owner));
                            else
                                owner.ReplaceBlock(t, new BirchLeaves(t.position, t.parent, t.owner));
                        }
                    }
            }
 
            //Upper part of the tree
            if (w == 5)
            {
                for (int i = -1; i <= 1; i++)
                    for (int j = -1; j <= 1; j++)
                    {
                        if (j == -1 && i == -1 && UnityEngine.Random.Range(0100< leavesSpawnChance)
                            continue;
 
                        if (j == -1 && i == 1 && UnityEngine.Random.Range(0100< leavesSpawnChance)
                            continue;
 
                        if (j == 1 && i == -1 && UnityEngine.Random.Range(0100< leavesSpawnChance)
                            continue;
 
                        if (j == 1 && i == 1 && UnityEngine.Random.Range(0100< leavesSpawnChance)
                            continue;
 
                        t = GetBlock(x + i, y + w, z + j);
                        if (t != null)
                        {
                            if (i == 0 && j == 0)
                                owner.ReplaceBlock(t, new Birch(t.position, t.parent, t.owner));
                            else
                                owner.ReplaceBlock(t, new BirchLeaves(t.position, t.parent, t.owner));
                        }
                    }
            }
 
            //Top part of the tree
            if (w == 6)
            {
                for (int i = -1; i <= 1; i++)
                    for (int j = -1; j <= 1; j++)
                    {
                        if (j == -1 && i == -1)
                            continue;
 
                        if (j == -1 && i == 1)
                            continue;
 
                        if (j == 1 && i == -1)
                            continue;
 
                        if (j == 1 && i == 1)
                            continue;
 
                        t = GetBlock(x + i, y + w, z + j);
                        if (t != null)
                        {
                            owner.ReplaceBlock(t, new BirchLeaves(t.position, t.parent, t.owner));
                        }
                    }
            }
        }
    }

3. The result

I am very happy with the result. I secretly wanted more, especially biomes, but I did not have enough time for that. Maybe I was just a little bit too ambitious. The code is pretty optimized, especially the the drawing part. However, rendering new chunks during runtime, while the player is moving can still be pretty heavy for the CPU. Luckily that wasn’t my main focus of the game.

I did not achieve every goal sadly, because I ran out of time. The biggest goal that I did not achieve was biomes. I had a hard time trying to figure out how to connect two different biomes with each other and since a lot of tasks had more priority, I decided to drop this one. Next goal was to have 30 fps while exploring in-game. This was also something I had trouble with figuring out. I wanted to apply Frustum Culling to my game, but I couldn’t figure out how to only render something the player is able to see, while it’s not even there yet.

I had a lot of fun with this project and will definitely continue this project. It was very common for me to rework a lot of code, and this took a lot of time. It was still very worth it in my opinion, because a lot of stuff wouldn’t be possible if I didn’t do that.

Figure 13: World generation in action

4. Future work

I have many plans for the future! I want to start with creating chunks with a height of 256 blocks instead of 16. This will get it of many calculations between chunks. I have already started with this. Currently, I am rendering chunks with a height of 256 blocks, but this needs optimization. Having to redraw a chunk with the size of 16x256x16 for every block interaction isn’t optimal. That’s why I have to implement Frustum Culling next. My plan is to only render parts of the chunk that the player can see. I am not sure how to tackle this yet, but that will come eventually!

Figure 14: 16x256x16 chunks

After all that it’s time to expand the game. I want to work on new biomes, trees and caves. I also want ores to spawn in groups with a minimum and maximum size.


5. Sources

Lague, S (2018, November 3). [Unity] Procedural Object Placement (E01: poisson disc sampling) [Internet Resource]. YouTube. https://www.youtube.com/watch?v=7WcmyxyFO7o

AntVenom (2017, October 23). HOW do MINECRAFT WORLDS GENERATE? [Internet Resource]. YouTube. https://www.youtube.com/watch?v=FE5S2NeD7uU

SebLague (2019, May 7). Marching-Cubes [Internet Resource]. GitHub. https://github.com/SebLague/Marching-Cubes

Google Sites (2020, December 6). Let’s Make a Voxel Engine [Internet Resource]. Google Sites. https://sites.google.com/site/letsmakeavoxelengine/home

de Byl, P (2020, December). How to Program Voxel Worlds Like Minecraft with C# in Unity [Internet Resource]. Udemy. https://www.udemy.com/course/unityminecraft/

lolpro311. (2017, February 17). lolpro’s vanilla server [Image]. PlanetMinecraft. https://www.planetminecraft.com/server/lolpros-vanilla-server/

Gamepedia. Visualization of the ground portion of a single chunk. The entire chunk extends up to a height of 256 [Image]. Minecraft Fandom. https://minecraft.fandom.com/wiki/Chunk


Related Posts