Complete Guide to Procedural Level Generation in Unity – Part 3

In the last tutorial we added more noise variables to our level, such as temperature and moisture. In addition, we used those noise variables to assign biomes to different regions of our level.  By now, we already have a fairly complete procedurally generated level, but we can still add some other things, and that’s what we are going to do in this tutorial.

This tutorial series takes inspiration and expands on techniques presented by Sebastian Lague, Holistic3D and this GDC talk on procedural generation in No Man Sky.

First we are going to add trees in our level. Those trees will be randomly spread on the level, with different concentrations according to the biome. The same idea we are going to use for adding trees you can use to add things like rocks and houses in your game later.

The second thing we are going to add are rivers. Rivers will start in random high coordinates, such as mountains. Then, they will flow downwards until they reach a water region.

Finally, the last thing we are going to add is a first person character so that you can navigate through the level.

Don't miss out! Offer ends in
  • Access all 200+ courses
  • New courses added monthly
  • Cancel anytime
  • Certificates of completion

In order to follow this tutorial, you are expected to be familiar with the following concepts:

  • C# programming
  • Basic Unity concepts, such as importing assets, creating prefabs and adding components

 Source code files

You can download the tutorial source code files (the source code does not include the Unity packages imported in this tutorial, due to their large size) here.

Joining tiles data

In order to add trees and rivers to our level, it is more interesting for the algorithms to be able to visualize the whole level, and not only a single tile. In order to allow this, we are going to join all the tiles data in a single data structure, so that it can be acessed later to generate trees and rivers.

The first thing we need is an object to store all the data for a given tile. So, we are going to create a TileData class inside the TileGeneration script, as below. This object will store all the noise maps, as well as the chosen terrain types, the chosen biomes and the mesh of the tile. The constructor will simply save all this data received as parameters.

Now, before building this TileData object, we need to build the chosenBiomes matrix, similarly to how we did with the chosenTerrainTypes matrixes.

First, we change the BuildBiomeTexture method to receive as parameter the chosenBiomes matrix, which will be assigned inside the method. The method will save the biome inside this matrix only when the biome is not water.

Then, we can change the TileGeneration Script to send this parameter to the BuildBiomeTexture method. After doing this, we can create the TileData in the end of the method, using all the objects built inside of it. Then, the method will return the TileData, so that it can be joined later.

Finally, we need to merge all those tiles data in the LevelGeneration Script. First, we create a new class inside this Script called LevelData. This class will be responsible for saving the merged data for all tiles. So, it will need a matrix to store the tiles data, which will be initialized in the constructor. Then, it will have an AddTileData method which will save a new TileData in a given coordinate.

Now, we change the GenerateMap method to build this LevelData object and fill it with the tiles that will be generated. First, we need to calculate the number of vertices of each tile (in each axis) based on the tile mesh. Then, we build an empty levelData. Finally, after generating each tile, we add its tileData to the levelData.

Coordinate system conversion

However, now we have a coordinate system problem. The figure below shows how is the coordinate system of a LevelData with 2×2 tiles, where each tile has 11×11 vertices. If an algorithm needs to access the height value of the top right coordinate of the top right tile, first it needs to access the index of this tile (1, 1), and then accessing the index of the top right coordinate (0,0). Also, notice that, due to the way we built our tiles, the origin of each tile is its top right corner, and  each coordinate is represented by its (z,x) vertex.

On the other hand, it would be more interesting if we could access the level data as a single matrix, as in the figure below. Notice that now the origin of the level is the bottom left corner (instead of the top right one). This way, if we want to access the top right coordinate of the top right tile, we only need to access the (22,22) coordinate of the level data.

What we are going to do, then, is allowing the LevelData to convert from the second coordinate system (which we will call Level Coordinate System) to the first one (which we will call Tile Coordinate System). This way, any algorithm that uses the LevelData can access everything using the Level Coordinate System, while the LevelData object uses the Tile Coordinate System under the hood.

In order to do that, we are going to start by adding the following class definition in the LevelGeneration Script. This TileCoordinate class will represent a coordinate in the Tile Coordinate System. So, it needs two pairs of coordinates: the tile index inside the level and the actual coordinate index inside the tile. The constructor will simply save those values.

Then, we create a method to create a Tile Coordinate from an index pair in the Level Coordinate System. In the previous example, this method would receive the coordinate (22, 22) in the Level Coordinate System and should return a TileCoordinate with (1, 1) as the tile index and (0, 0) as the coordinate index.

We can calculate the tile index simply by dividing the Level Coordinate System index by the number of tiles in each axis. To calculate the coordinate index, on the other hand, is a little more complicated. First, we need to calculate the remainder of the division we made to calculate the tile index. Also, we need to change the origin of the coordinate system to be the bottom left corner (instead of the top right one). So, we need to subtract the calculated remainder from the top right corner.

And that’s it for joining all the TileData. Now, we are going to use this LevelData information to generate trees and rivers.

Generating trees

We are going to focus on two goals when gerating trees in our level:

  1. Trees should be randomly spread across the level
  2. Different biomes should have different trees concentrations. For example, a desert should have less trees than a forest.

