Adventures in Programming: 1-2 Voxel Terrain Generation – Generating and Optimising Chunks (+Textures based on block type)

Welcome back to my Voxel Terrain generation blog series thing. If you’ve followed the last post, you should have a basic cube rendering with a simple grass texture on each face. This time, I will be going over chunk generation.

A chunk is the term given to a group of ‘blocks’ combined into a single mesh. By doing this, we can drastically reduce the amount of time taken to generate terrain. This is the same system that most voxel terrain system use, including the king of voxel sandboxes, Minecraft (the game that got me into game dev).

First, we should create a script called BlockDatabase.cs. This script will contain the information for all of our block types, starting with the information about the specific textures from our atlas to use on each face. We are doing this step first so that we won’t have to change our chunk generation code later.

Here is the code that our BlockDatabase class will contain.

public class BlockDatabase : MonoBehaviour
{
    public enum BlockType { AIR, GRASS, DIRT, STONE };
    public static BlockDatabase instance;

    [System.Serializable]
    public class BlockData
    { 
        public BlockType type;
        public Vector2 frontOffset;
        public Vector2 backOffset;
        public Vector2 leftOffset;
        public Vector2 rightOffset;
        public Vector2 topoffset;
        public Vector2 bottomOffset;
    }

    public List<BlockData> blockData = new List<BlockData>();

    private void Awake()
    {
        if (!instance)
            instance = this;
    }
}

This class is very basic. It simply stores the texture offsets for each face of each block type in a list. We also have a static instance of this class so that we can easily access it.

Next, we will create a new C# script called Chunk.cs. This class will be used to handle our chunk generation. The first step for this class is to add our required components, we will need a MeshFilter, a MeshRenderer and a MeshCollider. Next, we will set up a dictionary to store all of the chunks block information. Each entry will be made up of a position and a block type. We will also declare a integer value that will act as our chunk size.

public int chunkSize = 16; //Chunk will be made up of chunkSize*chunkSize*chunkSize blocks.

public Dictionary blockPositions = new Dictionary();

We will then add a new function called generate block positions. Eventually, this function will be used to apply the noise for our terrain, however, for now, it will simply create a chunkSize*chunkSize*chunkSize grass blocks of cubes, however, it will not generate the meshes yet.

 void GeneratePositions()
    {
        for (int x = 0; x < chunkSize; x++)
        {
            for (int y = 0; y < chunkSize; y++)
            {
                for (int z = 0; z < chunkSize; z++)
                {
                    blockPositions.Add(new Vector3Int(x,y,z), BlockDatabase.BlockType.GRASS);
                }
            }
        }

        GenerateChunk();
    }

Next we need to create the GenerateChunk function, however, for now we will leave this function empty, as there are a few more steps we must take in order to optimise our generation. Since we are grouping blocks into larger segments of the world, and the player will only be able to see surface blocks, it is a waste of time to generate any of the faces inside of the chunks. Hence we must create a function that will take a specific position and check the positions around it. If there is a block there, the face on that side will not be rendered. This function will be called CheckAdjacentBlocks() and will take a Vector3 position as a parameter.

List<int> CheckAdjacentPositions(Vector3Int pos)
{
    List<int> verts = new List<int>();

    Vector3Int originalPos = pos;

    pos.x -= 1;
    if (!blockPositions.ContainsKey(pos))
        foreach (int i in Block.left)
            verts.Add(i);


    pos.x += 2;
    if (!blockPositions.ContainsKey(pos))
        foreach (int i in Block.right)
            verts.Add(i);


    pos = originalPos;


    pos.y -= 1;
    if (!blockPositions.ContainsKey(pos))
        foreach (int i in Block.bottom)
            verts.Add(i);


    pos.y += 2;
    if (!blockPositions.ContainsKey(pos))
        foreach (int i in Block.top)
            verts.Add(i);

    pos = originalPos;


    pos.z -= 1;
    if (!blockPositions.ContainsKey(pos))
        foreach (int i in Block.front)
            verts.Add(i);

    pos.z += 2;
    if (!blockPositions.ContainsKey(pos))
        foreach (int i in Block.back)
            verts.Add(i);

    return verts;

}

