Skip to content

Implicit state handling for p5.strands addons #9259

Closed

Description

@davepagurek

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

  1. bhabishnu commented on Oct 8, 2026

    @bhabishnu
    Contributor

    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 fill while keeping the accumulated minDist.

  2. bhabishnu commented on Oct 8, 2026

    @bhabishnu
    Contributor

    Also, would registerStrandsState support two independent envLight(...) helpers in the same hook?

  3. davepagurek commented on Oct 8, 2026

    @davepagurek
    ContributorAuthor

    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[]) => State so it always returns an object with those two parts, and you could modify either?

    Also, would registerStrandsState support two independent envLight(...) helpers in the same hook?

    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?

  4. bhabishnu commented on Oct 8, 2026

    @bhabishnu
    Contributor

    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 the scoped parts.

  5. davepagurek commented on Oct 8, 2026

    @davepagurek
    ContributorAuthor

    That sounds interesting, could you give a code example of what that'd look like / how addons would use it?

  6. bhabishnu commented on Oct 8, 2026

    @bhabishnu
    Contributor

    For envLight, I was thinking of something like this. This is just an API sketch, with the shape helpers left out:

    function env(p5, fn) {
      const createEnvLight = p5.registerStrandsState('envLight', {
        init(baseColor, dir, blur) {
          return {
            config: { dir, blur },
            state: { scoped: {}, global: { color: p5.strandsNode(baseColor) } }
          }
        },
        update: {
          mix(state, { blur }, shape, materialColor) {
            const color = this.mixEnv(shape, materialColor, state.global.color, blur)
            return { ...state, global: { ...state.global, color } }
          }
        },
        read: {
          get: state => state.global.color
        }
      })
    
      fn.envLight = createEnvLight
    }

    I’d have the callbacks use the sketch as this. Updates return the next state, while the helper’s mix() returns the helper so calls can still be chained.

    Inside the envColor hook:

    const sky = envLight(skyColor, envColor.dir, envColor.blur)
    const haze = envLight(horizonColor, envColor.dir, envColor.blur)
    const light = sky
    
    for (let i = 0; i < 3; i++) {
      const shape = envCircle(envColor.dir, vec3(cos(i), 0, sin(i)), PI * 0.03)
      light.mix(shape, sunColor)
    }
    
    envColor.set(mix(sky.get(), haze.get(), 0.25))

    light and sky share state, while haze has its own. The idea is to register the changing colour so Strands can carry it through the loop, keeping dir and blur as 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.

  7. davepagurek commented on Oct 8, 2026

    @davepagurek
    ContributorAuthor

    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 to fn like in your code example but also make the methods in registerStrandsState global, but if something wanted to be instance-only like the current envLight API would possibly clash with globals or other addons (e.g. its current mix method.) Updating the envLight API 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?

  8. davepagurek commented on Oct 8, 2026

    @davepagurek
    ContributorAuthor

    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.

  9. bhabishnu commented on Oct 8, 2026

    @bhabishnu
    Contributor

    I’d go with your second option. Registration could return the factory and keep the methods on each helper, so envLight’s mix() wouldn’t clash with p5’s mix(). A base shader could create a shared instance where it makes sense and choose which methods to expose.

    if you are interested in working on this

    Yes, I’d like to work on this! Thanks for offering them as test cases. p5.env sounds like a good place to start.

  10. davepagurek commented on Oct 8, 2026

    @davepagurek
    ContributorAuthor

    We have a bit of precedent for that sort of thing, e.g. in compute shaders we've added a hook property that you can specify to auto-wrap the callback in the .begin() and .end() of a hook:

    return this.baseComputeShader().modify(cb, context, { hook: 'iteration' });

    if (options.hook) {
    strandsContext.renderer._pInst[options.hook].begin();
    for (const key of strandsContext.renderer._pInst[options.hook]
    ._properties) {
    const hookProp = strandsContext.renderer._pInst[options.hook][key];
    fnOverrides[key] = fn[key];
    fn[key] = hookProp;
    windowOverrides[key] = window[key];
    window[key] = hookProp;
    graphicsOverrides[key] = p5.Graphics.prototype[key];
    p5.Graphics.prototype[key] = hookProp;
    }
    }

    ...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 to fn, window, etc?

  11. bhabishnu commented on Oct 8, 2026

    @bhabishnu
    Contributor

    That sounds like a good fit. The optional factory could create the shared instance, with .installGlobal() exposing its methods for the callback. envLight could stay instance-only.

    I’d have the previous methods restored when the callback finishes, so the overrides don’t leak into later shader builds.

  12. davepagurek commented on Oct 8, 2026

    @davepagurek
    ContributorAuthor

    There's some existing code to handle uninstalling globals, if you install them by adding the originals to fnOverrides and windowOverrides and graphicsOverrides:

    } finally {
    for (const key in fnOverrides) {
    fn[key] = fnOverrides[key];
    }
    for (const key in windowOverrides) {
    window[key] = windowOverrides[key];
    }
    for (const key in graphicsOverrides) {
    p5.Graphics[key] = graphicsOverrides[key];
    }

    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.

  13. bhabishnu commented on Oct 9, 2026

    @bhabishnu
    Contributor

    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.

  14. davepagurek commented on Oct 18, 2026

    @davepagurek
    ContributorAuthor

    Sorry for the delay on this, I think probably throwing the error makes sense to stop the build early.

  15. bhabishnu commented on Oct 19, 2026

    @bhabishnu
    Contributor

    @davepagurek No worries, and thanks! I’ve followed that approach in the PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions