Creative Coding

Getting Started With p5.strands: Writing Shaders in p5.js Without Writing GLSL

Five hook blocks let you modify vertices and pixels in JavaScript, and p5.js translates the result to GLSL behind your back. Here is the one working sketch that makes the pattern click, plus the parts still marked experimental.

There is a familiar wall in creative coding. Your sketch draws a thousand shapes in a for loop, it runs at twelve frames a second, and every answer online says “use a shader.” You open a GLSL tutorial, meet vec4, varying, gl_FragCoord and a separate language with its own build step, and the sketch stays at twelve frames a second.

p5.strands is p5.js’s answer to that wall. You write JavaScript-style code, p5 translates it to GLSL behind the scenes, and the work runs on the GPU. It arrived in p5.js 2.0 and has been stabilising through the 2.x releases; the current version at the time of writing is 2.3.4.

A warning first, because it matters for teaching material: p5.strands is still marked experimental in the reference, and its behaviour may change. Pin your p5 version in anything you intend to keep.

The mental model: hooks, not programs

This is the part that trips up people who already know GLSL, and helps people who do not.

In raw WebGL you write two complete programs — a vertex shader and a fragment shader — and you are responsible for everything in them, including the parts that just reproduce normal behaviour. In p5.strands you start from p5’s existing shader and replace specific stages of it. Those stages are called hooks, and your function fills in only the ones you care about.

For a material shader the hooks are:

  • objectInputs — runs per vertex, before any positioning is applied
  • worldInputs — runs per vertex, after transformations, in scene coordinates
  • cameraInputs — runs per vertex, after transformations, relative to the camera
  • combineColors — runs per pixel, controlling how lighting terms are combined
  • finalColor — runs per pixel, adjusting the colour after all lighting

You never write a main(). You open a hook, change the properties you want, and close it.

A first sketch: making a sphere wave

Here is the canonical example, straight from the p5.js reference. Save it as index.html with the p5 script tag, or paste it into the web editor.

let myShader;

function setup() {
  createCanvas(200, 200, WEBGL);
  myShader = buildMaterialShader(material);
}

function material() {
  let t = millis();
  worldInputs.begin();
  // Move the vertex up and down in a wave in world space.
  // The sphere is ~50 units tall here, so 20 gives a noticeable wave.
  worldInputs.position.y += 20 * sin(t * 0.001 + worldInputs.position.x * 0.05);
  worldInputs.end();
}

function draw() {
  background(255);
  shader(myShader);
  lights();
  noStroke();
  fill('red');
  sphere(50);
}

Three things to notice.

The shader is built once, in setup(). buildMaterialShader() takes your hook function and compiles it. Building it every frame in draw() would recompile a GPU program sixty times a second.

material() is not called by you. You never invoke it. It describes the shader; p5 runs its contents on the GPU, per vertex, once compiled.

worldInputs.position is one vertex, not the mesh. The function body executes for every vertex independently, and position refers to whichever one is currently being processed. This is the single biggest shift from normal p5 thinking: there is no loop, because there are hundreds of simultaneous copies.

Note also that millis() works inside a hook — support for it was added in p5.js 2.2.2 — and that familiar p5 functions like sin() are available.

Changing pixel colour instead of geometry

finalColor is the per-pixel hook. It exposes color, a four-component vector of red, green, blue and alpha, and texCoord, the two-component texture coordinate. You call .set() with a four-component value to replace the colour.

The combineColors hook goes a level deeper, exposing the individual lighting terms — baseColor, diffuse, ambientColor, ambient, specularColor, specular, emissive and opacity — so you can change how they are combined rather than just tinting the result:

function material() {
  combineColors.begin();
  combineColors.set([
    combineColors.baseColor * combineColors.diffuse +
      combineColors.ambientColor * combineColors.ambient +
      combineColors.specularColor * combineColors.specular +
      combineColors.emissive +
      [0, 0.2, 0], // green tint
    combineColors.opacity
  ]);
  combineColors.end();
}

That body is p5’s default lighting equation with one term added. Being able to see the default and edit it is the pedagogical advantage over GLSL, where the equivalent means writing the whole thing from scratch and hoping you remembered it correctly.

Filter shaders and the callback style

Full-canvas effects use a different builder and a different, flatter style — a callback that receives inputs and returns a colour:

let fadeShader;

function fadeCallback() {
  getColor((inputs) => {
    // x goes from 0 to 1 across the canvas
    let x = inputs.texCoord.x;
    // smoothstep creates a soft transition instead of a hard edge
    let t = smoothstep(0.25, 0.35, x);
    return [t, t, t, 1];
  });
}

function setup() {
  createCanvas(300, 200, WEBGL);
  fadeShader = buildFilterShader(fadeCallback);
}

function draw() {
  background(0);
  filter(fadeShader);
}

Both styles coexist: the simpler, flatter API landed in 2.2.1, and the block style with .begin()/.end() remains. smoothstep() and mix() are provided, as are shouldDiscard() for throwing pixels away and getTexture() for sampling.

The builders available are buildColorShader(), buildMaterialShader(), buildNormalShader(), buildStrokeShader() and buildFilterShader(). buildColorShader(fn) is shorthand for baseColorShader().modify(fn).

How to find out what a shader exposes

The genuinely useful discovery tool:

baseColorShader().inspectHooks();

That prints the hooks available on the base colour shader and what each one gives you, which beats guessing property names from documentation.

Two things to know before you build on this

WebGPU is coming, and your shaders should survive it. p5.js 2.2 shipped an experimental WebGPU renderer. The project’s stated intention is that WebGL shaders written in p5.strands will work unchanged under WebGPU. Switching needs async/await with createCanvas() and loading the add-on alongside the main library:

<script src="https://cdn.jsdelivr.net/npm/p5@2.3.4/lib/p5.js"></script>
<script src="https://cdn.jsdelivr.net/npm/p5@2.3.4/lib/p5.webgpu.js"></script>

Instancing is not here yet. If you have read about instances(500).sphere(20) drawing 500 spheres in one line, that API is merged but lands in p5.js 2.4 and is not available in 2.3.x. Until then, instancing goes through the older retained-geometry route — buildGeometry(), then model(), then instanceID() in the shader to distinguish copies.

Where to go next

Start by taking the wave sketch above and changing only the numbers: the amplitude from 20, the time multiplier from 0.001, the spatial frequency from 0.05. Then move the same expression from worldInputs into objectInputs and watch what changes when you add a translate() — world space is affected by it, object space is not. That one comparison teaches the coordinate-space distinction better than any diagram.

p5.strands is developed in the open, with discussion in the #p5strands and #webgpu channels on the p5.js Discord, and the API has been visibly shaped by that conversation.

Sources