Complete Guide to Procedural Level Generation in Unity – Part 2

In the last tutorial we created a Level object by creating its individual tiles. For each Tile, we generated pseudorandom height values using a noise function, so that we could assign terrain types and heights for each Tile region.

As I mentioned in the previous tutorial, noise can be used as different things in your game. In the last tutorial we used it as height values. Now, we are going to use it to assign heat and moisture values to different regions of the Level. In the end, using this heat and moisture values we are going to assign biomes for those different regions.

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 here.

Generating heat map

We are going to start by generating a heat map to our Level, as well as assigning a texture to it based on this heat map. One way of doing so is by repeating the process we did for the height map: for each Tile, generating a heat map where each vertex of the tile corresponds to a coordinate in the heat map. This would lead to a heat map randomly distributed over or map.

However, in our world, the temperature is not evenly distributed. Actually, regions with lower latitude are usually hotter, while regions with high latitude are usually colder. So, it may be interesting to generate the heat map in such way, where regions close to the center of the level are hotter.

In order to do this, we are going to need to create a new method in the NoiseMapGeneration Script called GenerateUniformNoiseMap. (the original noise generation function will be renamed to GeneratePerlinNoiseMap as well).  This method will receive as parameters the map dimensions, but also the Z coordinate of the center of the level, as well as the maximum distance to the this center coordinate, so that we can generate noise proportional to the distance of each map coordinate to this center. Also, we are going to need the offset in the Z axis of the Tile, so that we can properly calculate its temperature values.

Notice that the maxDistanceZ and offsetZ are given in number of vertices. So, a maxDistanceZ of 10 means that any point will be at most 10 vertices away of the center. On the other hand, an offsetZ of 11 means the Tile we are working on is 11 vertices away from the origin.

Once we have those parameters, we can calculate the sampleZ based on the index and offset, and then calculating the noise proportional to the distance to the center of the Level. The noise is basically the absolute distance from the sample to the center of the Level divided by the maximum distance. This way, the noise will be lower for regions close to the center, as we intend, and it will always be between 0 and 1. In the end, we apply this noise to all points with the same Z coordinate.

In order to test this method, we need to build a Texture2D that shows it in our game. First, we need to add in the TileGeneration Script a new attribute to store the TerrainTypes we are going to use to show the heat map. Also, we are going to use the same BuildTexture method to show both the height map and the heat map, so let’s change it to receive the terrainTypes array as a parameter, instead of using the one in the attributes.

Then, in the GenerateTile method we create a heat map using the GenerateUniformNoiseMap and build a texture from it. However, we can not show both the height map and the heat map at the same time, since we need to change the tileRenderer material texture for that.

So, we are going to add to the Script a new attribute called visualizationMode. This attribute will be an enum with two values: Height and Heat. Then, in the GenerateTile method we change the tileRenderer texture according to the value of the visualizationMode attribute.

Then, we can set some values for those new parameters and visualize the result of the heat map by going to the game mode. In the example below, I created four heat values, from hottest to coldest. The result is the one in the righthand figure.

Randomizing the heat map

We managed to generate our latitute-based heat map. However, in a real world the heat map shouldn’t look so uniform. What we need to do then, is randomize this heat map a little bit.

We can do that by mixing the uniform heat map with a Perlin Noise heat map. Another thing we can do is changing the heat map values according to the height in that same coordinate. That’s because usually higher areas have lower temperatures.

In order to do so, we need to change the GenerateTile method as follows. After creating the uniform heat map, we are going to call the GeneratePerlinNoiseMap function to generate another heat map using Perlin Noise. Then, we iterate through all the noise map coordinates multiplying the uniformHeatMap and the randomHeatMap together. In this same iteration we can increase the heat according to the height value in each coordinate, by adding to the heat map the multiplication of the heat by the height value in that coordinate. By doing so, we decrease the temperature in higher values.

In order to make it even more customizable, we can add a heatCurve as an attribute of our Script. Then, when increasing the heat according to the height, we can evaluate the height in this curve. This way, we can control the multiplication factor according to the height.

In the end, your heat map should look similar to the one below. Notice that the heat is still concentrated in the center of the level, but the distribution looks more random.

Generating moisture map

The third noise type we are going to add to our game is moisture. Different from the heat map, we are only going to use Perlin Noise to generate the moisture, and not a uniform noise. However, similar to the heat map, the moisture also should be affected by the height. In practice, the higher the region, the dryer it should be, since it is further from the sea.

In order to do so we are going to change the TileGeneration script similarly to how we did for the heat map. First we need to create the moistureMap, and we are going to do so as we did with the height map, using Perlin Noise. Notice that this requires a new attribute to represent the moistureWaves. Then, we iterate through all the coordinates of the moistureMap and update its value according to the height of the region. However, different from the heat map, the higher the region, the lower it should be the moisture. That’s because higher regions are further from the sea, so they should be dryer.

After building the moistureMap, we can build a Texture2D based on the moistureMap and the moistureTerrainTypes (which must be added as an attribute). Finally, we add a new Visualization Mode called Moisture, and a case statement when the Visualization Mode is this new one. In this case, we want to show the moistureTexture.

Now, you can set some values for the moistureTerrainTypes, as well as the moistureWaves. Then, you can try playing the game to visualize the moisture map.

Generating biomes

The last thing we are going to do in this tutorial is using all the noise variables we generated so far to assign biomes for different regions of the level. Our biome generation will be based on the Whittaker’s model, which classifies biomes according to their temperature and moisture as depicted below.

biomes1

In our case, we are going to adapt it to our game, and build a table with the biome type for each combination of moisture and heat values. Since we have four different heat terrain types, and four different moisture terrain types, our table will need 16 entries. In this tutorial I’m going to use the table shown below.

hottesthotcoldcoldest
dryestdesertgrasslandtundratundra
drysavannasavannaboreal foresttundra
wettropical rainforestboreal forestboreal foresttundra
wettesttropical rainforesttropical rainforesttundratundra

Now we need to add those biomes to our game. The idea here is similar to how we did we the TerrainTyp: creating a Serializable class for it so that it can be configured in the editor. However, now we need a 2D array of biomes, since we have a table with different biomes, and 2D arrays are not serializable in Unity. So, what we are going to do is creating two classes:

  1. BiomeRow: represents a row of the table above
  2. Biome: represents a cell of the table above

Then, we can add an array of biomes to our TileGeneration Script, as below. Another thing we need to do is updating the TerrainType class to have an index attribute, so that we can use this index later to access the biomes table.

The next step is to assign the biomes to the level regions according to the terrain types of that region. So, we are going to create a method called BuildBiomeTexture, which will receive as parameters the TerrainTypes for all our noise variables (height, heat and moisture), and will build a Texture2D based on their values.

In order to create the biome texture we need to iterate through all the tile coordinates. For each coordinate, we first check if this is a water region. If so, we don’s select a biome based on the table, but we actually set its Color to be a predefined waterColor (set as an attribute).

If the coordinate is not a water region, we select its biome according to the heat and moisture values. We can do that by using the new index attribute from the TerrainType class. First, we use the moisture index to access the correct row in the biomes table. Then, we use the heat index to access the correct cell in the biomes table. In the end, we assign the color according to the chosen Biome.

Finally, we need to call this BuildBiomeTexture method inside the GenerateTile method. First, in the BuildBiomeTexture method we need to know which TerrainTypes were chosen for each noise variable type. So, we are going to add a new parameter in the BuildTexture method to save the chosenTerrainTypes.

In the GenerateTile method we create a 2D array for each noise variable type and save all the chosenTerrainTypes. Then, we can call the BuildBiomeTexture method using those 2D arrays. In the end, we are going to add another case statement when the visualization mode is equal to Biome.

Now, you can set some values for the biomes table (which means setting the name and color of each biome). The figure below shows my result using the table I presented above and selecting some colors for each biome. Remember that the final result depends on the values you’ve used for all the noise variable types. So, feel free to try several different combinations until you find the one you prefer.

And this concludes this tutorial. In the next one we are going to finish you generated level adding more content such as trees, as well as adding a character that can walk around the level.

Published by

Renan Oliveira

Renan is a computer science master student and game enthusiast. His interest in game development started a few years ago with a 2D game engine course, which resulted in a small 2D engine and game. He started working with Javascript and Phaser with the Zenva Game Development Course. Currently, he is working in his own game.

Share this article