Vibes DIY
Vibes DIY / Blog
From the build log

Borrowing the GPU

The tutorial said p5 "just runs in the page" and moved on. This post is for the readers who stopped there and asked how, and for the ones who hit sixty frames a second with a thousand particles and want ten thousand.

How p5 gets into a vibe

There is no build step in a vibe, so a bare import has to resolve somewhere. Write this at the top of App.jsx:

jsimport p5 from "p5";

and at deploy time it is rewritten to a versioned CDN URL, with the library's own dependency graph resolved and React marked external so you never ship a second copy of it. The page carries an import map; the browser does the rest. Your source stays readable and the resolution is the platform's problem.

The painter does something else. It names the version itself:

jsimport p5 from "https://esm.sh/[email protected]";

That is a lever, and generative art is the case where you want it. Most software can take a library upgrade as a free improvement — the tests still pass, the buttons still work. A sketch cannot, because a library update can change what your piece looks like. A different noise implementation, a changed default for pixel density, a rounding tweak deep in how a line is rasterised: any of those produce a picture that is still beautiful and is no longer the one somebody kept.

And in the second post we made that literal. A kept piece is five numbers plus an implied renderer. Pin the version the way a printmaker dates an edition — not out of caution, but because the date is part of what the thing is.

Instance mode, and why React leaves you no choice

Every p5 tutorial on the internet starts with a global function setup(). That works when the sketch owns the page. In a vibe, React owns the page and the sketch is a component that can mount, unmount, and mount again every time a slider moves. So the painter uses instance mode: p5 is handed a function that receives the instance as p, and everything it does goes through that handle.

jsReact.useEffect(() => {
  const instance = new p5(makeSketch({ params, host: node }), node);
  return () => instance.remove();
}, [key]);

Two things in those four lines are load-bearing. new p5(sketch, node) draws into that element instead of appending a canvas to the body, so the layout is React's and the pixels are p5's. And the effect returns instance.remove(), which is the part people forget. Without it, every change to the sketch's inputs mounts a fresh instance next to the old one, and the old one keeps running: two draw loops, then four, a leaked canvas per change, and a painter that gets mysteriously faster and blurrier every time you touch a slider. With it, a slider change is a clean swap, which is why the whole sketch can be a pure function of its five parameters.

What a frame costs

Start with the arithmetic, because it is worse than it looks. The painter runs 1,400 particles. Each one draws a line from where it was to where it is — and then draws it again for every arm of the symmetry. At 8-fold that is 11,200 line segments per frame, and at sixty frames a second, 672,000 lines every second. On a canvas the size of a laptop screen.

It holds up, but only because of two decisions that look like style and are not. Both are in this loop:

jsif (q.c !== cur) { cur = q.c; p.stroke(cur); }   // 1. change colour rarely
const ax = q.px - cx, ay = q.py - cy;
const bx = q.x - cx,  by = q.y - cy;
for (const [co, si] of arms) {                    // 2. rotate by hand
  p.line(cx + ax * co - ay * si, cy + ax * si + ay * co,
         cx + bx * co - by * si, cy + bx * si + by * co);
}

The first is colour batching. Particles are handed their palette colour in contiguous blocks rather than round-robin, so walking the list changes stroke() five times per frame instead of 1,400.

The second is the transform. The obvious way to rotate a line around the centre is push(), translate(), rotate(), draw, pop() — and doing that per particle per arm means 11,200 matrix pushes a frame. Instead the painter precomputes one cos/sin pair per arm, once, and rotates the four coordinates with four multiplies.

Get posts like this in your inbox

One email field. Real updates. No algorithm required.

We measured both, by running the same 1,400-particle field four ways for 130 frames each and taking the median frame time after the first ten:

what the loop does median frame
batched colour + rotate by hand 4.1 ms
batched colour + push/pop per arm 58.8 ms
stroke() per particle + rotate by hand 5.6 ms
stroke() per particle + push/pop per arm 53.8 ms

The frame budget at sixty frames a second is 16.7 ms, so the top row has room to spare and the two push/pop rows are running at about sixteen frames a second — visibly, miserably slow.

Note which column did the work. We expected the colour batching to be the headline and it is worth about a quarter of the remaining frame; the transform is worth fourteen times. That is the general shape of p5 performance: the per-item costs that hurt are the ones that touch the renderer's state, not the ones that do arithmetic. Four multiplies are free. A matrix push is not.

One caveat, since a post about measuring should be careful about what it measured: this is one headless browser in one Linux container at pixel density 1. Your laptop will be quicker and a phone slower, and the size of the gap moves with the renderer underneath it — even inside this table the transform costs 14× when the colour is batched and about 10× when it is not. What travels is the direction and its size class: the transform is the expensive one by an order of magnitude, the colour batching is worth a fraction of a frame, and nothing plausible reverses that.