First, we are going to worry only about the first goal. The tree concentration will be configurable, but it will be the same for all biomes. Then, we are going to adapt our code to allow different tree concentrations according to the biome.

Let’s start by creating a new Script called TreeGeneration, as below. This Script will have the following attributes: noiseMapGeneration, waves and levelScale will be used to generate a tree noise map using Perlin Noise. The neighborRadius attribute will be used to configure the tree concentration, while the treePrefab is the prefab that will be instantiated.

The algorithm we are going to use to generate the trees is as follows:

  1. Generate a tree noise map using Perlin Noise
  2. Iterate through each coordinate of this tree noise map, and for each one do:
    1. Calculate the maximum noise inside a circle of origin equals to the coordinate and radius equal to neighborRadius
    2. If the current coordinate noise value is the maximum of the circle, place a tree in that position

Basically, a given coordinate will have a tree if it has the highest noise value among all its neighbors. By changing the neighborRadius value we can change the tree concentration. Higher values will result in less trees, while lower values will result in more trees.

Then, we can call the GenerateTrees method inside the LevelGeneration Script. Basically, we add the TreeGeneration Script as an attribute and call its method in the end of the GenerateMap method.

Now, we can try playing our game to see if the trees are being correctly generated. You can obtain the tree prefabs from a Unity package, by selecting Assets -> Import Package -> Environment. This will import several different objects, including tree prefabs.

The figure below shows an example of trees being generated using the parameters showed in the righthand side.

As I mentioned earlier, now that we can configure the tree concentration, we need to add different concentrations for different biomes. Also, different biomes should have different types of trees. Basically, the neighborRadius and treePrefab attributes should be transformed in Arrays. Then, for a given coordinate we can access its biome from the tileData and use the biome index (you need to add an index attribute in the Biome class as well) to access the correct neighborRadius and treePrefab.

Now we can try playing the game again to see if it is generating different types of trees with different tree concentrations. The figure below shows an example using the parameters configured as in the righthand figure. In order to obtain this result, I assigned the following biome indices: desert = 0, tundra = 1, savanna = 2, boreal forest = 3, tropical rainforest = 4.

Generating rivers

The next thing we are going to add in our game are the rivers. The idea to generate rivers is that they start in high locations and flow to lower locations until they reach the water.

So, let’s start by creating a new Script called RiverGeneration with a method called GenerateRivers, as below. This Script will have as parameters: the number of rivers to be generated, the height threshold used to select a high location where rivers can start and the river color, used to show the river in the map.

Then, the GenerateRivers will simply call the two steps we are going to use for tree generation: ChooseRiverOrigin and BuildRiver. The first step will be responsible for picking a high location where the river will start, while the second step will build the river throughout the level until it reaches water. Notice that those two steps are called once for each river (from  0 to numberOfRivers).

Now let’s implement the ChooseRiverOrigin method. This method will run a while loop until it finds a suitable river origin location. In order to do so, it starts by generating a random coordinate between 0 and the level size. Then, it checks if the height of this coordinate is higher than or equal to the heightThreshold. If so, this will be the river origin and it breaks out of the loop. If not, it keeps iterating until finding a good location. Also, notice that we need to convert the random indices to Tile Coordinate System, as we did with the tree generation.

The BuildRiver method is a little bit more complex. First it will build a HashSet to store the coordinates that are already part of the river (this will be useful to avoid loops later). Then, it iterates in a while loop until it finds a water location. Inside the loop, it insert the currentCoordinate in the visitedCoordinates HashSet and checks if it is a water location. If so, it breaks out of the loop. Otherwise, it will change the color of the TileData texture to show the river (you need to add the Texture2D of the tile inside the TileData as well) and then it will pick the lowest neighbor to be the next coordinate.

In order to pick the lowest neihbor it adds all of them in a List and then iterates through the list checking their height values. Also, notice that it checks if the neighbor is not in the visitedCoordinates HashSet, since we do not want to go back to already visited coordinates. Once it finds the lowest neighbor, it sets it as the new currentCoordinate and goes to the next iteration of the while loop.

Finally, we can call this GenerateRivers method inside the LevelGeneration Script, by adding the RiverGeneration Script as an attribute of LevelGeneration and calling the method in the end of the GenerateMap method, as we did for the trees.

As a result, you can try playing the game to see if the rivers are being generated correctly. The figure below shows an example with 10 rivers and a heightThreshold of 0.6 (I chose a pink color for the rivers so that you can see them in the snapshot. You should choose a more suitable color).

Adding the first person character

Finally, the last thing we are going to do is adding a First Person Character. This will be easy, since we are not going to create our own. Instead, we are going to import one from Unity.

This will be similar to how we imported the tree prefabs. But now, select Assets -> Import Package -> Characters. This will import the Characters package as below.

Then, we can add a FPSController (located in Assets/Standard Assets/Characters/FirstPersonCharacter/Prefabs) to our game. Try positioning it in the middle of where the level will be generated.

Then, we can play the game and we should be able to navigate through the level using our First Person Character.

And this concludes this tutorial series! You can use the ideas you learned here to further improve your game. For example, you can generate rocks and buildings using a similar idea to the tree generation. Try different things and let me know your opinions in the comments section.