Skip to content

Add paletteLerp for p5.strands (closes #8751) - #8817

Merged
davepagurek merged 8 commits into
processing:mainfrom
LalitNarayanYadav:feat/strands-palette-lerp
Oct 15, 2026
Merged

davepagurek merged 8 commits into
processing:mainfrom
LalitNarayanYadav:feat/strands-palette-lerp

Conversation

@LalitNarayanYadav

Copy link
Copy Markdown
Contributor

Resolves #8751

Changes

Implements paletteLerp for p5.strands, giving GPU-side feature parity with the CPU paletteLerp.

strands_transpiler.js

Adds a pre-pass that rewrites paletteLerp([[color, pos], ...], t) into parallel color/position arrays before the main ArrayExpression pass fires. This is necessary because acorn-walk visits children before parents — the outer array would be mis-typed as a vector before the paletteLerp handler could split the pairs. A _isPaletteLerpArg flag prevents the resulting arrays from being wrapped in strandsNode.

strands_api.js

Registers paletteLerp as a dual-mode runtime function. When inside a strands context, builds an IR FUNCTION_CALL node and injects the GLSL helper functions into the shader preamble once via getPaletteLerpGLSL(). CPU fallback is preserved.

strands_glslBackend.js

Adds getPaletteLerpGLSL() which returns overloaded GLSL helpers for 2–8 color stops using typed array constructors (vec4[N], float[N]). Overloads are required because GLSL ES 3.0 has no variadic functions.

strands_builtins.js

Minor cleanup.

Usage

let s = buildFilterShader(() => {
  filterColor.begin();
  filterColor.set(paletteLerp(
    [
      [color(255, 0, 0), 0.0],
      [color(0, 255, 0), 0.5],
      [color(0, 0, 255), 1.0],
    ],
    filterColor.texCoord.x
  ));
  filterColor.end();
});

function draw() {
  background(220);
  rect(-200, -200, 400, 400);
  filter(s);
}

This matches the ergonomics of the CPU paletteLerp:

// CPU equivalent
paletteLerp(
  [color(255, 0, 0), color(0, 255, 0), color(0, 0, 255)],
  [0.0, 0.5, 1.0],
  map(mouseX, 0, width, 0, 1)
);

Notes

  • Supports 2–8 color stops (covers the vast majority of gradient use cases; more stops can be chained)
  • WGSL path not included — can be a follow-up
  • All 157 existing shader tests pass with no regressions

Screenshots

No visual rendering screenshot included — browser cache issues prevented local verification. The full pipeline (transpiler → IR → GLSL emission) was verified by code review and the existing test suite passes cleanly.

PR Checklist

  • npm run lint passes
  • Inline reference is included / updated — JSDoc to be added in a follow-up
  • Unit tests are included / updated — no strands unit test infrastructure exists yet; tests to be added once the framework is in place

@perminder-17
perminder-17 self-requested a review May 21, 2026 12:24

@davepagurek davepagurek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking this on! I had a few questions at a high level around the approach. We should probably also add some visual tests in WebGL and WebGPU mode too.

Comment thread src/strands/strands_transpiler.js Outdated
`Array literals in shader functions are transpiled to vectors and must have 2-4 elements (got ${node.elements.length}).`
);
// Don't wrap paletteLerp's pre-split color/position arrays as vectors
if (node._isPaletteLerpArg) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We talked a bit in #8751 (comment) about a more generalized way of doing this that wouldn't be specific to paletteLerp, is that something we could add?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. I'll implement the parameter type registry approach we discussed. The plan is to add a strandsParamTypes map (or co-locate type descriptors with each function's definition) so the ArrayExpression visitor can look up whether a given array argument should be treated as a typed array rather than a vector, without any function-specific special-casing. I'll update the PR with that generalization.

Comment thread src/webgl/strands_glslBackend.js Outdated
if (t <= p[0]) return c[0]; if (t >= p[1]) return c[1];
return _p5s_paletteLerpSeg(c[0],c[1],p[0],p[1],t);
}
vec4 paletteLerp(vec4[3] c, float[3] p, float t) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One other thought: is it necessary that these be functions? We could potentially just generate the if statement/loop and return the final result when you call paletteLerp. Then we wouldn't need to generate a GLSL and WGSL version. (If we do stick with this format, we'd need to create a WGSL version too.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a cleaner approach, generating the interpolation inline would remove the GLSL/WGSL split entirely. For N stops, the transpiler would emit something like:

