Generating procedural terrain on the go

A few people have asked me how I implemented the procedural terrain for my game prototype after I posted a gif of it on Twitter. This is how I did it.

I created a root prefab called Terrain which the FloorTerrain.cs script. On initialization we'll generate several planes from the center of the Terrain object.

To achieve this we'll have to create a root prefab; Which I've called Terrain.

Things we need to know is the location of the Player at every frame and which entity/prefab we'll have to create when the player reaches the edge.

On initialization we create the starting platform like so:

//create the starting platform
for (int x = -halfTilesX; x < halfTilesX; x++)
{
    for (int z = -halfTilesZ; z < halfTilesZ; z++)
    {
        Vector3 pos = new Vector3((x * planeSize + startPos.x), 0, (z * planeSize + startPos.z));
        GameObject t = (GameObject)Instantiate(tilePrefab, pos, Quaternion.identity);
        tileName = "Tile_" + ((int)(pos.x)).ToString() + "_" + ((int)(pos.z)).ToString();
        t.GetComponent<Floor>().Init(pos, chunkSize, tileName, ft);
        tile = new Tile(t, updateTime);
        tiles.Add(tileName, tile);
    }
}

Here I am assuming that the Y-axis is up instead of Z. All we're doing is generating a 2D grid of Tiles these tiles are just regular planes, which is an equilateral square. Once we've calculated the position of the plane we'll add the Floor script to it and initialize it then we'll add it to a HashTable. Which happens here:

string tileName = "Tile_" + ((int)(pos.x)).ToString() + "_" + ((int)(pos.z)).ToString();
tiles.Add(tileName, tile);

This is not ideal and you don't want to use a Hashmap here at all, even though the lookup is in constant time on average. You should instead try to use an array that contains larger chunks of terrain and inside each chunk is a subdivision of smaller terrain planes. The indices of each plane can then be calculated based on the position of the player, how big the chunk dimension is and how many chunks and planes there are. Casey Muratori does an amazing job at explaining how this is done in his Handmade Hero game engine series. When I wrote this I had no idea how easy it was and ended up using a Hashtable because that's always the easy solution.

Main logic

For the main logic, this is what we do in FixedUpdate:

private void FixedUpdate() 
{
    //determine how far the player has moved since last terrain update
    int xMove = (int)(player.transform.position.x - startPos.x);
    int zMove = (int)(player.transform.position.z - startPos.z);
    if (Mathf.Abs(xMove) >= planeSize || Mathf.Abs(zMove) >= planeSize)
    {
        float updateTime = Time.realtimeSinceStartup;

        //force integer position and round to nearest tilesize
        int playerX = (int)(Mathf.RoundToInt(player.transform.position.x / planeSize) * planeSize);
        int playerZ = (int)(Mathf.RoundToInt(player.transform.position.z / planeSize) * planeSize);

        for (int x = -halfTilesX; x < halfTilesX; x++)
        {
            for (int z = -halfTilesZ; z < halfTilesZ; z++)
            {
                Vector3 pos = new Vector3((x * planeSize + playerX), 0, (z * planeSize + playerZ));

                string tilename = "Tile_" + ((int)(pos.x)).ToString() + "_" + ((int)(pos.z)).ToString();

                if (!tiles.ContainsKey(tilename))
                {
                    GameObject t = (GameObject)Instantiate(tilePrefab, pos, Quaternion.identity);
                    t.GetComponent<Floor>().Init(pos, chunkSize, tilename, ft);
                    Tile tile = new Tile(t, updateTime);
                    tiles.Add(tilename, tile);
                }
            }
        }
        startPos = player.transform.position;
    }
}

The above code triggers when the player moves and thus, we don't have to do extra work when the player is standing still. We round down the nearest player position which is important. Then we'll generate terrain around the player whilst checking that we're not generating duplicates. We do that by a lookup to the hashtable.

The important thing to note here is that because we're just generating new GameObjects on the fly when we need them as opposed to a predefined size, and because we're storing them in a hashmap this gives us sparseness for very little code.

Procedurally creating the terrain

For procedural work, it's important to mark the mesh as dynamic. This allows unity to do some optimizations in the background and prevents unity from using static buffers.

terrainMeshFilter.mesh.MarkDynamic();

However, since we're generating Terrain and not changing the vertices of the terrain afterwards, we don't have to worry about marking it as Dynamic. Instead, in this case, it would be far better not to.

This is the entire tile creation routine:

