Creating procedural heightmap terrain from noise cover image

Creating procedural heightmap terrain from noise


0
Like

Height map terrain has to be one of my favourite procedural generation projects to implement and tinker with. Setting down a first person controller to explore an endless mountain range fuels the imagination. It can easily be tweaked to create rolling hills, craggy mountains or islands and archipelagos. It’s also rather quick and easy to implement with a few fundamental concepts. In this post I will cover how I went about implementing heightmap terrains in Legends of Rahnok.

What is a heightmap terrain?

Heightmaps are essentially a grid of numbers, representing height.

If we were to look down at the ground from above. For every position on the ground we could assign a value between 0.0 and 1.0 describing how high the ground is at that specific point. A value of 0.0 describes the bottom of a valley and a value of 1.0 describes the top of a mountain. Everything in between is a slope.

Heightmaps can be visualised as grey scale images such as the one below:

image of Noise

Lighter values represent higher terrain while darker values represent lower terrain.

A common approach to creating heightmaps is to use a procedural noise algorithm. In godot4 we can use the FastnoiseLite resource to create a heightmap.

How to create procedural noise in godot

FastnoiseLite is an inbuilt godot resource used to generate procedural noise. It can be created via script or directly in the editor. My personal preference is to create a custom resource to hold all my terrain data and parameters such as the following script.

extends Resource
class_name TerrainGenerator

@export var terrain_noise: FastNoiseLite
#other stuff that we will explore later

This allows us to create and reuse multiple terrain types. For example desert dunes and craggy mountains would have very different values and resulting geometry. Using a custom resource also allows playing and experimenting, when we discover a result we like it is then saved to disk for later use.

How to build a mesh

So we have our 2D heightmap representing mountains and valleys, but how do we use this to create the actual 3D terrain?

Drawing a triangle

First thing to understand when generating a 3d terrain is how to draw a triangle. A triangle can be represented as three points in 3d space, each point representing a corner of the triangle. The graphics engine can then fill in the space creating the triangle.

image of triangle

var a = Vector2(0, 0)
var b = Vector2(0, 1)
var c = Vector2(1, 0)

(For illustration purposes, Vector2s are used above. When generating a 3D terrain we need to use Vector3s instead)

For the remainder of the tutorial I will refer to these points as vertices or vertex. In godot we use a Vector3 to describe the position of these vertices in 3d space.

We can use Godot’s surface tool to create a triangle mesh from these vertices. We add each vertex to the surface tool using the add_vertex() method. This must be done in the order we want the triangle corners connected. Although the order does not matter too much for a single triangle it will become important later when we start combining many triangles to form our terrain.

# create a new surface tool and set it to draw primitive triangles
var st = SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)

# add each vector to the surface tool
st.add_vertex(Vector3(0,0,0))
st.add_vertex(Vector3(1,0,0))
st.add_vertex(Vector3(0,0,1))

# create the mesh by calling commit
var mesh = st.commit()

Drawing a polygon

A four sided polygon can be created by adding a second triangle. Note that the second triangle shares two vertices with the previous triangle. The surface tool can index the mesh with index(), allowing us to avoid storing duplicate vertices.

image of 2 triangles making a quad

# add each vector of triangle 1 to the surface tool
st.add_vertex(Vector3(0,0,0))
st.add_vertex(Vector3(0,0,1))
st.add_vertex(Vector3(1,0,0))

# add each vector of triangle 2 to the surface tool
st.add_vertex(Vector3(0,0,1))
st.add_vertex(Vector3(1,0,1))
st.add_vertex(Vector3(1,0,0))

# index the two duplicated vertices (1,0,0) and (0,0,1)
st.index()

Drawing a series of polygons from a 2D array of Vector3s

At this stage it becomes useful to store the vertices we want to draw in an array. We can visualise the terrain using a 2D array.

image of a grid of triangles

in code...

var vertices = [
    [Vector3(0,0,2), Vector3(1,0,2), Vector3(2,0,2)],
    [Vector3(0,0,1), Vector3(1,0,1), Vector3(2,0,1)],
    [Vector3(0,0,0), Vector3(1,0,0), Vector3(2,0,0)]
]

Now we can loop through this array, adding each polygon as we go

