The Perlin Noise Algorithm
Plain random numbers are the wrong kind of randomness for most graphics
and simulation work. A grid where every cell is an independent
rand() % 256 looks like television static β useful for nothing. Clouds,
terrain, marble, fire, and countless procedural textures instead need
randomness that is smooth: neighboring points should have similar
values, while distant points can differ completely. Ken Perlin developed
exactly that in 1983 (originally for the movie Tron), and it won him a
Technical Achievement Award from the Academy of Motion Picture Arts and
Sciences in 1997.
The idea
Lay an invisible grid over the plane, and give every grid corner a fixed pseudo-random gradient vector β a direction, not a value. To find the noise value at an arbitrary point, first find which grid cell it falls into, then compute the dot product between each of that cell's four corner gradients and the vector pointing from that corner to the query point. That gives four numbers, one per corner, each describing how much that corner's gradient "points toward" or "away from" the query point.
Those four numbers then get blended together with a weighted average based on how close the query point is to each corner β smoothly, not linearly, using a curve (the fade function below) that eases in and out instead of changing at a constant rate. That smoothing is what keeps the result continuous across cell boundaries: two points on either side of a grid line get nearly the same blend of nearby gradients, so the noise never jumps.
The gradients themselves come from a fixed permutation table: a
shuffled list of the numbers 0β255, indexed by grid coordinates to pick
one of a small set of gradient directions per corner. Since the table is
fixed once and reused everywhere, the same input coordinates always
produce the same noise value β Perlin noise is a deterministic function of
(x, y), not actually random at all once the table is built.
Implementation
#include "plot.hpp"
#include <math.h>
#include <stdlib.h>
int perm[512];
void init_permutation() {
int p[256];
for (int i = 0; i < 256; i++) p[i] = i;
srand(1);
for (int i = 255; i > 0; i--) {
int j = rand() % (i + 1);
int tmp = p[i];
p[i] = p[j];
p[j] = tmp;
}
for (int i = 0; i < 512; i++) perm[i] = p[i & 255];
}
double fade(double t) {
return t * t * t * (t * (t * 6 - 15) + 10);
}
double lerp(double t, double a, double b) {
return a + t * (b - a);
}
double grad(int hash, double x, double y) {
switch (hash & 7) {
case 0: return x + y;
case 1: return x - y;
case 2: return -x + y;
case 3: return -x - y;
case 4: return x;
case 5: return -x;
case 6: return y;
default: return -y;
}
}
double perlin(double x, double y) {
int xi = (int)floor(x) & 255;
int yi = (int)floor(y) & 255;
double xf = x - floor(x);
double yf = y - floor(y);
double u = fade(xf);
double v = fade(yf);
int aa = perm[perm[xi] + yi];
int ab = perm[perm[xi] + yi + 1];
int ba = perm[perm[xi + 1] + yi];
int bb = perm[perm[xi + 1] + yi + 1];
double x1 = lerp(u, grad(aa, xf, yf), grad(ba, xf - 1, yf));
double x2 = lerp(u, grad(ab, xf, yf - 1), grad(bb, xf - 1, yf - 1));
return lerp(v, x1, x2);
}
// Fractal Brownian motion: several octaves of Perlin noise at increasing
// frequency and decreasing amplitude, summed together.
double fbm(double x, double y, int octaves) {
double total = 0;
double amplitude = 1.0;
double frequency = 1.0;
double maxValue = 0;
for (int i = 0; i < octaves; i++) {
total += perlin(x * frequency, y * frequency) * amplitude;
maxValue += amplitude;
amplitude *= 0.5;
frequency *= 2.0;
}
return total / maxValue;
}
void frame() {
static bool ready = false;
if (!ready) {
init_permutation();
ready = true;
}
int cell = 4;
for (int py = 0; py < plot_height; py += cell) {
for (int px = 0; px < plot_width; px += cell) {
double n = fbm(px / 80.0, py / 80.0, 4);
int gray = (int)((n + 1.0) * 0.5 * 255.0);
if (gray < 0) gray = 0;
if (gray > 255) gray = 255;
PlotColor(gray, gray, gray);
PlotFilledRectangle(px, py, px + cell, py + cell);
}
}
}
init_permutation builds the shuffled table once (guarded by the ready
flag in frame(), since this sample has no setup() of its own β see
below). grad picks one of eight fixed directions based on a hash value
and returns the dot product with (x, y) directly, which is a common
simplification of Perlin's original 3D gradient set for two dimensions.
perlin does the four-corner lookup-and-blend described above; fade
is the smoothing curve, lerp the weighted blend.
fbm (fractal Brownian motion) is what actually makes the result look
like clouds rather than a blurry blob: it samples perlin several times
at doubling frequency and halving amplitude, and adds the results
together. The first octave provides the broad shape, and each following
octave adds finer detail on top β the same idea behind adding overtones to
a fundamental frequency in sound.
This sample defines no setup(), so the default PlotCanvas(640, 480)
applies automatically β the same pattern used by the
Bresenham line and
midpoint circle samples. frame()
draws the noise as a grid of 4Γ4 pixel blocks rather than one draw call
per pixel, mapping each block's fbm value from its native [-1, 1]-ish
range to a grayscale brightness.
Walking through an example
Consider the noise value at x = 0.5, y = 0.5 β the exact center of the
grid cell between corners (0,0), (1,0), (0,1), (1,1). Each corner's
contribution is its gradient's dot product with the vector to (0.5, 0.5) relative to that corner:
| corner | vector to point | example gradient | dot product |
|---|---|---|---|
| (0,0) | (0.5, 0.5) | (1, 1) direction β x+y |
0.5 + 0.5 = 1.0 |
| (1,0) | (β0.5, 0.5) | (1, β1) direction β xβy |
β0.5 β 0.5 = β1.0 |
| (0,1) | (0.5, β0.5) | (β1, 1) direction β βx+y |
β0.5 β 0.5 = β1.0 |
| (1,1) | (β0.5, β0.5) | (β1, β1) direction β βxβy |
0.5 + 0.5 = 1.0 |
At the exact midpoint, fade(0.5) = 0.5 for both axes, so the blend
weights every corner equally. Averaging the four dot products above gives
0, which is exactly what should happen at a point equidistant from all
four corners with these particular (illustrative) gradients: no single
corner's direction dominates. Off-center points weight the nearer corners
more heavily, which is where the actual shape of the noise comes from.
Why the smoothing matters
Two design choices in this algorithm are easy to get wrong, and both happen to be visible if you do:
- Linear interpolation instead of
fade. Usingtdirectly instead oftΒ³(6tΒ² β 15t + 10)still produces continuous noise, but with visible creases along the grid lines β the rate of change is discontinuous at cell boundaries even though the value isn't.fade's first and second derivatives are both zero att = 0andt = 1, which is what actually makes the grid invisible in the output. - Too few octaves. A single octave of Perlin noise looks soft and blobby β recognizable as noise, but not as clouds or terrain. Real procedural textures almost always sum several octaves; the sample above uses four, doubling in frequency each time, which is enough to go from "blurry" to "convincingly organic."
Conclusion
Perlin noise turns a fixed table of pseudo-random directions into a
continuous function that looks organic at any zoom level and any point,
while staying entirely deterministic β the same (x, y) always produces
the same value, with no state to store beyond the permutation table
itself. That combination β natural-looking, infinitely dense, and cheap to
recompute rather than store β is why it still underlies so much procedural
content, four decades after it was built for a single movie.