diff --git a/src/core/main.js b/src/core/main.js index b0f7ad8405..0d5c4cbee7 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -540,6 +540,25 @@ function createBindGlobal(instance) { }; } +// Decorators return a new function, which would otherwise drop any +// properties attached to the original (for example the `argTypes` that +// p5.strands functions declare for the transpiler). Carry those over so +// decorating a function does not strip its metadata. +function copyFunctionProperties(original, decorated) { + if ( + typeof original !== 'function' || + typeof decorated !== 'function' || + original === decorated + ) { + return; + } + for (const key of Object.keys(original)) { + if (!(key in decorated)) { + decorated[key] = original[key]; + } + } +} + // Generic function to decorate classes function decorateClass(Target, decorations, path) { // Static properties @@ -555,6 +574,7 @@ function decorateClass(Target, decorations, path) { static: true }); if (result) { + copyFunctionProperties(Target[key], result); Object.defineProperty(Target, key, { enumerable: true, writable: true, @@ -593,6 +613,7 @@ function decorateClass(Target, decorations, path) { static: false }); if (result) { + copyFunctionProperties(Target.prototype[member], result); Object.defineProperty(Target.prototype, member, { enumerable: true, writable: true, diff --git a/src/strands/strands_api.js b/src/strands/strands_api.js index efe4639b33..a837af989e 100644 --- a/src/strands/strands_api.js +++ b/src/strands/strands_api.js @@ -770,6 +770,76 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { ); return createStrandsNode(id, dimension, strandsContext); }); + augmentFn(fn, p5, 'paletteLerp', function(stopsNode, tNode) { + if (!strandsContext.active) return; + + if (!Array.isArray(stopsNode)) { + throw new Error( + 'paletteLerp() first argument must be an array literal: [[color(...), pos], ...]' + ); + } + const n = stopsNode.length; + if (n < 2 || n > 8) { + throw new Error(`paletteLerp() requires 2–8 color stops, got ${n}.`); + } + for (let i = 0; i < n; i++) { + if (!Array.isArray(stopsNode[i]) || stopsNode[i].length !== 2) { + throw new Error( + `paletteLerp() stop ${i} must be a 2-element array: [color(...), position]` + ); + } + } + + // Wrap raw values into StrandsNodes + const colors = stopsNode.map(([c]) => p5.strandsNode(c)); + const positions = stopsNode.map(([, p]) => p5.strandsNode(p)); + const t = p5.strandsNode(tNode); + + // Helper: mix(a, b, clamp((t - pa) / (pb - pa), 0, 1)) + function segmentLerp(ca, cb, pa, pb) { + const zero = p5.strandsNode(0.0); + const one = p5.strandsNode(1.0); + const num = t.sub(pa); + const den = pb.sub(pa); + // mix and clamp are builtin functions, not methods on strands nodes + const localT = fn.clamp(num.div(den), zero, one); + return buildTernary( + strandsContext, + pa.equalTo(pb), // guard: pa == pb → return midpoint + fn.mix(ca, cb, p5.strandsNode(0.5)), + fn.mix(ca, cb, localT) + ); + } + + // Build nested ternary chain from right to left: + // t >= p[last] ? c[last] : (t < p[n-1] ? seg(n-2,n-1) : (...)) + let result = colors[n - 1]; + for (let i = n - 2; i >= 0; i--) { + const seg = segmentLerp(colors[i], colors[i + 1], positions[i], positions[i + 1]); + result = buildTernary(strandsContext, t.lessThan(positions[i + 1]), seg, result); + } + // Clamp edges + result = buildTernary(strandsContext, t.greaterEqual(positions[n - 1]), colors[n - 1], result); + result = buildTernary(strandsContext, t.lessEqual(positions[0]), colors[0], result); + + return result; + }); + // Tell the transpiler that argument 0 is a raw array (the stops) whose + // elements are themselves raw arrays (the [color, position] pairs) — + // both levels left untouched rather than converted to vectors. The + // bare 'any' terminal means pair contents aren't constrained any + // further; see strands_transpiler.js's ArrayExpression visitor for how + // this nesting is walked. + fn.paletteLerp.argTypes = [ + { + type: 'Array', // stops + subtype: { + type: 'Array', // color + position tuples + subtype: 'any' + } + }, + { type: 'Number' } + ]; augmentFn(fn, p5, 'randomSeed', function (seed) { if (!strandsContext.active) { diff --git a/src/strands/strands_builtins.js b/src/strands/strands_builtins.js index 9d71adc2f5..6138429174 100644 --- a/src/strands/strands_builtins.js +++ b/src/strands/strands_builtins.js @@ -343,4 +343,4 @@ const builtInGLSLFunctions = { export const strandsBuiltinFunctions = { ...builtInGLSLFunctions -}; +}; \ No newline at end of file diff --git a/src/strands/strands_transpiler.js b/src/strands/strands_transpiler.js index 8269af368f..62a835ea90 100644 --- a/src/strands/strands_transpiler.js +++ b/src/strands/strands_transpiler.js @@ -8,6 +8,20 @@ import { createStrandsShaderNameState, getOrCreateInternalShaderName } from './strands_names'; + +// Strands functions opt out of array-to-vector conversion by declaring +// `argTypes` on themselves (see strands_api.js's `paletteLerp` for an +// example), rather than being special-cased here. A descriptor of +// `{ type: 'Array', subtype: { type: 'any' } }` marks an argument as an +// opaque array: the ArrayExpression visitor leaves it (and anything +// nested inside it, to any depth) untouched instead of converting it to +// a vector/strandsNode call. This lets any current or future function — +// including ones added by addons — take raw array arguments without +// touching this file again. +function getArgTypes(fnName, p5) { + return p5?.prototype?.[fnName]?.argTypes; +} + let blockVarCounter = 0; let loopVarCounter = 0; function replaceBinaryOperator(codeSource) { @@ -660,8 +674,11 @@ const ASTCallbacks = { node.arguments = []; } }, + // The callbacks for AssignmentExpression and BinaryExpression handle // operator overloading including +=, *= assignment expressions + + ArrayExpression(node, state, ancestors) { if ( ancestors.some( @@ -672,6 +689,52 @@ const ASTCallbacks = { ) { return; } + // Don't wrap arrays that are (or are nested inside) an argument a + // function has declared as opaque via argTypes. We look this up + // structurally — via the nearest enclosing CallExpression and which + // of its arguments node descends from — rather than depending on + // that CallExpression's own visitor having already run, since + // acorn-walk's ancestor() calls child callbacks before parent ones. + // + // Opacity is depth-scoped, not blanket: argType descriptors nest via + // `.subtype`, one level per ArrayExpression descended through from + // the top-level argument down to this node. We walk that chain in + // lockstep with the descriptor so a function can mark the outer + // array and some number of inner levels as opaque (`type: 'Array'`) + // while still letting arrays beyond that depth resume normal + // vectorization — signaled by the descriptor bottoming out at the + // bare string `'any'` (or running out) before reaching this node's + // depth. + for (let i = ancestors.length - 1; i >= 0; i--) { + const a = ancestors[i]; + if (a.type === 'CallExpression') { + const fnName = a.callee?.type === 'Identifier' + ? a.callee.name + : a.callee?.type === 'MemberExpression' && a.callee.property?.type === 'Identifier' + ? a.callee.property.name + : undefined; + const argTypes = fnName && getArgTypes(fnName, state.p5); + if (argTypes) { + // The chain of nodes from the top-level argument down to (and + // including) this array. + // ancestors already ends with `node` itself (acorn-walk, in + // this codebase, includes the current node as its own last + // ancestor entry) — so slicing from i + 1 already gives the + // full chain from the top-level argument down to and + // including `node`, with no need to append it again. + const path = ancestors.slice(i + 1); + const argIndex = a.arguments.indexOf(path[0]); + let argType = argIndex !== -1 ? argTypes[argIndex] : undefined; + for (let depth = 1; depth < path.length && argType; depth++) { + argType = argType.subtype; + } + if (argType?.type === 'Array') { + return; + } + } + break; // only consider the nearest enclosing call + } + } if (node.elements.length < 2 || node.elements.length > 4) { FES.userError( @@ -1919,7 +1982,8 @@ function makeGuardedCallbacks(callbacks) { function runNonControlFlowPass( ast, uniformCallbackNames, - initialShaderNameState + initialShaderNameState, + p5 ) { const nonControlFlowCallbacks = { ...ASTCallbacks }; delete nonControlFlowCallbacks.IfStatement; @@ -1930,7 +1994,8 @@ function runNonControlFlowPass( shaderNameMap: createStrandsShaderNameMap(), shaderNameState: createStrandsShaderNameState( initialShaderNameState?.nextSuffix || 0 - ) + ), + p5 }; ancestor(ast, nonControlFlowCallbacks, undefined, state); return { @@ -2065,7 +2130,8 @@ export function transpileStrandsToJS( const { shaderNameMap, shaderNameState } = runNonControlFlowPass( ast, uniformCallbackNames, - initialShaderNameState + initialShaderNameState, + p5 ); // Pass 3: transform helper functions with early returns to use __returnValue pattern @@ -2080,4 +2146,4 @@ export function transpileStrandsToJS( shaderNameMap, shaderNameState }; -} +} \ No newline at end of file diff --git a/src/webgl/strands_glslBackend.js b/src/webgl/strands_glslBackend.js index 8fd3ccaa81..22ca186240 100644 --- a/src/webgl/strands_glslBackend.js +++ b/src/webgl/strands_glslBackend.js @@ -211,6 +211,7 @@ export const glslBackend = { getRandomVertexShaderSnippet() { return randomVertGLSL; }, + getTypeName(baseType, dimension) { const primitiveTypeName = TypeNames[baseType + dimension]; if (!primitiveTypeName) { diff --git a/test/unit/visual/cases/webgl.js b/test/unit/visual/cases/webgl.js index 757e597e47..cf9668ff60 100644 --- a/test/unit/visual/cases/webgl.js +++ b/test/unit/visual/cases/webgl.js @@ -1577,6 +1577,93 @@ visualSuite('WebGL', function () { screenshot(); }); + visualTest( + 'paletteLerp() maps texCoord through a multi-stop gradient in a filter', + (p5, screenshot) => { + p5.createCanvas(50, 50, p5.WEBGL); + const shader = p5.baseFilterShader().modify( + () => { + p5.filterColor.begin(); + p5.filterColor.set( + p5.paletteLerp( + [ + [[1, 0, 0, 1], 0.0], + [[1, 1, 0, 1], 0.33], + [[0, 1, 0, 1], 0.66], + [[0, 0, 1, 1], 1.0] + ], + p5.filterColor.texCoord.x + ) + ); + p5.filterColor.end(); + }, + { p5 } + ); + p5.background(220); + p5.filter(shader); + screenshot(); + } + ); + + visualTest( + 'paletteLerp() handles uneven stop positions and more than 4 stops', + (p5, screenshot) => { + p5.createCanvas(50, 50, p5.WEBGL); + const shader = p5.baseFilterShader().modify( + () => { + p5.filterColor.begin(); + // Stops bunched toward the right, with flat regions on either end + p5.filterColor.set( + p5.paletteLerp( + [ + [[0, 0, 0, 1], 0.2], + [[1, 0, 0, 1], 0.4], + [[1, 0.5, 0, 1], 0.55], + [[1, 1, 0, 1], 0.65], + [[1, 1, 1, 1], 0.8] + ], + p5.filterColor.texCoord.x + ) + ); + p5.filterColor.end(); + }, + { p5 } + ); + p5.background(220); + p5.filter(shader); + screenshot(); + } + ); + + visualTest( + 'paletteLerp() can be driven by a different input than texCoord.x', + (p5, screenshot) => { + p5.createCanvas(50, 50, p5.WEBGL); + const shader = p5.baseFilterShader().modify( + () => { + p5.filterColor.begin(); + // Diagonal gradient between two stops + const uv = p5.filterColor.texCoord; + const t = (uv.x + uv.y) / 2; + p5.filterColor.set( + p5.paletteLerp( + [ + [[0.1, 0.1, 0.5, 1], 0.0], + [[1, 0.8, 0.2, 1], 1.0] + ], + t + ) + ); + p5.filterColor.end(); + }, + { p5 } + ); + p5.background(220); + p5.filter(shader); + screenshot(); + } + ); + visualTest('random() colors a basic shader', (p5, screenshot) => { p5.createCanvas(50, 50, p5.WEBGL); const shader = p5.baseColorShader().modify( diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index 4959ade510..f9f30887f4 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -837,6 +837,63 @@ visualSuite('WebGPU', function () { }); }); + visualTest( + 'paletteLerp() maps texCoord through a multi-stop gradient in a filter (WebGPU)', + async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + const shader = p5.baseFilterShader().modify( + () => { + p5.filterColor.begin(); + p5.filterColor.set( + p5.paletteLerp( + [ + [[1, 0, 0, 1], 0.0], + [[1, 1, 0, 1], 0.33], + [[0, 1, 0, 1], 0.66], + [[0, 0, 1, 1], 1.0] + ], + p5.filterColor.texCoord.x + ) + ); + p5.filterColor.end(); + }, + { p5 } + ); + p5.background(220); + p5.filter(shader); + await screenshot(); + } + ); + + visualTest( + 'paletteLerp() handles uneven stop positions and more than 4 stops (WebGPU)', + async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + const shader = p5.baseFilterShader().modify( + () => { + p5.filterColor.begin(); + p5.filterColor.set( + p5.paletteLerp( + [ + [[0, 0, 0, 1], 0.2], + [[1, 0, 0, 1], 0.4], + [[1, 0.5, 0, 1], 0.55], + [[1, 1, 0, 1], 0.65], + [[1, 1, 1, 1], 0.8] + ], + p5.filterColor.texCoord.x + ) + ); + p5.filterColor.end(); + }, + { p5 } + ); + p5.background(220); + p5.filter(shader); + await screenshot(); + } + ); + visualTest( 'randomGaussian() colors a basic shader (WebGPU)', async function (p5, screenshot) { diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() can be driven by a different input than texCoord.x/000.png b/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() can be driven by a different input than texCoord.x/000.png new file mode 100644 index 0000000000..f6cf862c14 Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() can be driven by a different input than texCoord.x/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() can be driven by a different input than texCoord.x/metadata.json b/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() can be driven by a different input than texCoord.x/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() can be driven by a different input than texCoord.x/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() handles uneven stop positions and more than 4 stops/000.png b/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() handles uneven stop positions and more than 4 stops/000.png new file mode 100644 index 0000000000..e3b6419193 Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() handles uneven stop positions and more than 4 stops/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() handles uneven stop positions and more than 4 stops/metadata.json b/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() handles uneven stop positions and more than 4 stops/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() handles uneven stop positions and more than 4 stops/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() maps texCoord through a multi-stop gradient in a filter/000.png b/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() maps texCoord through a multi-stop gradient in a filter/000.png new file mode 100644 index 0000000000..d5714ff13a Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() maps texCoord through a multi-stop gradient in a filter/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() maps texCoord through a multi-stop gradient in a filter/metadata.json b/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() maps texCoord through a multi-stop gradient in a filter/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() maps texCoord through a multi-stop gradient in a filter/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/webgl/p5.Shader.js b/test/unit/webgl/p5.Shader.js index dce8456271..fb86e87b88 100644 --- a/test/unit/webgl/p5.Shader.js +++ b/test/unit/webgl/p5.Shader.js @@ -769,6 +769,194 @@ suite('p5.Shader', function () { assert.approximately(middle[0], 128, 10); assert.approximately(right[0], 204, 10); }); + test('paletteLerp() blends between multiple stops', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + const testShader = myp5.baseMaterialShader().modify( + () => { + myp5.getPixelInputs(inputs => { + inputs.color = myp5.paletteLerp( + [ + [[1, 0, 0, 1], 0.0], + [[0, 1, 0, 1], 0.5], + [[0, 0, 1, 1], 1.0] + ], + inputs.texCoord.x + ); + return inputs; + }); + }, + { myp5 } + ); + + myp5.noStroke(); + myp5.shader(testShader); + myp5.plane(myp5.width, myp5.height); + + const left = myp5.get(1, 25); + const middle = myp5.get(25, 25); + const right = myp5.get(48, 25); + // Near the first stop: mostly red + assert.isAbove(left[0], 200); + assert.isBelow(left[2], 40); + // At the middle stop: mostly green + assert.isAbove(middle[1], 220); + assert.isBelow(middle[0], 40); + assert.isBelow(middle[2], 40); + // Near the last stop: mostly blue + assert.isAbove(right[2], 200); + assert.isBelow(right[0], 40); + }); + + test('paletteLerp() supports more than 4 stops', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + // Array literals normally have to be 2-4 elements long since they + // become vectors, but the stops list must not be treated that way. + const testShader = myp5.baseMaterialShader().modify( + () => { + myp5.getPixelInputs(inputs => { + inputs.color = myp5.paletteLerp( + [ + [[1, 0, 0, 1], 0.0], + [[1, 1, 0, 1], 0.25], + [[0, 1, 0, 1], 0.5], + [[0, 1, 1, 1], 0.75], + [[0, 0, 1, 1], 1.0] + ], + inputs.texCoord.x + ); + return inputs; + }); + }, + { myp5 } + ); + expect(() => { + myp5.noStroke(); + myp5.shader(testShader); + myp5.plane(myp5.width, myp5.height); + }).not.toThrowError(); + }); + + test('paletteLerp() clamps t outside of the first and last stops', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + const testShader = myp5.baseMaterialShader().modify( + () => { + myp5.getPixelInputs(inputs => { + // Stops only cover the middle of the 0-1 range + inputs.color = myp5.paletteLerp( + [ + [[1, 0, 0, 1], 0.3], + [[0, 0, 1, 1], 0.7] + ], + inputs.texCoord.x + ); + return inputs; + }); + }, + { myp5 } + ); + + myp5.noStroke(); + myp5.shader(testShader); + myp5.plane(myp5.width, myp5.height); + + const left = myp5.get(1, 25); + const right = myp5.get(48, 25); + assert.approximately(left[0], 255, 10); + assert.approximately(left[2], 0, 10); + assert.approximately(right[0], 0, 10); + assert.approximately(right[2], 255, 10); + }); + + test('paletteLerp() shows an error for malformed stops', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + const makeShader = stops => + myp5.baseMaterialShader().modify( + () => { + myp5.getPixelInputs(inputs => { + inputs.color = myp5.paletteLerp(stops, inputs.texCoord.x); + return inputs; + }); + }, + { myp5, stops } + ); + // Not enough stops + expect(() => makeShader([[[1, 0, 0, 1], 0]])).toThrowError(/2.8/); + // A stop that isn't a [color, position] pair + expect(() => + makeShader([[[1, 0, 0, 1], 0], [[0, 0, 1, 1]]]) + ).toThrowError(/2-element array/); + }); + + test('argTypes keeps declared array levels as arrays', () => { + myp5.createCanvas(5, 5, myp5.WEBGL); + let received; + // A stand-in for a function that wants a list of [value, number] + // pairs. The outer list and the pairs are plain arrays, but + // anything nested any deeper should still become a vector. + p5.prototype.testArgTypesFn = function (stops) { + received = stops; + }; + p5.prototype.testArgTypesFn.argTypes = [ + { + type: 'Array', + subtype: { + type: 'Array', + subtype: 'any' + } + } + ]; + try { + myp5.baseMaterialShader().modify( + () => { + myp5.getPixelInputs(inputs => { + myp5.testArgTypesFn([ + [[1, 0, 0], 0], + [[0, 0, 1], 1] + ]); + return inputs; + }); + }, + { myp5 } + ); + } finally { + delete p5.prototype.testArgTypesFn; + } + + assert.isArray(received); + assert.lengthOf(received, 2); + for (const pair of received) { + // The pair is still a plain array... + assert.isArray(pair); + assert.lengthOf(pair, 2); + // ...but the color inside it has been turned into a vector + assert.isFalse(Array.isArray(pair[0])); + assert.isTrue(pair[0].isStrandsNode); + assert.strictEqual(pair[0].dimension, 3); + } + }); + + test('paletteLerp keeps its argTypes after p5 decorates its methods', () => { + // p5 wraps prototype methods (e.g. for parameter validation), which + // would drop the argTypes the transpiler reads if they weren't carried over + assert.isArray(p5.prototype.paletteLerp.argTypes); + }); + + test('arrays outside of argTypes arguments still become vectors', () => { + myp5.createCanvas(5, 5, myp5.WEBGL); + // More than 4 elements would be an error if this were a vector + expect(() => { + myp5.baseMaterialShader().modify( + () => { + myp5.getPixelInputs(inputs => { + const tooLong = [1, 2, 3, 4, 5]; + inputs.color = [tooLong, 0, 0, 1]; + return inputs; + }); + }, + { myp5 } + ); + }).toThrowError(/2-4 elements/); + }); test('color() with hex string returns correct vec4 in strands', () => { myp5.createCanvas(50, 50, myp5.WEBGL); const testShader = myp5.baseMaterialShader().modify(