// paletteLerp with 3 stops, inlined
(t <= p0 ? c0 : t >= p2 ? c2 : t < p1 ? mix(c0, c1, clamp((t-p0)/(p1-p0), 0., 1.)) : mix(c1, c2, clamp((t-p1)/(p2-p1), 0., 1.)))

I'll switch to inline emission in strands_api.js instead of the GLSL helper functions. That also simplifies the backend significantly.

@LalitNarayanYadav
LalitNarayanYadav force-pushed the feat/strands-palette-lerp branch 2 times, most recently from d8f9772 to 80c7e89 Compare May 22, 2026 15:47
@LalitNarayanYadav
LalitNarayanYadav force-pushed the feat/strands-palette-lerp branch from 80c7e89 to 645ccd1 Compare May 22, 2026 17:00
@LalitNarayanYadav

Copy link
Copy Markdown
Contributor Author

Updated. Both comments addressed:

  1. Replaced _isPaletteLerpArg with a general ARRAY_ARG_FUNCTIONS registry at the top of strands_transpiler.js. The ArrayExpression visitor now walks ancestors to find the nearest CallExpression and checks the registry for whether that argument index should be treated as a raw array. Any future function taking array parameters can register there without touching ArrayExpression again.

  2. Switched to inline IR emission in strands_api.js using buildTernary, mix, clamp, and sub/div — no GLSL helper functions injected, so WGSL works automatically with no separate backend needed. Removed getPaletteLerpGLSL() from strands_glslBackend.js entirely.

All 157 existing shader tests pass.

@davepagurek davepagurek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we could add some visual tests that specifically use paletteLerp, e.g. mapping texCoord.x in a filter shader through a multi-stop gradient?

I also left a few comments around the transpiler -- I think the goal for that, if possible, is to make it so that paletteLerp isn't special-cased anywhere, and we have a way to opt other functions into the same behaviour where we keep arrays as arrays. That probably means not changing the shape of the parameters to paletteLerp, as that's a source of extra special-casing in the transpiler currently. I think that should be possible? but let me know if you look into it more and run into issues.

Comment thread src/strands/strands_transpiler.js Outdated

