Repository navigation
Add paletteLerp for p5.strands (closes #8751) - #8817
Conversation
davepagurek
left a comment
There was a problem hiding this comment.
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.
| `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) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
| 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) { |
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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.
d8f9772 to
80c7e89
Compare
80c7e89 to
645ccd1
Compare
|
Updated. Both comments addressed:
All 157 existing shader tests pass. |
davepagurek
left a comment
There was a problem hiding this comment.
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.
|
|
||
| // 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, { |
There was a problem hiding this comment.
Do we still need this with the ARRAY_ARG_FUNCTIONS check?
| // 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) { |
There was a problem hiding this comment.
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?
|
Good points. Looking at it more carefully:
Does that direction sound right? |
|
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 |
|
Sorry for the slow reply here too, picking this back up now. That direction makes sense, happy to implement it. Plan:
Will push an update once it’s working against the existing 157 shader tests. |
|
No problem! Thanks for your work on this, let me know how it goes! |
…lerp modified: test/unit/dom/p5.Element.js
|
Pushed a merge with the latest main to resolve the conflicts. Should be all set once CI runs. |
davepagurek
left a comment
There was a problem hiding this comment.
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
| 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') { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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' }
];There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
|
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 |
|
@davepagurek I’ve pushed the tests in
Writing the tests also exposed a bug in the original implementation: 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. |
|
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. |
|
Pushed the visual baselines for the WebGL
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
left a comment
There was a problem hiding this comment.
Good catch on the decorators! I think this looks good to go now!
Resolves #8751
Changes
Implements
paletteLerpfor p5.strands, giving GPU-side feature parity with the CPUpaletteLerp.strands_transpiler.jsAdds a pre-pass that rewrites
paletteLerp([[color, pos], ...], t)into parallel color/position arrays before the mainArrayExpressionpass fires. This is necessary because acorn-walk visits children before parents — the outer array would be mis-typed as a vector before thepaletteLerphandler could split the pairs. A_isPaletteLerpArgflag prevents the resulting arrays from being wrapped instrandsNode.strands_api.jsRegisters
paletteLerpas a dual-mode runtime function. When inside a strands context, builds an IRFUNCTION_CALLnode and injects the GLSL helper functions into the shader preamble once viagetPaletteLerpGLSL(). CPU fallback is preserved.strands_glslBackend.jsAdds
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.jsMinor cleanup.
Usage
This matches the ergonomics of the CPU
paletteLerp:Notes
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 lintpasses