And on a retina screen the ceiling stops being JavaScript at all: at pixelDensity(2) you are filling four times the pixels, and the cost moves into the part of the pipeline you cannot optimise from here. Which is the handoff to the next section, because there is somewhere else to put it.

The stress test: the same field on the GPU

Everything so far moves particles and draws lines: a few thousand things, each costing a little. A fragment shader inverts that. Instead of asking "where do my particles go", you ask, for every pixel on the canvas at once, "what is the field doing here?" — and the GPU answers all of them in parallel.

In p5 that is a WEBGL canvas, a shader compiled from two strings, and a rectangle to run it over:

jsp.createCanvas(w, h, p.WEBGL);
const field = p.createShader(VERT, FRAG);
p.shader(field);
field.setUniform("uTime", p.millis() / 1000);
field.setUniform("uScale", params.noiseScale);
p.rect(-w / 2, -h / 2, w, h);

The rect() is not really a rectangle. It is an excuse to run FRAG once per pixel it covers, and FRAG is where the field lives now. No particle list, no trails buffer, no per-item loop in JavaScript at all — the whole canvas is one draw call.

But the obvious shader is a disappointment, and it is worth knowing why before you write it. Sample the noise at this pixel, colour by the direction and strength you find there, and you get a blobby topographic map: correct, and not a flow field. What made the 2D version read as flow was the trail — a mark left behind by something that moved — and a pixel cannot move or remember.

So it walks instead. Each pixel steps forward along the field for a short distance, sampling a fine grain as it goes, and averages what it collects. Neighbouring pixels on the same current walk over nearly the same grain and come back nearly the same shade; pixels on different currents do not. The streaks reappear, drawn by the correlation rather than by history. Instead of leaving a trail behind it, every pixel walks forward and smears the grain into the streaks the particles used to draw.

What that buys is resolution. The 2D version is capped by how many lines you can afford; this one costs the same whether the pattern is coarse or so fine that every pixel differs from its neighbour, which is a picture the 2D version has no sixty-frame answer for at any particle count.

What it costs is accumulation. Each frame is computed from scratch out of its uniforms, so the slow smoky build-up of the first post — where a picture was the history of everything that moved — is gone unless you hand-roll it into a buffer. The shader gives you streaks instantly and a history never. Different instrument, not a better one.

The five-numbers idea survives intact, which is the reassuring part. A shader is a pure function of its uniforms, so a kept piece is still a recipe — it is just a recipe the GPU cooks.

p5-shader-field — open the live vibe

Lineage, told properly

p5.js did not come from the web. It descends from Processing, which Casey Reas and Ben Fry started in 2001 at the MIT Media Lab because they wanted artists and designers to have a programming environment that treated a sketch as the unit of work, not a project. The setup()/draw() pair, the idea that you should be able to see something on screen in your first minute, the word "sketch" itself — all of that is Processing's, and Lauren Lee McCarthy's p5.js carried it to the browser in 2013 with the same commitment written into its community statement: the tool exists to bring more people into making things with code, not fewer.

It is also free software in the strong sense. p5.js is licensed under the LGPL 2.1, and the way a vibe uses it respects that deal the same way the music series respects Strudel's AGPL: the library travels from its own versioned address to the visitor's browser, unmodified, with its source and license a click away, and your sketch is the file you wrote. We never bundle it into an app. Usual caveat: enthusiasts, not lawyers. What is not in question is the credit. If the painter in the first post made you want to make something, the people who made that possible are the Processing Foundation, and their tools are worth supporting directly.

Remix it

Every demo in this series is public, so all of them are one press away from being yours — the shader field included, which is the one to take if you want the GPU version as a starting point rather than a thing to build.

One honest note to end on. When you ask this platform to write you a sketch, it writes what its corpus taught it, and until the skill doc that goes with this series landed, p5 was not in that corpus — so it would have given you global-mode function setup(), the way every p5 tutorial on the internet does, which leaks a second draw loop every time React remounts the component. It knows better now: ask for a sketch and you get instance mode with its cleanup, and a pinned import. That gap was invisible from every angle we check — the platform worked, the tests passed, and the only symptom was apps written the old way. Worth saying out loud in a post about mechanics.

Bring your own renderer

If it ships on a CDN, a vibe can draw with it. Remix the shader field and find out.

Start building →

Enjoyed this? Get the next post by email

One email field. Real updates. No algorithm required.

Prefer a feed? RSS · Atom