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.
What is Perlin noise?
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.
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.
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.
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.
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:
(defn fade [t]
(* t t t (+ (* t (- (* t 6) 15)) 10)))
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:
(defn lerp [t a b]
(+ a (* t (- b a))))
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:
(defn grad [hash x y z]
(let [h (bit-and hash 15)
u (if (< h 8) x y)
v (if (< h 4) y (if (or (== h 12) (== h 14)) x z))]
(+ (if (zero? (bit-and h 1)) u (- u))
(if (zero? (bit-and h 2)) v (- v)))))
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:
(/ (+ 1 n) 2)
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.
From a number to a current
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:
(defn create-flow-field
[{:keys [noise noise-scale force-scale time-scale]
:or {noise-scale 0.003 force-scale 1 time-scale 0.15}}]
(let [noise3 (:noise3 noise)]
{:force-at
(fn [x y t]
(let [theta (* (noise3 (* x noise-scale) (* y noise-scale) (* t time-scale))
js/Math.PI 2)]
#js {:x (* (js/Math.cos theta) force-scale)
:y (* (js/Math.sin theta) force-scale)}))}))
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.
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 little vortices.
A keen reader will have noticed that the function takes a third coordinate, t, that we'll come back to later. For now, I'll leave a hint that it's going to be our secret ingredient for motion.
Drawing the curves
To actually see the current we have to drop particles into the field and let them drift. Each particle needs to keep track of its previous position as it's moved by its local current, so that we can draw a short line segment from where it was to where it landed:
(defn update-particle! [p force]
(set! (.-lifetime p) (dec (.-lifetime p)))
(if (neg? (.-lifetime p))
(respawn! p)
(do
(set! (.-prevX p) (.-x p))
(set! (.-prevY p) (.-y p))
(set! (.-x p) (+ (.-x p) (.-x force)))
(set! (.-y p) (+ (.-y p) (.-y force)))
(wrap! p (.-width p) (.-height p))))
p)
When we run that for a few thousand particles over a thousand frames in a row, they trace a curve through the field, and since the field is smooth and continuous, neighbouring particles trace neighbouring curves. The collective result has a look of flow lines following a current similar to the way dye disperses in moving water.
Each segment itself is just a stroked line, tinted by a second, finer noise pass so the colour shimmers across the surface instead of reading as just flat cyan:
(defn- draw-segment! [p noise2]
(let [v (noise2 (* (.-x p) 0.004) (* (.-y p) 0.004))
hue (+ 185 (* v 30))
light (+ 55 (* v 25))]
(set! (.-strokeStyle ctx) (str "hsla(" hue ", 80%, " light "%, 0.3)"))
(doto ctx
(.beginPath)
(.moveTo (.-prevX p) (.-prevY p))
(.lineTo (.-x p) (.-y p))
(.stroke))))
So the shape of the motion comes from the noise field while the colour comes from an independent one sampled at a different scale. Thus, we have two channels of the same primitive, doing two different jobs.
Two additions that make it move
Everything we've done so far produces a frozen flow field. Next, we'll need to make two small changes to turn it into a living animation.
1. Time is just a third dimension
Remember the unused t in force-at, which we were going to come back to? Well, what I didn't mention is that Perlin noise can be defined for any number of dimensions, and the implementation here is actually 3D. The first two dimensions are in space, but the third one is time. Each frame, we advance t a tiny bit, and because the noise is smooth in all directions, the entire current field ends up drifting as a result. Eddies migrate, streams bend, while calm patches open and close. The field smoothly evolves from one frame to another as we increment the counter:
(swap! state update :time inc)
The time-scale parameter governs how fast that evolution happens, and we want to keep it small to produce gentle change rather than a strobe. And that's how using an extra noise dimension as the clock turns a static render into an animation. In case you're wondering, you can generalize it freely, and a 3D animation can be similarly created using 4D noise.
2. Trails decay into the deep
The last step is to make sure that our trails fade over time to create continuous motion as old trails fade out, and new ones appear over time. To achieve a shimmering effect we want to avoid fully clearing the canvas. Instead, every frame paints a translucent dark rectangle over the scene before drawing the new segments:
(set! (.-fillStyle ctx) "rgba(3, 18, 26, 0.03)")
(.fillRect ctx 0 0 width height)
A value of 0.03 alpha is doing a huge amount of work creating the effect of old line segments slowly getting drowned. A particle's recent trail glows bright, one from half a second ago starts to fade, and then it's gone completely. The result is a cheap, accidental motion-blur that gives the surface its reflective, continuously flowing quality.
Tuning this alpha number shifts the whole mood, with higher values making trails vanish almost instantly, while lower ones smear into long ghostly streaks.
Tying the edges together
Another thing to consider is how to keep the surface believable at the borders. Here, we can have particles that drift off one edge reappear on the opposite side using a toroidal, seamlessly tiling wrap. When a particle wraps, its previous position needs to wrap along with it to avoid drawing ugly streaks across the canvas:
(defn- wrap-delta [v extent]
(cond (>= v extent) (- extent)
(neg? v) extent
:else 0))
In a flow field, particles spiral into a handful of attractor orbits and drain out of the rest of the canvas. To keep the whole surface populated, each particle has to have a randomized lifetime so that when it expires, it can respawn at a fresh random location with its lifetime reset. Lifetimes also need to be jittered from the start so that respawns stay staggered rather than all firing on the same frame.
The loop
Here's how the whole machine runs frame by frame:
(defn draw []
(let [{:keys [width height particles time]} @state
force-at (:force-at field)
noise2 (:noise2 noise)
n (alength particles)]
;; Fade old trails toward the deep.
(set! (.-fillStyle ctx) "rgba(3, 18, 26, 0.03)")
(.fillRect ctx 0 0 width height)
(set! (.-lineWidth ctx) 1)
(set! (.-lineCap ctx) "round")
;; For each particle: sample the current, drift, draw its segment.
(dotimes [i n]
(let [p (aget particles i)]
(update-particle! p (force-at (.-x p) (.-y p) time))
(draw-segment! p noise2)))
;; Advance the clock and schedule the next frame.
(swap! state update :time inc)
(reset! raf-id (js/requestAnimationFrame draw))))
If you read the function from top to bottom, you can see the exact steps that are happening. First, a dark background is painted on the canvas, then the noise field is sampled for each of a couple thousand particles to see which way the water flows. The particles are nudged that way, and a faint coloured line is drawn behind each. Doing that sixty times a second creates the final animation.
Why it works
Now we can see how the whole animation is put together. The noise gives us the basis for the flow field, the third dimension provides an arrow of time, and the fade creates a sense of motion. All of these ideas compose into something that feels far more complex than the sum of its parts.
The full source can be seen on Squint playground, and the version running above is served from perlin-flow.js generated from Squint.