(iterate think thoughts): The Hidden Elegance of Gradient Noise
(iterate
think
thoughts)
Theme
June 17, 2026
The Hidden Elegance of Gradient Noise
How would you go about rendering a scene reminiscent of dark teal water, lit from somewhere below, with thousands of faint cyan filaments drifting and swirling across it? Your instinct might be to reach for a shader or to create a particle simulation, but you could render the whole thing using just a couple hundred lines of arithmetic instead. That's precisely what we're going to do in this post by rendering these filaments using the same function Ken Perlin wrote in 1985 to fake textures on a computer that couldn't draw them for real, which we know today as Perlin noise.
I'll walk you through a moving-water visualization to illustrate what Perlin noise actually is, and how a single noise value can be used to steer thousands of particles into curving currents to create a flowing surface. The snippets use the Squint ClojureScript dialect, but the ideas are language-agnostic.<br>What is Perlin noise?<br>Naively using random values is the wrong approach for creating a natural-looking texture. Pure randomness at every pixel will produce boring static that's chaotic and grainy. Real surfaces such as marble or water are smooth because neighbouring points tend to be correlated. A piece of marble that's bright here is probably still fairly bright a millimetre over there.<br>Perlin noise provides a way to generate that kind of structured pseudo-randomness. It's a deterministic function from a point in space to a scalar value, with three properties that make it magical for graphics, which are as follows.<br>Nearby inputs give nearby outputs without seams, leading to smooth transitions. The same seed always gives the same output, so the texture ends up being stable across frames. And it has no preferred direction, making it look isotropic, unlike a simple grid of blurred random dots.<br>Under the hood it's just gradient noise generated in three steps. First, we need a tile space in the form of a grid, and then we plant a pseudo-random gradient vector at every corner to provide a direction. For any point inside a cell, we need to figure out how strongly each corner's gradient points toward it using a dot product. Finally, we just blend the contributions of the surrounding corners.<br>A naive linear blend would leave ugly visible creases at every grid line. Perlin, instead, passes the interpolation parameter through a fade curve which is a polynomial shaped so that it starts and ends flat, allowing the value to ease gently into each corner:<br>(defn fade [t]<br>(* t t t (+ (* t (- (* t 6) 15)) 10)))<br>The formula above is just 6t⁵ − 15t⁴ + 10t³ with its first derivative being zero at both t = 0 and t = 1, which is precisely what guarantees the output is smooth across cell boundaries. Linear interpolation itself is likewise dead simple:<br>(defn lerp [t a b]<br>(+ a (* t (- b a))))<br>The gradient lookup hashes a corner to one of a fixed set of directions and returns the dot product against the point's offset within the cell:<br>(defn grad [hash x y z]<br>(let [h (bit-and hash 15)<br>u (if (A small seeded PRNG shuffles an identity permutation table at construction time to decide which gradient each corner gets, making the field reproducible. A caller doesn't need to worry about any of this and simply passes their desired x, y, and z to noise3 to get back a smooth value. Perlin's raw output sits roughly in [-1, 1], and the implementation remaps it to [0, 1] so that downstream consumers can scale it linearly into their own positive range:<br>(/ (+ 1 n) 2)<br>And that's the whole noise engine in a nutshell. Now that we have our noise, let's see what we can do with it to create a smooth animation.<br>From a number to a current<br>Smooth scalar values are nice, but what if we wanted to create an animation which moves in a particular direction? Well, to do that we just have to treat the noise value as an angle to give us a compass heading. Next, we multiply by a full turn (2π) so that the entire [0, 1] range maps to every possible direction:<br>(defn create-flow-field<br>[{:keys [noise noise-scale force-scale time-scale]<br>:or {noise-scale 0.003 force-scale 1 time-scale 0.15}}]<br>(let [noise3 (:noise3 noise)]<br>{:force-at<br>(fn [x y t]<br>(let [theta (* (noise3 (* x noise-scale) (* y noise-scale) (* t time-scale))<br>js/Math.PI 2)]<br>#js {:x (* (js/Math.cos theta) force-scale)<br>:y (* (js/Math.sin theta) force-scale)}))}))<br>And with that trick we get a flow field which we can ask for a velocity vector of a pixel at (x, y). Since the underlying noise is smooth, nearby pixels get nearly identical headings and the field ends up looking like a coherent map of currents, complete with eddies, calm spots, and converging streams.<br>The noise-scale knob controls the zoom factor of the flow. Scaling the coordinates down before sampling samples the noise at a coarse resolution, creating swirls that are broad and slow. On the other hand, scaling up produces nervous...