AlexStv

Unity voxel block tutorial pt. 3

Once again welcome to the voxel block tutorial, this time we're going to expand to multiple chunks and the system to handle infinite terrain.

We'll start this part by adding a new script called world for handling the larger scale of our voxel terrain that's going to be the focus of this part but before we can create that script we'll need to set up a bit first. the world script will need a collection variable for the chunks it manages that allows for easy access based on their 3d positions and lets us add and remove chunks arbitrarily. In the last tutorial I used a 3 dimensional array because we could store chunks where their index represented their position in 3 dimensions but an array wasn't the best idea for infinite terrain and a lot of people were interested in that so this time we'll use a dictionary collection.

A dictionary stores values along with a key that can be any type, then you can look up a value by its key making it fast to find a value without knowing its index as long as you know its key

To use a dictionary we're going to create a struct to use as a key, this struct will be the chunk's position in the world so so before we can start working on the world script create a script called WorldPos:

 using UnityEngine;
 using System.Collections;
 public struct WorldPos
 {
     public int x, y, z;
     public WorldPos(int x, int y, int z)
     {
         this.x = x;
         this.y = y;
         this.z = z;
     }
 }

World position is what we'll use for any voxel positions that need to be stored as a single value and it can be used as a key in our dictionary. In it you can see we have three ints, x, y and z and a constructor that assigns all three. Now we will also override the default Equals function with our own because it's a little faster than the default value type Equals.

     public struct WorldPos
     {
         public int x, y, z;
         public WorldPos(int x, int y, int z)
         {
             this.x = x;
             this.y = y;
             this.z = z;
         }
         //Add this function:
         public override bool Equals(object obj)
         {
             if (!(obj is WorldPos))
                 return false;
             WorldPos pos = (WorldPos)obj;
             if (pos.x != x || pos.y != y || pos.z != z)
             {
                 return false;
             }
             else
             {
                 return true;
             }
         }
     }

What this does is check if the object being compared is a WorldPos and that x, y and z are equal to this one's x, y and z. This gives us a variable for positions that can quickly be compared.

Now create the World script and use the automatically generated content but add this line at the top of the file with the other includes:

using System.Collections.Generic;

This let's us use the dictionary. Next let's add the dictionary variable:

     public Dictionary<WorldPos, Chunk> chunks = new Dictionary<WorldPos, Chunk>();

It defines a dictionary with WorldPos as the key and Chunk as the value. We can use this to store as many chunks as we want in any position and out methods will look them up based on their positions.

Let's start with a function to create new chunks, since chunks are game objects we'll need to use the instantiate method with a prefab to create the chunks so create a variable chunkPrefab:

     public GameObject chunkPrefab;

And also create a prefab of a chunk. Use the chunk object we've been using or create a new one. It just needs to be a game object with the material applied and chunk script which automatically includes the mesh renderer, mesh filter and mesh collide . By the way make sure your mesh collider has convex set to false or your colliders wont work. Make a prefab of it by dragging it from the hierarchy to the project view. Then delete it from the scene and make a new object with the world script on it and set the chunkPrefab variable to the one we just made.

We'll create a function to add chunks to the game but first we need change the chunk script a little so that they're initialized from world not on their own. So in the chunk script's Start function remove everything except the lines that fetch the mesh filter and mesh collider so it looks like this:

//Chunk.cs
    void Start () {
         filter = gameObject.GetComponent<MeshFilter>();
         coll = gameObject.GetComponent<MeshCollider>();
    }

And also change the blocks variable to set its size when it's defined:

//chunk.cs
     private Block[ , , ] blocks = new Block[chunkSize, chunkSize, chunkSize];

And while we're here add a World variable so chunks always have a reference to the world containing them and a WorldPos variable so the chunk has easy access to it's position in the world without using floats through Transform.position:

     public World world;
     public WorldPos pos;

Now in the World.cs script we can start creating chunks with a new function in the world script, open the world file to add a new function:

     public void CreateChunk(int x, int y, int z)
     {
         WorldPos worldPos = new WorldPos(x, y , z);
 
         //Instantiate the chunk at the coordinates using the chunk prefab
         GameObject newChunkObject = Instantiate(
                         chunkPrefab, new Vector3(x, y, z),
                         Quaternion.Euler(Vector3.zero)
                     ) as GameObject;
 
         Chunk newChunk = newChunkObject.GetComponent<Chunk>();
 
         newChunk.pos = worldPos;
         newChunk.world = this;
 
         //Add it to the chunks dictionary with the position as the key
         chunks.Add(worldPos, newChunk);
     }

Here we instantiate the prefab in the world at the coordinates supplied then set the chunk's pos and world variables and lastly add it to the dictionary.

Let's put something together that lets us test this. In the world start function add the following to create some chunks on startup:

void Start()
{
    for (int x = -2; x < 2; x++)
    {
        for (int y = -1; y < 1; y++)
        {
            for (int z = -1; z < 1; z++)
            {
                CreateChunk(x * 16, y * 16, z * 16);
            }
        }
    }
}

Now we need functions in the world script that can get and set specific blocks in the world. We want the world script to be able to call get block with a global coordinate and then get the correct chunk and get the block from it. We'll need to first find the chunk containing this block, then call that chunk's get block script to return the block:

     public Chunk GetChunk(int x, int y, int z)
     {
         WorldPos pos = new WorldPos();
         float multiple = Chunk.chunkSize;
         pos.x = Mathf.FloorToInt(x / multiple ) * Chunk.chunkSize;
         pos.y = Mathf.FloorToInt(y / multiple ) * Chunk.chunkSize;
         pos.z = Mathf.FloorToInt(z / multiple ) * Chunk.chunkSize;
         Chunk containerChunk = null;
         chunks.TryGetValue(pos, out containerChunk);
 
         return containerChunk;
     }
     public Block GetBlock(int x, int y, int z)
     {
         Chunk containerChunk = GetChunk(x, y, z);
         if (containerChunk != null)
         {
             Block block = containerChunk.GetBlock(
                 x - containerChunk.pos.x,
                 y -containerChunk.pos.y,
                 z - containerChunk.pos.z);
 
             return block;
         }
         else
         {
             return new BlockAir();
         }
 
     }

