Procedural Terrain Generation using Perlin Noise

By Charles LAZIOSI
Published on

Procedural terrain generation allows developers to create vast, randomized terrains programmatically. In this article, we'll explore how to generate realistic terrains in Python using Perlin Noise. Let's embark on this exciting journey into the world of procedural generation!

Introduction

Procedural generation is a method of creating data algorithmically rather than manually. It's widely used in game development to create expansive and varied environments without the need for handcrafted assets. Perlin Noise, developed by Ken Perlin in 1983, is a type of gradient noise often used in procedural generation to create natural-looking textures and terrains.

Prerequisites

  • Python 3.x installed on your system.
  • NumPy library for numerical operations:
    pip install numpy
    
  • Matplotlib for plotting the terrain:
    pip install matplotlib
    
  • Basic understanding of Python programming.

What Is Perlin Noise?

Perlin Noise is a gradient noise function used to generate smooth, natural patterns. It's commonly used to simulate textures like wood grain, marble, clouds, and terrains. The noise function produces a pseudo-random sequence of values that are smoothly interpolated, resulting in a coherent and natural appearance.

Implementing Perlin Noise in Python

We'll start by implementing a simple version of Perlin Noise and then use it to generate a terrain height map.

1. Importing Necessary Libraries

import numpy as np
import matplotlib.pyplot as plt

2. Defining the Perlin Noise Function

First, we'll create functions to generate Perlin Noise.

def lerp(a, b, x):
    """Linear interpolation between a and b with x."""
    return a + x * (b - a)

def fade(t):
    """Fade function as defined by Ken Perlin. This eases coordinate values 
    so that they will ease towards integral values. This ends up smoothing 
    the final output."""
    return 6 * t**5 - 15 * t**4 + 10 * t**3

def gradient(h, x, y):
    """Calculates the dot product of a pseudorandom gradient vector and the 
    vector from the input coordinate to the grid node."""
    vectors = np.array([[0,1], [0,-1], [1,0], [-1,0]])
    g = vectors[h % 4]
    return g[:, :, 0] * x + g[:, :, 1] * y

def perlin(x, y, seed=0):
    """Generate Perlin noise based on the input x and y coordinates."""
    np.random.seed(seed)
    # Determine grid cell coordinates
    x0 = x.astype(int)
    x1 = x0 + 1
    y0 = y.astype(int)
    y1 = y0 + 1

    # Interpolation weights
    sx = fade(x - x0)
    sy = fade(y - y0)

    # Random hash
    h00 = np.random.randint(0, 4, size=(x.shape[0], x.shape[1]))
    h01 = np.random.randint(0, 4, size=(x.shape[0], x.shape[1]))
    h10 = np.random.randint(0, 4, size=(x.shape[0], x.shape[1]))
    h11 = np.random.randint(0, 4, size=(x.shape[0], x.shape[1]))

    # Gradients
    n00 = gradient(h00, x - x0, y - y0)
    n10 = gradient(h10, x - x1, y - y0)
    n01 = gradient(h01, x - x0, y - y1)
    n11 = gradient(h11, x - x1, y - y1)

    # Interpolate
    ix0 = lerp(n00, n10, sx)
    ix1 = lerp(n01, n11, sx)
    value = lerp(ix0, ix1, sy)

    return value

3. Generating the Terrain Height Map

We'll create a grid of coordinates and compute the Perlin Noise values.

