DEV Community

SEN LLC
SEN LLC

Posted on

Implementing Ken Perlin's 1983 Noise Algorithm, With Terrain and Marble Presets

Implementing Ken Perlin's 1983 Noise Algorithm, With Terrain and Marble Presets

Perlin noise is gradient noise: at each integer lattice point you define a random unit vector, then for any non-integer point you interpolate the dot products of the lattice gradients against the relative offsets. It's less than 100 lines of actual math, and it produces all the procedural textures in games, movies, and terrain generators.

Perlin noise won Ken Perlin an Academy Award. Not a regular one — a technical achievement award, for the algorithm he invented in 1983 while working on Disney's Tron. That should tell you something: this piece of math powers most of the procedural textures you've ever seen in a movie or game.

🔗 Live demo: https://sen.ltd/portfolio/perlin-noise/
📦 GitHub: https://github.com/sen-ltd/perlin-noise

Screenshot

Features:

  • Classic 2D Perlin noise implementation
  • Fractal Brownian Motion (octaves, persistence, lacunarity)
  • 4 color palettes (grayscale, terrain, heatmap, neon)
  • 5 presets: Terrain, Cloud, Marble, Wood, Fire
  • Animated mode with time offset
  • PNG export
  • Seeded PRNG for reproducible output
  • Japanese / English UI
  • Zero dependencies, 33 tests

The core algorithm

Given a point (x, y):

  1. Find the integer grid cell: (floor(x), floor(y)) and its three neighbors
  2. Look up a random gradient vector at each of the 4 corners
  3. Compute the offset from each corner to the point
  4. Dot-product each gradient with its offset
  5. Interpolate the 4 dot products using a smoothstep fade
function noise2D(x, y) {
  const X = Math.floor(x) & 255;
  const Y = Math.floor(y) & 255;
  const xf = x - Math.floor(x);
  const yf = y - Math.floor(y);
  const u = fade(xf);
  const v = fade(yf);
  const aa = perm[perm[X    ] + Y    ];
  const ab = perm[perm[X    ] + Y + 1];
  const ba = perm[perm[X + 1] + Y    ];
  const bb = perm[perm[X + 1] + Y + 1];
  return lerp(
    lerp(grad2(aa, xf,     yf    ), grad2(ba, xf - 1, yf    ), u),
    lerp(grad2(ab, xf,     yf - 1), grad2(bb, xf - 1, yf - 1), u),
    v
  );
}
Enter fullscreen mode Exit fullscreen mode

The perm[perm[X] + Y] trick generates a pseudo-random index from two coordinates using a single 256-entry permutation table. No hashing library needed.

The fade function

Perlin's 1983 paper used 3t² - 2t³ for smoothing, but in 2002 he published an "improved noise" update that uses a quintic polynomial:

export function fade(t) {
  return t * t * t * (t * (t * 6 - 15) + 10);
}
Enter fullscreen mode Exit fullscreen mode

This is 6t⁵ - 15t⁴ + 10t³. Why quintic? Because both its first and second derivatives are zero at t=0 and t=1. The classic cubic had zero first derivative but non-zero second derivative, which produced visible artifacts on smooth surfaces.

Fractal Brownian Motion

A single octave of Perlin noise is smooth but uninteresting. To get detailed, cloud-like textures, sum multiple octaves at increasing frequencies and decreasing amplitudes:

export function fbm(noiseFn, x, y, octaves, persistence, lacunarity) {
  let total = 0;
  let amplitude = 1;
  let frequency = 1;
  let maxAmplitude = 0;
  for (let i = 0; i < octaves; i++) {
    total += noiseFn(x * frequency, y * frequency) * amplitude;
    maxAmplitude += amplitude;
    amplitude *= persistence;
    frequency *= lacunarity;
  }
  return total / maxAmplitude;
}
Enter fullscreen mode Exit fullscreen mode
  • Octaves — how many layers to sum (typically 4-8)
  • Persistence — amplitude multiplier per octave (typically 0.5)
  • Lacunarity — frequency multiplier per octave (typically 2)

The maxAmplitude normalization keeps output in [-1, 1] regardless of parameter choices.

Presets via domain warp

Some of the presets use domain warp: offset the input coordinates by another noise sample before computing the main noise. This turns smooth clouds into sinuous marble veins:

const warpX = noiseFn(x * 0.5, y * 0.5) * warpStrength;
const warpY = noiseFn(x * 0.5 + 100, y * 0.5 + 100) * warpStrength;
const value = fbm(noiseFn, x + warpX, y + warpY, octaves, persistence, lacunarity);
Enter fullscreen mode Exit fullscreen mode
  • Marble: medium warp, high frequency base noise, sine function on output
  • Wood: strong warp in Y direction, concentric rings
  • Fire: warp + vertical scroll over time

Same Perlin noise function, three visually distinct outputs.

Seeding with mulberry32

For reproducible output, the permutation table is shuffled with a seeded PRNG:

function mulberry32(seed) {
  return function() {
    let t = seed += 0x6D2B79F5;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
Enter fullscreen mode Exit fullscreen mode

A 32-bit seed gives you 4 billion unique noise fields. Same seed = same result every time.

Series milestone

This is entry #50 in my 100+ public portfolio series — halfway to the goal.

Top comments (0)