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.

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

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.

FREE COURSES
Python Blog Image

FINAL DAYS: Unlock coding courses in Unity, Godot, Unreal, Python and more.

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.

public float[,] GenerateUniformNoiseMap(int mapDepth, int mapWidth, float centerVertexZ, float maxDistanceZ, float offsetZ) {
		// create an empty noise map with the mapDepth and mapWidth coordinates
		float[,] noiseMap = new float[mapDepth, mapWidth];

		for (int zIndex = 0; zIndex < mapDepth; zIndex++) {
			// calculate the sampleZ by summing the index and the offset
			float sampleZ = zIndex + offsetZ;
			// calculate the noise proportional to the distance of the sample to the center of the level
			float noise = Mathf.Abs (sampleZ - centerVertexZ) / maxDistanceZ;
			// apply the noise for all points with this Z coordinate
			for (int xIndex = 0; xIndex < mapWidth; xIndex++) {
				noiseMap [mapDepth - zIndex - 1, xIndex] = noise;
			}
		}

		return noiseMap;
	}

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.

public class TileGeneration : MonoBehaviour {

	[SerializeField]
	private TerrainType[] heightTerrainTypes;

	[SerializeField]
	private TerrainType[] heatTerrainTypes;

        private Texture2D BuildTexture(float[,] heightMap, TerrainType[] terrainTypes) {
		int tileDepth = heightMap.GetLength (0);
		int tileWidth = heightMap.GetLength (1);

		Color[] colorMap = new Color[tileDepth * tileWidth];
		for (int zIndex = 0; zIndex < tileDepth; zIndex++) {
			for (int xIndex = 0; xIndex < tileWidth; xIndex++) {
				// transform the 2D map index is an Array index
				int colorIndex = zIndex * tileWidth + xIndex;
				float height = heightMap [zIndex, xIndex];
				// choose a terrain type according to the height value
				TerrainType terrainType = ChooseTerrainType (height, terrainTypes);
				// assign the color according to the terrain type
				colorMap[colorIndex] = terrainType.color;
			}
		}

		// create a new texture and set its pixel colors
		Texture2D tileTexture = new Texture2D (tileWidth, tileDepth);
		tileTexture.wrapMode = TextureWrapMode.Clamp;
		tileTexture.SetPixels (colorMap);
		tileTexture.Apply ();

		return tileTexture;
	}

}

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.

public class TileGeneration : MonoBehaviour {

        [SerializeField]
	private VisualizationMode visualizationMode;

public void GenerateTile(float centerVertexZ, float maxDistanceZ) {
		// calculate tile depth and width based on the mesh vertices
		Vector3[] meshVertices = this.meshFilter.mesh.vertices;
		int tileDepth = (int)Mathf.Sqrt (meshVertices.Length);
		int tileWidth = tileDepth;

		// calculate the offsets based on the tile position
		float offsetX = -this.gameObject.transform.position.x;
		float offsetZ = -this.gameObject.transform.position.z;

		// generate a heightMap using Perlin Noise
		float[,] heightMap = this.noiseMapGeneration.GeneratePerlinNoiseMap (tileDepth, tileWidth, this.levelScale, offsetX, offsetZ, waves);

		// calculate vertex offset based on the Tile position and the distance between vertices
		Vector3 tileDimensions = this.meshFilter.mesh.bounds.size;
		float distanceBetweenVertices = tileDimensions.z / (float)tileDepth;
		float vertexOffsetZ = this.gameObject.transform.position.z / distanceBetweenVertices;

		// generate a heatMap using uniform noise
		float[,] heatMap = this.noiseMapGeneration.GenerateUniformNoiseMap (tileDepth, tileWidth, centerVertexZ, maxDistanceZ, vertexOffsetZ);

		// build a Texture2D from the height map
		Texture2D heightTexture = BuildTexture (heightMap, this.heightTerrainTypes);
		// build a Texture2D from the heat map
		Texture2D heatTexture = BuildTexture (heatMap, this.heatTerrainTypes);

		switch (this.visualizationMode) {
		case VisualizationMode.Height:
			// assign material texture to be the heightTexture
			this.tileRenderer.material.mainTexture = heightTexture;
			break;
		case VisualizationMode.Heat:
			// assign material texture to be the heatTexture
			this.tileRenderer.material.mainTexture = heatTexture;
			break;
		}

		// update the tile mesh vertices according to the height map
		UpdateMeshVertices (heightMap);
	}

}

enum VisualizationMode {Height, Heat}

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.

heat terrains uniform heat map

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.