// paletteLerp pre-pass: must run before the main pass so ArrayExpression
// doesn't wrap [[color,pos],...] as a vector before we can split the pairs.
ancestor(ast, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still need this with the ARRAY_ARG_FUNCTIONS check?

Comment thread src/strands/strands_transpiler.js Outdated
// Must run before ArrayExpression so the child arrays carry _isPaletteLerpArg
// and are not wrapped in strandsNode (which would mis-type them as vectors).

CallExpression(node, state, ancestors) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to generalize this too? Ideally we don't need anything specific to one function in the transpiler, and it's all generalized enough that we could add to it for another function if needed. I see that currently this is used to split the array of pairs into two separate arrays. Is this needed if we don't do that?

@LalitNarayanYadav

Copy link
Copy Markdown
Contributor Author

Good points. Looking at it more carefully:

  1. The pre-pass can be removed - ARRAY_ARG_FUNCTIONS already prevents the outer array from being vectorized, so there's no race condition to guard against.

  2. The CallExpression handler in ASTCallbacks can also be removed if we don't reshape the arguments. Instead of splitting [[color,pos],...] into two parallel arrays, strands_api.js can receive the pairs directly and unpack them there.

  3. To protect the inner [color, pos] sub-arrays from being vectorized, the registry can support nested descriptors - e.g. paletteLerp: { 0: { type: 'array', elements: 'pass-through' } } so ArrayExpression skips any array that's a descendant of a registered argument, not just the top level.

Does that direction sound right?

@davepagurek

Copy link
Copy Markdown
Contributor

Sorry for the delay on this, just getting back to it now if you're still interested in continuing to work on it!

I think that direction sounds good. For (3), I'm also thinking about what it would take for people to develop addons for p5.strands too. Rather than storing it in a variable like that, what if we store it on the function itself? e.g.:

fn.paletteLerp = function() {
 // ...
}
fn.paletteLerp.argTypes = [
  {
    type: 'Array',
    subtype: {
      type: 'any' // In theory this could even be another array
    },
  },
  { type: 'Number' }
]

So then if we see an array anywhere, we see if it's a descendent of an argument to a function (or method call, for instance mode myp5.paletteLerp) node. If that base function (e.g. something like paletteLerp for a function or myp5.paletteLerp for a method) is called fnName, we could doconst args = eval(fnName + ' && ' + fnName + '.argTypes') and see if that exists. Then addons can add that to their functions as well. We can basically ignore the types that aren't arrays for now, but we know that strands doesn't do FES very well currently, and possibly this could be used for that too?

@LalitNarayanYadav

Copy link
Copy Markdown
Contributor Author

Sorry for the slow reply here too, picking this back up now.

That direction makes sense, happy to implement it. Plan:

  • Move argTypes onto paletteLerp itself (fn.paletteLerp.argTypes = [...]) instead of the standalone ARRAY_ARG_FUNCTIONS map.
  • In the ArrayExpression visitor, walk up to the nearest CallExpression/MemberExpression call, resolve the callee name (or obj.method for instance calls), and look up argTypes on that function reference the way you described.
  • Keep a nested subtype descriptor so the inner [color, pos] pairs are also skipped from vectorization, not just the top-level array.
  • Since this generalizes cleanly, I’ll drop the pre-pass and the old ARRAY_ARG_FUNCTIONS registry entirely in the same commit.

Will push an update once it’s working against the existing 157 shader tests.

@davepagurek

Copy link
Copy Markdown
Contributor

No problem! Thanks for your work on this, let me know how it goes!

@LalitNarayanYadav

Copy link
Copy Markdown
Contributor Author

Pushed a merge with the latest main to resolve the conflicts. Should be all set once CI runs.

@p5-bot

p5-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

@davepagurek davepagurek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking good! I left a comment on a bit of logic to confirm, and then it would be great to add some visual tests making use of this to confirm how it looks

Comment thread src/strands/strands_transpiler.js Outdated
const childOnPath = i + 1 < ancestors.length ? ancestors[i + 1] : node;
const argIndex = a.arguments.indexOf(childOnPath);
const argType = argIndex !== -1 ? argTypes[argIndex] : undefined;
if (argType?.type === 'Array' && argType.subtype?.type === 'any') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we need the subtype clause here, or would we want to skip the strandsNode() wrapping for anything that's marked as needing to be an array?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we do want to keep the subtype check rather than gating on type: 'Array' alone.

The two currently do different jobs:

  • type: 'Array' only matches the outer array argument itself.
  • subtype.type === 'any' is what makes that opacity apply recursively to arrays nested inside it.

paletteLerp needs the recursive case, since the [color(...), pos] pairs are unpacked manually at runtime rather than going through vector construction.

That said, I could imagine a future function wanting the outer array left raw (variable-length, not vector-able) while its elements still become vectors normally. For example, a list of [r,g,b] triples where each triple should still compile to a vec3.

Dropping the subtype check would make that case inexpressible, since any type: 'Array' argument would then unconditionally exempt everything nested inside it too.

I'm happy to simplify if you'd rather not carry that distinction until there's a second real use case for it, though. Let me know which way you'd like to go.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean by "opacity" here?

I guess the issue is that we are just checking for top-level arrays right now and return here. Ideally if you had like paletteLerp([[1, 0, 0], 0], [[0, 0, 1], 1]], t) then it'd know to preserve the outer array and the inner tuples but still convert the colors to vectors. I'd imagine that for each array it finds, it'd need to check what level of type it's trying to parse, and then whether that level is an Array of any type, which implies you don't wrap it as a vector.

To do that at the two levels needed we'd maybe need it to be like:

  fn.paletteLerp.argTypes = [
    {
      type: 'Array', // stops
      subtype: {
        type: 'Array', // color + position tuples
        subtype: 'any' // any further nested arrays should be wrapped as vectors
      }
    },
    { type: 'Number' }
  ];

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense! you're right that our current check only tracks whether any exemption applies, not how deep it applies. Your schema fixes that by making opacity depth-explicit, with 'any' as the terminal marker where normal vectorization resumes.

