Repository navigation
Implicit state handling for p5.strands addons #9259
Description
Increasing access
I've been developing on the side an SDF renderer addon for p5. It currently looks something like this, as an API:
let mySDF
function setup() {
createCanvas(windowWidth, windowHeight, WEBGL)
mySDF = buildSDF(function() {
sdfScene.begin()
let sdf = distanceFunction(sdfScene)
sdf.smoothUnion(30)
sdf.push()
sdf.translate(100 + 50 * sin(millis() * 0.001), 0, 0)
sdf.sphere(40)
sdf.pop()
sdf.sphere(60)
// TODO deal with materials etc here somehow
sdfScene.dist = sdf.get()
sdfScene.end()
})
}
function draw() {
background(0)
orbitControl()
noStroke()
lights()
fill('red')
specularMaterial(200)
shininess(300)
mySDF.draw(200)
push()
fill('green')
const angle = millis() * 0.001
const r = 150
translate(r*cos(angle), 0, r*sin(angle))
box(50)
pop()
}However, if statements and for loops do not work with it. e.g. this will not work:
for (let row = 0; row < 4; row++) {
for (let col = 0; col < cols; col++) {
sdf.push()
sdf.fill(68, 68, 74)
sdf.translate(kX0 + col * pitchX, kY, kZ0 + row * pitchZ)
sdf.roundBox(keyW, keyH, keyD, keyR)
sdf.pop()
}
}However, this would:
let minDist = 10000
for (let row = 0; row < 4; row++) {
for (let col = 0; col < cols; col++) {
minDist = min(minDist, boxSDF(...))
}
}To a lesser extent, my p5.env library also needs this: the current color is also hidden state: https://github.com/davepagurek/p5.env/blob/241ab1d5d13bc44ba8dda4563e159c841c824f50/p5.env.js#L400 But other aspects of this library do work with if/else etc because you can use if/else for methods that input to the ones linked above that use hidden state.
The reason is that strands needs to convert control flow into its internal graph representation of your program, and to do that, it needs to know what gets modified in each part of the control flow. In the minDist example, the state updates are explicit so strands knows about it but can handle it.
However, the implicit internal state updates in my initial library design more closely mirror how p5 APIs work. If we can find a way to let addon authors make strands APIs that look more like regular p5, then it can help make strands feel more familiar and more p5-y/magical.
While the main point of strands is not to be "magical", and is about creating teachable moments with enough scaffolding to keep people unblocked, it is also a way that we can let more shader functionality be exposed in a similar mental model to the rest of p5 to make artists more productive. This would be most useful for that latter use case.
Most appropriate sub-area of p5.js?
- Accessibility
- Color
- Core/Environment/Rendering
- Data
- DOM
- Events
- Image
- IO
- Math
- Typography
- Utilities
- WebGL
- Build process
- Unit testing
- Internationalization
- Friendly errors
- Other (specify if possible)
Feature enhancement details
The main approach I can think of is to let addons write a function that does explicitly return state, i.e it takes in the current state and returns a new one. In TypeScript terms:
type StateFunction<State> = (currentState: State, ...otherArgs: any[]) => State...and then we mark the function in some way that would tell the transpiler to turn this, where these different state-updating functions look like they are reading some implicit internal state and return nothing:
for (let row = 0; row < 4; row++) {
for (let col = 0; col < cols; col++) {
push()
fill(68, 68, 74)
translate(kX0 + col * pitchX, kY, kZ0 + row * pitchZ)
roundBox(keyW, keyH, keyD, keyR)
pop()
}
}...into this, where it's all explicit:
let state = initialState()
for (let row = 0; row < 4; row++) {
for (let col = 0; col < cols; col++) {
state = push(state)
state = fill(state, 68, 68, 74)
state = translate(state, kX0 + col * pitchX, kY, kZ0 + row * pitchZ)
state = roundBox(state, keyW, keyH, keyD, keyR)
state = pop(state)
}
}Now this could look something like this, where a plugin registers a bunch of methods that operate on some state, with initialState being something special that we call at the start of a hook whenever we see one of these tagged methods used. API could look like this:
function myAddon(p5, fn) {
p5.registerStrandsState(
// State initializer
function initialState() {
return [{ minDist: 1000, fill: [255, 255, 255] }]
},
// Other methods mutating the state
{
push: (state) => [...state, {...state.at(-1)}],
pop: (state) => state.slice(0, -1),
fill: (state, r, g, b) => [...state, {...state.at(-1), fill: p5.strandsNode([r, g, b])}],
// etc
},
// Other methods that just read the state but then do something else
{
getDistance: (state) => state.at(-1).minDist
}
)
}...however I imagine we maybe want push/pop to globally work across state that different addons may add, so we maybe would want that to be generalized. The plugin API could be something like this:
function myAddon(p5, fn) {
p5.registerStrandsState(
// State initializer
function initialState() {
return { fill: [255, 255, 255], minDist: 1000 }
},
'sdf', // unique key for this plugin's state
// Other methods using the state. no push/pop this time
{
fill: (state, r, g, b) => [...state, {...state.at(-1), fill: p5.strandsNode([r, g, b])}],
// etc
},
// Other methods that just read the state but then do something else
{
getDistance: (state) => state.minDist
}
)
}...and then we auto-namespace addon state in the transpiled output:
let state = {}
state.sdf = __p5.strandsStateInitializers.sdf()
for (let row = 0; row < 4; row++) {
for (let col = 0; col < cols; col++) {
state = push(state) // Push and pop are still shared
state.sdf = fill(state.sdf, 68, 68, 74) // This is scoped though
state.sdf = translate(state.sdf, kX0 + col * pitchX, kY, kZ0 + row * pitchZ)
state.sdf = roundBox(state.sdf, keyW, keyH, keyD, keyR)
state = pop(state)
}
}Registering methods would both add them to p5.prototype but also add some extra properties to those functions, like how in #8817 we're starting to add .argTypes as an optional property of a strands function to help with FES and to allow arrays to be used in function signatures without them all being converted to strands vectors. We could call it something like .stateMetadata and we can check in the transpiler if this exists, and update it if so.
Activity
bhabishnu commentedon Oct 8, 2026
In the public SDF source,
pop()restores settings but keeps the accumulated shape result.For shared push/pop, should addons mark which fields to restore, or provide save/restore functions? For example, restore
fillwhile keeping the accumulatedminDist.bhabishnu commentedon Oct 8, 2026
Also, would
registerStrandsStatesupport two independentenvLight(...)helpers in the same hook?davepagurek commentedon Oct 8, 2026
Good point @bhabishnu, I suppose the analogy in that case would be like the canvas in regular p5: push/pop doesn't affect the canvas. One idea could be to force state to always have two parts, an ephemeral part that gets saved/restored, and a permanent/cumulative part? Maybe like
type StateFunction<State extends { scoped: any; global: any }> = (currentState: State, ...otherArgs: any[]) => Stateso it always returns an object with those two parts, and you could modify either?With this API, the answer would be no, similar to how regular p5 doesn't have a way to distinguish two separate transformations being active at once. If we wanted to support it I suppose we could make a way to use it in "instance mode", similar to the initial SDF example and current p5.sdf code where you have to explicitly initialize it and then call the methods on an object? Do you have thoughts on how that might fit in?
bhabishnu commentedon Oct 8, 2026
What if registration also gave addons a factory for creating separate helpers? Existing calls could use a default instance, while each helper keeps its own state. Aliases would share that state, with
push()/pop()restoring only thescopedparts.davepagurek commentedon Oct 8, 2026
That sounds interesting, could you give a code example of what that'd look like / how addons would use it?
bhabishnu commentedon Oct 8, 2026
For
envLight, I was thinking of something like this. This is just an API sketch, with the shape helpers left out:I’d have the callbacks use the sketch as
this. Updates return the next state, while the helper’smix()returns the helper so calls can still be chained.Inside the
envColorhook:lightandskyshare state, whilehazehas its own. The idea is to register the changing colour so Strands can carry it through the loop, keepingdirandbluras fixed configuration.Addons with shared calls could use a default instance. For
envLight, I’d keep it factory-only since its initial values come from the sketch.davepagurek commentedon Oct 8, 2026
Got it, that API looks good to me. How are you thinking about differentiating between instance and global default instance uses? We could return this
create*method that you can add tofnlike in your code example but also make the methods inregisterStrandsStateglobal, but if something wanted to be instance-only like the currentenvLightAPI would possibly clash with globals or other addons (e.g. its currentmixmethod.) Updating theenvLightAPI to not clash is one possibility. Another could be that things are by default not global, and then it's up to the base shader to make a single global instance somehow if they want that?davepagurek commentedon Oct 8, 2026
Also, if you are interested in working on this (no worries if not, this applies to anyone else who wants to help too!) I'm happy to let p5.env or the more-WIP p5.sdf be test cases for this, if you want to push to those too to experiment.
bhabishnu commentedon Oct 8, 2026
I’d go with your second option. Registration could return the factory and keep the methods on each helper, so
envLight’smix()wouldn’t clash with p5’smix(). A base shader could create a shared instance where it makes sense and choose which methods to expose.Yes, I’d like to work on this! Thanks for offering them as test cases.
p5.envsounds like a good place to start.davepagurek commentedon Oct 8, 2026
We have a bit of precedent for that sort of thing, e.g. in compute shaders we've added a
hookproperty that you can specify to auto-wrap the callback in the.begin()and.end()of a hook:p5.js/src/core/p5.Renderer3D.js
Line 2725 in 93c7ddb
p5.js/src/strands/p5.strands.js
Lines 289 to 301 in 93c7ddb
...maybe we'd be adding another property there with an optional function that returns a new instance that should be made global? and then we could add a
._installGlobal()method on all instances that adds its methods tofn,window, etc?bhabishnu commentedon Oct 8, 2026
That sounds like a good fit. The optional factory could create the shared instance, with
.installGlobal()exposing its methods for the callback.envLightcould stay instance-only.I’d have the previous methods restored when the callback finishes, so the overrides don’t leak into later shader builds.
davepagurek commentedon Oct 8, 2026
There's some existing code to handle uninstalling globals, if you install them by adding the originals to
fnOverridesandwindowOverridesandgraphicsOverrides:p5.js/src/strands/p5.strands.js
Lines 325 to 334 in 93c7ddb
Not sure yet what it would take to connect those variables to
installGlobal()but I think that'd be the approach I'd take to reuse the existing functionality.bhabishnu commentedon Oct 9, 2026
Thanks, I’ll try reusing that cleanup.
One thing I wanted to check: if a hook throws an error and the sketch catches it inside
modify(), should the shader build still fail?I’d lean towards having
modify()report the same error when its callback finishes, since the shader may be only partly built. That would apply to hooks without addon helpers too.davepagurek commentedon Oct 18, 2026
Sorry for the delay on this, I think probably throwing the error makes sense to stop the build early.
bhabishnu commentedon Oct 19, 2026
@davepagurek No worries, and thanks! I’ve followed that approach in the PR.
fix(strands): restore temporary properties after shader builds
feat(strands): add registered helper state
fix(strands): fail builds when hook errors are caught
feat(strands): support a shared helper instance