Skip to content

Commit ed44765

Browse files
authored
Merge pull request #8817 from LalitNarayanYadav/feat/strands-palette-lerp
Add paletteLerp for p5.strands (closes #8751)
2 parents 87d947b + 463a795 commit ed44765

14 files changed

Lines changed: 504 additions & 5 deletions

File tree

‎src/core/main.js‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,25 @@ function createBindGlobal(instance) {
540540
};
541541
}
542542

543+
// Decorators return a new function, which would otherwise drop any
544+
// properties attached to the original (for example the `argTypes` that
545+
// p5.strands functions declare for the transpiler). Carry those over so
546+
// decorating a function does not strip its metadata.
547+
function copyFunctionProperties(original, decorated) {
548+
if (
549+
typeof original !== 'function' ||
550+
typeof decorated !== 'function' ||
551+
original === decorated
552+
) {
553+
return;
554+
}
555+
for (const key of Object.keys(original)) {
556+
if (!(key in decorated)) {
557+
decorated[key] = original[key];
558+
}
559+
}
560+
}
561+
543562
// Generic function to decorate classes
544563
function decorateClass(Target, decorations, path) {
545564
// Static properties
@@ -555,6 +574,7 @@ function decorateClass(Target, decorations, path) {
555574
static: true
556575
});
557576
if (result) {
577+
copyFunctionProperties(Target[key], result);
558578
Object.defineProperty(Target, key, {
559579
enumerable: true,
560580
writable: true,
@@ -593,6 +613,7 @@ function decorateClass(Target, decorations, path) {
593613
static: false
594614
});
595615
if (result) {
616+
copyFunctionProperties(Target.prototype[member], result);
596617
Object.defineProperty(Target.prototype, member, {
597618
enumerable: true,
598619
writable: true,

‎src/strands/strands_api.js‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,76 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) {
770770
);
771771
return createStrandsNode(id, dimension, strandsContext);
772772
});
773+
augmentFn(fn, p5, 'paletteLerp', function(stopsNode, tNode) {
774+
if (!strandsContext.active) return;
775+
776+
if (!Array.isArray(stopsNode)) {
777+
throw new Error(
778+
'paletteLerp() first argument must be an array literal: [[color(...), pos], ...]'
779+
);
780+
}
781+
const n = stopsNode.length;
782+
if (n < 2 || n > 8) {
783+
throw new Error(`paletteLerp() requires 2–8 color stops, got ${n}.`);
784+
}
785+
for (let i = 0; i < n; i++) {
786+
if (!Array.isArray(stopsNode[i]) || stopsNode[i].length !== 2) {
787+
throw new Error(
788+
`paletteLerp() stop ${i} must be a 2-element array: [color(...), position]`
789+
);
790+
}
791+
}
792+
793+
// Wrap raw values into StrandsNodes
794+
const colors = stopsNode.map(([c]) => p5.strandsNode(c));
795+
const positions = stopsNode.map(([, p]) => p5.strandsNode(p));
796+
const t = p5.strandsNode(tNode);
797+
798+
// Helper: mix(a, b, clamp((t - pa) / (pb - pa), 0, 1))
799+
function segmentLerp(ca, cb, pa, pb) {
800+
const zero = p5.strandsNode(0.0);
801+
const one = p5.strandsNode(1.0);
802+
const num = t.sub(pa);
803+
const den = pb.sub(pa);
804+
// mix and clamp are builtin functions, not methods on strands nodes
805+
const localT = fn.clamp(num.div(den), zero, one);
806+
return buildTernary(
807+
strandsContext,
808+
pa.equalTo(pb), // guard: pa == pb → return midpoint
809+
fn.mix(ca, cb, p5.strandsNode(0.5)),
810+
fn.mix(ca, cb, localT)
811+
);
812+
}
813+
814+
// Build nested ternary chain from right to left:
815+
// t >= p[last] ? c[last] : (t < p[n-1] ? seg(n-2,n-1) : (...))
816+
let result = colors[n - 1];
817+
for (let i = n - 2; i >= 0; i--) {
818+
const seg = segmentLerp(colors[i], colors[i + 1], positions[i], positions[i + 1]);
819+
result = buildTernary(strandsContext, t.lessThan(positions[i + 1]), seg, result);
820+
}
821+
// Clamp edges
822+
result = buildTernary(strandsContext, t.greaterEqual(positions[n - 1]), colors[n - 1], result);
823+
result = buildTernary(strandsContext, t.lessEqual(positions[0]), colors[0], result);
824+
825+
return result;
826+
});
827+
// Tell the transpiler that argument 0 is a raw array (the stops) whose
828+
// elements are themselves raw arrays (the [color, position] pairs) —
829+
// both levels left untouched rather than converted to vectors. The
830+
// bare 'any' terminal means pair contents aren't constrained any
831+
// further; see strands_transpiler.js's ArrayExpression visitor for how
832+
// this nesting is walked.
833+
fn.paletteLerp.argTypes = [
834+
{
835+
type: 'Array', // stops
836+
subtype: {
837+
type: 'Array', // color + position tuples
838+
subtype: 'any'
839+
}
840+
},
841+
{ type: 'Number' }
842+
];
773843

774844
augmentFn(fn, p5, 'randomSeed', function (seed) {
775845
if (!strandsContext.active) {

‎src/strands/strands_builtins.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,4 +343,4 @@ const builtInGLSLFunctions = {
343343

344344
export const strandsBuiltinFunctions = {
345345
...builtInGLSLFunctions
346-
};
346+
};

‎src/strands/strands_transpiler.js‎

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,20 @@ import {
88
createStrandsShaderNameState,
99
getOrCreateInternalShaderName
1010
} from './strands_names';
11+
12+
// Strands functions opt out of array-to-vector conversion by declaring
13+
// `argTypes` on themselves (see strands_api.js's `paletteLerp` for an
14+
// example), rather than being special-cased here. A descriptor of
15+
// `{ type: 'Array', subtype: { type: 'any' } }` marks an argument as an
16+
// opaque array: the ArrayExpression visitor leaves it (and anything
17+
// nested inside it, to any depth) untouched instead of converting it to
18+
// a vector/strandsNode call. This lets any current or future function —
19+
// including ones added by addons — take raw array arguments without
20+
// touching this file again.
21+
function getArgTypes(fnName, p5) {
22+
return p5?.prototype?.[fnName]?.argTypes;
23+
}
24+
1125
let blockVarCounter = 0;
1226
let loopVarCounter = 0;
1327
function replaceBinaryOperator(codeSource) {
@@ -660,8 +674,11 @@ const ASTCallbacks = {
660674
node.arguments = [];
661675
}
662676
},
677+
663678
// The callbacks for AssignmentExpression and BinaryExpression handle
664679
// operator overloading including +=, *= assignment expressions
680+
681+
665682
ArrayExpression(node, state, ancestors) {
666683
if (
667684
ancestors.some(
@@ -672,6 +689,52 @@ const ASTCallbacks = {
672689
) {
673690
return;
674691
}
692+
// Don't wrap arrays that are (or are nested inside) an argument a
693+
// function has declared as opaque via argTypes. We look this up
694+
// structurally — via the nearest enclosing CallExpression and which
695+
// of its arguments node descends from — rather than depending on
696+
// that CallExpression's own visitor having already run, since
697+
// acorn-walk's ancestor() calls child callbacks before parent ones.
698+
//
699+
// Opacity is depth-scoped, not blanket: argType descriptors nest via
700+
// `.subtype`, one level per ArrayExpression descended through from
701+
// the top-level argument down to this node. We walk that chain in
702+
// lockstep with the descriptor so a function can mark the outer
703+
// array and some number of inner levels as opaque (`type: 'Array'`)
704+
// while still letting arrays beyond that depth resume normal
705+
// vectorization — signaled by the descriptor bottoming out at the
706+
// bare string `'any'` (or running out) before reaching this node's
707+
// depth.
708+
for (let i = ancestors.length - 1; i >= 0; i--) {
709+
const a = ancestors[i];
710+
if (a.type === 'CallExpression') {
711+
const fnName = a.callee?.type === 'Identifier'
712+
? a.callee.name
713+
: a.callee?.type === 'MemberExpression' && a.callee.property?.type === 'Identifier'
714+
? a.callee.property.name
715+
: undefined;
716+
const argTypes = fnName && getArgTypes(fnName, state.p5);
717+
if (argTypes) {
718+
// The chain of nodes from the top-level argument down to (and
719+
// including) this array.
720+
// ancestors already ends with `node` itself (acorn-walk, in
721+
// this codebase, includes the current node as its own last
722+
// ancestor entry) — so slicing from i + 1 already gives the
723+
// full chain from the top-level argument down to and
724+
// including `node`, with no need to append it again.
725+
const path = ancestors.slice(i + 1);
726+
const argIndex = a.arguments.indexOf(path[0]);
727+
let argType = argIndex !== -1 ? argTypes[argIndex] : undefined;
728+
for (let depth = 1; depth < path.length && argType; depth++) {
729+
argType = argType.subtype;
730+
}
731+
if (argType?.type === 'Array') {
732+
return;
733+
}
734+
}
735+
break; // only consider the nearest enclosing call
736+
}
737+
}
675738

676739
if (node.elements.length < 2 || node.elements.length > 4) {
677740
FES.userError(
@@ -1919,7 +1982,8 @@ function makeGuardedCallbacks(callbacks) {
19191982
function runNonControlFlowPass(
19201983
ast,
19211984
uniformCallbackNames,
1922-
initialShaderNameState
1985+
initialShaderNameState,
1986+
p5
19231987
) {
19241988
const nonControlFlowCallbacks = { ...ASTCallbacks };
19251989
delete nonControlFlowCallbacks.IfStatement;
@@ -1930,7 +1994,8 @@ function runNonControlFlowPass(
19301994
shaderNameMap: createStrandsShaderNameMap(),
19311995
shaderNameState: createStrandsShaderNameState(
19321996
initialShaderNameState?.nextSuffix || 0
1933-
)
1997+
),
1998+
p5
19341999
};
19352000
ancestor(ast, nonControlFlowCallbacks, undefined, state);
19362001
return {
@@ -2065,7 +2130,8 @@ export function transpileStrandsToJS(
20652130
const { shaderNameMap, shaderNameState } = runNonControlFlowPass(
20662131
ast,
20672132
uniformCallbackNames,
2068-
initialShaderNameState
2133+
initialShaderNameState,
2134+
p5
20692135
);
20702136

20712137
// Pass 3: transform helper functions with early returns to use __returnValue pattern
@@ -2080,4 +2146,4 @@ export function transpileStrandsToJS(
20802146
shaderNameMap,
20812147
shaderNameState
20822148
};
2083-
}
2149+
}

‎src/webgl/strands_glslBackend.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ export const glslBackend = {
211211
getRandomVertexShaderSnippet() {
212212
return randomVertGLSL;
213213
},
214+
214215
getTypeName(baseType, dimension) {
215216
const primitiveTypeName = TypeNames[baseType + dimension];
216217
if (!primitiveTypeName) {

‎test/unit/visual/cases/webgl.js‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1577,6 +1577,93 @@ visualSuite('WebGL', function () {
15771577
screenshot();
15781578
});
15791579

1580+
visualTest(
1581+
'paletteLerp() maps texCoord through a multi-stop gradient in a filter',
1582+
(p5, screenshot) => {
1583+
p5.createCanvas(50, 50, p5.WEBGL);
1584+
const shader = p5.baseFilterShader().modify(
1585+
() => {
1586+
p5.filterColor.begin();
1587+
p5.filterColor.set(
1588+
p5.paletteLerp(
1589+
[
1590+
[[1, 0, 0, 1], 0.0],
1591+
[[1, 1, 0, 1], 0.33],
1592+
[[0, 1, 0, 1], 0.66],
1593+
[[0, 0, 1, 1], 1.0]
1594+
],
1595+
p5.filterColor.texCoord.x
1596+
)
1597+
);
1598+
p5.filterColor.end();
1599+
},
1600+
{ p5 }
1601+
);
1602+
p5.background(220);
1603+
p5.filter(shader);
1604+
screenshot();
1605+
}
1606+
);
1607+
1608+
visualTest(
1609+
'paletteLerp() handles uneven stop positions and more than 4 stops',
1610+
(p5, screenshot) => {
1611+
p5.createCanvas(50, 50, p5.WEBGL);
1612+
const shader = p5.baseFilterShader().modify(
1613+
() => {
1614+
p5.filterColor.begin();
1615+
// Stops bunched toward the right, with flat regions on either end
1616+
p5.filterColor.set(
1617+
p5.paletteLerp(
1618+
[
1619+
[[0, 0, 0, 1], 0.2],
1620+
[[1, 0, 0, 1], 0.4],
1621+
[[1, 0.5, 0, 1], 0.55],
1622+
[[1, 1, 0, 1], 0.65],
1623+
[[1, 1, 1, 1], 0.8]
1624+
],
1625+
p5.filterColor.texCoord.x
1626+
)
1627+
);
1628+
p5.filterColor.end();
1629+
},
1630+
{ p5 }
1631+
);
1632+
p5.background(220);
1633+
p5.filter(shader);
1634+
screenshot();
1635+
}
1636+
);
1637+
1638+
visualTest(
1639+
'paletteLerp() can be driven by a different input than texCoord.x',
1640+
(p5, screenshot) => {
1641+
p5.createCanvas(50, 50, p5.WEBGL);
1642+
const shader = p5.baseFilterShader().modify(
1643+
() => {
1644+
p5.filterColor.begin();
1645+
// Diagonal gradient between two stops
1646+
const uv = p5.filterColor.texCoord;
1647+
const t = (uv.x + uv.y) / 2;
1648+
p5.filterColor.set(
1649+
p5.paletteLerp(
1650+
[
1651+
[[0.1, 0.1, 0.5, 1], 0.0],
1652+
[[1, 0.8, 0.2, 1], 1.0]
1653+
],
1654+
t
1655+
)
1656+
);
1657+
p5.filterColor.end();
1658+
},
1659+
{ p5 }
1660+
);
1661+
p5.background(220);
1662+
p5.filter(shader);
1663+
screenshot();
1664+
}
1665+
);
1666+
15801667
visualTest('random() colors a basic shader', (p5, screenshot) => {
15811668
p5.createCanvas(50, 50, p5.WEBGL);
15821669
const shader = p5.baseColorShader().modify(

0 commit comments

Comments
 (0)