}
*
* @example
* let heart;
@@ -155,10 +164,10 @@ function attributes(p5, fn){
if ('imageSmoothingEnabled' in this.drawingContext) {
this.drawingContext.imageSmoothingEnabled = false;
}
+ return this;
} else {
- this.setAttributes('antialias', false);
+ return this.setAttributes('antialias', false);
}
- return this;
};
/**
@@ -186,6 +195,8 @@ function attributes(p5, fn){
* constants `CENTER`, `RADIUS`, `CORNER`, and `CORNERS` are defined this way.
* JavaScript is a case-sensitive language.
*
+ * Calling `rectMode()` without an argument returns the current rectMode, either `CORNER`, `CORNERS`, `CENTER`, or `RADIUS`.
+ *
* @method rectMode
* @param {(CENTER|RADIUS|CORNER|CORNERS)} mode either CORNER, CORNERS, CENTER, or RADIUS
* @chainable
@@ -254,8 +265,15 @@ function attributes(p5, fn){
* describe('A small gray square drawn at the center of a white square.');
* }
*/
+ /**
+ * @method rectMode
+ * @return {(CENTER|RADIUS|CORNER|CORNERS)} the current rectMode.
+ */
fn.rectMode = function(m) {
// p5._validateParameters('rectMode', arguments);
+ if (typeof m === 'undefined') { // getter
+ return this._renderer?.states.rectMode;
+ }
if (
m === constants.CORNER ||
m === constants.CORNERS ||
@@ -380,6 +398,9 @@ function attributes(p5, fn){
*/
fn.strokeCap = function(cap) {
// p5._validateParameters('strokeCap', arguments);
+ if (typeof cap === 'undefined') { // getter
+ return this._renderer.strokeCap();
+ }
if (
cap === constants.ROUND ||
cap === constants.SQUARE ||
@@ -401,6 +422,8 @@ function attributes(p5, fn){
* the constants `MITER`, `BEVEL`, and `ROUND` are defined this way.
* JavaScript is a case-sensitive language.
*
+ * Calling `strokeJoin()` without an argument returns the current stroke join style, either `MITER`, `BEVEL`, or `ROUND`.
+ *
* @method strokeJoin
* @param {(MITER|BEVEL|ROUND)} join either MITER, BEVEL, or ROUND
* @chainable
@@ -467,8 +490,15 @@ function attributes(p5, fn){
* describe('A right-facing arrowhead shape with a rounded tip in center of canvas.');
* }
*/
+ /**
+ * @method strokeJoin
+ * @return {(MITER|BEVEL|ROUND)} the current stroke join style.
+ */
fn.strokeJoin = function(join) {
// p5._validateParameters('strokeJoin', arguments);
+ if (typeof join === 'undefined') { // getter
+ return this._renderer.strokeJoin();
+ }
if (
join === constants.ROUND ||
join === constants.BEVEL ||
@@ -485,6 +515,8 @@ function attributes(p5, fn){
*
* Note: `strokeWeight()` is affected by transformations, especially calls to
* scale().
+ *
+ * Calling `strokeWeight()` without an argument returns the current stroke weight as a number.
*
* @method strokeWeight
* @param {Number} weight the weight of the stroke (in pixels).
@@ -527,10 +559,13 @@ function attributes(p5, fn){
* describe('Two horizontal black lines. The top line is thin and the bottom is five times thicker than the top.');
* }
*/
+ /**
+ * @method strokeWeight
+ * @return {Number} the current stroke weight.
+ */
fn.strokeWeight = function(w) {
// p5._validateParameters('strokeWeight', arguments);
- this._renderer.strokeWeight(w);
- return this;
+ return this._renderer.strokeWeight(w);
};
}
diff --git a/src/shape/curves.js b/src/shape/curves.js
index 5f5e278811..7e2b49a2a1 100644
--- a/src/shape/curves.js
+++ b/src/shape/curves.js
@@ -2,7 +2,6 @@
* @module Shape
* @submodule Curves
* @for p5
- * @requires core
*/
function curves(p5, fn){
diff --git a/src/shape/custom_shapes.js b/src/shape/custom_shapes.js
index 3a09200f75..c34ec0f5a4 100644
--- a/src/shape/custom_shapes.js
+++ b/src/shape/custom_shapes.js
@@ -2,8 +2,6 @@
* @module Shape
* @submodule Custom Shapes
* @for p5
- * @requires core
- * @requires constants
*/
// REMINDER: remove .js extension (currently using it to run file locally)
@@ -466,6 +464,85 @@ class Quad extends ShapePrimitive {
}
}
+/*
+ * TODO: Future enhancement β align with arcVertex proposal (#6459)
+ * Currently stores start/stop angles and mode (OPEN/CHORD/PIE).
+ * For full SVG compatibility and arcs inside beginShape/endShape,
+ * we may want to add an arc-to-vertex variant that matches the
+ * arcVertex() API discussed in #6459.
+ */
+
+class ArcPrimitive extends ShapePrimitive {
+ #x;
+ #y;
+ #w;
+ #h;
+ #start;
+ #stop;
+ #mode;
+ #vertexCapacity = 2;
+
+ constructor(startVertex, endVertex, x, y, w, h, start, stop, mode) {
+ // ShapePrimitive requires at least one vertex; pass a placeholder
+ super(startVertex, endVertex);
+ this.#x = x;
+ this.#y = y;
+ this.#w = w;
+ this.#h = h;
+ this.#start = start;
+ this.#stop = stop;
+ this.#mode = mode;
+ }
+
+ get x() { return this.#x; }
+ get y() { return this.#y; }
+ get w() { return this.#w; }
+ get h() { return this.#h; }
+ get start() { return this.#start; }
+ get stop() { return this.#stop; }
+ get mode() { return this.#mode; }
+ get startVertex() { return this.vertices[0]; }
+ get endVertex() { return this.vertices[1]; }
+
+ get vertexCapacity() {
+ return this.#vertexCapacity;
+ }
+
+ accept(visitor) {
+ visitor.visitArcPrimitive(this);
+ }
+}
+
+class EllipsePrimitive extends ShapePrimitive {
+ #x;
+ #y;
+ #w;
+ #h;
+ #vertexCapacity = 1;
+
+ constructor(centerVertex, x, y, w, h) {
+
+ super(centerVertex);
+ this.#x = x;
+ this.#y = y;
+ this.#w = w;
+ this.#h = h;
+ }
+
+ get x() { return this.#x; }
+ get y() { return this.#y; }
+ get w() { return this.#w; }
+ get h() { return this.#h; }
+
+ get vertexCapacity() {
+ return this.#vertexCapacity;
+ }
+
+ accept(visitor) {
+ visitor.visitEllipsePrimitive(this);
+ }
+}
+
// ---- TESSELLATION PRIMITIVES ----
class TriangleFan extends ShapePrimitive {
@@ -905,6 +982,49 @@ class Shape {
this.#generalVertex('arcVertex', position, textureCoordinates);
}
+
+ arcPrimitive(x,y,w,h,start,stop,mode){
+ this.beginShape();
+ const centerX = x+w/2;
+ const centerY = y+h/2;
+ const radiusX = w / 2;
+ const radiusY = h / 2;
+
+ const startVertex = this.#createVertex(
+ new Vector(
+ centerX + radiusX * Math.cos(start),
+ centerY + radiusY * Math.sin(start)
+ )
+ );
+
+ const endVertex = this.#createVertex(
+ new Vector(
+ centerX + radiusX * Math.cos(stop),
+ centerY + radiusY * Math.sin(stop)
+ )
+ );
+
+ const primitive = new ArcPrimitive(
+ startVertex,
+ endVertex,
+ x, y, w, h,
+ start,
+ stop,
+ mode
+ );
+ primitive.addToShape(this);
+ this.endShape();
+ return this;
+
+ }
+
+ ellipsePrimitive(x,y,w,h){
+ const centerVertex = this.#createVertex(new Vector(x+w/2,y+h/2));
+
+ const primitive = new EllipsePrimitive(centerVertex, x, y, w, h);
+ return primitive.addToShape(this);
+ }
+
beginContour(shapeKind = constants.PATH) {
if (this.at(-1)?.kind === constants.EMPTY_PATH) {
this.contours.pop();
@@ -1003,6 +1123,12 @@ class PrimitiveVisitor {
visitArcSegment(arcSegment) {
throw new Error('Method visitArcSegment() has not been implemented.');
}
+ visitArcPrimitive(arc) {
+ throw new Error('Method visitArcPrimitive() has not been implemented.');
+ }
+ visitEllipsePrimitive(ellipse) {
+ throw new Error('Method visitEllipsePrimitive() has not been implemented.');
+ }
// isolated primitives
visitPoint(point) {
@@ -1033,6 +1159,8 @@ class PrimitiveVisitor {
// requires testing
class PrimitiveToPath2DConverter extends PrimitiveVisitor {
path = new Path2D();
+ strokePath = null;
+ fillPath = null;
strokeWeight;
constructor({ strokeWeight }) {
@@ -1151,6 +1279,59 @@ class PrimitiveToPath2DConverter extends PrimitiveVisitor {
this.path.closePath();
}
}
+ visitArcPrimitive(arc) {
+ const centerX = arc.x + arc.w / 2;
+ const centerY = arc.y + arc.h / 2;
+ const radiusX = arc.w / 2;
+ const radiusY = arc.h / 2;
+ const startX = centerX + radiusX * Math.cos(arc.start);
+ const startY = centerY + radiusY * Math.sin(arc.start);
+
+ const delta = arc.stop - arc.start;
+ const isFullCircle = Math.abs(delta % (2 * Math.PI)) < 0.00001 &&
+ Math.abs(delta) > 0.00001;
+
+ const createPieSlice = ! (
+ arc.mode === constants.CHORD ||
+ arc.mode === constants.OPEN ||
+ isFullCircle
+ );
+
+ if (!this.fillPath) this.fillPath = new Path2D(this.path);
+ if (!this.strokePath) this.strokePath = new Path2D(this.path);
+
+ this.fillPath.moveTo(startX, startY);
+ this.fillPath.ellipse(centerX, centerY, radiusX, radiusY,
+ 0, arc.start, arc.stop);
+ if (createPieSlice) {
+ this.fillPath.lineTo(centerX, centerY);
+ }
+ this.fillPath.closePath();
+
+ this.strokePath.moveTo(startX, startY);
+ this.strokePath.ellipse(centerX, centerY, radiusX, radiusY,
+ 0, arc.start, arc.stop);
+ if (arc.mode === constants.PIE && createPieSlice) {
+ this.strokePath.lineTo(centerX, centerY);
+ }
+ if (arc.mode === constants.PIE || arc.mode === constants.CHORD) {
+ this.strokePath.closePath();
+ }
+
+ // Still maintain base path just in case
+ this.path.moveTo(startX, startY);
+ this.path.ellipse(centerX, centerY, radiusX, radiusY,
+ 0, arc.start, arc.stop);
+ }
+ visitEllipsePrimitive(ellipse) {
+ const centerX = ellipse.x + ellipse.w / 2;
+ const centerY = ellipse.y + ellipse.h / 2;
+ const radiusX = ellipse.w / 2;
+ const radiusY = ellipse.h / 2;
+
+ this.path.moveTo(centerX + radiusX, centerY);
+ this.path.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, 2 * Math.PI);
+ }
visitQuadStrip(quadStrip) {
for (let i = 0; i < quadStrip.vertices.length - 3; i += 2) {
const v0 = quadStrip.vertices[i];
@@ -1277,6 +1458,78 @@ class PrimitiveToVerticesConverter extends PrimitiveVisitor {
// WebGL itself interprets the vertices as a strip, no reformatting needed
this.contours.push(quadStrip.vertices.slice());
}
+ visitArcPrimitive(arc) {
+ const startVertex = arc.startVertex;
+ const endVertex = arc.endVertex;
+ const centerX = arc.x + arc.w / 2;
+ const centerY = arc.y + arc.h / 2;
+ const radiusX = arc.w / 2;
+ const radiusY = arc.h / 2;
+ const avgRadius = (radiusX + radiusY) / 2;
+
+ const arcLength = avgRadius * Math.abs(arc.stop - arc.start);
+
+ const numPoints = Math.max(3, Math.ceil(this.curveDetail * arcLength));
+ const verts = [];
+ const interpolateVertexProps = (v1, v2, t) => {
+ const props = {};
+ for (const [key, value] of Object.entries(v1)) {
+ if (key === 'position') continue;
+ if (typeof value === 'number' && typeof v2[key] === 'number') {
+ props[key] = value * (1 - t) + v2[key] * t;
+ } else {
+ props[key] = value;
+ }
+ }
+ return props;
+ };
+ if (arc.mode === constants.PIE) {
+ const centerProps = interpolateVertexProps(startVertex, endVertex, 0.5);
+ centerProps.position = new Vector(centerX, centerY);
+ verts.push(new Vertex(centerProps));
+ }
+
+ for (let i = 0; i <= numPoints; i++) {
+ const t = i / numPoints;
+ const angle = arc.start + (arc.stop - arc.start) * t;
+ const vertexProps = interpolateVertexProps(startVertex, endVertex, t);
+
+ vertexProps.position = new Vector(
+ centerX + radiusX * Math.cos(angle),
+ centerY + radiusY * Math.sin(angle)
+ );
+
+ verts.push(new Vertex(vertexProps));
+ }
+
+ this.contours.push(verts);
+ }
+ visitEllipsePrimitive(ellipse) {
+ const centerX = ellipse.x + ellipse.w / 2;
+ const centerY = ellipse.y + ellipse.h / 2;
+ const radiusX = ellipse.w / 2;
+ const radiusY = ellipse.h / 2;
+ const avgRadius = (radiusX + radiusY) / 2;
+ const perimeter = 2 * Math.PI * avgRadius;
+ const numPoints = Math.max(3, Math.ceil(this.curveDetail * perimeter));
+ const verts = [];
+ const centerVertex = ellipse.vertices[0];
+ for (let i = 0; i <= numPoints; i++) {
+ const angle = (2 * Math.PI * i) / numPoints;
+ const vertexProps = {};
+ for (const [key, value] of Object.entries(centerVertex)) {
+ if (key === 'position') continue;
+ vertexProps[key] = value;
+ }
+ vertexProps.position = new Vector(
+ centerX + radiusX * Math.cos(angle),
+ centerY + radiusY * Math.sin(angle)
+ );
+ verts.push(new Vertex(vertexProps));
+ }
+
+ this.contours.push(verts);
+ }
}
class PointAtLengthGetter extends PrimitiveVisitor {
@@ -1611,6 +1864,8 @@ function customShapes(p5, fn) {
* Note: `bezierVertex()` wonβt work when an argument is passed to
* beginShape().
*
+ * Calling `bezierOrder()` without an argument returns the current BΓ©zier order.
+ *
* @method bezierOrder
* @param {Number} order The new order to set. Can be either 2 or 3, by default 3
*
@@ -2793,6 +3048,8 @@ export {
Line,
Triangle,
Quad,
+ ArcPrimitive,
+ EllipsePrimitive,
TriangleFan,
TriangleStrip,
QuadStrip,
diff --git a/src/shape/vertex.js b/src/shape/vertex.js
index fbe46e0cba..0f41a6c043 100644
--- a/src/shape/vertex.js
+++ b/src/shape/vertex.js
@@ -2,8 +2,6 @@
* @module Shape
* @submodule Custom Shapes
* @for p5
- * @requires core
- * @requires constants
*/
function vertex(p5, fn){
diff --git a/src/strands/ir_builders.js b/src/strands/ir_builders.js
index efe3908d75..465572bebb 100644
--- a/src/strands/ir_builders.js
+++ b/src/strands/ir_builders.js
@@ -621,3 +621,185 @@ export function swizzleTrap(id, dimension, strandsContext, onRebind) {
};
return trap;
}
+
+export function arrayAccessNode(strandsContext, bufferNode, indexNode, accessMode) {
+ const { dag, cfg } = strandsContext;
+
+ // Ensure index is a StrandsNode
+ let index;
+ if (indexNode instanceof StrandsNode) {
+ index = indexNode;
+ } else {
+ const { id, dimension } = primitiveConstructorNode(
+ strandsContext,
+ { baseType: BaseType.INT, dimension: 1 },
+ indexNode
+ );
+ index = createStrandsNode(id, dimension, strandsContext);
+ }
+
+ // Array access returns a single float
+ const nodeData = DAG.createNodeData({
+ nodeType: NodeType.OPERATION,
+ opCode: OpCode.Binary.ARRAY_ACCESS,
+ dependsOn: [bufferNode.id, index.id],
+ dimension: 1,
+ baseType: BaseType.FLOAT,
+ accessMode // 'read' or 'read_write'
+ });
+
+ const id = DAG.getOrCreateNode(dag, nodeData);
+ CFG.recordInBasicBlock(cfg, cfg.currentBlock, id);
+
+ return { id, dimension: 1 };
+}
+
+export function createStructArrayElementProxy(strandsContext, bufferNode, indexNode, schema) {
+ const { dag, cfg } = strandsContext;
+
+ // Ensure index is a StrandsNode
+ let index;
+ if (indexNode instanceof StrandsNode) {
+ index = indexNode;
+ } else {
+ const { id, dimension } = primitiveConstructorNode(
+ strandsContext,
+ { baseType: BaseType.INT, dimension: 1 },
+ indexNode
+ );
+ index = createStrandsNode(id, dimension, strandsContext);
+ }
+
+ // Create a plain object with getters/setters for each struct field.
+ // When read, a field creates an ARRAY_ACCESS IR node with the field name encoded
+ // in the identifier slot. When written, an ASSIGNMENT IR node is recorded in the CFG.
+ const proxy = {};
+
+ for (const field of schema.fields) {
+ Object.defineProperty(proxy, field.name, {
+ get() {
+ // Encode field name in identifier so WGSL backend can emit buf[idx].field
+ const nodeData = DAG.createNodeData({
+ nodeType: NodeType.OPERATION,
+ opCode: OpCode.Binary.ARRAY_ACCESS,
+ dependsOn: [bufferNode.id, index.id],
+ dimension: field.dim,
+ baseType: BaseType.FLOAT,
+ identifier: field.name,
+ });
+ const id = DAG.getOrCreateNode(dag, nodeData);
+ CFG.recordInBasicBlock(cfg, cfg.currentBlock, id);
+ // When a swizzle assignment fires (e.g. buf[i].vel.y *= -1), onRebind
+ // receives the new vector ID and writes it back to the buffer field,
+ // equivalent to buf[i].vel = newVec.
+ const onRebind = (newFieldID) => {
+ const accessData = DAG.createNodeData({
+ nodeType: NodeType.OPERATION,
+ opCode: OpCode.Binary.ARRAY_ACCESS,
+ dependsOn: [bufferNode.id, index.id],
+ dimension: field.dim,
+ baseType: BaseType.FLOAT,
+ identifier: field.name,
+ });
+ const accessID = DAG.getOrCreateNode(dag, accessData);
+ const assignData = DAG.createNodeData({
+ nodeType: NodeType.ASSIGNMENT,
+ dependsOn: [accessID, newFieldID],
+ phiBlocks: [],
+ });
+ const assignID = DAG.getOrCreateNode(dag, assignData);
+ CFG.recordInBasicBlock(cfg, cfg.currentBlock, assignID);
+ };
+ return createStrandsNode(id, field.dim, strandsContext, onRebind);
+ },
+ set(val) {
+ // Create access node as assignment target (field name in identifier)
+ const accessData = DAG.createNodeData({
+ nodeType: NodeType.OPERATION,
+ opCode: OpCode.Binary.ARRAY_ACCESS,
+ dependsOn: [bufferNode.id, index.id],
+ dimension: field.dim,
+ baseType: BaseType.FLOAT,
+ identifier: field.name,
+ });
+ const accessID = DAG.getOrCreateNode(dag, accessData);
+
+ let valueID;
+ if (val?.isStrandsNode) {
+ valueID = val.id;
+ } else {
+ const { id } = primitiveConstructorNode(
+ strandsContext,
+ { baseType: BaseType.FLOAT, dimension: field.dim },
+ val
+ );
+ valueID = id;
+ }
+
+ const assignData = DAG.createNodeData({
+ nodeType: NodeType.ASSIGNMENT,
+ dependsOn: [accessID, valueID],
+ phiBlocks: [],
+ });
+ const assignID = DAG.getOrCreateNode(dag, assignData);
+ CFG.recordInBasicBlock(cfg, cfg.currentBlock, assignID);
+ },
+ configurable: true,
+ });
+ }
+
+ return proxy;
+}
+
+export function arrayAssignmentNode(strandsContext, bufferNode, indexNode, valueNode) {
+ const { dag, cfg } = strandsContext;
+
+ // Ensure index is a StrandsNode
+ let index;
+ if (indexNode instanceof StrandsNode) {
+ index = indexNode;
+ } else {
+ const { id, dimension } = primitiveConstructorNode(
+ strandsContext,
+ { baseType: BaseType.INT, dimension: 1 },
+ indexNode
+ );
+ index = createStrandsNode(id, dimension, strandsContext);
+ }
+
+ // Ensure value is a StrandsNode
+ let value;
+ if (valueNode instanceof StrandsNode) {
+ value = valueNode;
+ } else {
+ const { id, dimension } = primitiveConstructorNode(
+ strandsContext,
+ { baseType: BaseType.FLOAT, dimension: 1 },
+ valueNode
+ );
+ value = createStrandsNode(id, dimension, strandsContext);
+ }
+
+ // Create array access node as the assignment target
+ const arrayAccessData = DAG.createNodeData({
+ nodeType: NodeType.OPERATION,
+ opCode: OpCode.Binary.ARRAY_ACCESS,
+ dependsOn: [bufferNode.id, index.id],
+ dimension: 1,
+ baseType: BaseType.FLOAT
+ });
+ const arrayAccessID = DAG.getOrCreateNode(dag, arrayAccessData);
+
+ // Create assignment node: buffer[index] = value
+ const assignmentData = DAG.createNodeData({
+ nodeType: NodeType.ASSIGNMENT,
+ dependsOn: [arrayAccessID, value.id],
+ phiBlocks: []
+ });
+ const assignmentID = DAG.getOrCreateNode(dag, assignmentData);
+
+ // CRITICAL: Record in CFG to preserve sequential ordering
+ CFG.recordInBasicBlock(cfg, cfg.currentBlock, assignmentID);
+
+ return { id: assignmentID };
+}
diff --git a/src/strands/ir_types.js b/src/strands/ir_types.js
index 9f480d5c9d..20a432ffdc 100644
--- a/src/strands/ir_types.js
+++ b/src/strands/ir_types.js
@@ -11,6 +11,8 @@ export const NodeType = {
STATEMENT: 'statement',
ASSIGNMENT: 'assignment',
};
+export const INSTANCE_ID_VARYING_NAME = '_p5_instanceID';
+export const HOOK_PARAM_PREFIX = '_p5_param_';
export const NodeTypeToName = Object.fromEntries(
Object.entries(NodeType).map(([key, val]) => [val, key])
);
@@ -120,6 +122,7 @@ export const OpCode = {
LOGICAL_AND: 11,
LOGICAL_OR: 12,
MEMBER_ACCESS: 13,
+ ARRAY_ACCESS: 14,
},
Unary: {
LOGICAL_NOT: 100,
@@ -130,6 +133,8 @@ export const OpCode = {
Nary: {
FUNCTION_CALL: 200,
CONSTRUCTOR: 201,
+ TERNARY: 202,
+ ARRAY_ASSIGNMENT: 203,
},
ControlFlow: {
RETURN: 300,
diff --git a/src/strands/p5.strands.js b/src/strands/p5.strands.js
index 9b58389174..e9d4cd7143 100644
--- a/src/strands/p5.strands.js
+++ b/src/strands/p5.strands.js
@@ -2,7 +2,6 @@
* @module 3D
* @submodule p5.strands
* @for p5
- * @requires core
*/
import { transpileStrandsToJS } from "./strands_transpiler";
@@ -40,6 +39,7 @@ function strands(p5, fn) {
ctx.uniforms = [];
ctx.vertexDeclarations = new Set();
ctx.fragmentDeclarations = new Set();
+ ctx.computeDeclarations = new Set();
ctx.hooks = [];
ctx.backend = backend;
ctx.active = active;
@@ -49,6 +49,7 @@ function strands(p5, fn) {
ctx.windowOverrides = {};
ctx.fnOverrides = {};
ctx.graphicsOverrides = {};
+ ctx._randomSeed = null;
if (active) {
p5.disableFriendlyErrors = true;
}
@@ -61,8 +62,10 @@ function strands(p5, fn) {
ctx.uniforms = [];
ctx.vertexDeclarations = new Set();
ctx.fragmentDeclarations = new Set();
+ ctx.computeDeclarations = new Set();
ctx.hooks = [];
ctx.active = false;
+ ctx._randomSeed = null;
p5.disableFriendlyErrors = ctx.previousFES;
for (const key in ctx.windowOverrides) {
window[key] = ctx.windowOverrides[key];
@@ -113,7 +116,10 @@ function strands(p5, fn) {
//////////////////////////////////////////////
const oldModify = p5.Shader.prototype.modify;
- p5.Shader.prototype.modify = function (shaderModifier, scope = {}) {
+ p5.Shader.prototype.modify = function (shaderModifier, scope = {}, options = {}) {
+ const fnOverrides = {};
+ const windowOverrides = {};
+ const graphicsOverrides = {};
try {
if (
shaderModifier instanceof Function ||
@@ -128,7 +134,8 @@ function strands(p5, fn) {
});
createShaderHooksFunctions(strandsContext, fn, this);
// TODO: expose this, is internal for debugging for now.
- const options = { parser: true, srcLocations: false };
+ options.parser = true;
+ options.srcLocations = false;
// 1. Transpile from strands DSL to JS
let strandsCallback;
@@ -155,11 +162,24 @@ function strands(p5, fn) {
BlockType.GLOBAL,
);
pushBlock(strandsContext.cfg, globalScope);
+ if (options.hook) {
+ strandsContext.renderer._pInst[options.hook].begin();
+ for (const key of strandsContext.renderer._pInst[options.hook]._properties) {
+ const hookProp = strandsContext.renderer._pInst[options.hook][key];
+ fnOverrides[key] = fn[key];
+ fn[key] = hookProp;
+ windowOverrides[key] = window[key];
+ window[key] = hookProp;
+ graphicsOverrides[key] = p5.Graphics.prototype[key];
+ p5.Graphics.prototype[key] = hookProp;
+ }
+ }
if (strandsContext.renderer?._pInst?._runStrandsInGlobalMode) {
withTempGlobalMode(strandsContext.renderer._pInst, strandsCallback);
} else {
strandsCallback();
}
+ if (options.hook) strandsContext.renderer._pInst[options.hook].end();
popBlock(strandsContext.cfg);
// 3. Generate shader code hooks object from the IR
@@ -172,6 +192,15 @@ function strands(p5, fn) {
return oldModify.call(this, shaderModifier);
}
} finally {
+ for (const key in fnOverrides) {
+ fn[key] = fnOverrides[key];
+ }
+ for (const key in windowOverrides) {
+ window[key] = windowOverrides[key];
+ }
+ for (const key in graphicsOverrides) {
+ p5.Graphics[key] = graphicsOverrides[key];
+ }
// Reset the strands runtime context
deinitStrandsContext(strandsContext);
}
@@ -186,7 +215,18 @@ if (typeof p5 !== "undefined") {
/* ------------------------------------------------------------- */
/**
- * @property {Object} worldInputs
+ * @typedef {Object} WorldInputsHook
+ * @property {any} position
+ * @property {any} normal
+ * @property {any} texCoord
+ * @property {any} color
+ * @property {function(): undefined} begin
+ * @property {function(): undefined} end
+ */
+
+/**
+ * @property {WorldInputsHook} worldInputs
+ * @beta
* @description
* A shader hook block that modifies the world-space properties of each vertex in a shader. This hook can be used inside `buildColorShader()` and similar shader `modify()` calls to customize vertex positions, normals, texture coordinates, and colors before rendering. Modifications happen between the `.begin()` and `.end()` methods of the hook. "World space" refers to the coordinate system of the 3D scene, before any camera or projection transformations are applied.
*
@@ -230,7 +270,23 @@ if (typeof p5 !== "undefined") {
*/
/**
- * @property {Object} combineColors
+ * @typedef {Object} CombineColorsHook
+ * @property {any} baseColor
+ * @property {any} diffuse
+ * @property {any} ambientColor
+ * @property {any} ambient
+ * @property {any} specularColor
+ * @property {any} specular
+ * @property {any} emissive
+ * @property {any} opacity
+ * @property {function(): undefined} begin
+ * @property {function(): undefined} end
+ * @property {function(color: any): void} set
+ */
+
+/**
+ * @property {CombineColorsHook} combineColors
+ * @beta
* @description
* A shader hook block that modifies how color components are combined in the fragment shader. This hook can be used inside `buildMaterialShader()` and similar shader `modify()` calls to control the final color output of a material. Modifications happen between the `.begin()` and `.end()` methods of the hook.
*
@@ -281,8 +337,127 @@ if (typeof p5 !== "undefined") {
* }
*/
+/**
+ * @method instanceID
+ * @beta
+ * @description
+ * Returns the index of the current instance when drawing multiple copies of a
+ * shape with `model(count)`. The first instance has an
+ * ID of `0`, the second has `1`, and so on.
+ *
+ * This lets each copy of a shape behave differently. For example, you can use
+ * the ID to place instances at different positions, give them different colors,
+ * or animate them at different speeds.
+ *
+ * `instanceID()` can only be used inside a p5.strands shader callback.
+ *
+ * ```js example
+ * let instancesShader;
+ * let instance;
+ * let count = 5;
+ *
+ * function drawInstance() {
+ * sphere(15);
+ * }
+ *
+ * function setup() {
+ * createCanvas(200, 200, WEBGL);
+ * instance = buildGeometry(drawInstance);
+ * instancesShader = buildMaterialShader(drawSpaced);
+ * describe('Five red spheres arranged in a horizontal line.');
+ * }
+ *
+ * function drawSpaced() {
+ * worldInputs.begin();
+ * // Spread spheres evenly across the canvas based on their index
+ * let spacing = width / count;
+ * worldInputs.position.x +=
+ * (instanceID() - (count - 1) / 2) * spacing;
+ * worldInputs.end();
+ * }
+ *
+ * function draw() {
+ * background(220);
+ * lights();
+ * noStroke();
+ * fill('red');
+ * shader(instancesShader);
+ * model(instance, count);
+ * }
+ * ```
+ *
+ * If you are using WebGPU mode, a common pattern is to use `instanceID()` to look up data made with
+ * `createStorage()`.
+ * This lets you give each instance different properties.
+ *
+ * ```js example
+ * let instanceData;
+ * let instancesShader;
+ * let instance;
+ * let count = 5;
+ *
+ * async function setup() {
+ * await createCanvas(200, 200, WEBGPU);
+ *
+ * let data = [];
+ * for (let i = 0; i < count; i++) {
+ * data.push({
+ * position: createVector(
+ * random(-1, 1) * width / 2,
+ * random(-1, 1) * height / 2,
+ * 0,
+ * ),
+ * color: color(
+ * random(255),
+ * random(255),
+ * random(255)
+ * )
+ * });
+ * }
+ * instanceData = createStorage(data);
+ * instance = buildGeometry(drawInstance);
+ * instancesShader = buildMaterialShader(drawInstances);
+ * describe('Five spheres at random positions, each a different random color.');
+ * }
+ *
+ * function drawInstance() {
+ * sphere(15);
+ * }
+ *
+ * function drawInstances() {
+ * let data = uniformStorage(instanceData);
+ * let itemColor = sharedVec4();
+ *
+ * worldInputs.begin();
+ * let item = data[instanceID()];
+ * itemColor = item.color;
+ * worldInputs.position += item.position;
+ * worldInputs.end();
+ *
+ * finalColor.begin();
+ * finalColor.set(itemColor);
+ * finalColor.end();
+ * }
+ *
+ * function draw() {
+ * background(220);
+ * lights();
+ * noStroke();
+ * shader(instancesShader);
+ * model(instance, count);
+ * }
+ * ```
+ *
+ * This can be paired with `buildComputeShader`
+ * to update the data being read.
+ *
+ * @webgpu
+ * @returns {*} The index of the current instance.
+ */
+
/**
* @method smoothstep
+ * @beta
* @description
* A shader function that performs smooth Hermite interpolation between `0.0`
* and `1.0`.
@@ -443,7 +618,27 @@ if (typeof p5 !== "undefined") {
*/
/**
- * @property {Object} pixelInputs
+ * @typedef {Object} PixelInputsHook
+ * @property {any} normal
+ * @property {any} texCoord
+ * @property {any} ambientLight
+ * @property {any} ambientMaterial
+ * @property {any} specularMaterial
+ * @property {any} emissiveMaterial
+ * @property {any} color
+ * @property {any} shininess
+ * @property {any} metalness
+ * @property {any} tangent
+ * @property {any} center
+ * @property {any} position
+ * @property {any} strokeWeight
+ * @property {function(): undefined} begin
+ * @property {function(): undefined} end
+ */
+
+/**
+ * @property {PixelInputsHook} pixelInputs
+ * @beta
* @description
* A shader hook block that modifies the properties of each pixel before the final color is calculated. This hook can be used inside `buildMaterialShader()` and similar shader `modify()` calls to adjust per-pixel data before lighting is applied. Modifications happen between the `.begin()` and `.end()` methods of the hook.
*
@@ -530,12 +725,23 @@ if (typeof p5 !== "undefined") {
*/
/**
- * @property finalColor
+ * @typedef {Object} FinalColorHook
+ * @property {any} color
+ * @property {any} texCoord
+ * @property {function(): undefined} begin
+ * @property {function(): undefined} end
+ * @property {function(color: any): void} set
+ */
+
+/**
+ * @property {FinalColorHook} finalColor
+ * @beta
* @description
* A shader hook block that modifies the final color of each pixel after all lighting is applied. This hook can be used inside `buildMaterialShader()` and similar shader `modify()` calls to adjust the color before it appears on the screen. Modifications happen between the `.begin()` and `.end()` methods of the hook.
*
* `finalColor` has the following properties:
* - `color`: a four-component vector representing the pixel color (red, green, blue, alpha).
+ * - `texCoord`: a two-component vector representing the texture coordinates (u, v)
*
* Call `.set()` on the hook with a vector with four components (red, green, blue, alpha) to update the final color.
*
@@ -612,7 +818,18 @@ if (typeof p5 !== "undefined") {
*/
/**
- * @property {Object} filterColor
+ * @typedef {Object} FilterColorHook
+ * @property {any} texCoord
+ * @property {any} canvasSize
+ * @property {any} texelSize
+ * @property {any} canvasContent
+ * @property {function(): undefined} begin
+ * @property {function(): undefined} end
+ * @property {function(color: any): void} set
+ */
+
+/**
+ * @property {FilterColorHook} filterColor
* @description
* A shader hook block that sets the color for each pixel in a filter shader. This hook can be used inside `buildFilterShader()` to control the output color for each pixel.
*
@@ -656,7 +873,18 @@ if (typeof p5 !== "undefined") {
*/
/**
- * @property {Object} objectInputs
+ * @typedef {Object} ObjectInputsHook
+ * @property {any} position
+ * @property {any} normal
+ * @property {any} texCoord
+ * @property {any} color
+ * @property {function(): undefined} begin
+ * @property {function(): undefined} end
+ */
+
+/**
+ * @property {ObjectInputsHook} objectInputs
+ * @beta
* @description
* A shader hook block to modify the properties of each vertex before any transformations are applied. This hook can be used inside `buildMaterialShader()` and similar shader `modify()` calls to customize vertex positions, normals, texture coordinates, and colors before rendering. Modifications happen between the `.begin()` and `.end()` methods of the hook. "Object space" refers to the coordinate system of the 3D scene before any transformations, cameras, or projection transformations are applied.
*
@@ -697,7 +925,18 @@ if (typeof p5 !== "undefined") {
*/
/**
- * @property {Object} cameraInputs
+ * @typedef {Object} CameraInputsHook
+ * @property {any} position
+ * @property {any} normal
+ * @property {any} texCoord
+ * @property {any} color
+ * @property {function(): undefined} begin
+ * @property {function(): undefined} end
+ */
+
+/**
+ * @property {CameraInputsHook} cameraInputs
+ * @beta
* @description
* A shader hook block that adjusts vertex properties from the perspective of the camera. This hook can be used inside `buildMaterialShader()` and similar shader `modify()` calls to customize vertex positions, normals, texture coordinates, and colors before rendering. "Camera space" refers to the coordinate system of the 3D scene after transformations have been applied, seen relative to the camera.
*
@@ -740,30 +979,37 @@ if (typeof p5 !== "undefined") {
*/
/**
- * Retrieves the current color of a given texture at given coordinates.
+ * Declares a storage buffer uniform inside a modify() callback,
+ * making a createStorage() buffer accessible in the shader.
*
- * The given coordinates should be between [0, 0] representing the top-left of
- * the texture, and [1, 1] representing the bottom-right of the texture.
+ * Pass a `p5.StorageBuffer` (or a function returning one) as the second argument
+ * to set it as the default value, applied automatically each frame. Pass a plain
+ * object with the same field layout as the buffer's struct elements to declare the
+ * schema without binding a specific buffer.
*
- * The given texture could be, for example:
- * * p5.Image,
- * * a p5.Graphics, or
- * * a p5.Framebuffer.
+ * When called without a name, p5.strands automatically uses the name of the
+ * variable it is assigned to as the uniform name.
*
- * The retrieved color that is returned will behave like a vec4, with components
- * for red, green, blue, and alpha, each between 0.0 and 1.0.
+ * Note: `uniformStorage` is only available when using p5.strands.
*
- * Linear interpolation is used by default. For Framebuffer sources, you can
- * prevent this by creating the buffer with:
- * ```js
- * createFramebuffer({
- * textureFiltering: NEAREST
- * })
- * ```
- * This can be useful if you are using your texture to store data other than color.
- * See createFramebuffer.
- *
- * Note: The `getTexture` function is only available when using p5.strands.
+ * @method uniformStorage
+ * @beta
+ * @webgpu
+ * @webgpuOnly
+ * @submodule p5.strands
+ * @param {String} name The name of the storage buffer uniform in the shader.
+ * @param {p5.StorageBuffer|Function|Object} [bufferOrSchema] A storage buffer to bind,
+ * a function returning a storage buffer (called each frame), or a plain object
+ * describing the struct field layout.
+ * @returns {*} A strands node representing the storage buffer.
+ */
+/**
+ * @method uniformStorage
+ * @param {p5.StorageBuffer|Function|Object} [bufferOrSchema]
+ * @returns {*}
+ */
+
+/**
*
* @method getTexture
* @beta
@@ -873,30 +1119,170 @@ if (typeof p5 !== "undefined") {
/**
* @method getWorldInputs
+ * @beta
* @param {Function} callback
*/
/**
* @method getPixelInputs
+ * @beta
* @param {Function} callback
*/
/**
* @method getFinalColor
+ * @beta
* @param {Function} callback
*/
/**
* @method getColor
+ * @beta
* @param {Function} callback
*/
/**
* @method getObjectInputs
+ * @beta
* @param {Function} callback
*/
/**
* @method getCameraInputs
+ * @beta
* @param {Function} callback
*/
+
+/**
+ * Performs linear interpolation between two values.
+ *
+ * The `mix()` function linearly interpolates between two values based on a third
+ * parameter. It's a GLSL built-in function available in p5.strands shaders.
+ *
+ * The function computes: `x * (1 - a) + y * a`
+ *
+ * When `a` is 0.0, the function returns `x`. When `a` is 1.0, it returns `y`.
+ * Values between 0.0 and 1.0 produce a linear blend between the two values.
+ *
+ * This function works with scalars, vectors (vec2, vec3, vec4), and can also
+ * accept a boolean for the third parameter to select between the two values.
+ *
+ * Note: This function is only available inside shader code created with
+ * buildMaterialShader(),
+ * buildColorShader(), or similar functions.
+ * For regular p5.js code, use lerp() instead.
+ *
+ * @method mix
+ * @param {Number|p5.Vector} x first value to interpolate from.
+ * @param {Number|p5.Vector} y second value to interpolate to.
+ * @param {Number|Boolean} a interpolation amount (0.0-1.0) or boolean selector.
+ * @return {Number|p5.Vector} interpolated value.
+ *
+ * @example
+ *
+ *
+ * let myShader;
+ *
+ * function setup() {
+ * createCanvas(200, 200, WEBGL);
+ * myShader = buildMaterialShader(applyMix);
+ * describe('A sphere that transitions smoothly between red and blue.');
+ * }
+ *
+ * function applyMix() {
+ * let factor = uniformFloat();
+ *
+ * pixelInputs.begin();
+ * // Mix between red and blue based on factor
+ * let red = vec3(1, 0, 0);
+ * let blue = vec3(0, 0, 1);
+ * let mixedColor = mix(red, blue, factor);
+ * pixelInputs.color = vec4(mixedColor, 1);
+ * // Set ambient color to match to avoid default ambient lighting
+ * pixelInputs.ambientColor = pixelInputs.color.rgb;
+ * pixelInputs.end();
+ * }
+ *
+ * function draw() {
+ * background(255);
+ * shader(myShader);
+ * // Oscillate factor between 0 and 1
+ * let factor = (sin(frameCount * 0.02) + 1) / 2;
+ * myShader.setUniform('factor', factor);
+ * noStroke();
+ * sphere(80);
+ * }
+ *
+ *
+ *
+ * @example
+ *
+ *
+ * let myShader;
+ *
+ * function setup() {
+ * createCanvas(200, 200, WEBGL);
+ * myShader = buildMaterialShader(positionMix);
+ * describe('A sphere with vertices that blend between two positions.');
+ * }
+ *
+ * function positionMix() {
+ * let time = uniformFloat();
+ *
+ * worldInputs.begin();
+ * // Blend vertex position between original and modified
+ * let originalPos = worldInputs.position;
+ * let modifiedPos = originalPos + vec3(0, sin(time * 0.001) * 20, 0);
+ * let factor = (sin(worldInputs.position.x * 0.1) + 1) / 2;
+ * worldInputs.position = mix(originalPos, modifiedPos, factor);
+ * worldInputs.end();
+ * }
+ *
+ * function draw() {
+ * background(220);
+ * shader(myShader);
+ * myShader.setUniform('time', millis());
+ * lights();
+ * noStroke();
+ * fill('red');
+ * sphere(70);
+ * }
+ *
+ *
+ *
+ * @example
+ *
+ *
+ * let myShader;
+ *
+ * function setup() {
+ * createCanvas(200, 200, WEBGL);
+ * myShader = buildMaterialShader(gradientMix);
+ * describe('A torus with a color gradient created using mix().');
+ * }
+ *
+ * function gradientMix() {
+ * pixelInputs.begin();
+ * // Create a gradient based on texture coordinates
+ * let gradient = pixelInputs.texCoord.x;
+ * let color1 = vec3(1, 0.5, 0); // Orange
+ * let color2 = vec3(0.5, 0, 1); // Purple
+ * let mixedColor = mix(color1, color2, gradient);
+ * pixelInputs.color = vec4(mixedColor, 1);
+ * // Set ambient color to match to avoid default ambient lighting
+ * pixelInputs.ambientColor = pixelInputs.color.rgb;
+ * pixelInputs.end();
+ * }
+ *
+ * function draw() {
+ * background(200);
+ * shader(myShader);
+ * lights();
+ * noStroke();
+ * rotateX(frameCount * 0.01);
+ * rotateY(frameCount * 0.01);
+ * torus(60, 20);
+ * }
+ *
+ *
+ */
diff --git a/src/strands/strands_api.js b/src/strands/strands_api.js
index 52001b3c99..8675dc69b6 100644
--- a/src/strands/strands_api.js
+++ b/src/strands/strands_api.js
@@ -10,11 +10,13 @@ import {
OpCode,
StatementType,
NodeType,
+ HOOK_PARAM_PREFIX,
// isNativeType
} from './ir_types'
import { strandsBuiltinFunctions } from './strands_builtins'
import { StrandsConditional } from './strands_conditionals'
import { StrandsFor } from './strands_for'
+import { buildTernary } from './strands_ternary'
import * as CFG from './ir_cfg'
import * as DAG from './ir_dag';
import * as FES from './strands_FES'
@@ -194,6 +196,10 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) {
return new StrandsFor(strandsContext, initialCb, conditionCb, updateCb, bodyCb, initialVars).build();
};
augmentFn(fn, p5, 'strandsFor', p5.strandsFor);
+ p5.strandsTernary = function(condition, ifTrue, ifFalse) {
+ return buildTernary(strandsContext, condition, ifTrue, ifFalse);
+ };
+ augmentFn(fn, p5, 'strandsTernary', p5.strandsTernary);
p5.strandsEarlyReturn = function(value) {
const { dag, cfg } = strandsContext;
@@ -214,7 +220,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) {
const nodeData = DAG.createNodeData({
nodeType: NodeType.STATEMENT,
statementType: StatementType.EARLY_RETURN,
- dependsOn: [valueNode.id]
+ dependsOn: value !== undefined ? [valueNode.id] : []
});
const earlyReturnID = DAG.getOrCreateNode(dag, nodeData);
CFG.recordInBasicBlock(cfg, cfg.currentBlock, earlyReturnID);
@@ -279,6 +285,33 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) {
}
}
+ // Alias lerp to GLSL mix in strands context
+ const originalLerp = fn.lerp;
+ augmentFn(fn, p5, 'lerp', function (...args) {
+ if (strandsContext.active) {
+ return fn.mix(...args);
+ } else {
+ return originalLerp.apply(this, args);
+ }
+ });
+
+ const originalMap = fn.map;
+ augmentFn(fn, p5, 'map', function (...args) {
+ if (!strandsContext.active) {
+ return originalMap.apply(this, args);
+ }
+ const [n, start1, stop1, start2, stop2, withinBounds] = args;
+ const nNode = p5.strandsNode(n);
+ const start1Node = p5.strandsNode(start1);
+ const stop1Node = p5.strandsNode(stop1);
+ const t = nNode.sub(start1Node).div(stop1Node.sub(start1Node));
+ const result = this.mix(start2, stop2, t);
+ if (withinBounds) {
+ return this.clamp(result, this.min(start2, stop2), this.max(start2, stop2));
+ }
+ return result;
+ });
+
augmentFn(fn, p5, 'getTexture', function (...rawArgs) {
if (strandsContext.active) {
const { id, dimension } = strandsContext.backend.createGetTextureCall(strandsContext, rawArgs);
@@ -303,6 +336,8 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) {
// Add noise function with backend-agnostic implementation
const originalNoise = fn.noise;
const originalNoiseDetail = fn.noiseDetail;
+ const originalRandom = fn.random;
+ const originalRandomSeed = fn.randomSeed;
const originalMillis = fn.millis;
strandsContext._noiseOctaves = null;
@@ -322,9 +357,10 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) {
return originalNoise.apply(this, args); // fallback to regular p5.js noise
}
// Get noise shader snippet from the current renderer
- const noiseSnippet = this._renderer.getNoiseShaderSnippet();
+ const noiseSnippet = strandsContext.backend.getNoiseShaderSnippet();
strandsContext.vertexDeclarations.add(noiseSnippet);
strandsContext.fragmentDeclarations.add(noiseSnippet);
+ strandsContext.computeDeclarations.add(noiseSnippet);
// Make each input into a strands node so that we can check their dimensions
const strandsArgs = args.flat().map(arg => p5.strandsNode(arg));
@@ -366,6 +402,84 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) {
return createStrandsNode(id, dimension, strandsContext);
});
+ strandsContext._randomSeed = null;
+
+ augmentFn(fn, p5, 'randomSeed', function (seed) {
+ if (!strandsContext.active) {
+ return originalRandomSeed.apply(this, arguments);
+ }
+ strandsContext._randomSeed = seed;
+ });
+
+ augmentFn(fn, p5, 'random', function (...args) {
+ if (!strandsContext.active) {
+ return originalRandom.apply(this, args);
+ }
+
+ const randomVertSnippet = strandsContext.backend.getRandomVertexShaderSnippet();
+ const randomFragSnippet = strandsContext.backend.getRandomFragmentShaderSnippet();
+
+ strandsContext.vertexDeclarations.add(randomVertSnippet);
+ strandsContext.fragmentDeclarations.add(randomFragSnippet);
+
+ if (strandsContext.backend.getRandomComputeShaderSnippet) {
+ const randomComputeSnippet = strandsContext.backend.getRandomComputeShaderSnippet();
+ strandsContext.computeDeclarations.add(randomComputeSnippet);
+ }
+
+ let seedNode;
+ if (strandsContext._randomSeed !== null && strandsContext._randomSeed.isStrandsNode) {
+ seedNode = strandsContext._randomSeed;
+ } else {
+ const userSeed = strandsContext._randomSeed;
+ seedNode = getOrCreateUniformNode(
+ strandsContext,
+ '_p5_randomSeed',
+ DataType.float1,
+ userSeed !== null
+ ? () => userSeed
+ : () => performance.now(),
+ );
+ }
+
+ // The shader-side random() owns a private per-invocation counter, so a
+ // single AST node still produces distinct values across runtime loop
+ // iterations. We just pass the seed.
+ const nodeArgs = [seedNode];
+ const randomOverloads = [{
+ params: [DataType.float1],
+ returnType: DataType.float1,
+ }];
+
+ if (args.length === 0) {
+ const { id, dimension } = build.functionCallNode(strandsContext, 'random', nodeArgs, {
+ overloads: randomOverloads,
+ });
+ return createStrandsNode(id, dimension, strandsContext);
+ } else if (args.length === 1) {
+ // random(max) β [0, max)
+ const rawNode = build.functionCallNode(strandsContext, 'random', nodeArgs, {
+ overloads: randomOverloads,
+ });
+ const rawStrandsNode = createStrandsNode(rawNode.id, rawNode.dimension, strandsContext);
+ return rawStrandsNode.mult(p5.strandsNode(args[0]));
+ } else if (args.length === 2) {
+ // random(min, max) β [min, max)
+ const rawNode = build.functionCallNode(strandsContext, 'random', nodeArgs, {
+ overloads: randomOverloads,
+ });
+ const rawStrandsNode = createStrandsNode(rawNode.id, rawNode.dimension, strandsContext);
+ const minNode = p5.strandsNode(args[0]);
+ const maxNode = p5.strandsNode(args[1]);
+ // min + raw * (max - min)
+ return rawStrandsNode.mult(maxNode.sub(minNode)).add(minNode);
+ } else {
+ p5._friendlyError(
+ `It looks like you've called random() with ${args.length} arguments. In strands, random() supports 0, 1, or 2 numeric arguments.`
+ );
+ }
+ });
+
augmentFn(fn, p5, 'millis', function (...args) {
if (!strandsContext.active) {
return originalMillis.apply(this, args);
@@ -477,6 +591,53 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) {
}
});
}
+
+ // Storage buffer uniform function for compute shaders
+ fn.uniformStorage = function(name, bufferOrSchema) {
+ let schema = null;
+ let defaultValue = null;
+
+ // If it's a function, evaluate it immediately to infer schema,
+ // then store the function so it gets called each frame.
+ let value = bufferOrSchema;
+ if (typeof bufferOrSchema === 'function') {
+ value = bufferOrSchema();
+ if (value?._schema) {
+ defaultValue = bufferOrSchema;
+ }
+ }
+
+ if (value?._schema) {
+ // Struct storage buffer with pre-computed schema
+ schema = value._schema;
+ if (defaultValue === null) defaultValue = value;
+ } else if (value && typeof value === 'object' && !value._isStorageBuffer) {
+ // Plain object schema template -- only used to infer struct layout, not as a default value
+ schema = strandsContext.renderer?._inferStructSchema(value) ?? null;
+ } else if (value?._isStorageBuffer) {
+ defaultValue = bufferOrSchema;
+ }
+
+ const { id, dimension } = build.variableNode(
+ strandsContext,
+ { baseType: 'storage', dimension: 1 },
+ name
+ );
+ strandsContext.uniforms.push({
+ name,
+ typeInfo: { baseType: 'storage', dimension: 1, schema },
+ defaultValue,
+ });
+
+ // Create StrandsNode with _originalIdentifier set (like varying variables)
+ // This enables proper assignment node creation and ordering preservation
+ const node = createStrandsNode(id, dimension, strandsContext);
+ node._originalIdentifier = name;
+ node._originalBaseType = 'storage';
+ node._originalDimension = 1;
+ node._schema = schema;
+ return node;
+ };
}
//////////////////////////////////////////////
// Per-Hook functions
@@ -487,7 +648,7 @@ function createHookArguments(strandsContext, parameters){
for (const param of parameters) {
if(isStructType(param.type)) {
const structTypeInfo = structType(param);
- const { id, dimension } = build.structInstanceNode(strandsContext, structTypeInfo, param.name, []);
+ const { id, dimension } = build.structInstanceNode(strandsContext, structTypeInfo, `${HOOK_PARAM_PREFIX}${param.name}`, []);
const structNode = createStrandsNode(id, dimension, strandsContext).withStructProperties(
structTypeInfo.properties.map(prop => prop.name)
);
@@ -500,7 +661,7 @@ function createHookArguments(strandsContext, parameters){
const oldDeps = dag.dependsOn[structNode.id];
const newDeps = oldDeps.slice();
newDeps[i] = newFieldID;
- const rebuilt = build.structInstanceNode(strandsContext, structTypeInfo, param.name, newDeps);
+ const rebuilt = build.structInstanceNode(strandsContext, structTypeInfo, `${HOOK_PARAM_PREFIX}${param.name}`, newDeps);
structNode.id = rebuilt.id;
};
// TODO: implement member access operations
@@ -521,7 +682,7 @@ function createHookArguments(strandsContext, parameters){
newValueID = newVal.id;
}
newDependsOn[i] = newValueID;
- const newStructInfo = build.structInstanceNode(strandsContext, structTypeInfo, param.name, newDependsOn);
+ const newStructInfo = build.structInstanceNode(strandsContext, structTypeInfo, `${HOOK_PARAM_PREFIX}${param.name}`, newDependsOn);
structNode.id = newStructInfo.id;
}
})
@@ -537,7 +698,7 @@ function createHookArguments(strandsContext, parameters){
throw new Error(`Missing dataType for parameter ${param.name} of type ${param.type.typeName}`);
}
const typeInfo = param.type.dataType;
- const { id, dimension } = build.variableNode(strandsContext, typeInfo, param.name);
+ const { id, dimension } = build.variableNode(strandsContext, typeInfo, `${HOOK_PARAM_PREFIX}${param.name}`);
const arg = createStrandsNode(id, dimension, strandsContext);
args.push(arg);
}
@@ -595,10 +756,14 @@ export function createShaderHooksFunctions(strandsContext, fn, shader) {
const fragmentHooksWithContext = Object.fromEntries(
Object.entries(shader.hooks.fragment).map(([name, hook]) => [name, { ...hook, shaderContext: 'fragment' }])
);
+ const computeHooksWithContext = Object.fromEntries(
+ Object.entries(shader.hooks.compute).map(([name, hook]) => [name, { ...hook, shaderContext: 'compute' }])
+ );
const availableHooks = {
...vertexHooksWithContext,
...fragmentHooksWithContext,
+ ...computeHooksWithContext,
}
const hookTypes = Object.keys(availableHooks).map(name => shader.hookTypes(name));
@@ -628,9 +793,11 @@ export function createShaderHooksFunctions(strandsContext, fn, shader) {
if (numStructArgs === 1) {
argIdx = hookType.parameters.findIndex(param => param.type.properties);
}
+ hook._properties = [];
for (let i = 0; i < args.length; i++) {
if (i === argIdx) {
for (const key of args[argIdx].structProperties || []) {
+ hook._properties.push(key);
Object.defineProperty(hook, key, {
get() {
return args[argIdx][key];
@@ -645,6 +812,7 @@ export function createShaderHooksFunctions(strandsContext, fn, shader) {
hook.set(args[argIdx]);
}
} else {
+ hook._properties.push(hookType.parameters[i].name);
hook[hookType.parameters[i].name] = args[i];
}
}
@@ -716,17 +884,21 @@ export function createShaderHooksFunctions(strandsContext, fn, shader) {
return newStruct.id;
}
}
+ else if (!expectedReturnType.dataType || expectedReturnType.typeName?.trim() === 'void') {
+ return null;
+ }
else /*if(isNativeType(expectedReturnType.typeName))*/ {
- if (!expectedReturnType.dataType) {
- throw new Error(`Missing dataType for return type ${expectedReturnType.typeName}`);
- }
const expectedTypeInfo = expectedReturnType.dataType;
return enforceReturnTypeMatch(strandsContext, expectedTypeInfo, retNode, hookType.name);
}
}
for (const { valueNode, earlyReturnID } of hook.earlyReturns) {
const id = handleRetVal(valueNode);
- dag.dependsOn[earlyReturnID] = [id];
+ if (id !== null) {
+ dag.dependsOn[earlyReturnID] = [id];
+ } else {
+ dag.dependsOn[earlyReturnID] = [];
+ }
}
rootNodeID = userReturned ? handleRetVal(userReturned) : undefined;
const fullHookName = `${hookType.returnType.typeName} ${hookType.name}`;
@@ -735,7 +907,7 @@ export function createShaderHooksFunctions(strandsContext, fn, shader) {
hookType,
entryBlockID,
rootNodeID,
- shaderContext: hookInfo?.shaderContext, // 'vertex' or 'fragment'
+ shaderContext: hookInfo?.shaderContext, // 'vertex', 'fragment', or 'compute'
});
CFG.popBlock(cfg);
};
diff --git a/src/strands/strands_codegen.js b/src/strands/strands_codegen.js
index 615b248e67..38e24c511e 100644
--- a/src/strands/strands_codegen.js
+++ b/src/strands/strands_codegen.js
@@ -7,18 +7,26 @@ export function generateShaderCode(strandsContext) {
cfg,
backend,
vertexDeclarations,
- fragmentDeclarations
+ fragmentDeclarations,
+ computeDeclarations
} = strandsContext;
const hooksObj = {
uniforms: {},
+ storageUniforms: {},
varyingVariables: [],
};
for (const {name, typeInfo, defaultValue} of strandsContext.uniforms) {
- const key = backend.generateHookUniformKey(name, typeInfo);
- if (key !== null) {
- hooksObj.uniforms[key] = defaultValue;
+ if (typeInfo.baseType === 'storage') {
+ if (defaultValue !== null && defaultValue !== undefined) {
+ hooksObj.storageUniforms[name] = defaultValue;
+ }
+ } else {
+ const key = backend.generateHookUniformKey(name, typeInfo);
+ if (key !== null) {
+ hooksObj.uniforms[key] = defaultValue;
+ }
}
}
@@ -27,6 +35,11 @@ export function generateShaderCode(strandsContext) {
backend.addTextureBindingsToDeclarations(strandsContext);
}
+ // Add storage buffer bindings to declarations for WebGPU backend
+ if (backend.addStorageBufferBindingsToDeclarations) {
+ backend.addStorageBufferBindingsToDeclarations(strandsContext);
+ }
+
for (const { hookType, rootNodeID, entryBlockID, shaderContext } of strandsContext.hooks) {
const generationContext = {
indent: 1,
@@ -51,14 +64,13 @@ export function generateShaderCode(strandsContext) {
let returnType;
if (hookType.returnType.properties) {
returnType = structType(hookType.returnType);
+ } else if (!hookType.returnType.dataType || hookType.returnType.typeName?.trim() === 'void') {
+ returnType = null;
} else {
- if (!hookType.returnType.dataType) {
- throw new Error(`Missing dataType for return type ${hookType.returnType.typeName}`);
- }
returnType = hookType.returnType.dataType;
}
- if (rootNodeID) {
+ if (rootNodeID !== undefined) {
backend.generateReturnStatement(strandsContext, generationContext, rootNodeID, returnType);
}
hooksObj[`${hookType.returnType.typeName} ${hookType.name}`] = [firstLine, ...generationContext.codeLines, '}'].join('\n');
@@ -81,8 +93,14 @@ export function generateShaderCode(strandsContext) {
}
}
+ // Register instanceID varying if used in a fragment hook
+ if (strandsContext._instanceIDUsedInFragment) {
+ hooksObj.instanceIDVarying = backend.generateInstanceIDVarying();
+ }
+
hooksObj.vertexDeclarations = [...vertexDeclarations].join('\n');
hooksObj.fragmentDeclarations = [...fragmentDeclarations].join('\n');
+ hooksObj.computeDeclarations = [...computeDeclarations].join('\n');
return hooksObj;
}
diff --git a/src/strands/strands_node.js b/src/strands/strands_node.js
index 9eb994f4c3..f7638855bc 100644
--- a/src/strands/strands_node.js
+++ b/src/strands/strands_node.js
@@ -1,4 +1,4 @@
-import { swizzleTrap, primitiveConstructorNode, variableNode } from './ir_builders';
+import { swizzleTrap, primitiveConstructorNode, variableNode, arrayAccessNode, arrayAssignmentNode, createStructArrayElementProxy } from './ir_builders';
import { BaseType, NodeType, OpCode } from './ir_types';
import { getNodeDataFromID, createNodeData, getOrCreateNode } from './ir_dag';
import { recordInBasicBlock } from './ir_cfg';
@@ -8,6 +8,9 @@ export class StrandsNode {
this.strandsContext = strandsContext;
this.dimension = dimension;
this.structProperties = null;
+ // Schema for struct storage buffers (set by uniformStorage when buffer has a struct layout).
+ // When set, buf.get(idx) returns a field proxy instead of a scalar StrandsNode.
+ this._schema = null;
this.isStrandsNode = true;
// Store original identifier for varying variables
@@ -161,6 +164,55 @@ export class StrandsNode {
return this;
}
+
+ get(index) {
+ const nodeData = getNodeDataFromID(this.strandsContext.dag, this.id);
+
+ // Validate baseType is 'storage'
+ // For struct storage buffers, return a proxy with per-field getters/setters
+ if (nodeData.baseType === 'storage' && this._schema) {
+ return createStructArrayElementProxy(this.strandsContext, this, index, this._schema);
+ }
+
+ // Create array access node for storage and non-storage (vector) access
+ const { id, dimension } = arrayAccessNode(
+ this.strandsContext,
+ this,
+ index,
+ 'read'
+ );
+ return createStrandsNode(id, dimension, this.strandsContext);
+ }
+
+ set(index, value) {
+ // Validate baseType is 'storage' and has _originalIdentifier
+ const nodeData = getNodeDataFromID(this.strandsContext.dag, this.id);
+ if (nodeData.baseType !== 'storage') {
+ throw new Error('set() can only be used on storage buffers');
+ }
+ if (!this._originalIdentifier) {
+ throw new Error('set() can only be used on storage buffers with an identifier');
+ }
+
+ // If value is a plain object (struct literal), expand to per-field assignments
+ // e.g. buf[idx] = { position: pos, velocity: vel }
+ // becomes buf[idx].position = pos; buf[idx].velocity = vel;
+ if (value !== null && typeof value === 'object' && !value.isStrandsNode && this._schema) {
+ const proxy = createStructArrayElementProxy(this.strandsContext, this, index, this._schema);
+ for (const [fieldName, fieldValue] of Object.entries(value)) {
+ proxy[fieldName] = fieldValue;
+ }
+ return this;
+ }
+
+ // Create array assignment node: buffer.set(index, value) -> buffer[index] = value
+ // This creates an ASSIGNMENT node and records it in the CFG basic block
+ // CFG preserves sequential order, preventing reordering of assignments
+ arrayAssignmentNode(this.strandsContext, this, index, value);
+
+ // Return this for chaining
+ return this;
+ }
}
export function createStrandsNode(id, dimension, strandsContext, onRebind) {
return new Proxy(
diff --git a/src/strands/strands_ternary.js b/src/strands/strands_ternary.js
new file mode 100644
index 0000000000..dcd84522ce
--- /dev/null
+++ b/src/strands/strands_ternary.js
@@ -0,0 +1,53 @@
+import * as DAG from './ir_dag';
+import * as CFG from './ir_cfg';
+import { NodeType, OpCode, BaseType } from './ir_types';
+import { createStrandsNode } from './strands_node';
+import * as FES from './strands_FES';
+
+export function buildTernary(strandsContext, condition, ifTrue, ifFalse) {
+ const { dag, cfg, p5 } = strandsContext;
+
+ // Ensure all inputs are StrandsNodes
+ const condNode = condition?.isStrandsNode ? condition : p5.strandsNode(condition);
+ const trueNode = ifTrue?.isStrandsNode ? ifTrue : p5.strandsNode(ifTrue);
+ const falseNode = ifFalse?.isStrandsNode ? ifFalse : p5.strandsNode(ifFalse);
+
+ // Get type info for both nodes
+ let trueType = DAG.extractNodeTypeInfo(dag, trueNode.id);
+ let falseType = DAG.extractNodeTypeInfo(dag, falseNode.id);
+
+ // Propagate type from the known branch to any ASSIGN_ON_USE branch
+ if (trueType.baseType === BaseType.ASSIGN_ON_USE && falseType.baseType !== BaseType.ASSIGN_ON_USE) {
+ DAG.propagateTypeToAssignOnUse(dag, trueNode.id, falseType.baseType, falseType.dimension);
+ trueType = DAG.extractNodeTypeInfo(dag, trueNode.id);
+ } else if (falseType.baseType === BaseType.ASSIGN_ON_USE && trueType.baseType !== BaseType.ASSIGN_ON_USE) {
+ DAG.propagateTypeToAssignOnUse(dag, falseNode.id, trueType.baseType, trueType.dimension);
+ falseType = DAG.extractNodeTypeInfo(dag, falseNode.id);
+ }
+
+ // After ASSIGN_ON_USE propagation, if both types are known, they must match
+ if (
+ trueType.baseType !== BaseType.ASSIGN_ON_USE &&
+ falseType.baseType !== BaseType.ASSIGN_ON_USE &&
+ (trueType.baseType !== falseType.baseType || trueType.dimension !== falseType.dimension)
+ ) {
+ FES.userError('type error',
+ 'The true and false branches of a ternary expression must have the same type. ' +
+ `Right now, the true branch is a ${trueType.baseType}${trueType.dimension}, and the false branch is a ${falseType.baseType}${falseType.dimension}.`
+ );
+ }
+
+ const resultType = trueType;
+
+ const nodeData = DAG.createNodeData({
+ nodeType: NodeType.OPERATION,
+ opCode: OpCode.Nary.TERNARY,
+ dependsOn: [condNode.id, trueNode.id, falseNode.id],
+ baseType: resultType.baseType,
+ dimension: resultType.dimension,
+ });
+
+ const id = DAG.getOrCreateNode(dag, nodeData);
+ CFG.recordInBasicBlock(cfg, cfg.currentBlock, id);
+ return createStrandsNode(id, resultType.dimension, strandsContext);
+}
diff --git a/src/strands/strands_transpiler.js b/src/strands/strands_transpiler.js
index 836a177c6c..ff3a4e2f88 100644
--- a/src/strands/strands_transpiler.js
+++ b/src/strands/strands_transpiler.js
@@ -2,6 +2,7 @@ import { parse } from 'acorn';
import { ancestor, recursive } from 'acorn-walk';
import escodegen from 'escodegen';
import { UnarySymbolToName } from './ir_types';
+import * as FES from './strands_FES';
let blockVarCounter = 0;
let loopVarCounter = 0;
function replaceBinaryOperator(codeSource) {
@@ -40,6 +41,50 @@ function nodeIsUniform(ancestor) {
);
}
+function nodeIsUniformCallbackFn(node, names) {
+ if (!names?.size) return false;
+ if (node.type === 'FunctionDeclaration' && names.has(node.id?.name)) return true;
+ if (
+ node.type === 'VariableDeclarator' && names.has(node.id?.name) &&
+ (node.init?.type === 'FunctionExpression' || node.init?.type === 'ArrowFunctionExpression')
+ ) {
+ return true;
+ }
+ return false;
+}
+
+function collectUniformCallbackNames(ast) {
+ // Sub-pass 1: collect all named function definitions
+ const namedFunctions = new Set();
+ ancestor(ast, {
+ FunctionDeclaration(node) {
+ if (node.id) namedFunctions.add(node.id.name);
+ },
+ VariableDeclarator(node) {
+ if (
+ node.id?.type === 'Identifier' &&
+ (node.init?.type === 'FunctionExpression' || node.init?.type === 'ArrowFunctionExpression')
+ ) {
+ namedFunctions.add(node.id.name);
+ }
+ }
+ });
+ // Sub-pass 2: find which of those names are passed as uniform call arguments
+ const names = new Set();
+ ancestor(ast, {
+ CallExpression(node) {
+ if (nodeIsUniform(node)) {
+ for (const arg of node.arguments) {
+ if (arg.type === 'Identifier' && namedFunctions.has(arg.name)) {
+ names.add(arg.name);
+ }
+ }
+ }
+ }
+ });
+ return names;
+}
+
function nodeIsVarying(node) {
return node && node.type === 'CallExpression'
&& (
@@ -54,7 +99,64 @@ function nodeIsVarying(node) {
)
);
}
+// Convert static member expressions into dotted paths such as
+// `loopProtect.protect` so loop-protection calls can be matched reliably.
+function getMemberExpressionPath(node) {
+ if (node?.type === 'Identifier') return node.name;
+
+ // Computed properties like `obj[prop]` are not safe to match as fixed paths.
+ if (node?.type !== 'MemberExpression' || node.computed) return null;
+
+ const objectPath = getMemberExpressionPath(node.object);
+ const propertyName = node.property?.name;
+
+ return objectPath && propertyName
+ ? `${objectPath}.${propertyName}`
+ : null;
+}
+
+// Detect calls added by loop protection before Strands tries to transpile them.
+function isLoopProtectionCall(node) {
+ if (node?.type !== 'CallExpression') return false;
+
+ const path = getMemberExpressionPath(node.callee);
+
+ if (!path) return false;
+
+ return (
+ path === 'loopProtect.protect' ||
+ path.endsWith('.loopProtect') ||
+ path.endsWith('.loopProtect.protect')
+ );
+}
+
+// Scan AST for loop-protection injection and throw with `// noprotect` hint.
+function throwIfLoopProtectionInserted(ast) {
+ let found = false;
+
+ ancestor(ast, {
+ CallExpression(node) {
+ if (isLoopProtectionCall(node)) {
+ found = true;
+ }
+ },
+ LogicalExpression(node) {
+ // Loop protection may appear as the right side of a short-circuit check.
+ if (
+ node.right?.type === 'CallExpression' &&
+ isLoopProtectionCall(node.right)
+ ) {
+ found = true;
+ }
+ }
+ });
+ if (found) {
+ FES.internalError(
+ 'loop protection error Loop protection code detected. Add `// noprotect` at the top of your sketch and run again.'
+ );
+ }
+}
// Helper function to check if a statement is a variable declaration with strands control flow init
function statementContainsStrandsControlFlow(stmt) {
// Check for variable declarations with strands control flow init
@@ -191,9 +293,134 @@ function replaceReferences(node, tempVarMap) {
internalReplaceReferences(node);
}
+function replaceIdentifierReferences(node, oldName, newName) {
+ if (!node || typeof node !== 'object') return node;
+
+ const replaceInNode = (n) => {
+ if (!n || typeof n !== 'object') return n;
+ if (n.type === 'Identifier' && n.name === oldName) {
+ return { ...n, name: newName };
+ }
+ const newNode = { ...n };
+ for (const key in n) {
+ if (n.hasOwnProperty(key) && key !== 'parent') {
+ if (Array.isArray(n[key])) {
+ newNode[key] = n[key].map(replaceInNode);
+ } else if (typeof n[key] === 'object') {
+ newNode[key] = replaceInNode(n[key]);
+ }
+ }
+ }
+ return newNode;
+ };
+
+ return replaceInNode(node);
+}
+
+// Shared handler for both BinaryExpression and LogicalExpression β
+// both follow the same operator-to-method-call transformation pattern.
+function transformBinaryOrLogical(node, state, ancestors) {
+ if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) {
+ return;
+ }
+ node.left = {
+ type: 'CallExpression',
+ callee: {
+ type: 'Identifier',
+ name: '__p5.strandsNode',
+ },
+ arguments: [node.left]
+ };
+ node.type = 'CallExpression';
+ node.callee = {
+ type: 'MemberExpression',
+ object: node.left,
+ property: {
+ type: 'Identifier',
+ name: replaceBinaryOperator(node.operator),
+ },
+ };
+ node.arguments = [node.right];
+}
+
+// Shared helper used by both IfStatement and ForStatement handlers.
+// Adds temp variable copies, replaces references, and appends a return
+// statement to a branch/loop function body.
+// sourcePrefix: the root identifier to read from ('vars' for loops,
+// null for if-branches where we read directly from the outer variable).
+function addCopyingAndReturn(functionBody, varsToReturn, sourcePrefix = null) {
+ if (functionBody.type !== 'BlockStatement') return;
+
+ const tempVarMap = new Map();
+ const copyStatements = [];
+
+ for (const varPath of varsToReturn) {
+ const parts = varPath.split('.');
+ const tempName = `__copy_${parts.join('_')}_${blockVarCounter++}`;
+ tempVarMap.set(varPath, tempName);
+
+ // If sourcePrefix is set (loop case), read from vars.x.y
+ // Otherwise (if-branch case), read directly from x.y
+ let sourceExpr = sourcePrefix
+ ? { type: 'Identifier', name: sourcePrefix }
+ : { type: 'Identifier', name: parts[0] };
+
+ const pathParts = sourcePrefix ? parts : parts.slice(1);
+ for (const part of pathParts) {
+ sourceExpr = {
+ type: 'MemberExpression',
+ object: sourceExpr,
+ property: { type: 'Identifier', name: part },
+ computed: false
+ };
+ }
+
+ copyStatements.push({
+ type: 'VariableDeclaration',
+ declarations: [{
+ type: 'VariableDeclarator',
+ id: { type: 'Identifier', name: tempName },
+ init: {
+ type: 'CallExpression',
+ callee: {
+ type: 'MemberExpression',
+ object: sourceExpr,
+ property: { type: 'Identifier', name: 'copy' },
+ computed: false
+ },
+ arguments: []
+ }
+ }],
+ kind: 'let'
+ });
+ }
+
+ functionBody.body.forEach(node => replaceReferences(node, tempVarMap));
+ functionBody.body.unshift(...copyStatements);
+
+ const returnObj = {
+ type: 'ObjectExpression',
+ properties: Array.from(varsToReturn).map(varPath => ({
+ type: 'Property',
+ key: { type: 'Literal', value: varPath },
+ value: { type: 'Identifier', name: tempVarMap.get(varPath) },
+ kind: 'init',
+ computed: false,
+ shorthand: false
+ }))
+ };
+
+ functionBody.body.push({
+ type: 'ReturnStatement',
+ argument: returnObj
+ });
+}
+
const ASTCallbacks = {
- UnaryExpression(node, _state, ancestors) {
- if (ancestors.some(nodeIsUniform)) { return; }
+ UnaryExpression(node, state, ancestors) {
+ if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) {
+ return;
+ }
const unaryFnName = UnarySymbolToName[node.operator];
const standardReplacement = (node) => {
node.type = 'CallExpression'
@@ -212,7 +439,7 @@ const ASTCallbacks = {
];
let isSwizzle = swizzleSets.some(set =>
[...property].every(char => set.includes(char))
- ) && node.argument.type === 'MemberExpression';
+ ) && node.argument.type === 'MemberExpression' && !node.argument.computed;
if (isSwizzle) {
node.type = 'MemberExpression';
node.object = {
@@ -236,8 +463,10 @@ const ASTCallbacks = {
delete node.argument;
delete node.operator;
},
- BreakStatement(node, _state, ancestors) {
- if (ancestors.some(nodeIsUniform)) { return; }
+ BreakStatement(node, state, ancestors) {
+ if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) {
+ return;
+ }
node.callee = {
type: 'Identifier',
name: '__p5.break'
@@ -245,8 +474,39 @@ const ASTCallbacks = {
node.arguments = [];
node.type = 'CallExpression';
},
- VariableDeclarator(node, _state, ancestors) {
- if (ancestors.some(nodeIsUniform)) { return; }
+ MemberExpression(node, state, ancestors) {
+ if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) {
+ return;
+ }
+ // Skip sets -- these will be converted to .set() method
+ // calls at the AssignmentExpression level
+ if (
+ ancestors.at(-2)?.type === 'AssignmentExpression' &&
+ ancestors.at(-2).left === node
+ ) {
+ return;
+ }
+ if (node.computed) {
+ const callee = node.object;
+ const member = node.property;
+ node.computed = undefined;
+ node.object = undefined;
+ node.callee = {
+ type: 'MemberExpression',
+ object: callee,
+ property: {
+ type: 'Identifier',
+ name: 'get',
+ }
+ };
+ node.arguments = [member];
+ node.type = 'CallExpression';
+ }
+ },
+ VariableDeclarator(node, state, ancestors) {
+ if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) {
+ return;
+ }
if (nodeIsUniform(node.init)) {
// Only inject the variable name if the first argument isn't already a string
if (node.init.arguments.length === 0 ||
@@ -271,16 +531,18 @@ const ASTCallbacks = {
value: node.id.name
}
node.init.arguments.unshift(varyingNameLiteral);
- _state.varyings[node.id.name] = varyingNameLiteral;
+ state.varyings[node.id.name] = varyingNameLiteral;
} else {
// Still track it as a varying even if name wasn't injected
- _state.varyings[node.id.name] = node.init.arguments[0];
+ state.varyings[node.id.name] = node.init.arguments[0];
}
}
},
- Identifier(node, _state, ancestors) {
- if (ancestors.some(nodeIsUniform)) { return; }
- if (_state.varyings[node.name]
+ Identifier(node, state, ancestors) {
+ if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) {
+ return;
+ }
+ if (state.varyings[node.name]
&& !ancestors.some(a => a.type === 'AssignmentExpression' && a.left === node)
) {
node.type = 'CallExpression';
@@ -300,8 +562,18 @@ const ASTCallbacks = {
},
// The callbacks for AssignmentExpression and BinaryExpression handle
// operator overloading including +=, *= assignment expressions
- ArrayExpression(node, _state, ancestors) {
- if (ancestors.some(nodeIsUniform)) { return; }
+ ArrayExpression(node, state, ancestors) {
+ if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) {
+ return;
+ }
+
+ if (node.elements.length < 2 || node.elements.length > 4) {
+ FES.userError(
+ 'type error',
+ `Array literals in shader functions are transpiled to vectors and must have 2-4 elements (got ${node.elements.length}).`
+ );
+ }
+
const original = JSON.parse(JSON.stringify(node));
node.type = 'CallExpression';
node.callee = {
@@ -310,8 +582,10 @@ const ASTCallbacks = {
};
node.arguments = [original];
},
- AssignmentExpression(node, _state, ancestors) {
- if (ancestors.some(nodeIsUniform)) { return; }
+ AssignmentExpression(node, state, ancestors) {
+ if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) {
+ return;
+ }
const unsafeTypes = ['Literal', 'ArrayExpression', 'Identifier'];
if (node.operator !== '=') {
const methodName = replaceBinaryOperator(node.operator.replace('=',''));
@@ -340,7 +614,7 @@ const ASTCallbacks = {
node.right = rightReplacementNode;
}
// Handle direct varying variable assignment: myVarying = value
- if (_state.varyings[node.left.name]) {
+ if (state.varyings[node.left.name]) {
node.type = 'ExpressionStatement';
node.expression = {
type: 'CallExpression',
@@ -361,10 +635,31 @@ const ASTCallbacks = {
// Handle swizzle assignment to varying variable: myVarying.xyz = value
// Note: node.left.object might be worldPos.getValue() due to prior Identifier transformation
else if (node.left.type === 'MemberExpression') {
+ if (node.left.computed) {
+ const source = node.left;
+ const value = node.right;
+ const callee = source.object;
+ const member = source.property;
+ node.right = undefined;
+ node.left = undefined;
+ node.operator = undefined;
+ node.callee = {
+ type: 'MemberExpression',
+ object: callee,
+ property: {
+ type: 'Identifier',
+ name: 'set'
+ }
+ };
+ node.arguments = [member, value];
+ node.type = 'CallExpression';
+ return;
+ }
+
let varyingName = null;
// Check if it's a direct identifier: myVarying.xyz
- if (node.left.object.type === 'Identifier' && _state.varyings[node.left.object.name]) {
+ if (node.left.object.type === 'Identifier' && state.varyings[node.left.object.name]) {
varyingName = node.left.object.name;
}
// Check if it's a getValue() call: myVarying.getValue().xyz
@@ -372,7 +667,7 @@ const ASTCallbacks = {
node.left.object.callee?.type === 'MemberExpression' &&
node.left.object.callee.property?.name === 'getValue' &&
node.left.object.callee.object?.type === 'Identifier' &&
- _state.varyings[node.left.object.callee.object.name]) {
+ state.varyings[node.left.object.callee.object.name]) {
varyingName = node.left.object.callee.object.name;
}
@@ -403,70 +698,30 @@ const ASTCallbacks = {
}
}
},
- BinaryExpression(node, _state, ancestors) {
- // Don't convert uniform default values to node methods, as
- // they should be evaluated at runtime, not compiled.
- if (ancestors.some(nodeIsUniform)) { return; }
- // If the left hand side of an expression is one of these types,
- // we should construct a node from it.
- const unsafeTypes = ['Literal', 'ArrayExpression', 'Identifier'];
- if (unsafeTypes.includes(node.left.type)) {
- const leftReplacementNode = {
- type: 'CallExpression',
- callee: {
- type: 'Identifier',
- name: '__p5.strandsNode',
- },
- arguments: [node.left]
- }
- node.left = leftReplacementNode;
+ BinaryExpression: transformBinaryOrLogical,
+ LogicalExpression: transformBinaryOrLogical,
+
+
+ ConditionalExpression(node, state, ancestors) {
+ if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) {
+ return;
}
- // Replace the binary operator with a call expression
- // in other words a call to BaseNode.mult(), .div() etc.
+ // Transform condition ? consequent : alternate
+ // into __p5.strandsTernary(condition, consequent, alternate)
+ const test = node.test;
+ const consequent = node.consequent;
+ const alternate = node.alternate;
node.type = 'CallExpression';
- node.callee = {
- type: 'MemberExpression',
- object: node.left,
- property: {
- type: 'Identifier',
- name: replaceBinaryOperator(node.operator),
- },
- };
- node.arguments = [node.right];
+ node.callee = { type: 'Identifier', name: '__p5.strandsTernary' };
+ node.arguments = [test, consequent, alternate];
+ delete node.test;
+ delete node.consequent;
+ delete node.alternate;
},
- LogicalExpression(node, _state, ancestors) {
- // Don't convert uniform default values to node methods, as
- // they should be evaluated at runtime, not compiled.
- if (ancestors.some(nodeIsUniform)) { return; }
- // If the left hand side of an expression is one of these types,
- // we should construct a node from it.
- const unsafeTypes = ['Literal', 'ArrayExpression', 'Identifier'];
- if (unsafeTypes.includes(node.left.type)) {
- const leftReplacementNode = {
- type: 'CallExpression',
- callee: {
- type: 'Identifier',
- name: '__p5.strandsNode',
- },
- arguments: [node.left]
- }
- node.left = leftReplacementNode;
+ IfStatement(node, state, ancestors) {
+ if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) {
+ return;
}
- // Replace the logical operator with a call expression
- // in other words a call to BaseNode.or(), .and() etc.
- node.type = 'CallExpression';
- node.callee = {
- type: 'MemberExpression',
- object: node.left,
- property: {
- type: 'Identifier',
- name: replaceBinaryOperator(node.operator),
- },
- };
- node.arguments = [node.right];
- },
- IfStatement(node, _state, ancestors) {
- if (ancestors.some(nodeIsUniform)) { return; }
// Transform if statement into strandsIf() call
// The condition is evaluated directly, not wrapped in a function
const condition = node.test;
@@ -570,70 +825,6 @@ const ASTCallbacks = {
analyzeBranch(thenFunction.body);
analyzeBranch(elseFunction.body);
if (assignedVars.size > 0) {
- // Add copying, reference replacement, and return statements to branch functions
- const addCopyingAndReturn = (functionBody, varsToReturn) => {
- if (functionBody.type === 'BlockStatement') {
- // Create temporary variables and copy statements
- const tempVarMap = new Map(); // property path -> temp name
- const copyStatements = [];
- for (const varPath of varsToReturn) {
- const parts = varPath.split('.');
- const tempName = `__copy_${parts.join('_')}_${blockVarCounter++}`;
- tempVarMap.set(varPath, tempName);
-
- // Build the member expression for the property path
- let sourceExpr = { type: 'Identifier', name: parts[0] };
- for (let i = 1; i < parts.length; i++) {
- sourceExpr = {
- type: 'MemberExpression',
- object: sourceExpr,
- property: { type: 'Identifier', name: parts[i] },
- computed: false
- };
- }
-
- // let tempName = propertyPath.copy()
- copyStatements.push({
- type: 'VariableDeclaration',
- declarations: [{
- type: 'VariableDeclarator',
- id: { type: 'Identifier', name: tempName },
- init: {
- type: 'CallExpression',
- callee: {
- type: 'MemberExpression',
- object: sourceExpr,
- property: { type: 'Identifier', name: 'copy' },
- computed: false
- },
- arguments: []
- }
- }],
- kind: 'let'
- });
- }
- // Apply reference replacement to all statements
- functionBody.body.forEach(node => replaceReferences(node, tempVarMap));
- // Insert copy statements at the beginning
- functionBody.body.unshift(...copyStatements);
- // Add return statement with flat object using property paths as keys
- const returnObj = {
- type: 'ObjectExpression',
- properties: Array.from(varsToReturn).map(varPath => ({
- type: 'Property',
- key: { type: 'Literal', value: varPath },
- value: { type: 'Identifier', name: tempVarMap.get(varPath) },
- kind: 'init',
- computed: false,
- shorthand: false
- }))
- };
- functionBody.body.push({
- type: 'ReturnStatement',
- argument: returnObj
- });
- }
- };
addCopyingAndReturn(thenFunction.body, assignedVars);
addCopyingAndReturn(elseFunction.body, assignedVars);
// Create a block variable to capture the return value
@@ -734,8 +925,10 @@ const ASTCallbacks = {
delete node.consequent;
delete node.alternate;
},
- UpdateExpression(node, _state, ancestors) {
- if (ancestors.some(nodeIsUniform)) { return; }
+ UpdateExpression(node, state, ancestors) {
+ if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) {
+ return;
+ }
// Transform ++var, var++, --var, var-- into assignment expressions
let operator;
@@ -766,11 +959,13 @@ const ASTCallbacks = {
// Replace the update expression with the assignment expression
Object.assign(node, assignmentExpr);
delete node.prefix;
- this.BinaryExpression(node.right, _state, [...ancestors, node]);
- this.AssignmentExpression(node, _state, ancestors);
+ ASTCallbacks.BinaryExpression(node.right, state, [...ancestors, node]);
+ ASTCallbacks.AssignmentExpression(node, state, ancestors);
},
- ForStatement(node, _state, ancestors) {
- if (ancestors.some(nodeIsUniform)) { return; }
+ ForStatement(node, state, ancestors) {
+ if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) {
+ return;
+ }
// Transform for statement into strandsFor() call
// for (init; test; update) body -> strandsFor(initCb, conditionCb, updateCb, bodyCb, initialVars)
@@ -822,7 +1017,7 @@ const ASTCallbacks = {
// Replace loop variable references with the parameter
if (node.init?.type === 'VariableDeclaration') {
const loopVarName = node.init.declarations[0].id.name;
- conditionBody = this.replaceIdentifierReferences(conditionBody, loopVarName, uniqueLoopVar);
+ conditionBody = replaceIdentifierReferences(conditionBody, loopVarName, uniqueLoopVar);
}
const conditionAst = { type: 'Program', body: [{ type: 'ExpressionStatement', expression: conditionBody }] };
conditionBody = conditionAst.body[0].expression;
@@ -840,7 +1035,7 @@ const ASTCallbacks = {
// Replace loop variable references with the parameter
if (node.init?.type === 'VariableDeclaration') {
const loopVarName = node.init.declarations[0].id.name;
- updateExpr = this.replaceIdentifierReferences(updateExpr, loopVarName, uniqueLoopVar);
+ updateExpr = replaceIdentifierReferences(updateExpr, loopVarName, uniqueLoopVar);
}
const updateAst = { type: 'Program', body: [{ type: 'ExpressionStatement', expression: updateExpr }] };
updateExpr = updateAst.body[0].expression;
@@ -879,7 +1074,7 @@ const ASTCallbacks = {
// Replace loop variable references in the body
if (node.init?.type === 'VariableDeclaration') {
const loopVarName = node.init.declarations[0].id.name;
- bodyBlock = this.replaceIdentifierReferences(bodyBlock, loopVarName, uniqueLoopVar);
+ bodyBlock = replaceIdentifierReferences(bodyBlock, loopVarName, uniqueLoopVar);
}
const bodyFunction = {
@@ -934,73 +1129,8 @@ const ASTCallbacks = {
});
if (assignedVars.size > 0) {
- // Add copying, reference replacement, and return statements similar to if statements
- const addCopyingAndReturn = (functionBody, varsToReturn) => {
- if (functionBody.type === 'BlockStatement') {
- const tempVarMap = new Map();
- const copyStatements = [];
-
- for (const varPath of varsToReturn) {
- const parts = varPath.split('.');
- const tempName = `__copy_${parts.join('_')}_${blockVarCounter++}`;
- tempVarMap.set(varPath, tempName);
-
- // Build the member expression for vars.propertyPath
- // e.g., vars.inputs.color or vars.x
- let sourceExpr = { type: 'Identifier', name: 'vars' };
- for (const part of parts) {
- sourceExpr = {
- type: 'MemberExpression',
- object: sourceExpr,
- property: { type: 'Identifier', name: part },
- computed: false
- };
- }
- copyStatements.push({
- type: 'VariableDeclaration',
- declarations: [{
- type: 'VariableDeclarator',
- id: { type: 'Identifier', name: tempName },
- init: {
- type: 'CallExpression',
- callee: {
- type: 'MemberExpression',
- object: sourceExpr,
- property: { type: 'Identifier', name: 'copy' },
- computed: false
- },
- arguments: []
- }
- }],
- kind: 'let'
- });
- }
-
- functionBody.body.forEach(node => replaceReferences(node, tempVarMap));
- functionBody.body.unshift(...copyStatements);
-
- // Add return statement with flat object using property paths as keys
- const returnObj = {
- type: 'ObjectExpression',
- properties: Array.from(varsToReturn).map(varPath => ({
- type: 'Property',
- key: { type: 'Literal', value: varPath },
- value: { type: 'Identifier', name: tempVarMap.get(varPath) },
- kind: 'init',
- computed: false,
- shorthand: false
- }))
- };
-
- functionBody.body.push({
- type: 'ReturnStatement',
- argument: returnObj
- });
- }
- };
-
- addCopyingAndReturn(bodyFunction.body, assignedVars);
+ addCopyingAndReturn(bodyFunction.body, assignedVars, 'vars');
// Create block variable and assignments similar to if statements
const blockVar = `__block_${blockVarCounter++}`;
@@ -1115,33 +1245,8 @@ const ASTCallbacks = {
delete node.update;
},
- // Helper method to replace identifier references in AST nodes
- replaceIdentifierReferences(node, oldName, newName) {
- if (!node || typeof node !== 'object') return node;
-
- const replaceInNode = (n) => {
- if (!n || typeof n !== 'object') return n;
-
- if (n.type === 'Identifier' && n.name === oldName) {
- return { ...n, name: newName };
- }
- // Create a copy and recursively process properties
- const newNode = { ...n };
- for (const key in n) {
- if (n.hasOwnProperty(key) && key !== 'parent') {
- if (Array.isArray(n[key])) {
- newNode[key] = n[key].map(replaceInNode);
- } else if (typeof n[key] === 'object') {
- newNode[key] = replaceInNode(n[key]);
- }
- }
- }
- return newNode;
- };
- return replaceInNode(node);
- }
}
// Helper function to check if a function body contains return statements in control flow
@@ -1476,22 +1581,31 @@ function transformFunctionSetCalls(functionNode) {
}
// Main transformation pass: find and transform functions with .set() calls in control flow
-function transformSetCallsInControlFlow(ast) {
+function transformSetCallsInControlFlow(ast, names) {
const functionsToTransform = [];
// Collect functions that have .set() calls in control flow
const collectFunctions = {
ArrowFunctionExpression(node, ancestors) {
+ if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, names))) {
+ return;
+ }
if (functionHasSetInControlFlow(node)) {
functionsToTransform.push(node);
}
},
FunctionExpression(node, ancestors) {
+ if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, names))) {
+ return;
+ }
if (functionHasSetInControlFlow(node)) {
functionsToTransform.push(node);
}
},
FunctionDeclaration(node, ancestors) {
+ if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, names))) {
+ return;
+ }
if (functionHasSetInControlFlow(node)) {
functionsToTransform.push(node);
}
@@ -1507,12 +1621,15 @@ function transformSetCallsInControlFlow(ast) {
}
// Main transformation pass: find and transform helper functions with early returns
-function transformHelperFunctionEarlyReturns(ast) {
+function transformHelperFunctionEarlyReturns(ast, names) {
const helperFunctionsToTransform = [];
// Collect helper functions that need transformation
const collectHelperFunctions = {
VariableDeclarator(node, ancestors) {
+ if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, names))) {
+ return;
+ }
const init = node.init;
if (init && (init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression')) {
if (functionHasEarlyReturns(init)) {
@@ -1521,6 +1638,9 @@ function transformHelperFunctionEarlyReturns(ast) {
}
},
FunctionDeclaration(node, ancestors) {
+ if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, names))) {
+ return;
+ }
if (functionHasEarlyReturns(node)) {
helperFunctionsToTransform.push(node);
}
@@ -1540,67 +1660,106 @@ function transformHelperFunctionEarlyReturns(ast) {
}
}
-export function transpileStrandsToJS(p5, sourceString, srcLocations, scope) {
- // Reset counters at the start of each transpilation
- blockVarCounter = 0;
- loopVarCounter = 0;
-
- const ast = parse(sourceString, {
- ecmaVersion: 2021,
- locations: srcLocations
- });
-
- // First pass: transform .set() calls in control flow to use intermediate variables
- transformSetCallsInControlFlow(ast);
+/**
+ * Transpiles a p5.strands callback into executable JavaScript by applying
+ * a multi-pass AST transformation pipeline.
+ *
+ * Pipeline stages:
+ *
+ * 1. Collect uniform callback names
+ * - Identifies functions passed into uniform() so they are excluded from transformation
+ *
+ * 2. transformSetCallsInControlFlow
+ * - Rewrites `.set()` calls inside control flow into intermediate variable assignments
+ *
+ * 3. Non-control-flow transformations
+ * - Applies ASTCallbacks to transform expressions, assignments, etc.
+ * - Skips IfStatement and ForStatement (handled later)
+ *
+ * 4. transformHelperFunctionEarlyReturns
+ * - Converts early returns in helper functions into a single return value pattern
+ *
+ * 5. Control flow transformation (post-order)
+ * - Transforms IfStatement β __p5.strandsIf
+ * - Transforms ForStatement β __p5.strandsFor
+ * - Handles variable propagation across branches/loops
+ *
+ * This staged approach ensures correct ordering and avoids transformation conflicts.
+ */
+
+// Wraps each callback with a uniform context guard, eliminating the need
+// to repeat the early-return check at the top of every handler.
+function makeGuardedCallbacks(callbacks) {
+ const guarded = {};
+ for (const [name, fn] of Object.entries(callbacks)) {
+ guarded[name] = (node, state, ancestors) => {
+ if (ancestors.some(a =>
+ nodeIsUniform(a) ||
+ nodeIsUniformCallbackFn(a, state.uniformCallbackNames)
+ )) return;
+ return fn(node, state, ancestors);
+ };
+ }
+ return guarded;
+}
- // Second pass: transform everything except if/for statements using normal ancestor traversal
- const nonControlFlowCallbacks = { ...ASTCallbacks };
+function runNonControlFlowPass(ast, uniformCallbackNames) {
+ const nonControlFlowCallbacks = ({ ...ASTCallbacks });
delete nonControlFlowCallbacks.IfStatement;
delete nonControlFlowCallbacks.ForStatement;
- ancestor(ast, nonControlFlowCallbacks, undefined, { varyings: {} });
-
- // Third pass: transform helper functions with early returns to use __returnValue pattern
- transformHelperFunctionEarlyReturns(ast);
+ ancestor(ast, nonControlFlowCallbacks, undefined, { varyings: {}, uniformCallbackNames });
+}
- // Fourth pass: transform if/for statements in post-order using recursive traversal
+function runControlFlowPass(ast, uniformCallbackNames) {
const postOrderControlFlowTransform = {
+ CallExpression(node, state, c) {
+ if (nodeIsUniform(node)) { return; }
+ if (node.callee) c(node.callee, state);
+ for (const arg of node.arguments) c(arg, state);
+ },
+ FunctionDeclaration(node, state, c) {
+ if (state.uniformCallbackNames?.has(node.id?.name)) return;
+ if (node.body) c(node.body, state);
+ },
+ VariableDeclarator(node, state, c) {
+ if (
+ state.uniformCallbackNames?.has(node.id?.name) &&
+ (node.init?.type === 'FunctionExpression' || node.init?.type === 'ArrowFunctionExpression')
+ ) { return; }
+ if (node.init) c(node.init, state);
+ },
IfStatement(node, state, c) {
state.inControlFlow++;
- // First recursively process children
if (node.test) c(node.test, state);
if (node.consequent) c(node.consequent, state);
if (node.alternate) c(node.alternate, state);
- // Then apply the transformation to this node
ASTCallbacks.IfStatement(node, state, []);
state.inControlFlow--;
},
ForStatement(node, state, c) {
state.inControlFlow++;
- // First recursively process children
if (node.init) c(node.init, state);
if (node.test) c(node.test, state);
if (node.update) c(node.update, state);
if (node.body) c(node.body, state);
- // Then apply the transformation to this node
ASTCallbacks.ForStatement(node, state, []);
state.inControlFlow--;
},
ReturnStatement(node, state, c) {
if (!state.inControlFlow) return;
- // Convert return statement to strandsEarlyReturn call
node.type = 'ExpressionStatement';
node.expression = {
type: 'CallExpression',
- callee: {
- type: 'Identifier',
- name: '__p5.strandsEarlyReturn'
- },
+ callee: { type: 'Identifier', name: '__p5.strandsEarlyReturn' },
arguments: node.argument ? [node.argument] : []
};
delete node.argument;
}
};
- recursive(ast, { varyings: {}, inControlFlow: 0 }, postOrderControlFlowTransform);
+ recursive(ast, { varyings: {}, inControlFlow: 0, uniformCallbackNames }, postOrderControlFlowTransform);
+}
+
+function buildStrandsCallback(p5, ast, scope) {
const transpiledSource = escodegen.generate(ast);
const scopeKeys = Object.keys(scope);
const match = /\(?\s*(?:function)?\s*\w*\s*\(([^)]*)\)\s*(?:=>)?\s*{((?:.|\n)*)}\s*;?\s*\)?/
@@ -1620,15 +1779,11 @@ export function transpileStrandsToJS(p5, sourceString, srcLocations, scope) {
}
const body = match[2];
try {
- const internalStrandsCallback = new Function(
- // Create a parameter called __p5, not just p5, because users of instance mode
- // may pass in a variable called p5 as a scope variable. If we rely on a variable called
- // p5, then the scope variable called p5 might accidentally override internal function
- // calls to p5 static methods.
- '__p5',
- ...paramNames,
- body,
- );
+ const internalStrandsCallback = new Function('__p5', ...paramNames, body);
+ // Create a parameter called __p5, not just p5, because users of instance mode
+ // may pass in a variable called p5 as a scope variable. If we rely on a variable called
+ // p5, then the scope variable called p5 might accidentally override internal function
+ // calls to p5 static methods.
return () => internalStrandsCallback(p5, ...paramVals);
} catch (e) {
console.error(e);
@@ -1637,3 +1792,31 @@ export function transpileStrandsToJS(p5, sourceString, srcLocations, scope) {
throw new Error('Error transpiling p5.strands callback!');
}
}
+
+
+
+export function transpileStrandsToJS(p5, sourceString, srcLocations, scope) {
+ blockVarCounter = 0;
+ loopVarCounter = 0;
+
+ const ast = parse(sourceString, { ecmaVersion: 2021, locations: srcLocations });
+
+ throwIfLoopProtectionInserted(ast);
+
+ // Pre-pass: collect names of functions passed by reference as uniform callbacks
+ const uniformCallbackNames = collectUniformCallbackNames(ast);
+
+ // Pass 1: transform .set() calls in control flow to use intermediate variables
+ transformSetCallsInControlFlow(ast, uniformCallbackNames);
+
+ // Pass 2: transform non-control-flow nodes (operators, varyings, uniforms, arrays)
+ runNonControlFlowPass(ast, uniformCallbackNames);
+
+ // Pass 3: transform helper functions with early returns to use __returnValue pattern
+ transformHelperFunctionEarlyReturns(ast, uniformCallbackNames);
+
+ // Pass 4: transform if/for statements post-order into strandsIf/strandsFor calls
+ runControlFlowPass(ast, uniformCallbackNames);
+
+ return buildStrandsCallback(p5, ast, scope);
+}
diff --git a/src/type/lib/Typr.js b/src/type/lib/Typr.js
index e81fcb58f1..ec7e94a5ec 100644
--- a/src/type/lib/Typr.js
+++ b/src/type/lib/Typr.js
@@ -323,7 +323,7 @@ Typr["B"] = {
}
return s;
},
- _tdec: window["TextDecoder"] ? new window["TextDecoder"]() : null,
+ _tdec: globalThis["TextDecoder"] ? new globalThis["TextDecoder"]() : null,
readUTF8: function (buff, p, l) {
var tdec = Typr["B"]._tdec;
if (tdec && p == 0 && l == buff.length) return tdec["decode"](buff);
diff --git a/src/type/textCore.js b/src/type/textCore.js
index 345267fa4c..b978fdee12 100644
--- a/src/type/textCore.js
+++ b/src/type/textCore.js
@@ -1,6 +1,5 @@
/**
* @module Typography
- * @requires core
*/
import { Renderer } from '../core/p5.Renderer';
@@ -1459,22 +1458,37 @@ function textCore(p5, fn) {
Renderer.prototype.textAlign = function (h, v) {
- // the setter
- if (typeof h !== 'undefined') {
+ if (arguments.length === 0) { // the getter
+ return {
+ horizontal: this.states.textAlign,
+ vertical: this.states.textBaseline
+ };
+ }
+
+ // allow an object with horizontal and vertical properties
+ if (typeof h === 'object' && h !== null) {
+ if (h.hasOwnProperty('vertical')) {
+ v = h.vertical;
+ }
+ if (h.hasOwnProperty('horizontal')) {
+ h = h.horizontal;
+ }
+ }
+
+ // horizontal value as separate argument
+ if (typeof h === 'string' || h instanceof String) {
this.states.setValue('textAlign', h);
- if (typeof v !== 'undefined') {
- if (v === fn.CENTER) {
- v = textCoreConstants._CTX_MIDDLE;
- }
- this.states.setValue('textBaseline', v);
+ }
+
+ // vertical value as separate argument
+ if (typeof v === 'string' || v instanceof String) {
+ if (v === fn.CENTER) {
+ v = textCoreConstants._CTX_MIDDLE;
}
- return this._applyTextProperties();
+ this.states.setValue('textBaseline', v);
}
- // the getter
- return {
- horizontal: this.states.textAlign,
- vertical: this.states.textBaseline
- };
+
+ return this._applyTextProperties();
};
Renderer.prototype._currentTextFont = function () {
@@ -1573,13 +1587,6 @@ function textCore(p5, fn) {
if (typeof weight === 'number') {
this.states.setValue('fontWeight', weight);
this._applyTextProperties();
-
- // Safari works without weight set in the canvas style attribute, and actually
- // has buggy behavior if it is present, using the wrong weight when drawing
- // multiple times with different weights
- if (!p5.prototype._isSafari()) {
- this._setCanvasStyleProperty('font-variation-settings', `"wght" ${weight}`);
- }
return;
}
// the getter
diff --git a/src/utilities/conversion.js b/src/utilities/conversion.js
index 7ebe26ba8f..5727735156 100644
--- a/src/utilities/conversion.js
+++ b/src/utilities/conversion.js
@@ -2,7 +2,6 @@
* @module Data
* @submodule Conversion
* @for p5
- * @requires core
*/
function conversion(p5, fn){
diff --git a/src/utilities/time_date.js b/src/utilities/time_date.js
index ab240ee058..5b0b4c154b 100644
--- a/src/utilities/time_date.js
+++ b/src/utilities/time_date.js
@@ -2,7 +2,6 @@
* @module IO
* @submodule Time & Date
* @for p5
- * @requires core
*/
function timeDate(p5, fn){
@@ -109,10 +108,8 @@ function timeDate(p5, fn){
* sketch includes asynchronous loading using `async`/`await`, then
* `millis()` begins tracking time as soon as the asynchronous code
* starts running.
- * @method millis
- * @return {Number} number of milliseconds since starting the sketch.
*
- * @example
+ * ```js example
* function setup() {
* createCanvas(100, 100);
*
@@ -133,8 +130,9 @@ function timeDate(p5, fn){
* `The text 'Startup time: ${round(ms, 2)} ms' written in black on a gray background.`
* );
* }
+ * ```
*
- * @example
+ * ```js example
* function setup() {
* createCanvas(100, 100);
*
@@ -155,8 +153,9 @@ function timeDate(p5, fn){
* // Display how long the sketch has run.
* text(`Running time: ${nf(s, 1, 1)} sec`, 5, 50, 90);
* }
+ * ```
*
- * @example
+ * ```js example
* function setup() {
* createCanvas(100, 100);
*
@@ -175,8 +174,9 @@ function timeDate(p5, fn){
* // Draw the circle.
* circle(x, 50, 30);
* }
+ * ```
*
- * @example
+ * ```js example
* async function setup() {
* // Load the GeoJSON.
* await loadJSON('https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson');
@@ -199,6 +199,40 @@ function timeDate(p5, fn){
* `The text "It took ${round(ms, 2)} ms to load the data" written in black on a gray background.`
* );
* }
+ * ```
+ *
+ * `millis()` can also be used in shaders with p5.strands. The following example
+ * uses `millis()` to create time-based color transitions on a shape.
+ *
+ * ```js example
+ * let myShader;
+ *
+ * function setup() {
+ * createCanvas(100, 100, WEBGL);
+ * myShader = buildColorShader(shaderCallback);
+ * describe('A sphere whose color shifts over time.');
+ * }
+ *
+ * function shaderCallback() {
+ * let t = millis() * 0.001;
+ * let value = 0.5 + 0.5 * sin(t);
+ * let skyBlue = [0.2, 0.6, 0.8, 1];
+ * let magenta = [0.8, 0.2, 0.6, 1];
+ * finalColor.begin();
+ * finalColor.set(mix(skyBlue, magenta, value));
+ * finalColor.end();
+ * }
+ *
+ * function draw() {
+ * background(220);
+ * shader(myShader);
+ * noStroke();
+ * sphere(30);
+ * }
+ * ```
+ *
+ * @method millis
+ * @return {Number} number of milliseconds since starting the sketch.
*/
fn.millis = function() {
if (this._millisStart === -1) {
diff --git a/src/utilities/utility_functions.js b/src/utilities/utility_functions.js
index 4929522642..11964db774 100644
--- a/src/utilities/utility_functions.js
+++ b/src/utilities/utility_functions.js
@@ -2,7 +2,6 @@
* @module Data
* @submodule Utility Functions
* @for p5
- * @requires core
*/
function utilityFunctions(p5, fn){
diff --git a/src/webgl/3d_primitives.js b/src/webgl/3d_primitives.js
index cd6cccc893..0d10bfdf4b 100644
--- a/src/webgl/3d_primitives.js
+++ b/src/webgl/3d_primitives.js
@@ -2,8 +2,6 @@
* @module Shape
* @submodule 3D Primitives
* @for p5
- * @requires core
- * @requires p5.Geometry
*/
import * as constants from '../core/constants';
@@ -1794,33 +1792,38 @@ function primitives3D(p5, fn){
const prevMode = this.states.textureMode;
this.states.setValue('textureMode', constants.NORMAL);
const prevOrder = this.bezierOrder();
- this.bezierOrder(2);
+ this.bezierOrder(3);
this.beginShape();
const addUVs = (x, y) => [x, y, 0, (x - x1)/width, (y - y1)/height];
+ const rr = 0.5523; // kappa: 4*(sqrt(2)-1)/3, handle ratio for cubic bezier circle approximation
if (tr !== 0) {
this.vertex(...addUVs(x2 - tr, y1));
- this.bezierVertex(...addUVs(x2, y1));
+ this.bezierVertex(...addUVs(x2 - tr + tr * rr, y1));
+ this.bezierVertex(...addUVs(x2, y1 + tr - tr * rr));
this.bezierVertex(...addUVs(x2, y1 + tr));
} else {
this.vertex(...addUVs(x2, y1));
}
if (br !== 0) {
this.vertex(...addUVs(x2, y2 - br));
- this.bezierVertex(...addUVs(x2, y2));
+ this.bezierVertex(...addUVs(x2, y2 - br + br * rr));
+ this.bezierVertex(...addUVs(x2 - br + rr * br, y2));
this.bezierVertex(...addUVs(x2 - br, y2));
} else {
this.vertex(...addUVs(x2, y2));
}
if (bl !== 0) {
this.vertex(...addUVs(x1 + bl, y2));
- this.bezierVertex(...addUVs(x1, y2));
+ this.bezierVertex(...addUVs(x1 + bl - bl * rr, y2));
+ this.bezierVertex(...addUVs(x1, y2 - bl + bl * rr));
this.bezierVertex(...addUVs(x1, y2 - bl));
} else {
this.vertex(...addUVs(x1, y2));
}
if (tl !== 0) {
this.vertex(...addUVs(x1, y1 + tl));
- this.bezierVertex(...addUVs(x1, y1));
+ this.bezierVertex(...addUVs(x1, y1 + tl - tl * rr));
+ this.bezierVertex(...addUVs(x1 + tl - tl * rr, y1));
this.bezierVertex(...addUVs(x1 + tl, y1));
} else {
this.vertex(...addUVs(x1, y1));
diff --git a/src/webgl/GeometryBuilder.js b/src/webgl/GeometryBuilder.js
index b59548bf67..777feb838e 100644
--- a/src/webgl/GeometryBuilder.js
+++ b/src/webgl/GeometryBuilder.js
@@ -64,11 +64,15 @@ class GeometryBuilder {
}
let startIdx = this.geometry.vertices.length;
- this.geometry.vertices.push(...this.transformVertices(input.vertices));
- this.geometry.vertexNormals.push(
- ...this.transformNormals(input.vertexNormals)
- );
- this.geometry.uvs.push(...input.uvs);
+ for (const v of this.transformVertices(input.vertices)) {
+ this.geometry.vertices.push(v);
+ }
+ for (const vn of this.transformNormals(input.vertexNormals)) {
+ this.geometry.vertexNormals.push(vn);
+ }
+ for (const val of input.uvs) {
+ this.geometry.uvs.push(val);
+ }
const inputUserVertexProps = input.userVertexProperties;
const builtUserVertexProps = this.geometry.userVertexProperties;
@@ -103,15 +107,17 @@ class GeometryBuilder {
);
}
if (this.renderer.states.strokeColor) {
- this.geometry.edges.push(
- ...input.edges.map(edge => edge.map(idx => idx + startIdx))
- );
+ for (const edge of input.edges.map(edge => edge.map(idx => idx + startIdx))) {
+ this.geometry.edges.push(edge);
+ }
}
const vertexColors = [...input.vertexColors];
while (vertexColors.length < input.vertices.length * 4) {
vertexColors.push(...this.renderer.states.curFillColor);
}
- this.geometry.vertexColors.push(...vertexColors);
+ for (const c of vertexColors) {
+ this.geometry.vertexColors.push(c);
+ }
}
/**
diff --git a/src/webgl/ShapeBuilder.js b/src/webgl/ShapeBuilder.js
index 124fa62bfa..9fbcf5955c 100644
--- a/src/webgl/ShapeBuilder.js
+++ b/src/webgl/ShapeBuilder.js
@@ -45,6 +45,10 @@ export class ShapeBuilder {
this.bufferStrides = { ...INITIAL_BUFFER_STRIDES };
}
+ friendlyErrorsDisabled() {
+ return false;
+ }
+
constructFromContours(shape, contours) {
if (this._useUserVertexProperties){
this._resetUserVertexProperties();
@@ -148,6 +152,27 @@ export class ShapeBuilder {
}
if (this.shapeMode === constants.PATH) {
+ const vertexCount = this.geometry.vertices.length;
+ const MAX_SAFE_TESSELLATION_VERTICES = 50000;
+
+ if (
+ vertexCount > MAX_SAFE_TESSELLATION_VERTICES &&
+ !this.friendlyErrorsDisabled() &&
+ !this.renderer._largeTessellationAcknowledged
+ ) {
+ const proceed = window.confirm(
+ 'πΈ p5.js says:\n\n' +
+ `This shape has ${vertexCount} vertices. Tessellating shapes with this ` +
+ 'many vertices can be very slow and may cause your browser to become ' +
+ 'unresponsive.\n\n' +
+ 'Do you want to continue tessellating this shape?'
+ );
+ if (!proceed) {
+ return;
+ }
+ this.renderer._largeTessellationAcknowledged = true;
+ }
+
this.isProcessingVertices = true;
this._tesselateShape();
this.isProcessingVertices = false;
diff --git a/src/webgl/interaction.js b/src/webgl/interaction.js
index ef5beb665a..669ff75553 100644
--- a/src/webgl/interaction.js
+++ b/src/webgl/interaction.js
@@ -2,7 +2,6 @@
* @module 3D
* @submodule Interaction
* @for p5
- * @requires core
*/
import * as constants from '../core/constants';
@@ -370,8 +369,11 @@ function interaction(p5, fn){
// accelerate rotate velocity
this._renderer.rotateVelocity.add(
deltaTheta * rotateAccelerationFactor,
- deltaPhi * rotateAccelerationFactor
+ deltaPhi * rotateAccelerationFactor,
+ 0
);
+
+ //console.log("added");
}
if (this._renderer.rotateVelocity.magSq() > 0.000001) {
// if freeRotation is true, the camera always rotates freely in the direction the pointer moves
@@ -390,8 +392,10 @@ function interaction(p5, fn){
}
// damping
this._renderer.rotateVelocity.mult(damping);
+ //console.log("multiplied", damping, this._renderer.rotateVelocity);
+
} else {
- this._renderer.rotateVelocity.set(0, 0);
+ this._renderer.rotateVelocity.set(0, 0, 0);
}
// move process
diff --git a/src/webgl/light.js b/src/webgl/light.js
index 72938bc291..1088c909e1 100644
--- a/src/webgl/light.js
+++ b/src/webgl/light.js
@@ -2,7 +2,6 @@
* @module 3D
* @submodule Lights
* @for p5
- * @requires core
*/
import { Renderer3D } from '../core/p5.Renderer3D';
@@ -499,6 +498,11 @@ function light(p5, fn){
* sphere(30);
* }
*
+ * function doubleClicked() {
+ * isLit = !isLit;
+ * return false;
+ * }
+ *
* @example
* // Click and drag the mouse to view the scene from different angles.
*
diff --git a/src/webgl/loading.js b/src/webgl/loading.js
index 23fe61b123..5edd0d0791 100755
--- a/src/webgl/loading.js
+++ b/src/webgl/loading.js
@@ -2,8 +2,6 @@
* @module Shape
* @submodule 3D Models
* @for p5
- * @requires core
- * @requires p5.Geometry
*/
import { Geometry } from './p5.Geometry';
@@ -615,7 +613,7 @@ function loading(p5, fn){
model.uvs.push(loadedVerts.vt.at(vertParts[1]) ?
loadedVerts.vt.at(vertParts[1]).slice() : [0, 0]);
model.vertexNormals.push(loadedVerts.vn.at(vertParts[2]) ?
- loadedVerts.vn.at(vertParts[2]).copy() : new Vector());
+ loadedVerts.vn.at(vertParts[2]).copy() : new Vector(0, 0, 0));
usedVerts[vertString][currentMaterial] = vertIndex;
face.push(vertIndex);
@@ -631,6 +629,7 @@ function loading(p5, fn){
model.vertexColors.push(1);
} else {
hasColorlessVertices = true;
+ model.vertexColors.push(-1, -1, -1, -1);
}
} else {
face.push(usedVerts[vertString][currentMaterial]);
@@ -652,9 +651,8 @@ function loading(p5, fn){
if (model.vertexNormals.length === 0) {
model.computeNormals();
}
- if (hasColoredVertices === hasColorlessVertices) {
- // If both are true or both are false, throw an error because the model is inconsistent
- throw new Error('Model coloring is inconsistent. Either all vertices should have colors or none should.');
+ if (!hasColoredVertices) {
+ model.vertexColors = [];
}
return model;
@@ -969,7 +967,7 @@ function loading(p5, fn){
/**
* Draws a p5.Geometry object to the canvas.
*
- * The parameter, `model`, is the
+ * The first parameter, `model`, is the
* p5.Geometry object to draw.
* p5.Geometry objects can be built with
* buildGeometry(). They can also be loaded from
@@ -977,11 +975,7 @@ function loading(p5, fn){
*
* Note: `model()` can only be used in WebGL mode.
*
- * @method model
- * @param {p5.Geometry} model 3D shape to be drawn.
- *
- * @param {Number} [count=1] number of instances to draw.
- * @example
+ * ```js example
* // Click and drag the mouse to view the scene from different angles.
*
* let shape;
@@ -1009,8 +1003,9 @@ function loading(p5, fn){
* function createShape() {
* cone();
* }
+ * ```
*
- * @example
+ * ```js example
* // Click and drag the mouse to view the scene from different angles.
*
* let shape;
@@ -1056,8 +1051,9 @@ function loading(p5, fn){
* cylinder(3, 20);
* pop();
* }
+ * ```
*
- * @example
+ * ```js example
* // Click and drag the mouse to view the scene from different angles.
*
* let shape;
@@ -1079,6 +1075,51 @@ function loading(p5, fn){
* // Draw the shape.
* model(shape);
* }
+ * ```
+ *
+ * Multiple instances can be drawn at once with `model(geometry, count)`. On its own,
+ * all the instances get drawn to the same spot, but you can use
+ * `instanceID()` inside of a shader to handle each instance.
+ * At large counts, this often runs faster than using a `for` loop.
+ *
+ * ```js example
+ * let instancesShader;
+ * let instance;
+ * let count = 5;
+ *
+ * function drawInstance() {
+ * sphere(15);
+ * }
+ *
+ * function setup() {
+ * createCanvas(200, 200, WEBGL);
+ * instance = buildGeometry(drawInstance);
+ * instancesShader = buildMaterialShader(drawSpaced);
+ * }
+ *
+ * function drawSpaced() {
+ * worldInputs.begin();
+ * // Spread spheres evenly across the canvas based on their index
+ * let spacing = width / count;
+ * worldInputs.position.x +=
+ * (instanceID() - (count - 1) / 2) * spacing;
+ * worldInputs.end();
+ * }
+ *
+ * function draw() {
+ * background(220);
+ * lights();
+ * noStroke();
+ * fill('red');
+ * shader(instancesShader);
+ * model(instance, count);
+ * }
+ * ```
+ *
+ * @method model
+ * @param {p5.Geometry} model 3D shape to be drawn.
+ *
+ * @param {Number} [count=1] number of instances to draw.
*/
fn.model = function (model, count = 1) {
this._assert3d('model');
diff --git a/src/webgl/material.js b/src/webgl/material.js
index 2e1bc0c7e1..000a12fb7c 100644
--- a/src/webgl/material.js
+++ b/src/webgl/material.js
@@ -2,7 +2,6 @@
* @module 3D
* @submodule Material
* @for p5
- * @requires core
*/
import * as constants from "../core/constants";
@@ -500,6 +499,7 @@ function material(p5, fn) {
* `loadFilterShader('myShader.js', onLoaded)`.
*
* @method loadFilterShader
+ * @beta
* @submodule p5.strands
* @param {String} filename path to a p5.strands JavaScript file or a GLSL fragment shader file
* @param {Function} [successCallback] callback to be called once the shader is
@@ -2652,6 +2652,8 @@ function material(p5, fn) {
*
* Note: `textureMode()` can only be used in WebGL mode.
*
+ * Calling `textureMode()` with no arguments returns the current texture mode.
+ *
* @method textureMode
* @param {(IMAGE|NORMAL)} mode either IMAGE or NORMAL.
*
@@ -2714,7 +2716,14 @@ function material(p5, fn) {
* endShape();
* }
*/
+ /**
+ * @method textureMode
+ * @return {(IMAGE|NORMAL)} The current texture mode, either IMAGE or NORMAL.
+ */
fn.textureMode = function (mode) {
+ if (typeof mode === 'undefined') { // getter
+ return this._renderer.states.textureMode;
+ }
if (mode !== constants.IMAGE && mode !== constants.NORMAL) {
console.warn(
`You tried to set ${mode} textureMode only supports IMAGE & NORMAL `,
@@ -2822,6 +2831,9 @@ function material(p5, fn) {
*
* Note: `textureWrap()` can only be used in WebGL mode.
*
+ * Calling `textureWrap()` with no arguments returns an object with the current
+ * mode for x and y directions, as in `{ wrapX: CLAMP, wrapY: REPEAT }`.
+ *
* @method textureWrap
* @param {(CLAMP|REPEAT|MIRROR)} wrapX either CLAMP, REPEAT, or MIRROR
* @param {(CLAMP|REPEAT|MIRROR)} [wrapY=wrapX] either CLAMP, REPEAT, or MIRROR
@@ -2977,13 +2989,31 @@ function material(p5, fn) {
* endShape();
* }
*/
+ /**
+ * @method textureWrap
+ * @return {{x: (CLAMP|REPEAT|MIRROR), y: (CLAMP|REPEAT|MIRROR)}} The current texture wrapping for x and y.
+ */
fn.textureWrap = function (wrapX, wrapY = wrapX) {
- this._renderer.states.setValue("textureWrapX", wrapX);
- this._renderer.states.setValue("textureWrapY", wrapY);
+ if (typeof wrapX === 'undefined') { // getter
+ return {
+ x: this._renderer.states.textureWrapX,
+ y: this._renderer.states.textureWrapY
+ };
+ }
+ // accept what is returned from the getter
+ if (wrapX.hasOwnProperty('x') && wrapX.hasOwnProperty('y')) {
+ wrapX = wrapX.x;
+ wrapY = wrapX.y;
+ }
+ this._renderer.states.setValue('textureWrapX', wrapX);
+ this._renderer.states.setValue('textureWrapY', wrapY);
- for (const texture of this._renderer.textures.values()) {
- texture.setWrapMode(wrapX, wrapY);
+ if (this._renderer.textures) {
+ for (const texture of this._renderer.textures.values()) {
+ texture.setWrapMode(wrapX, wrapY);
+ }
}
+ return this;
};
/**
diff --git a/src/webgl/p5.Camera.js b/src/webgl/p5.Camera.js
index 5dcca97a20..38929118fe 100644
--- a/src/webgl/p5.Camera.js
+++ b/src/webgl/p5.Camera.js
@@ -1,7 +1,6 @@
/**
* @module 3D
* @submodule Camera
- * @requires core
*/
import { Matrix } from '../math/p5.Matrix';
@@ -1601,10 +1600,10 @@ class Camera {
const up1 = rotMat1.row(1);
// prepare new vectors.
- const newFront = new Vector();
- const newUp = new Vector();
- const newEye = new Vector();
- const newCenter = new Vector();
+ const newFront = new Vector(0, 0, 0);
+ const newUp = new Vector(0, 0, 0);
+ const newEye = new Vector(0, 0, 0);
+ const newCenter = new Vector(0, 0, 0);
// Create the inverse matrix of mat0 by transposing mat0,
// and multiply it to mat1 from the right.
diff --git a/src/webgl/p5.Framebuffer.js b/src/webgl/p5.Framebuffer.js
index 29ef58bc3f..fc04f83792 100644
--- a/src/webgl/p5.Framebuffer.js
+++ b/src/webgl/p5.Framebuffer.js
@@ -1,6 +1,5 @@
/**
* @module Rendering
- * @requires constants
*/
import * as constants from '../core/constants';
diff --git a/src/webgl/p5.Geometry.js b/src/webgl/p5.Geometry.js
index dad4c860f4..67658cfb49 100644
--- a/src/webgl/p5.Geometry.js
+++ b/src/webgl/p5.Geometry.js
@@ -2,8 +2,6 @@
* @module Shape
* @submodule 3D Primitives
* @for p5
- * @requires core
- * @requires p5.Geometry
*/
//some of the functions are adjusted from Three.js(http://threejs.org)
@@ -330,7 +328,7 @@ class Geometry {
* let saveBtn;
* function setup() {
* createCanvas(200, 200, WEBGL);
- * myModel = buildGeometry(function()) {
+ * myModel = buildGeometry(function() {
* for (let i = 0; i < 5; i++) {
* push();
* translate(
@@ -1205,7 +1203,7 @@ class Geometry {
// initialize the vertexNormals array with empty vectors
vertexNormals.length = 0;
for (iv = 0; iv < vertices.length; ++iv) {
- vertexNormals.push(new Vector());
+ vertexNormals.push(new Vector(0, 0, 0));
}
// loop through all the faces adding its normal to the normal
diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js
index a17bd29334..78de4092f8 100644
--- a/src/webgl/p5.RendererGL.js
+++ b/src/webgl/p5.RendererGL.js
@@ -19,7 +19,6 @@ import { Image } from '../image/p5.Image';
import { glslBackend } from './strands_glslBackend';
import { TypeInfoFromGLSLName } from '../strands/ir_types.js';
import { getShaderHookTypes } from './shaderHookUtils';
-import noiseGLSL from './shaders/functions/noise3DGLSL.glsl';
import filterBaseVert from "./shaders/filters/base.vert";
import lightingShader from "./shaders/lighting.glsl";
@@ -530,7 +529,7 @@ class RendererGL extends Renderer3D {
/**
* Loads the pixels data for this canvas into the pixels[] attribute.
- * Note that updatePixels() and set() do not work.
+ * Note that set() does not work.
* Any pixel manipulation must be done directly to the pixels[] array.
*
* @private
@@ -723,7 +722,7 @@ class RendererGL extends Renderer3D {
color.a = components.opacity;
return color;
}`,
- "vec4 getFinalColor": "(vec4 color) { return color; }",
+ "vec4 getFinalColor": "(vec4 color, vec2 texCoord) { return color; }",
"void afterFragment": "() {}",
},
}
@@ -760,7 +759,7 @@ class RendererGL extends Renderer3D {
},
fragment: {
"void beforeFragment": "() {}",
- "vec4 getFinalColor": "(vec4 color) { return color; }",
+ "vec4 getFinalColor": "(vec4 color, vec2 texCoord) { return color; }",
"void afterFragment": "() {}",
},
}
@@ -788,7 +787,7 @@ class RendererGL extends Renderer3D {
},
fragment: {
"void beforeFragment": "() {}",
- "vec4 getFinalColor": "(vec4 color) { return color; }",
+ "vec4 getFinalColor": "(vec4 color, vec2 texCoord) { return color; }",
"void afterFragment": "() {}",
},
}
@@ -820,7 +819,7 @@ class RendererGL extends Renderer3D {
fragment: {
"void beforeFragment": "() {}",
"Inputs getPixelInputs": "(Inputs inputs) { return inputs; }",
- "vec4 getFinalColor": "(vec4 color) { return color; }",
+ "vec4 getFinalColor": "(vec4 color, vec2 texCoord) { return color; }",
"bool shouldDiscard": "(bool outside) { return outside; }",
"void afterFragment": "() {}",
},
@@ -1129,6 +1128,7 @@ class RendererGL extends Renderer3D {
);
}
+ shader._compiled = true;
shader._glProgram = program;
shader._vertShader = vertShader;
shader._fragShader = fragShader;
@@ -1904,10 +1904,6 @@ class RendererGL extends Renderer3D {
}
}
- getNoiseShaderSnippet() {
- return noiseGLSL;
- }
-
}
function rendererGL(p5, fn) {
diff --git a/src/webgl/p5.Shader.js b/src/webgl/p5.Shader.js
index 6cad5b6cfe..7818e45edd 100644
--- a/src/webgl/p5.Shader.js
+++ b/src/webgl/p5.Shader.js
@@ -3,18 +3,38 @@
* @module 3D
* @submodule Material
* @for p5
- * @requires core
*/
const TypedArray = Object.getPrototypeOf(Uint8Array);
class Shader {
constructor(renderer, vertSrc, fragSrc, options = {}) {
this._renderer = renderer;
- this._vertSrc = vertSrc;
- this._fragSrc = fragSrc;
+
+ // Detect compute shader: first arg is STRING and second is undefined OR an options object
+ if (
+ typeof vertSrc === 'string' && (
+ fragSrc === undefined || (typeof fragSrc === 'object' && !Array.isArray(fragSrc))
+ )
+ ) {
+ // Compute shader
+ this.shaderType = 'compute';
+ this._computeSrc = vertSrc;
+ this._vertSrc = null;
+ this._fragSrc = null;
+ // If fragSrc is an options object, use it
+ if (typeof fragSrc === 'object') {
+ options = fragSrc;
+ }
+ } else {
+ // Render shader - shaderType will be set later during binding ('fill', 'stroke', etc.)
+ this._vertSrc = vertSrc;
+ this._fragSrc = fragSrc;
+ this._computeSrc = null;
+ }
+
this._vertShader = -1;
this._fragShader = -1;
- this._glProgram = 0;
+ this._compiled = false;
this._loadedAttributes = false;
this.attributes = {};
this._loadedUniforms = false;
@@ -28,18 +48,25 @@ class Shader {
// Stores uniforms + default values.
uniforms: options.uniforms || {},
+ // Compute shader storage uniforms + default values
+ storageUniforms: options.storageUniforms || {},
+
// Stores custom uniform + helper declarations as a string.
declarations: options.declarations,
// Stores an array of variable names + types passed between the vertex and fragment shader
varyingVariables: options.varyingVariables || [],
+ // Stores instanceID varying info for forwarding to the fragment shader
+ instanceIDVarying: options.instanceIDVarying || null,
+
// Stores helper functions to prepend to shaders.
helpers: options.helpers || {},
// Stores the hook implementations
vertex: options.vertex || {},
fragment: options.fragment || {},
+ compute: options.compute || {},
hookAliases: options.hookAliases || {},
@@ -48,7 +75,8 @@ class Shader {
// yourShader.modify(...).
modified: {
vertex: (options.modified && options.modified.vertex) || {},
- fragment: (options.modified && options.modified.fragment) || {}
+ fragment: (options.modified && options.modified.fragment) || {},
+ compute: (options.modified && options.modified.compute) || {},
}
};
}
@@ -80,13 +108,20 @@ class Shader {
}
vertSrc() {
+ if (this.shaderType === 'compute') return null;
return this.shaderSrc(this._vertSrc, 'vertex');
}
fragSrc() {
+ if (this.shaderType === 'compute') return null;
return this.shaderSrc(this._fragSrc, 'fragment');
}
+ computeSrc() {
+ if (this.shaderType !== 'compute') return null;
+ return this.shaderSrc(this._computeSrc, 'compute');
+ }
+
/**
* Logs the hooks available in this shader, and their current implementation.
*
@@ -136,29 +171,40 @@ class Shader {
* color.a = components.opacity;
* return color;
* }
- * vec4 getFinalColor(vec4 color) { return color; }
+ * vec4 getFinalColor(vec4 color, vec2 texCoord) { return color; }
* void afterFragment() {}
* ```
*
* @beta
*/
inspectHooks() {
- console.log('==== Vertex shader hooks: ====');
- for (const key in this.hooks.vertex) {
- console.log(
- (this.hooks.modified.vertex[key] ? '[MODIFIED] ' : '') +
- key +
- this.hooks.vertex[key]
- );
- }
- console.log('');
- console.log('==== Fragment shader hooks: ====');
- for (const key in this.hooks.fragment) {
- console.log(
- (this.hooks.modified.fragment[key] ? '[MODIFIED] ' : '') +
- key +
- this.hooks.fragment[key]
- );
+ if (this.shaderType === 'compute') {
+ console.log('==== Compute shader hooks: ====');
+ for (const key in this.hooks.compute) {
+ console.log(
+ (this.hooks.modified.compute[key] ? '[MODIFIED] ' : '') +
+ key +
+ this.hooks.compute[key]
+ );
+ }
+ } else {
+ console.log('==== Vertex shader hooks: ====');
+ for (const key in this.hooks.vertex) {
+ console.log(
+ (this.hooks.modified.vertex[key] ? '[MODIFIED] ' : '') +
+ key +
+ this.hooks.vertex[key]
+ );
+ }
+ console.log('');
+ console.log('==== Fragment shader hooks: ====');
+ for (const key in this.hooks.fragment) {
+ console.log(
+ (this.hooks.modified.fragment[key] ? '[MODIFIED] ' : '') +
+ key +
+ this.hooks.fragment[key]
+ );
+ }
}
console.log('');
console.log('==== Helper functions: ====');
@@ -209,15 +255,15 @@ class Shader {
* type of the data, such as `uniformFloat` for a number or `uniformVector2` for a two-component vector.
* They take in a function that returns the data for the variable. You can then reference these
* variables in your hooks, and their values will update every time you apply
- * the shader with the result of your function.
- *
+ * the shader with the result of your function.
+ *
* Move the mouse over this sketch to increase the moveCounter which will be passed to the shader as a uniform.
*
* ```js example
* let myShader;
* //count of frames in which mouse has been moved
* let moveCounter = 0;
- *
+ *
* function setup() {
* createCanvas(200, 200, WEBGL);
* myShader = baseMaterialShader().modify(() => {
@@ -370,28 +416,37 @@ class Shader {
const newHooks = {
vertex: {},
fragment: {},
+ compute: {},
helpers: {}
};
for (const key in hooks) {
if (key === 'declarations') continue;
if (key === 'uniforms') continue;
+ if (key === 'storageUniforms') continue;
if (key === 'varyingVariables') continue;
+ if (key === 'instanceIDVarying') continue;
if (key === 'vertexDeclarations') {
newHooks.vertex.declarations =
(newHooks.vertex.declarations || '') + '\n' + hooks[key];
} else if (key === 'fragmentDeclarations') {
newHooks.fragment.declarations =
(newHooks.fragment.declarations || '') + '\n' + hooks[key];
+ } else if (key === 'computeDeclarations') {
+ newHooks.compute.declarations =
+ (newHooks.compute.declarations || '') + '\n' + hooks[key];
} else if (this.hooks.vertex[key]) {
newHooks.vertex[key] = hooks[key];
} else if (this.hooks.fragment[key]) {
newHooks.fragment[key] = hooks[key];
+ } else if (this.hooks.compute[key]) {
+ newHooks.compute[key] = hooks[key];
} else {
newHooks.helpers[key] = hooks[key];
}
}
const modifiedVertex = Object.assign({}, this.hooks.modified.vertex);
const modifiedFragment = Object.assign({}, this.hooks.modified.fragment);
+ const modifiedCompute = Object.assign({}, this.hooks.modified.compute);
for (const key in newHooks.vertex || {}) {
if (key === 'declarations') continue;
modifiedVertex[key] = true;
@@ -400,21 +455,37 @@ class Shader {
if (key === 'declarations') continue;
modifiedFragment[key] = true;
}
+ for (const key in newHooks.compute || {}) {
+ if (key === 'declarations') continue;
+ modifiedCompute[key] = true;
+ }
- return new Shader(this._renderer, this._vertSrc, this._fragSrc, {
+ const args = [this._renderer];
+ if (this.shaderType === 'compute') {
+ args.push(this._computeSrc);
+ } else {
+ args.push(this._vertSrc, this._fragSrc);
+ }
+ args.push({
declarations:
(this.hooks.declarations || '') + '\n' + (hooks.declarations || ''),
uniforms: Object.assign({}, this.hooks.uniforms, hooks.uniforms || {}),
+ storageUniforms: Object.assign({}, this.hooks.storageUniforms, hooks.storageUniforms || {}),
varyingVariables: (hooks.varyingVariables || []).concat(this.hooks.varyingVariables || []),
+ instanceIDVarying: hooks.instanceIDVarying || this.hooks.instanceIDVarying || null,
fragment: Object.assign({}, this.hooks.fragment, newHooks.fragment || {}),
vertex: Object.assign({}, this.hooks.vertex, newHooks.vertex || {}),
+ compute: Object.assign({}, this.hooks.compute, newHooks.compute || {}),
helpers: Object.assign({}, this.hooks.helpers, newHooks.helpers || {}),
hookAliases: Object.assign({}, this.hooks.hookAliases, newHooks.hookAliases || {}),
modified: {
vertex: modifiedVertex,
- fragment: modifiedFragment
+ fragment: modifiedFragment,
+ compute: modifiedCompute,
}
});
+
+ return new Shader(...args);
}
/**
@@ -436,7 +507,9 @@ class Shader {
);
}
- this._loadAttributes();
+ if (this.shaderType !== 'compute') {
+ this._loadAttributes();
+ }
this._loadUniforms();
this._renderer._finalizeShader(this);
@@ -464,6 +537,13 @@ class Shader {
this.setUniform(name, value);
}
}
+ for (const name in this.hooks.storageUniforms) {
+ const initializer = this.hooks.storageUniforms[name];
+ const value = initializer instanceof Function ? initializer() : initializer;
+ if (value !== undefined && value !== null) {
+ this.setUniform(name, value);
+ }
+ }
}
/**
@@ -639,11 +719,14 @@ class Shader {
* }
*/
copyToContext(context) {
- const shader = new Shader(
- context._renderer,
- this._vertSrc,
- this._fragSrc
- );
+ const args = [context._renderer];
+ if (this.shaderType === 'compute') {
+ args.push(this._computeSrc);
+ } else {
+ args.push(this._vertSrc, this._fragSrc);
+ }
+ args.push(this.hooks);
+ const shader = new Shader(...args);
shader.ensureCompiledOnContext(context._renderer);
return shader;
}
@@ -652,11 +735,11 @@ class Shader {
* @private
*/
ensureCompiledOnContext(context) {
- if (this._glProgram !== 0 && this._renderer !== context) {
+ if (this._compiled && this._renderer !== context) {
throw new Error(
'The shader being run is attached to a different context. Do you need to copy it to this context first with .copyToContext()?'
);
- } else if (this._glProgram === 0) {
+ } else if (!this._compiled) {
this._renderer = context?._renderer?.filterRenderer?._renderer || context;
this.init();
}
@@ -809,7 +892,7 @@ class Shader {
* @chainable
* @param {String} uniformName name of the uniform. Must match the name
* used in the vertex and fragment shaders.
- * @param {Boolean|Number|Number[]|p5.Image|p5.Graphics|p5.MediaElement|p5.Texture}
+ * @param {Boolean|p5.Vector|p5.Color|Number|Number[]|p5.Image|p5.Graphics|p5.MediaElement|p5.Texture|p5.StorageBuffer}
* data value to assign to the uniform. Must match the uniformβs data type.
*
* @example
@@ -1020,6 +1103,16 @@ class Shader {
return;
}
+ // In p5.strands-related code, where some of the code may be in
+ // p5.webgpu.js instead of the main p5.js build, we generally use
+ // duck typing instead of instanceof to avoid accidentally importing
+ // and comparing against a separate copy of p5 classes
+ if (data?.isVector) {
+ data = data.values.length !== data.dimensions ? data.values.slice(0, data.dimensions) : data.values;
+ } else if (data?.isColor) {
+ data = data._getRGBA([1, 1, 1, 1]);
+ }
+
if (uniform.isArray) {
if (
uniform._cachedData &&
diff --git a/src/webgl/p5.Texture.js b/src/webgl/p5.Texture.js
index 7441ae5139..3c12e6ed69 100644
--- a/src/webgl/p5.Texture.js
+++ b/src/webgl/p5.Texture.js
@@ -3,7 +3,6 @@
* @module 3D
* @submodule Material
* @for p5
- * @requires core
*/
import * as constants from '../core/constants';
diff --git a/src/webgl/shaders/basic.frag b/src/webgl/shaders/basic.frag
index 1406964ca9..b7db52b1ad 100644
--- a/src/webgl/shaders/basic.frag
+++ b/src/webgl/shaders/basic.frag
@@ -1,7 +1,8 @@
IN vec4 vColor;
+IN highp vec2 vVertTexCoord;
void main(void) {
HOOK_beforeFragment();
- OUT_COLOR = HOOK_getFinalColor(vColor);
+ OUT_COLOR = HOOK_getFinalColor(vColor, vVertTexCoord);
OUT_COLOR.rgb *= OUT_COLOR.a; // Premultiply alpha before rendering
HOOK_afterFragment();
-}
+}
\ No newline at end of file
diff --git a/src/webgl/shaders/functions/randomGLSL.glsl b/src/webgl/shaders/functions/randomGLSL.glsl
new file mode 100644
index 0000000000..0894462373
--- /dev/null
+++ b/src/webgl/shaders/functions/randomGLSL.glsl
@@ -0,0 +1,27 @@
+// _p5_hash: "Hash without Sine" by Dave Hoskins (https://www.shadertoy.com/view/4djSRW)
+// Mixing constants: Rβ sequence by Martin Roberts (https://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences/)
+// Ξ±β = 1/Οβ = 0.7548776662 (plastic constant reciprocal)
+// Ξ±β = 1/ΟβΒ² = 0.5698402910
+// 1/Ο = 0.6180339887 (golden ratio conjugate)
+
+int _p5_randomCallIndex = 0;
+
+float _p5_hash(vec3 p) {
+ p = fract(p * vec3(0.1031, 0.1030, 0.0973));
+ p += dot(p, p.yxz + 33.33);
+ return fract((p.x + p.y) * p.z);
+}
+
+float random(float seed) {
+ vec2 pixelCoord = gl_FragCoord.xy;
+ float callIndex = float(_p5_randomCallIndex);
+ _p5_randomCallIndex += 1;
+ // fract(seed * Ξ±β) normalizes large seeds (e.g. performance.now()) into [0,1)
+ // and spreads them optimally via the Rβ sequence's plastic constant
+ float s = fract(seed * 0.7548776662);
+ return _p5_hash(vec3(
+ pixelCoord.x + s,
+ pixelCoord.y + callIndex * 0.5698402910,
+ s + callIndex * 0.6180339887
+ ));
+}
diff --git a/src/webgl/shaders/functions/randomVertGLSL.glsl b/src/webgl/shaders/functions/randomVertGLSL.glsl
new file mode 100644
index 0000000000..2c9b1128bb
--- /dev/null
+++ b/src/webgl/shaders/functions/randomVertGLSL.glsl
@@ -0,0 +1,25 @@
+// _p5_hash: "Hash without Sine" by Dave Hoskins (https://www.shadertoy.com/view/4djSRW)
+// Mixing constants: Rβ sequence by Martin Roberts (https://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences/)
+// Ξ±β = 1/Οβ = 0.7548776662 (plastic constant reciprocal)
+// Ξ±β = 1/ΟβΒ² = 0.5698402910
+// 1/Ο = 0.6180339887 (golden ratio conjugate)
+
+int _p5_randomCallIndex = 0;
+
+float _p5_hash(vec3 p) {
+ p = fract(p * vec3(0.1031, 0.1030, 0.0973));
+ p += dot(p, p.yxz + 33.33);
+ return fract((p.x + p.y) * p.z);
+}
+
+float random(float seed) {
+ float vid = float(gl_VertexID);
+ float callIndex = float(_p5_randomCallIndex);
+ _p5_randomCallIndex += 1;
+ float s = fract(seed * 0.7548776662);
+ return _p5_hash(vec3(
+ vid + s,
+ vid * 0.5698402910 + callIndex * 0.6180339887,
+ s + callIndex * 0.7548776662
+ ));
+}
diff --git a/src/webgl/shaders/line.frag b/src/webgl/shaders/line.frag
index a0ca059040..ec39236912 100644
--- a/src/webgl/shaders/line.frag
+++ b/src/webgl/shaders/line.frag
@@ -69,6 +69,7 @@ void main() {
discard;
}
}
- OUT_COLOR = HOOK_getFinalColor(vec4(inputs.color.rgb, 1.) * inputs.color.a);
+ OUT_COLOR = HOOK_getFinalColor(inputs.color, vec2(0.0, 0.0));
+ OUT_COLOR.rgb *= OUT_COLOR.a;
HOOK_afterFragment();
}
diff --git a/src/webgl/shaders/normal.frag b/src/webgl/shaders/normal.frag
index 0cb362265a..fbb9258547 100644
--- a/src/webgl/shaders/normal.frag
+++ b/src/webgl/shaders/normal.frag
@@ -1,6 +1,7 @@
IN vec3 vVertexNormal;
+IN highp vec2 vVertTexCoord;
void main(void) {
HOOK_beforeFragment();
- OUT_COLOR = HOOK_getFinalColor(vec4(vVertexNormal, 1.0));
+ OUT_COLOR = HOOK_getFinalColor(vec4(vVertexNormal, 1.0), vVertTexCoord);
HOOK_afterFragment();
-}
+}
\ No newline at end of file
diff --git a/src/webgl/shaders/phong.frag b/src/webgl/shaders/phong.frag
index 78cfb76163..47ec519d47 100644
--- a/src/webgl/shaders/phong.frag
+++ b/src/webgl/shaders/phong.frag
@@ -77,7 +77,7 @@ void main(void) {
c.ambient = inputs.ambientLight;
c.specular = specular;
c.emissive = inputs.emissiveMaterial;
- OUT_COLOR = HOOK_getFinalColor(HOOK_combineColors(c));
+ OUT_COLOR = HOOK_getFinalColor(HOOK_combineColors(c), vTexCoord);
OUT_COLOR.rgb *= OUT_COLOR.a; // Premultiply alpha before rendering
HOOK_afterFragment();
}
diff --git a/src/webgl/strands_glslBackend.js b/src/webgl/strands_glslBackend.js
index daf804a8e8..bbd05a5950 100644
--- a/src/webgl/strands_glslBackend.js
+++ b/src/webgl/strands_glslBackend.js
@@ -1,4 +1,7 @@
-import { NodeType, OpCodeToSymbol, BlockType, OpCode, NodeTypeToName, isStructType, BaseType, StatementType, DataType } from "../strands/ir_types";
+import noiseGLSL from './shaders/functions/noise3DGLSL.glsl';
+import randomGLSL from './shaders/functions/randomGLSL.glsl';
+import randomVertGLSL from './shaders/functions/randomVertGLSL.glsl';
+import { NodeType, OpCodeToSymbol, BlockType, OpCode, NodeTypeToName, isStructType, BaseType, StatementType, DataType, INSTANCE_ID_VARYING_NAME, HOOK_PARAM_PREFIX } from "../strands/ir_types";
import { getNodeDataFromID, extractNodeTypeInfo } from "../strands/ir_dag";
import * as FES from '../strands/strands_FES';
import * as build from '../strands/ir_builders';
@@ -165,10 +168,19 @@ const cfgHandlers = {
export const glslBackend = {
hookEntry(hookType) {
const firstLine = `(${hookType.parameters.flatMap((param) => {
- return `${param.qualifiers?.length ? param.qualifiers.join(' ') : ''}${param.type.typeName} ${param.name}`;
+ return `${param.qualifiers?.length ? param.qualifiers.join(' ') : ''}${param.type.typeName} ${HOOK_PARAM_PREFIX}${param.name}`;
}).join(', ')}) {`;
return firstLine;
},
+ getNoiseShaderSnippet() {
+ return noiseGLSL;
+ },
+ getRandomFragmentShaderSnippet() {
+ return randomGLSL;
+ },
+ getRandomVertexShaderSnippet() {
+ return randomVertGLSL;
+ },
getTypeName(baseType, dimension) {
const primitiveTypeName = TypeNames[baseType + dimension]
if (!primitiveTypeName) {
@@ -231,6 +243,10 @@ export const glslBackend = {
return `${typeName} ${tmp} = ${expr};`;
},
generateReturnStatement(strandsContext, generationContext, rootNodeID, returnType) {
+ if (!returnType) {
+ generationContext.write('return;');
+ return;
+ }
const dag = strandsContext.dag;
const rootNode = getNodeDataFromID(dag, rootNodeID);
if (isStructType(returnType)) {
@@ -270,6 +286,13 @@ export const glslBackend = {
sharedVar.usedInFragment = true;
}
}
+
+ // Detect instanceID usage in fragment context and rewrite to varying name
+ if (node.identifier === this.instanceIdReference() && generationContext.shaderContext === 'fragment') {
+ generationContext.strandsContext._instanceIDUsedInFragment = true;
+ return INSTANCE_ID_VARYING_NAME;
+ }
+
return node.identifier;
case NodeType.OPERATION:
const useParantheses = node.usedBy.length > 0;
@@ -289,6 +312,13 @@ export const glslBackend = {
const functionArgs = node.dependsOn.map(arg =>this.generateExpression(generationContext, dag, arg));
return `${node.identifier}(${functionArgs.join(', ')})`;
}
+ if (node.opCode === OpCode.Nary.TERNARY) {
+ const [condID, trueID, falseID] = node.dependsOn;
+ const cond = this.generateExpression(generationContext, dag, condID);
+ const trueExpr = this.generateExpression(generationContext, dag, trueID);
+ const falseExpr = this.generateExpression(generationContext, dag, falseID);
+ return `(${cond} ? ${trueExpr} : ${falseExpr})`;
+ }
if (node.opCode === OpCode.Binary.MEMBER_ACCESS) {
const [lID, rID] = node.dependsOn;
const lName = this.generateExpression(generationContext, dag, lID);
@@ -300,6 +330,12 @@ export const glslBackend = {
const parentExpr = this.generateExpression(generationContext, dag, parentID);
return `${parentExpr}.${node.swizzle}`;
}
+ if (node.opCode === OpCode.Binary.ARRAY_ACCESS) {
+ const [bufferID, indexID] = node.dependsOn;
+ const bufferExpr = this.generateExpression(generationContext, dag, bufferID);
+ const indexExpr = this.generateExpression(generationContext, dag, indexID);
+ return `${bufferExpr}[${indexExpr}]`;
+ }
if (node.dependsOn.length === 2) {
const [lID, rID] = node.dependsOn;
const left = this.generateExpression(generationContext, dag, lID);
@@ -387,4 +423,8 @@ export const glslBackend = {
instanceIdReference() {
return 'gl_InstanceID';
},
+
+ generateInstanceIDVarying() {
+ return { name: INSTANCE_ID_VARYING_NAME, declaration: `int ${INSTANCE_ID_VARYING_NAME}`, source: 'gl_InstanceID', interpolation: 'flat' };
+ },
}
diff --git a/src/webgl/utils.js b/src/webgl/utils.js
index c5c6e3d7c9..a5742c3f50 100644
--- a/src/webgl/utils.js
+++ b/src/webgl/utils.js
@@ -1,4 +1,5 @@
import * as constants from "../core/constants";
+import { INSTANCE_ID_VARYING_NAME } from "../strands/ir_types";
import { Texture } from "./p5.Texture";
/**
@@ -6,8 +7,8 @@ import { Texture } from "./p5.Texture";
* @param {Uint8Array|Float32Array|undefined} pixels An existing pixels array to reuse if the size is the same
* @param {WebGLRenderingContext} gl The WebGL context
* @param {WebGLFramebuffer|null} framebuffer The Framebuffer to read
- * @param {Number} x The x coordiante to read, premultiplied by pixel density
- * @param {Number} y The y coordiante to read, premultiplied by pixel density
+ * @param {Number} x The x coordinate to read, premultiplied by pixel density
+ * @param {Number} y The y coordinate to read, premultiplied by pixel density
* @param {Number} width The width in pixels to be read (factoring in pixel density)
* @param {Number} height The height in pixels to be read (factoring in pixel density)
* @param {GLEnum} format Either RGB or RGBA depending on how many channels to read
@@ -429,6 +430,20 @@ export function populateGLSLHooks(shader, src, shaderType) {
}
}
}
+
+ // Handle instanceID varying for fragment access
+ if (shader.hooks.instanceIDVarying) {
+ const { declaration, source, interpolation } = shader.hooks.instanceIDVarying;
+ const qualifier = interpolation ? `${interpolation} ` : '';
+ if (shaderType === "vertex") {
+ // Emit flat out declaration and inject assignment into main() body
+ hooks += `${qualifier}OUT ${declaration};\n`;
+ postMain = postMain.replace(/\{/, `{\n ${declaration.split(' ').pop()} = ${source};`);
+ } else if (shaderType === "fragment") {
+ hooks += `${qualifier}IN ${declaration};\n`;
+ }
+ }
+
for (const hookDef in shader.hooks.helpers) {
hooks += `${hookDef}${shader.hooks.helpers[hookDef]}\n`;
}
diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js
index 678745f6fb..3b909230b7 100644
--- a/src/webgpu/p5.RendererWebGPU.js
+++ b/src/webgpu/p5.RendererWebGPU.js
@@ -1,6 +1,12 @@
+/**
+ * @module 3D
+ * @submodule p5.strands
+ * @for p5
+ */
+
import * as constants from '../core/constants';
import { getStrokeDefs } from '../webgl/enums';
-import { DataType } from '../strands/ir_types.js';
+import { DataType, INSTANCE_ID_VARYING_NAME } from '../strands/ir_types.js';
import { colorVertexShader, colorFragmentShader } from './shaders/color';
import { lineVertexShader, lineFragmentShader} from './shaders/line';
@@ -8,9 +14,10 @@ import { materialVertexShader, materialFragmentShader } from './shaders/material
import { fontVertexShader, fontFragmentShader } from './shaders/font';
import { blitVertexShader, blitFragmentShader } from './shaders/blit';
import { wgslBackend } from './strands_wgslBackend';
-import noiseWGSL from './shaders/functions/noise3DWGSL';
+
import { baseFilterVertexShader, baseFilterFragmentShader } from './shaders/filters/base';
import { imageLightVertexShader, imageLightDiffusedFragmentShader, imageLightSpecularFragmentShader } from './shaders/imageLight';
+import { baseComputeShader } from './shaders/compute';
const FRAME_STATE = {
PENDING: 0,
@@ -33,6 +40,338 @@ function rendererWebGPU(p5, fn) {
RGBA,
} = p5;
+ class StorageBuffer {
+ constructor(buffer, size, renderer, schema = null) {
+ this._isStorageBuffer = true;
+ this.buffer = buffer;
+ this.size = size;
+ this._renderer = renderer;
+ this._schema = schema;
+ }
+
+ /**
+ * Updates the data in the buffer with new values. The new data must be in
+ * the same format as the data originally passed to
+ * `createStorage()`.
+ *
+ * ```js example
+ * let particles;
+ * let computeShader;
+ * let displayShader;
+ * let instance;
+ * const numParticles = 100;
+ *
+ * async function setup() {
+ * await createCanvas(100, 100, WEBGPU);
+ * particles = createStorage(makeParticles(width / 2, height / 2));
+ * computeShader = buildComputeShader(simulate);
+ * displayShader = buildMaterialShader(display);
+ * instance = buildGeometry(drawParticle);
+ * describe('100 orange particles shooting outward.');
+ * }
+ *
+ * function makeParticles(x, y) {
+ * let data = [];
+ * for (let i = 0; i < numParticles; i++) {
+ * let angle = (i / numParticles) * TWO_PI;
+ * let speed = random(0.5, 2);
+ * data.push({
+ * position: createVector(x, y),
+ * velocity: createVector(cos(angle) * speed, sin(angle) * speed),
+ * });
+ * }
+ * return data;
+ * }
+ *
+ * function drawParticle() {
+ * sphere(2);
+ * }
+ *
+ * function simulate() {
+ * let data = uniformStorage(particles);
+ * let idx = index.x;
+ * data[idx].position = data[idx].position + data[idx].velocity;
+ * }
+ *
+ * function display() {
+ * let data = uniformStorage(particles);
+ * worldInputs.begin();
+ * let pos = data[instanceID()].position;
+ * worldInputs.position.xy += pos - [width / 2, height / 2];
+ * worldInputs.end();
+ * }
+ *
+ * function draw() {
+ * background(30);
+ * if (frameCount % 60 === 0) {
+ * particles.update(makeParticles(random(width), random(height)));
+ * }
+ * compute(computeShader, numParticles);
+ * noStroke();
+ * fill(255, 200, 50);
+ * shader(displayShader);
+ * model(instance, numParticles);
+ * }
+ * ```
+ *
+ * @method update
+ * @for p5.StorageBuffer
+ * @beta
+ * @webgpu
+ * @webgpuOnly
+ * @param {Number[]|Float32Array|Object[]} data The new data to write into the buffer.
+ */
+ update(data) {
+ const device = this._renderer.device;
+
+ if (this._schema !== null) {
+ // Buffer was created with a struct array
+ if (
+ !Array.isArray(data) ||
+ data.length === 0 ||
+ typeof data[0] !== 'object' ||
+ Array.isArray(data[0])
+ ) {
+ throw new Error(
+ 'update() expects an array of objects matching the original struct format'
+ );
+ }
+
+ const newSchema = this._renderer._inferStructSchema(data[0]);
+ if (newSchema.structBody !== this._schema.structBody) {
+ throw new Error(
+ `update() data structure doesn't match the original.\n` +
+ ` Expected: ${this._schema.structBody}\n` +
+ ` Got: ${newSchema.structBody}`
+ );
+ }
+
+ const packed = this._renderer._packStructArray(data, this._schema);
+ if (packed.byteLength > this.size) {
+ throw new Error(
+ `update() data (${packed.byteLength} bytes) exceeds buffer size (${this.size} bytes)`
+ );
+ }
+ device.queue.writeBuffer(this.buffer, 0, packed);
+ } else {
+ // Buffer was created with a float array
+ let floatData;
+ if (data instanceof Float32Array) {
+ floatData = data;
+ } else if (Array.isArray(data)) {
+ floatData = new Float32Array(data);
+ } else {
+ throw new Error(
+ 'update() expects a Float32Array or array of numbers for this buffer'
+ );
+ }
+
+ if (floatData.byteLength > this.size) {
+ throw new Error(
+ `update() data (${floatData.byteLength} bytes) exceeds buffer size (${this.size} bytes)`
+ );
+ }
+ device.queue.writeBuffer(this.buffer, 0, floatData);
+ }
+ }
+
+ /**
+ * Reads data from a storage buffer back into JavaScript.
+ *
+ * Copies data from the GPU to the CPU using a temporary buffer,
+ * so it must be awaited. Returns a `Float32Array` for number
+ * buffers, or an array of plain objects for struct buffers.
+ *
+ * Note: This is a GPU -> CPU read, so calling it often (like every frame)
+ * can be slow.
+ *
+ * ```js example
+ * let data;
+ * let computeShader;
+ *
+ * async function setup() {
+ * await createCanvas(100, 100, WEBGPU);
+ *
+ * data = createStorage(new Float32Array([1, 2, 3, 4]));
+ * computeShader = buildComputeShader(doubleValues);
+ * compute(computeShader, 4);
+ *
+ * let result = await data.read();
+ * // result is Float32Array [2, 4, 6, 8]
+ * for (let i = 0; i < result.length; i++) {
+ * print(result[i]);
+ * }
+ * describe('Prints the values 2, 4, 6, 8 to the console.');
+ * }
+ *
+ * function doubleValues() {
+ * let d = uniformStorage(data);
+ * let idx = index.x;
+ * d[idx] = d[idx] * 2;
+ * }
+ * ```
+ *
+ * @method read
+ * @for p5.StorageBuffer
+ * @beta
+ * @webgpu
+ * @webgpuOnly
+ * @returns {Promise}
+ */
+ async read() {
+ const device = this._renderer.device;
+ this._renderer.flushDraw();
+
+ const stagingBuffer = device.createBuffer({
+ size: this.size,
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
+ });
+
+ const commandEncoder = device.createCommandEncoder();
+ commandEncoder.copyBufferToBuffer(this.buffer, 0, stagingBuffer, 0, this.size);
+ device.queue.submit([commandEncoder.finish()]);
+
+ await stagingBuffer.mapAsync(GPUMapMode.READ, 0, this.size);
+ const mappedRange = stagingBuffer.getMappedRange(0, this.size);
+
+ // Copy before unmapping because mapped memory becomes invalid after unmap
+ const rawCopy = new Float32Array(mappedRange.byteLength / 4);
+ rawCopy.set(new Float32Array(mappedRange));
+
+ stagingBuffer.unmap();
+ stagingBuffer.destroy();
+
+ if (this._schema !== null) {
+ return this._renderer._unpackStructArray(rawCopy, this._schema);
+ }
+ return rawCopy;
+ }
+
+ /**
+ * Updates a single element in the buffer at a given index. Use this
+ * when only a small number of elements need to change. If you need to
+ * replace all the data at once, use
+ * `update()` instead.
+ *
+ * ```js
+ * let buf;
+ *
+ * async function setup() {
+ * await createCanvas(100, 100, WEBGPU);
+ *
+ * // Float buffer: update one value by index
+ * buf = createStorage(new Float32Array([1, 2, 3, 4]));
+ * buf.set(2, 9.5); // only index 2 changes β [1, 2, 9.5, 4]
+ *
+ * let result = await buf.read();
+ * print(result[2]); // 9.5
+ * describe('Prints 9.5 to the console.');
+ * }
+ * ```
+ *
+ * ```js
+ * let particles;
+ * const numParticles = 100;
+ *
+ * async function setup() {
+ * await createCanvas(100, 100, WEBGPU);
+ * particles = createStorage(makeParticles());
+ *
+ * // Replace particle 42 without touching the others
+ * particles.set(42, {
+ * position: createVector(0, 0),
+ * velocity: createVector(1, 0),
+ * });
+ *
+ * // Read back to confirm the update
+ * let result = await particles.read();
+ * print(result[42].position.x, result[42].position.y); // 0, 0
+ * describe('Prints the position of particle 42 after updating it.');
+ * }
+ *
+ * function makeParticles() {
+ * let data = [];
+ * for (let i = 0; i < numParticles; i++) {
+ * data.push({
+ * position: createVector(random(width), random(height)),
+ * velocity: createVector(random(-1, 1), random(-1, 1)),
+ * });
+ * }
+ * return data;
+ * }
+ * ```
+ *
+ * @method set
+ * @for p5.StorageBuffer
+ * @beta
+ * @webgpu
+ * @webgpuOnly
+ * @param {Number} index The zero-based index of the element to update.
+ * @param {Number|Object} value The new value. Pass a number for float
+ * buffers, or a plain object matching the original struct layout for
+ * struct buffers.
+ */
+ set(index, value) {
+ const device = this._renderer.device;
+
+ if (this._schema !== null) {
+ // buffer was created with an array of structs
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+ throw new Error(
+ 'set() expects a plain object matching the original struct format for this buffer'
+ );
+ }
+
+ const { stride } = this._schema;
+ const byteOffset = index * stride;
+
+ if (byteOffset + stride > this.size) {
+ throw new Error(
+ `set() index ${index} is out of bounds for this buffer ` +
+ `(buffer holds ${Math.floor(this.size / stride)} elements)`
+ );
+ }
+
+ // pack just this one element using the same logic as update()
+ const packed = this._renderer._packStructArray([value], this._schema);
+ // use packed.buffer (ArrayBuffer) so the size arg is always in bytes
+ device.queue.writeBuffer(this.buffer, byteOffset, packed.buffer, 0, stride);
+ } else {
+ // buffer was created with a float array
+ if (typeof value !== 'number') {
+ throw new Error(
+ 'set() expects a number for this float buffer'
+ );
+ }
+
+ const byteOffset = index * 4;
+
+ if (byteOffset + 4 > this.size) {
+ throw new Error(
+ `set() index ${index} is out of bounds for this buffer ` +
+ `(buffer holds ${Math.floor(this.size / 4)} floats)`
+ );
+ }
+
+ device.queue.writeBuffer(this.buffer, byteOffset, new Float32Array([value]));
+ }
+ }
+ }
+
+ /**
+ * A block of data that shaders can read from, and compute shaders can also
+ * write to. This is only available in WebGPU mode.
+ *
+ * Note: `createStorage()` is the recommended
+ * way to create an instance of this class.
+ *
+ * @class p5.StorageBuffer
+ * @beta
+ * @webgpu
+ * @webgpuOnly
+ */
+ p5.StorageBuffer = StorageBuffer;
+
class RendererWebGPU extends Renderer3D {
constructor(pInst, w, h, isMainCanvas, elt) {
super(pInst, w, h, isMainCanvas, elt)
@@ -84,6 +423,9 @@ function rendererWebGPU(p5, fn) {
// Retired buffers to destroy at end of frame
this._retiredBuffers = [];
+ // Storage buffers for compute shaders
+ this._storageBuffers = new Set();
+
// 2D canvas for pixel reading fallback
this._pixelReadCanvas = null;
this._pixelReadCtx = null;
@@ -160,7 +502,7 @@ function rendererWebGPU(p5, fn) {
}
if (this._pInst._webgpuAttributes[key] !== value) {
//changing value of previously altered attribute
- this._webgpuAttributes[key] = value;
+ this._pInst._webgpuAttributes[key] = value;
unchanged = false;
}
//setting all attributes with some change
@@ -294,9 +636,21 @@ function rendererWebGPU(p5, fn) {
const _b = args[2] || 0;
const _a = args[3] || 0;
- // If PENDING and no custom framebuffer, clear means stay UNPROMOTED
- if (this._frameState === FRAME_STATE.PENDING && !this.activeFramebuffer()) {
- this._frameState = FRAME_STATE.UNPROMOTED;
+ // If PENDING and no custom framebuffer, clear means stay UNPROMOTED.
+ // However, if we are still in setup (frameCount == 0), we must promote
+ // so that mainFramebuffer gets the cleared content. This ensures that if
+ // draw() later promotes without a copy, it starts from the correct state
+ // rather than a stale mainFramebuffer.
+ // Note: a mid-draw-loop transition from UNPROMOTED back to PROMOTED
+ // (i.e. calling background() some frames but not others) will still
+ // lose intermediate UNPROMOTED frame content.
+ if (this._frameState !== FRAME_STATE.PROMOTED && !this.activeFramebuffer()) {
+ if (this._pInst.frameCount > 0) {
+ this._frameState = FRAME_STATE.UNPROMOTED;
+ } else {
+ this._promoteToFramebufferWithoutCopy();
+ // clear() then targets mainFramebuffer via activeFramebuffer()
+ }
}
this._finishActiveRenderPass();
@@ -499,7 +853,8 @@ function rendererWebGPU(p5, fn) {
return 4; // Cap at 4 for broader compatibility
}
- _shaderOptions({ mode }) {
+ _shaderOptions({ mode, compute, workgroupSize }) {
+ if (compute) return { compute: true, workgroupSize };
const activeFramebuffer = this.activeFramebuffer();
const format = activeFramebuffer ?
this._getWebGPUColorFormat(activeFramebuffer) :
@@ -510,9 +865,9 @@ function rendererWebGPU(p5, fn) {
1; // No MSAA needed when blitting already-antialiased textures to canvas
const sampleCount = this._getValidSampleCount(requestedSampleCount);
- const depthFormat = activeFramebuffer && activeFramebuffer.useDepth ?
- this._getWebGPUDepthFormat(activeFramebuffer) :
- this.depthFormat;
+ const depthFormat = activeFramebuffer
+ ? (activeFramebuffer.useDepth ? this._getWebGPUDepthFormat(activeFramebuffer) : undefined)
+ : this.depthFormat;
const drawTarget = this.drawTarget();
const clipping = this._clipping;
@@ -540,6 +895,31 @@ function rendererWebGPU(p5, fn) {
_initShader(shader) {
const device = this.device;
+ if (shader.shaderType === 'compute') {
+ // Compute shader initialization
+ shader.computeModule = device.createShaderModule({ code: shader.computeSrc() });
+ shader._computePipelineCache = null;
+ shader._workgroupSize = null;
+
+ // Create compute pipeline (deferred until first compute() call)
+ shader.getPipeline = ({ workgroupSize }) => {
+ if (!shader._computePipelineCache) {
+ shader._computePipelineCache = device.createComputePipeline({
+ layout: shader._pipelineLayout,
+ compute: {
+ module: shader.computeModule,
+ entryPoint: 'main'
+ }
+ });
+ shader._workgroupSize = workgroupSize;
+ }
+ return shader._computePipelineCache;
+ };
+
+ return;
+ }
+
+ // Render shader initialization
shader.vertModule = device.createShaderModule({ code: shader.vertSrc() });
shader.fragModule = device.createShaderModule({ code: shader.fragSrc() });
@@ -564,25 +944,27 @@ function rendererWebGPU(p5, fn) {
},
primitive: { topology },
multisample: { count: sampleCount },
- depthStencil: {
- format: depthFormat,
- depthWriteEnabled: !clipping,
- depthCompare: 'less-equal',
- stencilFront: {
- compare: clipping ? 'always' : (clipApplied ? 'not-equal' : 'always'),
- failOp: 'keep',
- depthFailOp: 'keep',
- passOp: clipping ? 'replace' : 'keep',
- },
- stencilBack: {
- compare: clipping ? 'always' : (clipApplied ? 'not-equal' : 'always'),
- failOp: 'keep',
- depthFailOp: 'keep',
- passOp: clipping ? 'replace' : 'keep',
+ ...(depthFormat ? {
+ depthStencil: {
+ format: depthFormat,
+ depthWriteEnabled: !clipping,
+ depthCompare: 'less-equal',
+ stencilFront: {
+ compare: clipping ? 'always' : (clipApplied ? 'not-equal' : 'always'),
+ failOp: 'keep',
+ depthFailOp: 'keep',
+ passOp: clipping ? 'replace' : 'keep',
+ },
+ stencilBack: {
+ compare: clipping ? 'always' : (clipApplied ? 'not-equal' : 'always'),
+ failOp: 'keep',
+ depthFailOp: 'keep',
+ passOp: clipping ? 'replace' : 'keep',
+ },
+ stencilReadMask: 0xFF,
+ stencilWriteMask: clipping ? 0xFF : 0x00,
},
- stencilReadMask: 0xFF,
- stencilWriteMask: clipping ? 0xFF : 0x00,
- },
+ } : {}),
});
shader._pipelineCache.set(key, pipeline);
}
@@ -638,7 +1020,9 @@ function rendererWebGPU(p5, fn) {
entries.push({
bufferGroup,
binding: bufferGroup.binding,
- visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
+ visibility: shader.shaderType === 'compute'
+ ? GPUShaderStage.COMPUTE
+ : GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
buffer: { type: 'uniform', hasDynamicOffset: bufferGroup.dynamic },
});
structEntries.set(bufferGroup.group, entries);
@@ -672,6 +1056,24 @@ function rendererWebGPU(p5, fn) {
groupEntries.set(group, entries);
}
+ // Add storage buffer bindings
+ for (const storage of shader._storageBuffers || []) {
+ const group = storage.group;
+ const entries = groupEntries.get(group) || [];
+
+ entries.push({
+ binding: storage.binding,
+ visibility: storage.visibility,
+ buffer: {
+ type: storage.accessMode === 'read' ? 'read-only-storage' : 'storage'
+ },
+ storage: storage,
+ });
+
+ entries.sort((a, b) => a.binding - b.binding);
+ groupEntries.set(group, entries);
+ }
+
// Create layouts and bind groups
const groupEntriesArr = [];
for (const [group, entries] of groupEntries) {
@@ -690,6 +1092,7 @@ function rendererWebGPU(p5, fn) {
shader._pipelineLayout = this.device.createPipelineLayout({
bindGroupLayouts: shader._bindGroupLayouts,
});
+ shader._compiled = true;
}
_getBlendState(mode) {
@@ -936,8 +1339,11 @@ function rendererWebGPU(p5, fn) {
_resetBuffersBeforeDraw() {
this._finishActiveRenderPass();
+
// Set state to PENDING - we'll decide on first draw
- this._frameState = FRAME_STATE.PENDING;
+ if (this._pInst.frameCount > 0) {
+ this._frameState = FRAME_STATE.PENDING;
+ }
// Clear depth buffer but DON'T start any render pass yet
const activeFramebuffer = this.activeFramebuffer();
@@ -1048,6 +1454,8 @@ function rendererWebGPU(p5, fn) {
// once we're drawing to the framebuffer, because normally
// those are reset.
const savedModelMatrix = this.states.uModelMatrix.copy();
+ this.states.uModelMatrix.set(this.states.uModelMatrix.copy());
+ this.states.uModelMatrix.reset();
this.mainFramebuffer.defaultCamera.set(this.states.curCamera);
this.mainFramebuffer.begin();
@@ -1056,6 +1464,11 @@ function rendererWebGPU(p5, fn) {
}
_promoteToFramebufferWithoutCopy() {
+ // Already promoted this frame
+ if (this._frameState === FRAME_STATE.PROMOTED) {
+ return;
+ }
+
// Ensure mainFramebuffer matches canvas size
if (this.mainFramebuffer.width !== this.width ||
this.mainFramebuffer.height !== this.height) {
@@ -1070,6 +1483,8 @@ function rendererWebGPU(p5, fn) {
// Preserve transformation state
const savedModelMatrix = this.states.uModelMatrix.copy();
+ this.states.uModelMatrix.set(this.states.uModelMatrix.copy());
+ this.states.uModelMatrix.reset();
this.mainFramebuffer.defaultCamera.set(this.states.curCamera);
// Begin rendering to mainFramebuffer
@@ -1383,7 +1798,6 @@ function rendererWebGPU(p5, fn) {
}
this.flushDraw();
- // this._pInst.background('red');
this._pInst.push();
this.states.setValue('enableLighting', false);
this.states.setValue('activeImageLight', null);
@@ -1448,25 +1862,9 @@ function rendererWebGPU(p5, fn) {
this._beginActiveRenderPass();
const passEncoder = this.activeRenderPass;
- const currentShader = this._curShader;
- const shaderOptions = this._shaderOptions({ mode });
- if (this.activeShader !== currentShader || this._shaderOptionsDifferent(shaderOptions)) {
- passEncoder.setPipeline(currentShader.getPipeline(shaderOptions));
- }
- this.activeShader = currentShader;
- this.activeShaderOptions = shaderOptions;
- // Set stencil reference value for clipping
- const drawTarget = this.drawTarget();
- if (drawTarget._isClipApplied && !this._clipping) {
- // When using the clip mask, test against reference value 0 (background)
- // WebGL uses NOTEQUAL with ref 0, so fragments pass where stencil != 0
- // In WebGPU with 'not-equal', we need ref 0 to pass where stencil != 0
- passEncoder.setStencilReference(0);
- } else if (this._clipping) {
- // When writing to the clip mask, write reference value 1
- passEncoder.setStencilReference(1);
- }
+ const currentShader = this._curShader;
+ this.setupShaderBindGroups(currentShader, passEncoder, { mode, buffers });
// Bind vertex buffers
for (const buffer of currentShader._vertexBuffers || this._getVertexBuffers(currentShader)) {
const location = currentShader.attributes[buffer.attr].location;
@@ -1474,6 +1872,58 @@ function rendererWebGPU(p5, fn) {
passEncoder.setVertexBuffer(location, gpuBuffer, 0);
}
+ if (currentShader.shaderType === "fill") {
+ // Bind index buffer and issue draw
+ if (buffers.indexBuffer) {
+ const indexFormat = buffers.indexFormat || "uint16";
+ passEncoder.setIndexBuffer(buffers.indexBuffer, indexFormat);
+ passEncoder.drawIndexed(geometry.faces.length * 3, count, 0, 0, 0);
+ } else {
+ passEncoder.draw(geometry.vertices.length, count, 0, 0);
+ }
+ } else if (currentShader.shaderType === "text") {
+ if (!buffers.indexBuffer) {
+ throw new Error("Text geometry must have an index buffer");
+ }
+ const indexFormat = buffers.indexFormat || "uint16";
+ passEncoder.setIndexBuffer(buffers.indexBuffer, indexFormat);
+ passEncoder.drawIndexed(geometry.faces.length * 3, count, 0, 0, 0);
+ }
+
+ if (buffers.lineVerticesBuffer && currentShader.shaderType === "stroke") {
+ passEncoder.draw(geometry.lineVertices.length / 3, count, 0, 0);
+ }
+
+ // Mark that we have pending draws that need submission
+ this._hasPendingDraws = true;
+ }
+
+ setupShaderBindGroups(currentShader, passEncoder, shaderOptionsParams) {
+ const shaderOptions = this._shaderOptions(shaderOptionsParams);
+ if (
+ shaderOptions.compute ||
+ this.activeShader !== currentShader ||
+ this._shaderOptionsDifferent(shaderOptions)
+ ) {
+ passEncoder.setPipeline(currentShader.getPipeline(shaderOptions));
+ }
+ if (!shaderOptions.compute) {
+ this.activeShader = currentShader;
+ this.activeShaderOptions = shaderOptions;
+
+ // Set stencil reference value for clipping
+ const drawTarget = this.drawTarget();
+ if (drawTarget._isClipApplied && !this._clipping) {
+ // When using the clip mask, test against reference value 0 (background)
+ // WebGL uses NOTEQUAL with ref 0, so fragments pass where stencil != 0
+ // In WebGPU with 'not-equal', we need ref 0 to pass where stencil != 0
+ passEncoder.setStencilReference(0);
+ } else if (this._clipping) {
+ // When writing to the clip mask, write reference value 1
+ passEncoder.setStencilReference(1);
+ }
+ }
+
for (const bufferGroup of currentShader._uniformBufferGroups) {
if (bufferGroup.dynamic) {
// Bind uniforms into a part of a big dynamic memory block because
@@ -1526,6 +1976,13 @@ function rendererWebGPU(p5, fn) {
currentShader.buffersDirty.delete(key);
}
}
+ for (const storage of currentShader._storageBuffers || []) {
+ const key = storage.group * 1000 + storage.binding;
+ if (currentShader.buffersDirty.has(key)) {
+ currentShader._cachedBindGroup[storage.group] = undefined;
+ currentShader.buffersDirty.delete(key);
+ }
+ }
// Bind sampler/texture uniforms and uniform buffers
for (const iter of currentShader._groupEntries) {
@@ -1555,6 +2012,19 @@ function rendererWebGPU(p5, fn) {
: { buffer: uniformBufferInfo.buffer },
});
}
+ } else if (entry.storage && !bindGroup) {
+ // Storage buffer binding
+ const uniform = currentShader.uniforms[entry.storage.name];
+ if (!uniform || !uniform._cachedData || !uniform._cachedData._isStorageBuffer) {
+ throw new Error(
+ `Storage buffer "${entry.storage.name}" not set. ` +
+ `Use shader.setUniform("${entry.storage.name}", storageBuffer)`
+ );
+ }
+ bgEntries.push({
+ binding: entry.binding,
+ resource: { buffer: uniform._cachedData.buffer },
+ });
} else if (!bindGroup) {
bgEntries.push({
binding: entry.binding,
@@ -1588,84 +2058,71 @@ function rendererWebGPU(p5, fn) {
);
}
}
+ return passEncoder;
+ }
- if (currentShader.shaderType === "fill") {
- // Bind index buffer and issue draw
- if (buffers.indexBuffer) {
- const indexFormat = buffers.indexFormat || "uint16";
- passEncoder.setIndexBuffer(buffers.indexBuffer, indexFormat);
- passEncoder.drawIndexed(geometry.faces.length * 3, count, 0, 0, 0);
+ //////////////////////////////////////////////
+ // SHADER
+ //////////////////////////////////////////////
+
+ // Writes a single field's value into a Float32Array+DataView at (baseOffset + field.offset).
+ //
+ // Field interface (shared by uniform fields from _parseStruct and struct storage schema fields):
+ // baseType: string - 'f32', 'i32', 'u32', etc.
+ // size: number - byte size of the field
+ // offset: number - byte offset of the field within its struct
+ // packInPlace: bool - true for mat3, written with manual column padding
+ //
+ // value: number or number[] - the data to write
+ _packField(field, value, floatView, dataView, baseOffset) {
+ if (value === undefined) return;
+
+ // Duck typing instead of instanceof to avoid importing a separate
+ // copy of the Color/Vector classes
+ if (value?.isVector) {
+ value = value.values.length !== value.dimensions ? value.values.slice(0, value.dimensions) : value.values;
+ } else if (value?.isColor) {
+ value = value._getRGBA([1, 1, 1, 1]);
+ }
+ const byteOffset = baseOffset + field.offset;
+ if (field.baseType === 'u32') {
+ if (field.size === 4) {
+ dataView.setUint32(byteOffset, value, true);
} else {
- passEncoder.draw(geometry.vertices.length, count, 0, 0);
+ for (let i = 0; i < value.length; i++) {
+ dataView.setUint32(byteOffset + i * 4, value[i], true);
+ }
}
- } else if (currentShader.shaderType === "text") {
- if (!buffers.indexBuffer) {
- throw new Error("Text geometry must have an index buffer");
+ } else if (field.baseType === 'i32') {
+ if (field.size === 4) {
+ dataView.setInt32(byteOffset, value, true);
+ } else {
+ for (let i = 0; i < value.length; i++) {
+ dataView.setInt32(byteOffset + i * 4, value[i], true);
+ }
}
- const indexFormat = buffers.indexFormat || "uint16";
- passEncoder.setIndexBuffer(buffers.indexBuffer, indexFormat);
- passEncoder.drawIndexed(geometry.faces.length * 3, count, 0, 0, 0);
- }
-
- if (buffers.lineVerticesBuffer && currentShader.shaderType === "stroke") {
- passEncoder.draw(geometry.lineVertices.length / 3, count, 0, 0);
+ } else if (field.packInPlace) {
+ // In-place packing for mat3: write directly to buffer with padding
+ const base = byteOffset / 4;
+ floatView[base + 0] = value[0]; floatView[base + 1] = value[1]; floatView[base + 2] = value[2];
+ floatView[base + 4] = value[3]; floatView[base + 5] = value[4]; floatView[base + 6] = value[5];
+ floatView[base + 8] = value[6]; floatView[base + 9] = value[7]; floatView[base + 10] = value[8];
+ } else if (field.size === 4) {
+ floatView.set([value], byteOffset / 4);
+ } else {
+ floatView.set(value, byteOffset / 4);
}
-
- // Mark that we have pending draws that need submission
- this._hasPendingDraws = true;
}
- //////////////////////////////////////////////
- // SHADER
- //////////////////////////////////////////////
-
_packUniformGroup(shader, groupUniforms, bufferInfo) {
// Pack a single group's uniforms into a buffer
const data = bufferInfo.data;
const dataView = bufferInfo.dataView;
-
const offset = bufferInfo.offset || 0;
for (const uniform of groupUniforms) {
const fullUniform = shader.uniforms[uniform.name];
if (!fullUniform || fullUniform.isSampler) continue;
- const uniformData = fullUniform._mappedData;
-
- if (fullUniform.baseType === 'u32') {
- if (fullUniform.size === 4) {
- dataView.setUint32(offset + fullUniform.offset, uniformData, true);
- } else {
- for (let i = 0; i < uniformData.length; i++) {
- dataView.setUint32(offset + fullUniform.offset + i * 4, uniformData[i], true);
- }
- }
- } else if (fullUniform.baseType === 'i32') {
- if (fullUniform.size === 4) {
- dataView.setInt32(offset + fullUniform.offset, uniformData, true);
- } else {
- for (let i = 0; i < uniformData.length; i++) {
- dataView.setInt32(offset + fullUniform.offset + i * 4, uniformData[i], true);
- }
- }
- } else if (fullUniform.packInPlace) {
- // In-place packing for mat3: write directly to buffer with padding
- const baseOffset = (offset + fullUniform.offset) / 4;
- // Column 0
- data[baseOffset + 0] = uniformData[0];
- data[baseOffset + 1] = uniformData[1];
- data[baseOffset + 2] = uniformData[2];
- // Column 1
- data[baseOffset + 4] = uniformData[3];
- data[baseOffset + 5] = uniformData[4];
- data[baseOffset + 6] = uniformData[5];
- // Column 2
- data[baseOffset + 8] = uniformData[6];
- data[baseOffset + 9] = uniformData[7];
- data[baseOffset + 10] = uniformData[8];
- } else if (fullUniform.size === 4) {
- data.set([uniformData], (offset + fullUniform.offset) / 4);
- } else if (uniformData !== undefined) {
- data.set(uniformData, (offset + fullUniform.offset) / 4);
- }
+ this._packField(fullUniform, fullUniform._mappedData, data, dataView, offset);
}
}
@@ -1812,10 +2269,11 @@ function rendererWebGPU(p5, fn) {
const uniformVarRegex = /@group\((\d+)\)\s+@binding\((\d+)\)\s+var\s+(\w+)\s*:\s*(\w+);/g;
let match;
- while ((match = uniformVarRegex.exec(shader.vertSrc())) !== null) {
+ const src = shader.shaderType === 'compute' ? shader.computeSrc() : shader.vertSrc();
+ while ((match = uniformVarRegex.exec(src)) !== null) {
const [_, groupNum, binding, varName, structType] = match;
const bindingIndex = parseInt(binding);
- const uniforms = this._parseStruct(shader.vertSrc(), structType);
+ const uniforms = this._parseStruct(src, structType);
uniformGroups.push({
group: parseInt(groupNum),
@@ -1826,7 +2284,7 @@ function rendererWebGPU(p5, fn) {
});
}
- if (uniformGroups.length === 0) {
+ if (uniformGroups.length === 0 && shader.shaderType !== 'compute') {
throw new Error('Expected at least one uniform struct bound to @group(0)');
}
@@ -1853,6 +2311,10 @@ function rendererWebGPU(p5, fn) {
// TODO: support other texture types
const samplerRegex = /@group\((\d+)\)\s*@binding\((\d+)\)\s*var\s+(\w+)\s*:\s*(texture_2d|sampler);/g;
+ // Extract storage buffers
+ const storageBuffers = {};
+ const storageRegex = /@group\((\d+)\)\s*@binding\((\d+)\)\s*var\s+(\w+)\s*:\s*array<\w+>/g;
+
// Track which bindings are taken by the struct properties we've parsed
// (the rest should be textures/samplers)
const structUniformBindings = {};
@@ -1862,8 +2324,11 @@ function rendererWebGPU(p5, fn) {
for (const [src, visibility] of [
[shader.vertSrc(), GPUShaderStage.VERTEX],
- [shader.fragSrc(), GPUShaderStage.FRAGMENT]
+ [shader.fragSrc(), GPUShaderStage.FRAGMENT],
+ [shader.computeSrc ? shader.computeSrc() : null, GPUShaderStage.COMPUTE]
]) {
+ if (!src) continue; // Skip if shader stage doesn't exist
+
let match;
while ((match = samplerRegex.exec(src)) !== null) {
const [_, group, binding, name, type] = match;
@@ -1898,21 +2363,51 @@ function rendererWebGPU(p5, fn) {
samplerNode.textureSource = sampler;
}
}
+
+ // Parse storage buffers
+ while ((match = storageRegex.exec(src)) !== null) {
+ const [_, group, binding, accessMode, name] = match;
+ const groupIndex = parseInt(group);
+ const bindingIndex = parseInt(binding);
+
+ const key = `${groupIndex},${bindingIndex}`;
+ const existing = storageBuffers[key];
+ // If any stage uses read_write, the bind group layout must use read_write
+ const finalAccessMode = (existing?.accessMode === 'read_write' || accessMode === 'read_write')
+ ? 'read_write'
+ : accessMode;
+
+ storageBuffers[key] = {
+ visibility: (existing?.visibility || 0) | visibility,
+ group: groupIndex,
+ binding: bindingIndex,
+ name,
+ accessMode: finalAccessMode, // 'read' or 'read_write'
+ isStorage: true,
+ type: 'storage'
+ };
+ }
}
- return [...Object.values(allUniforms).sort((a, b) => a.index - b.index), ...Object.values(samplers)];
+
+ // Store storage buffers on shader for later use
+ shader._storageBuffers = Object.values(storageBuffers);
+
+ return [...Object.values(allUniforms).sort((a, b) => a.index - b.index), ...Object.values(samplers), ...Object.values(storageBuffers)];
}
- getNextBindingIndex({ vert, frag }, group = 0) {
+ getNextBindingIndex({ vert, frag, compute }, group = 0) {
// Get the highest binding index in the specified group and return the next available
- const samplerRegex = /@group\((\d+)\)\s*@binding\((\d+)\)\s*var(?:)?\s+(\w+)\s*:\s*(texture_2d|sampler|uniform|\w+)/g;
+ const bindingRegex = /@group\((\d+)\)\s*@binding\((\d+)\)/g;
let maxBindingIndex = -1;
- for (const [src, visibility] of [
- [vert, GPUShaderStage.VERTEX],
- [frag, GPUShaderStage.FRAGMENT]
- ]) {
+ const sources = [];
+ if (vert) sources.push([vert, GPUShaderStage.VERTEX]);
+ if (frag) sources.push([frag, GPUShaderStage.FRAGMENT]);
+ if (compute) sources.push([compute, GPUShaderStage.COMPUTE]);
+
+ for (const [src, visibility] of sources) {
let match;
- while ((match = samplerRegex.exec(src)) !== null) {
+ while ((match = bindingRegex.exec(src)) !== null) {
const [_, groupIndex, bindingIndex] = match;
if (parseInt(groupIndex) === group) {
maxBindingIndex = Math.max(maxBindingIndex, parseInt(bindingIndex));
@@ -1927,7 +2422,7 @@ function rendererWebGPU(p5, fn) {
if (uniform.isSampler) {
uniform.texture =
data instanceof Texture ? data : this.getTexture(data);
- } else {
+ } else if (!data?._isStorageBuffer) {
uniform._mappedData = this._mapUniformData(uniform, uniform._cachedData);
}
shader.buffersDirty.add(uniform.group * 1000 + uniform.binding);
@@ -2034,7 +2529,7 @@ function rendererWebGPU(p5, fn) {
rgb += components.emissive;
return vec4(rgb, components.opacity);
}`,
- "vec4f getFinalColor": "(color: vec4) { return color; }",
+ "vec4f getFinalColor": "(color: vec4, texCoord: vec2) { return color; }",
"void afterFragment": "() {}",
},
}
@@ -2059,7 +2554,7 @@ function rendererWebGPU(p5, fn) {
},
fragment: {
"void beforeFragment": "() {}",
- "vec4 getFinalColor": "(color: vec4) { return color; }",
+ "vec4 getFinalColor": "(color: vec4, texCoord: vec2) { return color; }",
"void afterFragment": "() {}",
},
}
@@ -2085,7 +2580,7 @@ function rendererWebGPU(p5, fn) {
fragment: {
"void beforeFragment": "() {}",
"Inputs getPixelInputs": "(inputs: Inputs) { return inputs; }",
- "vec4 getFinalColor": "(color: vec4) { return color; }",
+ "vec4 getFinalColor": "(color: vec4, texCoord: vec2) { return color; }",
"bool shouldDiscard": "(outside: bool) { return outside; };",
"void afterFragment": "() {}",
},
@@ -2244,11 +2739,87 @@ function rendererWebGPU(p5, fn) {
}
);
- let [preMain, main, postMain] = src.split(/((?:@(?:vertex|fragment)\s*)?fn main[^{]+\{)/);
- if (shaderType !== 'fragment') {
- if (!main.match(/\@builtin\s*\(\s*instance_index\s*\)/)) {
- main = main.replace(/\)\s*(->|\{)/, ', @builtin(instance_index) instanceID: u32) $1');
+ let [preMain, main, postMain] = src.split(/((?:@(?:vertex|fragment|compute)\s*(?:@workgroup_size\([^)]+\)\s*)?)?fn main[^{]+\{)/);
+
+ const getBuiltinParamName = (mainSrc, builtinName) => {
+ const match = new RegExp(`@builtin\\s*\\(\\s*${builtinName}\\s*\\)\\s*(\\w+)\\s*:`).exec(mainSrc);
+ return match ? match[1] : null;
+ };
+
+ const ensureBuiltinParam = (mainSrc, builtinName, fallbackName, typeName) => {
+ const existingName = getBuiltinParamName(mainSrc, builtinName);
+ if (existingName) {
+ return { mainSrc, argName: existingName };
}
+
+ const hasParams = /\(\s*\S/.test(mainSrc);
+ const injectedMain = mainSrc.replace(
+ /\)\s*(->|\{)/,
+ `${hasParams ? ', ' : ''}@builtin(${builtinName}) ${fallbackName}: ${typeName}) $1`
+ );
+
+ return { mainSrc: injectedMain, argName: fallbackName };
+ };
+
+ const getMainStructParameter = (mainSrc) => {
+ const match = /fn main\s*\(\s*(\w+)\s*:\s*(\w+)/.exec(mainSrc);
+ if (!match) return null;
+ return { inputName: match[1], structName: match[2] };
+ };
+
+ const getStructBuiltinFieldName = (structName, builtinName) => {
+ const structMatch = new RegExp(`struct\\s+${structName}\\s*\\{([^}]*)\\}`, 's').exec(preMain);
+ if (!structMatch) return null;
+ const fieldMatch = new RegExp(`@builtin\\s*\\(\\s*${builtinName}\\s*\\)\\s*(\\w+)\\s*:`, 's').exec(structMatch[1]);
+ return fieldMatch ? fieldMatch[1] : null;
+ };
+
+ const appendHookParams = (params, additionalParams) => {
+ if (additionalParams.length === 0) return params;
+ const hasParams = !/^\(\s*\)$/.test(params);
+ return `${params.slice(0, -1)}${hasParams ? ', ' : ''}${additionalParams.join(', ')})`;
+ };
+
+ let hookExtraParams = [];
+ let hookExtraArgs = [];
+
+ if (shaderType === 'vertex') {
+ const ensuredInstance = ensureBuiltinParam(main, 'instance_index', 'instanceID', 'u32');
+ main = ensuredInstance.mainSrc;
+
+ const ensuredVertex = ensureBuiltinParam(main, 'vertex_index', '_p5VertexId', 'u32');
+ main = ensuredVertex.mainSrc;
+
+ hookExtraParams = ['instanceID: u32', '_p5VertexId: u32'];
+ hookExtraArgs = [ensuredInstance.argName, ensuredVertex.argName];
+ } else if (shaderType === 'fragment') {
+ const directPositionArg = getBuiltinParamName(main, 'position');
+ let fragmentPositionArg = directPositionArg;
+
+ if (!fragmentPositionArg) {
+ const mainStructParam = getMainStructParameter(main);
+ if (mainStructParam) {
+ const positionField = getStructBuiltinFieldName(mainStructParam.structName, 'position');
+ if (positionField) {
+ fragmentPositionArg = `${mainStructParam.inputName}.${positionField}`;
+ }
+ }
+ }
+
+ if (!fragmentPositionArg) {
+ const ensuredPosition = ensureBuiltinParam(main, 'position', '_p5FragPos', 'vec4');
+ main = ensuredPosition.mainSrc;
+ fragmentPositionArg = ensuredPosition.argName;
+ }
+
+ hookExtraParams = ['_p5FragPos: vec4'];
+ hookExtraArgs = [fragmentPositionArg];
+ } else if (shaderType === 'compute') {
+ const ensuredGlobalId = ensureBuiltinParam(main, 'global_invocation_id', '_p5GlobalId', 'vec3');
+ main = ensuredGlobalId.mainSrc;
+
+ hookExtraParams = ['_p5GlobalId: vec3'];
+ hookExtraArgs = [ensuredGlobalId.argName];
}
// Inject hook uniforms as a separate struct at a new binding
@@ -2268,6 +2839,7 @@ function rendererWebGPU(p5, fn) {
const nextBinding = this.getNextBindingIndex({
vert: shaderType === 'vertex' ? preMain + (shader.hooks.vertex?.declarations ?? '') + shader.hooks.declarations : shader._vertSrc,
frag: shaderType === 'fragment' ? preMain + (shader.hooks.fragment?.declarations ?? '') + shader.hooks.declarations : shader._fragSrc,
+ compute: shaderType === 'compute' ? preMain + (shader.hooks.compute?.declarations ?? '') + shader.hooks.declarations : shader._computeSrc,
}, 0);
// Create HookUniforms struct and binding
@@ -2278,8 +2850,14 @@ ${hookUniformFields}}
@group(0) @binding(${nextBinding}) var hooks: HookUniforms;
`;
- // Insert before the first @group binding
- preMain = preMain.replace(/(@group\(0\)\s+@binding)/, `${hookUniformsDecl}\n$1`);
+ // Insert before the first @group binding, or at the end if there are none
+ const replaced = preMain.replace(/(@group\(0\)\s+@binding)/, `${hookUniformsDecl}\n$1`);
+ if (replaced === preMain) {
+ // No @group bindings found in base shader, append to preMain
+ preMain = preMain + '\n' + hookUniformsDecl;
+ } else {
+ preMain = replaced;
+ }
}
// Handle varying variables by injecting them into VertexOutput and FragmentInput structs
@@ -2345,10 +2923,9 @@ ${hookUniformFields}}
initStatements += ` ${varName} = INPUT_VAR.${varName};\n`;
}
- // Find the input parameter name from the main function signature (anchored to start)
- const inputMatch = main.match(/fn main\s*\((\w+):\s*\w+\)/);
- if (inputMatch) {
- const inputVarName = inputMatch[1];
+ const mainStructParam = getMainStructParameter(main);
+ if (mainStructParam) {
+ const inputVarName = mainStructParam.inputName;
initStatements = initStatements.replace(/INPUT_VAR/g, inputVarName);
// Insert after the main function parameter but before any other code (anchored to start)
postMain = initStatements + postMain;
@@ -2356,12 +2933,56 @@ ${hookUniformFields}}
}
}
+ // Handle instanceID varying for fragment access
+ if (shader.hooks.instanceIDVarying) {
+ const { name, declaration, source, interpolation } = shader.hooks.instanceIDVarying;
+ const nextLocIndex = this._getNextAvailableLocation(preMain, shaderType);
+ const interpAttr = interpolation ? ` @interpolate(${interpolation})` : '';
+ const [varName, varType] = declaration.split(':').map(s => s.trim());
+ const structMember = `@location(${nextLocIndex})${interpAttr} ${declaration},`;
+
+ if (shaderType === 'vertex') {
+ // Inject into VertexOutput struct
+ preMain = preMain.replace(
+ /struct\s+VertexOutput\s+\{([^}]*)\}/,
+ (match, body) => `struct VertexOutput {${body}\n${structMember}}`
+ );
+ // Add private global
+ preMain += `var ${declaration};\n`;
+ // Assign from built-in instanceID at start of main()
+ postMain = `\n ${varName} = ${source};\n` + postMain;
+ // Copy to output struct before return
+ const returnMatch = postMain.match(/return\s+(\w+)\s*;/);
+ if (returnMatch) {
+ const outputVarName = returnMatch[1];
+ postMain = postMain.replace(
+ /(return\s+\w+\s*;)/g,
+ `${outputVarName}.${varName} = ${varName};\n $1`
+ );
+ }
+ } else if (shaderType === 'fragment') {
+ // Inject into FragmentInput struct
+ preMain = preMain.replace(
+ /struct\s+FragmentInput\s+\{([^}]*)\}/,
+ (match, body) => `struct FragmentInput {${body}\n${structMember}}`
+ );
+ // Add private global
+ preMain += `var ${declaration};\n`;
+ // Initialize from input struct at start of main()
+ const mainStructParam = getMainStructParameter(main);
+ if (mainStructParam) {
+ const inputVarName = mainStructParam.inputName;
+ postMain = `\n ${varName} = ${inputVarName}.${varName};\n` + postMain;
+ }
+ }
+ }
+
let hooks = '';
let defines = '';
if (shader.hooks.declarations) {
hooks += shader.hooks.declarations + '\n';
}
- if (shader.hooks[shaderType].declarations) {
+ if (shader.hooks[shaderType] && shader.hooks[shaderType].declarations) {
hooks += shader.hooks[shaderType].declarations + '\n';
}
for (const hookDef in shader.hooks.helpers) {
@@ -2385,11 +3006,7 @@ ${hookUniformFields}}
let [_, params, body] = /^(\([^\)]*\))((?:.|\n)*)$/.exec(shader.hooks[shaderType][hookDef]);
- if (shaderType !== 'fragment') {
- // Splice the instance ID in as a final parameter to every WGSL hook function
- let hasParams = !!params.match(/^\(\s*\S+.*\)$/);
- params = params.slice(0, -1) + (hasParams ? ', ' : '') + 'instanceID: u32)';
- }
+ params = appendHookParams(params, hookExtraParams);
if (hookType === 'void') {
hooks += `fn HOOK_${hookName}${params}${body}\n`;
@@ -2398,40 +3015,45 @@ ${hookUniformFields}}
}
}
- // Add the instance ID as a final parameter to each hook call
- if (shaderType !== 'fragment') {
- const addInstanceIDParam = (src) => {
- let result = src;
- let idx = 0;
- let match;
- do {
- match = /HOOK_\w+\(/.exec(result.slice(idx));
- if (match) {
- idx += match.index + match[0].length - 1;
- let nesting = 0;
- let hasParams = false;
- while (idx < result.length) {
- if (result[idx] === '(') {
- nesting++;
- } else if (result[idx] === ')') {
- nesting--;
- } else if (result[idx].match(/\S/)) {
- hasParams = true;
- }
- idx++;
- if (nesting === 0) {
- break;
- }
+ // Pass stage-specific builtins from main to each hook call.
+ // Collect ALL HOOK_ calls (including nested ones) then insert
+ // extra args from right to left so position shifts don't
+ // invalidate earlier insertion points.
+ if (hookExtraArgs.length > 0) {
+ const addHookArgs = (src) => {
+ const insertions = [];
+ let searchIdx = 0;
+ let m;
+ while ((m = /HOOK_\w+\(/.exec(src.slice(searchIdx))) !== null) {
+ const openParen = searchIdx + m.index + m[0].length - 1;
+ let pos = openParen + 1;
+ let nesting = 1;
+ let hasParams = false;
+ while (pos < src.length && nesting > 0) {
+ if (src[pos] === '(') nesting++;
+ else if (src[pos] === ')') {
+ nesting--;
+ if (nesting === 0) break;
+ } else if (/\S/.test(src[pos])) {
+ hasParams = true;
}
- const insertion = (hasParams ? ', ' : '') + 'instanceID';
- result = result.slice(0, idx-1) + insertion + result.slice(idx-1);
- idx += insertion.length;
+ pos++;
}
- } while (match);
+ insertions.push({ pos, hasParams });
+ searchIdx = openParen + 1;
+ }
+
+ insertions.sort((a, b) => b.pos - a.pos);
+
+ let result = src;
+ for (const { pos, hasParams } of insertions) {
+ const insertion = (hasParams ? ', ' : '') + hookExtraArgs.join(', ');
+ result = result.slice(0, pos) + insertion + result.slice(pos);
+ }
return result;
};
- preMain = addInstanceIDParam(preMain);
- postMain = addInstanceIDParam(postMain);
+ preMain = addHookArgs(preMain);
+ postMain = addHookArgs(postMain);
}
return preMain + '\n' + defines + hooks + main + postMain;
@@ -2490,6 +3112,10 @@ ${hookUniformFields}}
body = shader.hooks.fragment[hookName];
fullSrc = shader._fragSrc;
}
+ if (!body) {
+ body = shader.hooks.compute[hookName];
+ fullSrc = shader._computeSrc;
+ }
if (!body) {
throw new Error(`Can't find hook ${hookName}!`);
}
@@ -2621,7 +3247,7 @@ ${hookUniformFields}}
}
defaultFramebufferAntialias() {
- return true;
+ return this._pInst._webgpuAttributes?.antialias !== false;
}
supportsFramebufferAntialias() {
@@ -2814,6 +3440,267 @@ ${hookUniformFields}}
};
}
+ // Maps a plain JS value to the WGSL type string that represents it in a struct.
+ _jsValueToWgslType(value) {
+ if (typeof value === 'number') return 'f32';
+ // Duck typing instead of instanceof to avoid importing a separate
+ // copy of the Color/Vector classes
+ if (value?.isVector) {
+ if (value.dimensions === 2) return 'vec2f';
+ if (value.dimensions === 3) return 'vec3f';
+ if (value.dimensions === 4) return 'vec4f';
+ throw new Error(`Unsupported vector dimension ${value.dimensions} for struct storage field`);
+ }
+ if (value?.isColor) {
+ return 'vec4f';
+ }
+ if (Array.isArray(value)) {
+ if (value.length === 2) return 'vec2f';
+ if (value.length === 3) return 'vec3f';
+ if (value.length === 4) return 'vec4f';
+ throw new Error(`Unsupported array length ${value.length} for struct storage field`);
+ }
+ throw new Error(`Unsupported value type ${typeof value} for struct storage field`);
+ }
+
+ // Infers a struct schema from the first element of a struct array.
+ //
+ // Returns { fields, stride, structBody } where:
+ // fields: field has the _packField interface (baseType, size, offset, packInPlace) plus:
+ // name: string - JS property name
+ // dim: number - float component count, used when creating StrandsNodes
+ // structBody: everything inside the { ... } of a WGSL struct definition
+ // stride: how many bytes are reserved for this struct in the buffer
+ _inferStructSchema(firstElement) {
+ const entries = Object.entries(firstElement);
+
+ if (!p5.disableFriendlyErrors) {
+ for (const [name, value] of entries) {
+ if (
+ value !== null &&
+ typeof value === 'object' &&
+ !Array.isArray(value) &&
+ // Duck typing instead of instanceof to avoid importing a separate
+ // copy of the Color/Vector classes
+ !value?.isVector &&
+ !value?.isColor
+ ) {
+ p5._friendlyError(
+ `The "${name}" property in your storage data contains a nested object. ` +
+ `Make sure you only use properties with numbers, arrays of numbers, or p5.Vector.`,
+ 'createStorage'
+ );
+ }
+ }
+ }
+
+ const fieldLines = entries.map(([name, value]) =>
+ ` ${name}: ${this._jsValueToWgslType(value)},`
+ ).join('\n');
+ const structBody = `{\n${fieldLines}\n}`;
+ const elements = this._parseStruct(`struct _Tmp ${structBody}`, '_Tmp');
+
+ let maxEnd = 0;
+ let maxAlign = 1;
+ const fields = entries.map(([name, value]) => {
+ const el = elements[name];
+ maxEnd = Math.max(maxEnd, el.offsetEnd);
+ // Alignment for scalars/vectors: <=4 -> 4, <=8 -> 8, else 16
+ const align = el.size <= 4 ? 4 : el.size <= 8 ? 8 : 16;
+ maxAlign = Math.max(maxAlign, align);
+ // Track original JS type for reconstruction during readback
+ const kind = value?.isVector ? 'vector'
+ : value?.isColor ? 'color'
+ : undefined;
+ return {
+ name,
+ baseType: el.baseType,
+ size: el.size,
+ offset: el.offset,
+ packInPlace: el.packInPlace ?? false,
+ dim: el.size / 4,
+ kind,
+ };
+ });
+
+ const stride = Math.ceil(maxEnd / maxAlign) * maxAlign;
+ return { fields, stride, structBody };
+ }
+
+ // Packs an array of plain objects into a Float32Array using the given struct schema.
+ // Reuses _packField so layout rules match uniform packing exactly.
+ _packStructArray(data, schema) {
+ const { fields, stride } = schema;
+ const totalBytes = Math.max(data.length * stride, 16);
+ const alignedBytes = Math.ceil(totalBytes / 16) * 16;
+ const buffer = new ArrayBuffer(alignedBytes);
+ const floatView = new Float32Array(buffer);
+ const dataView = new DataView(buffer);
+ for (let i = 0; i < data.length; i++) {
+ const item = data[i];
+ const baseOffset = i * stride;
+ for (const field of fields) {
+ this._packField(field, item[field.name], floatView, dataView, baseOffset);
+ }
+ }
+ return floatView;
+ }
+
+ // Inverse of _packStructArray reads packed buffer back into plain JS objects
+ // using the same schema layout - fields, stride and offsets
+ _unpackStructArray(floatView, schema) {
+ const { fields, stride } = schema;
+ const dataView = new DataView(floatView.buffer);
+ const count = Math.floor(floatView.byteLength / stride);
+ const result = [];
+
+ for (let i = 0; i < count; i++) {
+ const item = {};
+ const baseOffset = i * stride;
+ for (const field of fields) {
+ const byteOffset = baseOffset + field.offset;
+ const n = field.size / 4;
+
+ if (field.baseType === 'u32') {
+ if (n === 1) {
+ item[field.name] = dataView.getUint32(byteOffset, true);
+ } else {
+ item[field.name] = Array.from({ length: n }, (_, j) =>
+ dataView.getUint32(byteOffset + j * 4, true)
+ );
+ }
+ } else if (field.baseType === 'i32') {
+ if (n === 1) {
+ item[field.name] = dataView.getInt32(byteOffset, true);
+ } else {
+ item[field.name] = Array.from({ length: n }, (_, j) =>
+ dataView.getInt32(byteOffset + j * 4, true)
+ );
+ }
+ } else {
+ const idx = byteOffset / 4;
+ if (n === 1) {
+ item[field.name] = floatView[idx];
+ } else {
+ const values = Array.from(floatView.slice(idx, idx + n));
+ if (field.kind === 'vector') {
+ item[field.name] = this._pInst.createVector(...values);
+ } else if (field.kind === 'color') {
+ // Color was packed as normalized RGBA [0-1] via _getRGBA([1,1,1,1])
+ // Scale back to the current colorMode range
+ const maxes = this.states.colorMaxes[this.states.colorMode];
+ item[field.name] = this._pInst.color(
+ values[0] * maxes[0], values[1] * maxes[1],
+ values[2] * maxes[2], values[3] * maxes[3]
+ );
+ } else {
+ item[field.name] = values;
+ }
+ }
+ }
+ }
+ result.push(item);
+ }
+
+ return result;
+ }
+
+ createStorage(dataOrCount) {
+ const device = this.device;
+
+ // Struct array: an array of plain objects
+ if (Array.isArray(dataOrCount) && dataOrCount.length > 0 &&
+ typeof dataOrCount[0] === 'object' && !Array.isArray(dataOrCount[0])) {
+ if (!p5.disableFriendlyErrors && dataOrCount.length > 1) {
+ const firstKeys = Object.keys(dataOrCount[0]);
+ let warned = false;
+ for (let i = 1; i < dataOrCount.length; i++) {
+ const el = dataOrCount[i];
+ const elKeys = Object.keys(el);
+ const sameKeys = firstKeys.length === elKeys.length &&
+ firstKeys.every((k, j) => k === elKeys[j]);
+ if (!sameKeys) {
+ p5._friendlyError(
+ `Element ${i} has different fields than element 0. ` +
+ `All elements should have the same properties.`,
+ 'createStorage'
+ );
+ break;
+ }
+ for (const key of firstKeys) {
+ const firstType = this._jsValueToWgslType(dataOrCount[0][key]);
+ const elType = this._jsValueToWgslType(el[key]);
+ if (firstType !== elType) {
+ p5._friendlyError(
+ `The "${key}" property of element ${i} has type ${elType} ` +
+ `but element 0 has type ${firstType}. Proporties should have the same type across all elements.`,
+ 'createStorage'
+ );
+ warned = true;
+ break;
+ }
+ }
+ if (warned) break;
+ }
+ }
+ const schema = this._inferStructSchema(dataOrCount[0]);
+ const packed = this._packStructArray(dataOrCount, schema);
+ const size = packed.byteLength;
+ const buffer = device.createBuffer({
+ size,
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
+ mappedAtCreation: true,
+ });
+ new Float32Array(buffer.getMappedRange()).set(packed);
+ buffer.unmap();
+ const storageBuffer = new StorageBuffer(buffer, size, this, schema);
+ this._storageBuffers.add(storageBuffer);
+ return storageBuffer;
+ }
+
+ // Determine buffer size and initial data
+ let size, initialData;
+ if (typeof dataOrCount === 'number') {
+ // createStorage(count) - zero-initialized
+ size = dataOrCount * 4; // floats are 4 bytes
+ initialData = new Float32Array(dataOrCount);
+ } else {
+ // createStorage(array) - from data
+ if (dataOrCount instanceof Float32Array) {
+ initialData = dataOrCount;
+ } else if (Array.isArray(dataOrCount)) {
+ initialData = new Float32Array(dataOrCount);
+ } else {
+ throw new Error('createStorage expects a number or array/Float32Array');
+ }
+ size = initialData.byteLength;
+ }
+
+ // Align to 16 bytes (WGSL storage buffer alignment requirement)
+ size = Math.ceil(size / 16) * 16;
+
+ // Create storage buffer with STORAGE | COPY_DST | COPY_SRC usage
+ const buffer = device.createBuffer({
+ size,
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
+ mappedAtCreation: initialData.length > 0
+ });
+
+ // Write initial data if provided
+ if (initialData.length > 0) {
+ const mapping = new Float32Array(buffer.getMappedRange());
+ mapping.set(initialData);
+ buffer.unmap();
+ }
+
+ const storageBuffer = new StorageBuffer(buffer, size, this);
+
+ // Track for cleanup
+ this._storageBuffers.add(storageBuffer);
+
+ return storageBuffer;
+ }
+
_getWebGPUColorFormat(framebuffer) {
if (framebuffer.format === constants.FLOAT) {
return framebuffer.channels === RGBA ? 'rgba32float' : 'rgba32float';
@@ -3110,10 +3997,6 @@ ${hookUniformFields}}
return super.filter(...args);
}
- getNoiseShaderSnippet() {
- return noiseWGSL;
- }
-
baseFilterShader() {
if (!this._baseFilterShader) {
@@ -3137,6 +4020,21 @@ ${hookUniformFields}}
return this._baseFilterShader;
}
+ baseComputeShader() {
+ if (!this._baseComputeShader) {
+ this._baseComputeShader = new Shader(
+ this,
+ baseComputeShader,
+ {
+ compute: {
+ 'void iteration': '(index: vec3) {}',
+ },
+ }
+ );
+ }
+ return this._baseComputeShader;
+ }
+
/*
* WebGPU-specific implementation of imageLight shader creation
*/
@@ -3236,6 +4134,69 @@ ${hookUniformFields}}
glDataType: dataType || 'uint8'
};
}
+
+ compute(shader, x, y = 1, z = 1) {
+ if (shader.shaderType !== 'compute') {
+ throw new Error('compute() can only be called with a compute shader');
+ }
+
+ this._finishActiveRenderPass();
+
+ // Ensure shader is initialized and finalized
+ if (!shader._compiled) {
+ shader.init();
+ }
+
+ // Set default uniforms
+ shader.setDefaultUniforms();
+ shader.setUniform('uTotalCount', [x, y, z]);
+
+ // Calculate optimal workgroup size (8x8x1 = 64 threads per workgroup)
+ const WORKGROUP_SIZE_X = 8;
+ const WORKGROUP_SIZE_Y = 8;
+ const WORKGROUP_SIZE_Z = 1;
+
+ // auto spreading: if any dimension is too large or for performance optimization,
+ // spread total iteration count across dimensions
+ const totalIterations = x * y * z;
+ const MAX_THREADS_PER_DIM = 65535 * 8;
+
+ let px = x;
+ let py = y;
+ let pz = z;
+
+ // we spread if we exceed GPU limits OR if it involves a large 1D dispatch
+ const exceedsLimits = x > MAX_THREADS_PER_DIM || y > MAX_THREADS_PER_DIM || z > MAX_THREADS_PER_DIM;
+ const isLarge1D = totalIterations > 1024 && y === 1 && z === 1;
+
+ if (exceedsLimits || isLarge1D) {
+ // Always use 2D square spreading (βN Γ βN).
+ // Benchmarks showed 2D square equals or outperforms 3D cube at every
+ // scale tested, with simpler index reconstruction in the shader.
+ px = Math.ceil(Math.sqrt(totalIterations));
+ py = Math.ceil(totalIterations / px);
+ pz = 1;
+ }
+
+ shader.setUniform('uPhysicalCount', [px, py, pz]);
+
+ const workgroupCountX = Math.ceil(px / WORKGROUP_SIZE_X);
+ const workgroupCountY = Math.ceil(py / WORKGROUP_SIZE_Y);
+ const workgroupCountZ = Math.ceil(pz / WORKGROUP_SIZE_Z);
+
+ const commandEncoder = this.device.createCommandEncoder();
+ const passEncoder = commandEncoder.beginComputePass();
+ this.setupShaderBindGroups(shader, passEncoder, {
+ compute: true,
+ workgroupSize: [WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y, WORKGROUP_SIZE_Z],
+ });
+
+ // Dispatch compute workgroups
+ passEncoder.dispatchWorkgroups(workgroupCountX, workgroupCountY, workgroupCountZ);
+
+ passEncoder.end();
+ this.device.queue.submit([commandEncoder.finish()]);
+ }
}
p5.RendererWebGPU = RendererWebGPU;
@@ -3246,6 +4207,7 @@ ${hookUniformFields}}
fn.setAttributes = async function (key, value) {
return this._renderer._setAttributes(key, value);
}
+
}
export default rendererWebGPU;
diff --git a/src/webgpu/shaders/color.js b/src/webgpu/shaders/color.js
index cea80efc9b..cf4ab00285 100644
--- a/src/webgpu/shaders/color.js
+++ b/src/webgpu/shaders/color.js
@@ -119,7 +119,7 @@ ${uniforms}
@fragment
fn main(input: FragmentInput) -> @location(0) vec4 {
HOOK_beforeFragment();
- var outColor = HOOK_getFinalColor(input.vColor);
+ var outColor = HOOK_getFinalColor(input.vColor, input.vVertTexCoord);
outColor = vec4(outColor.rgb * outColor.a, outColor.a);
HOOK_afterFragment();
return outColor;
diff --git a/src/webgpu/shaders/compute.js b/src/webgpu/shaders/compute.js
new file mode 100644
index 0000000000..dafe356ee6
--- /dev/null
+++ b/src/webgpu/shaders/compute.js
@@ -0,0 +1,30 @@
+export const baseComputeShader = `
+struct ComputeUniforms {
+ uTotalCount: vec3,
+ uPhysicalCount: vec3,
+}
+@group(0) @binding(0) var uniforms: ComputeUniforms;
+
+@compute @workgroup_size(8, 8, 1)
+fn main(
+ @builtin(global_invocation_id) globalId: vec3,
+ @builtin(local_invocation_id) localId: vec3,
+ @builtin(workgroup_id) workgroupId: vec3,
+ @builtin(local_invocation_index) localIndex: u32
+) {
+ let totalIterations = u32(uniforms.uTotalCount.x) * u32(uniforms.uTotalCount.y) * u32(uniforms.uTotalCount.z);
+ let physicalId = globalId.x + globalId.y * (u32(uniforms.uPhysicalCount.x)) + globalId.z * (u32(uniforms.uPhysicalCount.x) * u32(uniforms.uPhysicalCount.y));
+
+ if (physicalId >= totalIterations) {
+ return;
+ }
+
+ var index = vec3(0);
+ index.x = i32(physicalId % u32(uniforms.uTotalCount.x));
+ let remainingY = physicalId / u32(uniforms.uTotalCount.x);
+ index.y = i32(remainingY % u32(uniforms.uTotalCount.y));
+ index.z = i32(remainingY / u32(uniforms.uTotalCount.y));
+
+ HOOK_iteration(index);
+}
+`;
diff --git a/src/webgpu/shaders/functions/randomComputeWGSL.js b/src/webgpu/shaders/functions/randomComputeWGSL.js
new file mode 100644
index 0000000000..321ed4640a
--- /dev/null
+++ b/src/webgpu/shaders/functions/randomComputeWGSL.js
@@ -0,0 +1,29 @@
+// _p5_hash: "Hash without Sine" by Dave Hoskins (https://www.shadertoy.com/view/4djSRW)
+// Mixing constants: Rβ sequence by Martin Roberts (https://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences/)
+// Ξ±β = 1/Οβ = 0.7548776662 (plastic constant reciprocal)
+// Ξ±β = 1/ΟβΒ² = 0.5698402910
+// 1/Ο = 0.6180339887 (golden ratio conjugate)
+//
+// Compute shader version: invocationId is passed in from main via @builtin(global_invocation_id).
+
+export default `
+var _p5_randomCallIndex: i32 = 0;
+
+fn _p5_hash(p: vec3) -> f32 {
+ var p3 = fract(p * vec3(0.1031, 0.1030, 0.0973));
+ p3 = p3 + dot(p3, p3.yxz + 33.33);
+ return fract((p3.x + p3.y) * p3.z);
+}
+
+fn random(seed: f32, invocationId: vec3) -> f32 {
+ let id = vec3(invocationId);
+ let callIndex = f32(_p5_randomCallIndex);
+ _p5_randomCallIndex = _p5_randomCallIndex + 1;
+ let s = fract(seed * 0.7548776662);
+ return _p5_hash(vec3(
+ id.x + s,
+ id.y + callIndex * 0.5698402910,
+ id.z + s + callIndex * 0.6180339887
+ ));
+}
+`;
diff --git a/src/webgpu/shaders/functions/randomVertWGSL.js b/src/webgpu/shaders/functions/randomVertWGSL.js
new file mode 100644
index 0000000000..210d6c49c8
--- /dev/null
+++ b/src/webgpu/shaders/functions/randomVertWGSL.js
@@ -0,0 +1,28 @@
+// _p5_hash: "Hash without Sine" by Dave Hoskins (https://www.shadertoy.com/view/4djSRW)
+// Mixing constants: Rβ sequence by Martin Roberts (https://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences/)
+// Ξ±β = 1/Οβ = 0.7548776662 (plastic constant reciprocal)
+// Ξ±β = 1/ΟβΒ² = 0.5698402910
+// 1/Ο = 0.6180339887 (golden ratio conjugate)
+//
+// Vertex shader version: vertexId is passed in from main via @builtin(vertex_index).
+
+export default `
+var _p5_randomCallIndex: i32 = 0;
+
+fn _p5_hash(p: vec3) -> f32 {
+ var p3 = fract(p * vec3(0.1031, 0.1030, 0.0973));
+ p3 = p3 + dot(p3, p3.yxz + 33.33);
+ return fract((p3.x + p3.y) * p3.z);
+}
+
+fn random(seed: f32, vertexId: f32) -> f32 {
+ let callIndex = f32(_p5_randomCallIndex);
+ _p5_randomCallIndex = _p5_randomCallIndex + 1;
+ let s = fract(seed * 0.7548776662);
+ return _p5_hash(vec3(
+ vertexId + s,
+ vertexId * 0.5698402910 + callIndex * 0.6180339887,
+ s + callIndex * 0.7548776662
+ ));
+}
+`;
diff --git a/src/webgpu/shaders/functions/randomWGSL.js b/src/webgpu/shaders/functions/randomWGSL.js
new file mode 100644
index 0000000000..62005bd2a4
--- /dev/null
+++ b/src/webgpu/shaders/functions/randomWGSL.js
@@ -0,0 +1,28 @@
+// _p5_hash: "Hash without Sine" by Dave Hoskins (https://www.shadertoy.com/view/4djSRW)
+// Mixing constants: Rβ sequence by Martin Roberts (https://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences/)
+// Ξ±β = 1/Οβ = 0.7548776662 (plastic constant reciprocal)
+// Ξ±β = 1/ΟβΒ² = 0.5698402910
+// 1/Ο = 0.6180339887 (golden ratio conjugate)
+//
+// Fragment shader version: pixelCoord is passed in from main via @builtin(position).
+
+export default `
+var _p5_randomCallIndex: i32 = 0;
+
+fn _p5_hash(p: vec3) -> f32 {
+ var p3 = fract(p * vec3(0.1031, 0.1030, 0.0973));
+ p3 = p3 + dot(p3, p3.yxz + 33.33);
+ return fract((p3.x + p3.y) * p3.z);
+}
+
+fn random(seed: f32, pixelCoord: vec2) -> f32 {
+ let callIndex = f32(_p5_randomCallIndex);
+ _p5_randomCallIndex = _p5_randomCallIndex + 1;
+ let s = fract(seed * 0.7548776662);
+ return _p5_hash(vec3(
+ pixelCoord.x + s,
+ pixelCoord.y + callIndex * 0.5698402910,
+ s + callIndex * 0.6180339887
+ ));
+}
+`;
diff --git a/src/webgpu/shaders/line.js b/src/webgpu/shaders/line.js
index a46317cee6..2b58857286 100644
--- a/src/webgpu/shaders/line.js
+++ b/src/webgpu/shaders/line.js
@@ -362,7 +362,7 @@ fn main(input: StrokeFragmentInput) -> @location(0) vec4 {
discard;
}
}
- var col = HOOK_getFinalColor(inputs.color);
+ var col = HOOK_getFinalColor(inputs.color, vec2(0.0, 0.0));
col = vec4(col.rgb, 1.0) * col.a;
HOOK_afterFragment();
return vec4(col);
diff --git a/src/webgpu/shaders/material.js b/src/webgpu/shaders/material.js
index a740e9d17a..751ec0ad3a 100644
--- a/src/webgpu/shaders/material.js
+++ b/src/webgpu/shaders/material.js
@@ -416,9 +416,9 @@ fn main(input: FragmentInput) -> @location(0) vec4 {
inputs.emissiveMaterial
);
- var outColor = HOOK_getFinalColor(
- HOOK_combineColors(components)
- );
+ var outColor = HOOK_getFinalColor(
+ HOOK_combineColors(components), input.vTexCoord
+ );
outColor = vec4(outColor.rgb * outColor.a, outColor.a);
HOOK_afterFragment();
return outColor;
diff --git a/src/webgpu/strands_wgslBackend.js b/src/webgpu/strands_wgslBackend.js
index 4394210414..79d0f2816b 100644
--- a/src/webgpu/strands_wgslBackend.js
+++ b/src/webgpu/strands_wgslBackend.js
@@ -1,4 +1,8 @@
-import { NodeType, OpCodeToSymbol, BlockType, OpCode, NodeTypeToName, isStructType, BaseType, StatementType, DataType } from "../strands/ir_types";
+import noiseWGSL from './shaders/functions/noise3DWGSL.js';
+import randomWGSL from './shaders/functions/randomWGSL';
+import randomVertWGSL from './shaders/functions/randomVertWGSL';
+import randomComputeWGSL from './shaders/functions/randomComputeWGSL';
+import { NodeType, OpCodeToSymbol, BlockType, OpCode, NodeTypeToName, isStructType, BaseType, StatementType, DataType, INSTANCE_ID_VARYING_NAME, HOOK_PARAM_PREFIX } from "../strands/ir_types";
import { getNodeDataFromID, extractNodeTypeInfo } from "../strands/ir_dag";
import * as FES from '../strands/strands_FES';
import * as build from '../strands/ir_builders';
@@ -186,16 +190,16 @@ export const wgslBackend = {
hookEntry(hookType) {
const params = hookType.parameters.map((param) => {
// For struct types, use a raw prefix since we'll create a mutable copy
- const paramName = param.type.properties ? `_p5_strands_raw_${param.name}` : param.name;
+ const paramName = param.type.properties ? `_p5_strands_raw_${param.name}` : `${HOOK_PARAM_PREFIX}${param.name}`;
return `${paramName}: ${param.type.typeName}`;
}).join(', ');
const firstLine = `(${params}) {`;
- // Generate mutable copies for struct parameters with original names
+ // Generate mutable copies for struct parameters
const mutableCopies = hookType.parameters
.filter(param => param.type.properties) // Only struct types
- .map(param => ` var ${param.name} = _p5_strands_raw_${param.name};`)
+ .map(param => ` var ${HOOK_PARAM_PREFIX}${param.name} = _p5_strands_raw_${param.name};`)
.join('\n');
return mutableCopies ? firstLine + '\n' + mutableCopies : firstLine;
@@ -204,10 +208,10 @@ export const wgslBackend = {
// Add texture and sampler bindings for sampler2D uniforms to both vertex and fragment declarations
if (!strandsContext.renderer || !strandsContext.baseShader) return;
- // Get the next available binding index from the renderer
let bindingIndex = strandsContext.renderer.getNextBindingIndex({
- vert: strandsContext.baseShader.vertSrc(),
- frag: strandsContext.baseShader.fragSrc(),
+ vert: strandsContext.baseShader._vertSrc,
+ frag: strandsContext.baseShader._fragSrc,
+ compute: strandsContext.baseShader._computeSrc,
});
for (const {name, typeInfo} of strandsContext.uniforms) {
@@ -224,6 +228,38 @@ export const wgslBackend = {
}
}
},
+ addStorageBufferBindingsToDeclarations(strandsContext) {
+ if (!strandsContext.renderer || !strandsContext.baseShader) return;
+
+ const isComputeShader = strandsContext.baseShader.shaderType === 'compute';
+ let bindingIndex = strandsContext.renderer.getNextBindingIndex({
+ vert: strandsContext.baseShader._vertSrc,
+ frag: strandsContext.baseShader._fragSrc,
+ compute: strandsContext.baseShader._computeSrc,
+ });
+
+ for (const {name, typeInfo} of strandsContext.uniforms) {
+ if (typeInfo.baseType === 'storage') {
+ const accessMode = isComputeShader ? 'read_write' : 'read';
+ let declaration;
+ if (typeInfo.schema) {
+ const structTypeName = `${name}Element`;
+ declaration = `struct ${structTypeName} ${typeInfo.schema.structBody}\n@group(0) @binding(${bindingIndex}) var ${name}: array<${structTypeName}>;`;
+ } else {
+ declaration = `@group(0) @binding(${bindingIndex}) var ${name}: array;`;
+ }
+
+ if (isComputeShader) {
+ strandsContext.computeDeclarations.add(declaration);
+ } else {
+ strandsContext.vertexDeclarations.add(declaration);
+ strandsContext.fragmentDeclarations.add(declaration);
+ }
+
+ bindingIndex += 1;
+ }
+ }
+ },
getTypeName(baseType, dimension) {
const primitiveTypeName = TypeNames[baseType + dimension]
if (!primitiveTypeName) {
@@ -231,6 +267,19 @@ export const wgslBackend = {
}
return primitiveTypeName;
},
+ getNoiseShaderSnippet() {
+ return noiseWGSL;
+ },
+ getRandomFragmentShaderSnippet() {
+ return randomWGSL;
+ },
+ getRandomVertexShaderSnippet() {
+ return randomVertWGSL;
+ },
+ getRandomComputeShaderSnippet() {
+ return randomComputeWGSL;
+ },
+
generateHookUniformKey(name, typeInfo) {
// For sampler2D types, we don't add them to the uniform struct,
// but we still need them in the shader's hooks object so that
@@ -238,6 +287,11 @@ export const wgslBackend = {
if (typeInfo.baseType === 'sampler2D') {
return `${name}: sampler2D`; // Signal that this should not be added to uniform struct
}
+ // For storage buffers, we don't add them to the uniform struct
+ // Instead, they become separate storage buffer bindings
+ if (typeInfo.baseType === 'storage') {
+ return null; // Signal that this should not be added to uniform struct
+ }
return `${name}: ${this.getTypeName(typeInfo.baseType, typeInfo.dimension)}`;
},
generateVaryingVariable(varName, typeInfo) {
@@ -264,9 +318,13 @@ export const wgslBackend = {
// Generate just a semicolon (unless suppressed)
generationContext.write(semicolon);
} else if (node.statementType === StatementType.EARLY_RETURN) {
- const exprNodeID = node.dependsOn[0];
- const expr = this.generateExpression(generationContext, dag, exprNodeID);
- generationContext.write(`return ${expr}${semicolon}`);
+ if (node.dependsOn && node.dependsOn.length > 0) {
+ const exprNodeID = node.dependsOn[0];
+ const expr = this.generateExpression(generationContext, dag, exprNodeID);
+ generationContext.write(`return ${expr}${semicolon}`);
+ } else {
+ generationContext.write(`return${semicolon}`);
+ }
}
},
generateAssignment(generationContext, dag, nodeID) {
@@ -278,6 +336,17 @@ export const wgslBackend = {
const targetNode = getNodeDataFromID(dag, targetNodeID);
const semicolon = generationContext.suppressSemicolon ? '' : ';';
+ // Check if target is an array access (storage buffer assignment)
+ if (targetNode.opCode === OpCode.Binary.ARRAY_ACCESS) {
+ const [bufferID, indexID] = targetNode.dependsOn;
+ const bufferExpr = this.generateExpression(generationContext, dag, bufferID);
+ const indexExpr = this.generateExpression(generationContext, dag, indexID);
+ const sourceExpr = this.generateExpression(generationContext, dag, sourceNodeID);
+ const fieldSuffix = targetNode.identifier ? `.${targetNode.identifier}` : '';
+ generationContext.write(`${bufferExpr}[i32(${indexExpr})]${fieldSuffix} = ${sourceExpr}${semicolon}`);
+ return;
+ }
+
// Check if target is a swizzle assignment
if (targetNode.opCode === OpCode.Unary.SWIZZLE) {
const parentID = targetNode.dependsOn[0];
@@ -335,6 +404,10 @@ export const wgslBackend = {
return `var ${tmp}: ${typeName} = ${expr};`;
},
generateReturnStatement(strandsContext, generationContext, rootNodeID, returnType) {
+ if (!returnType) {
+ generationContext.write('return;');
+ return;
+ }
const dag = strandsContext.dag;
const rootNode = getNodeDataFromID(dag, rootNodeID);
if (isStructType(returnType)) {
@@ -375,9 +448,15 @@ export const wgslBackend = {
}
}
- // Check if this is a uniform variable (but not a texture)
+ // Detect instanceID usage in fragment context and rewrite to varying name
+ if (node.identifier === this.instanceIdReference() && generationContext.shaderContext === 'fragment') {
+ generationContext.strandsContext._instanceIDUsedInFragment = true;
+ return INSTANCE_ID_VARYING_NAME;
+ }
+
+ // Check if this is a uniform variable (but not a texture or storage buffer)
const uniform = generationContext.strandsContext?.uniforms?.find(uniform => uniform.name === node.identifier);
- if (uniform && uniform.typeInfo.baseType !== 'sampler2D') {
+ if (uniform && uniform.typeInfo.baseType !== 'sampler2D' && uniform.typeInfo.baseType !== 'storage') {
return `hooks.${node.identifier}`;
}
@@ -396,6 +475,13 @@ export const wgslBackend = {
const deps = node.dependsOn.map((dep) => this.generateExpression(generationContext, dag, dep));
return `${T}(${deps.join(', ')})`;
}
+ if (node.opCode === OpCode.Nary.TERNARY) {
+ const [condID, trueID, falseID] = node.dependsOn;
+ const cond = this.generateExpression(generationContext, dag, condID);
+ const trueExpr = this.generateExpression(generationContext, dag, trueID);
+ const falseExpr = this.generateExpression(generationContext, dag, falseID);
+ return `select(${falseExpr}, ${trueExpr}, ${cond})`;
+ }
if (node.opCode === OpCode.Nary.FUNCTION_CALL) {
// Convert mod() function calls to % operator in WGSL
if (node.identifier === 'mod' && node.dependsOn.length === 2) {
@@ -417,6 +503,18 @@ export const wgslBackend = {
}
const functionArgs = node.dependsOn.map(arg =>this.generateExpression(generationContext, dag, arg));
+
+ if (node.identifier === 'random') {
+ const ctx = generationContext.shaderContext;
+ if (ctx === 'fragment') {
+ functionArgs.push('_p5FragPos.xy');
+ } else if (ctx === 'vertex') {
+ functionArgs.push('f32(_p5VertexId)');
+ } else if (ctx === 'compute') {
+ functionArgs.push('_p5GlobalId');
+ }
+ }
+
return `${node.identifier}(${functionArgs.join(', ')})`;
}
if (node.opCode === OpCode.Binary.MEMBER_ACCESS) {
@@ -430,6 +528,13 @@ export const wgslBackend = {
const parentExpr = this.generateExpression(generationContext, dag, parentID);
return `${parentExpr}.${node.swizzle}`;
}
+ if (node.opCode === OpCode.Binary.ARRAY_ACCESS) {
+ const [bufferID, indexID] = node.dependsOn;
+ const bufferExpr = this.generateExpression(generationContext, dag, bufferID);
+ const indexExpr = this.generateExpression(generationContext, dag, indexID);
+ const fieldSuffix = node.identifier ? `.${node.identifier}` : '';
+ return `${bufferExpr}[i32(${indexExpr})]${fieldSuffix}`;
+ }
if (node.dependsOn.length === 2) {
const [lID, rID] = node.dependsOn;
const left = this.generateExpression(generationContext, dag, lID);
@@ -503,12 +608,25 @@ export const wgslBackend = {
const samplerVariable = build.variableNode(strandsContext, { baseType: BaseType.SAMPLER, dimension: 1 }, samplerIdentifier);
const samplerNode = createStrandsNode(samplerVariable.id, samplerVariable.dimension, strandsContext);
- // Create the augmented args: [texture, sampler, coords]
- const augmentedArgs = [textureArg, samplerNode, coordsArg];
-
- const { id, dimension } = build.functionCallNode(strandsContext, 'textureSample', augmentedArgs, {
+ // Create a LOD literal node (0.0) so we can use textureSampleLevel instead
+ // of textureSample. textureSample doesn't let you use uniform values in control
+ // flow, whereas textureSampleLevel does. While we don't have mipmaps, we don't
+ // miss out.
+ // TODO: if we *do* add mipmap support, update this logic -- we'd need to hoist
+ // the texture lookup out of the control flow.
+ const lodLiteral = build.scalarLiteralNode(
+ strandsContext,
+ { dimension: 1, baseType: BaseType.FLOAT },
+ 0.0
+ );
+ const lodNode = createStrandsNode(lodLiteral.id, lodLiteral.dimension, strandsContext);
+
+ // Create the augmented args: [texture, sampler, coords, lod]
+ const augmentedArgs = [textureArg, samplerNode, coordsArg, lodNode];
+
+ const { id, dimension } = build.functionCallNode(strandsContext, 'textureSampleLevel', augmentedArgs, {
overloads: [{
- params: [DataType.sampler2D, DataType.sampler, DataType.float2],
+ params: [DataType.sampler2D, DataType.sampler, DataType.float2, DataType.float1],
returnType: DataType.float4
}]
});
@@ -518,4 +636,8 @@ export const wgslBackend = {
instanceIdReference() {
return 'instanceID';
},
+
+ generateInstanceIDVarying() {
+ return { name: INSTANCE_ID_VARYING_NAME, declaration: `${INSTANCE_ID_VARYING_NAME}: i32`, source: 'i32(instanceID)', interpolation: 'flat' };
+ },
}
diff --git a/test/bench/vectors.bench.js b/test/bench/vectors.bench.js
new file mode 100644
index 0000000000..952c428509
--- /dev/null
+++ b/test/bench/vectors.bench.js
@@ -0,0 +1,120 @@
+import { Vector } from '../../src/math/p5.Vector.js';
+
+import { bench, describe } from "vitest";
+
+
+describe("vector operations", () => {
+
+ bench(
+ "mult 5",
+ () => {
+ const nLimited = 5;
+ // TODO try just operating on det. values based on i and j
+ // without re-creation
+
+ for (let i = 0; i < nLimited; i++) {
+ for (let j = 0; j < nLimited; j++) {
+ const tmp = arr[i].mult(arr[j]);
+ }
+ }
+ }
+ );
+
+ bench(
+ "mult 10",
+ () => {
+ const nLimited = 10;
+ const arr = setupVectors();
+
+ for (let i = 0; i < nLimited; i++) {
+ for (let j = 0; j < nLimited; j++) {
+ const tmp = arr[i].mult(arr[j]);
+ }
+ }
+ }
+ );
+
+ bench(
+ "mult 20",
+ () => {
+ const nLimited = 20;
+ const arr = setupVectors();
+
+ for (let i = 0; i < nLimited; i++) {
+ for (let j = 0; j < nLimited; j++) {
+ const tmp = arr[i].mult(arr[j]);
+ }
+ }
+ }
+ );
+ bench(
+ "mult 100",
+ () => {
+ const nLimited = 100;
+ const arr = setupVectors();
+
+ for (let i = 0; i < nLimited; i++) {
+ for (let j = 0; j < nLimited; j++) {
+ const tmp = arr[i].mult(arr[j]);
+ }
+ }
+ }
+ );
+
+
+
+ bench(
+ "add 5",
+ () => {
+ const nLimited = 5;
+ const arr = setupVectors();
+
+ for (let i = 0; i < nLimited; i++) {
+ for (let j = 0; j < nLimited; j++) {
+ const tmp = arr[i].add(arr[j]);
+ }
+ }
+ }
+ );
+
+ bench(
+ "add 10",
+ () => {
+ const nLimited = 10;
+ const arr = setupVectors();
+
+ for (let i = 0; i < nLimited; i++) {
+ for (let j = 0; j < nLimited; j++) {
+ const tmp = arr[i].add(arr[j]);
+ }
+ }
+ });
+
+ bench(
+ "add 20",
+ () => {
+ const nLimited = 20;
+ const arr = setupVectors();
+
+ for (let i = 0; i < nLimited; i++) {
+ for (let j = 0; j < nLimited; j++) {
+ const tmp = arr[i].add(arr[j]);
+ }
+ }
+ }
+ );
+ bench(
+ "add 100",
+ () => {
+ const nLimited = 100;
+ const arr = setupVectors();
+
+ for (let i = 0; i < nLimited; i++) {
+ for (let j = 0; j < nLimited; j++) {
+ const tmp = arr[i].add(arr[j]);
+ }
+ }
+ },
+ );
+
+});
diff --git a/test/types/webgpu.ts b/test/types/webgpu.ts
new file mode 100644
index 0000000000..7c9016cdf1
--- /dev/null
+++ b/test/types/webgpu.ts
@@ -0,0 +1,9 @@
+import p5 from '../../types/global'
+
+async function setup() {
+ const renderer: p5.Renderer = await createCanvas(100, 100, WEBGPU);
+ background(0);
+ fill(255);
+ noStroke();
+ circle(0, 0, 50);
+}
diff --git a/test/unit/core/States.js b/test/unit/core/States.js
new file mode 100644
index 0000000000..d4b2b56a35
--- /dev/null
+++ b/test/unit/core/States.js
@@ -0,0 +1,70 @@
+import { States } from '../../../src/core/States.js';
+
+suite('States', function () {
+ test('initialises with provided state', function () {
+ const s = new States({ fill: 'red', stroke: 'blue' });
+ assert.equal(s.fill, 'red');
+ assert.equal(s.stroke, 'blue');
+ });
+
+ test('setValue() updates the value', function () {
+ const s = new States({ fill: 'red' });
+ s.setValue('fill', 'green');
+ assert.equal(s.fill, 'green');
+ });
+
+ test('setValue() records the original before first modification', function () {
+ const s = new States({ fill: 'red' });
+ s.setValue('fill', 'green');
+ assert.equal(s.getModified().fill, 'red');
+ });
+
+ test('setValue() does not overwrite original on repeated calls', function () {
+ const s = new States({ fill: 'red' });
+ s.setValue('fill', 'green');
+ s.setValue('fill', 'blue');
+ assert.equal(s.getModified().fill, 'red');
+ });
+
+ test('takeDiff() returns the modified map', function () {
+ const s = new States({ fill: 'red' });
+ s.setValue('fill', 'green');
+ const diff = s.takeDiff();
+ assert.equal(diff.fill, 'red');
+ });
+
+ test('takeDiff() clears #modified after returning', function () {
+ const s = new States({ fill: 'red' });
+ s.setValue('fill', 'green');
+ s.takeDiff();
+ assert.deepEqual(s.takeDiff(), {});
+ });
+
+ test('applyDiff() undoes current modifications and replaces #modified', function () {
+ const s = new States({ fill: 'red' });
+ s.setValue('fill', 'green');
+ const outerModified = {};
+ s.applyDiff(outerModified);
+ assert.equal(s.fill, 'red');
+ assert.deepEqual(s.getModified(), {});
+ });
+
+ test('applyDiff() with a non-empty prevModified replaces #modified', function () {
+ const s = new States({ fill: 'red', stroke: 'black' });
+ s.setValue('fill', 'green');
+ const outerModified = { stroke: 'black' };
+ s.applyDiff(outerModified);
+ assert.equal(s.fill, 'red');
+ assert.deepEqual(s.getModified(), { stroke: 'black' });
+ });
+
+ test('push/pop cycle: applyDiff restores prior state', function () {
+ const s = new States({ fill: 'red' });
+ const beforePush = s.takeDiff();
+ s.setValue('fill', 'green');
+ assert.equal(s.fill, 'green');
+ s.applyDiff(beforePush);
+ assert.equal(s.fill, 'red');
+ assert.deepEqual(s.getModified(), {});
+ });
+});
\ No newline at end of file
diff --git a/test/unit/core/properties.js b/test/unit/core/properties.js
new file mode 100644
index 0000000000..e7def6900e
--- /dev/null
+++ b/test/unit/core/properties.js
@@ -0,0 +1,68 @@
+import p5 from '../../../src/app.js';
+
+suite('Set/get properties', function() {
+
+ let p = new p5(function (sketch) {
+ sketch.setup = function () { };
+ sketch.draw = function () { };
+ });
+
+ let getters = {
+ fill: new p5.Color([100, 200, 50]),
+ stroke: new p5.Color([200, 100, 50, 100]),
+ tint: new p5.Color([100, 140, 50]),
+
+ rectMode: p.CENTER,
+ colorMode: p.HSB,
+ blendMode: p.BLEND,
+ imageMode: p.CORNER,
+ ellipseMode: p.CORNER,
+ angleMode: p.DEGREES,
+
+ strokeWeight: 6,
+ strokeCap: p.ROUND,
+ strokeJoin: p.MITER,
+ cursor: p.HAND,
+ pixelDensity: 1,
+
+ bezierOrder: 2,
+ splineProperties: { ends: p.EXCLUDE, tightness: -5 },
+
+ textureMode: p.IMAGE, // 3D only
+ textureWrap: { x: p.REPEAT, y: p.MIRROR }, // 3D only
+
+ textAlign: { horizontal: p.CENTER, vertical: p.CENTER },
+ textLeading: 18,
+ textFont: 'arial',
+ textSize: 1,
+ textStyle: 1,
+ textWrap: p.WORD,
+ textDirection: 1,
+ textWeight: 1
+
+ // see #8278
+ // rotate: p.PI,
+ // translate: { x: 1, y: 2 },
+ // scale: { x: 1, y: 2 },
+ // background: new p5.Color([100, 100, 50])
+ };
+
+ Object.keys(getters).forEach(prop => {
+ let arg = getters[prop];
+ test(`${prop}()`, function() {
+
+ // setter
+ if (typeof arg === 'object' && !(arg instanceof p5.Color)) {
+ p[prop](...Object.values(arg)); // set with object
+ }
+ else if (Array.isArray(arg)) {
+ p[prop](...arg); // set with array
+ }
+ else {
+ p[prop](arg); // set with primitive or p5.Color
+ }
+ // getter
+ assert.strictEqual(p[prop]().toString(), arg.toString(), `${arg.toString()}`);
+ });
+ });
+});
diff --git a/test/unit/io/loadModel.js b/test/unit/io/loadModel.js
index f88a5807cc..590a12bb1e 100644
--- a/test/unit/io/loadModel.js
+++ b/test/unit/io/loadModel.js
@@ -79,11 +79,27 @@ suite('loadModel', function() {
assert.deepEqual(model.vertexColors, expectedColors);
});
- test('inconsistent vertex coloring throws error', async function() {
- // Attempt to load the model and catch the error
- await expect(mockP5Prototype.loadModel(inconsistentColorObjFile))
- .rejects
- .toThrow('Model coloring is inconsistent. Either all vertices should have colors or none should.');
+ test('mixed material coloring loads model with sentinel colors for uncolored vertices', async function() {
+ const model = await mockP5Prototype.loadModel(inconsistentColorObjFile);
+ assert.instanceOf(model, Geometry);
+ assert.equal(
+ model.vertexColors.length,
+ model.vertices.length * 4,
+ 'vertexColors should have four entries per vertex'
+ );
+ const hasSentinel = model.vertexColors.some(
+ (_, i) =>
+ i % 4 === 0 &&
+ model.vertexColors[i] === -1 &&
+ model.vertexColors[i + 1] === -1 &&
+ model.vertexColors[i + 2] === -1 &&
+ model.vertexColors[i + 3] === -1
+ );
+ const hasRealColor = model.vertexColors.some(
+ (_, i) => i % 4 === 0 && model.vertexColors[i] !== -1
+ );
+ assert.isTrue(hasSentinel, 'Uncolored vertices should have sentinel color');
+ assert.isTrue(hasRealColor, 'Colored vertices should retain their color');
});
test('missing MTL file shows OBJ model without vertexColors', async function() {
diff --git a/test/unit/math/calculation.js b/test/unit/math/calculation.js
index 1e0e62c1d0..82868210b8 100644
--- a/test/unit/math/calculation.js
+++ b/test/unit/math/calculation.js
@@ -299,6 +299,14 @@ suite('Calculation', function() {
result = mockP5Prototype.max([10, 10]);
assert.equal(result, 10);
});
+ test('should handle Infinity as a valid argument', function() {
+ result = mockP5Prototype.max(Infinity, 42);
+ assert.equal(result, Infinity);
+ });
+ test('should handle -Infinity as a valid argument', function() {
+ result = mockP5Prototype.max(-Infinity, 42);
+ assert.equal(result, 42);
+ });
});
suite('p5.prototype.min', function() {
@@ -331,6 +339,14 @@ suite('Calculation', function() {
result = mockP5Prototype.min([10, 10]);
assert.equal(result, 10);
});
+ test('should handle Infinity as a valid argument', function() {
+ result = mockP5Prototype.min(Infinity, 42);
+ assert.equal(result, 42);
+ });
+ test('should handle -Infinity as a valid argument', function() {
+ result = mockP5Prototype.min(-Infinity, 42);
+ assert.equal(result, -Infinity);
+ });
});
suite('p5.prototype.norm', function() {
diff --git a/test/unit/math/p5.Vector.js b/test/unit/math/p5.Vector.js
index 4028ee2dba..df694d71a6 100644
--- a/test/unit/math/p5.Vector.js
+++ b/test/unit/math/p5.Vector.js
@@ -1,24 +1,48 @@
-import vector from '../../../src/math/p5.Vector.js';
+import { default as vector, Vector } from '../../../src/math/p5.Vector.js';
+import { default as math } from '../../../src/math/math.js';
+import { _defaultEmptyVector, _validatedVectorOperation } from '../../../src/math/patch-vector.js';
import { vi } from 'vitest';
+
suite('p5.Vector', function () {
var v;
+ let FESCalled = false;
const mockP5 = {
- _validateParameters: vi.fn()
+ _friendlyError: function(msg, func) {
+ FESCalled = true;
+ console.warn(msg);
+ }
};
+ const options = { p5: mockP5 };
const mockP5Prototype = {};
- beforeEach(async function () {
+ beforeAll(async function () {
+ // Makes createVector available
+ mockP5.Vector = Vector;
+ math(mockP5, mockP5Prototype);
vector(mockP5, mockP5Prototype);
+
+ // Ensures all decorators are used by unit tests
+ mockP5Prototype.createVector = _defaultEmptyVector(
+ mockP5Prototype.createVector,
+ options
+ );
+
+ // The following mocks simulate the validation decorator
+ Vector.prototype.add = _validatedVectorOperation(false)(Vector.prototype.add, options);
+ Vector.prototype.sub = _validatedVectorOperation(false)(Vector.prototype.sub, options);
+ Vector.prototype.mult = _validatedVectorOperation(true)(Vector.prototype.mult, options);
+ Vector.prototype.rem = _validatedVectorOperation(true)(Vector.prototype.rem, options);
+ Vector.prototype.div = _validatedVectorOperation(true)(Vector.prototype.div, options);
});
afterEach(function () {});
suite.todo('p5.prototype.setHeading() RADIANS', function () {
beforeEach(function () {
- mockP5Prototype.angleMode(mockP5.RADIANS);
- v = mockP5Prototype.createVector(1, 1);
+ mockP5Prototype.angleMode(RADIANS);
+ v = new Vector(1, 1);
v.setHeading(1);
});
test('should have heading() value of 1 (RADIANS)', function () {
@@ -28,7 +52,7 @@ suite('p5.Vector', function () {
suite.todo('p5.prototype.setHeading() DEGREES', function () {
beforeEach(function () {
- mockP5Prototype.angleMode(mockP5.DEGREES);
+ mockP5Prototype.angleMode(DEGREES);
v = mockP5Prototype.createVector(1, 1);
v.setHeading(1);
});
@@ -44,7 +68,7 @@ suite('p5.Vector', function () {
v = mockP5Prototype.createVector();
});
test('should create instance of p5.Vector', function () {
- assert.instanceOf(v, mockP5.Vector);
+ assert.instanceOf(v, Vector);
});
test('should have x, y, z be initialized to 0', function () {
@@ -69,6 +93,32 @@ suite('p5.Vector', function () {
assert.equal(v.z, 3);
});
+ test('should have values be initialized to 1,2,3', function () {
+ assert.deepEqual(v.values, [1, 2, 3]);
+ });
+
+ test('should have dimensions initialized to 3', function () {
+ assert.equal(v.dimensions, 3);
+ });
+ });
+
+
+ suite.todo('p5.prototype.createVector()', function () {
+ beforeEach(function () {
+ v = mockP5Prototype.createVector();
+ });
+
+ test('should have x, y, z be initialized to 0,0,0', function () {
+ console.log(typeof mockP5Prototype.createVector);
+ assert.equal(v.x, 0);
+ assert.equal(v.y, 0);
+ assert.equal(v.z, 0);
+ });
+
+ test('should have values be initialized to 0,0,0', function () {
+ assert.deepEqual(v.values, [0,0,0]);
+ });
+
test('should have dimensions initialized to 3', function () {
assert.equal(v.dimensions, 3);
});
@@ -76,10 +126,10 @@ suite('p5.Vector', function () {
suite('new p5.Vector()', function () {
beforeEach(function () {
- v = new mockP5.Vector();
+ v = new Vector();
});
test('should set constant to DEGREES', function () {
- assert.instanceOf(v, mockP5.Vector);
+ assert.instanceOf(v, Vector);
});
test('should have x, y, z be initialized to 0', function () {
@@ -91,7 +141,7 @@ suite('p5.Vector', function () {
suite('new p5.Vector(1, 2, 3)', function () {
beforeEach(function () {
- v = new mockP5.Vector(1, 2, 3);
+ v = new Vector(1, 2, 3);
});
test('should have x, y, z be initialized to 1,2,3', function () {
@@ -101,9 +151,9 @@ suite('p5.Vector', function () {
});
});
- suite('new p5.Vector(1,2,undefined)', function () {
+ suite('new p5.Vector(1,2)', function () {
beforeEach(function () {
- v = new mockP5.Vector(1, 2, undefined);
+ v = new Vector(1, 2);
});
test('should have x, y, z be initialized to 1,2,0', function () {
@@ -116,13 +166,13 @@ suite('p5.Vector', function () {
suite('rotate', function () {
suite('p5.Vector.prototype.rotate() [INSTANCE]', function () {
test('should return the same object', function () {
- v = new mockP5.Vector(0, 1);
+ v = new Vector(0, 1);
expect(v.rotate(Math.PI)).to.eql(v);
});
suite.todo('radians', function () {
beforeEach(function () {
- mockP5Prototype.angleMode(mockP5.RADIANS);
+ mockP5Prototype.angleMode(RADIANS);
});
test('should rotate the vector [0, 1, 0] by pi radians to [0, -1, 0]', function () {
@@ -152,7 +202,7 @@ suite('p5.Vector', function () {
suite.todo('degrees', function () {
beforeEach(function () {
- mockP5Prototype.angleMode(mockP5.DEGREES);
+ mockP5Prototype.angleMode(DEGREES);
});
test('should rotate the vector [0, 1, 0] by 180 degrees to [0, -1, 0]', function () {
@@ -175,12 +225,12 @@ suite('p5.Vector', function () {
suite.todo('p5.Vector.rotate() [CLASS]', function () {
beforeEach(function () {
- mockP5Prototype.angleMode(mockP5.RADIANS);
+ mockP5Prototype.angleMode(RADIANS);
});
test('should not change the original object', function () {
v = mockP5Prototype.createVector(1, 0, 0);
- mockP5.Vector.rotate(v, Math.PI / 2);
+ Vector.rotate(v, Math.PI / 2);
expect(v.x).to.equal(1);
expect(v.y).to.equal(0);
expect(v.z).to.equal(0);
@@ -188,7 +238,7 @@ suite('p5.Vector', function () {
test('should rotate the vector [0, 1, 0] by pi radians to [0, -1, 0]', function () {
v = mockP5Prototype.createVector(0, 1, 0);
- const v1 = mockP5.Vector.rotate(v, Math.PI);
+ const v1 = Vector.rotate(v, Math.PI);
expect(v1.x).to.be.closeTo(0, 0.01);
expect(v1.y).to.be.closeTo(-1, 0.01);
expect(v1.z).to.be.closeTo(0, 0.01);
@@ -196,7 +246,7 @@ suite('p5.Vector', function () {
test('should rotate the vector [1, 0, 0] by -pi/2 radians to [0, -1, 0]', function () {
v = mockP5Prototype.createVector(1, 0, 0);
- const v1 = mockP5.Vector.rotate(v, -Math.PI / 2);
+ const v1 = Vector.rotate(v, -Math.PI / 2);
expect(v1.x).to.be.closeTo(0, 0.01);
expect(v1.y).to.be.closeTo(-1, 0.01);
expect(v1.z).to.be.closeTo(0, 0.01);
@@ -207,8 +257,8 @@ suite('p5.Vector', function () {
suite('angleBetween', function () {
let v1, v2;
beforeEach(function () {
- v1 = new mockP5.Vector(1, 0, 0);
- v2 = new mockP5.Vector(2, 2, 0);
+ v1 = new Vector(1, 0, 0);
+ v2 = new Vector(2, 2, 0);
});
suite('p5.Vector.prototype.angleBetween() [INSTANCE]', function () {
@@ -218,60 +268,60 @@ suite('p5.Vector', function () {
});
test('should not trip on rounding issues in 2D space', function () {
- v1 = new mockP5.Vector(-11, -20);
- v2 = new mockP5.Vector(-5.5, -10);
- const v3 = new mockP5.Vector(5.5, 10);
+ v1 = new Vector(-11, -20);
+ v2 = new Vector(-5.5, -10);
+ const v3 = new Vector(5.5, 10);
expect(v1.angleBetween(v2)).to.be.closeTo(0, 0.00001);
expect(v1.angleBetween(v3)).to.be.closeTo(Math.PI, 0.00001);
});
test('should not trip on rounding issues in 3D space', function () {
- v1 = new mockP5.Vector(1, 1.1, 1.2);
- v2 = new mockP5.Vector(2, 2.2, 2.4);
+ v1 = new Vector(1, 1.1, 1.2);
+ v2 = new Vector(2, 2.2, 2.4);
expect(v1.angleBetween(v2)).to.be.closeTo(0, 0.00001);
});
test('should return NaN for zero vector', function () {
- v1 = new mockP5.Vector(0, 0, 0);
- v2 = new mockP5.Vector(2, 3, 4);
+ v1 = new Vector(0, 0, 0);
+ v2 = new Vector(2, 3, 4);
expect(v1.angleBetween(v2)).to.be.NaN;
expect(v2.angleBetween(v1)).to.be.NaN;
});
test.todo('between [1,0,0] and [1,0,0] should be 0 degrees', function () {
- mockP5Prototype.angleMode(mockP5.DEGREES);
- v1 = new mockP5.Vector(1, 0, 0);
- v2 = new mockP5.Vector(1, 0, 0);
+ mockP5Prototype.angleMode(DEGREES);
+ v1 = new Vector(1, 0, 0);
+ v2 = new Vector(1, 0, 0);
expect(v1.angleBetween(v2)).to.equal(0);
});
test.todo(
'between [0,3,0] and [0,-3,0] should be 180 degrees',
function () {
- mockP5Prototype.angleMode(mockP5.DEGREES);
- v1 = new mockP5.Vector(0, 3, 0);
- v2 = new mockP5.Vector(0, -3, 0);
+ mockP5Prototype.angleMode(DEGREES);
+ v1 = new Vector(0, 3, 0);
+ v2 = new Vector(0, -3, 0);
expect(v1.angleBetween(v2)).to.be.closeTo(180, 0.01);
}
);
test('between [1,0,0] and [2,2,0] should be 1/4 PI radians', function () {
- v1 = new mockP5.Vector(1, 0, 0);
- v2 = new mockP5.Vector(2, 2, 0);
+ v1 = new Vector(1, 0, 0);
+ v2 = new Vector(2, 2, 0);
expect(v1.angleBetween(v2)).to.be.closeTo(Math.PI / 4, 0.01);
expect(v2.angleBetween(v1)).to.be.closeTo((-1 * Math.PI) / 4, 0.01);
});
test('between [2,0,0] and [-2,0,0] should be PI radians', function () {
- v1 = new mockP5.Vector(2, 0, 0);
- v2 = new mockP5.Vector(-2, 0, 0);
+ v1 = new Vector(2, 0, 0);
+ v2 = new Vector(-2, 0, 0);
expect(v1.angleBetween(v2)).to.be.closeTo(Math.PI, 0.01);
});
test('between [2,0,0] and [-2,-2,0] should be -3/4 PI radians ', function () {
- v1 = new mockP5.Vector(2, 0, 0);
- v2 = new mockP5.Vector(-2, -2, 0);
+ v1 = new Vector(2, 0, 0);
+ v2 = new Vector(-2, -2, 0);
expect(v1.angleBetween(v2)).to.be.closeTo(
-1 * (Math.PI / 2 + Math.PI / 4),
0.01
@@ -279,8 +329,8 @@ suite('p5.Vector', function () {
});
test('between [-2,-2,0] and [2,0,0] should be 3/4 PI radians', function () {
- v1 = new mockP5.Vector(-2, -2, 0);
- v2 = new mockP5.Vector(2, 0, 0);
+ v1 = new Vector(-2, -2, 0);
+ v2 = new Vector(2, 0, 0);
expect(v1.angleBetween(v2)).to.be.closeTo(
Math.PI / 2 + Math.PI / 4,
0.01
@@ -288,40 +338,40 @@ suite('p5.Vector', function () {
});
test('For the same vectors, the angle between them should always be 0.', function () {
- v1 = new mockP5.Vector(288, 814);
- v2 = new mockP5.Vector(288, 814);
+ v1 = new Vector(288, 814);
+ v2 = new Vector(288, 814);
expect(v1.angleBetween(v2)).to.equal(0);
});
test('The angle between vectors pointing in opposite is always PI.', function () {
- v1 = new mockP5.Vector(219, 560);
- v2 = new mockP5.Vector(-219, -560);
+ v1 = new Vector(219, 560);
+ v2 = new Vector(-219, -560);
expect(v1.angleBetween(v2)).to.be.closeTo(Math.PI, 0.0000001);
});
});
suite('p5.Vector.angleBetween() [CLASS]', function () {
test('should return NaN for zero vector', function () {
- v1 = new mockP5.Vector(0, 0, 0);
- v2 = new mockP5.Vector(2, 3, 4);
- expect(mockP5.Vector.angleBetween(v1, v2)).to.be.NaN;
- expect(mockP5.Vector.angleBetween(v2, v1)).to.be.NaN;
+ v1 = new Vector(0, 0, 0);
+ v2 = new Vector(2, 3, 4);
+ expect(Vector.angleBetween(v1, v2)).to.be.NaN;
+ expect(Vector.angleBetween(v2, v1)).to.be.NaN;
});
test.todo(
'between [1,0,0] and [0,-1,0] should be -90 degrees',
function () {
- mockP5Prototype.angleMode(mockP5.DEGREES);
- v1 = new mockP5.Vector(1, 0, 0);
- v2 = new mockP5.Vector(0, -1, 0);
- expect(mockP5.Vector.angleBetween(v1, v2)).to.be.closeTo(-90, 0.01);
+ mockP5Prototype.angleMode(DEGREES);
+ v1 = new Vector(1, 0, 0);
+ v2 = new Vector(0, -1, 0);
+ expect(Vector.angleBetween(v1, v2)).to.be.closeTo(-90, 0.01);
}
);
test('between [0,3,0] and [0,-3,0] should be PI radians', function () {
- v1 = new mockP5.Vector(0, 3, 0);
- v2 = new mockP5.Vector(0, -3, 0);
- expect(mockP5.Vector.angleBetween(v1, v2)).to.be.closeTo(Math.PI, 0.01);
+ v1 = new Vector(0, 3, 0);
+ v2 = new Vector(0, -3, 0);
+ expect(Vector.angleBetween(v1, v2)).to.be.closeTo(Math.PI, 0.01);
});
});
});
@@ -329,7 +379,7 @@ suite('p5.Vector', function () {
suite('set()', function () {
suite('with p5.Vector', function () {
test("should have x, y, z be initialized to the vector's x, y, z", function () {
- v.set(new mockP5.Vector(2, 5, 6));
+ v.set(new Vector(2, 5, 6));
expect(v.x).to.eql(2);
expect(v.y).to.eql(5);
expect(v.z).to.eql(6);
@@ -364,7 +414,7 @@ suite('p5.Vector', function () {
suite('copy', function () {
beforeEach(function () {
- v = new mockP5.Vector(2, 3, 4);
+ v = new Vector(2, 3, 4);
});
suite('p5.Vector.prototype.copy() [INSTANCE]', function () {
@@ -383,12 +433,12 @@ suite('p5.Vector', function () {
suite('p5.Vector.copy() [CLASS]', function () {
test('should not return the same instance', function () {
- var newObject = mockP5.Vector.copy(v);
+ var newObject = Vector.copy(v);
expect(newObject).to.not.equal(v);
});
test("should return the passed object's x, y, z", function () {
- var newObject = mockP5.Vector.copy(v);
+ var newObject = Vector.copy(v);
expect(newObject.x).to.eql(2);
expect(newObject.y).to.eql(3);
expect(newObject.z).to.eql(4);
@@ -398,12 +448,12 @@ suite('p5.Vector', function () {
suite('add()', function () {
beforeEach(function () {
- v = new mockP5.Vector();
+ v = new Vector(0, 0, 0);
});
suite('with p5.Vector', function () {
test('should add x, y, z from the vector argument', function () {
- v.add(new mockP5.Vector(1, 5, 6));
+ v.add(new Vector(1, 5, 6));
expect(v.x).to.eql(1);
expect(v.y).to.eql(5);
expect(v.z).to.eql(6);
@@ -457,9 +507,9 @@ suite('p5.Vector', function () {
suite('p5.Vector.add(v1, v2)', function () {
var v1, v2, res;
beforeEach(function () {
- v1 = new mockP5.Vector(2, 0, 3);
- v2 = new mockP5.Vector(0, 1, 3);
- res = mockP5.Vector.add(v1, v2);
+ v1 = new Vector(2, 0, 3);
+ v2 = new Vector(0, 1, 3);
+ res = Vector.add(v1, v2);
});
test('should return neither v1 nor v2', function () {
@@ -477,7 +527,7 @@ suite('p5.Vector', function () {
suite('rem()', function () {
beforeEach(function () {
- v = new mockP5.Vector(3, 4, 5);
+ v = new Vector(3, 4, 5);
});
test('should give same vector if nothing passed as parameter', function () {
@@ -495,10 +545,12 @@ suite('p5.Vector', function () {
});
test('should give correct output if passed two numeric value', function () {
+ expect(v.dimensions).to.eql(3);
v.rem(2, 3);
expect(v.x).to.eql(1);
expect(v.y).to.eql(1);
- expect(v.z).to.eql(5);
+ expect(v.z).to.eql(0);
+ expect(v.dimensions).to.eql(2);
});
test('should give correct output if passed three numeric value', function () {
@@ -510,28 +562,28 @@ suite('p5.Vector', function () {
suite('with p5.Vector', function () {
test('should return correct output if only one component is non-zero', function () {
- v.rem(new mockP5.Vector(0, 0, 4));
+ v.rem(new Vector(0, 0, 4));
expect(v.x).to.eql(3);
expect(v.y).to.eql(4);
expect(v.z).to.eql(1);
});
test('should return correct output if x component is zero', () => {
- v.rem(new mockP5.Vector(0, 3, 4));
+ v.rem(new Vector(0, 3, 4));
expect(v.x).to.eql(3);
expect(v.y).to.eql(1);
expect(v.z).to.eql(1);
});
test('should return correct output if all components are non-zero', () => {
- v.rem(new mockP5.Vector(2, 3, 4));
+ v.rem(new Vector(2, 3, 4));
expect(v.x).to.eql(1);
expect(v.y).to.eql(1);
expect(v.z).to.eql(1);
});
test('should return same vector if all components are zero', () => {
- v.rem(new mockP5.Vector(0, 0, 0));
+ v.rem(new Vector(0, 0, 0));
expect(v.x).to.eql(3);
expect(v.y).to.eql(4);
expect(v.z).to.eql(5);
@@ -541,10 +593,10 @@ suite('p5.Vector', function () {
suite('with negative vectors', function () {
let v;
beforeEach(function () {
- v = new mockP5.Vector(-15, -5, -2);
+ v = new Vector(-15, -5, -2);
});
test('should return correct output', () => {
- v.rem(new mockP5.Vector(2, 3, 3));
+ v.rem(new Vector(2, 3, 3));
expect(v.x).to.eql(-1);
expect(v.y).to.eql(-2);
expect(v.z).to.eql(-2);
@@ -562,14 +614,16 @@ suite('p5.Vector', function () {
v.rem([2, 3]);
expect(v.x).to.eql(1);
expect(v.y).to.eql(1);
- expect(v.z).to.eql(5);
+ expect(v.z).to.eql(0);
+ expect(v.dimensions).to.eql(2);
});
test('should return correct output if x,y components are zero for 2D vector', () => {
v.rem([0, 0]);
expect(v.x).to.eql(3);
expect(v.y).to.eql(4);
- expect(v.z).to.eql(5);
+ expect(v.z).to.eql(0);
+ expect(v.dimensions).to.eql(2);
});
test('should return same vector if any vector component is non-finite number', () => {
@@ -583,9 +637,9 @@ suite('p5.Vector', function () {
suite('p5.Vector.rem(v1,v2)', function () {
let v1, v2, res;
beforeEach(function () {
- v1 = new mockP5.Vector(2, 3, 4);
- v2 = new mockP5.Vector(1, 2, 3);
- res = mockP5.Vector.rem(v1, v2);
+ v1 = new Vector(2, 3, 4);
+ v2 = new Vector(1, 2, 3);
+ res = Vector.rem(v1, v2);
});
test('should return neither v1 nor v2', function () {
@@ -605,14 +659,11 @@ suite('p5.Vector', function () {
beforeEach(function () {
v.x = 0;
v.y = 0;
- v.z = 0;
});
suite('with p5.Vector', function () {
test('should sub x, y, z from the vector argument', function () {
- v.sub(new mockP5.Vector(2, 5, 6));
- expect(v.x).to.eql(-2);
- expect(v.y).to.eql(-5);
- expect(v.z).to.eql(-6);
+ v.sub(new Vector(2, 5));
+ assert.deepEqual(v.values, [-2, -5]);
});
});
@@ -626,11 +677,9 @@ suite('p5.Vector', function () {
});
});
- test("should subtract from the array's 0,1,2 index", function () {
+ test("should subtract from the array's 0, 1, 2 index", function () {
v.sub([2, 5, 6]);
- expect(v.x).to.eql(-2);
- expect(v.y).to.eql(-5);
- expect(v.z).to.eql(-6);
+ expect(v.values).to.eql([-2, -5]);
});
});
@@ -645,19 +694,19 @@ suite('p5.Vector', function () {
suite('sub(2,3,4)', function () {
test('should subtract the x, y, z components', function () {
- v.sub(5, 5, 5);
+ v.sub(5, 5);
expect(v.x).to.eql(-5);
expect(v.y).to.eql(-5);
- expect(v.z).to.eql(-5);
+ //expect(v.z).to.eql(-5);
});
});
suite('p5.Vector.sub(v1, v2)', function () {
var v1, v2, res;
beforeEach(function () {
- v1 = new mockP5.Vector(2, 0, 3);
- v2 = new mockP5.Vector(0, 1, 3);
- res = mockP5.Vector.sub(v1, v2);
+ v1 = new Vector(2, 0, 3);
+ v2 = new Vector(0, 1, 3);
+ res = Vector.sub(v1, v2);
});
test('should return neither v1 nor v2', function () {
@@ -675,7 +724,7 @@ suite('p5.Vector', function () {
suite('mult()', function () {
beforeEach(function () {
- v = new mockP5.Vector(1, 1, 1);
+ v = new Vector(1, 1, 1);
});
test('should return the same object', function () {
@@ -705,11 +754,20 @@ suite('p5.Vector', function () {
});
});
+ suite('with arglist', function () {
+ test('multiply the x, y, z with the scalar', function () {
+ v.mult(2, 3, 4);
+ expect(v.x).to.eql(2);
+ expect(v.y).to.eql(3);
+ expect(v.z).to.eql(4);
+ });
+ });
+
suite('v0.mult(v1)', function () {
var v0, v1;
beforeEach(function () {
- v0 = new mockP5.Vector(1, 2, 3);
- v1 = new mockP5.Vector(2, 3, 4);
+ v0 = new Vector(1, 2, 3);
+ v1 = new Vector(2, 3, 4);
v0.mult(v1);
});
@@ -723,7 +781,7 @@ suite('p5.Vector', function () {
suite('v0.mult(arr)', function () {
var v0, arr;
beforeEach(function () {
- v0 = new mockP5.Vector(1, 2, 3);
+ v0 = new Vector(1, 2, 3);
arr = [2, 3, 4];
v0.mult(arr);
});
@@ -738,8 +796,8 @@ suite('p5.Vector', function () {
suite('p5.Vector.mult(v, n)', function () {
var v, res;
beforeEach(function () {
- v = new mockP5.Vector(1, 2, 3);
- res = mockP5.Vector.mult(v, 4);
+ v = new Vector(1, 2, 3);
+ res = Vector.mult(v, 4);
});
test('should return a new p5.Vector', function () {
@@ -756,9 +814,9 @@ suite('p5.Vector', function () {
suite('p5.Vector.mult(v, v', function () {
var v0, v1, res;
beforeEach(function () {
- v0 = new mockP5.Vector(1, 2, 3);
- v1 = new mockP5.Vector(2, 3, 4);
- res = mockP5.Vector.mult(v0, v1);
+ v0 = new Vector(1, 2, 3);
+ v1 = new Vector(2, 3, 4);
+ res = Vector.mult(v0, v1);
});
test('should return new vector from component wise multiplication', function () {
@@ -768,12 +826,12 @@ suite('p5.Vector', function () {
});
});
- suite('p5.Vector.mult(v, arr', function () {
+ suite('p5.Vector.mult(v, arr)', function () {
var v0, arr, res;
beforeEach(function () {
- v0 = new mockP5.Vector(1, 2, 3);
+ v0 = new Vector(1, 2, 3);
arr = [2, 3, 4];
- res = mockP5.Vector.mult(v0, arr);
+ res = Vector.mult(v0, arr);
});
test('should return new vector from component wise multiplication with an array', function () {
@@ -786,7 +844,7 @@ suite('p5.Vector', function () {
suite('div()', function () {
beforeEach(function () {
- v = new mockP5.Vector(1, 1, 1);
+ v = new Vector(1, 1, 1);
});
test('should return the same object', function () {
@@ -823,11 +881,20 @@ suite('p5.Vector', function () {
});
});
+ suite('with arglist', function () {
+ test('divide the x, y, z with the scalar', function () {
+ v.div(2, 3, 4);
+ expect(v.x).to.be.closeTo(0.5, 0.01);
+ expect(v.y).to.be.closeTo(0.333, 0.01);
+ expect(v.z).to.be.closeTo(0.25, 0.01);
+ });
+ });
+
suite('p5.Vector.div(v, n)', function () {
var v, res;
beforeEach(function () {
- v = new mockP5.Vector(1, 1, 1);
- res = mockP5.Vector.div(v, 4);
+ v = new Vector(1, 1, 1);
+ res = Vector.div(v, 4);
});
test('should not be undefined', function () {
@@ -848,10 +915,10 @@ suite('p5.Vector', function () {
suite('v0.div(v1)', function () {
var v0, v1, v2, v3;
beforeEach(function () {
- v0 = new mockP5.Vector(2, 6, 9);
- v1 = new mockP5.Vector(2, 2, 3);
- v2 = new mockP5.Vector(1, 1, 1);
- v3 = new mockP5.Vector(0, 0, 0);
+ v0 = new Vector(2, 6, 9);
+ v1 = new Vector(2, 2, 3);
+ v2 = new Vector(1, 1, 1);
+ v3 = new Vector(0, 0, 0);
v0.div(v1);
});
@@ -870,8 +937,8 @@ suite('p5.Vector', function () {
});
test('should work on 2D vectors', function () {
- const v = new mockP5.Vector(1, 1);
- const divisor = new mockP5.Vector(2, 2);
+ const v = new Vector(1, 1);
+ const divisor = new Vector(2, 2);
v.div(divisor);
expect(v.x).to.eql(0.5);
expect(v.y).to.eql(0.5);
@@ -879,8 +946,8 @@ suite('p5.Vector', function () {
});
test('should work when the dividend has 0', function () {
- const v = new mockP5.Vector(1, 0);
- const divisor = new mockP5.Vector(2, 2);
+ const v = new Vector(1, 0);
+ const divisor = new Vector(2, 2);
v.div(divisor);
expect(v.x).to.eql(0.5);
expect(v.y).to.eql(0);
@@ -888,8 +955,8 @@ suite('p5.Vector', function () {
});
test('should do nothing when the divisor has 0', function () {
- const v = new mockP5.Vector(1, 1);
- const divisor = new mockP5.Vector(0, 2);
+ const v = new Vector(1, 1);
+ const divisor = new Vector(0, 2);
v.div(divisor);
expect(v.x).to.eql(1);
expect(v.y).to.eql(1);
@@ -900,8 +967,8 @@ suite('p5.Vector', function () {
suite('v0.div(arr)', function () {
var v0, v1, arr;
beforeEach(function () {
- v0 = new mockP5.Vector(2, 6, 9);
- v1 = new mockP5.Vector(1, 1, 1);
+ v0 = new Vector(2, 6, 9);
+ v1 = new Vector(1, 1, 1);
arr = [2, 2, 3];
v0.div(arr);
});
@@ -923,9 +990,9 @@ suite('p5.Vector', function () {
suite('p5.Vector.div(v, v', function () {
var v0, v1, res;
beforeEach(function () {
- v0 = new mockP5.Vector(2, 6, 9);
- v1 = new mockP5.Vector(2, 2, 3);
- res = mockP5.Vector.div(v0, v1);
+ v0 = new Vector(2, 6, 9);
+ v1 = new Vector(2, 2, 3);
+ res = Vector.div(v0, v1);
});
test('should return new vector from component wise division', function () {
@@ -938,9 +1005,9 @@ suite('p5.Vector', function () {
suite('p5.Vector.div(v, arr', function () {
var v0, arr, res;
beforeEach(function () {
- v0 = new mockP5.Vector(2, 6, 9);
+ v0 = new Vector(2, 6, 9);
arr = [2, 2, 3];
- res = mockP5.Vector.div(v0, arr);
+ res = Vector.div(v0, arr);
});
test('should return new vector from component wise division with an array', function () {
@@ -951,6 +1018,56 @@ suite('p5.Vector', function () {
});
});
+
+ suite('smaller dimension', function () {
+ let v1, v2, v3;
+ beforeEach(function () {
+ v1 = new Vector(1);
+ v2 = new Vector(2, 3);
+ v3 = new Vector(4, 5, 6);
+ });
+
+ test('should be prioritized in add()', function () {
+ assert.deepEqual(v1.add(v2).values, [3]);
+ expect(v1.add(v2).dimensions).to.eql(1);
+
+ assert.deepEqual(v3.add(v2).values, [6, 8]);
+ expect(v3.add(v2).dimensions).to.eql(2);
+ });
+
+ test('should be prioritized in sub()', function () {
+ assert.deepEqual(v1.sub(v2).values, [-1]);
+ expect(v1.sub(v2).dimensions).to.eql(1);
+
+ assert.deepEqual(v3.sub(v2).values, [2, 2]);
+ expect(v3.sub(v2).dimensions).to.eql(2);
+ });
+
+ test('should be prioritized in mult()', function () {
+ assert.deepEqual(v1.mult(v2).values, [2]);
+ expect(v1.mult(v2).dimensions).to.eql(1);
+
+ assert.deepEqual(v3.mult(v2).values, [8, 15]);
+ expect(v3.mult(v2).dimensions).to.eql(2);
+ });
+
+ test('should be prioritized in div()', function () {
+ assert.deepEqual(v1.div(v2).values, [1/2]);
+ expect(v1.div(v2).dimensions).to.eql(1);
+
+ assert.deepEqual(v3.div(v2).values, [2, 5/3]);
+ expect(v3.div(v2).dimensions).to.eql(2);
+ });
+
+ test('should be prioritized in rem()', function () {
+ assert.deepEqual(v1.rem(v2).values, [1]);
+ expect(v1.rem(v2).dimensions).to.eql(1);
+
+ assert.deepEqual(v3.rem(v2).values, [0, 2]);
+ expect(v3.rem(v2).dimensions).to.eql(2);
+ });
+ });
+
suite('dot', function () {
beforeEach(function () {
v.x = 1;
@@ -959,12 +1076,12 @@ suite('p5.Vector', function () {
});
test('should return a number', function () {
- expect(typeof v.dot(new mockP5.Vector()) === 'number').to.eql(true);
+ expect(typeof v.dot(new Vector()) === 'number').to.eql(true);
});
suite('with p5.Vector', function () {
test('should be the dot product of the vector', function () {
- expect(v.dot(new mockP5.Vector(2, 2))).to.eql(4);
+ expect(v.dot(new Vector(2, 2))).to.eql(4);
});
});
@@ -981,9 +1098,9 @@ suite('p5.Vector', function () {
suite('p5.Vector.dot(v, n)', function () {
var v1, v2, res;
beforeEach(function () {
- v1 = new mockP5.Vector(1, 1, 1);
- v2 = new mockP5.Vector(2, 3, 4);
- res = mockP5.Vector.dot(v1, v2);
+ v1 = new Vector(1, 1, 1);
+ v2 = new Vector(2, 3, 4);
+ res = Vector.dot(v1, v2);
});
test('should return a number', function () {
@@ -1005,12 +1122,12 @@ suite('p5.Vector', function () {
});
test('should return a new product', function () {
- expect(v.cross(new mockP5.Vector())).to.not.eql(v);
+ expect(v.cross(new Vector())).to.not.eql(v);
});
suite('with p5.Vector', function () {
test('should cross x, y, z from the vector argument', function () {
- res = v.cross(new mockP5.Vector(2, 5, 6));
+ res = v.cross(new Vector(2, 5, 6));
expect(res.x).to.eql(1); //this.y * v.z - this.z * v.y
expect(res.y).to.eql(-4); //this.z * v.x - this.x * v.z
expect(res.z).to.eql(3); //this.x * v.y - this.y * v.x
@@ -1020,9 +1137,9 @@ suite('p5.Vector', function () {
suite('p5.Vector.cross(v1, v2)', function () {
var v1, v2, res;
beforeEach(function () {
- v1 = new mockP5.Vector(3, 6, 9);
- v2 = new mockP5.Vector(1, 1, 1);
- res = mockP5.Vector.cross(v1, v2);
+ v1 = new Vector(3, 6, 9);
+ v2 = new Vector(1, 1, 1);
+ res = Vector.cross(v1, v2);
});
test('should not be undefined', function () {
@@ -1045,9 +1162,9 @@ suite('p5.Vector', function () {
suite('dist', function () {
var b, c;
beforeEach(function () {
- v = new mockP5.Vector(0, 0, 1);
- b = new mockP5.Vector(0, 0, 5);
- c = new mockP5.Vector(3, 4, 1);
+ v = new Vector(0, 0, 1);
+ b = new Vector(0, 0, 5);
+ c = new Vector(3, 4, 1);
});
test('should return a number', function () {
@@ -1070,23 +1187,23 @@ suite('p5.Vector', function () {
suite('p5.Vector.dist(v1, v2)', function () {
var v1, v2;
beforeEach(function () {
- v1 = new mockP5.Vector(0, 0, 0);
- v2 = new mockP5.Vector(0, 3, 4);
+ v1 = new Vector(0, 0, 0);
+ v2 = new Vector(0, 3, 4);
});
test('should return a number', function () {
- expect(typeof mockP5.Vector.dist(v1, v2) === 'number').to.eql(true);
+ expect(typeof Vector.dist(v1, v2) === 'number').to.eql(true);
});
test('should be commutative', function () {
- expect(mockP5.Vector.dist(v1, v2)).to.eql(mockP5.Vector.dist(v2, v1));
+ expect(Vector.dist(v1, v2)).to.eql(Vector.dist(v2, v1));
});
});
suite('normalize', function () {
suite('p5.Vector.prototype.normalize() [INSTANCE]', function () {
beforeEach(function () {
- v = new mockP5.Vector(1, 1, 1);
+ v = new Vector(1, 1, 1);
});
test('should return the same object', function () {
@@ -1117,8 +1234,8 @@ suite('p5.Vector', function () {
suite('p5.Vector.normalize(v) [CLASS]', function () {
var res;
beforeEach(function () {
- v = new mockP5.Vector(1, 0, 0);
- res = mockP5.Vector.normalize(v);
+ v = new Vector(1, 0, 0);
+ res = Vector.normalize(v);
});
test('should not be undefined', function () {
@@ -1139,7 +1256,7 @@ suite('p5.Vector', function () {
v.x = 2;
v.y = 2;
v.z = 1;
- res = mockP5.Vector.normalize(v);
+ res = Vector.normalize(v);
expect(res.x).to.be.closeTo(0.6666, 0.01);
expect(res.y).to.be.closeTo(0.6666, 0.01);
expect(res.z).to.be.closeTo(0.3333, 0.01);
@@ -1151,7 +1268,7 @@ suite('p5.Vector', function () {
let v;
beforeEach(function () {
- v = new mockP5.Vector(5, 5, 5);
+ v = new Vector(5, 5, 5);
});
suite('p5.Vector.prototype.limit() [INSTANCE]', function () {
@@ -1180,12 +1297,12 @@ suite('p5.Vector', function () {
suite('p5.Vector.limit() [CLASS]', function () {
test('should not return the same object', function () {
- expect(mockP5.Vector.limit(v)).to.not.equal(v);
+ expect(Vector.limit(v)).to.not.equal(v);
});
suite('with a vector larger than the limit', function () {
test('should limit the vector', function () {
- const res = mockP5.Vector.limit(v, 1);
+ const res = Vector.limit(v, 1);
expect(res.x).to.be.closeTo(0.5773, 0.01);
expect(res.y).to.be.closeTo(0.5773, 0.01);
expect(res.z).to.be.closeTo(0.5773, 0.01);
@@ -1194,7 +1311,7 @@ suite('p5.Vector', function () {
suite('with a vector smaller than the limit', function () {
test('should not limit the vector', function () {
- const res = mockP5.Vector.limit(v, 8.67);
+ const res = Vector.limit(v, 8.67);
expect(res.x).to.eql(5);
expect(res.y).to.eql(5);
expect(res.z).to.eql(5);
@@ -1203,8 +1320,8 @@ suite('p5.Vector', function () {
suite('when given a target vector', function () {
test('should store limited vector in the target', function () {
- const target = new mockP5.Vector(0, 0, 0);
- mockP5.Vector.limit(v, 1, target);
+ const target = new Vector(0, 0, 0);
+ Vector.limit(v, 1, target);
expect(target.x).to.be.closeTo(0.5773, 0.01);
expect(target.y).to.be.closeTo(0.5773, 0.01);
expect(target.z).to.be.closeTo(0.5773, 0.01);
@@ -1217,7 +1334,7 @@ suite('p5.Vector', function () {
let v;
beforeEach(function () {
- v = new mockP5.Vector(1, 0, 0);
+ v = new Vector(1, 0, 0);
});
suite('p5.Vector.setMag() [INSTANCE]', function () {
@@ -1245,11 +1362,11 @@ suite('p5.Vector', function () {
suite('p5.Vector.prototype.setMag() [CLASS]', function () {
test('should not return the same object', function () {
- expect(mockP5.Vector.setMag(v, 2)).to.not.equal(v);
+ expect(Vector.setMag(v, 2)).to.not.equal(v);
});
test('should set the magnitude of the vector', function () {
- const res = mockP5.Vector.setMag(v, 4);
+ const res = Vector.setMag(v, 4);
expect(res.x).to.eql(4);
expect(res.y).to.eql(0);
expect(res.z).to.eql(0);
@@ -1259,7 +1376,7 @@ suite('p5.Vector', function () {
v.x = 2;
v.y = 3;
v.z = -6;
- const res = mockP5.Vector.setMag(v, 14);
+ const res = Vector.setMag(v, 14);
expect(res.x).to.eql(4);
expect(res.y).to.eql(6);
expect(res.z).to.eql(-12);
@@ -1267,8 +1384,8 @@ suite('p5.Vector', function () {
suite('when given a target vector', function () {
test('should set the magnitude on the target', function () {
- const target = new mockP5.Vector(0, 1, 0);
- const res = mockP5.Vector.setMag(v, 4, target);
+ const target = new Vector(0, 1, 0);
+ const res = Vector.setMag(v, 4, target);
expect(target).to.equal(res);
expect(target.x).to.eql(4);
expect(target.y).to.eql(0);
@@ -1280,7 +1397,7 @@ suite('p5.Vector', function () {
suite('heading', function () {
beforeEach(function () {
- v = new mockP5.Vector();
+ v = new Vector(0,0,0);
});
suite('p5.Vector.prototype.heading() [INSTANCE]', function () {
@@ -1311,7 +1428,7 @@ suite('p5.Vector', function () {
suite.todo('with `angleMode(DEGREES)`', function () {
beforeEach(function () {
- mockP5Prototype.angleMode(mockP5.DEGREES);
+ mockP5Prototype.angleMode(DEGREES);
});
test('heading for vector pointing right is 0', function () {
@@ -1339,28 +1456,28 @@ suite('p5.Vector', function () {
suite('p5.Vector.heading() [CLASS]', function () {
test('should return a number', function () {
- expect(typeof mockP5.Vector.heading(v) === 'number').to.eql(true);
+ expect(typeof Vector.heading(v) === 'number').to.eql(true);
});
test('heading for vector pointing right is 0', function () {
v.x = 1;
v.y = 0;
v.z = 0;
- expect(mockP5.Vector.heading(v)).to.be.closeTo(0, 0.01);
+ expect(Vector.heading(v)).to.be.closeTo(0, 0.01);
});
test('heading for vector pointing down is PI/2', function () {
v.x = 0;
v.y = 1;
v.z = 0;
- expect(mockP5.Vector.heading(v)).to.be.closeTo(Math.PI / 2, 0.01);
+ expect(Vector.heading(v)).to.be.closeTo(Math.PI / 2, 0.01);
});
test('heading for vector pointing left is PI', function () {
v.x = -1;
v.y = 0;
v.z = 0;
- expect(mockP5.Vector.heading(v)).to.be.closeTo(Math.PI, 0.01);
+ expect(Vector.heading(v)).to.be.closeTo(Math.PI, 0.01);
});
});
});
@@ -1370,14 +1487,13 @@ suite('p5.Vector', function () {
expect(v.lerp()).to.eql(v);
});
- // PEND: ADD BACK IN
- // suite('with p5.Vector', function() {
- // test('should call lerp with 4 arguments', function() {
- // spyOn(v, 'lerp').andCallThrough();
- // v.lerp(new p5.Vector(1,2,3), 1);
- // expect(v.lerp).toHaveBeenCalledWith(1, 2, 3, 1);
- // });
- // });
+ suite('with p5.Vector', function() {
+ test('should call lerp with 4 arguments', function() {
+ vi.spyOn(v, 'lerp');
+ v.lerp(new Vector(1,2,3), 1);
+ expect(v.lerp).toHaveBeenCalledWith(1, 2, 3, 1);
+ });
+ });
suite('with x, y, z, amt', function () {
beforeEach(function () {
@@ -1416,9 +1532,9 @@ suite('p5.Vector', function () {
suite('p5.Vector.lerp(v1, v2, amt)', function () {
var res, v1, v2;
beforeEach(function () {
- v1 = new mockP5.Vector(0, 0, 0);
- v2 = new mockP5.Vector(2, 2, 2);
- res = mockP5.Vector.lerp(v1, v2, 0.5);
+ v1 = new Vector(0, 0, 0);
+ v2 = new Vector(2, 2, 2);
+ res = Vector.lerp(v1, v2, 0.5);
});
test('should not be undefined', function () {
@@ -1426,7 +1542,7 @@ suite('p5.Vector', function () {
});
test('should be a p5.Vector', function () {
- expect(res).to.be.an.instanceof(mockP5.Vector);
+ expect(res).to.be.an.instanceof(Vector);
});
test('should return neither v1 nor v2', function () {
@@ -1445,7 +1561,7 @@ suite('p5.Vector', function () {
var w;
beforeEach(function () {
v.set(1, 2, 3);
- w = new mockP5.Vector(4, 6, 8);
+ w = new Vector(4, 6, 8);
});
test('if amt is 0, returns original vector', function () {
@@ -1496,9 +1612,9 @@ suite('p5.Vector', function () {
suite('p5.Vector.slerp(v1, v2, amt)', function () {
var res, v1, v2;
beforeEach(function () {
- v1 = new mockP5.Vector(1, 0, 0);
- v2 = new mockP5.Vector(0, 0, 1);
- res = mockP5.Vector.slerp(v1, v2, 1 / 3);
+ v1 = new Vector(1, 0, 0);
+ v2 = new Vector(0, 0, 1);
+ res = Vector.slerp(v1, v2, 1 / 3);
});
test('should not be undefined', function () {
@@ -1506,7 +1622,7 @@ suite('p5.Vector', function () {
});
test('should be a p5.Vector', function () {
- expect(res).to.be.an.instanceof(mockP5.Vector);
+ expect(res).to.be.an.instanceof(Vector);
});
test('should return neither v1 nor v2', function () {
@@ -1521,14 +1637,14 @@ suite('p5.Vector', function () {
});
test('Make sure the interpolation in -1/3 is correct', function () {
- mockP5.Vector.slerp(v1, v2, -1 / 3, res);
+ Vector.slerp(v1, v2, -1 / 3, res);
expect(res.x).to.be.closeTo(Math.cos(-Math.PI / 6), 0.00001);
expect(res.y).to.be.closeTo(0, 0.00001);
expect(res.z).to.be.closeTo(Math.sin(-Math.PI / 6), 0.00001);
});
test('Make sure the interpolation in 5/3 is correct', function () {
- mockP5.Vector.slerp(v1, v2, 5 / 3, res);
+ Vector.slerp(v1, v2, 5 / 3, res);
expect(res.x).to.be.closeTo(Math.cos((5 * Math.PI) / 6), 0.00001);
expect(res.y).to.be.closeTo(0, 0.00001);
expect(res.z).to.be.closeTo(Math.sin((5 * Math.PI) / 6), 0.00001);
@@ -1539,7 +1655,7 @@ suite('p5.Vector', function () {
var res, angle;
beforeEach(function () {
angle = Math.PI / 2;
- res = mockP5.Vector.fromAngle(angle);
+ res = Vector.fromAngle(angle);
});
test('should be a p5.Vector with values (0,1)', function () {
@@ -1551,7 +1667,7 @@ suite('p5.Vector', function () {
suite('p5.Vector.random2D()', function () {
var res;
beforeEach(function () {
- res = mockP5.Vector.random2D();
+ res = Vector.random2D();
});
test('should be a unit p5.Vector', function () {
@@ -1562,7 +1678,7 @@ suite('p5.Vector', function () {
suite('p5.Vector.random3D()', function () {
var res;
beforeEach(function () {
- res = mockP5.Vector.random3D();
+ res = Vector.random3D();
});
test('should be a unit p5.Vector', function () {
expect(res.mag()).to.be.closeTo(1, 0.01);
@@ -1571,7 +1687,7 @@ suite('p5.Vector', function () {
suite('array', function () {
beforeEach(function () {
- v = new mockP5.Vector(1, 23, 4);
+ v = new Vector(1, 23, 4);
});
suite('p5.Vector.prototype.array() [INSTANCE]', function () {
@@ -1586,11 +1702,11 @@ suite('p5.Vector', function () {
suite('p5.Vector.array() [CLASS]', function () {
test('should return an array', function () {
- expect(mockP5.Vector.array(v)).to.be.instanceof(Array);
+ expect(Vector.array(v)).to.be.instanceof(Array);
});
test('should return an with the x y and z components', function () {
- expect(mockP5.Vector.array(v)).to.eql([1, 23, 4]);
+ expect(Vector.array(v)).to.eql([1, 23, 4]);
});
});
});
@@ -1609,31 +1725,31 @@ suite('p5.Vector', function () {
incoming_x = 1;
incoming_y = 1;
incoming_z = 1;
- original_incoming = new mockP5.Vector(
+ original_incoming = new Vector(
incoming_x,
incoming_y,
incoming_z
);
- x_normal = new mockP5.Vector(3, 0, 0);
- y_normal = new mockP5.Vector(0, 3, 0);
- z_normal = new mockP5.Vector(0, 0, 3);
+ x_normal = new Vector(3, 0, 0);
+ y_normal = new Vector(0, 3, 0);
+ z_normal = new Vector(0, 0, 3);
- x_bounce_incoming = new mockP5.Vector(
+ x_bounce_incoming = new Vector(
incoming_x,
incoming_y,
incoming_z
);
x_bounce_outgoing = x_bounce_incoming.reflect(x_normal);
- y_bounce_incoming = new mockP5.Vector(
+ y_bounce_incoming = new Vector(
incoming_x,
incoming_y,
incoming_z
);
y_bounce_outgoing = y_bounce_incoming.reflect(y_normal);
- z_bounce_incoming = new mockP5.Vector(
+ z_bounce_incoming = new Vector(
incoming_x,
incoming_y,
incoming_z
@@ -1642,9 +1758,9 @@ suite('p5.Vector', function () {
});
test('should return a p5.Vector', function () {
- expect(x_bounce_incoming).to.be.an.instanceof(mockP5.Vector);
- expect(y_bounce_incoming).to.be.an.instanceof(mockP5.Vector);
- expect(z_bounce_incoming).to.be.an.instanceof(mockP5.Vector);
+ expect(x_bounce_incoming).to.be.an.instanceof(Vector);
+ expect(y_bounce_incoming).to.be.an.instanceof(Vector);
+ expect(z_bounce_incoming).to.be.an.instanceof(Vector);
});
test('should update this', function () {
@@ -1720,47 +1836,47 @@ suite('p5.Vector', function () {
incoming_x = 1;
incoming_y = 1;
incoming_z = 1;
- original_incoming = new mockP5.Vector(
+ original_incoming = new Vector(
incoming_x,
incoming_y,
incoming_z
);
- x_target = new mockP5.Vector();
- y_target = new mockP5.Vector();
- z_target = new mockP5.Vector();
+ x_target = new Vector(0, 0, 0);
+ y_target = new Vector(0, 0, 0);
+ z_target = new Vector(0, 0, 0);
- x_normal = new mockP5.Vector(3, 0, 0);
- y_normal = new mockP5.Vector(0, 3, 0);
- z_normal = new mockP5.Vector(0, 0, 3);
+ x_normal = new Vector(3, 0, 0);
+ y_normal = new Vector(0, 3, 0);
+ z_normal = new Vector(0, 0, 3);
- x_bounce_incoming = new mockP5.Vector(
+ x_bounce_incoming = new Vector(
incoming_x,
incoming_y,
incoming_z
);
- x_bounce_outgoing = mockP5.Vector.reflect(
+ x_bounce_outgoing = Vector.reflect(
x_bounce_incoming,
x_normal,
x_target
);
- y_bounce_incoming = new mockP5.Vector(
+ y_bounce_incoming = new Vector(
incoming_x,
incoming_y,
incoming_z
);
- y_bounce_outgoing = mockP5.Vector.reflect(
+ y_bounce_outgoing = Vector.reflect(
y_bounce_incoming,
y_normal,
y_target
);
- z_bounce_incoming = new mockP5.Vector(
+ z_bounce_incoming = new Vector(
incoming_x,
incoming_y,
incoming_z
);
- z_bounce_outgoing = mockP5.Vector.reflect(
+ z_bounce_outgoing = Vector.reflect(
z_bounce_incoming,
z_normal,
z_target
@@ -1768,9 +1884,9 @@ suite('p5.Vector', function () {
});
test('should return a p5.Vector', function () {
- expect(x_bounce_incoming).to.be.an.instanceof(mockP5.Vector);
- expect(y_bounce_incoming).to.be.an.instanceof(mockP5.Vector);
- expect(z_bounce_incoming).to.be.an.instanceof(mockP5.Vector);
+ expect(x_bounce_incoming).to.be.an.instanceof(Vector);
+ expect(y_bounce_incoming).to.be.an.instanceof(Vector);
+ expect(z_bounce_incoming).to.be.an.instanceof(Vector);
});
test('should not update this', function () {
@@ -1846,8 +1962,8 @@ suite('p5.Vector', function () {
let v1;
beforeEach(function () {
- v0 = new mockP5.Vector(0, 0, 0);
- v1 = new mockP5.Vector(1, 2, 3);
+ v0 = new Vector(0, 0, 0);
+ v1 = new Vector(1, 2, 3);
});
suite('p5.Vector.prototype.mag() [INSTANCE]', function () {
@@ -1859,8 +1975,8 @@ suite('p5.Vector', function () {
suite('p5.Vector.mag() [CLASS]', function () {
test('should return the magnitude of the vector', function () {
- expect(mockP5.Vector.mag(v0)).to.eql(0);
- expect(mockP5.Vector.mag(v1)).to.eql(MAG);
+ expect(Vector.mag(v0)).to.eql(0);
+ expect(Vector.mag(v1)).to.eql(MAG);
});
});
});
@@ -1872,8 +1988,8 @@ suite('p5.Vector', function () {
let v1;
beforeEach(function () {
- v0 = new mockP5.Vector(0, 0, 0);
- v1 = new mockP5.Vector(1, 2, 3);
+ v0 = new Vector(0, 0, 0);
+ v1 = new Vector(1, 2, 3);
});
suite('p5.Vector.prototype.magSq() [INSTANCE]', function () {
@@ -1885,8 +2001,8 @@ suite('p5.Vector', function () {
suite('p5.Vector.magSq() [CLASS]', function () {
test('should return the magnitude of the vector', function () {
- expect(mockP5.Vector.magSq(v0)).to.eql(0);
- expect(mockP5.Vector.magSq(v1)).to.eql(MAG);
+ expect(Vector.magSq(v0)).to.eql(0);
+ expect(Vector.magSq(v1)).to.eql(MAG);
});
});
});
@@ -1894,8 +2010,8 @@ suite('p5.Vector', function () {
suite('equals', function () {
suite('p5.Vector.prototype.equals() [INSTANCE]', function () {
test('should return false for parameters inequal to the vector', function () {
- const v1 = new mockP5.Vector(0, -1, 1);
- const v2 = new mockP5.Vector(1, 2, 3);
+ const v1 = new Vector(0, -1, 1);
+ const v2 = new Vector(1, 2, 3);
const a2 = [1, 2, 3];
expect(v1.equals(v2)).to.be.false;
expect(v1.equals(a2)).to.be.false;
@@ -1903,88 +2019,89 @@ suite('p5.Vector', function () {
});
test('should return true for equal vectors', function () {
- const v1 = new mockP5.Vector(0, -1, 1);
- const v2 = new mockP5.Vector(0, -1, 1);
+ const v1 = new Vector(0, -1, 1);
+ const v2 = new Vector(0, -1, 1);
expect(v1.equals(v2)).to.be.true;
});
test('should return true for arrays equal to the vector', function () {
- const v1 = new mockP5.Vector(0, -1, 1);
+ const v1 = new Vector(0, -1, 1);
const a1 = [0, -1, 1];
expect(v1.equals(a1)).to.be.true;
});
test('should return true for arguments equal to the vector', function () {
- const v1 = new mockP5.Vector(0, -1, 1);
+ const v1 = new Vector(0, -1, 1);
expect(v1.equals(0, -1, 1)).to.be.true;
});
});
suite('p5.Vector.equals() [CLASS]', function () {
test('should return false for inequal parameters', function () {
- const v1 = new mockP5.Vector(0, -1, 1);
- const v2 = new mockP5.Vector(1, 2, 3);
+ const v1 = new Vector(0, -1, 1);
+ const v2 = new Vector(1, 2, 3);
const a2 = [1, 2, 3];
- expect(mockP5.Vector.equals(v1, v2)).to.be.false;
- expect(mockP5.Vector.equals(v1, a2)).to.be.false;
- expect(mockP5.Vector.equals(a2, v1)).to.be.false;
+ expect(Vector.equals(v1, v2)).to.be.false;
+ expect(Vector.equals(v1, a2)).to.be.false;
+ expect(Vector.equals(a2, v1)).to.be.false;
});
test('should return true for equal vectors', function () {
- const v1 = new mockP5.Vector(0, -1, 1);
- const v2 = new mockP5.Vector(0, -1, 1);
- expect(mockP5.Vector.equals(v1, v2)).to.be.true;
+ const v1 = new Vector(0, -1, 1);
+ const v2 = new Vector(0, -1, 1);
+ expect(Vector.equals(v1, v2)).to.be.true;
});
test('should return true for equal vectors and arrays', function () {
- const v1 = new mockP5.Vector(0, -1, 1);
+ const v1 = new Vector(0, -1, 1);
const a1 = [0, -1, 1];
- expect(mockP5.Vector.equals(v1, a1)).to.be.true;
- expect(mockP5.Vector.equals(a1, v1)).to.be.true;
+ expect(Vector.equals(v1, a1)).to.be.true;
+ expect(Vector.equals(a1, v1)).to.be.true;
});
test('should return true for equal arrays', function () {
const a1 = [0, -1, 1];
const a2 = [0, -1, 1];
- expect(mockP5.Vector.equals(a1, a2)).to.be.true;
+ expect(Vector.equals(a1, a2)).to.be.true;
});
});
});
suite('set values', function () {
beforeEach(function () {
- v = new mockP5.Vector();
+ v = new Vector();
});
- test('should set values to [0,0,0] if values array is empty', function () {
+ test('should NOT set values to [0,0,0] if values array is empty', function () {
v.values = [];
assert.equal(v.x, 0);
assert.equal(v.y, 0);
assert.equal(v.z, 0);
- assert.equal(v.dimensions, 2);
+ assert.equal(v.dimensions, 0);
});
});
suite('get value', function () {
test('should return element in range of a non empty vector', function () {
- let vect = new mockP5.Vector(1, 2, 3, 4);
+ let vect = new Vector(1, 2, 3, 4);
assert.equal(vect.getValue(0), 1);
assert.equal(vect.getValue(1), 2);
assert.equal(vect.getValue(2), 3);
assert.equal(vect.getValue(3), 4);
});
- test.fails(
- 'should throw friendly error if attempting to get element outside lenght',
+ test('should throw friendly error if attempting to get element outside length',
function () {
- let vect = new mockP5.Vector(1, 2, 3, 4);
- assert.equal(vect.getValue(5), 1);
+ let vect = new Vector(1, 2, 3, 4);
+ FESCalled = false;
+ assert.equal(vect.getValue(5), undefined);
+ assert.equal(FESCalled, true);
}
);
});
suite('set value', function () {
test('should set value of element in range', function () {
- let vect = new mockP5.Vector(1, 2, 3, 4);
+ let vect = new Vector(1, 2, 3, 4);
vect.setValue(0, 7);
assert.equal(vect.getValue(0), 7);
assert.equal(vect.getValue(1), 2);
@@ -1992,30 +2109,31 @@ suite('p5.Vector', function () {
assert.equal(vect.getValue(3), 4);
});
- test.fails(
- 'should throw friendly error if attempting to set element outside lenght',
+ test('should throw friendly error if attempting to set element outside lenght',
function () {
- let vect = new mockP5.Vector(1, 2, 3, 4);
+ let vect = new Vector(1, 2, 3, 4);
+ FESCalled = false;
vect.setValue(100, 7);
+ assert.equal(FESCalled, true);
}
);
});
describe('get w', () => {
it('should return the w component of the vector', () => {
- v = new mockP5.Vector(1, 2, 3, 4);
+ v = new Vector(1, 2, 3, 4);
expect(v.w).toBe(4);
});
it('should return 0 if w component is not set', () => {
- v = new mockP5.Vector(1, 2, 3);
+ v = new Vector(1, 2, 3);
expect(v.w).toBe(0);
});
});
describe('set w', () => {
it('should set 4th dimension of vector to w value if it exists', () => {
- v = new mockP5.Vector(1, 2, 3, 4);
+ v = new Vector(1, 2, 3, 4);
v.w = 7;
expect(v.x).toBe(1);
expect(v.y).toBe(2);
@@ -2024,24 +2142,22 @@ suite('p5.Vector', function () {
});
it('should throw error if trying to set w if vector dimensions is less than 4', () => {
- v = new mockP5.Vector(1, 2);
+ v = new Vector(1, 2);
v.w = 5;
- console.log(v);
- console.log(v.w);
expect(v.w).toBe(0); //TODO: Check this, maybe this should fail
});
});
describe('vector to string', () => {
it('should return the string version of a vector', () => {
- v = new mockP5.Vector(1, 2, 3, 4);
+ v = new Vector(1, 2, 3, 4);
expect(v.toString()).toBe('vector[1, 2, 3, 4]');
});
});
describe('set heading', () => {
it('should rotate a 2D vector by specified angle without changing magnitude', () => {
- v = new mockP5.Vector(0, 2);
+ v = new Vector(0, 2);
const mag = v.mag();
expect(v.setHeading(2 * Math.PI).mag()).toBe(mag);
expect(v.x).toBe(2);
@@ -2051,23 +2167,23 @@ suite('p5.Vector', function () {
describe('clamp to zero', () => {
it('should clamp values cloze to zero to zero, with Number.epsilon value', () => {
- v = new mockP5.Vector(0, 1, 0.5, 0.1, 0.0000000000000001);
+ v = new Vector(0, 1, 0.5, 0.1, 0.0000000000000001);
expect(v.clampToZero().values).toEqual([0, 1, 0.5, 0.1, 0]);
});
});
suite('p5.Vector.fromAngles()', function () {
- it('should create a v3ctor froma pair of ISO spherical angles', () => {
- let vect = mockP5.Vector.fromAngles(0, 0);
+ it('should create a vector froma pair of ISO spherical angles', () => {
+ let vect = Vector.fromAngles(0, 0);
expect(vect.values).toEqual([0, -1, 0]);
});
});
suite('p5.Vector.rotate()', function () {
it('should rotate the vector (only 2D vectors) by the given angle; magnitude remains the same.', () => {
- v = new mockP5.Vector(0, 1, 2);
- let target = new mockP5.Vector();
- mockP5.Vector.rotate(v, 1 * Math.PI, target);
+ v = new Vector(0, 1, 2);
+ let target = new Vector();
+ Vector.rotate(v, 1 * Math.PI, target);
expect(target.values).toEqual([
-4.10759023698152e-16, -2.23606797749979, 2
]);
diff --git a/test/unit/visual/cases/webgl.js b/test/unit/visual/cases/webgl.js
index 1893e40252..7519d4e2ea 100644
--- a/test/unit/visual/cases/webgl.js
+++ b/test/unit/visual/cases/webgl.js
@@ -272,6 +272,37 @@ visualSuite('WebGL', function() {
screenshot();
});
+
+ for (const mode of ['webgl', '2d']) {
+ visualTest(`Transparent background colors are correct in ${mode} mode`, function(p5, screenshot) {
+ p5.createCanvas(50, 50, p5.WEBGL);
+ const g = p5.createGraphics(50, 50, mode === 'webgl' ? p5.WEBGL : p5.P2D);
+ if (mode === 'webgl') g.translate(-p5.width/2, -p5.height/2);
+ g.noStroke();
+ g.fill(255, 0, 0, 100);
+ g.rect(10, 10, 30, 30);
+ g.filter(p5.BLUR, 4);
+ p5.imageMode(p5.CENTER);
+ p5.image(g, 0, 0);
+ screenshot();
+ });
+
+ visualTest(`Multiple filter passes work correctly on a p5.Graphics in ${mode} mode`, function(p5, screenshot) {
+ p5.createCanvas(50, 50, p5.WEBGL);
+ const g = p5.createGraphics(50, 50, mode === 'webgl' ? p5.WEBGL : p5.P2D);
+ if (mode === 'webgl') g.translate(-g.width/2, -g.height/2);
+ g.background(255);
+ g.noStroke();
+ g.fill(0);
+ g.rect(10, 10, 6, 6);
+ g.filter(p5.BLUR, 2);
+ g.rect(30, 30, 6, 6);
+ g.filter(p5.BLUR, 2);
+ p5.imageMode(p5.CENTER);
+ p5.image(g, 0, 0);
+ screenshot();
+ });
+ }
});
visualSuite('Lights', function() {
@@ -614,6 +645,15 @@ visualSuite('WebGL', function() {
p5.circle(0, 0, 50);
screenshot();
});
+
+ visualTest('noTint() before image() does not throw', async (p5, screenshot) => {
+ p5.createCanvas(50, 50, p5.WEBGL);
+ const img = await p5.loadImage('/test/unit/assets/cat.jpg');
+ p5.noTint();
+ p5.imageMode(p5.CENTER);
+ p5.image(img, 0, 0, 50, 50);
+ screenshot();
+ });
});
visualSuite('Hooks coordinate spaces', () => {
@@ -1034,6 +1074,33 @@ visualSuite('WebGL', function() {
p5.model(obj, 25);
screenshot();
});
+ visualTest('instanceID in fragment hook colors instances', (p5, screenshot) => {
+ p5.createCanvas(50, 50, p5.WEBGL);
+ const numInstances = 4;
+ const shader = p5.baseMaterialShader().modify(() => {
+ // Vertex hook: position instances in a horizontal row
+ p5.getWorldInputs((inputs) => {
+ const id = p5.instanceID();
+ const spacing = 12;
+ const offset = (id - (numInstances - 1) / 2.0) * spacing;
+ inputs.position.x += offset;
+ return inputs;
+ });
+ // Fragment hook: color each instance based on instanceID
+ p5.getFinalColor((color) => {
+ const id = p5.instanceID();
+ const t = id / (numInstances - 1.0);
+ color = [t, t, t, 1];
+ return color;
+ });
+ }, { p5, numInstances });
+ p5.background(128);
+ p5.noStroke();
+ p5.shader(shader);
+ const obj = p5.buildGeometry(() => p5.circle(0, 0, 10));
+ p5.model(obj, numInstances);
+ screenshot();
+ });
});
visualSuite('p5.strands', () => {
@@ -1049,6 +1116,23 @@ visualSuite('WebGL', function() {
screenshot();
});
+ visualTest('random() colors a basic shader', (p5, screenshot) => {
+ p5.createCanvas(50, 50, p5.WEBGL);
+ const shader = p5.baseColorShader().modify(() => {
+ p5.randomSeed(12);
+ p5.getFinalColor((color) => {
+ const value = p5.random(0.2, 0.9);
+ color = [value, value, value, 1];
+ return color;
+ });
+ }, { p5 });
+ p5.background(0);
+ p5.noStroke();
+ p5.shader(shader);
+ p5.plane(50, 50);
+ screenshot();
+ });
+
visualTest('uses width/height in getFinalColor', (p5, screenshot) => {
let firstShader;
function firstShaderCallback() {
@@ -1067,6 +1151,60 @@ visualSuite('WebGL', function() {
screenshot();
});
+ visualTest('lerp maps to mix in strands context', (p5, screenshot) => {
+ p5.createCanvas(50, 50, p5.WEBGL);
+ // lerp should behave identically to mix inside strands
+ const shader = p5.baseColorShader().modify(() => {
+ p5.getFinalColor((color) => {
+ color = p5.lerp(
+ [1, 0, 0, 1],
+ [0, 0, 1, 1],
+ 0.5
+ );
+ return color;
+ });
+ }, { p5 });
+ p5.background(0);
+ p5.shader(shader);
+ p5.noStroke();
+ p5.plane(50, 50);
+ screenshot();
+ });
+
+ visualTest('mix produces same result as lerp in strands', (p5, screenshot) => {
+ p5.createCanvas(50, 50, p5.WEBGL);
+ // mix directly, should produce identical output to lerp test above
+ const shader = p5.baseColorShader().modify(() => {
+ p5.getFinalColor((color) => {
+ color = p5.mix(
+ [1, 0, 0, 1],
+ [0, 0, 1, 1],
+ 0.5
+ );
+ return color;
+ });
+ }, { p5 });
+ p5.background(0);
+ p5.shader(shader);
+ p5.noStroke();
+ p5.plane(50, 50);
+ screenshot();
+ });
+
+ visualTest('texCoord is available in getFinalColor', (p5, screenshot) => {
+ p5.createCanvas(50, 50, p5.WEBGL);
+ const shader = p5.baseColorShader().modify(() => {
+ p5.finalColor.begin();
+ p5.finalColor.set([p5.finalColor.texCoord, 0, 1]);
+ p5.finalColor.end();
+ }, { p5 });
+ p5.background(0);
+ p5.shader(shader);
+ p5.noStroke();
+ p5.plane(50, 50);
+ screenshot();
+ });
+
visualSuite('auto-return for shader hooks', () => {
visualTest('auto-returns input struct when return is omitted', (p5, screenshot) => {
p5.createCanvas(50, 50, p5.WEBGL);
@@ -1293,6 +1431,39 @@ visualSuite('WebGL', function() {
screenshot();
});
+
+ visualTest('setUniform with p5.Vector offsets position', (p5, screenshot) => {
+ p5.createCanvas(50, 50, p5.WEBGL);
+ const myShader = p5.baseMaterialShader().modify(() => {
+ const uOffset = p5.uniformVec2('uOffset');
+ p5.worldInputs.begin();
+ p5.worldInputs.position.xy += uOffset;
+ p5.worldInputs.end();
+ }, { p5 });
+ p5.background(200);
+ p5.shader(myShader);
+ myShader.setUniform('uOffset', p5.createVector(10, -10));
+ p5.noStroke();
+ p5.fill('red');
+ p5.circle(0, 0, 20);
+ screenshot();
+ });
+
+ visualTest('setUniform with p5.Color sets final color', (p5, screenshot) => {
+ p5.createCanvas(50, 50, p5.WEBGL);
+ const myShader = p5.baseMaterialShader().modify(() => {
+ const uColor = p5.uniformVec4('uColor');
+ p5.finalColor.begin();
+ p5.finalColor.set(uColor);
+ p5.finalColor.end();
+ }, { p5 });
+ p5.background(200);
+ p5.shader(myShader);
+ myShader.setUniform('uColor', p5.color(0, 100, 200));
+ p5.noStroke();
+ p5.circle(0, 0, 30);
+ screenshot();
+ });
});
visualSuite('background()', function () {
@@ -1392,6 +1563,17 @@ visualSuite('WebGL', function() {
});
});
+ visualSuite('2D Shapes', function() {
+ visualTest('rect() rounded into a circle', function(p5, screenshot) {
+ p5.createCanvas(50, 50, p5.WEBGL);
+ p5.background(255);
+ p5.noStroke();
+ p5.fill('red');
+ p5.rect(-20, -20, 40, 40, 20);
+ screenshot();
+ });
+ });
+
visualSuite('3D Primitives', function() {
visualTest('cylinder() renders correctly', function(p5, screenshot) {
p5.createCanvas(100, 100, p5.WEBGL);
diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js
index 9dbc344dea..b7c613603d 100644
--- a/test/unit/visual/cases/webgpu.js
+++ b/test/unit/visual/cases/webgpu.js
@@ -272,8 +272,101 @@ visualSuite("WebGPU", function () {
p5.filter(invert);
await screenshot();
});
+
+ visualTest('filter shaders can sample a texture inside a conditional branch', async function(p5, screenshot) {
+ await p5.createCanvas(50, 50, p5.WEBGPU);
+ p5.background(255);
+ p5.noStroke();
+ p5.fill(0);
+ p5.circle(0, 0, 20);
+ // This shader only samples the texture for pixels in the left half of the
+ // canvas, exercising getTexture() inside a non-uniform conditional
+ const conditionalInvert = p5.buildFilterShader(({ p5 }) => {
+ p5.filterColor.begin();
+ if (p5.filterColor.texCoord.x < 0.5) {
+ const col = p5.getTexture(
+ p5.filterColor.canvasContent,
+ p5.filterColor.texCoord
+ );
+ p5.filterColor.set([1 - col.rgb, col.a]);
+ } else {
+ p5.filterColor.set([0, 0, 1, 1]);
+ }
+ p5.filterColor.end();
+ }, { p5 });
+ p5.filter(conditionalInvert);
+ await screenshot();
+ });
+
+ visualTest('instanceID in fragment hook colors instances (WebGPU)', async function(p5, screenshot) {
+ await p5.createCanvas(50, 50, p5.WEBGPU);
+ const numInstances = 4;
+ const shader = p5.baseMaterialShader().modify(() => {
+ // Vertex hook: position instances in a horizontal row
+ p5.getWorldInputs((inputs) => {
+ const id = p5.instanceID();
+ const spacing = 12;
+ const offset = (id - (numInstances - 1) / 2.0) * spacing;
+ inputs.position.x += offset;
+ return inputs;
+ });
+ // Fragment hook: color each instance based on instanceID
+ p5.getFinalColor((color) => {
+ const id = p5.instanceID();
+ const t = id / (numInstances - 1.0);
+ color = [t, t, t, 1];
+ return color;
+ });
+ }, { p5, numInstances });
+ p5.background(128);
+ p5.noStroke();
+ p5.shader(shader);
+ const obj = p5.buildGeometry(() => p5.circle(0, 0, 10));
+ p5.model(obj, numInstances);
+ await screenshot();
+ });
+
+ visualTest('random() colors a basic shader (WebGPU)', async function(p5, screenshot) {
+ await p5.createCanvas(50, 50, p5.WEBGPU);
+ const shader = p5.baseColorShader().modify(() => {
+ p5.randomSeed(12);
+ p5.getFinalColor((color) => {
+ const value = p5.random(0.2, 0.9);
+ color = [value, value, value, 1];
+ return color;
+ });
+ }, { p5 });
+ p5.background(0);
+ p5.noStroke();
+ p5.shader(shader);
+ p5.plane(50, 50);
+ await screenshot();
+ });
+
+ visualTest('random() in a fragment loop averages to gray (WebGPU)', async function(p5, screenshot) {
+ await p5.createCanvas(50, 50, p5.WEBGPU);
+ const shader = p5.baseMaterialShader().modify(() => {
+ p5.randomSeed(7);
+ p5.getPixelInputs(inputs => {
+ let sum = p5.float(0.0);
+ for (let i = 0; i < 20; i++) {
+ sum = sum + p5.random();
+ }
+ const avg = sum / 20;
+ inputs.color = [avg, avg, avg, 1.0];
+ return inputs;
+ });
+ }, { p5 });
+
+ p5.background(0);
+ p5.noStroke();
+ p5.shader(shader);
+ p5.plane(50, 50);
+ await screenshot();
+ });
});
+
visualSuite('filters', function() {
const setupSketch = async (p5) => {
await p5.createCanvas(50, 50, p5.WEBGPU);
@@ -494,6 +587,28 @@ visualSuite("WebGPU", function () {
},
);
+ visualTest(
+ "Framebuffer with depth disabled",
+ async function (p5, screenshot) {
+ await p5.createCanvas(50, 50, p5.WEBGPU);
+ const fbo = p5.createFramebuffer({ width: 50, height: 50, depth: false });
+
+ fbo.draw(() => {
+ p5.background(0, 0, 200);
+ p5.fill(255, 200, 0);
+ p5.noStroke();
+ p5.circle(0, 0, 30);
+ });
+
+ p5.background(50);
+ p5.texture(fbo);
+ p5.noStroke();
+ p5.plane(50, 50);
+
+ await screenshot();
+ },
+ );
+
visualTest(
"Fixed-size framebuffer after manual resize",
async function (p5, screenshot) {
@@ -535,6 +650,21 @@ visualSuite("WebGPU", function () {
);
});
+ visualSuite("Rendering attributes", function () {
+ visualTest(
+ "noSmooth() does not crash and disables antialiasing",
+ async function (p5, screenshot) {
+ await p5.createCanvas(50, 50, p5.WEBGPU);
+ await p5.noSmooth();
+ p5.background(0);
+ p5.fill(255);
+ p5.noStroke();
+ p5.circle(0, 0, 30);
+ await screenshot();
+ },
+ );
+ });
+
visualSuite("Clipping", function () {
visualTest(
"Basic clipping with circles",
@@ -996,4 +1126,513 @@ visualSuite("WebGPU", function () {
await screenshot();
});
});
+
+ visualSuite('Compute shaders', function() {
+ visualTest(
+ 'Storage buffer (float array) can be read in a vertex shader for instanced rendering',
+ async function(p5, screenshot) {
+ await p5.createCanvas(50, 50, p5.WEBGPU);
+
+ // Positions for 3 spheres: (-15,0), (0,0), (15,0)
+ const positions = p5.createStorage([-15, 0, 0, 0, 15, 0]);
+
+ const sphereShader = p5.baseMaterialShader().modify(() => {
+ const posData = p5.uniformStorage();
+ p5.getWorldInputs((inputs) => {
+ const idx = p5.instanceID();
+ inputs.position.x += posData[idx * 2];
+ inputs.position.y += posData[idx * 2 + 1];
+ return inputs;
+ });
+ }, { p5 });
+ sphereShader.setUniform('posData', positions);
+
+ const geo = p5.buildGeometry(() => p5.sphere(5));
+ p5.background(200);
+ p5.noStroke();
+ p5.fill(255, 0, 0);
+ p5.shader(sphereShader);
+ p5.model(geo, 3);
+
+ await screenshot();
+ }
+ );
+
+ visualTest(
+ 'Compute shader writes float values to storage buffer, vertex shader reads them',
+ async function(p5, screenshot) {
+ await p5.createCanvas(50, 50, p5.WEBGPU);
+
+ // Start with zeros; compute shader will write [20, -10]
+ const offset = p5.createStorage(2);
+
+ const computeShader = p5.buildComputeShader(() => {
+ const buf = p5.uniformStorage();
+ buf[0] = 20;
+ buf[1] = -10;
+ }, { p5 });
+ computeShader.setUniform('buf', offset);
+ p5.compute(computeShader, 1);
+
+ const sphereShader = p5.baseMaterialShader().modify(() => {
+ const buf = p5.uniformStorage();
+ p5.getWorldInputs((inputs) => {
+ inputs.position.x += buf[0];
+ inputs.position.y += buf[1];
+ return inputs;
+ });
+ }, { p5 });
+ sphereShader.setUniform('buf', offset);
+
+ const geo = p5.buildGeometry(() => p5.sphere(5));
+ p5.background(200);
+ p5.noStroke();
+ p5.fill(255, 0, 0);
+ p5.shader(sphereShader);
+ p5.model(geo, 1);
+
+ await screenshot();
+ }
+ );
+
+ visualTest(
+ 'Compute shader reads and transforms float array values',
+ async function(p5, screenshot) {
+ await p5.createCanvas(50, 50, p5.WEBGPU);
+
+ // Initialize with [10, 0] - compute will double x to get [20, 0]
+ const buf = p5.createStorage([10, 0]);
+
+ const computeShader = p5.buildComputeShader(() => {
+ const data = p5.uniformStorage();
+ data[0] = data[0] * 2;
+ }, { p5 });
+ computeShader.setUniform('data', buf);
+ p5.compute(computeShader, 1);
+
+ const sphereShader = p5.baseMaterialShader().modify(() => {
+ const data = p5.uniformStorage();
+ p5.getWorldInputs((inputs) => {
+ inputs.position.x += data[0];
+ inputs.position.y += data[1];
+ return inputs;
+ });
+ }, { p5 });
+ sphereShader.setUniform('data', buf);
+
+ const geo = p5.buildGeometry(() => p5.sphere(5));
+ p5.background(200);
+ p5.noStroke();
+ p5.fill(255, 0, 0);
+ p5.shader(sphereShader);
+ p5.model(geo, 1);
+
+ await screenshot();
+ }
+ );
+
+
+ visualTest(
+ 'Struct storage buffer fields can be read in a vertex shader for instanced rendering',
+ async function(p5, screenshot) {
+ await p5.createCanvas(50, 50, p5.WEBGPU);
+
+ // Three particles at known positions: left, center, right
+ const particles = p5.createStorage([
+ { position: [-15, 0] },
+ { position: [0, 0] },
+ { position: [15, 0] },
+ ]);
+
+ const sphereShader = p5.baseMaterialShader().modify(() => {
+ const buf = p5.uniformStorage('buf', particles);
+ p5.getWorldInputs((inputs) => {
+ const p = buf[p5.instanceID()].position;
+ inputs.position.x += p.x;
+ inputs.position.y += p.y;
+ return inputs;
+ });
+ }, { p5, particles });
+
+ const geo = p5.buildGeometry(() => p5.sphere(5));
+ p5.background(200);
+ p5.noStroke();
+ p5.fill(255, 0, 0);
+ p5.shader(sphereShader);
+ p5.model(geo, 3);
+
+ await screenshot();
+ }
+ );
+
+ visualTest(
+ 'Struct storage buffer fields can use p5.Vector values',
+ async function(p5, screenshot) {
+ await p5.createCanvas(50, 50, p5.WEBGPU);
+
+ // Three particles at known positions: left, center, right
+ const particles = p5.createStorage([
+ { position: p5.createVector(-15, 0) },
+ { position: p5.createVector(0, 0) },
+ { position: p5.createVector(15, 0) },
+ ]);
+
+ const sphereShader = p5.baseMaterialShader().modify(() => {
+ const buf = p5.uniformStorage('buf', particles);
+ p5.getWorldInputs((inputs) => {
+ const p = buf[p5.instanceID()].position;
+ inputs.position.x += p.x;
+ inputs.position.y += p.y;
+ return inputs;
+ });
+ }, { p5, particles });
+
+ const geo = p5.buildGeometry(() => p5.sphere(5));
+ p5.background(200);
+ p5.noStroke();
+ p5.fill(255, 0, 0);
+ p5.shader(sphereShader);
+ p5.model(geo, 3);
+
+ await screenshot();
+ }
+ );
+
+ visualTest(
+ 'Struct storage buffer fields can be read using an inline schema template',
+ async function(p5, screenshot) {
+ await p5.createCanvas(50, 50, p5.WEBGPU);
+
+ // Same layout as above but schema is declared inline rather than via the buffer
+ const particles = p5.createStorage([
+ { position: [-15, 0] },
+ { position: [0, 0] },
+ { position: [15, 0] },
+ ]);
+
+ const sphereShader = p5.baseMaterialShader().modify(() => {
+ const buf = p5.uniformStorage('buf', { position: [0, 0] });
+ p5.getWorldInputs((inputs) => {
+ const p = buf[p5.instanceID()].position;
+ inputs.position.x += p.x;
+ inputs.position.y += p.y;
+ return inputs;
+ });
+ }, { p5 });
+ sphereShader.setUniform('buf', particles);
+
+ const geo = p5.buildGeometry(() => p5.sphere(5));
+ p5.background(200);
+ p5.noStroke();
+ p5.fill(255, 0, 0);
+ p5.shader(sphereShader);
+ p5.model(geo, 3);
+
+ await screenshot();
+ }
+ );
+
+ visualTest(
+ 'Compute shader writes to struct storage fields, vertex shader reads them',
+ async function(p5, screenshot) {
+ await p5.createCanvas(50, 50, p5.WEBGPU);
+
+ const particles = p5.createStorage([
+ { position: [0, 0] },
+ ]);
+
+ const computeShader = p5.buildComputeShader(() => {
+ const buf = p5.uniformStorage('buf', particles);
+ buf[p5.index.x].position = [15, -10];
+ }, { p5, particles });
+ p5.compute(computeShader, 1);
+
+ const sphereShader = p5.baseMaterialShader().modify(() => {
+ const buf = p5.uniformStorage('buf', particles);
+ p5.getWorldInputs((inputs) => {
+ const pos = buf[0].position;
+ inputs.position.x += pos.x;
+ inputs.position.y += pos.y;
+ return inputs;
+ });
+ }, { p5, particles });
+
+ const geo = p5.buildGeometry(() => p5.sphere(5));
+ p5.background(200);
+ p5.noStroke();
+ p5.fill(255, 0, 0);
+ p5.shader(sphereShader);
+ p5.model(geo, 1);
+
+ await screenshot();
+ }
+ );
+
+ visualTest(
+ 'Compute shader reads and updates struct fields (position += velocity)',
+ async function(p5, screenshot) {
+ await p5.createCanvas(50, 50, p5.WEBGPU);
+
+ const particles = p5.createStorage([
+ { position: [0, 0], velocity: [15, -10] },
+ ]);
+
+ const computeShader = p5.buildComputeShader(() => {
+ const buf = p5.uniformStorage('buf', particles);
+ const idx = p5.index.x;
+ buf[idx].position = buf[idx].position + buf[idx].velocity;
+ }, { p5, particles });
+ p5.compute(computeShader, 1);
+
+ const sphereShader = p5.baseMaterialShader().modify(() => {
+ const buf = p5.uniformStorage('buf', particles);
+ p5.getWorldInputs((inputs) => {
+ const pos = buf[0].position;
+ inputs.position.x += pos.x;
+ inputs.position.y += pos.y;
+ return inputs;
+ });
+ }, { p5, particles });
+
+ const geo = p5.buildGeometry(() => p5.sphere(5));
+ p5.background(200);
+ p5.noStroke();
+ p5.fill(255, 0, 0);
+ p5.shader(sphereShader);
+ p5.model(geo, 1);
+
+ await screenshot();
+ }
+ );
+
+ visualTest(
+ 'Compute shader updates struct fields via intermediate struct variable',
+ async function(p5, screenshot) {
+ await p5.createCanvas(50, 50, p5.WEBGPU);
+
+ const particles = p5.createStorage([
+ { position: [0, 0], velocity: [15, -10] },
+ ]);
+
+ // Store the struct element proxy in a variable and assign through it
+ const computeShader = p5.buildComputeShader(() => {
+ const buf = p5.uniformStorage('buf', particles);
+ const idx = p5.index.x;
+ const entry = buf[idx];
+ entry.position = entry.position + entry.velocity;
+ }, { p5, particles });
+ p5.compute(computeShader, 1);
+
+ const sphereShader = p5.baseMaterialShader().modify(() => {
+ const buf = p5.uniformStorage('buf', particles);
+ p5.getWorldInputs((inputs) => {
+ const pos = buf[0].position;
+ inputs.position.x += pos.x;
+ inputs.position.y += pos.y;
+ return inputs;
+ });
+ }, { p5, particles });
+
+ const geo = p5.buildGeometry(() => p5.sphere(5));
+ p5.background(200);
+ p5.noStroke();
+ p5.fill(255, 0, 0);
+ p5.shader(sphereShader);
+ p5.model(geo, 1);
+
+ await screenshot();
+ }
+ );
+
+ visualTest(
+ 'Compute shader updates struct fields via intermediate field variable',
+ async function(p5, screenshot) {
+ await p5.createCanvas(50, 50, p5.WEBGPU);
+
+ const particles = p5.createStorage([
+ { position: [0, 0], velocity: [15, -10] },
+ ]);
+
+ // Store a field value in an intermediate variable, update it, write it back
+ const computeShader = p5.buildComputeShader(() => {
+ const buf = p5.uniformStorage('buf', particles);
+ const idx = p5.index.x;
+ let pos = buf[idx].position;
+ pos = pos + buf[idx].velocity;
+ buf[idx].position = pos;
+ }, { p5, particles });
+ p5.compute(computeShader, 1);
+
+ const sphereShader = p5.baseMaterialShader().modify(() => {
+ const buf = p5.uniformStorage('buf', particles);
+ p5.getWorldInputs((inputs) => {
+ const pos = buf[0].position;
+ inputs.position.x += pos.x;
+ inputs.position.y += pos.y;
+ return inputs;
+ });
+ }, { p5, particles });
+
+ const geo = p5.buildGeometry(() => p5.sphere(5));
+ p5.background(200);
+ p5.noStroke();
+ p5.fill(255, 0, 0);
+ p5.shader(sphereShader);
+ p5.model(geo, 1);
+
+ await screenshot();
+ }
+ );
+
+ visualTest(
+ 'Compute shader writes a whole struct element as an object literal',
+ async function(p5, screenshot) {
+ await p5.createCanvas(50, 50, p5.WEBGPU);
+
+ const particles = p5.createStorage([
+ { position: [0, 0], velocity: [15, -10] },
+ ]);
+
+ const computeShader = p5.buildComputeShader(() => {
+ const buf = p5.uniformStorage('buf', particles);
+ const idx = p5.index.x;
+ let pos = buf[idx].position;
+ let vel = buf[idx].velocity;
+ pos = pos + vel;
+ buf[idx] = { position: pos, velocity: vel };
+ }, { p5, particles });
+ p5.compute(computeShader, 1);
+
+ const sphereShader = p5.baseMaterialShader().modify(() => {
+ const buf = p5.uniformStorage('buf', particles);
+ p5.getWorldInputs((inputs) => {
+ const pos = buf[0].position;
+ inputs.position.x += pos.x;
+ inputs.position.y += pos.y;
+ return inputs;
+ });
+ }, { p5, particles });
+
+ const geo = p5.buildGeometry(() => p5.sphere(5));
+ p5.background(200);
+ p5.noStroke();
+ p5.fill(255, 0, 0);
+ p5.shader(sphereShader);
+ p5.model(geo, 1);
+
+ await screenshot();
+ }
+ );
+
+ visualTest(
+ 'Compute shader assigns to a swizzle of a struct vector field',
+ async function(p5, screenshot) {
+ await p5.createCanvas(50, 50, p5.WEBGPU);
+
+ const particles = p5.createStorage([
+ { position: [15, 10] },
+ ]);
+
+ // Negate position.y via swizzle assignment
+ const computeShader = p5.buildComputeShader(() => {
+ const buf = p5.uniformStorage('buf', particles);
+ const idx = p5.index.x;
+ buf[idx].position.y *= -1;
+ }, { p5, particles });
+ p5.compute(computeShader, 1);
+
+ const sphereShader = p5.baseMaterialShader().modify(() => {
+ const buf = p5.uniformStorage('buf', particles);
+ p5.getWorldInputs((inputs) => {
+ const pos = buf[0].position;
+ inputs.position.x += pos.x;
+ inputs.position.y += pos.y;
+ return inputs;
+ });
+ }, { p5, particles });
+
+ const geo = p5.buildGeometry(() => p5.sphere(5));
+ p5.background(200);
+ p5.noStroke();
+ p5.fill(255, 0, 0);
+ p5.shader(sphereShader);
+ p5.model(geo, 1);
+
+ await screenshot();
+ }
+ );
+
+ visualTest(
+ 'Compute shader assigns to a swizzle of a struct vector field inside an if statement',
+ async function(p5, screenshot) {
+ await p5.createCanvas(50, 50, p5.WEBGPU);
+
+ const particles = p5.createStorage([
+ { position: [0, 0], velocity: [5, 5] },
+ ]);
+
+ // Move by velocity, then negate velocity.y if position.y > 0.
+ // After 1st run: position=[5,5], velocity=[5,-5].
+ // After 2nd run: position=[10,0], velocity=[5,-5].
+ const computeShader = p5.buildComputeShader(() => {
+ const buf = p5.uniformStorage('buf', particles);
+ const idx = p5.index.x;
+ buf[idx].position += buf[idx].velocity;
+ if (buf[idx].position.y > 0) {
+ buf[idx].velocity.y *= -1;
+ }
+ }, { p5, particles });
+ p5.compute(computeShader, 1);
+ p5.compute(computeShader, 1);
+
+ const sphereShader = p5.baseMaterialShader().modify(() => {
+ const buf = p5.uniformStorage('buf', particles);
+ p5.getWorldInputs((inputs) => {
+ const pos = buf[0].position;
+ inputs.position.x += pos.x;
+ inputs.position.y += pos.y;
+ return inputs;
+ });
+ }, { p5, particles });
+
+ const geo = p5.buildGeometry(() => p5.sphere(5));
+ p5.background(200);
+ p5.noStroke();
+ p5.fill(255, 0, 0);
+ p5.shader(sphereShader);
+ p5.model(geo, 1);
+
+ await screenshot();
+ }
+ );
+ });
+
+ visualSuite('Feedback', function() {
+ visualTest(
+ 'Drawing accumulates across frames when background is set in setup',
+ async function(p5, screenshot) {
+ await p5.createCanvas(50, 50, p5.WEBGPU);
+
+ // Set an initial background before the draw loop starts.
+ // This should persist into the first draw frame.
+ p5.background('red');
+
+ return new Promise(resolve => {
+ let frame = 0;
+ p5.draw = function() {
+ // Draw circles without clearing, so they accumulate
+ p5.noStroke();
+ p5.fill('blue');
+ p5.circle(-15 + frame * 15, 0, 10);
+ frame++;
+ if (frame >= 3) {
+ p5.noLoop();
+ screenshot().then(resolve);
+ }
+ };
+ p5.loop();
+ });
+ }
+ );
+ });
});
diff --git a/test/unit/visual/screenshots/WebGL/2D Shapes/rect() rounded into a circle/000.png b/test/unit/visual/screenshots/WebGL/2D Shapes/rect() rounded into a circle/000.png
new file mode 100644
index 0000000000..e5ec27fc33
Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/2D Shapes/rect() rounded into a circle/000.png differ
diff --git a/test/unit/visual/screenshots/WebGL/2D Shapes/rect() rounded into a circle/metadata.json b/test/unit/visual/screenshots/WebGL/2D Shapes/rect() rounded into a circle/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGL/2D Shapes/rect() rounded into a circle/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGL/Opacity/noTint() before image() does not throw/000.png b/test/unit/visual/screenshots/WebGL/Opacity/noTint() before image() does not throw/000.png
new file mode 100644
index 0000000000..c92e8ba0a8
Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/Opacity/noTint() before image() does not throw/000.png differ
diff --git a/test/unit/visual/screenshots/WebGL/Opacity/noTint() before image() does not throw/metadata.json b/test/unit/visual/screenshots/WebGL/Opacity/noTint() before image() does not throw/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGL/Opacity/noTint() before image() does not throw/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGL/filter/Multiple filter passes work correctly on a p5.Graphics in 2d mode/000.png b/test/unit/visual/screenshots/WebGL/filter/Multiple filter passes work correctly on a p5.Graphics in 2d mode/000.png
new file mode 100644
index 0000000000..88030f07cc
Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/filter/Multiple filter passes work correctly on a p5.Graphics in 2d mode/000.png differ
diff --git a/test/unit/visual/screenshots/WebGL/filter/Multiple filter passes work correctly on a p5.Graphics in 2d mode/metadata.json b/test/unit/visual/screenshots/WebGL/filter/Multiple filter passes work correctly on a p5.Graphics in 2d mode/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGL/filter/Multiple filter passes work correctly on a p5.Graphics in 2d mode/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGL/filter/Multiple filter passes work correctly on a p5.Graphics in webgl mode/000.png b/test/unit/visual/screenshots/WebGL/filter/Multiple filter passes work correctly on a p5.Graphics in webgl mode/000.png
new file mode 100644
index 0000000000..88030f07cc
Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/filter/Multiple filter passes work correctly on a p5.Graphics in webgl mode/000.png differ
diff --git a/test/unit/visual/screenshots/WebGL/filter/Multiple filter passes work correctly on a p5.Graphics in webgl mode/metadata.json b/test/unit/visual/screenshots/WebGL/filter/Multiple filter passes work correctly on a p5.Graphics in webgl mode/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGL/filter/Multiple filter passes work correctly on a p5.Graphics in webgl mode/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGL/filter/Transparent background colors are correct in 2d mode/000.png b/test/unit/visual/screenshots/WebGL/filter/Transparent background colors are correct in 2d mode/000.png
new file mode 100644
index 0000000000..eb51606a3d
Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/filter/Transparent background colors are correct in 2d mode/000.png differ
diff --git a/test/unit/visual/screenshots/WebGL/filter/Transparent background colors are correct in 2d mode/metadata.json b/test/unit/visual/screenshots/WebGL/filter/Transparent background colors are correct in 2d mode/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGL/filter/Transparent background colors are correct in 2d mode/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGL/filter/Transparent background colors are correct in webgl mode/000.png b/test/unit/visual/screenshots/WebGL/filter/Transparent background colors are correct in webgl mode/000.png
new file mode 100644
index 0000000000..eb51606a3d
Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/filter/Transparent background colors are correct in webgl mode/000.png differ
diff --git a/test/unit/visual/screenshots/WebGL/filter/Transparent background colors are correct in webgl mode/metadata.json b/test/unit/visual/screenshots/WebGL/filter/Transparent background colors are correct in webgl mode/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGL/filter/Transparent background colors are correct in webgl mode/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGL/instanced randering/instanceID in fragment hook colors instances/000.png b/test/unit/visual/screenshots/WebGL/instanced randering/instanceID in fragment hook colors instances/000.png
new file mode 100644
index 0000000000..86eb4ba339
Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/instanced randering/instanceID in fragment hook colors instances/000.png differ
diff --git a/test/unit/visual/screenshots/WebGL/instanced randering/instanceID in fragment hook colors instances/metadata.json b/test/unit/visual/screenshots/WebGL/instanced randering/instanceID in fragment hook colors instances/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGL/instanced randering/instanceID in fragment hook colors instances/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/lerp maps to mix in strands context/000.png b/test/unit/visual/screenshots/WebGL/p5.strands/lerp maps to mix in strands context/000.png
new file mode 100644
index 0000000000..223241d6cd
Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/p5.strands/lerp maps to mix in strands context/000.png differ
diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/lerp maps to mix in strands context/metadata.json b/test/unit/visual/screenshots/WebGL/p5.strands/lerp maps to mix in strands context/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGL/p5.strands/lerp maps to mix in strands context/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/mix produces same result as lerp in strands/000.png b/test/unit/visual/screenshots/WebGL/p5.strands/mix produces same result as lerp in strands/000.png
new file mode 100644
index 0000000000..223241d6cd
Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/p5.strands/mix produces same result as lerp in strands/000.png differ
diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/mix produces same result as lerp in strands/metadata.json b/test/unit/visual/screenshots/WebGL/p5.strands/mix produces same result as lerp in strands/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGL/p5.strands/mix produces same result as lerp in strands/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/random() colors a basic shader/000.png b/test/unit/visual/screenshots/WebGL/p5.strands/random() colors a basic shader/000.png
new file mode 100644
index 0000000000..92b7e4956a
Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/p5.strands/random() colors a basic shader/000.png differ
diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/random() colors a basic shader/metadata.json b/test/unit/visual/screenshots/WebGL/p5.strands/random() colors a basic shader/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGL/p5.strands/random() colors a basic shader/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/setUniform with p5.Color sets final color/000.png b/test/unit/visual/screenshots/WebGL/p5.strands/setUniform with p5.Color sets final color/000.png
new file mode 100644
index 0000000000..ada712c7c5
Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/p5.strands/setUniform with p5.Color sets final color/000.png differ
diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/setUniform with p5.Color sets final color/metadata.json b/test/unit/visual/screenshots/WebGL/p5.strands/setUniform with p5.Color sets final color/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGL/p5.strands/setUniform with p5.Color sets final color/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/setUniform with p5.Vector offsets position/000.png b/test/unit/visual/screenshots/WebGL/p5.strands/setUniform with p5.Vector offsets position/000.png
new file mode 100644
index 0000000000..eee8dd8f28
Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/p5.strands/setUniform with p5.Vector offsets position/000.png differ
diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/setUniform with p5.Vector offsets position/metadata.json b/test/unit/visual/screenshots/WebGL/p5.strands/setUniform with p5.Vector offsets position/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGL/p5.strands/setUniform with p5.Vector offsets position/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/texCoord is available in getFinalColor/000.png b/test/unit/visual/screenshots/WebGL/p5.strands/texCoord is available in getFinalColor/000.png
new file mode 100644
index 0000000000..460f144e68
Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/p5.strands/texCoord is available in getFinalColor/000.png differ
diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/texCoord is available in getFinalColor/metadata.json b/test/unit/visual/screenshots/WebGL/p5.strands/texCoord is available in getFinalColor/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGL/p5.strands/texCoord is available in getFinalColor/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGL/texture()/on a rect with rounded corners/000.png b/test/unit/visual/screenshots/WebGL/texture()/on a rect with rounded corners/000.png
index 5e400290b2..a248b08ebb 100644
Binary files a/test/unit/visual/screenshots/WebGL/texture()/on a rect with rounded corners/000.png and b/test/unit/visual/screenshots/WebGL/texture()/on a rect with rounded corners/000.png differ
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader assigns to a swizzle of a struct vector field inside an if statement/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader assigns to a swizzle of a struct vector field inside an if statement/000.png
new file mode 100644
index 0000000000..2bf313d4bf
Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader assigns to a swizzle of a struct vector field inside an if statement/000.png differ
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader assigns to a swizzle of a struct vector field inside an if statement/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader assigns to a swizzle of a struct vector field inside an if statement/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader assigns to a swizzle of a struct vector field inside an if statement/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader assigns to a swizzle of a struct vector field/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader assigns to a swizzle of a struct vector field/000.png
new file mode 100644
index 0000000000..0b6d74a1c5
Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader assigns to a swizzle of a struct vector field/000.png differ
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader assigns to a swizzle of a struct vector field/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader assigns to a swizzle of a struct vector field/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader assigns to a swizzle of a struct vector field/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader reads and transforms float array values/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader reads and transforms float array values/000.png
new file mode 100644
index 0000000000..cf4799e76b
Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader reads and transforms float array values/000.png differ
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader reads and transforms float array values/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader reads and transforms float array values/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader reads and transforms float array values/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader reads and updates struct fields (position += velocity)/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader reads and updates struct fields (position += velocity)/000.png
new file mode 100644
index 0000000000..a561306a22
Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader reads and updates struct fields (position += velocity)/000.png differ
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader reads and updates struct fields (position += velocity)/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader reads and updates struct fields (position += velocity)/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader reads and updates struct fields (position += velocity)/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader updates struct fields via intermediate field variable/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader updates struct fields via intermediate field variable/000.png
new file mode 100644
index 0000000000..a561306a22
Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader updates struct fields via intermediate field variable/000.png differ
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader updates struct fields via intermediate field variable/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader updates struct fields via intermediate field variable/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader updates struct fields via intermediate field variable/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader updates struct fields via intermediate struct variable/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader updates struct fields via intermediate struct variable/000.png
new file mode 100644
index 0000000000..a561306a22
Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader updates struct fields via intermediate struct variable/000.png differ
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader updates struct fields via intermediate struct variable/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader updates struct fields via intermediate struct variable/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader updates struct fields via intermediate struct variable/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes a whole struct element as an object literal/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes a whole struct element as an object literal/000.png
new file mode 100644
index 0000000000..a561306a22
Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes a whole struct element as an object literal/000.png differ
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes a whole struct element as an object literal/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes a whole struct element as an object literal/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes a whole struct element as an object literal/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes float values to storage buffer, vertex shader reads them/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes float values to storage buffer, vertex shader reads them/000.png
new file mode 100644
index 0000000000..7e7af5583a
Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes float values to storage buffer, vertex shader reads them/000.png differ
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes float values to storage buffer, vertex shader reads them/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes float values to storage buffer, vertex shader reads them/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes float values to storage buffer, vertex shader reads them/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes to struct storage fields, vertex shader reads them/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes to struct storage fields, vertex shader reads them/000.png
new file mode 100644
index 0000000000..a561306a22
Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes to struct storage fields, vertex shader reads them/000.png differ
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes to struct storage fields, vertex shader reads them/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes to struct storage fields, vertex shader reads them/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes to struct storage fields, vertex shader reads them/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Storage buffer (float array) can be read in a vertex shader for instanced rendering/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Storage buffer (float array) can be read in a vertex shader for instanced rendering/000.png
new file mode 100644
index 0000000000..70a5a6e04f
Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Storage buffer (float array) can be read in a vertex shader for instanced rendering/000.png differ
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Storage buffer (float array) can be read in a vertex shader for instanced rendering/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Storage buffer (float array) can be read in a vertex shader for instanced rendering/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Storage buffer (float array) can be read in a vertex shader for instanced rendering/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can be read in a vertex shader for instanced rendering/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can be read in a vertex shader for instanced rendering/000.png
new file mode 100644
index 0000000000..70a5a6e04f
Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can be read in a vertex shader for instanced rendering/000.png differ
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can be read in a vertex shader for instanced rendering/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can be read in a vertex shader for instanced rendering/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can be read in a vertex shader for instanced rendering/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can be read using an inline schema template/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can be read using an inline schema template/000.png
new file mode 100644
index 0000000000..70a5a6e04f
Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can be read using an inline schema template/000.png differ
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can be read using an inline schema template/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can be read using an inline schema template/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can be read using an inline schema template/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can use p5.Vector values/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can use p5.Vector values/000.png
new file mode 100644
index 0000000000..70a5a6e04f
Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can use p5.Vector values/000.png differ
diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can use p5.Vector values/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can use p5.Vector values/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can use p5.Vector values/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGPU/Feedback/Drawing accumulates across frames when background is set in setup/000.png b/test/unit/visual/screenshots/WebGPU/Feedback/Drawing accumulates across frames when background is set in setup/000.png
new file mode 100644
index 0000000000..8f406fd13d
Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Feedback/Drawing accumulates across frames when background is set in setup/000.png differ
diff --git a/test/unit/visual/screenshots/WebGPU/Feedback/Drawing accumulates across frames when background is set in setup/metadata.json b/test/unit/visual/screenshots/WebGPU/Feedback/Drawing accumulates across frames when background is set in setup/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGPU/Feedback/Drawing accumulates across frames when background is set in setup/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with depth disabled/000.png b/test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with depth disabled/000.png
new file mode 100644
index 0000000000..20fb23ed5a
Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with depth disabled/000.png differ
diff --git a/test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with depth disabled/metadata.json b/test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with depth disabled/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with depth disabled/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGPU/Rendering attributes/noSmooth() does not crash and disables antialiasing/000.png b/test/unit/visual/screenshots/WebGPU/Rendering attributes/noSmooth() does not crash and disables antialiasing/000.png
new file mode 100644
index 0000000000..5c5063d118
Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Rendering attributes/noSmooth() does not crash and disables antialiasing/000.png differ
diff --git a/test/unit/visual/screenshots/WebGPU/Rendering attributes/noSmooth() does not crash and disables antialiasing/metadata.json b/test/unit/visual/screenshots/WebGPU/Rendering attributes/noSmooth() does not crash and disables antialiasing/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGPU/Rendering attributes/noSmooth() does not crash and disables antialiasing/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/filter shaders can sample a texture inside a conditional branch/000.png b/test/unit/visual/screenshots/WebGPU/Shaders/filter shaders can sample a texture inside a conditional branch/000.png
new file mode 100644
index 0000000000..2a307cdf64
Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Shaders/filter shaders can sample a texture inside a conditional branch/000.png differ
diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/filter shaders can sample a texture inside a conditional branch/metadata.json b/test/unit/visual/screenshots/WebGPU/Shaders/filter shaders can sample a texture inside a conditional branch/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGPU/Shaders/filter shaders can sample a texture inside a conditional branch/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/instanceID in fragment hook colors instances (WebGPU)/000.png b/test/unit/visual/screenshots/WebGPU/Shaders/instanceID in fragment hook colors instances (WebGPU)/000.png
new file mode 100644
index 0000000000..1e99dcacc9
Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Shaders/instanceID in fragment hook colors instances (WebGPU)/000.png differ
diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/instanceID in fragment hook colors instances (WebGPU)/metadata.json b/test/unit/visual/screenshots/WebGPU/Shaders/instanceID in fragment hook colors instances (WebGPU)/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGPU/Shaders/instanceID in fragment hook colors instances (WebGPU)/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/random() colors a basic shader (WebGPU)/000.png b/test/unit/visual/screenshots/WebGPU/Shaders/random() colors a basic shader (WebGPU)/000.png
new file mode 100644
index 0000000000..be4a035dbf
Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Shaders/random() colors a basic shader (WebGPU)/000.png differ
diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/random() colors a basic shader (WebGPU)/metadata.json b/test/unit/visual/screenshots/WebGPU/Shaders/random() colors a basic shader (WebGPU)/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGPU/Shaders/random() colors a basic shader (WebGPU)/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/random() in a fragment loop averages to gray (WebGPU)/000.png b/test/unit/visual/screenshots/WebGPU/Shaders/random() in a fragment loop averages to gray (WebGPU)/000.png
new file mode 100644
index 0000000000..5e19d4c988
Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Shaders/random() in a fragment loop averages to gray (WebGPU)/000.png differ
diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/random() in a fragment loop averages to gray (WebGPU)/metadata.json b/test/unit/visual/screenshots/WebGPU/Shaders/random() in a fragment loop averages to gray (WebGPU)/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGPU/Shaders/random() in a fragment loop averages to gray (WebGPU)/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/visualTest.js b/test/unit/visual/visualTest.js
index f921fefdbf..b761665d9c 100644
--- a/test/unit/visual/visualTest.js
+++ b/test/unit/visual/visualTest.js
@@ -64,7 +64,6 @@ export function visualSuite(
suiteFn(name, () => {
let lastShiftThreshold;
let lastPrefix;
- let lastDeviceRatio = window.devicePixelRatio;
beforeAll(() => {
lastPrefix = namePrefix;
namePrefix += escapeName(name) + '/';
@@ -72,16 +71,12 @@ export function visualSuite(
if (newShiftThreshold !== undefined) {
shiftThreshold = newShiftThreshold;
}
-
- // Force everything to be 1x
- window.devicePixelRatio = 1;
});
callback();
afterAll(() => {
namePrefix = lastPrefix;
- window.devicePixelRatio = lastDeviceRatio;
shiftThreshold = lastShiftThreshold;
});
});
@@ -398,9 +393,12 @@ export function visualTest(
suiteFn(testName, function() {
let name;
let myp5;
+ let lastDeviceRatio = window.devicePixelRatio;
beforeAll(function() {
name = namePrefix + escapeName(testName);
+ // Force everything to be 1x
+ window.devicePixelRatio = 1;
return new Promise(res => {
myp5 = new p5(function(p) {
p.setup = function() {
@@ -411,6 +409,7 @@ export function visualTest(
});
afterAll(function() {
+ window.devicePixelRatio = lastDeviceRatio;
myp5.remove();
});
diff --git a/test/unit/webgl/p5.RendererGL.js b/test/unit/webgl/p5.RendererGL.js
index b42562051f..079b8000f6 100644
--- a/test/unit/webgl/p5.RendererGL.js
+++ b/test/unit/webgl/p5.RendererGL.js
@@ -89,6 +89,33 @@ suite('p5.RendererGL', function() {
});
});
+ suite('p5.strands', function() {
+ test('a uniform whose name matches a hook parameter name does not break', function() {
+ myp5.createCanvas(10, 10, myp5.WEBGL);
+ myp5.pixelDensity(1);
+
+ // 'color' is the GLSL parameter name of the getFinalColor hook's first argument.
+ // Creating a uniform with the same name used to cause a GLSL name clash.
+ const myShader = myp5.baseColorShader().modify(() => {
+ const color = myp5.uniformFloat('color', 0.5);
+ myp5.finalColor.begin();
+ myp5.finalColor.set([color, color, color, 1]);
+ myp5.finalColor.end();
+ }, { myp5 });
+
+ myp5.background(0);
+ myp5.noStroke();
+ myp5.shader(myShader);
+ myp5.plane(myp5.width, myp5.height);
+
+ const pixel = myp5.get(5, 5);
+ assert.approximately(pixel[0], 128, 1);
+ assert.equal(pixel[0], pixel[1]);
+ assert.equal(pixel[1], pixel[2]);
+ assert.equal(pixel[3], 255);
+ });
+ });
+
suite('texture binding', function() {
test('setting a custom texture works', function() {
myp5.createCanvas(10, 10, myp5.WEBGL);
@@ -1490,33 +1517,56 @@ suite('p5.RendererGL', function() {
});
suite('tint() in WEBGL mode', function() {
- test('default tint value is set and not null', function() {
+ test('default tint value', function() {
myp5.createCanvas(100, 100, myp5.WEBGL);
- assert.deepEqual(myp5._renderer.states.tint, [255, 255, 255, 255]);
+ assert.deepEqual(
+ myp5._renderer.states.tint?._getRGBA([255, 255, 255, 255]) ?? [255, 255, 255, 255],
+ [255, 255, 255, 255]
+ );
});
+
+
test('tint value is modified correctly when tint() is called', function() {
+
+ function assertColorEq(tint, colArray) {
+ assert.deepEqual(tint._getRGBA([255, 255, 255, 255]), colArray);
+ }
+
myp5.createCanvas(100, 100, myp5.WEBGL);
+
myp5.tint(0, 153, 204, 126);
- assert.deepEqual(myp5._renderer.states.tint, [0, 153, 204, 126]);
+ assertColorEq(myp5._renderer.states.tint, [0, 153, 204, 126]);
+
myp5.tint(100, 120, 140);
- assert.deepEqual(myp5._renderer.states.tint, [100, 120, 140, 255]);
+ assertColorEq(myp5._renderer.states.tint, [100, 120, 140, 255]);
+
myp5.tint('violet');
- assert.deepEqual(myp5._renderer.states.tint, [238, 130, 238, 255]);
+ // Note that in WEBGL mode, we don't convert color strings to arrays until the shader,
+ // so the tint state is still the string 'violet' at this point, not the array [238, 130, 238, 255].
+ //assertDeepEqualColor(myp5._renderer.states.tint, [238, 130, 238, 255]);
+ assert.equal(myp5._renderer.states.tint, 'violet');
+
myp5.tint(100);
- assert.deepEqual(myp5._renderer.states.tint, [100, 100, 100, 255]);
+ assertColorEq(myp5._renderer.states.tint, [100, 100, 100, 255]);
+
myp5.tint(100, 126);
- assert.deepEqual(myp5._renderer.states.tint, [100, 100, 100, 126]);
+ assertColorEq(myp5._renderer.states.tint, [100, 100, 100, 126]);
+
myp5.tint([100, 126, 0, 200]);
- assert.deepEqual(myp5._renderer.states.tint, [100, 126, 0, 200]);
+ assertColorEq(myp5._renderer.states.tint, [100, 126, 0, 200]);
+
myp5.tint([100, 126, 0]);
- assert.deepEqual(myp5._renderer.states.tint, [100, 126, 0, 255]);
+ assertColorEq(myp5._renderer.states.tint, [100, 126, 0, 255]);
+
myp5.tint([100]);
- assert.deepEqual(myp5._renderer.states.tint, [100, 100, 100, 255]);
+ assertColorEq(myp5._renderer.states.tint, [100, 100, 100, 255]);
+
myp5.tint([100, 126]);
- assert.deepEqual(myp5._renderer.states.tint, [100, 100, 100, 126]);
+ assertColorEq(myp5._renderer.states.tint, [100, 100, 100, 126]);
+
myp5.tint(myp5.color(255, 204, 0));
- assert.deepEqual(myp5._renderer.states.tint, [255, 204, 0, 255]);
+ assertColorEq(myp5._renderer.states.tint, [255, 204, 0, 255]);
});
test('tint should be reset after draw loop', function() {
@@ -1533,7 +1583,8 @@ suite('p5.RendererGL', function() {
};
});
}).then(function(_tint) {
- assert.deepEqual(_tint, [255, 255, 255, 255]);
+ assert.deepEqual(_tint?._getRGBA([255, 255, 255, 255]) ?? [255, 255, 255, 255],
+ [255, 255, 255, 255]);
});
});
});
@@ -2010,6 +2061,98 @@ suite('p5.RendererGL', function() {
[-10, 0, 10]
);
});
+
+ suite('large tessellation guard', function() {
+ test('prompts user before tessellating >50k vertices', function() {
+ const renderer = myp5.createCanvas(10, 10, myp5.WEBGL);
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
+ const tessSpy = vi.spyOn(
+ renderer.shapeBuilder,
+ '_tesselateShape'
+ ).mockImplementation(() => {});
+
+ myp5.beginShape();
+ for (let i = 0; i < 60000; i++) {
+ myp5.vertex(i % 100, Math.floor(i / 100), 0);
+ }
+ myp5.endShape();
+
+ expect(confirmSpy).toHaveBeenCalled();
+ expect(confirmSpy.mock.calls[0][0]).toContain('60000');
+ expect(tessSpy).not.toHaveBeenCalled();
+
+ confirmSpy.mockRestore();
+ tessSpy.mockRestore();
+ });
+
+ test('only prompts once when user approves large tessellation', function() {
+ const renderer = myp5.createCanvas(10, 10, myp5.WEBGL);
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
+ const tessSpy = vi.spyOn(
+ renderer.shapeBuilder,
+ '_tesselateShape'
+ ).mockImplementation(() => {});
+
+ myp5.beginShape();
+ for (let i = 0; i < 60000; i++) {
+ myp5.vertex(i % 100, Math.floor(i / 100), 0);
+ }
+ myp5.endShape();
+
+ expect(confirmSpy).toHaveBeenCalledTimes(1);
+ expect(renderer._largeTessellationAcknowledged).toBe(true);
+
+ myp5.beginShape();
+ for (let i = 0; i < 60000; i++) {
+ myp5.vertex(i % 100, Math.floor(i / 100), 0);
+ }
+ myp5.endShape();
+
+ expect(confirmSpy).toHaveBeenCalledTimes(1);
+
+ confirmSpy.mockRestore();
+ tessSpy.mockRestore();
+ });
+
+ test('skips prompt when p5.disableFriendlyErrors is true', function() {
+ const renderer = myp5.createCanvas(10, 10, myp5.WEBGL);
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
+ const tessSpy = vi.spyOn(
+ renderer.shapeBuilder,
+ '_tesselateShape'
+ ).mockImplementation(() => {});
+ p5.disableFriendlyErrors = true;
+
+ myp5.beginShape();
+ for (let i = 0; i < 60000; i++) {
+ myp5.vertex(i % 100, Math.floor(i / 100), 0);
+ }
+ myp5.endShape();
+
+ expect(confirmSpy).not.toHaveBeenCalled();
+ expect(tessSpy).toHaveBeenCalled();
+
+ p5.disableFriendlyErrors = false;
+ confirmSpy.mockRestore();
+ tessSpy.mockRestore();
+ });
+
+ test('works normally for <50k vertices', function() {
+ const renderer = myp5.createCanvas(10, 10, myp5.WEBGL);
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
+
+ myp5.beginShape();
+ myp5.vertex(-10, -10, 0);
+ myp5.vertex(10, -10, 0);
+ myp5.vertex(10, 10, 0);
+ myp5.vertex(-10, 10, 0);
+ myp5.endShape(myp5.CLOSE);
+
+ expect(confirmSpy).not.toHaveBeenCalled();
+
+ confirmSpy.mockRestore();
+ });
+ });
});
suite('color interpolation', function() {
@@ -2975,5 +3118,56 @@ suite('p5.RendererGL', function() {
myp5.model(geom);
expect(myp5.get(5, 5)).toEqual([255, 0, 0, 255]);
});
+ test('does not throw with a large number of vertices', function() {
+ myp5.createCanvas(10, 10, myp5.WEBGL);
+ // Enough triangles to exceed the ~65k argument limit of Function.prototype.apply,
+ // which would cause a stack overflow if vertices were spread into push() calls.
+ const numTriangles = 30000;
+ expect(() => {
+ myp5.buildGeometry(() => {
+ myp5.beginShape(myp5.TRIANGLES);
+ for (let i = 0; i < numTriangles; i++) {
+ myp5.vertex(0, 0, 0);
+ myp5.vertex(1, 0, 0);
+ myp5.vertex(0, 1, 0);
+ }
+ myp5.endShape();
+ });
+ }).not.toThrow();
+ });
+ });
+
+ suite('fontWidth', function() {
+ test('respects textSize changes across push/pop', async function() {
+ myp5.createCanvas(100, 100, myp5.WEBGL);
+ const font = await myp5.loadFont('test/unit/assets/acmesa.ttf');
+
+ myp5.push();
+ myp5.textFont(font);
+ myp5.textSize(12);
+ myp5.push();
+ myp5.textSize(20);
+ const widthAt20 = myp5.fontWidth('X');
+ myp5.pop();
+ const widthAt12 = myp5.fontWidth('X');
+ myp5.pop();
+
+ expect(widthAt20).toBeGreaterThan(widthAt12);
+ });
+
+ test('fontWidth restores correctly when font is unset inside push/pop', async function() {
+ myp5.createCanvas(100, 100, myp5.WEBGL);
+ const font = await myp5.loadFont('test/unit/assets/acmesa.ttf');
+ myp5.textFont(font);
+ myp5.textSize(12);
+ myp5.push();
+ myp5.textFont('sans-serif'); // unset loaded font
+ myp5.pop();
+ // After pop, should be back to size 12 with loaded font
+ const widthAfterPop = myp5.fontWidth('X');
+ myp5.textSize(20);
+ const widthAt20 = myp5.fontWidth('X');
+ expect(widthAfterPop).toBeLessThan(widthAt20);
+ });
});
});
diff --git a/test/unit/webgl/p5.Shader.js b/test/unit/webgl/p5.Shader.js
index b0f04a78bc..786540cafa 100644
--- a/test/unit/webgl/p5.Shader.js
+++ b/test/unit/webgl/p5.Shader.js
@@ -318,7 +318,7 @@ suite('p5.Shader', function() {
uniforms: {
'sampler2D myTex': null
},
- 'vec4 getFinalColor': `(vec4 c) {
+ 'vec4 getFinalColor': `(vec4 c, vec2 texCoord) {
return getTexture(myTex, vec2(0.,0.));
}`
});
@@ -459,6 +459,21 @@ suite('p5.Shader', function() {
}).not.toThrowError();
});
+ test('buildFilterShader can use numeric constants from scope', () => {
+ myp5.createCanvas(5, 5, myp5.WEBGL);
+ const constants = { val: 100 };
+ const myShader = myp5.buildFilterShader(({ constants }) => {
+ filterColor.begin();
+ let c = 0;
+ c += constants.val / 255;
+ filterColor.set([c, c, c, 1]);
+ filterColor.end();
+ }, { constants });
+ expect(() => {
+ myp5.filter(myShader);
+ }).not.toThrowError();
+ });
+
test('buildMaterialShader forwards scope to modify', () => {
myp5.createCanvas(5, 5, myp5.WEBGL);
expect(() => {
@@ -495,6 +510,128 @@ test('returns numbers for builtin globals outside hooks and a strandNode when ca
});
+ test('map() works inside a strands modify callback', () => {
+ myp5.createCanvas(50, 50, myp5.WEBGL);
+ const testShader = myp5.baseMaterialShader().modify(() => {
+ myp5.getPixelInputs(inputs => {
+ const v = myp5.map(0.25, 0, 1, 0, 1);
+ inputs.color = [v, v, v, 1.0];
+ return inputs;
+ });
+ }, { myp5 });
+
+ myp5.noStroke();
+ myp5.shader(testShader);
+ myp5.plane(myp5.width, myp5.height);
+
+ const pixelColor = myp5.get(25, 25);
+ assert.approximately(pixelColor[0], 64, 5);
+ assert.approximately(pixelColor[1], 64, 5);
+ assert.approximately(pixelColor[2], 64, 5);
+ });
+
+ test('map() with withinBounds clamps the result inside strands', () => {
+ myp5.createCanvas(50, 50, myp5.WEBGL);
+ const testShader = myp5.baseMaterialShader().modify(() => {
+ myp5.getPixelInputs(inputs => {
+ const v = myp5.map(2.0, 0, 1, 0, 1, true);
+ inputs.color = [v, v, v, 1.0];
+ return inputs;
+ });
+ }, { myp5 });
+
+ myp5.noStroke();
+ myp5.shader(testShader);
+ myp5.plane(myp5.width, myp5.height);
+
+ const pixelColor = myp5.get(25, 25);
+ assert.approximately(pixelColor[0], 255, 5);
+ assert.approximately(pixelColor[1], 255, 5);
+ assert.approximately(pixelColor[2], 255, 5);
+ });
+
+ test('map() shrinks a wider input range into a narrower output range', () => {
+ myp5.createCanvas(50, 50, myp5.WEBGL);
+ const testShader = myp5.baseMaterialShader().modify(() => {
+ myp5.getPixelInputs(inputs => {
+ const v = myp5.map(5, 0, 10, 0, 1);
+ inputs.color = [v, v, v, 1.0];
+ return inputs;
+ });
+ }, { myp5 });
+
+ myp5.noStroke();
+ myp5.shader(testShader);
+ myp5.plane(myp5.width, myp5.height);
+
+ const pixelColor = myp5.get(25, 25);
+ assert.approximately(pixelColor[0], 128, 5);
+ assert.approximately(pixelColor[1], 128, 5);
+ assert.approximately(pixelColor[2], 128, 5);
+ });
+
+ test('map() handles offset output ranges correctly', () => {
+ myp5.createCanvas(50, 50, myp5.WEBGL);
+ const testShader = myp5.baseMaterialShader().modify(() => {
+ myp5.getPixelInputs(inputs => {
+ const v = myp5.map(0.5, 0, 1, 0.2, 0.8);
+ inputs.color = [v, v, v, 1.0];
+ return inputs;
+ });
+ }, { myp5 });
+
+ myp5.noStroke();
+ myp5.shader(testShader);
+ myp5.plane(myp5.width, myp5.height);
+
+ const pixelColor = myp5.get(25, 25);
+ assert.approximately(pixelColor[0], 128, 5);
+ assert.approximately(pixelColor[1], 128, 5);
+ assert.approximately(pixelColor[2], 128, 5);
+ });
+
+ test('map() handles a negative input range', () => {
+ myp5.createCanvas(50, 50, myp5.WEBGL);
+ const testShader = myp5.baseMaterialShader().modify(() => {
+ myp5.getPixelInputs(inputs => {
+ const v = myp5.map(0, -1, 1, 0, 1);
+ inputs.color = [v, v, v, 1.0];
+ return inputs;
+ });
+ }, { myp5 });
+
+ myp5.noStroke();
+ myp5.shader(testShader);
+ myp5.plane(myp5.width, myp5.height);
+
+ const pixelColor = myp5.get(25, 25);
+ assert.approximately(pixelColor[0], 128, 5);
+ assert.approximately(pixelColor[1], 128, 5);
+ assert.approximately(pixelColor[2], 128, 5);
+ });
+
+ test('map() remaps texCoord.x into a horizontal gradient', () => {
+ myp5.createCanvas(50, 50, myp5.WEBGL);
+ const testShader = myp5.baseMaterialShader().modify(() => {
+ myp5.getPixelInputs(inputs => {
+ const v = myp5.map(inputs.texCoord.x, 0, 1, 0.2, 0.8);
+ inputs.color = [v, v, v, 1.0];
+ return inputs;
+ });
+ }, { myp5 });
+
+ myp5.noStroke();
+ myp5.shader(testShader);
+ myp5.plane(myp5.width, myp5.height);
+
+ const left = myp5.get(2, 25);
+ const middle = myp5.get(25, 25);
+ const right = myp5.get(47, 25);
+ assert.approximately(left[0], 51, 10);
+ assert.approximately(middle[0], 128, 10);
+ assert.approximately(right[0], 204, 10);
+ });
+
test('handle custom uniform names with automatic values', () => {
myp5.createCanvas(50, 50, myp5.WEBGL);
const testShader = myp5.baseMaterialShader().modify(() => {
@@ -542,6 +679,107 @@ test('returns numbers for builtin globals outside hooks and a strandNode when ca
assert.approximately(pixelColor[2], 153, 5);
});
+ suite('array indexing on non-storage vectors (#8756)', () => {
+ afterEach(() => {
+ mockUserError.mockClear();
+ });
+
+ test('indexing into array returned from helper function works in WebGL', () => {
+ myp5.createCanvas(5, 5, myp5.WEBGL);
+ const myShader = myp5.baseMaterialShader().modify(() => {
+ const brightness = myp5.uniformFloat();
+ function getArr() {
+ return [1, 2];
+ }
+ const arr = getArr();
+ myp5.getPixelInputs(inputs => {
+ inputs.color = [arr[0] * brightness, arr[1], 0, 1];
+ return inputs;
+ });
+ }, { myp5 });
+ expect(() => {
+ myp5.shader(myShader);
+ myp5.plane(myp5.width, myp5.height);
+ }).not.toThrowError();
+ });
+
+ test('inline literal indexing [1, 2][0] works in WebGL', () => {
+ myp5.createCanvas(5, 5, myp5.WEBGL);
+ const myShader = myp5.baseMaterialShader().modify(() => {
+ const brightness = myp5.uniformFloat();
+ myp5.getPixelInputs(inputs => {
+ inputs.color = [[1, 2][0] * brightness, 0, 0, 1];
+ return inputs;
+ });
+ }, { myp5 });
+ expect(() => {
+ myp5.shader(myShader);
+ myp5.plane(myp5.width, myp5.height);
+ }).not.toThrowError();
+ });
+
+ test('array literal with 1 element throws descriptive error in WebGL', () => {
+ myp5.createCanvas(5, 5, myp5.WEBGL);
+ expect(() => {
+ myp5.baseMaterialShader().modify(() => {
+ const arr = [1];
+ myp5.getPixelInputs(inputs => {
+ inputs.color = [arr[0], 0, 0, 1];
+ return inputs;
+ });
+ }, { myp5 });
+ }).toThrowError('and must have 2-4 elements (got 1)');
+ });
+
+ test('array literal with 5 elements throws descriptive error in WebGL', () => {
+ myp5.createCanvas(5, 5, myp5.WEBGL);
+ expect(() => {
+ myp5.baseMaterialShader().modify(() => {
+ const arr = [1, 2, 3, 4, 5];
+ myp5.getPixelInputs(inputs => {
+ inputs.color = [arr[0], 0, 0, 1];
+ return inputs;
+ });
+ }, { myp5 });
+ }).toThrowError('and must have 2-4 elements (got 5)');
+ });
+
+ test('valid array lengths 2, 3, 4 work in WebGL', () => {
+ myp5.createCanvas(5, 5, myp5.WEBGL);
+ expect(() => {
+ const s2 = myp5.baseMaterialShader().modify(() => {
+ const arr = [1, 2];
+ myp5.getPixelInputs(inputs => {
+ inputs.color = [arr[0], 0, 0, 1];
+ return inputs;
+ });
+ }, { myp5 });
+ myp5.shader(s2);
+ myp5.plane(myp5.width, myp5.height);
+
+ const s3 = myp5.baseMaterialShader().modify(() => {
+ const arr = [1, 2, 3];
+ myp5.getPixelInputs(inputs => {
+ inputs.color = [arr[0], 0, 0, 1];
+ return inputs;
+ });
+ }, { myp5 });
+ myp5.shader(s3);
+ myp5.plane(myp5.width, myp5.height);
+
+ const s4 = myp5.baseMaterialShader().modify(() => {
+ const arr = [1, 2, 3, 4];
+ myp5.getPixelInputs(inputs => {
+ inputs.color = [arr[0], 0, 0, 1];
+ return inputs;
+ });
+ }, { myp5 });
+ myp5.shader(s4);
+ myp5.plane(myp5.width, myp5.height);
+ }).not.toThrowError();
+ });
+ });
+
suite('if statement conditionals', () => {
test('handle simple if statement with true condition', () => {
myp5.createCanvas(50, 50, myp5.WEBGL);
@@ -1204,6 +1442,55 @@ test('returns numbers for builtin globals outside hooks and a strandNode when ca
});
});
+ suite('ternary expressions', () => {
+ test('ternary changes color based on left/right side of canvas', () => {
+ myp5.createCanvas(50, 25, myp5.WEBGL);
+ const testShader = myp5.baseMaterialShader().modify(() => {
+ myp5.getPixelInputs(inputs => {
+ inputs.color = inputs.texCoord.x > 0.5 ? [1, 0, 0, 1] : [0, 0, 1, 1];
+ return inputs;
+ });
+ }, { myp5 });
+ myp5.noStroke();
+ myp5.shader(testShader);
+ myp5.plane(myp5.width, myp5.height);
+
+ const leftPixel = myp5.get(12, 12);
+ assert.approximately(leftPixel[0], 0, 5);
+ assert.approximately(leftPixel[1], 0, 5);
+ assert.approximately(leftPixel[2], 255, 5);
+
+ const rightPixel = myp5.get(37, 12);
+ assert.approximately(rightPixel[0], 255, 5);
+ assert.approximately(rightPixel[1], 0, 5);
+ assert.approximately(rightPixel[2], 0, 5);
+ });
+
+ test('ternary with scalar values', () => {
+ myp5.createCanvas(50, 25, myp5.WEBGL);
+ const testShader = myp5.baseMaterialShader().modify(() => {
+ myp5.getPixelInputs(inputs => {
+ const brightness = inputs.texCoord.x > 0.5 ? 1.0 : 0.0;
+ inputs.color = [brightness, brightness, brightness, 1];
+ return inputs;
+ });
+ }, { myp5 });
+ myp5.noStroke();
+ myp5.shader(testShader);
+ myp5.plane(myp5.width, myp5.height);
+
+ const leftPixel = myp5.get(12, 12);
+ assert.approximately(leftPixel[0], 0, 5);
+ assert.approximately(leftPixel[1], 0, 5);
+ assert.approximately(leftPixel[2], 0, 5);
+
+ const rightPixel = myp5.get(37, 12);
+ assert.approximately(rightPixel[0], 255, 5);
+ assert.approximately(rightPixel[1], 255, 5);
+ assert.approximately(rightPixel[2], 255, 5);
+ });
+ });
+
suite('for loop statements', () => {
test('handle simple for loop with known iteration count', () => {
myp5.createCanvas(50, 50, myp5.WEBGL);
@@ -2135,6 +2422,168 @@ test('returns numbers for builtin globals outside hooks and a strandNode when ca
assert.approximately(pixelColor[2], 0, 5);
});
+ test('handle uniformFloat with control flow in callback', () => {
+ myp5.createCanvas(50, 50, myp5.WEBGL);
+
+ const testShader = myp5.baseFilterShader().modify(() => {
+ // Uniform callback with an if-statement and multiple return paths
+ const pastOneSecond = myp5.uniformFloat(() => {
+ if (myp5.frameCount > 1000) {
+ return 1;
+ }
+ return 0;
+ });
+
+ myp5.filterColor.begin();
+ myp5.filterColor.set(myp5.mix([1, 0, 0, 1], [0, 1, 0, 1], pastOneSecond));
+ myp5.filterColor.end();
+ }, { myp5 });
+
+ myp5.background(255, 255, 255);
+ myp5.filter(testShader);
+
+ // frameCount <= 1000 so pastOneSecond = 0, mix returns [1, 0, 0, 1] = red
+ const pixelColor = myp5.get(25, 25);
+ assert.approximately(pixelColor[0], 255, 5);
+ assert.approximately(pixelColor[1], 0, 5);
+ assert.approximately(pixelColor[2], 0, 5);
+ });
+
+ test('handle uniformFloat with for loop in callback', () => {
+ myp5.createCanvas(50, 50, myp5.WEBGL);
+
+ const testShader = myp5.baseFilterShader().modify(() => {
+ // Uniform callback with a for loop accumulating a value
+ const brightness = myp5.uniformFloat(() => {
+ let sum = 0;
+ for (let i = 0; i < 3; i++) {
+ sum += i;
+ }
+ return sum / 10; // 0+1+2=3, 3/10=0.3
+ });
+
+ myp5.filterColor.begin();
+ myp5.filterColor.set([brightness, 0, 0, 1]);
+ myp5.filterColor.end();
+ }, { myp5 });
+
+ myp5.background(255, 255, 255);
+ myp5.filter(testShader);
+
+ // brightness = 0.3, so red channel = 0.3 * 255 β 76
+ const pixelColor = myp5.get(25, 25);
+ assert.approximately(pixelColor[0], 0.3 * 255, 5);
+ assert.approximately(pixelColor[1], 0, 5);
+ assert.approximately(pixelColor[2], 0, 5);
+ });
+
+ test('handle uniformFloat with sub-function call in callback', () => {
+ myp5.createCanvas(50, 50, myp5.WEBGL);
+
+ const testShader = myp5.baseFilterShader().modify(() => {
+ // Uniform callback that calls a sub-function
+ const brightness = myp5.uniformFloat(() => {
+ const getValue = () => 0.6;
+ return getValue();
+ });
+
+ myp5.filterColor.begin();
+ myp5.filterColor.set([brightness, 0, 0, 1]);
+ myp5.filterColor.end();
+ }, { myp5 });
+
+ myp5.background(255, 255, 255);
+ myp5.filter(testShader);
+
+ // brightness = 0.6, so red channel = 0.6 * 255 β 153
+ const pixelColor = myp5.get(25, 25);
+ assert.approximately(pixelColor[0], 0.6 * 255, 5);
+ assert.approximately(pixelColor[1], 0, 5);
+ assert.approximately(pixelColor[2], 0, 5);
+ });
+
+ test('handle uniformFloat with control flow in non-inline callback', () => {
+ myp5.createCanvas(50, 50, myp5.WEBGL);
+
+ const testShader = myp5.baseFilterShader().modify(() => {
+ function pastOneSecondValue() {
+ if (myp5.frameCount > 1000) {
+ return 1;
+ }
+ return 0;
+ }
+ const pastOneSecond = myp5.uniformFloat(pastOneSecondValue);
+
+ myp5.filterColor.begin();
+ myp5.filterColor.set(myp5.mix([1, 0, 0, 1], [0, 1, 0, 1], pastOneSecond));
+ myp5.filterColor.end();
+ }, { myp5 });
+
+ myp5.background(255, 255, 255);
+ myp5.filter(testShader);
+
+ // frameCount <= 1000 so pastOneSecond = 0, mix returns [1, 0, 0, 1] = red
+ const pixelColor = myp5.get(25, 25);
+ assert.approximately(pixelColor[0], 255, 5);
+ assert.approximately(pixelColor[1], 0, 5);
+ assert.approximately(pixelColor[2], 0, 5);
+ });
+
+ test('handle uniformFloat with for loop in non-inline callback', () => {
+ myp5.createCanvas(50, 50, myp5.WEBGL);
+
+ const testShader = myp5.baseFilterShader().modify(() => {
+ function brightnessValue() {
+ let sum = 0;
+ for (let i = 0; i < 3; i++) {
+ sum += i;
+ }
+ return sum / 10; // 0+1+2=3, 3/10=0.3
+ }
+ const brightness = myp5.uniformFloat(brightnessValue);
+
+ myp5.filterColor.begin();
+ myp5.filterColor.set([brightness, 0, 0, 1]);
+ myp5.filterColor.end();
+ }, { myp5 });
+
+ myp5.background(255, 255, 255);
+ myp5.filter(testShader);
+
+ // brightness = 0.3, so red channel = 0.3 * 255 β 76
+ const pixelColor = myp5.get(25, 25);
+ assert.approximately(pixelColor[0], 0.3 * 255, 5);
+ assert.approximately(pixelColor[1], 0, 5);
+ assert.approximately(pixelColor[2], 0, 5);
+ });
+
+ test('handle uniformFloat with sub-function call in non-inline callback', () => {
+ myp5.createCanvas(50, 50, myp5.WEBGL);
+
+ const testShader = myp5.baseFilterShader().modify(() => {
+ function getValue() {
+ return 0.6;
+ }
+ function brightnessValue() {
+ return getValue();
+ }
+ const brightness = myp5.uniformFloat(brightnessValue);
+
+ myp5.filterColor.begin();
+ myp5.filterColor.set([brightness, 0, 0, 1]);
+ myp5.filterColor.end();
+ }, { myp5 });
+
+ myp5.background(255, 255, 255);
+ myp5.filter(testShader);
+
+ // brightness = 0.6, so red channel = 0.6 * 255 β 153
+ const pixelColor = myp5.get(25, 25);
+ assert.approximately(pixelColor[0], 0.6 * 255, 5);
+ assert.approximately(pixelColor[1], 0, 5);
+ assert.approximately(pixelColor[2], 0, 5);
+ });
+
test('handle false .set() in if with content afterwards with flat API', () => {
myp5.createCanvas(50, 50, myp5.WEBGL);
@@ -2162,6 +2611,20 @@ test('returns numbers for builtin globals outside hooks and a strandNode when ca
});
suite('p5.strands error messages', () => {
+ const expectLoopProtectionError = run => {
+ let err;
+
+ try {
+ run();
+ } catch (e) {
+ err = e;
+ }
+
+ assert.instanceOf(err, Error);
+ assert.include(err.message, 'Loop protection');
+ assert.include(err.message, '// noprotect');
+ };
+
afterEach(() => {
mockUserError.mockClear();
});
@@ -2193,7 +2656,9 @@ test('returns numbers for builtin globals outside hooks and a strandNode when ca
}, { myp5 });
} catch (e) { /* expected */ }
- assert.isAbove(mockUserError.mock.calls.length, 0, 'FES.userError should have been called');
+
+
+ assert.isAbove(mockUserError.mock.calls.length, 0, 'FES.userError should have been called, btw: '+globalThis.FESCalled);
const errMsg = mockUserError.mock.calls[0][1];
assert.include(errMsg, 'float3');
assert.include(errMsg, 'float4');
@@ -2215,5 +2680,61 @@ test('returns numbers for builtin globals outside hooks and a strandNode when ca
assert.include(errMsg, 'Expected properties');
assert.include(errMsg, 'Received properties');
});
+
+ test('ternary with mismatched branch types shows both types in error', () => {
+ myp5.createCanvas(50, 50, myp5.WEBGL);
+
+ try {
+ myp5.baseMaterialShader().modify(() => {
+ myp5.getPixelInputs(inputs => {
+ // float1 vs float4 - type mismatch
+ const val = inputs.texCoord.x > 0.5 ? myp5.float(1.0) : [1, 0, 0, 1];
+ inputs.color = [val, val, val, 1];
+ return inputs;
+ });
+ }, { myp5 });
+ } catch (e) { /* expected */ }
+
+ assert.isAbove(mockUserError.mock.calls.length, 0, 'FES.userError should have been called');
+ const errMsg = mockUserError.mock.calls[0][1];
+ assert.include(errMsg, 'ternary');
+ assert.include(errMsg, 'float1');
+ assert.include(errMsg, 'float4');
+ });
+
+ test('shows a helpful error for web editor loop protection', () => {
+ myp5.createCanvas(50, 50, myp5.WEBGL);
+
+ expectLoopProtectionError(() => {
+ myp5.baseFilterShader().modify(() => {
+ myp5.filterColor.begin();
+ {
+ loopProtect.protect({ line: 11, reset: true });
+ for (let i = 0; i < 10; i++) {
+ if (loopProtect.protect({ line: 11 })) {
+ break;
+ }
+ }
+ }
+ myp5.filterColor.set([1, 0, 0, 1]);
+ myp5.filterColor.end();
+ }, { myp5 });
+ });
+ });
+
+ test('shows a helpful error for OpenProcessing loop protection', () => {
+ myp5.createCanvas(50, 50, myp5.WEBGL);
+
+ expectLoopProtectionError(() => {
+ myp5.baseFilterShader().modify(() => {
+ myp5.filterColor.begin();
+ for (let i = 0; i < 10; i++) {
+ window.$OP && $OP.loopProtect({ line: 11, ch: 2 });
+ }
+ myp5.filterColor.set([1, 0, 0, 1]);
+ myp5.filterColor.end();
+ }, { myp5 });
+ });
+ });
});
});
diff --git a/test/unit/webgpu/p5.RendererWebGPU.js b/test/unit/webgpu/p5.RendererWebGPU.js
index 1ed62563c5..3a21f174f6 100644
--- a/test/unit/webgpu/p5.RendererWebGPU.js
+++ b/test/unit/webgpu/p5.RendererWebGPU.js
@@ -127,6 +127,13 @@ suite('WebGPU p5.RendererWebGPU', function() {
});
});
+ suite('noSmooth()', function() {
+ test('disables antialiasing on the main canvas framebuffer', async function() {
+ await myp5.noSmooth();
+ expect(myp5._renderer.mainFramebuffer.antialias).to.equal(false);
+ });
+ });
+
suite('Stability', function() {
test('pixelDensity() after setAttributes() should not crash', async function() {
// This test simulates the issue where a synchronous call (pixelDensity)
@@ -160,4 +167,184 @@ suite('WebGPU p5.RendererWebGPU', function() {
expect(myp5._renderer).to.exist;
});
});
+
+ suite('StorageBuffer.read()', function() {
+ test('reads back float array data', async function() {
+ const input = new Float32Array([1, 2, 3, 4]);
+ const buf = myp5.createStorage(input);
+
+ const result = await buf.read();
+
+ expect(result).to.be.instanceOf(Float32Array);
+ expect(result.length).to.equal(input.length);
+ for (let i = 0; i < input.length; i++) {
+ expect(result[i]).to.be.closeTo(input[i], 0.001);
+ }
+ });
+
+ test('reads back struct array data', async function() {
+ const input = [
+ { x: 1.0, y: 2.0 },
+ { x: 3.0, y: 4.0 },
+ ];
+ const buf = myp5.createStorage(input);
+
+ const result = await buf.read();
+
+ expect(result).to.be.an('array');
+ expect(result.length).to.equal(input.length);
+ for (let i = 0; i < input.length; i++) {
+ expect(result[i].x).to.be.closeTo(input[i].x, 0.001);
+ expect(result[i].y).to.be.closeTo(input[i].y, 0.001);
+ }
+ });
+
+ test('read after update returns new data', async function() {
+ const buf = myp5.createStorage(new Float32Array([10, 20, 30]));
+ const updated = new Float32Array([100, 200, 300]);
+ buf.update(updated);
+
+ const result = await buf.read();
+
+ for (let i = 0; i < updated.length; i++) {
+ expect(result[i]).to.be.closeTo(updated[i], 0.001);
+ }
+ });
+
+ test('reads back struct with vector fields as p5.Vector', async function() {
+ const input = [
+ { position: myp5.createVector(1, 2), speed: 5.0 },
+ { position: myp5.createVector(3, 4), speed: 10.0 },
+ ];
+ const buf = myp5.createStorage(input);
+
+ const result = await buf.read();
+
+ expect(result).to.be.an('array');
+ expect(result.length).to.equal(2);
+ // Vector fields come back as p5.Vector
+ expect(result[0].position.isVector).to.be.true;
+ expect(result[0].position.x).to.be.closeTo(1, 0.001);
+ expect(result[0].position.y).to.be.closeTo(2, 0.001);
+ expect(result[0].speed).to.be.closeTo(5.0, 0.001);
+ expect(result[1].position.isVector).to.be.true;
+ expect(result[1].position.x).to.be.closeTo(3, 0.001);
+ expect(result[1].position.y).to.be.closeTo(4, 0.001);
+ expect(result[1].speed).to.be.closeTo(10.0, 0.001);
+ });
+
+ test('reads back data modified by a compute shader', async function() {
+ const input = new Float32Array([1, 2, 3, 4]);
+ const buf = myp5.createStorage(input);
+
+ const computeShader = myp5.buildComputeShader(() => {
+ const d = myp5.uniformStorage();
+ const idx = myp5.index.x;
+ d[idx] = d[idx] * 2;
+ }, { myp5 });
+
+ computeShader.setUniform('d', buf);
+ myp5.compute(computeShader, 4);
+
+ const result = await buf.read();
+
+ expect(result).to.be.instanceOf(Float32Array);
+ for (let i = 0; i < input.length; i++) {
+ expect(result[i]).to.be.closeTo(input[i] * 2, 0.001);
+ }
+ });
+ });
+
+ suite('StorageBuffer.set()', function() {
+ test('updates a single float value at the given index', async function() {
+ const buf = myp5.createStorage(new Float32Array([1, 2, 3, 4]));
+ buf.set(2, 9.5);
+
+ const result = await buf.read();
+
+ expect(result[0]).to.be.closeTo(1, 0.001);
+ expect(result[1]).to.be.closeTo(2, 0.001);
+ expect(result[2]).to.be.closeTo(9.5, 0.001); // only this changed
+ expect(result[3]).to.be.closeTo(4, 0.001);
+ });
+
+ test('updates a single struct element without touching neighbours', async function() {
+ const input = [
+ { x: 1.0, y: 2.0 },
+ { x: 3.0, y: 4.0 },
+ { x: 5.0, y: 6.0 },
+ ];
+ const buf = myp5.createStorage(input);
+
+ // Replace only the middle element
+ buf.set(1, { x: 99.0, y: 88.0 });
+
+ const result = await buf.read();
+
+ expect(result.length).to.be.at.least(3);
+ // Element 0 unchanged
+ expect(result[0].x).to.be.closeTo(1.0, 0.001);
+ expect(result[0].y).to.be.closeTo(2.0, 0.001);
+ // Element 1 updated
+ expect(result[1].x).to.be.closeTo(99.0, 0.001);
+ expect(result[1].y).to.be.closeTo(88.0, 0.001);
+ // Element 2 unchanged
+ expect(result[2].x).to.be.closeTo(5.0, 0.001);
+ expect(result[2].y).to.be.closeTo(6.0, 0.001);
+ });
+
+ test('set() then read() reflects the new value immediately', async function() {
+ const buf = myp5.createStorage(new Float32Array([0, 0, 0]));
+ buf.set(0, 42);
+
+ const result = await buf.read();
+ expect(result[0]).to.be.closeTo(42, 0.001);
+ });
+
+ test('throws on out-of-bounds index for float buffer', function() {
+ const buf = myp5.createStorage(new Float32Array([1, 2, 3]));
+ expect(() => buf.set(10, 5.0)).to.throw();
+ });
+
+ test('throws on out-of-bounds index for struct buffer', function() {
+ const buf = myp5.createStorage([{ x: 1.0 }, { x: 2.0 }]);
+ expect(() => buf.set(99, { x: 3.0 })).to.throw();
+ });
+
+ test('throws when passing a non-number to a float buffer', function() {
+ const buf = myp5.createStorage(new Float32Array([1, 2, 3]));
+ expect(() => buf.set(0, { x: 1 })).to.throw();
+ });
+
+ test('throws when passing a non-object to a struct buffer', function() {
+ const buf = myp5.createStorage([{ x: 1.0 }, { x: 2.0 }]);
+ expect(() => buf.set(0, 42)).to.throw();
+ });
+ });
+
+ suite('p5.strands', function() {
+ test('a uniform whose name matches a hook parameter name does not break', async function() {
+ myp5.pixelDensity(1);
+
+ // 'color' is the WGSL parameter name of the getFinalColor hook's first argument.
+ // Creating a uniform with the same name used to cause a WGSL name clash.
+ const myShader = myp5.baseColorShader().modify(() => {
+ const color = myp5.uniformFloat('color', 0.5);
+ myp5.finalColor.begin();
+ myp5.finalColor.set([color, color, color, 1]);
+ myp5.finalColor.end();
+ }, { myp5 });
+
+ myp5.background(0);
+ myp5.noStroke();
+ myp5.shader(myShader);
+ myp5.plane(myp5.width, myp5.height);
+
+ const pixel = await myp5.get(5, 5);
+ expect(pixel[0]).to.be.closeTo(128, 1);
+ expect(pixel[0]).to.equal(pixel[1]);
+ expect(pixel[1]).to.equal(pixel[2]);
+ expect(pixel[3]).to.equal(255);
+ });
+ });
});
diff --git a/test/unit/webgpu/p5.Shader.js b/test/unit/webgpu/p5.Shader.js
index 7453ee9d2b..eb9bb79990 100644
--- a/test/unit/webgpu/p5.Shader.js
+++ b/test/unit/webgpu/p5.Shader.js
@@ -490,7 +490,6 @@ suite('WebGPU p5.Shader', function() {
return [0.4, 0, 0, 1];
});
}, { myp5 });
- console.log(testShader.fragSrc())
myp5.background(255, 255, 255);
myp5.filter(testShader);
@@ -502,6 +501,55 @@ suite('WebGPU p5.Shader', function() {
});
});
+ suite('ternary expressions', () => {
+ test('ternary changes color based on left/right side of canvas', async () => {
+ await myp5.createCanvas(50, 25, myp5.WEBGPU);
+ const testShader = myp5.baseMaterialShader().modify(() => {
+ myp5.getPixelInputs(inputs => {
+ inputs.color = inputs.texCoord.x > 0.5 ? [1, 0, 0, 1] : [0, 0, 1, 1];
+ return inputs;
+ });
+ }, { myp5 });
+ myp5.noStroke();
+ myp5.shader(testShader);
+ myp5.plane(myp5.width, myp5.height);
+
+ const leftPixel = await myp5.get(12, 12);
+ assert.approximately(leftPixel[0], 0, 5);
+ assert.approximately(leftPixel[1], 0, 5);
+ assert.approximately(leftPixel[2], 255, 5);
+
+ const rightPixel = await myp5.get(37, 12);
+ assert.approximately(rightPixel[0], 255, 5);
+ assert.approximately(rightPixel[1], 0, 5);
+ assert.approximately(rightPixel[2], 0, 5);
+ });
+
+ test('ternary with scalar values', async () => {
+ await myp5.createCanvas(50, 25, myp5.WEBGPU);
+ const testShader = myp5.baseMaterialShader().modify(() => {
+ myp5.getPixelInputs(inputs => {
+ const brightness = inputs.texCoord.x > 0.5 ? 1.0 : 0.0;
+ inputs.color = [brightness, brightness, brightness, 1];
+ return inputs;
+ });
+ }, { myp5 });
+ myp5.noStroke();
+ myp5.shader(testShader);
+ myp5.plane(myp5.width, myp5.height);
+
+ const leftPixel = await myp5.get(12, 12);
+ assert.approximately(leftPixel[0], 0, 5);
+ assert.approximately(leftPixel[1], 0, 5);
+ assert.approximately(leftPixel[2], 0, 5);
+
+ const rightPixel = await myp5.get(37, 12);
+ assert.approximately(rightPixel[0], 255, 5);
+ assert.approximately(rightPixel[1], 255, 5);
+ assert.approximately(rightPixel[2], 255, 5);
+ });
+ });
+
suite('for loop statements', () => {
test('handle simple for loop with known iteration count', async () => {
await myp5.createCanvas(50, 50, myp5.WEBGPU);
@@ -1180,5 +1228,139 @@ suite('WebGPU p5.Shader', function() {
});
}
});
+
+ suite('compute shaders', () => {
+ test('handle early return in void compute hook', async () => {
+ await myp5.createCanvas(5, 5, myp5.WEBGPU);
+
+ // This test verifies that buildComputeShader and p5.compute
+ // correctly handle void hooks with early returns without crashing
+ // the strands compiler or hitting type errors.
+ expect(() => {
+ const computeShader = myp5.buildComputeShader(() => {
+ const id = myp5.index.x;
+ if (id > 10) {
+ return; // Early return in void hook
+ }
+ }, { myp5 });
+
+ myp5.compute(computeShader, 1);
+ }).not.toThrow();
+ });
+
+ test('early return in void compute hook stops execution', async () => {
+ await myp5.createCanvas(5, 5, myp5.WEBGPU);
+ const data = myp5.createStorage([0]);
+
+ const computeShader = myp5.buildComputeShader(() => {
+ const buf = myp5.uniformStorage();
+ const id = myp5.index.x;
+ if (id == 0) {
+ buf[0] = 1.0;
+ return;
+ buf[0] = 2.0; // Should not execute
+ }
+ }, { myp5 });
+
+ computeShader.setUniform('buf', data);
+
+ expect(() => {
+ myp5.compute(computeShader, 1);
+ }).not.toThrow();
+ });
+ });
+
+ suite('array indexing on non-storage vectors (#8756)', () => {
+ test('indexing into array returned from helper function does not throw', async () => {
+ await myp5.createCanvas(5, 5, myp5.WEBGPU);
+ const storage = myp5.createStorage(new Float32Array(4));
+
+ const computeShader = myp5.buildComputeShader(() => {
+ const data = myp5.uniformStorage();
+ function getArray() {
+ return [1, 2];
+ }
+ const arr = getArray();
+ data[myp5.index.x] = arr[0] + arr[1] + myp5.index.x;
+ }, { myp5 });
+
+ computeShader.setUniform('data', storage);
+
+ expect(() => {
+ myp5.compute(computeShader, 4);
+ }).not.toThrow();
+ });
+
+ test('inline literal indexing [1, 2][0] works end-to-end', async () => {
+ await myp5.createCanvas(5, 5, myp5.WEBGPU);
+ const storage = myp5.createStorage(new Float32Array(4));
+
+ const computeShader = myp5.buildComputeShader(() => {
+ const data = myp5.uniformStorage();
+ data[myp5.index.x] = [1, 2][0];
+ }, { myp5 });
+
+ computeShader.setUniform('data', storage);
+
+ expect(() => {
+ myp5.compute(computeShader, 4);
+ }).not.toThrow();
+ });
+
+ test('array literal with 1 element throws descriptive error', async () => {
+ await myp5.createCanvas(5, 5, myp5.WEBGPU);
+
+ expect(() => {
+ myp5.buildComputeShader(() => {
+ const data = myp5.uniformStorage();
+ const arr = [1];
+ data[myp5.index.x] = arr[0];
+ }, { myp5 });
+ }).toThrow('and must have 2-4 elements (got 1)');
+ });
+
+ test('array literal with 5 elements throws descriptive error', async () => {
+ await myp5.createCanvas(5, 5, myp5.WEBGPU);
+
+ expect(() => {
+ myp5.buildComputeShader(() => {
+ const data = myp5.uniformStorage();
+ const arr = [1, 2, 3, 4, 5];
+ data[myp5.index.x] = arr[0];
+ }, { myp5 });
+ }).toThrow('and must have 2-4 elements (got 5)');
+ });
+
+ test('valid array lengths 2, 3, 4 do not throw', async () => {
+ await myp5.createCanvas(5, 5, myp5.WEBGPU);
+ const storage = myp5.createStorage(new Float32Array(4));
+
+ expect(() => {
+ const s2 = myp5.buildComputeShader(() => {
+ const data = myp5.uniformStorage();
+ const arr = [1, 2];
+ data[myp5.index.x] = arr[0];
+ }, { myp5 });
+ s2.setUniform('data', storage);
+ myp5.compute(s2, 4);
+
+ const s3 = myp5.buildComputeShader(() => {
+ const data = myp5.uniformStorage();
+ const arr = [1, 2, 3];
+ data[myp5.index.x] = arr[0];
+ }, { myp5 });
+ s3.setUniform('data', storage);
+ myp5.compute(s3, 4);
+
+ const s4 = myp5.buildComputeShader(() => {
+ const data = myp5.uniformStorage();
+ const arr = [1, 2, 3, 4];
+ data[myp5.index.x] = arr[0];
+ }, { myp5 });
+ s4.setUniform('data', storage);
+ myp5.compute(s4, 4);
+ }).not.toThrow();
+ });
+ });
});
});
diff --git a/utils/data-processor.mjs b/utils/data-processor.mjs
index 022e188792..7349e10773 100644
--- a/utils/data-processor.mjs
+++ b/utils/data-processor.mjs
@@ -152,6 +152,8 @@ export function processData(rawData, strategy) {
submodule,
class: forEntry || 'p5',
beta: entry.tags?.some(t => t.title === 'beta') || undefined,
+ webgpu: entry.tags?.some(t => t.title === 'webgpu') || undefined,
+ webgpuOnly: entry.tags?.some(t => t.title === 'webgpuOnly') || undefined,
};
processed.classitems.push(item);
@@ -188,7 +190,10 @@ export function processData(rawData, strategy) {
},
is_constructor: 1,
module,
- submodule
+ submodule,
+ beta: entry.tags?.some(t => t.title === 'beta') || undefined,
+ webgpu: entry.tags?.some(t => t.title === 'webgpu') || undefined,
+ webgpuOnly: entry.tags?.some(t => t.title === 'webgpuOnly') || undefined,
};
// The @private tag doesn't seem to end up in the Documentation.js output.
@@ -269,6 +274,8 @@ export function processData(rawData, strategy) {
module: prevItem?.module ?? module,
submodule: prevItem?.submodule ?? submodule,
beta: prevItem?.beta || entry.tags?.some(t => t.title === 'beta') || undefined,
+ webgpu: prevItem?.webgpu || entry.tags?.some(t => t.title === 'webgpu') || undefined,
+ webgpuOnly: prevItem?.webgpuOnly || entry.tags?.some(t => t.title === 'webgpuOnly') || undefined,
};
processed.classMethods[className] = processed.classMethods[className] || {};
diff --git a/utils/patch.mjs b/utils/patch.mjs
index 446a6ef755..841ac9c703 100644
--- a/utils/patch.mjs
+++ b/utils/patch.mjs
@@ -175,4 +175,3 @@ export function applyPatches() {
}
}
}
-
diff --git a/utils/typescript.mjs b/utils/typescript.mjs
index ad119cf80e..751ff117f8 100644
--- a/utils/typescript.mjs
+++ b/utils/typescript.mjs
@@ -29,7 +29,8 @@ allRawData.forEach(entry => {
if (entry.kind === 'constant' || entry.kind === 'typedef') {
constantsLookup.add(entry.name);
if (entry.kind === 'typedef') {
- typedefs[entry.name] = entry.type;
+ // Store the full entry so we have access to both .type and .properties
+ typedefs[entry.name] = entry;
}
}
});
@@ -242,15 +243,29 @@ function convertTypeToTypeScript(typeNode, options = {}) {
}
}
- // Check if this is a p5 constant - use typeof since they're defined as values
+ // Check if this is a p5 constant/typedef
if (constantsLookup.has(typeName)) {
+ const typedefEntry = typedefs[typeName];
+
+ // Use interface name for object-shaped typedefs in all contexts
+ if (typedefEntry && hasTypedefProperties(typedefEntry)) {
+ if (inGlobalMode) {
+ return `P5.${typeName}`;
+ } else if (isInsideNamespace) {
+ return typeName;
+ } else {
+ return `p5.${typeName}`;
+ }
+ }
+
+ // Fallback to typeof or primitive resolution for alias-style typedefs
if (inGlobalMode) {
return `typeof P5.${typeName}`;
- } else if (typedefs[typeName]) {
+ } else if (typedefEntry) {
if (isConstantDef) {
- return convertTypeToTypeScript(typedefs[typeName], options);
+ return convertTypeToTypeScript(typedefEntry.type, options);
} else {
- return `typeof p5.${typeName}`
+ return `typeof p5.${typeName}`;
}
} else {
return `Symbol`;
@@ -330,6 +345,105 @@ function convertTypeToTypeScript(typeNode, options = {}) {
}
}
+// Check if typedef represents a real object shape
+function hasTypedefProperties(typedefEntry) {
+ if (!Array.isArray(typedefEntry.properties) || typedefEntry.properties.length === 0) {
+ return false;
+ }
+ // Reject self-referential single-property typedefs
+ if (
+ typedefEntry.properties.length === 1 &&
+ typedefEntry.properties[0].name === typedefEntry.name
+ ) {
+ return false;
+ }
+ return true;
+}
+
+// Convert JSDoc FunctionType into a TypeScript function signature string
+function convertFunctionTypeForInterface(typeNode, options) {
+ const params = (typeNode.params || [])
+ .map((param, i) => {
+ let typeObj;
+ let paramName;
+ if (param.type === 'ParameterType') {
+ typeObj = param.expression;
+ paramName = param.name ?? `p${i}`;
+ } else if (typeof param.type === 'object' && param.type !== null) {
+ typeObj = param.type;
+ paramName = param.name ?? `p${i}`;
+ } else {
+ // param itself is a plain type node
+ typeObj = param;
+ paramName = `p${i}`;
+ }
+ const paramType = convertTypeToTypeScript(typeObj, options);
+ return `${paramName}: ${paramType}`;
+ })
+ .join(', ');
+
+ const returnType = typeNode.result
+ ? convertTypeToTypeScript(typeNode.result, options)
+ : 'void';
+
+ // Normalise 'undefined' return to 'void' for idiomatic TypeScript
+ const normalisedReturn = returnType === 'undefined' ? 'void' : returnType;
+
+ return `(${params}) => ${normalisedReturn}`;
+}
+
+// Generate a TypeScript interface from a typedef with @property fields
+function generateTypedefInterface(name, typedefEntry, options = {}, indent = 2) {
+ const pad = ' '.repeat(indent);
+ const innerPad = ' '.repeat(indent + 2);
+ let output = '';
+
+ if (typedefEntry.description) {
+ const descStr = typeof typedefEntry.description === 'string'
+ ? typedefEntry.description
+ : descriptionStringForTypeScript(typedefEntry.description);
+ if (descStr) {
+ output += `${pad}/**\n`;
+ output += formatJSDocComment(descStr, indent) + '\n';
+ output += `${pad} */\n`;
+ }
+ }
+
+ output += `${pad}interface ${name} {\n`;
+
+ for (const prop of typedefEntry.properties) {
+ // Each prop: { name, type, description, optional }
+ const propName = prop.name;
+ const rawType = prop.type;
+ const isOptional = prop.optional || rawType?.type === 'OptionalType';
+ const optMark = isOptional ? '?' : '';
+
+ if (prop.description) {
+ const propDescStr = typeof prop.description === 'string'
+ ? prop.description.trim()
+ : descriptionStringForTypeScript(prop.description);
+ if (propDescStr) {
+ output += `${innerPad}/** ${propDescStr} */\n`;
+ }
+ }
+
+ if (rawType?.type === 'FunctionType') {
+ // Render FunctionType properties as method signatures instead of arrow properties
+ const sig = convertFunctionTypeForInterface(rawType, options);
+ const arrowIdx = sig.lastIndexOf('=>');
+ const paramsPart = sig.substring(0, arrowIdx).trim();
+ const retPart = sig.substring(arrowIdx + 2).trim();
+ output += `${innerPad}${propName}${paramsPart}: ${retPart};\n`;
+ } else {
+ const tsType = rawType ? convertTypeToTypeScript(rawType, options) : 'any';
+ output += `${innerPad}${propName}${optMark}: ${tsType};\n`;
+ }
+ }
+
+ output += `${pad}}\n\n`;
+ return output;
+}
+
// Strategy for TypeScript output
const typescriptStrategy = {
shouldSkipEntry: (entry, context) => {
@@ -606,6 +720,10 @@ function generateTypeDefinitions() {
if (seenConstants.has(item.name)) {
return false;
}
+ // Skip typedefs that have real object shapes
+ if (typedefs[item.name] && hasTypedefProperties(typedefs[item.name])) {
+ return false;
+ }
seenConstants.add(item.name);
return true;
}
@@ -667,13 +785,20 @@ function generateTypeDefinitions() {
output += '\n';
-
p5Constants.forEach(constant => {
output += `${mutableProperties.has(constant.name) ? 'let' : 'const'} ${constant.name}: typeof __${constant.name};\n`;
});
output += '\n';
+ // Emit interfaces for typedefs that define object shapes
+ const namespaceOptions = { isInsideNamespace: true };
+ for (const [name, typedefEntry] of Object.entries(typedefs)) {
+ if (hasTypedefProperties(typedefEntry)) {
+ output += generateTypedefInterface(name, typedefEntry, namespaceOptions, 2);
+ }
+ }
+
// Generate other classes in namespace
Object.values(processed.classes).forEach(classData => {
if (classData.name !== 'p5') {
@@ -750,6 +875,14 @@ p5: P5;
globalDefinitions += '\n';
+ // Mirror typedef interfaces for global-mode usage
+ const globalNamespaceOptions = { isInsideNamespace: true, inGlobalMode: true };
+ for (const [name, typedefEntry] of Object.entries(typedefs)) {
+ if (hasTypedefProperties(typedefEntry)) {
+ globalDefinitions += generateTypedefInterface(name, typedefEntry, globalNamespaceOptions, 2);
+ }
+ }
+
// Add all real classes as both types and constructors
Object.values(processed.classes).forEach(classData => {
if (classData.name !== 'p5') {