You may also notice that this function requires some integer arrays in our Block class. These integer arrays simply store the lower and upper indexes of the triangles for each face, as such;

//Triangle/UV indexes that make up each face.
public static int[] front = { 0, 5 };
public static int[] back = { 24 , 29 };
public static int[] left = { 18, 23 };
public static int[] right = { 12, 17 };
public static int[] top = { 6, 11 };
public static int[] bottom = { 30, 35 };

Whilst we’re in our block class, we will also add a function to offset our UV positions based on the Vector2 offsets we are storing in our BlockDatabase. The code for this is as follows;

 public static Vector2 GetUV(int index, BlockDatabase.BlockData data)
    {
        Vector2 uv = UVs[index];

        if (index <= 5)
            uv += data.frontOffset;
        else if (index <= 11)
            uv += data.topoffset;
        else if (index <= 17)
            uv += data.rightOffset;
        else if (index <= 23)
            uv += data.leftOffset;
        else if (index <= 29)
            uv += data.backOffset;
        else if (index <= 35)
            uv += data.bottomOffset;


        return uv;
    }

This function takes in an index and a Block data type. It then compares the index to the upper indexs of the UVs for each vertex to determine which face the UV co-ordinate is for. It then offsets the Vector2 position based on the offset given in our BlockDatabase.

Finally, we can get to generating our chunk mesh. In the GenerateChunk() function we defined earlier, we will simply loop through the KeyValue pairs stored in our dictionary, checking each position using our CheckAdjacentPosition() function to determine which faces we need to render. We will then add the correct faces, UV’s (with their offsets based on block type) and triangles to our chunk mesh.

Here is the code for this:

void GenerateChunk()
    {
        List<Vector2> uvs = new List<Vector2>();
        List<Vector3> vertices = new List<Vector3>();
        List<int> triangles = new List<int>();

        foreach (KeyValuePair<Vector3Int, BlockDatabase.BlockType> pair in blockPositions)
        {
            List<int> neededVerts = CheckAdjacentPositions(pair.Key);
            BlockDatabase.BlockData blockData = null;

            foreach (BlockDatabase.BlockData data in BlockDatabase.instance.blockData)
            {
                if (data.type == pair.Value)
                    blockData = data;
            }

            for (int i = 0; i < neededVerts.Count; i += 2)
            {
                for (int t = neededVerts[i]; t <= neededVerts[i + 1]; t++)
                {
                    uvs.Add(Block.GetUV(t, blockData));
                    vertices.Add(Block.vertices[Block.triangles[t]] + pair.Key);
                    triangles.Add(vertices.Count - 1);
                }
            }
        }

        //Build the mesh
        mesh.Clear();
        mesh.vertices = vertices.ToArray();
        mesh.triangles = triangles.ToArray();
        mesh.uv = uvs.ToArray();
        mesh.Optimize();
        mesh.RecalculateNormals();
        collider.sharedMesh = mesh;
    }

There is one final step before we can run our code. Create an empty game object and add the BlockDatabase to it. Add a new element to the list and select grass as the type. If you are using the Texture atlas I provided in the previous post, the offsets are as follows:

Now we can finally run our code. Generating a chunk with a chunk size of 16 will render a 16*16*16 chunk of grass blocks.

This looks quite ugly, however adding different block types based on height is quite simple. Before next time, you may want to attempt to add this feature for yourself to ensure you understand the code. An extra challenge may be to attempt to generate more chunks or add some noise to change the height of the blocks.

Next time, we will be covering all of these topics, and will end up with some very basic voxel terrain generating. After that, we will look into adding trees, water and more interesting terrain.

Leave a Reply

Your email address will not be published. Required fields are marked *