To implement it: instead of a single argType.subtype?.type === 'any' check, I'll walk the type descriptor one step per ArrayExpression level between the matched top-level argument and the current node (using the ancestors chain to get that depth), following .subtype at each { type: 'Array', subtype: ... } level. The node is only exempted from vectorization if the descriptor at its exact depth is still an Array object if the walk hits the bare 'any' string before reaching that depth, normal vectorization resumes for that node and anything inside it.

For palette1erp this ends up behaviorally identical (stops array and the [color, pos] pairs are both still opaque, nothing deeper needs special-casing), but it's the right general shape for your [[1,0,0],[0,0,1]], 1) case. I'll push an update with this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed in c661f11. Checked it against your exact case -
paletteLerp([[1,0,0], 0], [[0,0,1], 1]], t) now preserves the outer array and the pairs, while the inner [1,0,0]/[0,0,1] correctly compile to vec3. Also confirmed it doesn't regress anything else: plain arrays elsewhere still vectorize and still hit the 2-4 element check, and a function with only a single-level Array descriptor only exempts that one level (nested arrays inside it still vectorize normally).

@davepagurek

Copy link
Copy Markdown
Contributor

nice, I think the method is looking good! Lastly do you think you could add some unit and visual tests to lock in the behaviour with some examples?

@LalitNarayanYadav

Copy link
Copy Markdown
Contributor Author

nice, I think the method is looking good! Lastly do you think you could add some unit and visual tests to lock in the behaviour with some examples?

Sure, adding unit + visual tests now: a multi-stop gradient, more than 4 stops, uneven stop positions and clamping at the ends, and a unit test for the nested argTypes behaviour (outer list and pairs stay arrays, colors inside become vectors). Will push shortly.

@LalitNarayanYadav

LalitNarayanYadav commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor Author

@davepagurek I’ve pushed the tests in c7bf260:

  • Unit tests (test/unit/webgl/p5.Shader.js): multi-stop blending, more than 4 stops, clamping outside the first and last stops, errors for malformed stops, and a check that argTypes keeps the outer list and the pairs as arrays while the colors inside become vectors.
  • Visual tests (WebGL and WebGPU): a multi-stop filter gradient and uneven stop positions with more than 4 stops. WebGL also has a diagonal gradient driven by something other than texCoord.x.

Writing the tests also exposed a bug in the original implementation: paletteLerp was calling .mix() and .clamp() as methods on strands nodes, which don’t exist (they’re builtin functions), so it would have thrown the first time it ran in a shader. That’s fixed in the same commit.

I couldn’t run the browser test suite locally, so the visual tests don’t have baseline screenshots yet. Could you (or CI) generate them? I’d also be happy to adjust anything that fails.

@davepagurek

Copy link
Copy Markdown
Contributor

Thanks for adding visual tests! For them to pass, you'll need to run them locally and then commit the resulting image files to the source code. The idea is then that when other users make changes, the codebase has an image of what things used to look like, so it can compare if things changed.

@LalitNarayanYadav

Copy link
Copy Markdown
Contributor Author

Pushed the visual baselines for the WebGL paletteLerp tests, plus one more fix I found by running them. p5’s decorators (used for parameter validation) wrap every p5.prototype method in a new function, which dropped paletteLerp.argTypes before the transpiler read it, so the stops list was being turned into vectors.

decorateClass in src/core/main.js now copies the original function’s own properties onto the wrapper. Shader unit tests pass (157/157), and so do the core tests (284).

The baselines were generated on Windows with Playwright’s Chromium, so if CI shows small rendering diffs I’m happy to regenerate them. I haven’t generated baselines for the two WebGPU visual tests, since as far as I can tell CI only runs the WebGL project. Let me know if you’d like me to try those or drop them.

@davepagurek davepagurek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the decorators! I think this looks good to go now!

@davepagurek
davepagurek merged commit ed44765 into processing:main Oct 15, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

add a paletteLerp equivalent for p5.strands

2 participants