public void GenerateTile(float centerVertexZ, float maxDistanceZ) {
		// calculate tile depth and width based on the mesh vertices
		Vector3[] meshVertices = this.meshFilter.mesh.vertices;
		int tileDepth = (int)Mathf.Sqrt (meshVertices.Length);
		int tileWidth = tileDepth;

		// calculate the offsets based on the tile position
		float offsetX = -this.gameObject.transform.position.x;
		float offsetZ = -this.gameObject.transform.position.z;

		// generate a heightMap using Perlin Noise
		float[,] heightMap = this.noiseMapGeneration.GeneratePerlinNoiseMap (tileDepth, tileWidth, this.levelScale, offsetX, offsetZ, this.heightWaves);

		// calculate vertex offset based on the Tile position and the distance between vertices
		Vector3 tileDimensions = this.meshFilter.mesh.bounds.size;
		float distanceBetweenVertices = tileDimensions.z / (float)tileDepth;
		float vertexOffsetZ = this.gameObject.transform.position.z / distanceBetweenVertices;

		// generate a heatMap using uniform noise
		float[,] uniformHeatMap = this.noiseMapGeneration.GenerateUniformNoiseMap (tileDepth, tileWidth, centerVertexZ, maxDistanceZ, vertexOffsetZ);
                // generate a heatMap using Perlin Noise
		float[,] randomHeatMap = this.noiseMapGeneration.GeneratePerlinNoiseMap (tileDepth, tileWidth, this.levelScale, offsetX, offsetZ, this.heatWaves);
		float[,] heatMap = new float[tileDepth, tileWidth];
		for (int zIndex = 0; zIndex < tileDepth; zIndex++) {
			for (int xIndex = 0; xIndex < tileWidth; xIndex++) {
                                // mix both heat maps together by multiplying their values
				heatMap [zIndex, xIndex] = uniformHeatMap [zIndex, xIndex] * randomHeatMap [zIndex, xIndex];
                                // makes higher regions colder, by adding the height value to the heat map
				heatMap [zIndex, xIndex] += heightMap [zIndex, xIndex] * heightMap [zIndex, xIndex];
			}
		}

		// build a Texture2D from the height map
		Texture2D heightTexture = BuildTexture (heightMap, this.heightTerrainTypes);
		// build a Texture2D from the heat map
		Texture2D heatTexture = BuildTexture (heatMap, this.heatTerrainTypes);

		switch (this.visualizationMode) {
		case VisualizationMode.Height:
			// assign material texture to be the heightTexture
			this.tileRenderer.material.mainTexture = heightTexture;
			break;
		case VisualizationMode.Heat:
			// assign material texture to be the heatTexture
			this.tileRenderer.material.mainTexture = heatTexture;
			break;
		}

		// update the tile mesh vertices according to the height map
		UpdateMeshVertices (heightMap);
	}

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.

public class TileGeneration : MonoBehaviour {
[SerializeField]
	private AnimationCurve heatCurve;

public void GenerateTile(float centerVertexZ, float maxDistanceZ) {
		// calculate tile depth and width based on the mesh vertices
		Vector3[] meshVertices = this.meshFilter.mesh.vertices;
		int tileDepth = (int)Mathf.Sqrt (meshVertices.Length);
		int tileWidth = tileDepth;

		// calculate the offsets based on the tile position
		float offsetX = -this.gameObject.transform.position.x;
		float offsetZ = -this.gameObject.transform.position.z;

		// generate a heightMap using Perlin Noise
		float[,] heightMap = this.noiseMapGeneration.GeneratePerlinNoiseMap (tileDepth, tileWidth, this.levelScale, offsetX, offsetZ, this.heightWaves);

		// calculate vertex offset based on the Tile position and the distance between vertices
		Vector3 tileDimensions = this.meshFilter.mesh.bounds.size;
		float distanceBetweenVertices = tileDimensions.z / (float)tileDepth;
		float vertexOffsetZ = this.gameObject.transform.position.z / distanceBetweenVertices;

		// generate a heatMap using uniform noise
		float[,] uniformHeatMap = this.noiseMapGeneration.GenerateUniformNoiseMap (tileDepth, tileWidth, centerVertexZ, maxDistanceZ, vertexOffsetZ);
		float[,] randomHeatMap = this.noiseMapGeneration.GeneratePerlinNoiseMap (tileDepth, tileWidth, this.levelScale, offsetX, offsetZ, this.heatWaves);
		float[,] heatMap = new float[tileDepth, tileWidth];
		for (int zIndex = 0; zIndex < tileDepth; zIndex++) {
			for (int xIndex = 0; xIndex < tileWidth; xIndex++) {
				heatMap [zIndex, xIndex] = uniformHeatMap [zIndex, xIndex] * randomHeatMap [zIndex, xIndex];
				heatMap [zIndex, xIndex] += this.heatCurve.Evaluate(heightMap [zIndex, xIndex]) * heightMap [zIndex, xIndex];
			}
		}

		// build a Texture2D from the height map
		Texture2D heightTexture = BuildTexture (heightMap, this.heightTerrainTypes);
		// build a Texture2D from the heat map
		Texture2D heatTexture = BuildTexture (heatMap, this.heatTerrainTypes);

		switch (this.visualizationMode) {
		case VisualizationMode.Height:
			// assign material texture to be the heightTexture
			this.tileRenderer.material.mainTexture = heightTexture;
			break;
		case VisualizationMode.Heat:
			// assign material texture to be the heatTexture
			this.tileRenderer.material.mainTexture = heatTexture;
			break;
		}

		// update the tile mesh vertices according to the height map
		UpdateMeshVertices (heightMap);
	}
}

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.

