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
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):
- Find the integer grid cell:
(floor(x), floor(y))and its three neighbors - Look up a random gradient vector at each of the 4 corners
- Compute the offset from each corner to the point
- Dot-product each gradient with its offset
- 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
);
}
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);
}
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;
}
- 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);
- 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;
};
}
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.
- 📦 Repo: https://github.com/sen-ltd/perlin-noise
- 🌐 Live: https://sen.ltd/portfolio/perlin-noise/
- 🏢 Company: https://sen.ltd/

Top comments (0)