Skip to content

The Rippled Vortex Ring: Generative Art, and the Infinite Loop That Wasn’t

Screenshot I spend most of my working life thinking about what happens inside a stirred tank. Impeller tip speed, shear on the cells, the shape of the vortex that forms when you get the baffling wrong. So when I fell down the rabbit hole of p5.js code golf last week, it was probably inevitable that...

Kemal Yaylali
Jul 31, 20269 min read

spend most of my working life thinking about what happens inside a stirred tank. Impeller tip speed, shear on the cells, the shape of the vortex that forms when you get the baffling wrong. So when I fell down the rabbit hole of p5.js code golf last week, it was probably inevitable that the first thing I built was a torus with a travelling wave running around its tube. A Rushton impeller vortex that finally learned to behave itself.

This post covers three things: the one-expression idiom that makes these sketches possible, the geometry of the ring itself, and a debugging story about an “infinite loop” error that was nothing of the sort. That last part is the useful bit, so stay for it.

The idiom

There is a whole subculture of people writing p5.js sketches that fit in a tweet. The canonical form looks roughly like this:

a=(y, /* a pile of default parameters doing all the work */) => point(x, y) t=0, draw=$=>{ t||createCanvas(w=400,w); background(9).stroke(w,96); for(t+=PI/80,i=1e4;i--;) a(i/235) }

Every frame, a tight loop calls a single arrow function tens of thousands of times with a monotonically increasing parameter. That function computes one (x, y) pair and plots one pixel. The apparent complexity, the flowers, the jellyfish, the shimmering knots comes entirely from what happens between the parameter and the point.

The trick that makes it readable-ish is JavaScript’s default parameter chaining. Default values are evaluated left to right, and each one can reference the ones before it. So the parameter list stops being a parameter list and becomes a sequence of let bindings that happens to live inside parentheses:

a = (u, A = t/4, v = u*11.3, R = 34 + 8*sin(u*3 + v/2 + t), d = 95 + R*cos(v)) => point(...)

That is a four-line function body wearing a trench coat. Terrible practice in production. Delightful here.

The geometry

My sketch is a point-sampled torus. Two angles do all the work:

  • u sweeps around the big ring (the impeller shaft axis, if you like)
  • v sweeps around the tube cross-section

The standard parametrisation gives you distance from the axis and height:

d = R_ring + r_tube·cos(v) h = r_tube·sin(v) (x, z) = (d·cos u, d·sin u)

Then three things turn that from a geometry-textbook donut into something worth looking at.

A travelling wave on the tube radius. Instead of a constant r_tube, I used R = 34 + 8·sin(3u + v/2 + t). The wave propagates in both parameters simultaneously and drifts with time, which knurls the surface into something braided. This is the single term that carries most of the visual interest, and it is also the one that maps most directly onto anything real. It is a standing wave on a toroidal surface, which is not a million miles from what you see on a free surface under periodic forcing.

Irrational winding. I set v = 11.3·u. If that coefficient were an integer, successive loops around the ring would land on the same tube phase every time and you would get discrete ribs. Making it irrational means the sampling never repeats, so the point cloud gradually fills the whole surface — a Lissajous argument, transplanted onto a torus. The + (i % 2) * 3.1 term interleaves a second pass roughly antipodal on the tube, which doubles the fill density for the cost of one modulo.

A pinhole projection. One rotation matrix about the X axis, then divide by depth:

Y = h*cos(A) - z*sin(A) Z = h*sin(A) + z*cos(A) f = 450 / (450 + Z)

f is the perspective scale factor, and I reuse it for brightness as well as position. Points nearer the camera get bigger and brighter. That is a depth cue for free, with no z-buffer and no sorting. Hue is driven by Z directly, so colour reads as depth too.

Here is the whole thing:

a=(u,A=t/4,v=u*11.3+i%2*3.1,R=34+8*sin(u*3+v/2+t),d=95+R*cos(v),h=R*sin(v),z=d*sin(u), Y=h*cos(A)-z*sin(A),Z=h*sin(A)+z*cos(A),f=450/(450+Z))=> (i%12||stroke((Z+u*9+999)%99,66,99,14+f*26),point(200+d*cos(u)*f*.9,200+Y*f*.9)) t=0,draw=$=>{t||(createCanvas(w=400,w),colorMode(HSB,99));background(0,0,6);for(t+=PI/90,i=8e3;i--;)a(i/120)}

The +999 inside the hue calculation exists purely because JavaScript’s % returns negative results for negative operands, and negative hues are not a thing. It is the least elegant character in the sketch and I have made my peace with it.

The infinite loop that wasn’t

My first version ran 20,000 points per frame and called stroke() on every single one. The p5.js web editor responded with this:

Error: Infinite loop detected at line 4. Stopping execution. Error: Multiple infinite loops detected. Stopping execution.

There is no infinite loop. There never was. The loop is for (i = 2e4; i--;) and it terminates when i hits zero, as loops tend to.

What actually happened is that the p5 web editor runs your code through loop-protect, a source transformer that injects a timer into every for, while, and do...while in your sketch. Roughly, it rewrites this:

for (i = 2e4; i--;) { ... }

into something morally equivalent to this:

const __start = Date.now(); for (i = 2e4; i--;) { if (Date.now() - __start > 1000) throw new Error("Infinite loop detected"); ... }

