Building a High-Performance Voxel Engine in Unity: A Step-by-Step Guide Part 2: Mesh Generation

CodeComplex
12 min readNov 18, 2023

--

In this section of our voxel engine development journey, we’re delving into one of the most critical and challenging aspects: Advanced Chunk Mesh Generation. This process lies at the heart of how our voxel world is visually represented and is key to both the aesthetic appeal and performance of the engine.

The Importance of Efficient Mesh Generation
In a voxel-based world, the sheer number of individual blocks (voxels) can be overwhelming for any rendering system, especially when dealing with expansive landscapes or complex structures. The goal of advanced mesh generation is to convert the data from these voxels into a format that can be efficiently processed and rendered by the graphics pipeline. This involves transforming the discrete voxel data into mesh data, comprising vertices, edges, and faces, that GPUs are designed to handle.

Focus on Exposed Faces
A voxel, by its nature, is a simple cube with six faces. However, in a dense voxel environment, most of these faces are hidden from view, either by other voxels or by being outside the player’s field of vision. Our strategy involves an optimization where we only generate mesh faces for the voxels that are exposed to air or to the edge of a chunk. This significantly reduces the number of faces that need to be rendered.

Greedy Meshing: A Game-Changer
To further optimize our mesh, we’ll implement a technique known as greedy meshing. This technique involves combining contiguous faces of voxels into larger polygons. By doing this, we can drastically reduce the number of vertices and triangles needed to represent our world. Greedy meshing not only helps in reducing the load on the graphics processor but also aids in reducing draw calls, a common bottleneck in rendering.

The Outcome
By the end of this section, our voxel engine will be capable of rendering vast, complex worlds with significantly improved performance. The implementation of advanced chunk mesh generation is a pivotal step in balancing visual fidelity with the computational limitations of real-time rendering, ensuring that our voxel-based game or simulation can run smoothly on a wide range of hardware.

Stay tuned as we navigate through the intricacies of mesh generation, overcoming challenges and exploring efficient coding practices to bring our voxel world to life.

So let us begin, we want to generate some real mesh now for the voxels. This section has a learning curve, so please go over the material, read it until you grasp the fundamentals.

Add some vars at the top of the Chunk class, these will hold our complete chunk mesh vertices, triangles and UVs…

private List<Vector3> vertices = new List<Vector3>();
private List<int> triangles = new List<int>();
private List<Vector2> uvs = new List<Vector2>();

Now add the IterateVoxels() function to traverse our chunk so we can do mesh gen on the voxels inside the specific chunk…

// New method to iterate through the voxel data
public void IterateVoxels()
{
for (int x = 0; x < chunkSize; x++)
{
for (int y = 0; y < chunkSize; y++)
{
for (int z = 0; z < chunkSize; z++)
{
ProcessVoxel(x, y, z);
}
}
}
}

We ping over the voxels to check the need in generating any mesh for them, maybe some voxels are not visible, meaning they are hidden behind other voxels, if that is the case we do not need to generate any info for them.

So let us now add the ProcessVoxel() function…

private void ProcessVoxel(int x, int y, int z)
{
// Check if the voxels array is initialized and the indices are within bounds
if (voxels == null || x < 0 || x >= voxels.GetLength(0) ||
y < 0 || y >= voxels.GetLength(1) || z < 0 || z >= voxels.GetLength(2))
{
return; // Skip processing if the array is not initialized or indices are out of bounds
}
Voxel voxel = voxels[x, y, z];
if (voxel.isActive)
{
// Check each face of the voxel for visibility
bool[] facesVisible = new bool[6];

// Check visibility for each face
facesVisible[0] = IsFaceVisible(x, y + 1, z); // Top
facesVisible[1] = IsFaceVisible(x, y - 1, z); // Bottom
facesVisible[2] = IsFaceVisible(x - 1, y, z); // Left
facesVisible[3] = IsFaceVisible(x + 1, y, z); // Right
facesVisible[4] = IsFaceVisible(x, y, z + 1); // Front
facesVisible[5] = IsFaceVisible(x, y, z - 1); // Back

for (int i = 0; i < facesVisible.Length; i++)
{
if (facesVisible[i])
AddFaceData(x, y, z, i); // Method to add mesh data for the visible face
}
}
}

