Pixels2GenAI
Path ii Continuum
M 06 · 6.1.1 · hands-on

6.1.1 Perlin & Value Noise

Build smooth, organic noise by sampling a random grid and interpolating between cells with a smoothstep. Stack octaves of decreasing amplitude and increasing frequency for cloud-like fractal Brownian motion.

Duration20–25 min
Levelintermediate
Load3 core concepts
Prereqs3.4.3 (scalar fields), basic NumPy broadcasting

Overview

Random pixel-by-pixel noise looks like television static — every cell independent of its neighbours, no structure at any scale. Ken Perlin’s 1983 insight was that coherent noise (where neighbouring samples are correlated) reads as natural — clouds, smoke, marble veins, terrain [1]. The recipe is two ingredients: a grid of random values, and a smooth interpolation between them. Layer several such noise fields at different scales and you get fractional Brownian motion (fBm), the workhorse of every procedural texture in games and VFX since Tron [2]. This lesson builds the pipeline in NumPy from scratch, so you can read every pixel-level decision.

Learning objectives

  1. Distinguish random noise (TV static) from coherent noise (Perlin/value noise) — the smoothness comes from interpolating a coarse random grid.
  2. Implement bilinear interpolation with a smoothstep S(t) = 3t² − 2t³ for the natural-looking transitions.
  3. Combine multiple octaves of noise with halving amplitude (persistence) and doubling frequency (lacunarity) to build fractal Brownian motion.
  4. Tune scale, octaves, and persistence to move between blobby and detailed textures.

Quick start — value noise clouds

python · quick_start.py
import numpy as np
from PIL import Image

def smoothstep(t):
    return t * t * t * (t * (t * 6 - 15) + 10)        # quintic, Perlin 2002

def value_noise(shape, scale, seed=0):
    h, w = shape
    rng = np.random.default_rng(seed)
    cells_y = int(np.ceil(h / scale)) + 2
    cells_x = int(np.ceil(w / scale)) + 2
    cells = rng.random((cells_y, cells_x))            # random per grid corner

    y, x = np.mgrid[:h, :w].astype(np.float64)
    yf = y / scale; xf = x / scale
    y0 = yf.astype(int); x0 = xf.astype(int)
    ty = smoothstep(yf - y0)
    tx = smoothstep(xf - x0)

    c00 = cells[y0,     x0    ]; c10 = cells[y0 + 1, x0    ]
    c01 = cells[y0,     x0 + 1]; c11 = cells[y0 + 1, x0 + 1]
    top = c00 + (c01 - c00) * tx
    bot = c10 + (c11 - c10) * tx
    return top + (bot - top) * ty                     # in [0, 1]

def fbm(shape, scale, octaves=6, persistence=0.5, seed=0):
    out = np.zeros(shape); amp = 1.0; s = scale; norm = 0
    for o in range(octaves):
        out += amp * value_noise(shape, s, seed + o)
        norm += amp; amp *= persistence; s /= 2
    return out / norm

z = fbm((512, 512), scale=90, octaves=6, seed=7)
img = (z * 255).astype(np.uint8)
Image.fromarray(img, 'L').save('value_clouds.png')
A grayscale image of smooth flowing cloud-like patterns. Lighter and darker regions transition gradually with no sharp edges.
Fig. 1 Six octaves of value noise. Same pipeline produces clouds, terrain, smoke — the difference is only how you map the [0, 1] field to colours.

Core concepts

Concept 1 — Random vs coherent

Pure random per-pixel noise produces TV-static — every neighbour is independent:

rng = np.random.default_rng(7)
static = rng.random((256, 256))     # white noise

Coherent noise samples a coarse random grid (say one cell per 30 pixels), then interpolates between the corners. Adjacent pixels in the output share most of their nearby grid values, so they take on similar interpolated values — neighbours look alike.

A side-by-side comparison. Left: random per-pixel grayscale noise that looks like television static. Right: smooth value noise with flowing cloud-like regions of darker and lighter intensity.
Fig. 2 Random noise (left) vs value noise (right). Same pixel count, totally different structure.

Concept 2 — Smoothstep interpolation

A naive linear interpolation between corner values gives visible diamond patterns at cell boundaries because the derivative changes abruptly at each crossing. Ken Perlin’s 2002 fix replaced the cubic smoothstep 3t² − 2t³ with a quintic 6t⁵ − 15t⁴ + 10t³ whose first and second derivatives both vanish at t = 0 and t = 1 [1].

def smoothstep(t):
    return t * t * t * (t * (t * 6 - 15) + 10)
  • t = 00, S′(0) = 0, S″(0) = 0.
  • t = 11, S′(1) = 0, S″(1) = 0.

