p5.strands: Writing shaders in JavaScript

April 8, 2025

In the new release of p5.js 2.0, we're adding a pretty big new feature: you can now write shaders in JavaScript! It's called p5.strands. Let's talk a bit into how it works!

p5.strands, animated via p5.strands

Shader programming is an area of creative coding that can feel like a dark art to many. People share lots of stunning visuals that are created with shaders, but shaders feel like a completely different way of coding, requiring you to learn a new language, pipeline, and paradigm.

p5.strands hopes to address all of those issues!

Firstly, a common misconception is that making shader art is an all-or-nothing choice: either you make art the normal way, or you spend your time in shader land. It doesn't have to be that drastic of a shift! Actually, no matter what you do in p5's WebGL mode, you're using shaders: we have some simple shaders to position shapes on the screen, and vertices to apply the colors and lighting that you use. So shaders are also used when drawing run-of-the-mill shapes in p5.

p5.strands lets you tap into what p5's shaders are already doing. Are you drawing shapes, but want to do some custom per-pixel coloring? There's a strand you can tap into for that:

myShader = baseMaterialShader().modify(() => {
  getPixelInputs((inputs) => {
    let stripe = smoothstep(
      -0.01,
      0.01,
      sin(sin(inputs.texCoord.x * 12 * PI) * 4 + inputs.texCoord.y * 10)
    );
    inputs.color.xyz = stripe;
    return inputs;
  })
})
    

A sphere with zebra stripes

Maybe you want to use the normal lighting system but wiggle the vertices of your 3D model over time:

const myShader = baseMaterialShader().modify(() => {
  const t = uniformFloat(() => millis())
  getWorldInputs((inputs) => {
    inputs.position.x += 20 * sin(inputs.position.y * 0.05 + t * 0.004)
    return inputs
  })
})
    

A wiggly sphere

You can do that to a line shader too!

const myStrokeShader = baseStrokeShader().modify(() => {
  const t = uniformFloat(() => millis())
  getWorldInputs((inputs) => {
    inputs.position.x += 20 * sin(inputs.position.y * 0.05 + t * 0.004)
    return inputs
  })
})
    

A wiggly outline of a circle

And, of course, what you're writing looks much more similar to what you'd write for the rest of p5. It's just JavaScript!

Design goals

I've been advocating for around two years now for some way to make shaders more accessible to newcomers. When I was at Figma in 2018, Rasmus Andersson described how Figma's learning curve should ideally be like a staircase. As summarized by Dylan Field: "First step should be easy to take, and further steps should lead you to build mastery over time." Learning shaders does not give you accessible steps to mastery; it presents you with a bunch of new things to grasp all at once:

I don't think it's possible or desirable to fully remove these concepts entirely. Instead, like Rasmus described, I want to make it so that you can learn one thing at a time, as it becomes relevant. In the context of p5.js, that means starting from as close to regular p5.js JavaScript code as possible.

For me, this means:

Inspiration

Started in 2021, I've been maintaining a library to generate 3D warp shaders. It's not the same UX that I'd like from a p5 shader builder, but it has a few similar features. Notably, you write a single part of a shader (a function that takes in a point in space, and returns a modified one) via JavaScript functions, which build up a graph of math operations, and can then output GLSL source code. This was where the initial seed of my interest came from.

From there, I've been looking at lots of projects and talking to lots of other people, all of which have provided inspiration for the approach we've gone with. In the order that I encountered them, here are a few:

How it works

After I made an initial version of shader hooks that had you write little strings of GLSL, Luke Plowden took the torch and wrote p5.strands. He has done a great job completing the API! Let's take a look at what it does for you. When you write code like this:

baseMaterialShader().modify(() => {
  const t = uniformFloat(() => millis())
  getWorldInputs((inputs) => {
    inputs.position += 20 * sin(inputs.position.y * 0.05 + t * 0.004)
    return inputs
  })
})

...it first takes the contents of your modify() callback function and runs it through a JavaScript parser. This lets us target specific parts of the syntax tree and rewrite them. Here's what it looks like afterwards:

baseMaterialShader().modify(() => {
  const t = uniformFloat('t', () => millis())
  getWorldInputs((inputs) => {
    inputs.position = inputs.position.add(dynamicNode(20).mult(sin(inputs.position.y.mult(0.05).add(dynamicNode(t).mult(0.004)))))
    return inputs
  })
})

It's kept the overall structure, but it has done a few things for you:

Then we run the code! A subtle difference from normal JavaScript is that every function call and math operation no longer does any calculation immediately. Instead, it returns a new node that tracks all the operations that have happened to it. Then, at the end, it can look at the result that it has built up, and combine it into GLSL. For the above, it looks like this:

vec3 temp_0 = inputs.position + (20.0000 * sin((inputs.position.y * 0.0500) + (t * 0.0040)));
inputs.position = temp_0;
return inputs;

Here, it has done some more things for you:

From there, your GLSL code is spliced into the default p5.js shader, where it mostly does everything it was doing before (positioning, lighting, materials, etc) but now additionally does the extra bit you asked it to do. You can override as much or as little behaviour as you want!

What you can make with it

I'm most excited about how people who know p5.js now have within reach a set of textures that were previously unavailable to them. Here's a little preview of a sketch that you work towards in a tutorial Luke has written:

We've got three types of shaders going on here:

This looks quite different than most other examples in p5.js tutorials. I'm really excited to see what other things people make!