random heat map

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.

public class TileGeneration : MonoBehaviour {
[SerializeField]
	private TerrainType[] moistureTerrainTypes;

[SerializeField]
	private AnimationCurve moistureCurve;

[SerializeField]
	private Wave[] moistureWaves;

public void GenerateTile(float centerVertexZ, float maxDistanceZ) {
		// calculate tile depth and width based on the mesh vertices
		Vector3[] meshVertices = this.meshFilter.mesh.vertices;
		int tileDepth = (int)Mathf.Sqrt (meshVertices.Length);
		int tileWidth = tileDepth;

		// calculate the offsets based on the tile position
		float offsetX = -this.gameObject.transform.position.x;
		float offsetZ = -this.gameObject.transform.position.z;

		// generate a heightMap using Perlin Noise
		float[,] heightMap = this.noiseMapGeneration.GeneratePerlinNoiseMap (tileDepth, tileWidth, this.levelScale, offsetX, offsetZ, this.heightWaves);

		// calculate vertex offset based on the Tile position and the distance between vertices
		Vector3 tileDimensions = this.meshFilter.mesh.bounds.size;
		float distanceBetweenVertices = tileDimensions.z / (float)tileDepth;
		float vertexOffsetZ = this.gameObject.transform.position.z / distanceBetweenVertices;

		// generate a heatMap using uniform noise
		float[,] uniformHeatMap = this.noiseMapGeneration.GenerateUniformNoiseMap (tileDepth, tileWidth, centerVertexZ, maxDistanceZ, vertexOffsetZ);
		// generate a heatMap using Perlin Noise
		float[,] randomHeatMap = this.noiseMapGeneration.GeneratePerlinNoiseMap (tileDepth, tileWidth, this.levelScale, offsetX, offsetZ, this.heatWaves);
		float[,] heatMap = new float[tileDepth, tileWidth];
		for (int zIndex = 0; zIndex < tileDepth; zIndex++) {
			for (int xIndex = 0; xIndex < tileWidth; xIndex++) {
				// mix both heat maps together by multiplying their values
				heatMap [zIndex, xIndex] = uniformHeatMap [zIndex, xIndex] * randomHeatMap [zIndex, xIndex];
				// makes higher regions colder, by adding the height value to the heat map
				heatMap [zIndex, xIndex] += this.heatCurve.Evaluate(heightMap [zIndex, xIndex]) * heightMap [zIndex, xIndex];
			}
		}

		// generate a moistureMap using Perlin Noise
		float[,] moistureMap = this.noiseMapGeneration.GeneratePerlinNoiseMap (tileDepth, tileWidth, this.levelScale, offsetX, offsetZ, this.moistureWaves);
		for (int zIndex = 0; zIndex < tileDepth; zIndex++) {
			for (int xIndex = 0; xIndex < tileWidth; xIndex++) {
				// makes higher regions dryer, by reducing the height value from the heat map
				moistureMap [zIndex, xIndex] -= this.moistureCurve.Evaluate(heightMap [zIndex, xIndex]) * heightMap [zIndex, xIndex];
			}
		}

		// build a Texture2D from the height map
		Texture2D heightTexture = BuildTexture (heightMap, this.heightTerrainTypes);
		// build a Texture2D from the heat map
		Texture2D heatTexture = BuildTexture (heatMap, this.heatTerrainTypes);
		// build a Texture2D from the moisture map
		Texture2D moistureTexture = BuildTexture (moistureMap, this.moistureTerrainTypes);

		switch (this.visualizationMode) {
		case VisualizationMode.Height:
			// assign material texture to be the heightTexture
			this.tileRenderer.material.mainTexture = heightTexture;
			break;
		case VisualizationMode.Heat:
			// assign material texture to be the heatTexture
			this.tileRenderer.material.mainTexture = heatTexture;
			break;
		case VisualizationMode.Moisture:
			// assign material texture to be the moistureTexture
			this.tileRenderer.material.mainTexture = moistureTexture;
			break;
		}

		// update the tile mesh vertices according to the height map
		UpdateMeshVertices (heightMap);
	}
}

enum VisualizationMode {Height, Heat, Moisture}

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.

moisture terrains 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.

public class TileGeneration : MonoBehaviour {
[SerializeField]
	private BiomeRow[] biomes;
}

[System.Serializable]
public class Biome {
	public string name;
	public Color color;
}

[System.Serializable]
public class BiomeRow {
	public Biome[] biomes;
}

[System.Serializable][System.Serializable]
public class TerrainType { 
        public string name; 
        public float threshold; 
        public Color color; 
        public int index;
}

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.

private Texture2D BuildBiomeTexture(TerrainType[,] heightTerrainTypes, TerrainType[,] heatTerrainTypes, TerrainType[,] moistureTerrainTypes) {
		int tileDepth = heatTerrainTypes.GetLength (0);
		int tileWidth = heatTerrainTypes.GetLength (1);

		Color[] colorMap = new Color[tileDepth * tileWidth];
		for (int zIndex = 0; zIndex < tileDepth; zIndex++) {
			for (int xIndex = 0; xIndex < tileWidth; xIndex++) {
				int colorIndex = zIndex * tileWidth + xIndex;

				TerrainType heightTerrainType = heightTerrainTypes [zIndex, xIndex];
				// check if the current coordinate is a water region
				if (heightTerrainType.name != "water") {
					// if a coordinate is not water, its biome will be defined by the heat and moisture values
					TerrainType heatTerrainType = heatTerrainTypes [zIndex, xIndex];
					TerrainType moistureTerrainType = moistureTerrainTypes [zIndex, xIndex];

					// terrain type index is used to access the biomes table
					Biome biome = this.biomes [moistureTerrainType.index].biomes [heatTerrainType.index];
					// assign the color according to the selected biome
					colorMap [colorIndex] = biome.color;
				} else {
					// water regions don't have biomes, they always have the same color
					colorMap [colorIndex] = this.waterColor;
				}
			}
		}

		// create a new texture and set its pixel colors
		Texture2D tileTexture = new Texture2D (tileWidth, tileDepth);
		tileTexture.filterMode = FilterMode.Point;
		tileTexture.wrapMode = TextureWrapMode.Clamp;
		tileTexture.SetPixels (colorMap);
		tileTexture.Apply ();

		return tileTexture;
	}

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.

private Texture2D BuildTexture(float[,] heightMap, TerrainType[] terrainTypes, TerrainType[,] chosenTerrainTypes) {
		int tileDepth = heightMap.GetLength (0);
		int tileWidth = heightMap.GetLength (1);

		Color[] colorMap = new Color[tileDepth * tileWidth];
		for (int zIndex = 0; zIndex < tileDepth; zIndex++) {
			for (int xIndex = 0; xIndex < tileWidth; xIndex++) {
				// transform the 2D map index is an Array index
				int colorIndex = zIndex * tileWidth + xIndex;
				float height = heightMap [zIndex, xIndex];
				// choose a terrain type according to the height value
				TerrainType terrainType = ChooseTerrainType (height, terrainTypes);
				// assign the color according to the terrain type
				colorMap[colorIndex] = terrainType.color;

				// save the chosen terrain type
				chosenTerrainTypes [zIndex, xIndex] = terrainType;
			}
		}

		// create a new texture and set its pixel colors
		Texture2D tileTexture = new Texture2D (tileWidth, tileDepth);
		tileTexture.wrapMode = TextureWrapMode.Clamp;
		tileTexture.SetPixels (colorMap);
		tileTexture.Apply ();

		return tileTexture;
	}

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.

public void GenerateTile(float centerVertexZ, float maxDistanceZ) {
		// calculate tile depth and width based on the mesh vertices
		Vector3[] meshVertices = this.meshFilter.mesh.vertices;
		int tileDepth = (int)Mathf.Sqrt (meshVertices.Length);
		int tileWidth = tileDepth;

		// calculate the offsets based on the tile position
		float offsetX = -this.gameObject.transform.position.x;
		float offsetZ = -this.gameObject.transform.position.z;

		// generate a heightMap using Perlin Noise
		float[,] heightMap = this.noiseMapGeneration.GeneratePerlinNoiseMap (tileDepth, tileWidth, this.levelScale, offsetX, offsetZ, this.heightWaves);

		// calculate vertex offset based on the Tile position and the distance between vertices
		Vector3 tileDimensions = this.meshFilter.mesh.bounds.size;
		float distanceBetweenVertices = tileDimensions.z / (float)tileDepth;
		float vertexOffsetZ = this.gameObject.transform.position.z / distanceBetweenVertices;

		// generate a heatMap using uniform noise
		float[,] uniformHeatMap = this.noiseMapGeneration.GenerateUniformNoiseMap (tileDepth, tileWidth, centerVertexZ, maxDistanceZ, vertexOffsetZ);
		// generate a heatMap using Perlin Noise
		float[,] randomHeatMap = this.noiseMapGeneration.GeneratePerlinNoiseMap (tileDepth, tileWidth, this.levelScale, offsetX, offsetZ, this.heatWaves);
		float[,] heatMap = new float[tileDepth, tileWidth];
		for (int zIndex = 0; zIndex < tileDepth; zIndex++) {
			for (int xIndex = 0; xIndex < tileWidth; xIndex++) {
				// mix both heat maps together by multiplying their values
				heatMap [zIndex, xIndex] = uniformHeatMap [zIndex, xIndex] * randomHeatMap [zIndex, xIndex];
				// makes higher regions colder, by adding the height value to the heat map
				heatMap [zIndex, xIndex] += this.heatCurve.Evaluate(heightMap [zIndex, xIndex]) * heightMap [zIndex, xIndex];
			}
		}

		// generate a moistureMap using Perlin Noise
		float[,] moistureMap = this.noiseMapGeneration.GeneratePerlinNoiseMap (tileDepth, tileWidth, this.levelScale, offsetX, offsetZ, this.moistureWaves);
		for (int zIndex = 0; zIndex < tileDepth; zIndex++) {
			for (int xIndex = 0; xIndex < tileWidth; xIndex++) {
				// makes higher regions dryer, by reducing the height value from the heat map
				moistureMap [zIndex, xIndex] -= this.moistureCurve.Evaluate(heightMap [zIndex, xIndex]) * heightMap [zIndex, xIndex];
			}
		}

		// build a Texture2D from the height map
		TerrainType[,] chosenHeightTerrainTypes = new TerrainType[tileDepth, tileWidth];
		Texture2D heightTexture = BuildTexture (heightMap, this.heightTerrainTypes, chosenHeightTerrainTypes);
		// build a Texture2D from the heat map
		TerrainType[,] chosenHeatTerrainTypes = new TerrainType[tileDepth, tileWidth];
		Texture2D heatTexture = BuildTexture (heatMap, this.heatTerrainTypes, chosenHeatTerrainTypes);
		// build a Texture2D from the moisture map
		TerrainType[,] chosenMoistureTerrainTypes = new TerrainType[tileDepth, tileWidth];
		Texture2D moistureTexture = BuildTexture (moistureMap, this.moistureTerrainTypes, chosenMoistureTerrainTypes);

		// build a biomes Texture2D from the three other noise variables
		Texture2D biomeTexture = BuildBiomeTexture(chosenHeightTerrainTypes, chosenHeatTerrainTypes, chosenMoistureTerrainTypes);

		switch (this.visualizationMode) {
		case VisualizationMode.Height:
			// assign material texture to be the heightTexture
			this.tileRenderer.material.mainTexture = heightTexture;
			break;
		case VisualizationMode.Heat:
			// assign material texture to be the heatTexture
			this.tileRenderer.material.mainTexture = heatTexture;
			break;
		case VisualizationMode.Moisture:
			// assign material texture to be the moistureTexture
			this.tileRenderer.material.mainTexture = moistureTexture;
			break;
		case VisualizationMode.Biome:
			// assign material texture to be the moistureTexture
			this.tileRenderer.material.mainTexture = biomeTexture;
			break;
		}

		// update the tile mesh vertices according to the height map
		UpdateMeshVertices (heightMap);
	}

enum VisualizationMode {Height, Heat, Moisture, 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.

biome map

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.

Access part 3 here