Understanding Perlin noise
In this little experiment I investigate Perlin noise, and end by making a fantasy map:
Oscar winning mathematics
Ken Perlin won an Oscar for the development of Perlin noise. The Academy of Motion Picture Arts and Sciences award said The development of Perlin Noise has allowed computer graphics artists to better represent the complexity of natural phenomena in visual effects for the motion picture industry. ## What is Perlin noise and how does it work? Perlin noise is a very versatile procedural texture which can be used to create all kinds of visual effects - flames, marbles, rusty metals, planet surfaces, maps, all kinds of stuff. It basically creates random “bumpy” textures, and uses methods to make them smoother and more natural looking.
I am going to use the Python noise library to experiment by calling it with different parameter values and seeing what happens. I am going to experiment with the pnoise2
function. It has the following parameters:
Parameter | Description |
---|---|
x |
The x-coordinate of the input point. This is the first of the two coordinates at which to evaluate the noise function. |
y |
The y-coordinate of the input point. This is the second of the two coordinates at which to evaluate the noise function. |
octaves |
(Optional) The number of octaves to use for the noise generation. More octaves result in more detailed noise. Default is 1. |
persistence |
(Optional) The persistence of the noise, which controls the amplitude of each octave. A higher persistence value results in higher amplitudes for higher octaves. Default is 0.5. |
lacunarity |
(Optional) The lacunarity of the noise, which controls the frequency of each octave. A higher lacunarity value results in higher frequencies for higher octaves. Default is 2.0. |
repeatx |
(Optional) The x-coordinate repeat interval. If this is non-zero, the noise function will repeat at this interval along the x-axis. Default is 1024. |
repeaty |
(Optional) The y-coordinate repeat interval. If this is non-zero, the noise function will repeat at this interval along the y-axis. Default is 1024. |
base |
(Optional) The base value for the noise function. This is used to offset the input coordinates, creating different variations of the noise. Default is 0. |
Ok, a lot of the parameters are optional so let’s ignore those for the moment. And let’s call the function with minimal values. Each time the function is called, it just returns the value at a that x,y point. Here’s code to use this function to create an array, then make it into a greyscale PNG image:
import noise
import numpy as np
from PIL import Image
import os
# Parameters for noise generation
= 512, 512
width, height = 100.0
scale
# Generate a single noise array
= np.zeros((width, height))
noise_array
for i in range(width):
for j in range(height):
= noise.pnoise2(i / scale, j / scale, base=0)
noise_array[i][j]
# Normalize the noise values to the range [0, 255] for image representation
= (255 * (noise_array - noise_array.min()) / (noise_array.max() - noise_array.min())).astype(np.uint8)
noise_array
# Create an image from the noise array
= Image.fromarray(noise_array, mode='L') # 'L' mode for grayscale
noise_image
# Save the image as a PNG file
'noise_image.png')
noise_image.save(
print("PNG image created: noise_image.png")
Here’s the resulting image:
Ok. So let’s try altering the octaves
parameter. Default is 1 (above). Let’s try some other values:
octaves = 4
Ah ok, so the density of the bumps is similar, but it is noisier.
Note that the base
parameter is the one that effectively acts as the randomiser. If we use the same base we will always get the same pattern. Let’s just test that by making base
1 for the next text:
Yep, as expected, a different distribution. I’ll put it back to 0 for the following tests because it’s actually quite useful to always be using the same pattern.
octaves = 8
So that is more sharply defined than the previous one. Does making it even higher do anything? Let’s look at an extreme:
octaves = 64
A little difference.
Tests on octaves
being 0 and 128 give out-of-bounds errors. Let’s reset it to 1 and now test persistence
.
persistence = 5
Let’s look at that side by side with persistence = 0.5
:
I can’t really see any difference. I tried with other values too but couldn’t get anything interesting to happen.
lacunarity
What about lacunarity
?
Lacunarity, from the Latin lacuna, meaning “gap” or “lake”, is a specialized term in geometry referring to a measure of how patterns, especially fractals, fill space, where patterns having more or larger gaps generally have higher lacunarity. Source.
I tried various values for lacunarity
, and couldn’t see any difference. I checked with ChatGPT about why that might be the case and I think the answer is that I was always using octaves=1
, which may cause the other parameters not to have much effect. It suggested these values:
octaves = 6 # Increase the number of octaves for more complexity
persistence = 0.8 # Higher persistence for more pronounced features
lacunarity = 3.0 # Higher lacunarity for more complex patterns
Which results in this:
Ok, yep I see a difference there. But I’m bored of that now, let’s try to do something interesting. This is something I’ve thought about before, but never tried to do. I want to make a gradient in Photoshop, representing the different parts of a map (sea, shore, grass, trees, mountains, snow-tops, and then map that gradient onto the greyscale results from Perlin noise. First, I’ll make the gradient. Here you go:
Now let’s apply that to the Perlin noise map:
Hey that’s pretty cool, right?
Changing the scale value makes a huge difference:
Here’s my final Python script:
import noise
import numpy as np
from PIL import Image
import random
import os
from datetime import datetime
# Step 1: Read the color gradient from the PNG image
= 'gradient.png'
gradient_image_path = Image.open(gradient_image_path)
gradient_image = np.array(gradient_image)[0] # The gradient is horizontal
gradient_colors
# Parameters for noise generation
= 1024, 1024
width, height = 400.0
scale = 6
octaves = 0.5
persistence = 2.0
lacunarity
# Generate a random base value
= random.randint(0, 100)
random_base
# Step 2: Generate a Perlin noise array
= np.zeros((width, height))
noise_array
for i in range(width):
for j in range(height):
= noise.pnoise2(
noise_array[i][j] / scale,
i / scale,
j =octaves,
octaves=persistence,
persistence=lacunarity,
lacunarity=random_base
base
)
# Normalize the noise values to the range [0, 1]
= (noise_array - noise_array.min()) / (noise_array.max() - noise_array.min())
normalized_noise_array
# Step 3: Map the normalized noise values to the color gradient
= np.zeros((width, height, 3), dtype=np.uint8)
color_image_array
for i in range(width):
for j in range(height):
= int(normalized_noise_array[i][j] * 255) # Scale to [0, 255] range
color_index = gradient_colors[color_index]
color_image_array[i][j]
# Step 4: Create and save the new color PNG with a unique name
= datetime.now().strftime('%Y%m%d_%H%M%S')
timestamp = f'color_noise_image_{timestamp}.png'
unique_filename = Image.fromarray(color_image_array)
color_image
color_image.save(unique_filename)
print(f"Color PNG image created: {unique_filename}")