惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Forbes - Security
Forbes - Security
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
P
Palo Alto Networks Blog
Martin Fowler
Martin Fowler
T
Threatpost
D
Docker
S
Schneier on Security
M
MIT News - Artificial intelligence
G
Google Developers Blog
L
LINUX DO - 热门话题
J
Java Code Geeks
月光博客
月光博客
博客园 - 三生石上(FineUI控件)
IT之家
IT之家
博客园 - Franky
C
Cyber Attacks, Cyber Crime and Cyber Security
K
Kaspersky official blog
Google DeepMind News
Google DeepMind News
N
News and Events Feed by Topic
V
Vulnerabilities – Threatpost
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
人人都是产品经理
人人都是产品经理
Spread Privacy
Spread Privacy
T
Tailwind CSS Blog
爱范儿
爱范儿
阮一峰的网络日志
阮一峰的网络日志
U
Unit 42
C
CERT Recently Published Vulnerability Notes
The GitHub Blog
The GitHub Blog
Simon Willison's Weblog
Simon Willison's Weblog
NISL@THU
NISL@THU
MongoDB | Blog
MongoDB | Blog
Application and Cybersecurity Blog
Application and Cybersecurity Blog
H
Heimdal Security Blog
Recorded Future
Recorded Future
云风的 BLOG
云风的 BLOG
SecWiki News
SecWiki News
P
Privacy International News Feed
P
Proofpoint News Feed
O
OpenAI News
B
Blog
腾讯CDC
F
Full Disclosure
Apple Machine Learning Research
Apple Machine Learning Research
T
Tor Project blog
H
Hacker News: Front Page
Project Zero
Project Zero
Hugging Face - Blog
Hugging Face - Blog
C
Cisco Blogs
S
Security Affairs

Lobsters

CIFSwitch: a non-universal Linux local root vulnerability RIPE NCC session fixation: poaching logins with an Atlas probe GNOME 2.20 but its Web Components Agentic Search for Context Engineering – Leonie Monigatti Garnix is shutting down [not OC] akashina.tngl.sh/jjc Concerning Emacs (and Jazz) Nitpicking the shell history scene in ‘Tron: Legacy’ What's cooking on SourceHut? Q2 2026 The tenth OpenPGP email summit Package managers that package package managers Clojure on Fennel part three: parsing WordPress at 23 Finding Miscompiles for Fun, Not Profit GitHub - creusot-rs/creusot: Creusot helps you prove your Rust code is correct. Announcing Rust 1.96.0 | Rust Blog A Love Letter to Neovim sqlite AGENTS.md Am I a Bad Friend? CSS vs. JavaScript • Josh W. Comeau Erlang Ecosystem Foundation - Supporting the BEAM community A brief note about slot access cost in Common Lisp Keyboard latency probe Rethinking the GNOME clipboard issues Back to the Building Blocks’ Building Blocks Tech Notes: Theseus: translating win32 to wasm Fast is better than slow Content-addressed Rust builds (or, what kache actually caches) Intent to Prototype: Embedding API Canada’s Bill C-22 and the security cost of collecting more data 5 PostgreSQL locking behaviors that trip people up okmij.org Stop advertising in your commits! | AksDev GitHub - mplsllc/macsurf: A modern web browser for Classic Mac OS 9 PowerPC. Real CSS3, ES5 JavaScript, native HTTPS — built with CodeWarrior on the Carbon API. Introducing DoomBench - Can Your Data Stack Run DOOM? What are some of your favourite developer tools? Building a Scalable Ingestion Pipeline with Temporal (Part 1) Converting shallow Git bundles into normal repositories Are you a member of any professional associations? What is a harmonic? An interactive comic about additive synthesis How Virtual Tables Work in the Itanium C++ ABI Using SwiftUI to Build a Mac-assed App in 2026 Rust (and Slint) on a jailbroken Kindle. ~jack/lambda-on-lambda - Serverless Haskell on AWS - sourcehut git Human proof for FOSS contributions Extremely simple internet radio controlled via IRC Announcing BABLR Splitting Konsole views from Helix to run tools | AksDev GitHub - yugr/rust-slides Serving files over HTTP three ways: synchronous, epoll, and io_uring update docs with information about building with build.py (#979) · astral-sh/python-build-standalone@c9c40c5 A Simple Makefile Tutorial On C extensions, portability, and alternative compilers Switching to Colemak | Pedro Alves Just How Bad Was The Intel IAPX432? Nix's Substituter List Is Not a Routing Table Accelerating copy_if using SIMD Lambda on Lambda: Serverless Haskell on AWS | Blog Announcing feed-repeat v1.0 Scaling Akvorado BMP RIB with sharding EYG news: A host of CLI improvements, new guides and new effects The social contract of writing JS Crossword C array types are weird; and related topics Flatpak will depend on systemd – OSnews Migrating from Go to Rust | corrode Rust Consulting A portentous reunion Vivado Licensing Options How my minimal, memory-safe Go rsync steers clear of vulnerabilities the entropy layer of a wavelet codec, on its own GitHub - nferhat/fht-compositor: A dynamic tiling Wayland compositor. Debian SE Linux and PinTheft Does bulk memmove speed up std::remove_if? (No.) 声明式部分更新 | Blog | Chrome for Developers Fully in-browser container builds Dianne Skoll's Web Site - Remind The Architecture of Open Source Applications (Volume 1)Berkeley DB Pardon MIE? - ironPeak Blog “Long-Term Support” doesn’t mean what you think Jira IS Turing-Complete May I recommend thinking of Emacs as your Fortress of Solitude hershey Floodgap Gopher-HTTP gateway gopher://thelambdalab.xyz/1cuneiforth/ HP QuickWeb, Singular And Pointless That one time I used Go panics for flow control A new suite of modern tools coming for editing and publishing RFCs From the Tabletop… The Digital Antiquarian Building a Host-Tuned GCC to Make GCC Compile Faster Are we self-sovereign PKI yet? Claw Patrol: an open-source security firewall for agents | Deno Revised^7 Report on Scheme, Large: Procedural Fascicle Draft is now public A Network Allow-List Won't Stop Exfiltration — André Graf From AFSK to Goertzel – µArt.cz Software For My New Home Server Introducing Neptune: Direct3D virtualization for QEMU AI Agent Bankrupted Their Operator While Trying to Scan DN42 - Lan Tian @ Blog mimalloc: A new, high-performance, scalable memory allocator for the modern era Making wl_shm fast The Soul of Maintaining a New Machine - Third Draft | Books in Progress What is Git made of?
The Hidden Elegance of Gradient Noise
yogthos.net · 2026-06-18 · via Lobsters

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 () 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.