So to find which chunk the block we need is contained in we divide the coordinates by the chunk size as a float then call the FloorToInt function which strips off everything beyond a multiple of the chunk size, then multiplying it by the size again gives us the coordinates of the chunk. I use the variable multiple so that the chunk size is a float when dividing because dividing two integers will give us trouble if they are negative. Then TryGetValue looks up the key in the dictionary and assigns the containerChunk with the result if found.

In the GetBlock function we create a chunk variable and try to assign the chunk we're looking for to it using the new GetChunk function. If it's successful and it returns something other than null we can call GetBlock in that chunk to get the block. We use the difference of the block coordinates and the chunk's which will give us the local location of the block. If the lookup fails we return a placeholder, something that we can use as a backup. Air works because it forces the side of the chunk to render everything so nothing is left unrendered but it does create unnecessary graphics work.

Now we have a little work on the other end in the Chunk, open the chunk script so we can look at the GetBlock function there, we left this unfinished before because we needed the world script to continue. What we'll do is check if the coordinates are within this chunk, if so return the chunk, otherwise link to the work script that can access the chunk that does contain it.

     public Block GetBlock(int x, int y, int z)
     {
         if(InRange(x) && InRange(y) && InRange(z))
             return blocks[x, y, z];
         return world.GetBlock(pos.x + x, pos.y + y, pos.z + z);
     }
     //new function
     public static bool InRange(int index)
     {
         if (index < 0 || index >= chunkSize)
             return false;
 
         return true;
     }

I added the function InRange to check coordinates quickly.

While we're in the chunk script let's also add a function to set blocks in this chunk,

     public void SetBlock(int x, int y, int z, Block block)
     {
         if (InRange(x) && InRange(y) && InRange(z))
         {
             blocks[x, y, z] = block;
         }
         else
         {
             world.SetBlock(pos.x + x, pos.y + y, pos.z + z, block);
         }
     }

It simply sets the block in the position described to the block provided and if it's not in range sends it to the world script. The world.SetBlock script is not created yet but it's fairly straight forward to imagine what it will do. So now in the world script lets add it:

     public void SetBlock(int x, int y, int z, Block block)
     {
         Chunk chunk = GetChunk(x, y, z);
 
         if (chunk != null)
         {
             chunk.SetBlock(x - chunk.pos.x, y - chunk.pos.y, z - chunk.pos.z, block);
             chunk.update = true;
         }
     }

We just need to find the correct chunk and call SetBlock within it. After that we need to set update to true so that the chunk updates when it has the chance to show our new changes. Just setting this boolean isn't enough however so in the chunk script let's make chunks update when the flag is true:

     //Update is called once per frame
     void Update()
     {
         if (update)
         {
             update = false;
             UpdateChunk();
         }
     }

Now chunks can communicate over borders because requests for block data will be routed through the world class to the correct chunk. Seamless.

Our start function creates a bunch of chunks so that's in place but they're all empty so we'll fill them with some test blocks. Add to the CreateChunk function the following:

     public void CreateChunk(int x, int y, int z)
     {
         //the coordinates of this chunk in the world
         WorldPos worldPos = new WorldPos(x, y, z);
 
         //Instantiate the chunk at the coordinates using the chunk prefab
         GameObject newChunkObject = Instantiate(
                         chunkPrefab, new Vector3(worldPos.x, worldPos.y, worldPos.z),
                         Quaternion.Euler(Vector3.zero)
                     ) as GameObject;
 
         //Get the object's chunk component
         Chunk newChunk = newChunkObject.GetComponent<Chunk>();
 
         //Assign its values
         newChunk.pos = worldPos;
         newChunk.world = this;
 
         //Add it to the chunks dictionary with the position as the key
         chunks.Add(worldPos, newChunk);
 
         //Add the following:
         for (int xi = 0; xi < 16; xi++)
         {
             for (int yi = 0; yi < 16; yi++)
             {
                 for (int zi = 0; zi < 16; zi++)
                 {
                     if (yi <= 7)
                     {
                         SetBlock(x+xi, y+yi, z+zi, new BlockGrass());
                     }
                     else
                     {
                         SetBlock(x + xi, y + yi, z + zi, new BlockAir());
                     }
                 }
             }
         }
     }

This will give us a little content to see that it's working so that when you run it you should be able to see something like this:

[image]../../images/voxsw.png[/image]

Cool! Last of all let's also add a function to remove chunks which we'll need later when we're loading and unloading our infinite terrain:

     public void DestroyChunk(int x, int y, int z)
     {
         Chunk chunk = null;
         if (chunks.TryGetValue(new WorldPos(x, y, z), out chunk))
         {
             Object.Destroy(chunk.gameObject);
             chunks.Remove(new WorldPos(x, y, z));
         }
     }

This takes the coordinates of a chunk and removes it from the world and from our dictionary freeing up space for chunks closer to the player.

With that we'll call this part complete, now we can create any number of chunks in any location and remove them again. In the next part we'll look into modifying the terrain using set block and getting block positions in the scene from the camera's view.

Tutorial part 2Download scripts so far Tutorial part 4

29th Dec, 2014
Alex