Mountains, Cliffs, and Caves: A Comprehensive Guide to Using Perlin Noise for Procedural Generation

Image generated by DALL-E, OpenAI

Procedural generation is everywhere—you’ve probably encountered it without even realising. It’s what gives in-game worlds their rolling hills, jagged cliffs, and winding cave systems. And at the heart of it all is Perlin noise: a special kind of randomness that isn’t entirely random at all. It’s smooth where it needs to be, rough when we want it to be, and endlessly customizable.

But what exactly is procedural generation? In simple terms, it’s a method of creating natural looking textures and objects using algorithms instead of manually designing every detail.

Take Minecraft as an example. Every time you load up a new world, you’re presented with an entirely unique landscape. These worlds aren’t designed by hand (think of the poor interns). Instead they’re built using procedural generation. Unlike traditional, handcrafted environments, procedural generation lets us create massive, complex landscapes on the fly—often with just a few lines of code.

In this guide, we’ll break down how Perlin noise works, implement it from scratch, and tweak it to shape our terrain exactly the way we want. Keep reading till the end to see how we can take this idea even further to start designing underground cave systems.

Let’s make some noise!

Believe it or not, this is the beginning of procedurally generated terrain. This is a graphical representation of noise – an array of randomly generated values between 0 and 1. There’s no apparent pattern to it and right now it doesn’t look much like a terrain map at all. It’s random and discontinuous which isn’t going to be much use for generating smooth terrain. What we really need is something more like this:

This is Perlin noise, it still looks pretty random and behaves unpredictably, but it has a smooth, continuous gradient that will make it much more useful for generating natural looking terrain.

The colouring here simulates a top down view of a topological map with snow-capped mountain peaks, green grasslands, and deep blue oceans. Perhaps it’s easier to visualise in 3 dimensions:

It’s not quite an epic mountain range yet, but it’s a step in the right direction. So before we try to fix it up into something more mountain-y, let’s explore how this smooth noise is created.

Thanks Ken!

In his 1985 paper (linked below), Ken Perlin describes his noise generation algorithm. He outlines some use cases for generating random natural textures for computer graphics.

There are various different implementations of this algorithm, but the core ideas are as follows…

Instead of starting with an array of random numbers, we’ll begin with a coordinate grid of random vectors.

Perlin noise is generated on a per pixel basis, so we’ll take each pixel in our output and map it onto the vector grid.

Here we introduce our first parameter, scale, which allows us to modify the way pixels are mapped to the vector grid. As we will see later, this affects the overall smoothness of the terrain.

Depending on the size of the terrain and the size of our desired output, we may need to expand or tile the grid to ensure it’s large enough to map all of our pixel coordinates to.


Plotting one of the pixel coordinates on the vector grid, we can now work out a distance vector from the point to each of the bounding corners (shown in green below).

Taking the dot product of each gradient vector with it’s corresponding distance vector produces 4 numbers, one for each corner.

To calculate the final noise value for this pixel, we interpolate between the four corner values, first horizontally, then vertically. This ensures a point closer to one of the corner vectors is influenced by it more than the other vectors.

Once all noise values have been generated, the final step is to normalise the values to be in the range [0, 1]. This will give us the noise map as we saw before:

For a full Python implementation of this algorithm, click here to expand.
import numpy as np
import matplotlib.pyplot as plt

gradients = [(1,1), (-1,1), (1,-1), (-1,-1), (1,0), (-1,0), (0,1), (0,-1)]
int_lattice = np.random.randint(8, size=(repeat, repeat))

def generate_heightmap(output_size, scale):

    # interpolation function
    def fade(t):
        return t * t * t * (t * (t * 6 - 15) + 10)

    # output array
    heightmap = np.zeros((output_size, output_size))

    # iterate over each pixel in the output
    for i in range(output_size):
        for j in range(output_size):
            x = i * scale
            y = j * scale

            # Wrap within repeat range to avoid boundary issues
            x %= repeat
            y %= repeat

            # Grid cell coordinates
            x1, y1 = int(x) % repeat, int(y) % repeat
            x2, y2 = (x1 + 1) % repeat, (y1 + 1) % repeat

            # Distance vectors from the corners
            dA, dB, dC, dD = [x1 - x, y1 - y], \
                            [x1 - x, y2 - y], \
                            [x2 - x, y1 - y], \
                            [x2 - x, y2 - y]

            # Identify gradient vectors
            gA, gB, gC, gD = gradients[int_lattice[x1, y1]], \
                            gradients[int_lattice[x1, y2]], \
                            gradients[int_lattice[x2, y1]], \
                            gradients[int_lattice[x2, y2]] 

            # Compute dot products
            dotA, dotB, dotC, dotD = np.dot(dA, gA), \
                                    np.dot(dB, gB), \
                                    np.dot(dC, gC), \
                                    np.dot(dD, gD)

            # Compute fade values
            u, v = fade(x - x1), fade(y - y1)

            # Interpolation
            tmp1 = u * dotC + (1 - u) * dotA
            tmp2 = u * dotD + (1 - u) * dotB
            pixel_value = v * tmp2 + (1 - v) * tmp1  # Interpolating in the y-direction last

            heightmap[i, j] = pixel_value

    return heightmap


# MAIN
repeat = 128
seed =  123456
np.random.seed(seed)

# generate terrain
output_size = 128
scale = 0.03
heightmap = generate_heightmap(output_size, scale)

# Plot the terrain as a 3D surface
X, Y = np.meshgrid(np.arange(output_size), np.arange(output_size))
fig = plt.figure(figsize=(10, 6))
ax = fig.add_subplot(111, projection='3d')
ax.plot_surface(X, Y, heightmap, cmap='terrain')

# hide axes and labels
ax.set_xticks([])
ax.set_yticks([])
ax.set_zticks([])
ax.grid(False)

plt.show()

Now let’s take a look at how we can introduce some parameters to customise the terrain.


Adding some lumps and bumps

Changing the scale parameter alters the smoothness of the terrain.

  • A larger scale value results in high frequency waves making the result look quite jagged
  • A smaller scale value results in lower frequency waves that have a smoother appearance

Scale is the mapping of pixel values to the vector grid. When the scale is smaller, we map lots of values to each square of the grid which gives us a smooth curve. When the scale is higher, we sample the vector grid over larger intervals so there can be more dramatic changes in the noise values.

Lower Scale = lower frequency terrain
Higher Scale = higher frequency terrain

It’s all coming together now

We can employ a technique known as Fractal Brownian Motion to increase the detail and complexity of the terrain. The method involves combining multiple layers of Perlin noise with different frequencies and amplitudes to create a more natural-looking result.

The layers of noise are known as octaves.

We can think about increasing the number of octaves as adding finer and finer levels of detail to our terrain. With one octave we add broad shapes, with a second we add slightly smaller features, but by the 6th or 8th octave we are adding just small blemishes and details to the surface of the map.

1 Octave
2 Octaves
4 Octaves
8 Octaves

We can also change the properties of the octaves and the way that we combine them using 2 more parameters: persistence and lacunarity.

Persistence alters the contribution of each octave to the final result. We start with an amplitude of 1 for our first octave and with each iteration, multiply the amplitude by our persistence scale factor.

For example, if we have persistence=0.5, we take 100% of the first octave, add the second one scaled down by 50%, then the next one scaled down to 25%, and so on. Each successive octave contributes less and less to the final result.

This coincides with the frequency of the octaves increasing so that we generate increasingly rough terrain, but it adds only small bumps to the surface rather than entirely new mountains.

Here we show an example of 2 octaves being combined with low versus high persistence.

Persistence = 0.2
Persistence= 0.4

The higher persistence model is impacted more by the higher frequency second octave, resulting in large jagged peaks sticking out of the ground.

By contrast, the lower persistence model has much smaller blemishes across the terrain as the impact of the second octave is smaller.


The other parameter we can alter is lacunarity. This refers to the rate of change of scale between the octaves.

In the examples below, we use 4 octaves and a persistence of 0.25. The left model shows a lacunarity of 2.0, so the scale doubles with each octave, whereas the right model uses a value of 4.0 so the scale quadruples with each octave.

Lacunarity = 2.0
Lacunarity = 4.0

As we can see, the final results have the same overall shape, but the higher lacunarity model has a much higher frequency of the smaller features.

This parameter is more subtle than the others, but useful for altering the texture of the terrain.

I think I’m getting carried away…

Beyond Fractal Brownian Motion, we can employ further adaptations to customise our terrain map…

Moisture Levels and Biomes

We can use Perlin noise to represent things other than the height of the terrain. In this instance, we will use it to represent moisture levels in the environment. Combining the altitude and moisture levels, we can classify each coordinate into a different biome and then colour them accordingly.

Previous colouring model
Adding a biome classifier using ‘moisture’ levels

Radial Dropoff

We can also apply functions to our noise maps to manipulate the terrain into different shapes.

In this example, we apply a quadratic function to our noise values that drops off as coordinates get further from the centre of the mountain. This results in a tall island mountain sticking out of the sea.

Custom Functions

But we are not limited to a simple quadratic function. We can take a nice complicated polynomial expression and apply it to our values.

The only thing to bear in mind is what the graph of the function looks like in the domain [0, 1]. We want to make sure that the function does not map our noise values to something outside of a reasonable range. This example below produces some interesting results, all while keeping the values within the range [0, 1].

We can then tailor this function to exaggerate and suppress different features of the landscape.

def f(x):
    return 7.105427e-15 + 0.7*x + 16.20*(x**2) - 69.76*(x**3) + 94.76*(x**4) - 41.07*(x**5) - 4.3

Cave Systems and Dungeons

We can also implement the Perlin noise algorithm in more than 2 dimensions, say for example… 3!

We can take a single octave of 3D noise, apply a threshold to the result and with a few tweaks and a bit of visualisation magic, the result is starting to look like an underground cave system:

With just a few clever applications of Perlin noise, we can create mountains, oceans, and even labyrinthine cave systems. By adjusting parameters like scale, persistence, and lacunarity, we gain fine control over the structure of our terrain, while layering multiple octaves adds depth and complexity.

And this is just the beginning. With further refinements—such as biome classification, moisture levels and the introduction of 3D models —we can push procedural generation even further. Whether you’re crafting a game world or experimenting with generative art, the power of noise is limited only by your imagination.

References and Further Reading

*All images are by the author unless stated otherwise.

Similar Posts

Leave a Reply

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