AlexStv

Unity voxel block tutorial pt. 1

I've done a voxel tutorial before that got a lot of attention and I figured it might be time for a second version that's a bit more advanced and more extendible. So this time we're going to delve a little deeper into making a maintainable and flexible system. this tutorial will cover some advanced topics so it might not be for everyone. This tutorial will cover 3d blocks and real time editing so I can't promise I can help you with marching cubes, water simulations, minecraft clones etc.

For those of you who didn't see my original tutorial it's here.

This is an advanced tutorial that requires existing knowledge with Unity and C#.

I want to cover a lot in this tutorial but it may take some time to get to everything. We'll get the basic rendering and 3d mesh stuff done first I think, then we'll take a look at making it load infinite terrain because so many people asked for it. We'll create a method for saving and loading terrain because that will be vital for a game with a world you can edit.

We'll start with an empty unity project but if you want you can do this in an existing project. We'll start with chunks and blocks.

A chunk is a section of voxels that are rendered together. I've used chunks of 16x16x16 voxels before but larger chunks (32x32x32) will lower your draw calls which improves rendering performance and smaller chunks (16x16x16) mean smaller updates when you change a block (because the entire chunk needs to be reloaded) or load the chunk. Unity has a maximum face count per mesh and each chunk has one mesh so if your chunk is too big and exceeds that it won't render.

Create 3 C# scripts, one called Chunk, one called Block and one called MeshData. Chunk's tasks are to store the data of their contents and create a mesh of their contained voxels for rendering and collisions. The Block will be a class that contains information about one block and how it should be rendered. MeshData will provide us with an easy way to store mesh data.

Now we'll start in the Chunk script by adding the necessary functions and variables.

//Chunk.cs
using UnityEngine;
using System.Collections;
[RequireComponent(typeof(MeshFilter))]
[RequireComponent(typeof(MeshRenderer))]
[RequireComponent(typeof(MeshCollider))]
public class Chunk : MonoBehaviour {
     Block[ , , ] blocks;
     public static int chunkSize = 16;
     public bool update = true;
    //Use this for initialization
    void Start () {
    }
    //Update is called once per frame
    void Update () {
    }
     public Block GetBlock(int x, int y, int z)
     {
         return blocks[x, y, z];
     }
     //Updates the chunk based on its contents
     void UpdateChunk()
     {
     }
     //Sends the calculated mesh information
     //to the mesh and collision components
     void RenderMesh()
     {
     }
}

First of all, we're including three components, MeshFilter, MeshRenderer and MeshCollider. These are included here so that when we create a gameobject with this script they are added automatically. We need those components to render the mesh and create the collider for the chunk.

Next the variables. "blocks" is a three dimensional array of Block classes, this way the Block class can store all the information we need about block type, meta data, etc. in the Block class. The static variable chunkSize will determine how many voxels in each direction our chunk will have. Lastly we'll use update as a flag that this chunk has been changed and will need to be updated at the end of the frame.

For the functions I've just added GetBlock to return blocks from the the chunk. UpdateChunk which will loop through all it's blocks building a 3d mesh based on their presence and types and then send it to RenderMesh which will send all our mesh data into the unity components.

Next let's move onto our MeshData script because we'll need this before we move on to the Block. Lay out the MeshData script like this:

//MeshData.cs
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
public class MeshData {
     public List<Vector3> vertices = new List<Vector3>();
     public List<int> triangles = new List<int>();
     public List<Vector2> uv = new List<Vector2>();
    public List<Vector3> colVertices = new List<Vector3>();
    public List<int> colTriangles = new List<int>();
    public MeshData() { }
}

Note that it doesn't inherit from MonoBehaviour, it doesn't need to because we won't be using it in the scene only through code and this let's us create it through the constructor with "new MeshData();" any time.

The variables here are the three components for the mesh: verts, tris and texture coordinates and two for the collider mesh, the verts and tris.

Vertices are vector 3 positions in space that define the points or corners of every triangle in the mesh. Every three entries in the triangles list defines one triangle and the entry itself is the index of a vector 3 in the vertices array. The uv list is a vector 2 list of texture coordinates and there are two entries per triangle, the coordinates of the lower left of the triangle and the upper right of the triangle.

The col lists are the same as vertices and triangles but for use as the collider mesh so that you can pass a different mesh for the rendering and the collider.