Now add the IsFaceVisible() function…

private bool IsFaceVisible(int x, int y, int z)
{
// Check if the neighboring voxel in the given direction is inactive or out of bounds
if (x < 0 || x >= chunkSize || y < 0 || y >= chunkSize || z < 0 || z >= chunkSize)
return true; // Face is at the boundary of the chunk
return !voxels[x, y, z].isActive;
}

In the ProcessVoxel() function, we first check if the voxels are initialized and the current voxel is active. If it is, we create an array to represent the visibility of each of its six faces (top, bottom, left, right, front, back).

We then adjust the voxel’s coordinates by adding or subtracting 1 along the x, y, or z axes to examine the neighbouring voxels. This step helps determine if there is an adjacent voxel blocking any face of the current voxel. For instance, if there’s a voxel above the current one, we know the top face is hidden and therefore doesn’t need to be rendered.

This approach efficiently skips rendering hidden faces, saving computational resources.

Required now is an AddFaceData() function, in which we work out the vertices and triangles based on the index and add them to the lists, then we calculate and add the UVs.

Add the AddFaceData() function…

private void AddFaceData(int x, int y, int z, int faceIndex)
{
// Based on faceIndex, determine vertices and triangles
// Add vertices and triangles for the visible face
// Calculate and add corresponding UVs

if (faceIndex == 0) // Top Face
{
vertices.Add(new Vector3(x, y + 1, z ));
vertices.Add(new Vector3(x, y + 1, z + 1));
vertices.Add(new Vector3(x + 1, y + 1, z + 1));
vertices.Add(new Vector3(x + 1, y + 1, z ));
uvs.Add(new Vector2(0, 0));
uvs.Add(new Vector2(1, 0));
uvs.Add(new Vector2(1, 1));
uvs.Add(new Vector2(0, 1));
}

if (faceIndex == 1) // Bottom Face
{
vertices.Add(new Vector3(x, y, z ));
vertices.Add(new Vector3(x + 1, y, z ));
vertices.Add(new Vector3(x + 1, y, z + 1));
vertices.Add(new Vector3(x, y, z + 1));
uvs.Add(new Vector2(0, 0));
uvs.Add(new Vector2(0, 1));
uvs.Add(new Vector2(1, 1));
uvs.Add(new Vector2(1, 0));
}

if (faceIndex == 2) // Left Face
{
vertices.Add(new Vector3(x, y, z ));
vertices.Add(new Vector3(x, y, z + 1));
vertices.Add(new Vector3(x, y + 1, z + 1));
vertices.Add(new Vector3(x, y + 1, z ));
uvs.Add(new Vector2(0, 0));
uvs.Add(new Vector2(0, 0));
uvs.Add(new Vector2(0, 1));
uvs.Add(new Vector2(0, 1));
}

if (faceIndex == 3) // Right Face
{
vertices.Add(new Vector3(x + 1, y, z + 1));
vertices.Add(new Vector3(x + 1, y, z ));
vertices.Add(new Vector3(x + 1, y + 1, z ));
vertices.Add(new Vector3(x + 1, y + 1, z + 1));
uvs.Add(new Vector2(1, 0));
uvs.Add(new Vector2(1, 1));
uvs.Add(new Vector2(1, 1));
uvs.Add(new Vector2(1, 0));
}

if (faceIndex == 4) // Front Face
{
vertices.Add(new Vector3(x, y, z + 1));
vertices.Add(new Vector3(x + 1, y, z + 1));
vertices.Add(new Vector3(x + 1, y + 1, z + 1));
vertices.Add(new Vector3(x, y + 1, z + 1));
uvs.Add(new Vector2(0, 1));
uvs.Add(new Vector2(0, 1));
uvs.Add(new Vector2(1, 1));
uvs.Add(new Vector2(1, 1));
}

if (faceIndex == 5) // Back Face
{
vertices.Add(new Vector3(x + 1, y, z ));
vertices.Add(new Vector3(x, y, z ));
vertices.Add(new Vector3(x, y + 1, z ));
vertices.Add(new Vector3(x + 1, y + 1, z ));
uvs.Add(new Vector2(0, 0));
uvs.Add(new Vector2(1, 0));
uvs.Add(new Vector2(1, 0));
uvs.Add(new Vector2(0, 0));

}
AddTriangleIndices();
}