def generate_perlin_noise_2d(shape, res, seed=0):
    """Generate a 2D numpy array of perlin noise."""
    def f(t):
        return 6 * t**5 - 15 * t**4 + 10 * t**3

    delta = (res[0] / shape[0], res[1] / shape[1])
    d = (shape[0] // res[0], shape[1] // res[1])

    grid = np.mgrid[0:res[0]:complex(0, shape[0]), 0:res[1]:complex(0, shape[1])].transpose(1, 2, 0) % 1

    # Gradients
    np.random.seed(seed)
    gradients = np.random.randn(res[0]+1, res[1]+1, 2)
    gradients /= np.linalg.norm(gradients, axis=2, keepdims=True)

    # Ramps
    g00 = gradients[0:-1, 0:-1].repeat(d[0], 0).repeat(d[1], 1)
    g10 = gradients[1:, 0:-1].repeat(d[0], 0).repeat(d[1], 1)
    g01 = gradients[0:-1, 1:].repeat(d[0], 0).repeat(d[1], 1)
    g11 = gradients[1:, 1:].repeat(d[0], 0).repeat(d[1], 1)

    n00 = np.sum(grid * g00, 2)
    n10 = np.sum(np.stack([grid[:,:,0]-1, grid[:,:,1]], axis=2) * g10, 2)
    n01 = np.sum(np.stack([grid[:,:,0], grid[:,:,1]-1], axis=2) * g01, 2)
    n11 = np.sum(np.stack([grid[:,:,0]-1, grid[:,:,1]-1], axis=2) * g11, 2)

    t = f(grid)
    n0 = n00*(1-t[:,:,0]) + t[:,:,0]*n10
    n1 = n01*(1-t[:,:,0]) + t[:,:,0]*n11
    return np.sqrt(2)*((1 - t[:,:,1])*n0 + t[:,:,1]*n1)

4. Visualizing the Terrain

Now, let's generate the noise and visualize it using Matplotlib.

shape = (500, 500)
res = (8, 8)
noise = generate_perlin_noise_2d(shape, res, seed=1)

plt.figure(figsize=(8, 8))
plt.imshow(noise, cmap='terrain')
plt.colorbar()
plt.title('Procedurally Generated Terrain')
plt.show()

Explanation:

  • shape: The dimensions of the generated terrain.
  • res: The number of periods of noise to generate along each axis.
  • seed: A seed value for the random number generator to produce repeatable results.
  • cmap='terrain': A colormap that gives the image a terrain-like appearance.

5. Enhancing Realism with Octaves

To create more realistic terrains, we can sum multiple layers of Perlin Noise at different frequencies and amplitudes, known as octaves.

def generate_fractal_noise_2d(shape, res, octaves=1, persistence=0.5, seed=0):
    noise = np.zeros(shape)
    frequency = 1
    amplitude = 1
    max_amplitude = 0
    for _ in range(octaves):
        noise += amplitude * generate_perlin_noise_2d(shape, (frequency * res[0], frequency * res[1]), seed=seed)
        max_amplitude += amplitude
        amplitude *= persistence
        frequency *= 2
    return noise / max_amplitude

Let's generate terrain with multiple octaves:

octaves = 6
persistence = 0.5
noise = generate_fractal_noise_2d(shape, res, octaves, persistence, seed=2)

plt.figure(figsize=(8, 8))
plt.imshow(noise, cmap='terrain')
plt.colorbar()
plt.title('Terrain with Multiple Octaves')
plt.show()

Explanation:

  • octaves: The number of noise layers.
  • persistence: Controls the amplitude of each octave. Lower values result in smoother terrains.

Full Source Code

Combining all the parts, here's the complete code:

import numpy as np
import matplotlib.pyplot as plt

def generate_perlin_noise_2d(shape, res, seed=0):
    def f(t):
        return 6 * t**5 - 15 * t**4 + 10 * t**3

    delta = (res[0] / shape[0], res[1] / shape[1])
    d = (shape[0] // res[0], shape[1] // res[1])

    grid = np.mgrid[0:res[0]:complex(0, shape[0]), 0:res[1]:complex(0, shape[1])].transpose(1, 2, 0) % 1

    # Gradients
    np.random.seed(seed)
    gradients = np.random.randn(res[0]+1, res[1]+1, 2)
    gradients /= np.linalg.norm(gradients, axis=2, keepdims=True)

    # Ramps
    g00 = gradients[0:-1, 0:-1].repeat(d[0], 0).repeat(d[1], 1)
    g10 = gradients[1:, 0:-1].repeat(d[0], 0).repeat(d[1], 1)
    g01 = gradients[0:-1, 1:].repeat(d[0], 0).repeat(d[1], 1)
    g11 = gradients[1:, 1:].repeat(d[0], 0).repeat(d[1], 1)

    n00 = np.sum(grid * g00, 2)
    n10 = np.sum(np.stack([grid[:,:,0]-1, grid[:,:,1]], axis=2) * g10, 2)
    n01 = np.sum(np.stack([grid[:,:,0], grid[:,:,1]-1], axis=2) * g01, 2)
    n11 = np.sum(np.stack([grid[:,:,0]-1, grid[:,:,1]-1], axis=2) * g11, 2)

    t = f(grid)
    n0 = n00*(1 - t[:,:,0]) + t[:,:,0]*n10
    n1 = n01*(1 - t[:,:,0]) + t[:,:,0]*n11
    return np.sqrt(2)*((1 - t[:,:,1])*n0 + t[:,:,1]*n1)

def generate_fractal_noise_2d(shape, res, octaves=1, persistence=0.5, seed=0):
    noise = np.zeros(shape)
    frequency = 1
    amplitude = 1
    max_amplitude = 0
    for _ in range(octaves):
        noise += amplitude * generate_perlin_noise_2d(
            shape, (frequency * res[0], frequency * res[1]), seed=seed)
        max_amplitude += amplitude
        amplitude *= persistence
        frequency *= 2
    return noise / max_amplitude

# Parameters
shape = (500, 500)
res = (4, 4)
octaves = 6
persistence = 0.5

# Generate noise
noise = generate_fractal_noise_2d(shape, res, octaves, persistence, seed=2)

# Plotting
plt.figure(figsize=(8, 8))
plt.imshow(noise, cmap='terrain')
plt.colorbar()
plt.title('Procedurally Generated Terrain')
plt.show()

Customizing the Terrain

You can adjust the terrain's appearance by tweaking parameters:

  • Resolution (res): Lower values create larger features; higher values create finer details.
  • Octaves: Increasing octaves adds more layers of detail.
  • Persistence: Controls how quickly the amplitudes decrease for higher octaves.

Adding Features: Rivers and Lakes

To simulate water bodies, we can set height thresholds:

water_level = -0.1
terrain = noise.copy()
terrain[terrain < water_level] = water_level

plt.figure(figsize=(8, 8))
plt.imshow(terrain, cmap='terrain')
plt.colorbar()
plt.title('Terrain with Water Bodies')
plt.show()

Conclusion

Procedural terrain generation using Perlin Noise opens up endless possibilities for creating dynamic and natural-looking environments. By adjusting a few parameters, you can generate a variety of terrains for games, simulations, or visualizations.

Next Steps

  • 3D Terrain Visualization: Use libraries like matplotlib's plot_surface or mayavi to visualize the terrain in 3D.
  • Texture Mapping: Apply textures to different height ranges to simulate grass, rocks, or snow.
  • Terrain Export: Save the generated terrain data for use in game engines or other applications.
  • Erosion Simulation: Implement algorithms to simulate natural erosion for more realistic terrains.

Conclusion

Procedural generation is a powerful tool in a developer's arsenal, enabling the creation of vast and varied environments with minimal manual effort. Python's simplicity and the availability of powerful libraries make it an excellent choice for experimenting with these concepts. So go ahead, tweak the code, and create your own unique worlds!