Now we can move on to the Block script. The Block class is going to be the base class all other block types will inherit from. This will mean that every block will have a class where we can define how it's rendered and store variables like damage values. We'll set up the base class with default functions that can be overridden. Start with

using UnityEngine;
using System.Collections;
public class Block {
     //Base block constructor
     public Block(){
     }
     public virtual MeshData Blockdata
         (Chunk chunk, int x, int y, int z, MeshData meshData)
     {
         return meshData;
     }
}

Start with this, here we have a class called Block that doesn't inherit from anything, then we have the constructor with no parameters. This base class' constructor can be called as part of the constructor of any sub class so code that we want to run for the construction of all blocks can go in this constructor. We also have the BlockData function that returns MeshData. This function will be called by the chunk for every block it contains so that the block can provide its own mesh information. We'll fill this out a little later.

Now we need a function to check if blocks are solid or not so that we can decide whether or not to render blocks adjacent to it.

When we render voxels we don't need to render every side of every voxel. Some faces will be impossible for the player to see because they are covered by other voxels. In the case of square voxels we can always assume that a block adjacent to another the sides facing each other aren't visible so we don't need to render them. It's simple enough to only render faces of blocks facing empty spaces but we'll also handle non-cube blocks.

We will let every block be solid or non-solid on a face by face basis, this means that something like a ramp block would be solid on the bottom and the north face but not on the others so the upwards face of the block under it won't render but the bottom face of a block above it would. For this we need a struct to describe the direction:

     public enum Direction { north, east, south, west, up, down };

and a function for getting the solidity of a block's face:

     public virtual bool IsSolid(Direction direction)
     {
         switch(direction){
             case Direction.north:
                 return true;
             case Direction.east:
                 return true;
             case Direction.south:
                 return true;
             case Direction.west:
                 return true;
             case Direction.up:
                 return true;
             case Direction.down:
                 return true;
         }
         return false;
     }

Because this function is virtual other blocks types can override it with their own solidity. The direction is sent as a parameter, this will be the direction of the face of the block being checked. So if up is the parameter it returns the solidity of the top face of the block. In the case of our base block we return true for all the cases because the base block will be a cube. For cubes we could just return true since all directions will be true but in this case it's nice to have an example of code that can be extended for any block with any combination of solid faces.

Now we can start filling in the BlockData function. For each face we want to render we need to check if the block in that direction is solid or not and if so we'll create some data for the block.

     public virtual MeshData Blockdata
         (Chunk chunk, int x, int y, int z, MeshData meshData)
     {
 
         if (!chunk.GetBlock(x, y + 1, z).IsSolid(Direction.down))
         {
             meshData = FaceDataUp(chunk, x, y, z, meshData);
         }
 
         if (!chunk.GetBlock(x, y - 1, z).IsSolid(Direction.up))
         {
             meshData = FaceDataDown(chunk, x, y, z, meshData);
         }
 
         if (!chunk.GetBlock(x, y, z + 1).IsSolid(Direction.south))
         {
             meshData = FaceDataNorth(chunk, x, y, z, meshData);
         }
 
         if (!chunk.GetBlock(x, y, z - 1).IsSolid(Direction.north))
         {
             meshData = FaceDataSouth(chunk, x, y, z, meshData);
         }
 
         if (!chunk.GetBlock(x + 1, y, z).IsSolid(Direction.west))
         {
             meshData = FaceDataEast(chunk, x, y, z, meshData);
         }
 
         if (!chunk.GetBlock(x - 1, y, z).IsSolid(Direction.east))
         {
             meshData = FaceDataWest(chunk, x, y, z, meshData);
         }
 
         return meshData;
 
     }
 
     protected virtual MeshData FaceDataUp
         (Chunk chunk, int x, int y, int z, MeshData meshData)
     {
         return meshData;
     }
 
     protected virtual MeshData FaceDataDown
         (Chunk chunk, int x, int y, int z, MeshData meshData)
     {
         return meshData;
     }
 
     protected virtual MeshData FaceDataNorth
         (Chunk chunk, int x, int y, int z, MeshData meshData)
     {
         return meshData;
     }
 
     protected virtual MeshData FaceDataEast
         (Chunk chunk, int x, int y, int z, MeshData meshData)
     {
         return meshData;
     }
 
     protected virtual MeshData FaceDataSouth
         (Chunk chunk, int x, int y, int z, MeshData meshData)
     {
         return meshData;
     }
 
     protected virtual MeshData FaceDataWest
         (Chunk chunk, int x, int y, int z, MeshData meshData)
     {
         return meshData;
     }