The result is invisible cell boundaries even under derivative-amplifying operations like edge detection.

Concept 3 — Fractional Brownian motion (fBm)

A single octave of noise is too smooth — it has features at exactly one scale. Adding multiple octaves at halving amplitude and doubling frequency layers detail without overwhelming the base shape. The result is fractional Brownian motion, the same mathematical object that describes Brownian particle paths and natural terrain [4]:

total = octave_1 + (1/2) * octave_2 + (1/4) * octave_3 + (1/8) * octave_4 + ...

Each subsequent octave halves the wavelength (lacunarity = 2) and halves the amplitude (persistence = 0.5).

Four panels side by side showing increasing detail: 1 octave produces large smooth blobs, 2 octaves add medium texture, 4 octaves give cloud-like detail, 8 octaves show fine grain on top of the cloud structure.
Fig. 3 One, two, four, eight octaves. Each adds finer detail without removing the large-scale structure of the previous layer.

Exercises

Three exercises in Execute → Modify → Create order: run the cloud generator, tune octaves and persistence, then build a blue-tinted cloud panorama.

EXECUTE I.

Run the cloud generator

Run value_clouds.py from the downloads. Inspect the output.

Reflection questions

  • Why does fbm divide by norm at the end?
  • What happens if you drop octaves to 1?
  • What happens if you raise persistence to 0.9?
MODIFY II.

Three parameter sweeps

Edit value_clouds.py to produce these three pictures.

Goals

  1. Big features — bump scale to 200 and watch the texture zoom out.
  2. Fewer layers — drop octaves to 2 for blobby contour-like output.
  3. Smoother decay — set persistence=0.3 to suppress fine detail.
CREATE III.

Blue-tinted clouds over a colour gradient

Render the noise as a soft blue cloud field by stuffing the noise value into the red and green channels and keeping blue near 1. Then add a vertical sky gradient under the clouds.

python · exercise3_starter.py
import numpy as np
from PIL import Image
# ... assume fbm() from quick start is defined ...

H, W = 320, 640
z = fbm((H, W), scale=110, octaves=5, seed=42)

# TODO 1: build a sky gradient — RGB that goes from a deep blue at top
#         to a paler blue at the horizon.

# TODO 2: build a cloud overlay: white where z is high, transparent where low.
#         Multiply-screen the cloud onto the sky.

Image.fromarray(rgb_uint8).save('blue_clouds.png')

Make it your own

  • Switch the colour ramp to evening — saturated orange at the horizon, deep purple at the zenith. The same noise reads as sunset clouds.
  • Swap value noise for the gradient version (Perlin). Implement it by storing 2D unit vectors at each grid corner and replacing cells[...] reads with dot products against (yf − y0, xf − x0).
  • Animate seed from 0 to 60 and assemble a GIF — the clouds drift coherently because each frame is one step along an underlying noise lattice.

Downloads

value_clouds.png — quick-start output reference octaves_grid.png — octave comparison perlin_clouds.png — v1 cloud reference (noise library)

Summary

Common pitfalls to avoid

  • Linear interpolation instead of smoothstep — visible cell grid in the output.
  • Using np.random.random per pixel — that is white noise, not value noise; the smoothness comes from interpolating a coarse grid.
  • Forgetting to normalise after summing octaves — the value range drifts above 1, and (z * 255).astype(uint8) saturates.
  • Persistence above 1 — higher octaves dominate the lower ones; the texture turns into noise.
  • Reusing the same seed across octaves — same noise patterns at different scales line up suspiciously. Offset the seed by the octave index.

References

  1. [1] Perlin, K. (1985). An image synthesizer. ACM SIGGRAPH Computer Graphics, 19(3), 287–296. doi:10.1145/325165.325247
  2. [2] Perlin, K. (2002). Improving noise. ACM Transactions on Graphics, 21(3), 681–682. doi:10.1145/566654.566636
  3. [3] Ebert, D. S., Musgrave, F. K., Peachey, D., Perlin, K., & Worley, S. (2003). Texturing and Modeling: A Procedural Approach (3rd ed.). Morgan Kaufmann.
  4. [4] Mandelbrot, B. B., & Van Ness, J. W. (1968). Fractional Brownian motions, fractional noises and applications. SIAM Review, 10(4), 422–437. doi:10.1137/1010093
  5. [5] Lagae, A., Lefebvre, S., Cook, R., et al. (2010). A survey of procedural noise functions. Computer Graphics Forum, 29(8), 2579–2600. doi:10.1111/j.1467-8659.2010.01827.x
  6. [6] Harris, C. R., Millman, K. J., van der Walt, S. J., et al. (2020). Array programming with NumPy. Nature, 585, 357–362. doi:10.1038/s41586-020-2649-2