Making a Procedurally Generated Level for our RTS Game

RTS game map procedurally generated

In the previous RTS camera tutorial, we saw a camera system in motion, but the level/map we saw there needs to be implemented. In this post, we will implement a procedurally generated level for our RTS game.

What are our requirements?

Our level should be a terrain of uneven elevation. Below certain height level, water is found. Beaches, grasses and forests should be present appropriately. There are no caves or cliffs.

For this, a simple heightmap based procedurally generated terrain system will work fine, which we are going to implement here. This is not limited to RTS games only, as the concept can be used for any game.

Heightmap Terrain System

Heightmap is the elevation map that represents elevation of a terrain in range (0.0 to 1.0 or -1.0 to 1.0). It can be represented in texture or be computed at run-time via a function. In most games, heightmap is computed using the Perlin noise function, which is a kind of noise that gives natural-looking pattern similar to mountains. Multiple layers of this noise with varying scales can be added, together with some other operations of math to achieve a very good terrain elevation. See all noise functions to get a rough idea of different noise functions.

perlin noise
This is how Perlin noise looks like. Darker areas represent 0.0, and lighter areas represent 1.0.

In our game, we will create separate chunks (pieces) of terrain, centered around the player. As player moves, more chunks will be created, and older chunks which have been left behind will be removed from level. This is the overall workflow:

  • Define a single chunk/tile of the terrain.
  • Create system where chunks are created and placed in correct positions.
  • Apply textures/shaders to terrain chunks.
  • Place objects on the terrain such as trees, rocks, etc.
  • Finally, add collision shape physics body to every terrain chunk.

Creating a Terrain Chunk

There are multiple approaches to terrain creation. The simplest of them is to start with a Plane mesh (a flat plane surface), and elevate its vertices using the above mentioned noise functions.

procedural terrain creation demo
This is how a flat plane can be deformed into a terrain-like shape. We can do it in run-time using code.

In Godot engine, we have some helper classes & functions that allow us to access mesh’s vertices & modify them, in run-time. So we loop over all the vertices, and on each vertex, call the noise function to move it upward or downward from its original position.

In Godot, create a folder named “TerrainSystem”, and then create a script called “TerrainChunk.gd”. In this script, write the logic for creating a single terrain chunk:


# Chunk creator script - it creates a single tile of the terrain

extends Node
class_name TerrainChunk


# Create a plane mesh and then displaces its vertices based on the chunk_manager's process_chunk_vertices() method
static func create_chunk(
	vertex_processing_function: Callable,
	chunk_size: float,
	chunk_position: Vector3,
	subdivision: int,
	should_smooth: bool
	):
	var plane_mesh = PlaneMesh.new()
	plane_mesh.size = Vector2(chunk_size, chunk_size)
	plane_mesh.subdivide_depth = subdivision
	plane_mesh.subdivide_width = subdivision

	var surface_tool := SurfaceTool.new()
	surface_tool.create_from(plane_mesh, 0)

	var mesh_data_tool := MeshDataTool.new()
	mesh_data_tool.create_from_surface(surface_tool.commit(), 0)

	for i in range(mesh_data_tool.get_vertex_count()):
		var vertex := mesh_data_tool.get_vertex(i)
		vertex = vertex_processing_function.call(vertex, chunk_position) # Returns vec3
		mesh_data_tool.set_vertex(i, vertex)
		
		if vertex.y > 0:
			if vertex.y > 6:
				mesh_data_tool.set_vertex_color(i, Color.BLUE)
			else: mesh_data_tool.set_vertex_color(i, Color.RED)
		else:
			mesh_data_tool.set_vertex_color(i, Color.GREEN)
	
	var array_mesh := ArrayMesh.new()
	mesh_data_tool.commit_to_surface(array_mesh)
	
	surface_tool.begin(Mesh.PRIMITIVE_TRIANGLES)
	
	if should_smooth: surface_tool.set_smooth_group(0) # If you want smooth shading
	else: surface_tool.set_smooth_group(1) # If you want flat shading
	
	surface_tool.append_from(array_mesh, 0, Transform3D()) # MeshInstance3D transform? or world_transform? or Transform3D()?
	surface_tool.generate_normals()
	
	var output_mesh: ArrayMesh = surface_tool.commit()
	
	return output_mesh

