Skip to content
21 changes: 21 additions & 0 deletions src/core/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
70 changes: 70 additions & 0 deletions src/strands/strands_api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src/strands/strands_builtins.js
Original file line number Diff line number Diff line change
Expand Up @@ -343,4 +343,4 @@ const builtInGLSLFunctions = {

export const strandsBuiltinFunctions = {
...builtInGLSLFunctions
};
};
74 changes: 70 additions & 4 deletions src/strands/strands_transpiler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -1919,7 +1982,8 @@ function makeGuardedCallbacks(callbacks) {
function runNonControlFlowPass(
ast,
uniformCallbackNames,
initialShaderNameState
initialShaderNameState,
p5
) {
const nonControlFlowCallbacks = { ...ASTCallbacks };
delete nonControlFlowCallbacks.IfStatement;
Expand All @@ -1930,7 +1994,8 @@ function runNonControlFlowPass(
shaderNameMap: createStrandsShaderNameMap(),
shaderNameState: createStrandsShaderNameState(
initialShaderNameState?.nextSuffix || 0
)
),
p5
};
ancestor(ast, nonControlFlowCallbacks, undefined, state);
return {
Expand Down Expand Up @@ -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
Expand All @@ -2080,4 +2146,4 @@ export function transpileStrandsToJS(
shaderNameMap,
shaderNameState
};
}
}
1 change: 1 addition & 0 deletions src/webgl/strands_glslBackend.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ export const glslBackend = {
getRandomVertexShaderSnippet() {
return randomVertGLSL;
},

getTypeName(baseType, dimension) {
const primitiveTypeName = TypeNames[baseType + dimension];
if (!primitiveTypeName) {
Expand Down
87 changes: 87 additions & 0 deletions test/unit/visual/cases/webgl.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading