We've been using noise() since episode 4 like a magic wand. Point it at a coordinate, get a smooth random value back. Beautiful. But -- allez, what's actually happening inside that function? Why is it smooth? Why is it different from random()? And why do people keep saying "Perlin noise" like it's a religion?
Today we build the whole thing from scratch. No p5, no libraries. Just math, a permutation table, and about 50 lines of code. By the end of this episode, you'll not only understand what noise() does -- you'll have your own implementation that you can customize in ways p5 doesn't let you.
(And honestly, once you see how simple the core idea is, you'll wonder why it took you this long to look inside. I know I did. There's something deeply satisfying about opening a black box and finding it's just math and cleverness :-)
If you haven't done episodes 9 through 11 yet, go back and do them first -- we're building on the vanilla Canvas API and pixel manipulation knowledge from those. Everything from here on is pure JavaScript, no p5 safety net.
Imagine a grid. At every intersection (every integer coordinate), place a random arrow -- a gradient vector pointing in a random direction. Now, for any point between the intersections, look at the four surrounding arrows. Each arrow "votes" on what the value at your point should be. Arrows pointing toward your point vote high, arrows pointing away vote low. Blend the four votes smoothly and you get your noise value.
That's it. That's Perlin noise. Ken Perlin invented it in 1983 while working on the original Tron -- he needed natural-looking textures for the computer graphics and regular random numbers looked terrible. He later won an Academy Award for the technique. Not bad for a lookup table and some dot products.
The reason it works so well for creative coding: the output is continuous. Nearby input coordinates produce nearby output values. That's why you get smooth hills instead of TV static. And that's what makes it perfect for terrain, clouds, organic movement, and basically anything that needs to look "natural but random."
Compare this to Math.random(): if you call it for pixel (100, 50) and then for pixel (101, 50), you get two completely unrelated values. The result is pure static. Noise gives you spatial coherence -- pixels that are close together in space get similar values, with smooth transitions between regions of different intensity. That's a fundamental property of natural phenomena. Tree bark, clouds, marble, ocean waves -- they all have local similarity with gradual variation. Perlin noise captures that quality mathematically.
We need a way to get a consistent random gradient for each grid point. Perlin's clever trick: a shuffled array of numbers 0-255 used as a lookup table.
class PerlinNoise {
constructor(seed) {
this.perm = this.buildPermutation(seed);
this.gradients = [
[1, 0], [-1, 0], [0, 1], [0, -1],
[1, 1], [-1, 1], [1, -1], [-1, -1]
];
}
buildPermutation(seed) {
// create 0-255, then shuffle deterministically
let p = [];
for (let i = 0; i < 256; i++) p[i] = i;
// simple seeded shuffle (LCG random number generator)
let s = seed || 42;
for (let i = 255; i > 0; i--) {
s = (s * 16807 + 0) % 2147483647;
let j = s % (i + 1);
[p[i], p[j]] = [p[j], p[i]];
}
// duplicate the array to avoid wrapping issues
return [...p, ...p];
}
}
The permutation table maps grid coordinates to consistent pseudo-random indices. The same grid point always produces the same gradient -- that's what makes noise deterministic. Doubling the array avoids modulo operations when we index into it. Smart optimization from 1983 that still works today.
For 2D noise, we need gradient vectors at each grid corner. Perlin used eight vectors pointing along axes and diagonals:
getGradient(ix, iy) {
let hash = this.perm[this.perm[ix & 255] + (iy & 255)];
return this.gradients[hash & 7];
}
The & 255 keeps our index within the table bounds (same as modulo 256 but faster). The permutation table scrambles the coordinates into a hash, which selects one of eight gradient directions. Two lines of code, but they're doing a lot of heavy lifting.
For each grid corner, we calculate the dot product between the gradient vector and the vector from the corner to our sample point:
dot(grad, dx, dy) {
return grad[0] * dx + grad[1] * dy;
}
If the gradient points toward our sample point, the dot product is positive (high vote). If it points away, negative (low vote). If it's perpendicular, zero. This is the core mechanism -- the gradients "influence" each point based on direction and distance.
Don't worry if dot products still feel abstract. The visual intuition is: each corner arrow pulls or pushes the value at your point. That's all a dot product does here.
We need to blend the four corner votes smoothly. A linear blend would create visible grid edges -- you'd see the grid pattern in your noise, which defeats the whole purpose. Perlin's fade function fixes this:
fade(t) {
// 6t^5 - 15t^4 + 10t^3 (improved Perlin fade)
return t * t * t * (t * (t * 6 - 15) + 10);
}
lerp(a, b, t) {
return a + t * (b - a);
}
This fifth-degree polynomial has zero first AND second derivatives at 0 and 1. In human terms: the transition is completely smooth at grid boundaries. No bumps, no seams. This is what makes noise look continuous even though it's computed from a grid. (The original 1983 version used 3t^2 - 2t^3, but Perlin improved it in 2002 because the original had slight second-derivative discontinuities that showed up in lighting calculations.)
class PerlinNoise {
constructor(seed) {
this.perm = this.buildPermutation(seed);
this.gradients = [
[1, 0], [-1, 0], [0, 1], [0, -1],
[1, 1], [-1, 1], [1, -1], [-1, -1]
];
}
buildPermutation(seed) {
let p = [];
for (let i = 0; i < 256; i++) p[i] = i;
let s = seed || 42;
for (let i = 255; i > 0; i--) {
s = (s * 16807 + 0) % 2147483647;
let j = s % (i + 1);
[p[i], p[j]] = [p[j], p[i]];
}
return [...p, ...p];
}
getGradient(ix, iy) {
let hash = this.perm[this.perm[ix & 255] + (iy & 255)];
return this.gradients[hash & 7];
}
dot(grad, dx, dy) {
return grad[0] * dx + grad[1] * dy;
}
fade(t) {
return t * t * t * (t * (t * 6 - 15) + 10);
}
lerp(a, b, t) {
return a + t * (b - a);
}
noise2D(x, y) {
let ix = Math.floor(x);
let iy = Math.floor(y);
let fx = x - ix;
let fy = y - iy;
let u = this.fade(fx);
let v = this.fade(fy);
let g00 = this.getGradient(ix, iy);
let g10 = this.getGradient(ix + 1, iy);
let g01 = this.getGradient(ix, iy + 1);
let g11 = this.getGradient(ix + 1, iy + 1);
let d00 = this.dot(g00, fx, fy);
let d10 = this.dot(g10, fx - 1, fy);
let d01 = this.dot(g01, fx, fy - 1);
let d11 = this.dot(g11, fx - 1, fy - 1);
let x1 = this.lerp(d00, d10, u);
let x2 = this.lerp(d01, d11, u);
let result = this.lerp(x1, x2, v);
return (result + 1) / 2; // normalize from [-1,1] to [0,1]
}
}
That's a complete Perlin noise implementation. About 50 lines. Not scary at all once you see the pieces, right? Let's test it:
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 400;
canvas.height = 400;
const perlin = new PerlinNoise(42);
const imgData = ctx.createImageData(400, 400);
const px = imgData.data;
for (let x = 0; x < 400; x++) {
for (let y = 0; y < 400; y++) {
let n = perlin.noise2D(x * 0.02, y * 0.02);
let c = n * 255;
let i = (y * 400 + x) * 4;
px[i] = c;
px[i + 1] = c;
px[i + 2] = c;
px[i + 3] = 255;
}
}
ctx.putImageData(imgData, 0, 0);
Smooth clouds. Just like p5's noise(), but you built every piece yourself.
The 0.02 multiplier is the frequency -- it controls how "zoomed in" you are. Smaller values = smoother, larger values = more detail. Try 0.005 for big rolling hills or 0.1 for fine grain. This is the same thing as p5's noiseDetail() zoom parameter.
Single-layer Perlin noise is smooth but a bit boring. Real textures have structure at multiple scales -- big hills with small bumps on them, big clouds with wispy edges. We achieve this by layering noise at different frequencies:
function fractalNoise(perlin, x, y, octaves, persistence) {
let total = 0;
let frequency = 1;
let amplitude = 1;
let maxValue = 0;
for (let i = 0; i < octaves; i++) {
total += perlin.noise2D(x * frequency, y * frequency) * amplitude;
maxValue += amplitude;
amplitude *= persistence; // each octave is quieter
frequency *= 2; // each octave is more detailed
}
return total / maxValue; // normalize to 0-1
}
for (let x = 0; x < 400; x++) {
for (let y = 0; y < 400; y++) {
let n = fractalNoise(perlin, x * 0.01, y * 0.01, 6, 0.5);
let c = n * 255;
let i = (y * 400 + x) * 4;
px[i] = c; px[i+1] = c; px[i+2] = c; px[i+3] = 255;
}
}
This produces terrain-like textures with both large-scale features and fine detail. Every terrain generator, every cloud renderer, every procedural texture in games uses this exact technique. You now know how Minecraft generates its worlds.
Here's a fun way to think about it: octave 1 is the shape of the continent. Octave 2 adds mountain ranges. Octave 3 adds individual peaks. Octave 4 adds rocks on the peaks. Octave 5 adds texture on the rocks. Each layer adds finer detail without changing the overall shape. The persistence parameter controls how much influence each layer has -- at persistence 0.5, each layer is half as important as the one before it. At persistence 0.8, the fine detail is almost as strong as the broad shapes, giving you a rougher, more chaotic texture. Play with these values and watch what happens -- it's one of the best ways to build intuition for how noise behaves.
Here's where it gets really creative. What if you use noise to distort the coordinates you feed into noise? This is called domain warping and it produces organic, fluid, almost biological-looking patterns:
function warpedNoise(perlin, x, y) {
// first layer of noise offsets the coordinates
let offsetX = fractalNoise(perlin, x * 0.01, y * 0.01, 4, 0.5) * 4;
let offsetY = fractalNoise(perlin, x * 0.01 + 5.2, y * 0.01 + 1.3, 4, 0.5) * 4;
// second layer samples at the warped coordinates
return fractalNoise(perlin, (x + offsetX) * 0.01, (y + offsetY) * 0.01, 4, 0.5);
}
// render it
for (let x = 0; x < 400; x++) {
for (let y = 0; y < 400; y++) {
let n = warpedNoise(perlin, x, y);
let c = n * 255;
let i = (y * 400 + x) * 4;
px[i] = c; px[i+1] = c; px[i+2] = c; px[i+3] = 255;
}
}
The + 5.2 and + 1.3 offsets make sure the X and Y warping use different noise regions (otherwise they'd be correlated and the warping would look uniform). Try different offset values and multipliers -- each combination produces a different organic texture. Inigo Quilez (the Shadertoy legend) popularized this technique and it's now used everywhere in generative art.
Notice our constructor takes a seed? Change the seed, get a completely different noise field -- but the same seed always produces the same field:
let noise1 = new PerlinNoise(42); // always the same pattern
let noise2 = new PerlinNoise(123); // different but also consistent
let noise3 = new PerlinNoise(42); // identical to noise1
This is how generative NFT platforms like fxhash and ArtBlocks work. Each mint gets a unique seed (often derived from the transaction hash). The program is deterministic -- same seed, same output, every time. Your art is defined by the algorithm plus the seed. Now you know exactly how that works under the hood.
Static noise is nice, but animation is where noise really shines. The trick: add time as a third coordinate. We need to extend our 2D implementation to 3D:
// add 3D gradients to the constructor
this.gradients3D = [
[1,1,0],[-1,1,0],[1,-1,0],[-1,-1,0],
[1,0,1],[-1,0,1],[0,1,1],[0,1,-1],
[1,0,-1],[-1,0,-1],[0,-1,1],[0,-1,-1]
];
noise3D(x, y, z) {
let ix = Math.floor(x), iy = Math.floor(y), iz = Math.floor(z);
let fx = x - ix, fy = y - iy, fz = z - iz;
let u = this.fade(fx), v = this.fade(fy), w = this.fade(fz);
// eight corner dot products (cube instead of square)
let n000 = this.dot3(ix, iy, iz, fx, fy, fz);
let n100 = this.dot3(ix+1, iy, iz, fx-1, fy, fz);
let n010 = this.dot3(ix, iy+1, iz, fx, fy-1, fz);
let n110 = this.dot3(ix+1, iy+1, iz, fx-1, fy-1, fz);
let n001 = this.dot3(ix, iy, iz+1, fx, fy, fz-1);
let n101 = this.dot3(ix+1, iy, iz+1, fx-1, fy, fz-1);
let n011 = this.dot3(ix, iy+1, iz+1, fx, fy-1, fz-1);
let n111 = this.dot3(ix+1, iy+1, iz+1, fx-1, fy-1, fz-1);
// trilinear interpolation
let x1 = this.lerp(n000, n100, u);
let x2 = this.lerp(n010, n110, u);
let y1 = this.lerp(x1, x2, v);
let x3 = this.lerp(n001, n101, u);
let x4 = this.lerp(n011, n111, u);
let y2 = this.lerp(x3, x4, v);
return (this.lerp(y1, y2, w) + 1) / 2;
}
dot3(ix, iy, iz, dx, dy, dz) {
let hash = this.perm[this.perm[this.perm[ix & 255] + (iy & 255)] + (iz & 255)];
let g = this.gradients3D[hash % 12];
return g[0] * dx + g[1] * dy + g[2] * dz;
}
Now use noise3D(x, y, time * 0.01) in your animation loop and the noise field evolves smoothly. The time axis is just another spatial dimension as far as the algorithm is concerned -- it doesn't know or care that we're using it for animation. This is exactly what p5's three-argument noise(x, y, z) does internally.
The speed of the animation depends on how fast you move through the z-axis. time * 0.001 gives you slow, flowing evolution -- perfect for ambient backgrounds or meditative pieces. time * 0.05 gives you fast, boiling turbulence -- good for fire, smoke, or energetic visualizations. You can also animate the domain warp parameters over time for even more complex motion. Layer two or three of these techniques and you start getting patterns that look genuinely alive -- which is, in my opinion, the whole point of creative coding :-)
p5.js uses a slightly different implementation (based on Stefan Gustavson's simplex-inspired approach), but the core concept is identical. The main differences:
noise() always returns values between 0 and 1 (already normalized)noiseDetail(octaves, falloff) configures fractal layering -- now you know what those parameters meannoiseSeed() for this)The real advantage of building your own: you can add domain warping, use custom gradient sets, implement tiling noise (for seamless textures), or swap in entirely different noise algorithms (simplex, value noise, Worley noise) with the same interface.
Let's combine everything into a piece of actual generative art. We'll map noise values to colors using HSL, apply domain warping, and render it as a full-canvas image:
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 600;
canvas.height = 600;
const perlin = new PerlinNoise(7);
const imgData = ctx.createImageData(600, 600);
const px = imgData.data;
for (let x = 0; x < 600; x++) {
for (let y = 0; y < 600; y++) {
// domain warping for organic shapes
let wx = fractalNoise(perlin, x * 0.008, y * 0.008, 3, 0.5) * 3;
let wy = fractalNoise(perlin, x * 0.008 + 3.7, y * 0.008 + 7.1, 3, 0.5) * 3;
// main noise at warped coordinates
let n = fractalNoise(perlin, (x + wx) * 0.005, (y + wy) * 0.005, 5, 0.6);
// map noise to color
let hue = 200 + n * 160; // blue to pink range
let sat = 60 + n * 30; // moderate saturation
let light = 30 + n * 40; // dark to medium
// convert HSL to RGB (simplified)
let h = hue / 360, s = sat / 100, l = light / 100;
let r, g, b;
if (s === 0) { r = g = b = l; }
else {
let q = l < 0.5 ? l * (1 + s) : l + s - l * s;
let p = 2 * l - q;
r = hue2rgb(p, q, h + 1/3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1/3);
}
let i = (y * 600 + x) * 4;
px[i] = r * 255;
px[i+1] = g * 255;
px[i+2] = b * 255;
px[i+3] = 255;
}
}
function hue2rgb(p, q, t) {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
}
ctx.putImageData(imgData, 0, 0);
Change the seed from 7 to anything else and you get a completely different composition with the same color palette and the same organic feel. Change the hue range and you shift the entire mood. Change the warp multiplier and you go from gentle flowing shapes to wild turbulence. Every parameter is a creative choice -- and now you understand what each one actually controls.
This is the power of building noise from scratch. With p5's noise(), you get one knob (frequency). With your own implementation, you get the entire machine. You can tune the color mapping to any palette, adjust the warp intensity per-region, add asymmetry, blend multiple warp layers -- the creative possibilities are basically endless once you own the code. I've spent hours just tweaking parameters on setups like this, watching completely different artworks emerge from the same 30 lines of rendering logic. It's addictive, I won't lie.
You'll hear people mention "simplex noise" -- that's Perlin's follow-up algorithm from 2001. Instead of a square grid, it uses a simplex grid (triangles in 2D, tetrahedra in 3D). It's faster in higher dimensions and avoids some directional artifacts. The concept is the same though -- gradient vectors at grid points, dot products, smooth interpolation. If you understand what we built today, you'll have no trouble reading a simplex implementation. (We might build one later in the series if there's interest. For now, classic Perlin is more than enough to create stunning generative work.)
6t^5 - 15t^4 + 10t^3) eliminates visible grid boundariesnoise() does. No more black boxes, no more magic wands.Phase 2 is well underway and we're going deep. Next time we tackle trigonometry -- not the boring school version, but the creative coding version where sin and cos become visual tools for spirals, Lissajous curves, and phyllotaxis patterns.
Sallukes! Thanks for reading.
X