So in the GetMesh function we look at every face and for every face of the block this goes through it finds the adjacent block to that side and checks if the side of the adjacent block facing this block is solid. If the block facing this face is not solid this face is visible so we run the specific function for that side sending it the meshData variable. The function for each side will need to add the data for the side and return the meshData variable back with the additions.

Now we'll fill in the FaceData functions with data starting with the upwards facing side:

//Block.cs
     protected virtual MeshData FaceDataUp
         (Chunk chunk, int x, int y, int z, MeshData meshData)
     {
         meshData.vertices.Add(new Vector3(x - 0.5f, y + 0.5f, z + 0.5f));
         meshData.vertices.Add(new Vector3(x + 0.5f, y + 0.5f, z + 0.5f));
         meshData.vertices.Add(new Vector3(x + 0.5f, y + 0.5f, z - 0.5f));
         meshData.vertices.Add(new Vector3(x - 0.5f, y + 0.5f, z - 0.5f));
         meshData.AddQuadTriangles();
         return meshData;
     }

I added a function to add the triangles to the array within the MeshData class for square meshes, it takes the last four added vertices and creates two triangles to make a quad with those vertices so we'll add that to the MeshData script before we continue:

 //MeshData.cs
     public void AddQuadTriangles()
     {
         triangles.Add(vertices.Count - 4);
         triangles.Add(vertices.Count - 3);
         triangles.Add(vertices.Count - 2);
         triangles.Add(vertices.Count - 4);
         triangles.Add(vertices.Count - 2);
         triangles.Add(vertices.Count - 1);
     }

Lets take a look at FaceDataUp, our first quad, we add four vertices to the vertices list, one for each corner of the quad. They are added in clockwise order. Then we call the AddQuadTriangles function which assumes that the last four entries in the vertices array make a quad so we make two triangles. The first one uses the first, second and third vertices, the second one uses the first, third and fourth vertices. This makes a quad of two triangles.

Now we'll do this for the other faces as well with different values for the vertex positions.

//Block.cs
     protected virtual MeshData FaceDataDown
     (Chunk chunk, int x, int y, int z, MeshData meshData)
     {
         meshData.vertices.Add(new Vector3(x - 0.5f, y - 0.5f, z - 0.5f));
         meshData.vertices.Add(new Vector3(x + 0.5f, y - 0.5f, z - 0.5f));
         meshData.vertices.Add(new Vector3(x + 0.5f, y - 0.5f, z + 0.5f));
         meshData.vertices.Add(new Vector3(x - 0.5f, y - 0.5f, z + 0.5f));
 
         meshData.AddQuadTriangles();
         return meshData;
     }
 
     protected virtual MeshData FaceDataNorth
         (Chunk chunk, int x, int y, int z, MeshData meshData)
     {
         meshData.vertices.Add(new Vector3(x + 0.5f, y - 0.5f, z + 0.5f));
         meshData.vertices.Add(new Vector3(x + 0.5f, y + 0.5f, z + 0.5f));
         meshData.vertices.Add(new Vector3(x - 0.5f, y + 0.5f, z + 0.5f));
        meshData.vertices.Add(new Vector3(x - 0.5f, y - 0.5f, z + 0.5f));
 
         meshData.AddQuadTriangles();
         return meshData;
     }
 
     protected virtual MeshData FaceDataEast
         (Chunk chunk, int x, int y, int z, MeshData meshData)
     {
         meshData.vertices.Add(new Vector3(x + 0.5f, y - 0.5f, z - 0.5f));
         meshData.vertices.Add(new Vector3(x + 0.5f, y + 0.5f, z - 0.5f));
         meshData.vertices.Add(new Vector3(x + 0.5f, y + 0.5f, z + 0.5f));
         meshData.vertices.Add(new Vector3(x + 0.5f, y - 0.5f, z + 0.5f));
 
         meshData.AddQuadTriangles();
         return meshData;
     }
 
     protected virtual MeshData FaceDataSouth
         (Chunk chunk, int x, int y, int z, MeshData meshData)
     {
         meshData.vertices.Add(new Vector3(x - 0.5f, y - 0.5f, z - 0.5f));
         meshData.vertices.Add(new Vector3(x - 0.5f, y + 0.5f, z - 0.5f));
         meshData.vertices.Add(new Vector3(x + 0.5f, y + 0.5f, z - 0.5f));
         meshData.vertices.Add(new Vector3(x + 0.5f, y - 0.5f, z - 0.5f));
 
         meshData.AddQuadTriangles();
         return meshData;
     }
 
     protected virtual MeshData FaceDataWest
         (Chunk chunk, int x, int y, int z, MeshData meshData)
     {
         meshData.vertices.Add(new Vector3(x - 0.5f, y - 0.5f, z + 0.5f));
         meshData.vertices.Add(new Vector3(x - 0.5f, y + 0.5f, z + 0.5f));
         meshData.vertices.Add(new Vector3(x - 0.5f, y + 0.5f, z - 0.5f));
         meshData.vertices.Add(new Vector3(x - 0.5f, y - 0.5f, z - 0.5f));
 
         meshData.AddQuadTriangles();
         return meshData;
     }

