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.
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.
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.
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.
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.
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)
Thank you for reading <3