Python Noise Generation Perlin — Deep Dive

The Perlin Noise Algorithm Step by Step

1. Permutation Table

Perlin noise uses a fixed permutation table — a shuffled array of integers 0-255, doubled to avoid index wrapping:

import numpy as np

def generate_permutation(seed=0):
    rng = np.random.default_rng(seed)
    p = np.arange(256, dtype=int)
    rng.shuffle(p)
    return np.tile(p, 2)  # double to avoid modular indexing

perm = generate_permutation(seed=42)

2. Gradient Vectors

In 2D, gradients are unit vectors at 45° intervals:

GRADIENTS_2D = np.array([
    (1, 1), (-1, 1), (1, -1), (-1, -1),
    (1, 0), (-1, 0), (0, 1), (0, -1)
], dtype=float)

def gradient(hash_val, x, y):
    g = GRADIENTS_2D[hash_val % 8]
    return g[0] * x + g[1] * y  # dot product

3. Fade Function

The fade curve ensures smooth interpolation at grid boundaries. Perlin’s improved version uses a quintic polynomial:

def fade(t):
    # 6t^5 - 15t^4 + 10t^3
    return t * t * t * (t * (t * 6 - 15) + 10)

4. Core Algorithm

def perlin_2d(x, y, perm):
    # Grid cell coordinates
    xi = int(np.floor(x)) & 255
    yi = int(np.floor(y)) & 255

    # Relative position within cell
    xf = x - np.floor(x)
    yf = y - np.floor(y)

    # Fade curves
    u = fade(xf)
    v = fade(yf)

    # Hash corners
    aa = perm[perm[xi] + yi]
    ab = perm[perm[xi] + yi + 1]
    ba = perm[perm[xi + 1] + yi]
    bb = perm[perm[xi + 1] + yi + 1]

    # Gradient dot products
    g_aa = gradient(aa, xf, yf)
    g_ba = gradient(ba, xf - 1, yf)
    g_ab = gradient(ab, xf, yf - 1)
    g_bb = gradient(bb, xf - 1, yf - 1)

    # Bilinear interpolation
    x1 = g_aa + u * (g_ba - g_aa)
    x2 = g_ab + u * (g_bb - g_ab)
    return x1 + v * (x2 - x1)

Vectorized Implementation with NumPy

The scalar version above is educational but slow for generating large heightmaps. A vectorized version processes entire arrays at once:

def perlin_2d_vectorized(x_arr, y_arr, perm):
    xi = np.floor(x_arr).astype(int) & 255
    yi = np.floor(y_arr).astype(int) & 255
    xf = x_arr - np.floor(x_arr)
    yf = y_arr - np.floor(y_arr)

    u = fade(xf)
    v = fade(yf)

    aa = perm[perm[xi] + yi]
    ab = perm[perm[xi] + yi + 1]
    ba = perm[perm[xi + 1] + yi]
    bb = perm[perm[xi + 1] + yi + 1]

    def grad_dot(h, dx, dy):
        g = GRADIENTS_2D[h % 8]
        return g[:, 0] * dx + g[:, 1] * dy if dx.ndim > 0 else g[0] * dx + g[1] * dy

    g_aa = grad_dot(aa, xf, yf)
    g_ba = grad_dot(ba, xf - 1, yf)
    g_ab = grad_dot(ab, xf, yf - 1)
    g_bb = grad_dot(bb, xf - 1, yf - 1)

    x1 = g_aa + u * (g_ba - g_aa)
    x2 = g_ab + u * (g_bb - g_ab)
    return x1 + v * (x2 - x1)

# Generate a 512x512 heightmap
size = 512
scale = 50.0
x = np.linspace(0, size / scale, size)
y = np.linspace(0, size / scale, size)
xx, yy = np.meshgrid(x, y)
heightmap = perlin_2d_vectorized(xx.flatten(), yy.flatten(), perm).reshape(size, size)

Performance Comparison

Method512×512 Heightmap2048×2048 Heightmap
Scalar Python (loop)~8 seconds~130 seconds
NumPy vectorized~0.05 seconds~0.8 seconds
C-backed noise library~0.02 seconds~0.3 seconds
FastNoise Lite (SIMD)~0.005 seconds~0.06 seconds

For production use, prefer C-backed libraries. Use the pure Python version for learning and customization.

Fractal Brownian Motion (fBm)

Layering octaves is formally called fractal Brownian motion:

def fbm(x, y, perm, octaves=6, lacunarity=2.0, persistence=0.5):
    total = 0.0
    amplitude = 1.0
    frequency = 1.0
    max_value = 0.0  # for normalization

    for _ in range(octaves):
        total += perlin_2d(x * frequency, y * frequency, perm) * amplitude
        max_value += amplitude
        amplitude *= persistence
        frequency *= lacunarity

    return total / max_value  # normalize to [-1, 1]

Domain Warping

Domain warping feeds noise output back as input coordinates, creating organic, swirling patterns:

def domain_warp(x, y, perm, warp_strength=4.0):
    # First noise layer warps the coordinates
    qx = fbm(x, y, perm)
    qy = fbm(x + 5.2, y + 1.3, perm)

    # Second layer uses warped coordinates
    return fbm(
        x + warp_strength * qx,
        y + warp_strength * qy,
        perm
    )

This technique produces terrain that looks weathered and eroded — far more realistic than plain fBm.

Multi-Level Domain Warping