It is a time check, not a termination check. It cannot tell the difference between a loop that will never finish and a loop that is merely slow. Anything that holds the main thread past the budget gets the same message. The error is a wall-clock complaint dressed up as a correctness complaint, and the line number it reports points at the loop, not at the expensive thing inside the loop.

So the real question was: why was 20,000 iterations of trivial arithmetic taking over a second?

It wasn’t the arithmetic. It was stroke().

Every call to stroke() with numeric arguments constructs a fresh p5.Color object, parsing the arguments, running the colour-mode conversion, allocating. Twenty thousand of those per frame is twenty thousand short-lived objects hitting the garbage collector sixty times a second. The trigonometry is nothing by comparison; the allocation churn is everything.

This is why the classic golf sketches call stroke() once, outside the loop, and vary only position and alpha. I had broken the idiom without noticing. p5.js 2.x also reworked the 2D colour path in ways that made it slower than 1.x for this pattern, so sketches that used to squeak under the budget now don’t.

Fix A: recolour less often

Consecutive i values map to nearly identical depths, so recolouring every point is wasted work. Recolour every twelfth:

(i%12 || stroke(...), point(...))

i % 12 || stroke(...) is short-circuit evaluation as flow control: when i % 12 is truthy (eleven times out of twelve) the stroke call is skipped entirely. Combined with dropping to 8,000 points and bumping alpha to compensate for the thinner fill, that takes ~20,000 colour allocations per frame down to about 667. Comfortably inside budget, visually indistinguishable.

Fix B: skip the renderer entirely

If you want 30,000+ points at 60fps, stop asking p5 to draw and write to the pixel buffer yourself:

let t = 0, W = 400; function setup() { createCanvas(W, W); pixelDensity(1); } function draw() { loadPixels(); const px = pixels; for (let k = 0; k < px.length; k += 4) { // exponential fade = motion trails px[k] *= .84; px[k+1] *= .84; px[k+2] *= .84; } t += PI / 90; const A = t / 4, cA = cos(A), sA = sin(A); for (let i = 0; i < 3e4; i++) { const u = i / 450, v = u * 11.3 + (i & 1) * 3.1; const R = 34 + 8 * sin(u * 3 + v / 2 + t); const d = 95 + R * cos(v), h = R * sin(v), z = d * sin(u); const Y = h * cA - z * sA, Z = h * sA + z * cA, f = 450 / (450 + Z); const x = (200 + d * cos(u) * f * .9) | 0, y = (200 + Y * f * .9) | 0; if (x < 0 || y < 0 || x >= W || y >= W) continue; const k = (y * W + x) * 4; const g = f * f * 110; // brightness, depth-cued const q = constrain((Z + 140) / 280, 0, 1); // 0 far, 1 near px[k] = min(255, px[k] + g * q); // warm as it approaches px[k+1] = min(255, px[k+1] + g * .5); px[k+2] = min(255, px[k+2] + g * (1 - q * .6)); px[k+3] = 255; } updatePixels(); }

Three details worth flagging:

pixelDensity(1) is mandatory. On a retina display the default doubles the buffer dimensions, and your (y * W + x) * 4 index arithmetic will silently address the wrong pixels. No error, just a smeared mess and an afternoon of confusion.

Hoist the trig. cos(A) and sin(A) are constant across the whole frame. Computing them once outside the loop instead of 30,000 times inside is the cheapest win available.

| 0 beats floor(), and (i & 1) beats i % 2, by enough to matter at this iteration count. Bitwise truncation is a single machine instruction; floor() is a function call with its own semantics around negatives.

The additive blending is a genuine upgrade over Fix A rather than just a speed hack. Overlapping points accumulate rather than overwrite, so density becomes visible as brightness — dense regions of the torus glow, sparse ones stay dim. That is exactly the right visual language for something meant to evoke a fluid.

The .84 fade factor is the knob I’d fiddle with first. .95 gives long ghostly trails; .6 gives an almost-crisp clear each frame.

Parameters worth abusing

TermWhat it does
95 / 34ring radius vs tube radius — push the tube past the ring for a self-intersecting spindle torus
8*sin(u*3+…)ripple amplitude and frequency; u*7 gives fine corrugation
11.3tube winding rate — integers give discrete ribs, irrationals give a solid shell
t/4tumble speed; add a second axis for a proper wobble
450focal length — drop to 250 for aggressive fisheye
.84trail persistence (Fix B only)

The actual lesson

The bit I want to keep is not the torus. It is that “infinite loop detected” was a lie, and believing it would have sent me looking for a bug that didn’t exist. The tooling reported a symptom (this loop held the thread too long) using the vocabulary of a completely different cause (this loop never terminates). I have lost hours to the same category of error in process control work: an alarm that fires on a threshold and names a fault, when all it actually knows is that a number went out of range.

When a diagnostic message names a cause rather than describing an observation, treat the name as a hypothesis. Check what the tool could actually have measured. Here, it measured elapsed time, and the fix was in allocation behaviour three levels away from the line it pointed at.

Also: don’t call stroke() twenty thousand times a frame. That one is less philosophically interesting but will save you more afternoons.

Did you enjoy this article?

Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.

Across the AtmosphereDiscussions