Mathematics · Procedural Generation
📅 Березень 2026 ⏱ ≈ 13 хв читання 🎯 Intermediate

Perlin Noise and fBm — how maths paints nature

Mountains, clouds, ocean floors, fire, marble — all generated from a single elegant function. Ken Perlin invented gradient noise while working on Tron (1982). Four decades later it remains the standard tool for procedural content generation.

1. Noise types: value vs. gradient

All procedural noise functions map a continuous coordinate to a pseudo-random value that varies smoothly — unlike Math.random(), which is discontinuous. The key property: nearby inputs produce nearby outputs (spatial coherence).

Why gradient noise looks better: Interpolating values produces broad flat regions with sharp transitions at grid crossings. Interpolating gradient dot products guarantees the derivative is also continuous, yielding smooth undulations without the "grid-aligned ridges" artifact.

2. Permutation table and hash

The critical ingredient for "random but reproducible" sampling is a permutation table — an array P[256] containing a shuffled sequence of integers 0…255. The table is doubled (P[512]) to avoid modulo when indexing with negative values.

hash(x, y) = P[ P[x & 255] + (y & 255) ]

P is a fixed permutation of 0..255, repeated twice.
For 3D: hash(x,y,z) = P[ P[ P[x&255] + (y&255) ] + (z&255) ]

This gives 256 unique hash values per dimension combination, creating a lattice that repeats every 256 units — which is fine for most use cases. The original Perlin table is fixed (not random), giving identical results across all platforms:

// Ken Perlin's original permutation table
const PERM = [
  151,160,137, 91, 90, 15,131, 13,201, 95, 96,
  53,194,233,  7,225,140, 36,103, 30, 69,142,
  8, 99, 37,240, 21, 10, 23,190,  6,148,247,
  /* ... 256 values total ... */
];
// Double to avoid index wrapping
const P = [...PERM, ...PERM];

3. Gradient vectors

Each hashed lattice point is assigned a gradient vector. In 2D, Perlin uses 8 unit vectors at 45° intervals. In 3D the original implementation uses 12 vectors pointing from the center of a cube to each of its 12 edges.

2D gradients (8 directions):
( 1, 1), (-1, 1), ( 1,-1), (-1,-1)
( 1, 0), (-1, 0), ( 0, 1), ( 0,-1)

3D gradients (12 directions):
( 1, 1, 0), (-1, 1, 0), ( 1,-1, 0), (-1,-1, 0)
( 1, 0, 1), (-1, 0, 1), ( 1, 0,-1), (-1, 0,-1)
( 0, 1, 1), ( 0,-1, 1), ( 0, 1,-1), ( 0,-1,-1)

The noise value at position (x,y) is the dot product of the grid corner's gradient with the distance vector from that corner to the query point. Corners with gradients pointing toward the query point contribute positive values; corners pointing away contribute negative values.

// 2D gradient dot product
function grad2(hash, x, y) {
  const h = hash & 7;  // one of 8 gradients
  const u = h < 4 ? x : y;
  const v = h < 4 ? y : x;
  return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
}

4. Fade function and interpolation

Naive linear interpolation between the four corner dot products produces discontinuous first derivatives (visible creases at grid boundaries). Perlin uses a quintic "fade" easing curve — also called smoothstep6 — which has zero first and second derivatives at t=0 and t=1:

Linear interpolation: lerp(t, a, b) = a + t·(b − a)

Cubic fade (Hermite): f(t) = 3t² − 2t³ ← C¹ smooth
Quintic fade (Perlin2002): f(t) = 6t⁵ − 15t⁴ + 10t³ ← C² smooth

Both: f(0) = 0, f(1) = 1, f'(0) = f'(1) = 0

The original 1985 Perlin paper used the cubic fade. The 2002 improved version switched to the quintic, eliminating the subtle "line artifacts" that appear with the cubic when the noise is used for normal maps or derivative estimation.

// Quintic fade (Perlin 2002)
function fade(t) {
  return t * t * t * (t * (t * 6 - 15) + 10);
}

function lerp(t, a, b) {
  return a + t * (b - a);
}

5. Classic 2D Perlin: full implementation

Putting it all together: find the unit square that contains the point, compute the fractional parts, fade the fractional parts, hash all four corners, compute the gradient dot products, bilinearly interpolate.

function noise2D(x, y) {
  // Unit square
  const X = Math.floor(x) & 255;
  const Y = Math.floor(y) & 255;

  // Fractional part
  x -= Math.floor(x);
  y -= Math.floor(y);

  // Fade curves
  const u = fade(x);
  const v = fade(y);

  // Hash corners
  const a  = P[X]   + Y;
  const aa = P[a],  ab = P[a + 1];
  const b  = P[X+1] + Y;
  const ba = P[b],  bb = P[b + 1];

  // Bilinear blend of gradient dot products
  return lerp(v,
    lerp(u, grad2(P[aa], x,   y  ),
             grad2(P[ba], x-1, y  )),
    lerp(u, grad2(P[ab], x,   y-1),
             grad2(P[bb], x-1, y-1))
  );
}

The output range is approximately −0.707 … +0.707 in 2D (not −1…1 as commonly assumed). To normalise to 0…1: v = noise2D(x, y) * 0.707 + 0.5.

Common mistake: assuming output range is exactly −1…+1. It is not — the extreme values are only reached at exactly 45° through a gradient vector. Always measure the actual range and normalise accordingly.

6. Fractional Brownian motion (fBm)

A single octave of Perlin noise produces smooth, gently rolling hills. Real terrain has small-scale roughness on top of large-scale structure — this is modelled by fractional Brownian motion: summing multiple noise octaves, each with doubled frequency and halved amplitude.

fBm(x, y) = Σᵢ amplitudeᵢ · noise(x · frequencyᵢ, y · frequencyᵢ)

frequencyᵢ = lacunarity^i (lacunarity ≈ 2.0)
amplitudeᵢ = gain^i (gain ≈ 0.5, also called persistence)
Octave 1
50%
Low freq, high amplitude. Continent-scale hills.
Octave 2
25%
Mountain ranges and ridges.
Octave 3
12.5%
Cliffs and escarpments.
Octave 4+
6%…
Boulders, pebbles, fine texture.
function fbm(x, y, octaves = 6, lacunarity = 2.0, gain = 0.5) {
  let value = 0;
  let amplitude = 0.5;
  let frequency = 1.0;
  let max = 0;  // for normalisation

  for (let i = 0; i < octaves; i++) {
    value     += amplitude * noise2D(x * frequency, y * frequency);
    max       += amplitude;
    amplitude *= gain;
    frequency *= lacunarity;
  }

  return value / max;  // normalise to roughly −1..1
}

// Terrain example: 512×512 heightmap
for (let y = 0; y < 512; y++) {
  for (let x = 0; x < 512; x++) {
    const scale = 0.004;  // zoom out
    heightmap[y * 512 + x] =
      fbm(x * scale, y * scale, 8, 2.0, 0.5);
  }
}

Variants

// Domain warp — Inigo Quilez style
function warpedFbm(x, y) {
  // q offsets by one fBm field
  const qx = fbm(x,       y,       4);
  const qy = fbm(x + 5.2, y + 1.3, 4);

  // r offsets by q
  const rx = fbm(x + 4.0*qx + 1.7,  y + 4.0*qy + 9.2,  4);
  const ry = fbm(x + 4.0*qx + 8.3,  y + 4.0*qy + 2.8,  4);

  return fbm(x + 4.0*rx, y + 4.0*ry, 5);
}

7. GLSL implementation

On the GPU the permutation table becomes a 256-pixel 1D texture (or is baked inline). Here is a compact and widely-used GLSL hash + 2D Perlin implementation by Ian McEwan / Ashima (MIT licensed) that runs in a fragment shader without any texture lookups:

// GLSL — compact 2D Perlin (hash via arithmetic)
vec2 fade2(vec2 t) {
  return t*t*t*(t*(t*6.0-15.0)+10.0);
}

vec4 permute4(vec4 x) {
  return mod(x*x*34.0+x, 289.0);
}

float cnoise2D(vec2 P) {
  vec4 Pi = floor(P.xyxy) + vec4(0,0,1,1);
  vec4 Pf = fract(P.xyxy) - vec4(0,0,1,1);
  Pi = mod(Pi, 289.0);
  vec4 ix = Pi.xzxz, iy = Pi.yyww;
  vec4 fx = Pf.xzxz, fy = Pf.yyww;
  vec4 i  = permute4(permute4(ix) + iy);
  vec4 gx = 2.0*fract(i/41.0) - 1.0;
  vec4 gy = abs(gx) - 0.5;
  gx = gx - floor(gx + 0.5);
  vec2 g00 = vec2(gx.x, gy.x);
  vec2 g10 = vec2(gx.y, gy.y);
  vec2 g01 = vec2(gx.z, gy.z);
  vec2 g11 = vec2(gx.w, gy.w);
  vec4 norm = 1.79284291400159 - 0.85373472095314*vec4(
    dot(g00,g00), dot(g01,g01), dot(g10,g10), dot(g11,g11));
  g00 *= norm.x; g01 *= norm.y; g10 *= norm.z; g11 *= norm.w;
  float n00 = dot(g00, fx.xy);
  float n10 = dot(g10, fx.zy);
  float n01 = dot(g01, fy.xw);
  float n11 = dot(g11, fy.zw);
  vec2 fade_xy = fade2(fract(P));
  vec2 n_x = mix(vec2(n00,n01), vec2(n10,n11), fade_xy.x);
  return 2.3 * mix(n_x.x, n_x.y, fade_xy.y);
}
GLSL fBm in 4 lines: once you have cnoise2D, fBm is just a loop. In GLSL, unroll the first 4 octaves manually for performance on older mobile GPUs.
// fBm in GLSL fragment shader
float fbm(vec2 p) {
  float value = 0.0;
  float amplitude = 0.5;
  for (int i = 0; i < 6; i++) {
    value += amplitude * cnoise2D(p);
    p *= 2.0;
    amplitude *= 0.5;
  }
  return value;
}

// Usage in main():
float h = fbm(vUv * 4.0 + u_time * 0.05);
vec3 color = mix(vec3(0.0,0.1,0.4), vec3(0.9,0.95,1.0), h*0.5+0.5);

8. Simplex noise

Ken Perlin's 2001 revision replaced the square/cube grid with a simplex lattice — the simplest polytope in each dimension (a line segment in 1D, a triangle in 2D, a tetrahedron in 3D). This produces:

Property Classic Perlin Simplex
Corners per eval (2D) 4 3
Corners per eval (3D) 8 4
Grid artifacts Visible at multiples of 90° None (rotational symmetry)
Derivative continuity C² (quintic fade) C¹ (but no artifacts)
GLSL complexity Medium Slightly lower
Patent status Public domain Was patented (US6867776) — expired 2021
Practical choice: With the simplex patent expired, both are now fully free. For shaders that need to evaluate noise in 3D+ at every pixel, simplex is faster. For pre-computed heightmaps in JS, classic Perlin is simpler to implement.

9. Applications: terrain, clouds, textures

Terrain heightmap

fBm gives the basic heightmap. Add a power curve (h = h^2.5) to sharpen peaks while keeping valleys flat. Multiply by a "continentality mask" (another low-freq noise) to create islands separated by ocean:

function getHeight(x, y) {
  let h = fbm(x * 0.003, y * 0.003, 8) * 0.5 + 0.5;
  const continent =
    fbm(x*0.0008, y*0.0008, 2) * 0.5 + 0.5;
  h = h * continent;
  h = Math.pow(h, 2.2);  // sharpen mountains
  return h;
}

Animated clouds

Three-dimensional noise evaluated at (x, y, time * speed) produces slow-moving clouds. Threshold the value to get hard cloud edges or use a smooth ramp for wispy edges:

// Fragment shader — animated cloud layer
float t = u_time * 0.07;
float c = fbm(vUv * 3.0 + vec2(t, t*0.4));
float cloud = smoothstep(0.45, 0.65, c * 0.5 + 0.5);

Marble & wood textures

Classic marble: use sin(x * freq + turbulence(x,y) * scale). The turbulence distorts the sinusoidal stripes into natural-looking veins.

// Marble vein pattern
float marble(vec2 p) {
  float n = fbm(p);
  return sin(p.x * 6.0 + n * 8.0) * 0.5 + 0.5;
}

// Wood rings
float wood(vec2 p) {
  float r = length(p) * 12.0;
  r += fbm(p * 2.0) * 4.0;
  return fract(r);
}

📐 Mathematics & Fractals

The procedural terrain generator and fractal explorer are coming to the Mathematics category — using fBm and Perlin noise exactly as described here.

Math category →