def double_warp(x, y, perm):
    # Level 1
    q_x = fbm(x + 0.0, y + 0.0, perm)
    q_y = fbm(x + 5.2, y + 1.3, perm)

    # Level 2
    r_x = fbm(x + 4.0 * q_x + 1.7, y + 4.0 * q_y + 9.2, perm)
    r_y = fbm(x + 4.0 * q_x + 8.3, y + 4.0 * q_y + 2.8, perm)

    return fbm(x + 4.0 * r_x, y + 4.0 * r_y, perm)

This creates intricate, painterly landscapes used in AAA game terrain.

Noise Types Beyond Perlin

Worley (Cellular) Noise

Distributes random feature points and colors each pixel by distance to the nearest point. Creates cell-like patterns — cracked earth, stone walls, biological tissue.

def worley_2d(x, y, seed=0, num_cells=16):
    rng = np.random.default_rng(seed)
    points = rng.random((num_cells * num_cells, 2)) * num_cells

    min_dist = float('inf')
    for px, py in points:
        dist = (x - px) ** 2 + (y - py) ** 2
        min_dist = min(min_dist, dist)
    return np.sqrt(min_dist)

Value Noise

Assigns random values to grid points and interpolates (without gradients). Simpler than Perlin but produces more “blocky” artifacts.

Ridged Noise

Takes the absolute value of noise and inverts it, creating sharp ridges:

def ridged_fbm(x, y, perm, octaves=6):
    total = 0.0
    amplitude = 1.0
    frequency = 1.0
    for _ in range(octaves):
        n = perlin_2d(x * frequency, y * frequency, perm)
        n = 1.0 - abs(n)  # invert absolute value → ridges
        n = n * n          # sharpen
        total += n * amplitude
        amplitude *= 0.5
        frequency *= 2.0
    return total

This produces mountain-ridge-like terrain and river-valley patterns.

Practical Applications

Terrain Generation with Biomes

def generate_world(size, seed):
    perm = generate_permutation(seed)

    elevation = np.zeros((size, size))
    moisture = np.zeros((size, size))

    for y in range(size):
        for x in range(size):
            nx, ny = x / size - 0.5, y / size - 0.5
            elevation[y][x] = fbm(nx * 6, ny * 6, perm, octaves=8)
            moisture[y][x] = fbm(nx * 4 + 100, ny * 4 + 100, perm, octaves=6)

    # Assign biomes
    biomes = np.empty((size, size), dtype='U20')
    biomes[(elevation > 0.6)] = "mountain"
    biomes[(elevation > 0.3) & (elevation <= 0.6) & (moisture > 0.3)] = "forest"
    biomes[(elevation > 0.3) & (elevation <= 0.6) & (moisture <= 0.3)] = "grassland"
    biomes[(elevation > 0.0) & (elevation <= 0.3)] = "beach"
    biomes[(elevation <= 0.0)] = "ocean"

    return elevation, moisture, biomes

Animated Effects

Use time as a third dimension for evolving patterns:

import time as time_mod

def animated_fire(x, y, t):
    # Offset y by time to create upward flow
    return fbm(x * 3, y * 3 - t * 2, perm, octaves=4, persistence=0.6)

Texture Synthesis

def marble_texture(x, y, perm):
    n = fbm(x * 2, y * 2, perm, octaves=6)
    return np.sin(x * 0.05 + n * 5)  # sine wave distorted by noise

def wood_texture(x, y, perm):
    n = fbm(x, y, perm, octaves=4)
    grain = 20 * n
    return grain - int(grain)  # fractional part creates rings

3D Noise for Caves and Volumetric Effects

Extending to 3D allows carving cave systems:

def generate_cave(size_x, size_y, size_z, threshold=0.0):
    cave = np.zeros((size_x, size_y, size_z), dtype=bool)
    for x in range(size_x):
        for y in range(size_y):
            for z in range(size_z):
                val = noise.pnoise3(x / 20, y / 20, z / 20, octaves=4)
                cave[x][y][z] = val > threshold  # True = solid
    return cave

Voxels where noise is below the threshold become air, creating interconnected tunnel systems.

Performance Best Practices

  1. Use C-backed libraries for production — noise, pyfastnoiselite, or opensimplex with Cython.
  2. Vectorize with NumPy — generate entire arrays instead of calling noise point-by-point.
  3. Cache generated chunks — store generated terrain to disk rather than regenerating.
  4. Generate on background threads — Python’s GIL releases during C library calls, enabling true parallelism.
  5. Use lower octaves for previews — 2 octaves renders 4× faster than 8 and is sufficient for mini-maps or LOD.

The one thing to remember: Perlin noise is a deterministic, smooth gradient function that, combined with octave layering and domain warping, produces endlessly varied natural patterns — and Python’s NumPy and C-backed libraries make it fast enough for real-time generation.

pythonperlin-noiseprocedural-generation

See Also

  • Python Arcade Library Think of a magical art table that draws your game characters, listens when you press buttons, and cleans up the mess — that's Python Arcade.
  • Python Audio Fingerprinting Ever wonder how Shazam identifies a song from just a few seconds of noisy audio? Audio fingerprinting is the magic behind it, and Python can do it too.
  • Python Barcode Generation Picture the stripy labels on grocery items to understand how Python can create those machine-readable barcodes from numbers.
  • Python Cellular Automata Imagine a checkerboard where each square follows simple rules to turn on or off — and suddenly complex patterns emerge like magic.
  • Python Godot Gdscript Bridge Imagine speaking English to a friend who speaks French, with a translator in the middle — that's how Python talks to the Godot game engine.