One face consists of 4 vertices and 2 triangles to make that face.

So based on which face it is we offset the x, y or z or all position by 1 to reach the correct corner and add a vertex there. We then call the AddTriangleIndices() function so add this to the Chunk script also…

private void AddTriangleIndices()
{
int vertCount = vertices.Count;

// First triangle
triangles.Add(vertCount - 4);
triangles.Add(vertCount - 3);
triangles.Add(vertCount - 2);

// Second triangle
triangles.Add(vertCount - 4);
triangles.Add(vertCount - 2);
triangles.Add(vertCount - 1);
}

It adds the two triangles needed to form the face of the voxel. Let us add the necessary components now to generate the mesh, add these vars to the top of the Chunk script…

private MeshFilter meshFilter;
private MeshRenderer meshRenderer;
private MeshCollider meshCollider;

When the Chunk script starts we need to use the Start() method to start the mesh gen process, remove any existing Start() code replace with this…

void Start()
{
// Initialize Mesh Components
meshFilter = gameObject.AddComponent<MeshFilter>();
meshRenderer = gameObject.AddComponent<MeshRenderer>();
meshCollider = gameObject.AddComponent<MeshCollider>();

// Call this to generate the chunk mesh
GenerateMesh();
}

We add a meshFilter, meshRenderer and meshCollider, built in Unity components. Now we call GenerateMesh()…

private void GenerateMesh()
{
IterateVoxels(); // Make sure this processes all voxels

Mesh mesh = new Mesh();
mesh.vertices = vertices.ToArray();
mesh.triangles = triangles.ToArray();
mesh.uv = uvs.ToArray();

mesh.RecalculateNormals(); // Important for lighting

meshFilter.mesh = mesh;
meshCollider.sharedMesh = mesh;

// Apply a material or texture if needed
// meshRenderer.material = ...;
}

As an added step we will add a material to the mesh. This will involve some work in the World class.

Add these variables to the top of the World script along with this awake function…

public static World Instance { get; private set; }

public Material VoxelMaterial;

void Awake()
{
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(gameObject); // Optional: if you want this to persist across scenes
}
else
{
Destroy(gameObject);
}
}

The Awake function in this Unity script is used for implementing the Singleton pattern with the World class. When a World object awakens (i.e., when it’s first loaded or instantiated), it checks if an Instance of the World class already exists (Instance == null). If not, it sets itself as the Instance and marks itself to not be destroyed on loading a new scene (DontDestroyOnLoad(gameObject)), ensuring that there’s always only one instance of World throughout the game. However, if an Instance already exists, it destroys the current game object to prevent duplicates.

This pattern is commonly used to manage game elements that should only exist in a single instance, like game managers or controllers.

So we can now reference the voxel material we want for our voxels form another script without creating multiple instances…

meshRenderer.material = World.Instance.VoxelMaterial;

Add it at the end of the GenerateMesh() function. Create a material then drop it in the Voxel Material location in the World GameObject. Press Play…

We have our Chunks fully meshed and with a material. We have a few issues to deal with now, while we are rendering only the visible faces, this is done on a per chunk basis…

Flying inside a chunk shows the non visible voxels are not rendered, but the bounds of chunks do not register the adjacent chunks and therefore render faces even though the next chunk is hiding the voxels.

For such an issue we can implement an “inter-chunk visibility check” as we shall call it.

Change our IsFaceVisible() function…

private bool IsFaceVisible(int x, int y, int z)
{
// Convert local chunk coordinates to global coordinates
Vector3 globalPos = transform.position + new Vector3(x, y, z);

// Check if the neighboring voxel is inactive or out of bounds in the current chunk
// and also if it's inactive or out of bounds in the world (neighboring chunks)
return IsVoxelHiddenInChunk(x, y, z) && IsVoxelHiddenInWorld(globalPos);
}

We will get the global position of the current chunk to reference against neighbours. We move the legacy code in this function to the new IsVoxelHiddenInChunk()…