func build_mesh(vertices: Array[Array]) -> Mesh: 
    for x in range(vertices.size()-1):
        for y in range(vertices[x].size()-1):
            var vertex1 = vertices[x][y]
            st.add_vertex(vertex1)

            var vertex2 = vertices[x][y+1]
            st.add_vertex(vertex2)

            var vertex3 = vertices[x+1][y]
            st.add_vertex(vertex3)

            var vertex4 = vertices[x][y+1]
            st.add_vertex(vertex4)
            var vertex5 = vertices[x+1][y+1]
            st.add_vertex(vertex5)
            var vertex6 = vertices[x+1][y]
            st.add_vertex(vertex6)

    #remove duplicate vertices
    st.index()
    # generate normals will ensure that our terrain has a dedicated “top”
    st.generate_normals()
    st.generate_tangents()

    var arr_mesh = st.commit()

    return arr_mesh

Applying height to the terrain mesh using procedural noise

We now have the ability to create terrain... Albeit very flat and uninteresting… So lets apply procedural noise to our mesh.

The function to create the terrains mesh takes a 2D array of Vector3s. So lets generate that 2D array while sampling our noise for height.

First export a couple more parameters at the top of our resource

@export var tile_size: int = 10 # how big each polygon should be
@export var tiles_x: int = 10 # how many polygons wide is the mesh
@export var tiles_y: int = 10 # how many polygons long is the mesh

And create the function to populate the 2D Array with Vector3s.

In order to get a value from FastNoiseLite, use the function terrain_noise.get_noise_2d(x, y), where x and y are the coordinates we wish to sample.

func generate_height_array() -> Array[Array]:
    var height_array: Array[Array] = []

    for x in range(tiles_x + 1):
        var column: Array[Vector3] = []
        for y in range(tiles_y + 1):
            var x_pos := x * tile_size
            var z_pos := y * tile_size
            var height := terrain_noise.get_noise_2d(x, y)

            column.append(Vector3(x_pos, height, z_pos))

        height_array.append(column)

    return height_array

Normalising and scaling the noise

There is another issue to address before this produces anything meaningful. The height we get from the noise is a value between -1 and 1. That isn't going to produce very useful terrain.

First, remap these values so they fall between 0 and 1. This can be done with the formula n = (n + 1.0) * 0.5.

Once we have a value between 0.0 and 1.0, we can multiply it by a value to control how high our terrain reaches. Export this multiplier at the top of our script:

@export var height_muliplier := 10
var height := terrain_noise.get_noise_2d(x,y)
height = (height + 1.0) * 0.5
column.append(Vector3(x_pos, height * height_multiplier, z_pos))

Mountains and valleys

At this point we have terrain, but the noise produces roughly equal amounts of flat and steep terrain. We can change the shape of the terrain by applying an exponent to the normalised height.

Values below 1 raise the lower elevations, while values above 1 push them down, emphasising the higher elevations. This allows us more control over what shape of terrain we get.

image of scaled and shaped terrain
Left: scaled terrain.
Right: scaled and shaped terrain

First add a new exported parameter

@export var hill_exponent := 2.0

To encapsulate all the calculations we are performing on the height I have opted to create a new function.

func get_reshaped_elevation(pos_x: int, pos_y: int) -> float:
    var n := hill_noise.get_noise_2d(
        pos_x,
        pos_y
    )

    # normalise the height
    n = (n + 1.0) * 0.5
    # adjust the shape using an exponent
    var shaped = pow(n, hill_exponent)
    # add multiplier
    return shaped * hill_multiplier

And then when we need the height instead of

var height := terrain_noise.get_noise_2d(x, y)

We can do

var height := get_reshaped_elevation(x,y)

Now we can create mountains poking up from flat valleys, or steep craggy hills depending on what we set our exponent and multiplier to.

One final addition and point of control is to add a “fudge” or tuning parameter just before applying the exponent. This gives us one more artistic control to tweak the look of our terrain.

@export var hill_fudge: float = 1.0

And the below adjustment in get_reshaped_elevation()

var shaped = pow(n * hill_fudge, hill_exponent)

One last method to pull it all together

At this point all the individual pieces are in place. We can wrap them up in a single method that generates the height array and returns the resulting mesh.

func generate_terrain() -> Mesh:
    var height_arr = generate_height_array()
    return build_mesh(height_arr)

And we have ourselves a reusable terrain resource!





Enjoying my website? You can help keep the adventure going!