diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js
index 57fb3f55e3..56c40e5510 100644
--- a/src/core/p5.Renderer3D.js
+++ b/src/core/p5.Renderer3D.js
@@ -2344,6 +2344,186 @@ function renderer3D(p5, fn) {
return this._renderer.createStorage(dataOrCount);
};
+ /**
+ * Creates a `p5.StorageList`, which is a
+ * variable-length block of data that compute shaders can push elements
+ * into, and regular shaders can read from. This is only available in WebGPU mode.
+ *
+ * It takes the maximum number of items that can be in the list, and then an optional
+ * example object of what you will push into the list. If you do not provide an example
+ * object, the list will be of numbers rather than objects.
+ *
+ * `p5.StorageList`s are similar to `p5.StorageBuffer`s,
+ * created with `createStorage()`, which can also be read from
+ * and written to by shaders. Those are fixed-length, so the number of items never changes.
+ * `p5.StorageList`s have a `push()` method that can be called from compute shaders, making
+ * this helpful for cases when the number of items might change.
+ *
+ * For example, you may want create particle systems where the number of particles visible
+ * is not fixed. Pass the `p5.StorageList` into `instances()`
+ * to draw one instance per item in the list:
+ *
+ * ```js example
+ * let particles, nextParticles; // Data
+ * let removeOld, emitNew; // Compute
+ * let drawParticles; // Rendering
+ * const MAX_PARTICLES = 300;
+ *
+ * async function setup() {
+ * await createCanvas(200, 200, WEBGPU);
+ *
+ * const schema = { position: createVector(0, 0), velocity: createVector(0, 0), life: 0 };
+ * particles = createStorageList(MAX_PARTICLES, schema);
+ * nextParticles = createStorageList(MAX_PARTICLES, schema);
+ *
+ * // Move any alive particles into nextParticles and simulate
+ * removeOld = buildComputeShader(() => {
+ * let src = uniformStorage(() => particles);
+ * let dst = uniformStorage(() => nextParticles);
+ * if (index.x < src.length) {
+ * let p = src[index.x];
+ * p.velocity.y += 0.08; // gravity
+ * p.position += p.velocity;
+ * p.life -= 0.02;
+ * if (p.life > 0) {
+ * dst.push(p);
+ * }
+ * }
+ * });
+ *
+ * // Emit new particles at the cursor with random outward velocities
+ * emitNew = buildComputeShader(() => {
+ * let dst = uniformStorage(() => nextParticles);
+ * let angle = random() * TWO_PI;
+ * dst.push({
+ * position: [mouseX, mouseY] - [width, height] / 2,
+ * velocity: [cos(angle), sin(angle) - 2.5], // shoot slightly upward
+ * life: 1.0
+ * });
+ * });
+ *
+ * drawParticles = buildMaterialShader(() => {
+ * let particleData = uniformStorage(() => particles);
+ * let p = particleData[instanceIndex];
+ *
+ * worldInputs.begin();
+ * worldInputs.position.xy += p.position;
+ * worldInputs.end();
+ *
+ * finalColor.begin();
+ * finalColor.set([1, p.life * 0.4, 0, p.life]);
+ * finalColor.end();
+ * });
+ *
+ * describe('Orange particles emitting from the cursor, arcing upward then falling with gravity.');
+ * }
+ *
+ * function draw() {
+ * background(0);
+ * noStroke();
+ *
+ * nextParticles.clear();
+ * compute(removeOld, MAX_PARTICLES);
+ * compute(emitNew, 5);
+ *
+ * // Swap so particles always holds the freshly built list for drawing and
+ * // for the next frame's filter pass.
+ * [particles, nextParticles] = [nextParticles, particles];
+ *
+ * shader(drawParticles);
+ * blendMode(ADD);
+ * instances(particles).circle(0, 0, 4);
+ * }
+ * ```
+ *
+ * Another thing you might want to do is draw a different number of instances of a shape
+ * every frame, but where you calculate the instances in a compute shader for speed, where
+ * it can happen in parallel:
+ *
+ * ```js example
+ * let cellLocs, circleIndices, squareIndices;
+ * let updateCells, drawParticles;
+ * const COLS = 10, ROWS = 10;
+ *
+ * async function setup() {
+ * await createCanvas(200, 200, WEBGPU);
+ *
+ * let locs = [];
+ * for (let x = 0; x < COLS; x++) {
+ * for (let y = 0; y < ROWS; y++) {
+ * locs.push({ position: createVector(x * 20 - 90, y * 20 - 90) });
+ * }
+ * }
+ * cellLocs = createStorage(locs);
+ * circleIndices = createStorageList(locs.length);
+ * squareIndices = createStorageList(locs.length);
+ *
+ * updateCells = buildComputeShader(() => {
+ * let locs = uniformStorage(cellLocs);
+ * let circles = uniformStorage(circleIndices);
+ * let squares = uniformStorage(squareIndices);
+ * let loc = locs[index.x].position;
+ * let r = 50 + 30 * sin(millis() * 0.004);
+ * if (distance(loc, [mouseX, mouseY] - [width, height] / 2) < r) {
+ * circles.push(index.x);
+ * } else {
+ * squares.push(index.x);
+ * }
+ * });
+ *
+ * drawParticles = buildMaterialShader(() => {
+ * let data = uniformStorage(cellLocs);
+ * let indices = uniformStorage(0);
+ * worldInputs.begin();
+ * worldInputs.position.xy += data[indices[instanceIndex]].position;
+ * worldInputs.end();
+ * });
+ *
+ * describe('A 10x10 grid of cells that switch between circles and squares based on mouse proximity.');
+ * }
+ *
+ * function draw() {
+ * background(255);
+ * noStroke();
+ *
+ * circleIndices.clear();
+ * squareIndices.clear();
+ * compute(updateCells, cellLocs.length);
+ *
+ * shader(drawParticles);
+ *
+ * fill('blue');
+ * drawParticles.setUniform('indices', circleIndices);
+ * instances(circleIndices).circle(0, 0, 12);
+ *
+ * fill('red');
+ * drawParticles.setUniform('indices', squareIndices);
+ * rectMode(CENTER);
+ * instances(squareIndices).rect(0, 0, 10, 10);
+ * }
+ * ```
+ *
+ * @method createStorageList
+ * @submodule p5.strands
+ * @beta
+ * @webgpu
+ * @webgpuOnly
+ * @param {Number} maxCapacity Maximum number of elements the list can hold.
+ * @param {Object|Object[]} [schemaOrData] A schema template object or initial
+ * array of struct objects. Omit for a float list.
+ * @returns {p5.StorageList}
+ */
+ fn.createStorageList = function (maxCapacity, schemaOrData) {
+ if (!this._renderer.createStorageList) {
+ p5._friendlyError(
+ `createStorageList() is only available with the WebGPU renderer. ${webGPUAddonMessage}`,
+ 'createStorageList'
+ );
+ return;
+ }
+ return this._renderer.createStorageList(maxCapacity, schemaOrData);
+ };
+
/**
* Returns the default shader used for compute operations.
*
@@ -2391,7 +2571,8 @@ function renderer3D(p5, fn) {
* into `compute`.
*
* A compute shader will read from and write to storage, which is often an array of
- * numbers or objects. Use `createStorage` to construct
+ * numbers or objects. Use `createStorage`
+ * or `createStorageList` to construct
* initial data. Connect your iteration function to the storage by passing the storage
* into `uniformStorage`.
*
diff --git a/src/strands/ir_builders.js b/src/strands/ir_builders.js
index 2dcbf3debf..ff73748c9b 100644
--- a/src/strands/ir_builders.js
+++ b/src/strands/ir_builders.js
@@ -902,10 +902,13 @@ export function arrayAssignmentNode(
index = createStrandsNode(id, dimension, strandsContext);
}
- // Ensure value is a StrandsNode
+ // Ensure value is a StrandsNode, casting to float if needed (e.g. index.x is i32)
let value;
if (valueNode instanceof StrandsNode) {
- value = valueNode;
+ value =
+ valueNode.typeInfo().baseType !== BaseType.FLOAT
+ ? castToFloat(strandsContext, valueNode)
+ : valueNode;
} else {
const { id, dimension } = primitiveConstructorNode(
strandsContext,
diff --git a/src/strands/strands_api.js b/src/strands/strands_api.js
index da61d5290a..d8d680f1f0 100644
--- a/src/strands/strands_api.js
+++ b/src/strands/strands_api.js
@@ -1051,6 +1051,115 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) {
});
}
+ // Adds push() and length getter to the node proxy returned by uniformStorage()
+ // when the underlying value is a StorageList.
+ //
+ // push() generates a call to the _p5_push_ WGSL helper function that
+ // atomically appends an element. length generates a call to _p5_length_
+ // which wraps atomicLoad so users can read the current count in shaders.
+ function _installStorageListMethods(node, listName, schema, ctx) {
+ const { dag, cfg } = ctx;
+
+ node.push = function (element) {
+ let argID;
+
+ if (schema) {
+ // Build a struct constructor call: Element(field0, field1, ...)
+ const structTypeName = `${listName}Element`;
+ const fieldIDs = schema.fields.map(field => {
+ const val =
+ element && typeof element === 'object' && !element.isStrandsNode
+ ? element[field.name]
+ : element;
+ if (val?.isStrandsNode) return val.id;
+ const { id: primID } = build.primitiveConstructorNode(
+ ctx,
+ { baseType: field.baseType, dimension: field.dim },
+ val
+ );
+ return primID;
+ });
+ const structCallData = DAG.createNodeData({
+ nodeType: NodeType.OPERATION,
+ opCode: OpCode.Nary.FUNCTION_CALL,
+ identifier: structTypeName,
+ dependsOn: fieldIDs,
+ baseType: BaseType.FLOAT,
+ dimension: 1
+ });
+ argID = DAG.getOrCreateNode(dag, structCallData);
+ } else {
+ // Float list
+ const val = element;
+ if (val?.isStrandsNode) {
+ const nodeData = getNodeDataFromID(dag, val.id);
+ if (nodeData.baseType !== BaseType.FLOAT) {
+ // Non-float node (e.g. index.x is i32): cast via the backend's type name so the cast is platform-independent
+ argID = build.castToFloat(ctx, val).id;
+ } else {
+ argID = val.id;
+ }
+ } else {
+ const { id: primID } = build.primitiveConstructorNode(
+ ctx,
+ { baseType: BaseType.FLOAT, dimension: 1 },
+ val
+ );
+ argID = primID;
+ }
+ }
+
+ const callData = DAG.createNodeData({
+ nodeType: NodeType.OPERATION,
+ opCode: OpCode.Nary.FUNCTION_CALL,
+ identifier: `_p5_push_${listName}`,
+ dependsOn: [argID],
+ baseType: BaseType.FLOAT,
+ dimension: 1
+ });
+ const callID = DAG.getOrCreateNode(dag, callData);
+
+ const stmtData = DAG.createNodeData({
+ nodeType: NodeType.STATEMENT,
+ statementType: StatementType.EXPRESSION,
+ dependsOn: [callID],
+ phiBlocks: []
+ });
+ CFG.recordInBasicBlock(cfg, cfg.currentBlock, DAG.getOrCreateNode(dag, stmtData));
+ };
+
+ node.pop = function () {
+ const callData = DAG.createNodeData({
+ nodeType: NodeType.OPERATION,
+ opCode: OpCode.Nary.FUNCTION_CALL,
+ identifier: `_p5_pop_${listName}`,
+ dependsOn: [],
+ baseType: BaseType.FLOAT,
+ dimension: 1
+ });
+ const callID = DAG.getOrCreateNode(dag, callData);
+ CFG.recordInBasicBlock(cfg, cfg.currentBlock, callID);
+ return createStrandsNode(callID, 1, ctx);
+ };
+
+ Object.defineProperty(node, 'length', {
+ get() {
+ const callData = DAG.createNodeData({
+ nodeType: NodeType.OPERATION,
+ opCode: OpCode.Nary.FUNCTION_CALL,
+ identifier: `_p5_length_${listName}`,
+ dependsOn: [],
+ baseType: BaseType.INT,
+ dimension: 1
+ });
+ const callID = DAG.getOrCreateNode(dag, callData);
+ CFG.recordInBasicBlock(cfg, cfg.currentBlock, callID);
+ return createStrandsNode(callID, 1, ctx);
+ },
+ configurable: true
+ });
+ }
+
// Storage buffer uniform function for compute shaders
fn.uniformStorage = function (name, bufferOrSchema) {
const shaderName = resolveShaderName(
@@ -1060,6 +1169,8 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) {
);
let schema = null;
let defaultValue = null;
+ let isStorageList = false;
+ let maxCapacity = 0;
// If it's a function, evaluate it immediately to infer schema,
// then store the function so it gets called each frame.
@@ -1071,8 +1182,12 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) {
}
}
- if (value?._schema) {
- // Struct storage buffer with pre-computed schema
+ if (value?._isStorageList) {
+ isStorageList = true;
+ maxCapacity = value.maxCapacity;
+ schema = value._schema;
+ if (defaultValue === null) defaultValue = value;
+ } else if (value?._schema) {
schema = value._schema;
if (defaultValue === null) defaultValue = value;
} else if (value && typeof value === 'object' && !value._isStorageBuffer) {
@@ -1089,7 +1204,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) {
);
strandsContext.uniforms.push({
name: shaderName,
- typeInfo: { baseType: 'storage', dimension: 1, schema },
+ typeInfo: { baseType: 'storage', dimension: 1, schema, isStorageList, maxCapacity },
defaultValue
});
@@ -1100,6 +1215,11 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) {
node._originalBaseType = 'storage';
node._originalDimension = 1;
node._schema = schema;
+
+ if (isStorageList) {
+ _installStorageListMethods(node, shaderName, schema, strandsContext);
+ }
+
return node;
};
}
diff --git a/src/webgl/3d_primitives.js b/src/webgl/3d_primitives.js
index 5c6c270745..5d5236637f 100644
--- a/src/webgl/3d_primitives.js
+++ b/src/webgl/3d_primitives.js
@@ -2655,8 +2655,9 @@ function primitives3D(p5, fn) {
* that reads per-instance data from an instanced attribute buffer.
*
* @method instances
- * @param {Number} count number of instances to draw. Must be a positive
- * integer.
+ * @param {Number|p5.StorageBuffer|p5.StorageList} count number of instances
+ * to draw, or a storage buffer/list whose element count is used. A plain
+ * number must be a positive integer.
* @returns {p5.InstancesWrapper} an object with methods `sphere`, `box`, `plane`,
* `ellipsoid`, `cylinder`, `cone`, `torus`, `triangle`, `rect`, `quad`,
* `ellipse`, `arc`, `model`, `line`, `point`, `bezier`, and `spline`. Call one of
@@ -2697,26 +2698,39 @@ function primitives3D(p5, fn) {
fn.instances = function (count) {
this._assert3d('instances');
- if (typeof count !== 'number' || !isFinite(count) || count < 1) {
- p5._friendlyError(
- 'instances() requires a positive integer count. Clamping to 1.',
- 'instances'
- );
- count = 1;
- } else {
- count = Math.round(count);
+ const isList = count?._isStorageList;
+
+ if (count?._isStorageBuffer) {
+ count = count.size;
+ }
+
+ if (!isList) {
+ if (typeof count !== 'number' || !isFinite(count) || count < 1) {
+ p5._friendlyError(
+ 'instances() requires a positive integer count, a StorageBuffer, or a StorageList. Clamping to 1.',
+ 'instances'
+ );
+ count = 1;
+ } else {
+ count = Math.round(count);
+ }
}
const r = this._renderer;
- // Each wrapped method: set _instanceCount, call the method with
- // the correct context, clear _instanceCount in finally so it never leaks.
+ // Each wrapped method: set _instanceCount or _instanceList, call the method
+ // with the correct context, clear both in finally so they never leak.
const wrap = (method, ctx = r) =>
function (...args) {
- r._instanceCount = count;
+ if (isList) {
+ r._instanceList = count;
+ } else {
+ r._instanceCount = count;
+ }
try {
method.apply(ctx, args);
} finally {
+ r._instanceList = undefined;
r._instanceCount = undefined;
}
};
diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js
index 072ae2f34c..7472786f3c 100644
--- a/src/webgpu/p5.RendererWebGPU.js
+++ b/src/webgpu/p5.RendererWebGPU.js
@@ -44,10 +44,11 @@ function rendererWebGPU(p5, fn) {
p5;
class StorageBuffer {
- constructor(buffer, size, renderer, schema = null) {
+ constructor(buffer, size, renderer, schema = null, length = null) {
this._isStorageBuffer = true;
this.buffer = buffer;
this.size = size;
+ this.length = length;
this._renderer = renderer;
this._schema = schema;
}
@@ -387,6 +388,273 @@ function rendererWebGPU(p5, fn) {
*/
p5.StorageBuffer = StorageBuffer;
+ class StorageList {
+ constructor(buffer, lengthOffset, maxCapacity, renderer, schema = null, initialCount = 0) {
+ this._isStorageList = true;
+ this.buffer = buffer;
+ this._lengthOffset = lengthOffset;
+ this.maxCapacity = maxCapacity;
+ this._renderer = renderer;
+ this._schema = schema;
+ this._stride = schema ? schema.stride : 4;
+ this._cpuLength = initialCount;
+ }
+
+ /**
+ * Empties the list.
+ *
+ * @method clear
+ * @for p5.StorageList
+ * @beta
+ * @webgpu
+ * @webgpuOnly
+ */
+ clear() {
+ this._cpuLength = 0;
+ const encoder = this._renderer.device.createCommandEncoder();
+ encoder.clearBuffer(this.buffer, this._lengthOffset, 4);
+ this._renderer._pendingCommandEncoders.push(encoder.finish());
+ this._renderer._hasPendingDraws = true;
+ }
+
+ /**
+ * Appends one element to the list. This can be called from your sketch's
+ * JavaScript or from within a compute shader. Use this to seed a list
+ * before any compute shaders run. Calling `push()` after a GPU compute pass
+ * that has already modified the list will produce undefined ordering.
+ *
+ * For a float list, pass a number. For a struct list, pass a plain object
+ * whose properties match the schema.
+ *
+ * ```js example
+ * let positions;
+ * let drawShader;
+ * const COUNT = 5;
+ *
+ * async function setup() {
+ * await createCanvas(200, 200, WEBGPU);
+ *
+ * positions = createStorageList(COUNT, { pos: createVector(0, 0) });
+ * for (let i = 0; i < COUNT; i++) {
+ * positions.push({
+ * pos: createVector(
+ * random(-1, 1) * width / 2,
+ * random(-1, 1) * height / 2
+ * )
+ * });
+ * }
+ *
+ * drawShader = buildMaterialShader(() => {
+ * let data = uniformStorage(positions);
+ * worldInputs.begin();
+ * worldInputs.position.xy += data[instanceIndex].pos;
+ * worldInputs.end();
+ * });
+ *
+ * describe('Five circles placed at random positions.');
+ * }
+ *
+ * function draw() {
+ * background(220);
+ * noStroke();
+ * shader(drawShader);
+ * instances(positions).circle(0, 0, 20);
+ * }
+ * ```
+ *
+ * You can also use `push()` from compute shaders. This approach can often
+ * be faster as no data needs to transfer from the CPU to the GPU.
+ *
+ * ```js example
+ * let particles, nextParticles;
+ * let removeOld, emitNew;
+ * let drawParticles;
+ * const MAX_PARTICLES = 300;
+ *
+ * async function setup() {
+ * await createCanvas(200, 200, WEBGPU);
+ *
+ * const schema = { position: createVector(0, 0), velocity: createVector(0, 0), life: 0 };
+ * particles = createStorageList(MAX_PARTICLES, schema);
+ * nextParticles = createStorageList(MAX_PARTICLES, schema);
+ *
+ * // Seed an initial burst from JavaScript so something is visible on frame 1.
+ * for (let i = 0; i < 20; i++) {
+ * let angle = random(TWO_PI);
+ * particles.push({
+ * position: createVector(0, 0),
+ * velocity: createVector(cos(angle) * 2, sin(angle) * 2 - 2.5),
+ * life: random(0.5, 1.0)
+ * });
+ * }
+ *
+ * removeOld = buildComputeShader(() => {
+ * let src = uniformStorage(() => particles);
+ * let dst = uniformStorage(() => nextParticles);
+ * if (index.x < src.length) {
+ * let p = src[index.x];
+ * p.velocity.y += 0.08;
+ * p.position += p.velocity;
+ * p.life -= 0.02;
+ * if (p.life > 0) {
+ * dst.push(p);
+ * }
+ * }
+ * });
+ *
+ * emitNew = buildComputeShader(() => {
+ * let dst = uniformStorage(() => nextParticles);
+ * let angle = random() * TWO_PI;
+ * dst.push({
+ * position: [mouseX, mouseY] - [width, height] / 2,
+ * velocity: [cos(angle), sin(angle) - 2.5],
+ * life: 1.0
+ * });
+ * });
+ *
+ * drawParticles = buildMaterialShader(() => {
+ * let particleData = uniformStorage(() => particles);
+ * let p = particleData[instanceIndex];
+ * worldInputs.begin();
+ * worldInputs.position.xy += p.position;
+ * worldInputs.end();
+ * finalColor.begin();
+ * finalColor.set([1, p.life * 0.4, 0, p.life]);
+ * finalColor.end();
+ * });
+ *
+ * describe('Orange particles emitting from the cursor, falling with gravity.');
+ * }
+ *
+ * function draw() {
+ * background(0);
+ * noStroke();
+ *
+ * nextParticles.clear();
+ * compute(removeOld, MAX_PARTICLES);
+ * compute(emitNew, 5);
+ * [particles, nextParticles] = [nextParticles, particles];
+ *
+ * shader(drawParticles);
+ * blendMode(ADD);
+ * instances(particles).circle(0, 0, 4);
+ * }
+ * ```
+ *
+ * @method push
+ * @for p5.StorageList
+ * @beta
+ * @webgpu
+ * @webgpuOnly
+ * @param {Number|Object} element A number for float lists, or a plain object
+ * matching the list's schema for struct lists.
+ */
+ push(element) {
+ if (this._cpuLength >= this.maxCapacity) {
+ throw new Error(
+ `StorageList is full (maxCapacity: ${this.maxCapacity})`
+ );
+ }
+ const device = this._renderer.device;
+ let packed;
+ if (this._schema) {
+ packed = this._renderer._packStructArray([element], this._schema);
+ } else {
+ if (typeof element !== 'number') {
+ throw new Error('Float StorageList.push() expects a number');
+ }
+ packed = new Float32Array([element]);
+ }
+ device.queue.writeBuffer(
+ this.buffer,
+ this._cpuLength * this._stride,
+ packed
+ );
+ this._cpuLength++;
+ device.queue.writeBuffer(
+ this.buffer,
+ this._lengthOffset,
+ new Uint32Array([this._cpuLength])
+ );
+ }
+
+ /**
+ * Reads the current contents of the list back to JavaScript.
+ *
+ * It returns a `Float32Array` for float lists, or an array of plain objects
+ * for struct lists.
+ *
+ * Note: This is a GPU-to-CPU read. Calling it frequently can be slow.
+ *
+ * @method read
+ * @for p5.StorageList
+ * @beta
+ * @webgpu
+ * @webgpuOnly
+ * @returns {Promise}
+ */
+ async read() {
+ const device = this._renderer.device;
+ this._renderer.flushDraw();
+
+ const totalSize = this.buffer.size;
+ const stagingBuffer = device.createBuffer({
+ size: totalSize,
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
+ });
+
+ const encoder = device.createCommandEncoder();
+ encoder.copyBufferToBuffer(this.buffer, 0, stagingBuffer, 0, totalSize);
+ device.queue.submit([encoder.finish()]);
+
+ await stagingBuffer.mapAsync(GPUMapMode.READ);
+ const mapped = stagingBuffer.getMappedRange();
+
+ const length = Math.min(
+ new DataView(mapped).getUint32(this._lengthOffset, true),
+ this.maxCapacity
+ );
+
+ let result;
+ const stride = this._schema ? this._schema.stride : 4;
+ if (this._schema) {
+ const byteLen = length * stride;
+ const rawCopy = new Float32Array(byteLen / 4);
+ if (byteLen > 0) {
+ rawCopy.set(new Float32Array(mapped, 0, byteLen / 4));
+ }
+ result = this._renderer._unpackStructArray(rawCopy, this._schema);
+ } else {
+ const rawCopy = new Float32Array(length);
+ if (length > 0) {
+ rawCopy.set(new Float32Array(mapped, 0, length));
+ }
+ result = rawCopy;
+ }
+
+ stagingBuffer.unmap();
+ stagingBuffer.destroy();
+
+ return result;
+ }
+ }
+
+ /**
+ * A variable-length buffer that compute can push to and pop from, like
+ * a JavaScript array.
+ *
+ * This is only available in WebGPU mode.
+ *
+ * Note: `createStorageList()` is the
+ * recommended way to create an instance of this class.
+ *
+ * @class p5.StorageList
+ * @beta
+ * @webgpu
+ * @webgpuOnly
+ */
+ p5.StorageList = StorageList;
+
class RendererWebGPU extends Renderer3D {
constructor(pInst, w, h, isMainCanvas, elt) {
super(pInst, w, h, isMainCanvas, elt);
@@ -441,6 +709,9 @@ function rendererWebGPU(p5, fn) {
// Storage buffers for compute shaders
this._storageBuffers = new Set();
+ // Temporary indirect draw buffers created during a frame; destroyed after submit
+ this._tempBuffers = [];
+
// 2D canvas for pixel reading fallback
this._pixelReadCanvas = null;
this._pixelReadCtx = null;
@@ -1801,6 +2072,16 @@ function rendererWebGPU(p5, fn) {
// Submit the commands
this.queue.submit(commandsToSubmit);
+ const tempBuffers = this._tempBuffers;
+ this._tempBuffers = [];
+ if (tempBuffers.length > 0) {
+ this._postSubmitCallbacks.push(() => {
+ for (const buf of tempBuffers) {
+ buf.destroy();
+ }
+ });
+ }
+
for (const buf of this.activeUniformBuffers) {
// buf.buffer = this.device.createBuffer({
// size: buf.size,
@@ -1944,10 +2225,17 @@ function rendererWebGPU(p5, fn) {
this._promoteToFramebufferWithoutCopy();
}
+ const currentShader = this._curShader;
+ const instanceList = this._instanceList;
+
+ if (instanceList) {
+ this._drawBuffersIndirect(geometry, buffers, currentShader, mode, instanceList);
+ return;
+ }
+
this._beginActiveRenderPass();
const passEncoder = this.activeRenderPass;
- const currentShader = this._curShader;
this.setupShaderBindGroups(currentShader, passEncoder, { mode, buffers });
// Bind vertex buffers
for (const buffer of currentShader._vertexBuffers ||
@@ -1983,6 +2271,94 @@ function rendererWebGPU(p5, fn) {
this._hasPendingDraws = true;
}
+ // Handles draw calls where instance count comes from a StorageList on the GPU.
+ // Each call creates a fresh indirect buffer so that multiple draws in the same
+ // frame with different geometries don't overwrite each other's indexCount slot
+ // before the GPU reads it.
+ _drawBuffersIndirect(geometry, buffers, currentShader, mode, instanceList) {
+ // End the current render pass so we can issue the buffer copy command
+ // before starting the new render pass that contains the draw.
+ this._finishActiveRenderPass();
+
+ // indexCount and vertexCount are geometry-specific; write them to a
+ // fresh indirect buffer so concurrent draws don't clobber each other.
+ // Use mappedAtCreation so the geometry counts are committed at buffer
+ // creation time, with no race against the copyBufferToBuffer below.
+ const isIndexed =
+ !!buffers.indexBuffer && currentShader.shaderType !== 'stroke';
+
+ const indirectBuffer = this.device.createBuffer({
+ size: 20, // 5 u32s: indexCount/vertexCount, instanceCount, firstIndex/firstVertex, baseVertex, firstInstance
+ usage: GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST,
+ mappedAtCreation: true
+ });
+ const indirectInit = new Uint32Array(indirectBuffer.getMappedRange());
+ if (currentShader.shaderType === 'stroke') {
+ // drawIndirect: [vertexCount, instanceCount, firstVertex, firstInstance, (padding)]
+ indirectInit[0] = geometry.lineVertices
+ ? geometry.lineVertices.length / 3
+ : 0;
+ } else if (isIndexed) {
+ // drawIndexedIndirect: [indexCount, instanceCount, firstIndex, baseVertex, firstInstance]
+ indirectInit[0] = geometry.faces.length * 3;
+ } else {
+ // drawIndirect: [vertexCount, instanceCount, firstVertex, firstInstance, (padding)]
+ indirectInit[0] = geometry.vertices.length;
+ }
+ // Slot 1 (instanceCount) is intentionally left 0; copyBufferToBuffer below
+ // overwrites it with the GPU-side list length before the draw.
+ indirectBuffer.unmap();
+ this._tempBuffers.push(indirectBuffer);
+
+ // Copy the GPU-side length into instanceCount slot (offset 4)
+ const copyEncoder = this.device.createCommandEncoder();
+ copyEncoder.copyBufferToBuffer(
+ instanceList.buffer,
+ instanceList._lengthOffset,
+ indirectBuffer,
+ 4,
+ 4
+ );
+ this._pendingCommandEncoders.push(copyEncoder.finish());
+
+ this._beginActiveRenderPass();
+ const passEncoder = this.activeRenderPass;
+
+ this.setupShaderBindGroups(currentShader, passEncoder, { mode, buffers });
+ for (const buffer of currentShader._vertexBuffers ||
+ this._getVertexBuffers(currentShader)) {
+ const location = currentShader.attributes[buffer.attr].location;
+ const gpuBuffer = buffers[buffer.dst];
+ passEncoder.setVertexBuffer(location, gpuBuffer, 0);
+ }
+
+ if (currentShader.shaderType === 'fill') {
+ if (isIndexed) {
+ passEncoder.setIndexBuffer(
+ buffers.indexBuffer,
+ buffers.indexFormat || 'uint16'
+ );
+ passEncoder.drawIndexedIndirect(indirectBuffer, 0);
+ } else {
+ passEncoder.drawIndirect(indirectBuffer, 0);
+ }
+ } else if (currentShader.shaderType === 'stroke') {
+ if (buffers.lineVerticesBuffer) {
+ passEncoder.drawIndirect(indirectBuffer, 0);
+ }
+ } else if (currentShader.shaderType === 'text') {
+ if (buffers.indexBuffer) {
+ passEncoder.setIndexBuffer(
+ buffers.indexBuffer,
+ buffers.indexFormat || 'uint16'
+ );
+ passEncoder.drawIndexedIndirect(indirectBuffer, 0);
+ }
+ }
+
+ this._hasPendingDraws = true;
+ }
+
setupShaderBindGroups(currentShader, passEncoder, shaderOptionsParams) {
const shaderOptions = this._shaderOptions(shaderOptionsParams);
if (
@@ -2127,7 +2503,8 @@ function rendererWebGPU(p5, fn) {
if (
!uniform ||
!uniform._cachedData ||
- !uniform._cachedData._isStorageBuffer
+ (!uniform._cachedData._isStorageBuffer &&
+ !uniform._cachedData._isStorageList)
) {
throw new Error(
`Storage buffer "${entry.storage.name}" not set. ` +
@@ -2463,8 +2840,10 @@ function rendererWebGPU(p5, fn) {
// Extract storage buffers
const storageBuffers = {};
+ // Matches plain array bindings (array or array), standalone atomic
+ // bindings (atomic), and named struct type bindings used by StorageList.
const storageRegex =
- /@group\((\d+)\)\s*@binding\((\d+)\)\s*var\s+(\w+)\s*:\s*array<\w+>/g;
+ /@group\((\d+)\)\s*@binding\((\d+)\)\s*var\s+(\w+)\s*:\s*(array<[\w<>, ]+>|atomic<\w+>|\w+)/g;
// Track which bindings are taken by the struct properties we've parsed
// (the rest should be textures/samplers)
@@ -3962,7 +4341,7 @@ ${hookUniformFields}}
});
new Float32Array(buffer.getMappedRange()).set(packed);
buffer.unmap();
- const storageBuffer = new StorageBuffer(buffer, size, this, schema);
+ const storageBuffer = new StorageBuffer(buffer, size, this, schema, dataOrCount.length);
this._storageBuffers.add(storageBuffer);
return storageBuffer;
}
@@ -4007,7 +4386,7 @@ ${hookUniformFields}}
buffer.unmap();
}
- const storageBuffer = new StorageBuffer(buffer, size, this);
+ const storageBuffer = new StorageBuffer(buffer, size, this, null, initialData.length);
// Track for cleanup
this._storageBuffers.add(storageBuffer);
@@ -4015,6 +4394,69 @@ ${hookUniformFields}}
return storageBuffer;
}
+ createStorageList(maxCapacity, schemaOrData) {
+ const device = this.device;
+
+ let schema = null;
+ let initialCount = 0;
+ let initialDataPacked = null;
+
+ if (
+ Array.isArray(schemaOrData) &&
+ schemaOrData.length > 0 &&
+ typeof schemaOrData[0] === 'object' &&
+ !Array.isArray(schemaOrData[0])
+ ) {
+ schema = this._inferStructSchema(schemaOrData[0]);
+ initialDataPacked = this._packStructArray(schemaOrData, schema);
+ initialCount = schemaOrData.length;
+ } else if (
+ schemaOrData !== undefined &&
+ typeof schemaOrData === 'object' &&
+ !Array.isArray(schemaOrData)
+ ) {
+ // Plain schema template object -- only used to infer layout
+ schema = this._inferStructSchema(schemaOrData);
+ }
+
+ const stride = schema ? schema.stride : 4;
+ // Length field (u32) follows the data array at a 4-byte aligned offset.
+ // Strides are always multiples of 4, so no padding is needed between
+ // the last element and the length field.
+ const lengthOffset = maxCapacity * stride;
+ const totalSize = Math.max(
+ Math.ceil((lengthOffset + 4) / 16) * 16,
+ 16
+ );
+
+ const buffer = device.createBuffer({
+ size: totalSize,
+ usage:
+ GPUBufferUsage.STORAGE |
+ GPUBufferUsage.COPY_DST |
+ GPUBufferUsage.COPY_SRC,
+ mappedAtCreation: true
+ });
+
+ const mapped = buffer.getMappedRange();
+ if (initialDataPacked !== null) {
+ new Float32Array(mapped).set(initialDataPacked);
+ }
+ new Uint32Array(mapped, lengthOffset, 1).set([initialCount]);
+ buffer.unmap();
+
+ const storageList = new StorageList(
+ buffer,
+ lengthOffset,
+ maxCapacity,
+ this,
+ schema,
+ initialCount
+ );
+ this._storageBuffers.add(storageList);
+ return storageList;
+ }
+
_getWebGPUColorFormat(framebuffer) {
if (framebuffer.format === constants.FLOAT) {
return framebuffer.channels === RGBA ? 'rgba32float' : 'rgba32float';
@@ -4518,12 +4960,18 @@ ${hookUniformFields}}
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);
+ // Use actual dispatch width as stride, not px: extra threads beyond px are
+ // still launched and would collide with threads in the next row if px were used.
+ shader.setUniform('uPhysicalCount', [
+ workgroupCountX * WORKGROUP_SIZE_X,
+ workgroupCountY * WORKGROUP_SIZE_Y,
+ workgroupCountZ * WORKGROUP_SIZE_Z
+ ]);
+
const commandEncoder = this.device.createCommandEncoder();
const passEncoder = commandEncoder.beginComputePass();
this.setupShaderBindGroups(shader, passEncoder, {
@@ -4539,7 +4987,10 @@ ${hookUniformFields}}
);
passEncoder.end();
- this.device.queue.submit([commandEncoder.finish()]);
+ // Queue alongside pending draws so the copy and render in flushDraw()
+ // are guaranteed to execute after the compute in the same submit batch.
+ this._pendingCommandEncoders.push(commandEncoder.finish());
+ this._hasPendingDraws = true;
}
}
diff --git a/src/webgpu/strands_wgslBackend.js b/src/webgpu/strands_wgslBackend.js
index 2d193f172f..aef5418e27 100644
--- a/src/webgpu/strands_wgslBackend.js
+++ b/src/webgpu/strands_wgslBackend.js
@@ -275,26 +275,104 @@ export const wgslBackend = {
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}>;`;
+ if (typeInfo.isStorageList) {
+ this._addStorageListBindings(
+ strandsContext,
+ name,
+ typeInfo,
+ bindingIndex,
+ isComputeShader
+ );
+ bindingIndex += 1;
} else {
- declaration = `@group(0) @binding(${bindingIndex}) var ${name}: array;`;
- }
+ 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);
- }
+ if (isComputeShader) {
+ strandsContext.computeDeclarations.add(declaration);
+ } else {
+ strandsContext.vertexDeclarations.add(declaration);
+ strandsContext.fragmentDeclarations.add(declaration);
+ }
- bindingIndex += 1;
+ bindingIndex += 1;
+ }
}
}
},
+
+ _addStorageListBindings(
+ strandsContext,
+ name,
+ typeInfo,
+ bindingIndex,
+ isComputeShader
+ ) {
+ const { schema, maxCapacity } = typeInfo;
+ const elementTypeName = schema ? `${name}Element` : 'f32';
+ const elementTypeDecl = schema
+ ? `struct ${elementTypeName} ${schema.structBody}\n`
+ : '';
+
+ if (isComputeShader) {
+ // Wrapper struct holds a fixed-size data array followed by an atomic
+ // length. The layout matches the single GPU buffer written by createStorageList.
+ const bufTypeName = `${name}_buf`;
+ const bufDecl =
+ `${elementTypeDecl}` +
+ `struct ${bufTypeName} { data: array<${elementTypeName}, ${maxCapacity}>, length: atomic }\n` +
+ `@group(0) @binding(${bindingIndex}) var ${name}: ${bufTypeName};`;
+
+ const pushParam = schema ? `element: ${elementTypeName}` : `value: f32`;
+ const pushStore = schema ? `${name}.data[_idx] = element;` : `${name}.data[_idx] = value;`;
+ const pushFn =
+ `fn _p5_push_${name}(${pushParam}) {\n` +
+ ` let _idx = atomicAdd(&${name}.length, 1u);\n` +
+ ` if (_idx < ${maxCapacity}u) { ${pushStore} }\n` +
+ `}`;
+
+ const popReturnType = schema ? elementTypeName : 'f32';
+ const popFn =
+ `fn _p5_pop_${name}() -> ${popReturnType} {\n` +
+ ` let _idx = atomicSub(&${name}.length, 1u);\n` +
+ ` return ${name}.data[_idx - 1u];\n` +
+ `}`;
+
+ const lengthFn =
+ `fn _p5_length_${name}() -> i32 {\n` +
+ ` return i32(atomicLoad(&${name}.length));\n` +
+ `}`;
+
+ strandsContext.computeDeclarations.add(bufDecl);
+ strandsContext.computeDeclarations.add(pushFn);
+ strandsContext.computeDeclarations.add(popFn);
+ strandsContext.computeDeclarations.add(lengthFn);
+ } else {
+ // In vertex/fragment shaders the same buffer is declared as a plain
+ // runtime-sized array. The atomic length bytes at the end of the buffer
+ // are beyond the last addressable element (floor(bufSize/stride) == maxCapacity),
+ // so they are never touched from the render side.
+ const dataDecl =
+ `${elementTypeDecl}` +
+ `@group(0) @binding(${bindingIndex}) var ${name}: array<${elementTypeName}>;`;
+ strandsContext.vertexDeclarations.add(dataDecl);
+ strandsContext.fragmentDeclarations.add(dataDecl);
+ }
+ },
+ _storageListDataAccess(generationContext, bufferExpr) {
+ if (generationContext.shaderContext !== 'compute') return bufferExpr;
+ const uniforms = generationContext.strandsContext?.uniforms;
+ if (!uniforms) return bufferExpr;
+ const uniform = uniforms.find(u => u.name === bufferExpr);
+ if (uniform?.typeInfo?.isStorageList) return `${bufferExpr}.data`;
+ return bufferExpr;
+ },
getTypeName(baseType, dimension) {
const primitiveTypeName = TypeNames[baseType + dimension];
if (!primitiveTypeName) {
@@ -322,7 +400,7 @@ 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
+ // For storage buffers/lists, 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
@@ -396,8 +474,9 @@ export const wgslBackend = {
const fieldSuffix = targetNode.identifier
? `.${targetNode.identifier}`
: '';
+ const accessExpr = this._storageListDataAccess(generationContext, bufferExpr);
generationContext.write(
- `${bufferExpr}[i32(${indexExpr})]${fieldSuffix} = ${sourceExpr}${semicolon}`
+ `${accessExpr}[i32(${indexExpr})]${fieldSuffix} = ${sourceExpr}${semicolon}`
);
return;
}
@@ -677,7 +756,8 @@ export const wgslBackend = {
indexID
);
const fieldSuffix = node.identifier ? `.${node.identifier}` : '';
- return `${bufferExpr}[i32(${indexExpr})]${fieldSuffix}`;
+ const accessExpr = this._storageListDataAccess(generationContext, bufferExpr);
+ return `${accessExpr}[i32(${indexExpr})]${fieldSuffix}`;
}
if (node.dependsOn.length === 2) {
const [lID, rID] = node.dependsOn;
diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js
index dd7ba1b742..ef51cb07b1 100644
--- a/test/unit/visual/cases/webgpu.js
+++ b/test/unit/visual/cases/webgpu.js
@@ -458,6 +458,117 @@ visualSuite('WebGPU', function () {
}
);
+ visualTest(
+ 'Writing to createStorageList() data',
+ async function (p5, screenshot) {
+ await p5.createCanvas(50, 50, p5.WEBGPU);
+
+ // 8 cells in a 2x4 grid. Left column cells become rects, right become circles.
+ // Positions are in p5's coordinate system (center origin).
+ const cellData = p5.createStorage([
+ { pos: [-12.5, -18.75] }, { pos: [12.5, -18.75] },
+ { pos: [-12.5, -6.25] }, { pos: [12.5, -6.25] },
+ { pos: [-12.5, 6.25] }, { pos: [12.5, 6.25] },
+ { pos: [-12.5, 18.75] }, { pos: [12.5, 18.75] }
+ ]);
+ const CELL_COUNT = 8;
+
+ const rectList = p5.createStorageList(CELL_COUNT, { pos: [0, 0] });
+ const circleList = p5.createStorageList(CELL_COUNT, { pos: [0, 0] });
+
+ // Classify cells by x position into separate lists so each draw
+ // call can use the GPU-side length via indirect draw.
+ const classifyShader = p5.buildComputeShader(
+ () => {
+ const cells = p5.uniformStorage('cells', cellData);
+ const rects = p5.uniformStorage('rects', rectList);
+ const circles = p5.uniformStorage('circles', circleList);
+ const i = p5.index.x;
+ if (cells[i].pos.x < 0) {
+ rects.push({ pos: cells[i].pos });
+ } else {
+ circles.push({ pos: cells[i].pos });
+ }
+ },
+ { p5, cellData, rectList, circleList }
+ );
+ p5.compute(classifyShader, CELL_COUNT);
+
+ const rectShader = p5.baseMaterialShader().modify(
+ () => {
+ const rects = p5.uniformStorage('rects', rectList);
+ p5.getWorldInputs(inputs => {
+ inputs.position.xy += rects[p5.instanceIndex].pos;
+ return inputs;
+ });
+ },
+ { p5, rectList }
+ );
+
+ const circleShader = p5.baseMaterialShader().modify(
+ () => {
+ const circles = p5.uniformStorage('circles', circleList);
+ p5.getWorldInputs(inputs => {
+ inputs.position.xy += circles[p5.instanceIndex].pos;
+ return inputs;
+ });
+ },
+ { p5, circleList }
+ );
+
+ p5.background(220);
+ p5.noStroke();
+
+ p5.fill(0, 0, 200);
+ p5.shader(rectShader);
+ p5.instances(rectList).rect(-10, -5, 20, 10);
+
+ p5.fill(0, 180, 0);
+ p5.shader(circleShader);
+ p5.instances(circleList).circle(0, 0, 10);
+
+ await screenshot();
+ }
+ );
+
+ visualTest(
+ 'instances() using createStorage() element count',
+ async function (p5, screenshot) {
+ await p5.createCanvas(50, 50, p5.WEBGPU);
+
+ const particles = p5.createStorage([
+ { pos: [-15, -15] },
+ { pos: [ 0, -15] },
+ { pos: [ 15, -15] },
+ { pos: [-15, 0] },
+ { pos: [ 0, 0] },
+ { pos: [ 15, 0] },
+ { pos: [-15, 15] },
+ { pos: [ 0, 15] },
+ { pos: [ 15, 15] }
+ ]);
+
+ const dotShader = p5.baseMaterialShader().modify(
+ () => {
+ const buf = p5.uniformStorage('buf', particles);
+ p5.getWorldInputs(inputs => {
+ inputs.position.xy += buf[p5.instanceIndex].pos;
+ return inputs;
+ });
+ },
+ { p5, particles }
+ );
+
+ p5.background(220);
+ p5.noStroke();
+ p5.fill(200, 50, 50);
+ p5.shader(dotShader);
+ p5.instances(particles).circle(0, 0, 8);
+
+ await screenshot();
+ }
+ );
+
visualTest(
'random() colors a basic shader (WebGPU)',
async function (p5, screenshot) {
diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/Writing to createStorageList() data/000.png b/test/unit/visual/screenshots/WebGPU/Shaders/Writing to createStorageList() data/000.png
new file mode 100644
index 0000000000..2bda6cb888
Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Shaders/Writing to createStorageList() data/000.png differ
diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/Writing to createStorageList() data/metadata.json b/test/unit/visual/screenshots/WebGPU/Shaders/Writing to createStorageList() data/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGPU/Shaders/Writing to createStorageList() data/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/instances() using createStorage() element count/000.png b/test/unit/visual/screenshots/WebGPU/Shaders/instances() using createStorage() element count/000.png
new file mode 100644
index 0000000000..b9972bde63
Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Shaders/instances() using createStorage() element count/000.png differ
diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/instances() using createStorage() element count/metadata.json b/test/unit/visual/screenshots/WebGPU/Shaders/instances() using createStorage() element count/metadata.json
new file mode 100644
index 0000000000..2d4bfe30da
--- /dev/null
+++ b/test/unit/visual/screenshots/WebGPU/Shaders/instances() using createStorage() element count/metadata.json
@@ -0,0 +1,3 @@
+{
+ "numScreenshots": 1
+}
\ No newline at end of file
diff --git a/test/unit/webgpu/p5.RendererWebGPU.js b/test/unit/webgpu/p5.RendererWebGPU.js
index 87b8ef6b5d..e1fd04eee6 100644
--- a/test/unit/webgpu/p5.RendererWebGPU.js
+++ b/test/unit/webgpu/p5.RendererWebGPU.js
@@ -269,6 +269,37 @@ suite('WebGPU p5.RendererWebGPU', function () {
});
});
+ suite('Compute dispatch', function () {
+ test('auto-spreading dispatches each index exactly once', async function () {
+ // 2500 > 1024 triggers 2D spreading (ceil(sqrt(2500))=50; so 50x50.)
+ // Each workgroup dimension is rounded up to the nearest multiple of 8,
+ // so extra threads are launched beyond px=50. The physicalId stride must
+ // use the actual dispatch width (56), not px (50), or threads wrap and
+ // collide, causing some indices to be written twice and others never.
+ const N = 2500;
+ const buf = myp5.createStorage(new Float32Array(N));
+
+ const shader = myp5.buildComputeShader(
+ () => {
+ const d = myp5.uniformStorage();
+ d[myp5.index.x] = myp5.index.x;
+ },
+ { myp5 }
+ );
+
+ shader.setUniform('d', buf);
+ myp5.compute(shader, N);
+
+ const result = await buf.read();
+
+ expect(result).to.be.instanceOf(Float32Array);
+ for (let i = 0; i < N; i++) {
+ expect(result[i]).to.be.closeTo(i, 0.001,
+ `index ${i} was not written exactly once`);
+ }
+ });
+ });
+
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]));
@@ -336,6 +367,207 @@ suite('WebGPU p5.RendererWebGPU', function () {
});
});
+ suite('StorageList', function () {
+ test('reads back float values pushed by a compute shader', async function () {
+ const src = myp5.createStorage(new Float32Array([10, 20, 30]));
+ const list = myp5.createStorageList(10);
+
+ const shader = myp5.buildComputeShader(
+ () => {
+ const s = myp5.uniformStorage('s', src);
+ const l = myp5.uniformStorage('l', list);
+ l.push(s[myp5.index.x]);
+ },
+ { myp5, src, list }
+ );
+ myp5.compute(shader, 3);
+
+ const result = await list.read();
+
+ expect(result).to.be.instanceOf(Float32Array);
+ expect(result.length).to.equal(3);
+ const values = new Set(Array.from(result).map(v => Math.round(v)));
+ expect(values.has(10)).to.be.true;
+ expect(values.has(20)).to.be.true;
+ expect(values.has(30)).to.be.true;
+ });
+
+ test('reads back struct values pushed by a compute shader', async function () {
+ const list = myp5.createStorageList(10, { x: 0.0 });
+
+ const shader = myp5.buildComputeShader(
+ () => {
+ const l = myp5.uniformStorage('l', list);
+ l.push({ x: 7.0 });
+ },
+ { myp5, list }
+ );
+ myp5.compute(shader, 4);
+
+ const result = await list.read();
+
+ expect(result).to.be.an('array');
+ expect(result.length).to.equal(4);
+ result.forEach(e => expect(e.x).to.be.closeTo(7.0, 0.001));
+ });
+
+ test('clear() resets the list length to zero', async function () {
+ const list = myp5.createStorageList(10);
+
+ const shader = myp5.buildComputeShader(
+ () => {
+ const l = myp5.uniformStorage('l', list);
+ l.push(1.0);
+ },
+ { myp5, list }
+ );
+ myp5.compute(shader, 5);
+ list.clear();
+
+ const result = await list.read();
+
+ expect(result.length).to.equal(0);
+ });
+
+ test('push beyond maxCapacity is silently clamped', async function () {
+ const list = myp5.createStorageList(3);
+
+ const shader = myp5.buildComputeShader(
+ () => {
+ const l = myp5.uniformStorage('l', list);
+ l.push(1.0);
+ },
+ { myp5, list }
+ );
+ myp5.compute(shader, 10);
+
+ const result = await list.read();
+
+ expect(result.length).to.equal(3);
+ });
+
+ test('pushing an integer-typed value (index.x) into a float list works', async function () {
+ const list = myp5.createStorageList(5);
+
+ const shader = myp5.buildComputeShader(
+ () => {
+ const l = myp5.uniformStorage('l', list);
+ l.push(myp5.index.x);
+ },
+ { myp5, list }
+ );
+ myp5.compute(shader, 3);
+
+ const result = await list.read();
+
+ expect(result.length).to.equal(3);
+ const values = new Set(Array.from(result).map(v => Math.round(v)));
+ expect(values.has(0)).to.be.true;
+ expect(values.has(1)).to.be.true;
+ expect(values.has(2)).to.be.true;
+ });
+ });
+
+ suite('StorageList.push() (CPU)', function () {
+ test('push a float and read it back', async function () {
+ const list = myp5.createStorageList(5);
+ list.push(42.0);
+
+ const result = await list.read();
+
+ expect(result).to.be.instanceOf(Float32Array);
+ expect(result.length).to.equal(1);
+ expect(result[0]).to.be.closeTo(42.0, 0.001);
+ });
+
+ test('push multiple floats and read them all back in order', async function () {
+ const list = myp5.createStorageList(5);
+ list.push(1.0);
+ list.push(2.0);
+ list.push(3.0);
+
+ const result = await list.read();
+
+ expect(result.length).to.equal(3);
+ expect(result[0]).to.be.closeTo(1.0, 0.001);
+ expect(result[1]).to.be.closeTo(2.0, 0.001);
+ expect(result[2]).to.be.closeTo(3.0, 0.001);
+ });
+
+ test('push a struct and read it back', async function () {
+ const list = myp5.createStorageList(5, { x: 0.0, y: 0.0 });
+ list.push({ x: 3.0, y: 7.0 });
+
+ const result = await list.read();
+
+ expect(result).to.be.an('array');
+ expect(result.length).to.equal(1);
+ expect(result[0].x).to.be.closeTo(3.0, 0.001);
+ expect(result[0].y).to.be.closeTo(7.0, 0.001);
+ });
+
+ test('push multiple structs and read them back in order', async function () {
+ const list = myp5.createStorageList(5, { x: 0.0, y: 0.0 });
+ list.push({ x: 1.0, y: 2.0 });
+ list.push({ x: 3.0, y: 4.0 });
+
+ const result = await list.read();
+
+ expect(result.length).to.equal(2);
+ expect(result[0].x).to.be.closeTo(1.0, 0.001);
+ expect(result[0].y).to.be.closeTo(2.0, 0.001);
+ expect(result[1].x).to.be.closeTo(3.0, 0.001);
+ expect(result[1].y).to.be.closeTo(4.0, 0.001);
+ });
+
+ test('throws when exceeding maxCapacity', function () {
+ const list = myp5.createStorageList(2);
+ list.push(1.0);
+ list.push(2.0);
+ expect(() => list.push(3.0)).to.throw();
+ });
+
+ test('throws when pushing a non-number to a float list', function () {
+ const list = myp5.createStorageList(5);
+ expect(() => list.push({ x: 1.0 })).to.throw();
+ });
+
+ test('clear() after CPU push resets length to zero', async function () {
+ const list = myp5.createStorageList(5);
+ list.push(1.0);
+ list.push(2.0);
+ list.clear();
+
+ const result = await list.read();
+
+ expect(result.length).to.equal(0);
+ });
+
+ test('CPU push is visible to a subsequent compute shader', async function () {
+ const list = myp5.createStorageList(10);
+ list.push(5.0);
+ list.push(10.0);
+
+ // Double every element already in the list
+ const shader = myp5.buildComputeShader(
+ () => {
+ const l = myp5.uniformStorage('l', list);
+ l.push(3.0);
+ },
+ { myp5, list }
+ );
+ myp5.compute(shader, 1);
+
+ const result = await list.read();
+
+ expect(result.length).to.equal(3);
+ const values = new Set(Array.from(result).map(v => Math.round(v)));
+ expect(values.has(5)).to.be.true;
+ expect(values.has(10)).to.be.true;
+ expect(values.has(3)).to.be.true;
+ });
+ });
+
suite('p5.strands', function () {
test('a uniform whose name matches a hook parameter name does not break', async function () {
myp5.pixelDensity(1);