private bool IsVoxelHiddenInChunk(int x, int y, int z)
{
if (x < 0 || x >= chunkSize || y < 0 || y >= chunkSize || z < 0 || z >= chunkSize)
return true; // Face is at the boundary of the chunk
return !voxels[x, y, z].isActive;
}

So this check is in a local chunk context, the other function we will introduce and check against, to find out if the voxel is up against another chunk, is the IsVoxelHiddenInChunk()…

private bool IsVoxelHiddenInWorld(Vector3 globalPos)
{
// Check if there is a chunk at the global position
Chunk neighborChunk = World.Instance.GetChunkAt(globalPos);
if (neighborChunk == null)
{
// No chunk at this position, so the voxel face should be hidden
return true;
}

// Convert the global position to the local position within the neighboring chunk
Vector3 localPos = neighborChunk.transform.InverseTransformPoint(globalPos);

// If the voxel at this local position is inactive, the face should be visible (not hidden)
return !neighborChunk.IsVoxelActiveAt(localPos);
}

So firstly in this function we call a new helper function in our World class designed to find if a chunk exists at a global position, if it finds a chunk then we convert the global position back to the local position and then we have another helper function to check if a voxel is active at the local position.

This is the GetChunkAt() function add it to the World class…

public Chunk GetChunkAt(Vector3 globalPosition)
{
// Calculate the chunk's starting position based on the global position
Vector3Int chunkCoordinates = new Vector3Int(
Mathf.FloorToInt(globalPosition.x / chunkSize) * chunkSize,
Mathf.FloorToInt(globalPosition.y / chunkSize) * chunkSize,
Mathf.FloorToInt(globalPosition.z / chunkSize) * chunkSize
);

// Retrieve and return the chunk at the calculated position
if (chunks.TryGetValue(chunkCoordinates, out Chunk chunk))
{
return chunk;
}

// Return null if no chunk exists at the position
return null;
}

The function calculates the coordinates of the chunk that should contain the given global position. It does this by dividing the global position by chunkSize, flooring the result to get the chunk’s index, and then multiplying by chunkSize to get the starting position of the chunk.

It then uses these coordinates to look up the corresponding chunk in the chunks dictionary.

This function is designed to be performant since it involves basic arithmetic calculations and a dictionary lookup, which is typically fast. However, the performance can still depend on the size of the world and the number of chunks. Further optimizations on all our code is possible.

Let us take a look at IsVoxelActiveAt()…

public bool IsVoxelActiveAt(Vector3 localPosition)
{
// Round the local position to get the nearest voxel index
int x = Mathf.RoundToInt(localPosition.x);
int y = Mathf.RoundToInt(localPosition.y);
int z = Mathf.RoundToInt(localPosition.z);

// Check if the indices are within the bounds of the voxel array
if (x >= 0 && x < chunkSize && y >= 0 && y < chunkSize && z >= 0 && z < chunkSize)
{
// Return the active state of the voxel at these indices
return voxels[x, y, z].isActive;
}

// If out of bounds, consider the voxel inactive
return false;
}

The function first rounds the local position to the nearest whole numbers to get the indices of the voxel. This rounding is important because the local position might be a floating-point value, whereas voxel indices are integers.

Before accessing the voxel array, it checks if the calculated indices are within the bounds of the array. This prevents out-of-bounds access, which can cause runtime errors.

If the indices are valid, it returns the isActive state of the voxel at those indices. This state determines whether the voxel is considered active (i.e., visible or solid) or not.

Press Play and now we have mesh gen which only renders visible faces. When I turn on render both faces, on the material, then I can see the inside of the cube.

And there you have it — another critical piece of our voxel engine firmly in place. With the mesh generation techniques we’ve explored in Part 2 of “Building a High-Performance Voxel Engine in Unity,” we’re carving out the very landscape of our virtual world, one voxel at a time. As we wrap up this chapter, we stand ready to breathe life into our creation, eagerly anticipating the myriad of possibilities it holds. Stay tuned for the next installment, where we’ll dive deeper into the world of voxel magic, enhancing our engine and expanding our horizons. Happy coding!

--

--

CodeComplex

Exploring the digital depths of coding and tech. Innovator at heart, sharing insights on voxel engine development and Unity. #CodeComplex 🌐💻🚀