All of these follow the same concept as the first face, four coordinates, one for each corner of the face. The center of the block is the origin of the block or the location of x, y, z.

I'm not going through every function but when eventually you want to build your own blocks that aren't cubes you'll have to get a good understanding of placing these vertices and making triangles with them.

Now we will quickly create the second basic block type: Air. Create a new script called BlockAir and open it. We're going to inherit from Block and override a few functions.

using UnityEngine;
using System.Collections;
public class BlockAir : Block
{
     public BlockAir()
         : base()
     {
     }
     public override MeshData Blockdata
         (Chunk chunk, int x, int y, int z, MeshData meshData)
     {
         return meshData;
     }
     public override bool IsSolid(Block.Direction direction)
     {
         return false;
     }
}

This class inherits Block and its constructor calls the base class' constructor. Then we override BlockData to return the mesh data unchanged and we override IsSolid to return false for all directions.

Let's turn our attention back to the chunk script now where we'll set up the Start function:

     MeshFilter filter;
     MeshCollider coll;
     // Use this for initialization
     void Start () {
         filter = gameObject.GetComponent<MeshFilter>();
         coll = gameObject.GetComponent<MeshCollider>();
         //past here is just to set up an example chunk
         blocks = new Block[chunkSize, chunkSize, chunkSize];
         for (int x = 0; x < chunkSize; x++)
         {
             for (int y = 0; y < chunkSize; y++)
             {
                 for (int z = 0; z < chunkSize; z++)
                 {
                     blocks[x, y, z] = new BlockAir();
                 }
             }
         }
         blocks[3, 5, 2] = new Block();
         UpdateChunk();
     }

Note the added filter and coll variables, we need the Mesh Filter and Mesh Collider components to assign the mesh data to. Then we need to set up a block for testing temporarily.

Now we'll have the update chunk function use every block's BlockData function to create the mesh and then send it to RenderMesh.

     // Updates the chunk based on its contents
     void UpdateChunk()
     {
         MeshData meshData = new MeshData();
         for (int x = 0; x < chunkSize; x++)
         {
             for (int y = 0; y < chunkSize; y++)
             {
                 for (int z = 0; z < chunkSize; z++)
                 {
                     meshData = blocks[x, y, z].Blockdata(this, x, y, z, meshData);
                 }
             }
         }
         RenderMesh(meshData);
     }
     // Sends the calculated mesh information
     // to the mesh and collision components
     void RenderMesh(MeshData meshData)
     {
         filter.mesh.Clear();
         filter.mesh.vertices = meshData.vertices.ToArray();
         filter.mesh.triangles = meshData.triangles.ToArray();
     }

UpdateChunk loops through all the blocks updating meshData based on the results and calls RenderMesh with meshData. Note that I added MeshData as a parameter of RenderMesh as this is what we will build the mesh from. Then the unity component Mesh Filter has a mesh variable so we can clear it and assign vertices and triangles to it from the mesh data.

Now we can run this and make sure it all works. Create a game object and place the Chunk script on it, this should also add the Mesh Filter, Mesh Renderer and Mesh Collider. Running it should render one textureless cube.

[image]../../images/voxelrun1.png[/image]

Note that for now any blocks on the edge of the chunk will cause out of bounds errors because they will try to check the solidity of blocks outside the chunk, we will address this later.

If your project isn't working at this point try downloading the scripts from this tutorial below and see if yours match. If you can't solve it then post below with your problem and any errors you're receiving. If you find any errors in my tutorial or have suggestions I would be grateful to hear them as well.

In part 2 we'll add textures and a collision mesh.

Download scripts so far Tutorial part 2

8th Dec, 2014
Alex