Last time I left you with a little promise, remember? We grew a whole living pattern out of one seed cell with neural cellular automata, and right at the end I said the tools that build whole worlds start with richer sources of controlled randomness than the plain noise we'd used so far. Allez, today we go collect those tools. This is the noise episode I've been itching to write for ages, because for the longest time "noise" in my head meant exactly one thing - Perlin noise, the smooth cloudy stuff we built by hand way back in episode 12. And that's a bit like thinking "music" means one instrument. There's a whole orchestra of noise, and each flavour has a completely different personality on the canvas.
So here's the plan for today. We're going to meet Worley noise (the one that makes organic cells and cracks), curl noise (the one that makes smoke and fluid flow without any physics engine), and a couple of tricks - domain warping, ridged noise, noise derivatives for lighting - that turn plain noise into something that genuinly looks carved and lit. Everything runs in vanilla JavaScript, everything is a field function you can paint, and by the end you'll have a little coloured terrain map that's basically the seed of a whole world. Let me show you what I figured out :-).
We can't do the fancy stuff without a base layer, so let me give us a compact value-noise engine to stand on. This is the same shape as our Perlin episode - a deterministic hash of integer coordinates, then smooth interpolation between the lattice points. I'm using value noise here rather than gradient noise because it's shorter to read and every technique today layers on top of it identically. First the hash:
// a fast integer hash -> pseudo-random float in [0, 1).
// same idea as our Perlin episode: deterministic, seedable, no library needed.
function hash2(x, y) {
let h = x * 374761393 + y * 668265263; // two big primes mix the coords
h = (h ^ (h >> 13)) * 1274126177;
h = h ^ (h >> 16);
return (h >>> 0) / 4294967295; // force unsigned, squash into 0..1
}
That function is the beating heart of everything below - give it the same (x, y) and it always spits the same number, but neighbouring inputs give wildly different outputs. That's exactly what we want from a hash. Now we smooth it into actual noise by blending the four corners of each grid cell:
// smooth value noise: hash the four lattice corners, blend with smoothstep.
function fade(t) { return t * t * (3 - 2 * t); } // smoothstep easing curve
function valueNoise(x, y) {
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const tl = hash2(xi, yi), tr = hash2(xi + 1, yi);
const bl = hash2(xi, yi + 1), br = hash2(xi + 1, yi + 1);
const u = fade(xf), v = fade(yf);
const top = tl + (tr - tl) * u; // lerp across the top edge
const bot = bl + (br - bl) * u; // lerp across the bottom edge
return top + (bot - top) * v; // lerp between top and bottom
}
The fade function is doing the important work there - without it you'd see the grid lines, hard and blocky. Smoothstep bends the interpolation so the transitions ease in and out, and the blockiness melts away. Makes sense, right? We covered that easing curve back in the easing-and-lerp episode, it just keeps showing up. One plain octave of this looks like soft blobs. To get detail we stack octaves, which is the fBm trick we love:
// fractal Brownian motion: stack octaves, each finer and fainter than the last.
function fbm(x, y, octaves = 5) {
let sum = 0, amp = 0.5, freq = 1, norm = 0;
for (let o = 0; o < octaves; o++) {
sum += amp * valueNoise(x * freq, y * freq);
norm += amp;
amp *= 0.5; // each octave half as loud
freq *= 2; // ... and twice as detailed
}
return sum / norm; // normalise back into 0..1
}
Right, that's our foundation. fbm gives us the classic cloud/terrain look. Now let's go somewhere Perlin can't.
Here's the first big new idea, and it's a lovely one because the mechanism is so different. Perlin noise blends values on a grid. Worley noise - also called cellular noise, invented by Steven Worley in 1996 - doesn't blend anything. Instead you scatter feature points around the plane, and for any position you ask a blunt question: "how far is the nearest feature point from me?" That distance is the noise value. Points close to a feature are dark, points far away are bright, and the result carves the plane into these gorgeous organic cells, like cracked mud or reptile skin or a stone wall.
The clever trick to make it fast is that we don't scatter points randomly across the whole plane - we put exactly one jittered point in each grid cell, and to find the nearest one we only need to check our own cell and its eight neighbours. Nine cells, nine candidate points, done.
// Worley (cellular) noise: distance to the NEAREST scattered feature point.
// one jittered point per grid cell; we scan the 3x3 block around us.
function worley(x, y) {
const xi = Math.floor(x), yi = Math.floor(y);
let best = Infinity;
for (let gy = -1; gy <= 1; gy++) {
for (let gx = -1; gx <= 1; gx++) {
const cx = xi + gx, cy = yi + gy;
// the feature point = cell corner + a jitter that lives inside the cell
const fx = cx + hash2(cx, cy);
const fy = cy + hash2(cy, cx); // swap the args = a different value
const dx = fx - x, dy = fy - y;
const d = dx * dx + dy * dy; // squared distance is cheaper
if (d < best) best = d;
}
}
return Math.sqrt(best);
}
Notice the little hash2(cx, cy) and hash2(cy, cx) pair - swapping the arguments gives me two independent-ish random numbers from one hash function, so each cell's point lands somewhere different inside it. And I keep distances squared while comparing (cheaper, no square root per candidate) and only take the real square root once at the very end. Small optimisations, but this function runs for every pixel so they add up.
Now the part that made me actually gasp the first time. If instead of the nearest point you track the two nearest, and you return the difference between them, something magic happens. That difference is near zero exactly on the boundary line halfway between two feature points - which means it lights up all the edges between cells and gives you a cracked-glass, veined, cellular-wall look. It's the same scatter of points, one tiny change, a totally different image:
// F2 - F1: distance to the SECOND nearest minus the nearest.
// this goes to zero exactly ON the boundary between cells -> crack/vein patterns.
function worleyEdges(x, y) {
const xi = Math.floor(x), yi = Math.floor(y);
let f1 = Infinity, f2 = Infinity;
for (let gy = -1; gy <= 1; gy++) {
for (let gx = -1; gx <= 1; gx++) {
const cx = xi + gx, cy = yi + gy;
const fx = cx + hash2(cx, cy);
const fy = cy + hash2(cy, cx);
const dx = fx - x, dy = fy - y;
const d = Math.sqrt(dx * dx + dy * dy);
if (d < f1) { f2 = f1; f1 = d; } // new nearest: old nearest slides to 2nd
else if (d < f2) { f2 = d; } // new second-nearest
}
}
return f2 - f1;
}
That if / else if cascade is the classic "track the top two" pattern - when a closer point shows up, the old champion gets demoted to runner-up. Watch it carefully, because getting that demotion right is the whole thing. To actually see any of these we need a way to paint a field function to the canvas, so let me write one reusable painter we'll use for the rest of the episode:
// paint any field function into the canvas as greyscale.
// fn(x, y) should return roughly 0..1; `scale` sets how zoomed in we are.
function paintField(ctx, w, h, scale, fn) {
const img = ctx.createImageData(w, h);
for (let py = 0; py < h; py++) {
for (let px = 0; px < w; px++) {
const v = Math.max(0, Math.min(1, fn(px * scale, py * scale)));
const idx = (py * w + px) * 4;
img.data[idx] = img.data[idx + 1] = img.data[idx + 2] = v * 255;
img.data[idx + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
// paintField(ctx, 400, 400, 0.02, worley); // organic cell pattern
// paintField(ctx, 400, 400, 0.02, worleyEdges); // cracked-glass veins
Run those two lines and stare. The first gives you that soft, bubbly, cellular field - brilliant for stone, cracked earth, water caustics, alien skin. The second gives you a web of thin bright veins, which is unreasonably good for cracks, leaf structure, and crackle glaze. Same nine points, one line of difference. That's the kind of thing that keeps me coding late.
Before we leave the flat-texture world, I have to show you my single favourite noise trick, because it costs almost nothing and looks like real effort. It's called domain warping, and the idea is deliciously simple: don't sample noise at (x, y) - sample it at a spot that another noise field points you toward. You're distorting the input coordinates before you look anything up. "Fetch noise, but from a place noise told you to go."
// domain warping: distort the INPUT coords with more noise before sampling.
// "look up fbm, but at a spot that fbm itself pushed you to" -> marbled swirls.
function warped(x, y) {
const qx = fbm(x, y); // offset field number one
const qy = fbm(x + 5.2, y + 1.3); // offset field two (different origin)
return fbm(x + 4 * qx, y + 4 * qy); // sample fbm at the pushed-around spot
}
Paint that with paintField(ctx, 400, 400, 0.006, warped) and the flat clouds suddenly fold into marble, into wood grain, into flowing rivery swirls. That 4 * is the warp strength - crank it up for chaos, keep it gentle for subtle. Those two offset origins (5.2, 1.3) are just there so the x and y pushes come from different parts of the noise field and don't move in lockstep. I use warping on almost everything now, it's such a cheap way to kill the "obviously computer-generated" flatness.
There's one more shaping trick to bag before the flowy stuff, and it's about what you do with the sign of the noise. Our valueNoise returns 0..1, but if we shift it to -1..1 and then take the absolute value, we fold the negatives up over the zero line - and that fold creates a sharp crease exactly where the noise crossed zero. Stack octaves of that and you get "turbulence", all sharp ridges and valleys instead of soft rolling hills:
// turbulence: absolute value of signed noise -> sharp creases at every zero crossing.
function turbulence(x, y, octaves = 5) {
let sum = 0, amp = 0.5, freq = 1, norm = 0;
for (let o = 0; o < octaves; o++) {
const n = valueNoise(x * freq, y * freq) * 2 - 1; // shift to -1..1
sum += amp * Math.abs(n); // fold negatives upward
norm += amp; amp *= 0.5; freq *= 2;
}
return sum / norm;
}
// ridged noise: flip turbulence over so the creases become bright RIDGES.
function ridged(x, y, octaves = 5) {
return 1 - turbulence(x, y, octaves);
}
Turbulence gives you smoky, wispy, flame-like fields (it's literally where a lot of fire and smoke textures come from). Ridged noise - just 1 - turbulence - flips those valleys into sharp bright mountain ridges, and it's the classic go-to for procedural mountain ranges. Same fBm loop as before, one Math.abs added, and the personality changes completely. I love how much mileage you get out of tiny changes in this whole area.
Okay, deep breath, because this is the big one and it's genuinly beautiful. Everything so far has been a static texture. Curl noise makes noise that moves - swirling, flowing, smoke-and-fluid motion - and it does it with zero physics simulation. No pressure, no Navier-Stokes, no solver. Just calculus on a noise field.
Here's the intuition. Imagine our fBm field is a landscape of hills and valleys - a scalar "potential". Normally the gradient of that field points straight uphill. But if you take that uphill arrow and rotate it ninety degrees, you get an arrow that points along the contour lines instead of across them. Do that everywhere and you've built a velocity field where everything flows around the hills like water around rocks. The gorgeous mathematical bonus: a field built this way is divergence-free. That's a fancy way of saying it never has any sources or sinks - particles riding it never bunch up into a clump or vanish into a drain, they just swirl forever. That's exactly why it looks like real smoke.
We get the gradient with finite differences (sample the field a hair to each side, subtract), then rotate:
// curl noise: take a scalar "potential" field and rotate its gradient 90 degrees.
// the result is DIVERGENCE-FREE - flow that swirls but never sources or drains.
function potential(x, y) { return fbm(x, y, 4); }
function curl(x, y) {
const e = 0.001; // a tiny step for the numerical derivative
const dpdx = (potential(x + e, y) - potential(x - e, y)) / (2 * e);
const dpdy = (potential(x, y + e) - potential(x, y - e)) / (2 * e);
return { vx: dpdy, vy: -dpdx }; // (dP/dy, -dP/dx) = gradient rotated 90 deg
}
That { vx: dpdy, vy: -dpdx } is the entire magic. The gradient is (dpdx, dpdy); rotating it a quarter turn gives (dpdy, -dpdx). One line, and you've turned a hilly texture into a flow field. Now let's put a swarm of particles on it and let them ride - this ties straight back to the particle systems we built in episode 11, we're just feeding them a smarter velocity:
// let a swarm of particles ride the curl field - instant flowing smoke.
const particles = [];
for (let i = 0; i < 2000; i++) {
particles.push({ x: Math.random() * 400, y: Math.random() * 400 });
}
function flowStep(ctx) {
ctx.fillStyle = "rgba(0, 0, 0, 0.03)"; // faint wash each frame -> trails
ctx.fillRect(0, 0, 400, 400);
ctx.fillStyle = "rgba(255, 220, 150, 0.5)";
for (const p of particles) {
const v = curl(p.x * 0.01, p.y * 0.01); // sample the flow at this spot
p.x += v.vx * 6;
p.y += v.vy * 6;
if (p.x < 0 || p.x > 400 || p.y < 0 || p.y > 400) { // recycle any strays
p.x = Math.random() * 400;
p.y = Math.random() * 400;
}
ctx.fillRect(p.x, p.y, 1.5, 1.5);
}
requestAnimationFrame(() => flowStep(ctx));
}
Open that and you'll watch two thousand little sparks organise themselves into silky, curling streams of light. The trick with the faint black rectangle each frame - painting rgba(0,0,0,0.03) over everything instead of clearing - is what leaves those lovely fading trails, same fade-buffer idea we used for the boids and the trails episodes. This is one of those effects that looks like it needs a fluid solver and actually needs about fifteen lines. When I first got this running I just sat there watching it for like ten minutes, no lie.
One last family of tricks, and it's the one that takes noise from "flat texture" to "carved, lit surface". If we treat an fBm field as a height map - tall where the value is high, low where it's low - then the slope of that surface tells us which way it's facing, and once we know which way a surface faces we can light it. The slope is just the gradient again, same finite-difference move we used for curl:
// treat fbm as a height field and read its SLOPE with finite differences.
// the gradient (how fast height changes in x and y) is all we need to light it.
function heightGradient(x, y) {
const e = 0.001;
const gx = (fbm(x + e, y) - fbm(x - e, y)) / (2 * e);
const gy = (fbm(x, y + e) - fbm(x, y - e)) / (2 * e);
return { gx, gy };
}
From that slope we build a surface normal (an arrow sticking straight out of the terrain), then dot it with a light direction to get classic Lambert shading - the exact same lighting maths we met in the raymarching materials episode, just applied to a heightfield instead of an SDF. Bright where the surface faces the light, dark where it turns away:
// shade the height field like a lit surface. normal points out of the slope,
// dotted with a light direction gives classic Lambert (diffuse) shading.
function shadeTerrain(ctx, w, h, scale) {
const img = ctx.createImageData(w, h);
const lx = -0.5, ly = -0.5, lz = 1; // light coming from the top-left
const llen = Math.hypot(lx, ly, lz);
for (let py = 0; py < h; py++) {
for (let px = 0; px < w; px++) {
const { gx, gy } = heightGradient(px * scale, py * scale);
const nx = -gx, ny = -gy, nz = 1; // surface normal from the slope
const nlen = Math.hypot(nx, ny, nz);
const diffuse = Math.max(0, (nx * lx + ny * ly + nz * lz) / (nlen * llen));
const idx = (py * w + px) * 4;
img.data[idx] = img.data[idx + 1] = img.data[idx + 2] = diffuse * 255;
img.data[idx + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
Paint that and the flat cloudy noise suddenly looks like a photographed mountain range shot from above, with real light raking across the ridges. Nothing changed about the noise itself - we just read its slope and pretended it was a surface. That reframing, "a noise value is a height, and height has a slope, and slope catches light", is one of the most useful mental moves in this whole field.
Let me tie the whole episode into one thing, because that's where it gets exciting. I'm going to combine fBm (rolling base terrain), ridged noise (sharp mountains grafted on top), and a colour ramp keyed to altitude. Low values become deep water, then shallows, sand, grass, rock, and snow at the peaks. This one function is basically a whole little planet's surface:
// combine everything: fbm base + ridged mountains, coloured by altitude.
// this is a whole tiny world in one function.
function terrainColor(x, y) {
let hgt = fbm(x, y, 6); // rolling base terrain
hgt = hgt * 0.7 + ridged(x, y, 4) * 0.3; // graft sharp ridges on top
if (hgt < 0.40) return [30, 60, 120]; // deep water
else if (hgt < 0.50) return [60, 110, 170]; // shallows
else if (hgt < 0.58) return [210, 200, 140]; // sand
else if (hgt < 0.75) return [70, 140, 70]; // grass
else if (hgt < 0.90) return [110, 100, 90]; // rock
else return [235, 235, 240]; // snow
}
// paint it with a colour-aware version of our field painter:
function paintColor(ctx, w, h, scale, fn) {
const img = ctx.createImageData(w, h);
for (let py = 0; py < h; py++) {
for (let px = 0; px < w; px++) {
const [r, g, b] = fn(px * scale, py * scale);
const idx = (py * w + px) * 4;
img.data[idx] = r; img.data[idx + 1] = g; img.data[idx + 2] = b;
img.data[idx + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
// paintColor(ctx, 400, 400, 0.004, terrainColor);
Run that last line and there it is - continents, coastlines, sandy beaches, green lowlands, grey rocky highlands, snowcapped peaks. All from mixing two flavours of noise and slapping a colour ramp on the height. You could feed the height into shadeTerrain too and get lit relief on top of the colour, and honestly that combination already looks like a map you'd find in a strategy game. We built a world out of nothing but hashed integers and some slope maths, and that's a proper "wait, I made that?" moment.
F2 - F1 and you get cracked-glass veins instead of cellsMath.abs. Turbulence gives smoke and flame; 1 - turbulence gives sharp mountain ridgesSo that's the advanced noise toolkit. The single thing I want to stick in your head: noise isn't one effect you sprinkle on, it's a language, and Worley, curl, warping, ridged, and derivatives are its vocabulary. Once you can shape randomness on purpose - crack it into cells, fold it into ridges, curl it into flow, light it like a surface - you stop decorating and start building. Get worley and worleyEdges painted side by side first, then treat yourself to the curl-noise smoke, it's the most fun fifteen lines in this whole series.
And hold onto that last little terrain function, because it's a doorway. We just made a single 400x400 square of world - but a real world doesn't stop at the edge of the screen. What happens when you want the land under your feet to connect smoothly to the land over the next hill, on and on, further than any one canvas can hold? All the noise we built today is seamless and infinite by nature - fbm(x, y) answers for any coordinate you ask, forever - and that property is exactly the key we need next, when we stop painting one fixed square and start generating worlds that run right off the edge of the screen. 't Was plezant to play the whole noise orchestra with you today - now go curl some smoke and build yourself a little planet :-).
Sallukes! Thanks for reading.
X