private void createFloorTile(Vector3 CenterPosition, float chunkSize, FloorTerrain ft)
{
    Vertices.Add(new Vector3(CenterPosition.x - chunkSize, CenterPosition.y, CenterPosition.z + chunkSize)); //0
    Vertices.Add(new Vector3(CenterPosition.x + chunkSize, CenterPosition.y, CenterPosition.z + chunkSize)); //1
    Vertices.Add(new Vector3(CenterPosition.x, CenterPosition.y, CenterPosition.z)); //2
    Vertices.Add(new Vector3(CenterPosition.x - chunkSize, CenterPosition.y, CenterPosition.z - chunkSize)); //3
    Vertices.Add(new Vector3(CenterPosition.x + chunkSize, CenterPosition.y, CenterPosition.z - chunkSize)); //4

    Normals.Add(Vector3.up); //0
    Normals.Add(Vector3.up); //1
    Normals.Add(Vector3.up); //2
    Normals.Add(Vector3.up); //3
    Normals.Add(Vector3.up); //4

    Triangles.Add(2);
    Triangles.Add(0);
    Triangles.Add(1);

    Triangles.Add(1);
    Triangles.Add(4);
    Triangles.Add(2);

    Triangles.Add(4);
    Triangles.Add(3);
    Triangles.Add(2);

    Triangles.Add(3);
    Triangles.Add(0);
    Triangles.Add(2);

    UVs.Add(new Vector2(0, 1));
    UVs.Add(new Vector2(1, 1));
    UVs.Add(new Vector2(0.5f, 0.5f));
    UVs.Add(new Vector2(0, 0));
    UVs.Add(new Vector2(1, 0));

    //ADD RANDOM COLOR
    //Gradient gradient = RandomE.gradientHSV;
    var noiseOffset = new Vector2(Random.Range(0f, 100f), Random.Range(0f, 100f));
    ft.floorContainer.floorColors.Clear();
    
	float noiseScale = 0.15f;
    float noise = 0f;

    foreach (Vector3 vertex in Vertices)
    {
        float x = noiseScale * vertex.x / 2 + noiseOffset.x;
        float y = noiseScale * vertex.z / 2 + noiseOffset.y;
        noise = Mathf.PerlinNoise(x, y);
        Colors.Add(ft.floorGradient.Evaluate(noise));
    }

    for (int i = 0; i < Vertices.Count; i++)
    {
        Vertices[i] = new Vector3(Vertices[i].x, Mathf.PerlinNoise((CenterPosition.x + transform.position.x) / detailScale, (CenterPosition.z + transform.position.z) / detailScale) * heightScale, Vertices[i].z);
    }

    ft.gameCenteredFloors.Add(CenterPosition, this);

    flush(ft);
}

CenterPosition is the coordinate we want to place the Tile at. ChunkSize is how big the tile is, I've set this to 1 meter. First, we create the vertices, assign the triangles (Unity uses a clockwise winding order for the triangles). We then generate the UVs and generate a random gradient sampling from a predefined gradient LUT:

gradient lut

This is the result:

For the edges and we can simply generate rectangles and place them on top with an offset. We also know the side length of each tile and can easily create oblong rectangles that span the entire side of the tile.

Another approach would be to procedurally generate the edges as well, which would be the optimal strategy. The challenges there would be to calculate the correct placement of each edge such that center-tiles won't have edges and edge-tiles would. This requires a numbering system of each tile in the chunk. There is more work involved, but in terms of performance, that's the best choice.

Doing all of that, this is the result:

Optimizations

I ran into optimization problems early on when doing this. Because creating a ton of planes and rendering them one by one is never as efficient as rendering a single big geometry all at once. I had the idea to try to combine the meshes like so:

public void OptimizeAndCombine()
{
    MeshFilter[] meshFilters = GetComponentsInChildren<MeshFilter>();
    CombineInstance[] combine = new CombineInstance[meshFilters.Length];
    int i = 0;
    while (i < meshFilters.Length)
    {
        combine[i].mesh = meshFilters[i].mesh;
        combine[i].transform = meshFilters[i].transform.localToWorldMatrix;
        meshFilters[i].gameObject.SetActive(false);
        if (meshFilters[i] != terrainMeshFilter)
        {
            unusedFloors.Add(meshFilters[i].gameObject);
        }
        i++;
    }
    terrainMeshFilter.mesh = new Mesh();
    terrainMeshFilter.mesh.CombineMeshes(combine, true);
    terrainMeshFilter.mesh.RecalculateBounds();
    terrainMeshCollider.sharedMesh = terrainMeshFilter.mesh;
    transform.gameObject.SetActive(true);
    removeUnwantedFloors();
    //groundDecoration.OptimizeAndCombineRocks();
}

This worked great and i did achieve significant fps speedups and less drawcalls. However, it's not a great solution and I wish I had more access to the Unity rendering API to have abit more control and optimize it further.

I then added some post-processing effect to the camera along with screen space antialiasing, and managed to capture this: