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.

Did you come across any errors in this tutorial? Please let us know by completing this form and we’ll look into it!

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

// class to store all data for a single tile
public class TileData {
	public float[,]  heightMap;
	public float[,]  heatMap;
	public float[,]  moistureMap;
	public TerrainType[,] chosenHeightTerrainTypes;
	public TerrainType[,] chosenHeatTerrainTypes;
	public TerrainType[,] chosenMoistureTerrainTypes;
	public Biome[,] chosenBiomes;
	public Mesh mesh;

	public TileData(float[,]  heightMap, float[,]  heatMap, float[,]  moistureMap, 
		TerrainType[,] chosenHeightTerrainTypes, TerrainType[,] chosenHeatTerrainTypes, TerrainType[,] chosenMoistureTerrainTypes,
		Biome[,] chosenBiomes, Mesh mesh) {
		this.heightMap = heightMap;
		this.heatMap = heatMap;
		this.moistureMap = moistureMap;
		this.chosenHeightTerrainTypes = chosenHeightTerrainTypes;
		this.chosenHeatTerrainTypes = chosenHeatTerrainTypes;
		this.chosenMoistureTerrainTypes = chosenMoistureTerrainTypes;
		this.chosenBiomes = chosenBiomes;
		this.mesh = mesh;
	}
}

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.

private Texture2D BuildBiomeTexture(TerrainType[,] heightTerrainTypes, TerrainType[,] heatTerrainTypes, TerrainType[,] moistureTerrainTypes, Biome[,] chosenBiomes) {
		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;

					// save biome in chosenBiomes matrix only when it is not water
					chosenBiomes [zIndex, xIndex] = biome;
				} 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.wrapMode = TextureWrapMode.Clamp;
		tileTexture.SetPixels (colorMap);
		tileTexture.Apply ();

		return tileTexture;
	}

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.

public TileData 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
		Biome[,] chosenBiomes = new Biome[tileDepth, tileWidth];
		Texture2D biomeTexture = BuildBiomeTexture(chosenHeightTerrainTypes, chosenHeatTerrainTypes, chosenMoistureTerrainTypes, chosenBiomes);

		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);

		TileData tileData = new TileData (heightMap, heatMap, moistureMap, chosenHeightTerrainTypes, chosenHeatTerrainTypes, chosenMoistureTerrainTypes, chosenBiomes, this.meshFilter.mesh);

		return tileData;
	}

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.

// class to store all the merged tiles data
public class LevelData {
	private int tileDepthInVertices, tileWidthInVertices;

	public TileData[,] tilesData;

	public LevelData(int tileDepthInVertices, int tileWidthInVertices, int levelDepthInTiles, int levelWidthInTiles) {
		// build the tilesData matrix based on the level depth and width
		tilesData = new TileData[tileDepthInVertices * levelDepthInTiles, tileWidthInVertices * levelWidthInTiles];

		this.tileDepthInVertices = tileDepthInVertices;
		this.tileWidthInVertices = tileWidthInVertices;
	}

	public void AddTileData(TileData tileData, int tileZIndex, int tileXIndex) {
		// save the TileData in the corresponding coordinate
		tilesData [tileZIndex, tileXIndex] = tileData;
	}
}

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.

void GenerateMap() {
		// get the tile dimensions from the tile Prefab
		Vector3 tileSize = tilePrefab.GetComponent<MeshRenderer> ().bounds.size;
		int tileWidth = (int)tileSize.x;
		int tileDepth = (int)tileSize.z;

		// calculate the number of vertices of the tile in each axis using its mesh
		Vector3[] tileMeshVertices = tilePrefab.GetComponent<MeshFilter> ().mesh.vertices;
		int tileDepthInVertices = (int)Mathf.Sqrt (tileMeshVertices.Length);
		int tileWidthInVertices = tileDepthInVertices;

		// build an empty LevelData object, to be filled with the tiles to be generated
		LevelData levelData = new LevelData (tileDepthInVertices, tileWidthInVertices, this.levelDepthInTiles, this.levelWidthInTiles);

		// for each Tile, instantiate a Tile in the correct position
		for (int xTileIndex = 0; xTileIndex < levelWidthInTiles; xTileIndex++) {
			for (int zTileIndex = 0; zTileIndex < levelDepthInTiles; zTileIndex++) {
				// calculate the tile position based on the X and Z indices
				Vector3 tilePosition = new Vector3(this.gameObject.transform.position.x + xTileIndex * tileWidth, 
					this.gameObject.transform.position.y, 
					this.gameObject.transform.position.z + zTileIndex * tileDepth);
				// instantiate a new Tile
				GameObject tile = Instantiate (tilePrefab, tilePosition, Quaternion.identity) as GameObject;
				// generate the Tile texture and save it in the levelData
				TileData tileData = tile.GetComponent<TileGeneration> ().GenerateTile (centerVertexZ, maxDistanceZ);
				levelData.AddTileData (tileData, zTileIndex, xTileIndex);
			}
		}
	}

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.

tile coordinate system

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.

level coordinate system 1

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.

// class to represent a coordinate in the Tile Coordinate System
public class TileCoordinate {
	public int tileZIndex;
	public int tileXIndex;
	public int coordinateZIndex;
	public int coordinateXIndex;

	public TileCoordinate(int tileZIndex, int tileXIndex, int coordinateZIndex, int coordinateXIndex) {
		this.tileZIndex = tileZIndex;
		this.tileXIndex = tileXIndex;
		this.coordinateZIndex = coordinateZIndex;
		this.coordinateXIndex = coordinateXIndex;
	}
}

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.

// class to store all the merged tiles data
public class LevelData {

	public TileCoordinate ConvertToTileCoordinate(int zIndex, int xIndex) {
		// the tile index is calculated by dividing the index by the number of tiles in that axis
		int tileZIndex = (int)Mathf.Floor ((float)zIndex / (float)this.tileDepthInVertices);
		int tileXIndex = (int)Mathf.Floor ((float)xIndex / (float)this.tileWidthInVertices);
		// the coordinate index is calculated by getting the remainder of the division above
		// we also need to translate the origin to the bottom left corner
		int coordinateZIndex = this.tileDepthInVertices - (zIndex % this.tileDepthInVertices) - 1;
		int coordinateXIndex = this.tileWidthInVertices - (xIndex % this.tileDepthInVertices) - 1;

		TileCoordinate tileCoordinate = new TileCoordinate (tileZIndex, tileXIndex, coordinateZIndex, coordinateXIndex);
		return tileCoordinate;
	}
}

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.

public class TreeGeneration : MonoBehaviour {

	[SerializeField]
	private NoiseMapGeneration noiseMapGeneration;

	[SerializeField]
	private Wave[] waves;

        [SerializeField] 
         private float levelScale;

	[SerializeField]
	private float neighborRadius;

	[SerializeField]
	private GameObject treePrefab;

	public void GenerateTrees(int levelDepth, int levelWidth, float levelScale, float distanceBetweenVertices, LevelData levelData) {
		// generate a tree noise map using Perlin Noise
		float[,] treeMap = this.noiseMapGeneration.GeneratePerlinNoiseMap (levelDepth, levelWidth, levelScale, 0, 0, this.waves);

		float levelSizeX = levelWidth * distanceBetweenVertices;
		float levelSizeZ = levelDepth * distanceBetweenVertices;

		for (int zIndex = 0; zIndex < levelDepth; zIndex++) {
			for (int xIndex = 0; xIndex < levelWidth; xIndex++) {
				// convert from Level Coordinate System to Tile Coordinate System and retrieve the corresponding TileData
				TileCoordinate tileCoordinate = levelData.ConvertToTileCoordinate (zIndex, xIndex);
				TileData tileData = levelData.tilesData [tileCoordinate.tileZIndex, tileCoordinate.tileXIndex];
				int tileWidth = tileData.heightMap.GetLength (1);

				// calculate the mesh vertex index
				Vector3[] meshVertices = tileData.mesh.vertices;
				int vertexIndex = tileCoordinate.coordinateZIndex * tileWidth + tileCoordinate.coordinateXIndex;

				// get the terrain type of this coordinate
				TerrainType terrainType = tileData.chosenHeightTerrainTypes [tileCoordinate.coordinateZIndex, tileCoordinate.coordinateXIndex];
				// check if it is a water terrain. Trees cannot be placed over the water
				if (terrainType.name != "water") {
					float treeValue = treeMap [zIndex, xIndex];

					int terrainTypeIndex = terrainType.index;

					// compares the current tree noise value to the neighbor ones
					int neighborZBegin = (int)Mathf.Max (0, zIndex - this.neighborRadius);
					int neighborZEnd = (int)Mathf.Min (levelDepth-1, zIndex + this.neighborRadius);
					int neighborXBegin = (int)Mathf.Max (0, xIndex - this.neighborRadius);
					int neighborXEnd = (int)Mathf.Min (levelWidth-1, xIndex + this.neighborRadius);
					float maxValue = 0f;
					for (int neighborZ = neighborZBegin; neighborZ <= neighborZEnd; neighborZ++) {
						for (int neighborX = neighborXBegin; neighborX <= neighborXEnd; neighborX++) {
							float neighborValue = treeMap [neighborZ, neighborX];
							// saves the maximum tree noise value in the radius
							if (neighborValue >= maxValue) {
								maxValue = neighborValue;
							}
						}
					}

					// if the current tree noise value is the maximum one, place a tree in this location
					if (treeValue == maxValue) {
						Vector3 treePosition = new Vector3(xIndex*distanceBetweenVertices, meshVertices[vertexIndex].y, zIndex*distanceBetweenVertices);
						GameObject tree = Instantiate (this.treePrefab, treePosition, Quaternion.identity) as GameObject;
						tree.transform.localScale = new Vector3 (0.05f, 0.05f, 0.05f);
					}
				}
			}
		}
	}
}

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.

public class LevelGeneration : MonoBehaviour {

[SerializeField]
	private TreeGeneration treeGeneration;

void GenerateMap() {
		// get the tile dimensions from the tile Prefab
		Vector3 tileSize = tilePrefab.GetComponent<MeshRenderer> ().bounds.size;
		int tileWidth = (int)tileSize.x;
		int tileDepth = (int)tileSize.z;

		// calculate the number of vertices of the tile in each axis using its mesh
		Vector3[] tileMeshVertices = tilePrefab.GetComponent<MeshFilter> ().sharedMesh.vertices;
		int tileDepthInVertices = (int)Mathf.Sqrt (tileMeshVertices.Length);
		int tileWidthInVertices = tileDepthInVertices;

		float distanceBetweenVertices = (float)tileDepth / (float)tileDepthInVertices;

		// build an empty LevelData object, to be filled with the tiles to be generated
		LevelData levelData = new LevelData (tileDepthInVertices, tileWidthInVertices, this.levelDepthInTiles, this.levelWidthInTiles);

		// for each Tile, instantiate a Tile in the correct position
		for (int xTileIndex = 0; xTileIndex < levelWidthInTiles; xTileIndex++) {
			for (int zTileIndex = 0; zTileIndex < levelDepthInTiles; zTileIndex++) {
				// calculate the tile position based on the X and Z indices
				Vector3 tilePosition = new Vector3(this.gameObject.transform.position.x + xTileIndex * tileWidth, 
					this.gameObject.transform.position.y, 
					this.gameObject.transform.position.z + zTileIndex * tileDepth);
				// instantiate a new Tile
				GameObject tile = Instantiate (tilePrefab, tilePosition, Quaternion.identity) as GameObject;
				// generate the Tile texture and save it in the levelData
				TileData tileData = tile.GetComponent<TileGeneration> ().GenerateTile (centerVertexZ, maxDistanceZ);
				levelData.AddTileData (tileData, zTileIndex, xTileIndex);
			}
		}

		// generate trees for the level
		treeGeneration.GenerateTrees (this.levelDepthInTiles * tileDepthInVertices, this.levelWidthInTiles * tileWidthInVertices, distanceBetweenVertices, levelData);
	}

}

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.

environment package

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

level with trees1level object with tree generation1

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.

public class TreeGeneration : MonoBehaviour {

	[SerializeField]
	private NoiseMapGeneration noiseMapGeneration;

	[SerializeField]
	private Wave[] waves;

	[SerializeField]
	private float levelScale;

	[SerializeField]
	private float[] neighborRadius;

	[SerializeField]
	private GameObject[] treePrefab;

	public void GenerateTrees(int levelDepth, int levelWidth, float distanceBetweenVertices, LevelData levelData) {
		// generate a tree noise map using Perlin Noise
		float[,] treeMap = this.noiseMapGeneration.GeneratePerlinNoiseMap (levelDepth, levelWidth, this.levelScale, 0, 0, this.waves);

		float levelSizeX = levelWidth * distanceBetweenVertices;
		float levelSizeZ = levelDepth * distanceBetweenVertices;

		for (int zIndex = 0; zIndex < levelDepth; zIndex++) {
			for (int xIndex = 0; xIndex < levelWidth; xIndex++) {
				// convert from Level Coordinate System to Tile Coordinate System and retrieve the corresponding TileData
				TileCoordinate tileCoordinate = levelData.ConvertToTileCoordinate (zIndex, xIndex);
				TileData tileData = levelData.tilesData [tileCoordinate.tileZIndex, tileCoordinate.tileXIndex];
				int tileWidth = tileData.heightMap.GetLength (1);

				// calculate the mesh vertex index
				Vector3[] meshVertices = tileData.mesh.vertices;
				int vertexIndex = tileCoordinate.coordinateZIndex * tileWidth + tileCoordinate.coordinateXIndex;

				// get the terrain type of this coordinate
				TerrainType terrainType = tileData.chosenHeightTerrainTypes [tileCoordinate.coordinateZIndex, tileCoordinate.coordinateXIndex];

				// get the biome of this coordinate
				Biome biome = tileData.chosenBiomes[tileCoordinate.coordinateZIndex, tileCoordinate.coordinateXIndex];

				// check if it is a water terrain. Trees cannot be placed over the water
				if (terrainType.name != "water") {
					float treeValue = treeMap [zIndex, xIndex];

					int terrainTypeIndex = terrainType.index;

					// compares the current tree noise value to the neighbor ones
					int neighborZBegin = (int)Mathf.Max (0, zIndex - this.neighborRadius[biome.index]);
					int neighborZEnd = (int)Mathf.Min (levelDepth-1, zIndex + this.neighborRadius[biome.index]);
					int neighborXBegin = (int)Mathf.Max (0, xIndex - this.neighborRadius[biome.index]);
					int neighborXEnd = (int)Mathf.Min (levelWidth-1, xIndex + this.neighborRadius[biome.index]);
					float maxValue = 0f;
					for (int neighborZ = neighborZBegin; neighborZ <= neighborZEnd; neighborZ++) {
						for (int neighborX = neighborXBegin; neighborX <= neighborXEnd; neighborX++) {
							float neighborValue = treeMap [neighborZ, neighborX];
							// saves the maximum tree noise value in the radius
							if (neighborValue >= maxValue) {
								maxValue = neighborValue;
							}
						}
					}

					// if the current tree noise value is the maximum one, place a tree in this location
					if (treeValue == maxValue) {
						Vector3 treePosition = new Vector3(xIndex*distanceBetweenVertices, meshVertices[vertexIndex].y, zIndex*distanceBetweenVertices);
						GameObject tree = Instantiate (this.treePrefab[biome.index], treePosition, Quaternion.identity) as GameObject;
						tree.transform.localScale = new Vector3 (0.05f, 0.05f, 0.05f);
					}
				}
			}
		}
	}
}

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.

level with trees2 level object with tree generation2

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

public class RiverGeneration : MonoBehaviour {

	[SerializeField]
	private int numberOfRivers;

	[SerializeField]
	private float heightThreshold;

	[SerializeField]
	private Color riverColor;

	public void GenerateRivers(int levelDepth, int levelWidth, LevelData levelData) {
		for (int riverIndex = 0; riverIndex < numberOfRivers; riverIndex++) {
			// choose a origin for the river
			Vector3 riverOrigin = ChooseRiverOrigin (levelDepth, levelWidth, levelData);
			// build the river starting from the origin
			BuildRiver (levelDepth, levelWidth, riverOrigin, levelData);
		}
	}

}

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.

private Vector3 ChooseRiverOrigin(int levelDepth, int levelWidth, LevelData levelData) {
		bool found = false;
		int randomZIndex = 0;
		int randomXIndex = 0;
		// iterates until finding a good river origin
		while (!found) {
			// pick a random coordinate inside the level
			randomZIndex = Random.Range (0, levelDepth);
			randomXIndex = Random.Range (0, levelWidth);

			// convert from Level Coordinate System to Tile Coordinate System and retrieve the corresponding TileData
			TileCoordinate tileCoordinate = levelData.ConvertToTileCoordinate (randomZIndex, randomXIndex);
			TileData tileData = levelData.tilesData [tileCoordinate.tileZIndex, tileCoordinate.tileXIndex];

			// if the height value of this coordinate is higher than the threshold, choose it as the river origin
			float heightValue = tileData.heightMap [tileCoordinate.coordinateZIndex, tileCoordinate.coordinateXIndex];
			if (heightValue >= this.heightThreshold) {
				found = true;
			}
		}
		return new Vector3 (randomXIndex, 0, randomZIndex);
	}

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.

private void BuildRiver(int levelDepth, int levelWidth, Vector3 riverOrigin, LevelData levelData) {
		HashSet<Vector3> visitedCoordinates = new HashSet<Vector3> ();
		// the first coordinate is the river origin
		Vector3 currentCoordinate = riverOrigin;
		bool foundWater = false;
		while (!foundWater) {
			// convert from Level Coordinate System to Tile Coordinate System and retrieve the corresponding TileData
			TileCoordinate tileCoordinate = levelData.ConvertToTileCoordinate ((int)currentCoordinate.z, (int)currentCoordinate.x);
			TileData tileData = levelData.tilesData [tileCoordinate.tileZIndex, tileCoordinate.tileXIndex];

			// save the current coordinate as visited
			visitedCoordinates.Add (currentCoordinate);

			// check if we have found water
			if (tileData.chosenHeightTerrainTypes [tileCoordinate.coordinateZIndex, tileCoordinate.coordinateXIndex].name == "water") {
				// if we found water, we stop
				foundWater = true;
			} else {
				// change the texture of the tileData to show a river
				tileData.texture.SetPixel (tileCoordinate.coordinateXIndex, tileCoordinate.coordinateZIndex, this.riverColor);
				tileData.texture.Apply ();

				// pick neighbor coordinates, if they exist
				List<Vector3> neighbors = new List<Vector3> ();
				if (currentCoordinate.z > 0) {
					neighbors.Add(new Vector3 (currentCoordinate.x, 0, currentCoordinate.z - 1));
				}
				if (currentCoordinate.z < levelDepth - 1) {
					neighbors.Add(new Vector3 (currentCoordinate.x, 0, currentCoordinate.z + 1));
				}
				if (currentCoordinate.x > 0) {
					neighbors.Add(new Vector3 (currentCoordinate.x - 1, 0, currentCoordinate.z));
				}
				if (currentCoordinate.x < levelWidth - 1) {
					neighbors.Add(new Vector3 (currentCoordinate.x + 1, 0, currentCoordinate.z));
				}

				// find the minimum neighbor that has not been visited yet and flow to it
				float minHeight = float.MaxValue;
				Vector3 minNeighbor = new Vector3(0, 0, 0);
				foreach (Vector3 neighbor in neighbors) {
					// convert from Level Coordinate System to Tile Coordinate System and retrieve the corresponding TileData
					TileCoordinate neighborTileCoordinate = levelData.ConvertToTileCoordinate ((int)neighbor.z, (int)neighbor.x);
					TileData neighborTileData = levelData.tilesData [neighborTileCoordinate.tileZIndex, neighborTileCoordinate.tileXIndex];

					// if the neighbor is the lowest one and has not been visited yet, save it
					float neighborHeight = tileData.heightMap [neighborTileCoordinate.coordinateZIndex, neighborTileCoordinate.coordinateXIndex];
					if (neighborHeight < minHeight && !visitedCoordinates.Contains(neighbor)) {
						minHeight = neighborHeight;
						minNeighbor = neighbor;
					}
				}
				// flow to the lowest neighbor
				currentCoordinate = minNeighbor;
			}
		}

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.

public class LevelGeneration : MonoBehaviour {

[SerializeField]
	private RiverGeneration riverGeneration;

void GenerateMap() {
		// get the tile dimensions from the tile Prefab
		Vector3 tileSize = tilePrefab.GetComponent<MeshRenderer> ().bounds.size;
		int tileWidth = (int)tileSize.x;
		int tileDepth = (int)tileSize.z;

		// calculate the number of vertices of the tile in each axis using its mesh
		Vector3[] tileMeshVertices = tilePrefab.GetComponent<MeshFilter> ().sharedMesh.vertices;
		int tileDepthInVertices = (int)Mathf.Sqrt (tileMeshVertices.Length);
		int tileWidthInVertices = tileDepthInVertices;

		float distanceBetweenVertices = (float)tileDepth / (float)tileDepthInVertices;

		// build an empty LevelData object, to be filled with the tiles to be generated
		LevelData levelData = new LevelData (tileDepthInVertices, tileWidthInVertices, this.levelDepthInTiles, this.levelWidthInTiles);

		// for each Tile, instantiate a Tile in the correct position
		for (int xTileIndex = 0; xTileIndex < levelWidthInTiles; xTileIndex++) {
			for (int zTileIndex = 0; zTileIndex < levelDepthInTiles; zTileIndex++) {
				// calculate the tile position based on the X and Z indices
				Vector3 tilePosition = new Vector3(this.gameObject.transform.position.x + xTileIndex * tileWidth, 
					this.gameObject.transform.position.y, 
					this.gameObject.transform.position.z + zTileIndex * tileDepth);
				// instantiate a new Tile
				GameObject tile = Instantiate (tilePrefab, tilePosition, Quaternion.identity) as GameObject;
				// generate the Tile texture and save it in the levelData
				TileData tileData = tile.GetComponent<TileGeneration> ().GenerateTile (centerVertexZ, maxDistanceZ);
				levelData.AddTileData (tileData, zTileIndex, xTileIndex);
			}
		}

		// generate trees for the level
		treeGeneration.GenerateTrees (this.levelDepthInTiles * tileDepthInVertices, this.levelWidthInTiles * tileWidthInVertices, distanceBetweenVertices, levelData);

		// generate rivers for the level
		riverGeneration.GenerateRivers(this.levelDepthInTiles * tileDepthInVertices, this.levelWidthInTiles * tileWidthInVertices, levelData);
	}

}

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

level with rivers level object with river generation

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.

import characters

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.

fps controller

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

level with fps 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.