Since the mesh generation function is static, we can access it using TerrainChunk.create_chunk through anywhere in the project.

The vertex_processing_function is a function defined somewhere and is responsible for displacing/moving the chunk’s vertices. This function takes a vertex and chunk's global position in input & returns a Vector3 output. This Vector3 output is assigned to vertex position, thus displacing it.

The above function is not called/used for now, as it is going to be a part of the Terrain System we are going to make.

Creating Multiple Chunks

Since we cannot create an entire world in one go, due to performance reasons. We often create our game terrain in chunks. We create chunks around player position only, so the part of world around the player is created and rest is not computed at all.

terrain chunks in blender
Four chunks of terrain. The separation between chunks is shown just for demonstration. It is typically fused together (separation=0.0).

Step 1: creating a terrain system

In Godot engine, thus we create a chunk manager, which creates or deletes chunks of terrain. So now, create a scene named “TerrainSystem” with root of Node3D type, and attach a script to it. This will be our chunk manager.

Since our terrain is going to be chunked (made up of tiles (in grid layout)), so we have to make a grid system to handle the exact positions of all the chunks. In TerrainSystem.gd:

extends Node3D

const CHUNK_SIZE := 256 # Per axis
const CHUNK_AMOUNT := 4 # Per axis
const CHUNK_SUBDIVISION := 8 # Per axis
const SHOULD_SMOOTH := true # Should terrain be smooth?

@export var player: Node3D
var loaded_chunks : Dictionary = {} # dict to hold loaded chunks

@export var terrain_material: Material # Assign the material in inspector

Step 2: managing terrain chunks

Now, we loop over the 3D space, chunk by chunk, around the player position. We take player position as center, and loop chunk-by-chunk along both x-axis & z-axis; the maximum extent in any direction is CHUNK_AMOUNT, so it will never loop chunks more distant than the amount of chunks represented by CHUNK_AMOUNT.

If the chunk within player’s range is not already made, we create it. And if it is no more in range, than we remove it. loaded_chunks is used to track the loaded chunks.


func generate_terrain(player_position: Vector3, vertex_processing_function: Callable):
	# Calculate player chunk position (which chunk player lies in)
	var player_chunk_x = int(player_position.x / CHUNK_SIZE)
	var player_chunk_z = int(player_position.z / CHUNK_SIZE)
	
	# Load and unload chunks based on player position
	for x in range(player_chunk_x - CHUNK_AMOUNT, player_chunk_x + CHUNK_AMOUNT + 1):
		for z in range(player_chunk_z - CHUNK_AMOUNT, player_chunk_z + CHUNK_AMOUNT + 1):
			var chunk_key = str(x) + "," + str(z)
			if not loaded_chunks.has(chunk_key):
				load_chunk(x, z, vertex_processing_function)
	
	# Unload chunks that are out of render distance
	for key in loaded_chunks.keys():
		var coords = key.split(",")
		var chunk_x = int(coords[0])
		var chunk_z = int(coords[1])
		if abs(chunk_x - player_chunk_x) > CHUNK_AMOUNT or abs(chunk_z - player_chunk_z) > CHUNK_AMOUNT:
			unload_chunk(chunk_x, chunk_z)

The vertex_processing_function is passed down, and will be used to displace the vertices of the terrain as I discussed earlier.

Step 3: Defining load_chunk & unload_chunk

In load_chunk (which is called above), we use TerrainChunk.create_chunk (defined above) to generate a singular chunk. Then we adjust it to correct position within our terrain, so it is placed exactly on a grid tile. Additionally, I called .create_trimesh_collision() to generate a collision shape & a StaticBody3D.

In unload_chunk, we remove the chunk if it exists. In above script, we see that unload_chunk is called only if a chunk is out of range from the player (i.e., it is more further than limit defined by the CHUNK_AMOUNT).


func load_chunk(x, z, vertex_processing_function: Callable):
	var chunk_mesh = TerrainChunk.create_chunk(vertex_processing_function, CHUNK_SIZE, Vector3(x, 0, z), CHUNK_SUBDIVISION, SHOULD_SMOOTH)
	var chunk_instance = MeshInstance3D.new()
	chunk_instance.mesh = chunk_mesh
	chunk_instance.position.x = x * CHUNK_SIZE
	chunk_instance.position.z = z * CHUNK_SIZE
	chunk_instance.material_override = terrain_material
	self.add_child(chunk_instance)
	loaded_chunks[str(x) + "," + str(z)] = chunk_instance
	
	# Add collision static body to terrain chunks
	chunk_instance.create_trimesh_collision()


func unload_chunk(x, z):
	var chunk_key = str(x) + "," + str(z)
	if loaded_chunks.has(chunk_key):
		var chunk_instance = loaded_chunks[chunk_key]
		chunk_instance.queue_free()
		loaded_chunks.erase(chunk_key)

Step 4: calling generate_terrain & defining the function to displace the vertices

Now its time to actually call the function in _process to generate our terrain.

func _process(delta):
	var player_position: Vector3 = player.global_transform.origin
	
	generate_terrain(player_position, _process_chunk_vertices)

# Defining vertex_processing_function:

# Logic to displace vertices of chunks (called by TerrainChunk.create_chunk())
func _process_chunk_vertices(vertex: Vector3, chunk_position: Vector3) -> Vector3:
	var noise_value := noise.get_noise_3d(vertex.x + chunk_position.x * CHUNK_SIZE, 0, vertex.z + chunk_position.z * CHUNK_SIZE)
	var vertex_global_position = vertex + chunk_position * CHUNK_SIZE

	# Apply combined falloff to noise
	return Vector3(
		vertex.x,
		(noise_value * noise_value if noise_value >= 0 else -noise_value * noise_value) * 128,
		vertex.z
	)

Step 5: Testing the terrain

Make sure to instance the TerrainSystem scene in you main level. And assign player (which was the export variable). Then run the game. If you wish to view it in editor, add @tool at the top of the TerrainSystem.gd script.

Texturing the Terrain

So far, we created a terrain with no material attached. To create a good looking map, we need to apply a material with multiple textures to the terrain.

The approach I used is called splat mapping or texture splatting. In this approach, we make a texture with 3 colors in it, RED, GREEN and BLUE; no the terrain surface. We apply 3 textures, one on the place of RED, another on BLUE, and another on GREEN. it is a simple operation in shader and looks something like this:

void fragment(){
  vec3 grass = grass_texture.rgb * splatmap.r; // Apply grass where RED color is intense
  vec3 sand = sand_texture.rgb * splatmap.g; // Apply sand where GREEN color is intense
  final_color = grass + sand; // At a given pixel, either grass will be intense or sand

But since using textures for splat map is inefficient for large terrains, as texture will need to be very huge, so I used vertex colors instead. In 3D mesh, there typically is a color associated with each vertex. In many cases it is not used at all. So we can utilize it for these purposes. In the TerrainChunk.create_chunk function, notice how I assigned a color to the vertex as well. We assigned color based on the height (elevation) of the vertex.

splat map terrain texturing
Splatmap visualized.
splatmap terrain texturing
Textures replaced our splatmap colors.

In the shader, I will use this vertex color and apply texture based on individual color intensity. Create a material named “TerrainMaterial” of type ShaderMaterial and create a shader with this code:

shader_type spatial;

uniform sampler2D grass_texture;
uniform sampler2D grass2_texture;
uniform sampler2D dirt_texture;
uniform float texture_scale = 1.0;

void fragment() {
    // Get vertex color
    vec3 vertex_color = COLOR.rgb;

    // Sample each texture
    vec2 uv_scaled = UV * texture_scale;
    vec4 grass_tex = texture(grass_texture, uv_scaled);
    vec4 grass2_tex = texture(grass2_texture, uv_scaled);
    vec4 dirt_tex = texture(dirt_texture, uv_scaled);

    // Mix textures based on vertex colors
    vec4 final_color = vec4(0.0);
    final_color += grass_tex * vertex_color.r; // Grass for red
    final_color += grass2_tex * vertex_color.g; // Grass2 for green
    final_color += dirt_tex * vertex_color.b; // Dirt for blue

    // Output the final color
    ALBEDO = final_color.rgb;
}

Make sure to assign terrain_material in inspector. And this shader must be assigned to the material. And, assign textures to shader uniforms.

splat map shader texture assignment

Placing Trees & Bushes on Terrain

I placed trees by calculating the height of terrain at a given position, and then instanced trees or any other objects there. Height calculation was done using the same function that elevated the vertices of terrain (which is our vertex_processing_function which is defined as _process_chunk_vertices above).

At this point, the simplest approach will be to loop over every chunk in a grid-like fashion, and for every position, call _process_chunk_vertices to look at the height of terrain at that point. If height is above water level (0.0), then do nothing, else instance an object and place it. You can do other checks as well, based on terrain type, weather and so on, but that is up to you.

Other approaches would be to use techniques like poisson disk sampling if you are looking for more natural looking object placement. But I will be using the simpler approach for RTS game.

In implementation, I used multi-mesh based appraoch, which allows us to efficiently render thousands of objects at once. In Godot, it can be done by using MultiMeshInstance3D node. I simply looped over all the chunks, and for every chunk, I called place_objects. The place_objects function creates a multi-mesh instance & puts objects in positions. Parameters such as range_start, range_end, etc are a hacky way to control the height limits of objects, and other parameters are, too, created in a hacky way to control placement of objects. object_class_name is just a way to label different kinds of objects, such as grasses, trees, rocks. You can copy-paste this code and use it:

# range_start/range_end are for perlin noise ranges from -1 to 1 for where to place objects
func generate_objects(player_position:Vector3, density: float, meshes: Array[Mesh], object_class_name: String, range_start:float, range_end:float):
	# Calculate player chunk position
	var player_chunk_x = int(player_position.x / CHUNK_SIZE)
	var player_chunk_z = int(player_position.z / CHUNK_SIZE)
	
	# Chunk based vegetation placement - vegetation node is child of chunk
	for x in range(player_chunk_x - CHUNK_AMOUNT, player_chunk_x + CHUNK_AMOUNT + 1):
		for z in range(player_chunk_z - CHUNK_AMOUNT, player_chunk_z + CHUNK_AMOUNT + 1):
			if not loaded_chunks[str(x) + "," + str(z)].has_node(object_class_name):
				place_objects(x, z, density, meshes, object_class_name, range_start, range_end)


func place_objects(chunk_space_x, chunk_space_z, density:float, meshes: Array[Mesh], object_class_name: String, range_start:float, range_end:float):
	# Calculate the density grid size based on the density variable
	var density_grid_size = int(round(CHUNK_SIZE * density))
	
	# Create a MultiMeshInstance3D for rocks in the chunk
	var rock_multimesh = MultiMeshInstance3D.new()
	rock_multimesh.name = object_class_name
	var multimesh = MultiMesh.new()
	rock_multimesh.multimesh = multimesh
	multimesh.transform_format = MultiMesh.TRANSFORM_3D
	multimesh.mesh = meshes.pick_random()
	
	var instance_transforms = []
	
	# Loop over the density grid and place rocks
	for x in range(density_grid_size):
		for z in range(density_grid_size):
			# Calculate the position in the original chunk grid
			var original_x = int(round(x / density))
			var original_z = int(round(z / density))
			
			var rock_position := _process_chunk_vertices(
				Vector3(original_x, 0, original_z),
				Vector3(chunk_space_x, 0, chunk_space_z)
			)
			if rock_position.y >= range_start and rock_position.y <= range_end: # CUSTOM RANGES
				instance_transforms.append(
					Transform3D(
						Basis().rotated(Vector3(randf_range(0, PI/3), randf_range(0, 2 * PI), randf_range(0, PI/3)).normalized(), randf_range(0, PI*2)).scaled(Vector3(1.,1.,1.)), # tmp rotated
						rock_position
					)
				)
	
	multimesh.instance_count = instance_transforms.size()
	for i in range(instance_transforms.size()):
		multimesh.set_instance_transform(i, instance_transforms[i])
	
	# Add the rock multimesh as a child of the chunk
	loaded_chunks[str(chunk_space_x) + "," + str(chunk_space_z)].add_child(rock_multimesh)

To use the above code, add following in _process:

func _process(delta):
# ... existing code ...
  generate_objects(player_position, 0.09, tree_meshes, "trees", 4, 8)
top down RTS game map

Thank you for reading <3

Leave a Reply

Your email address will not be published. Required fields are marked *