From c6b8aaf6eb81a6a68882985cea90b6fa7840ccfe Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 16 Aug 2026 09:02:03 -0400 Subject: [PATCH 01/14] Add initial storage buffer list class --- src/core/p5.Renderer3D.js | 25 +++ src/strands/strands_api.js | 120 ++++++++++++- src/webgl/3d_primitives.js | 31 ++-- src/webgpu/p5.RendererWebGPU.js | 285 +++++++++++++++++++++++++++++- src/webgpu/strands_wgslBackend.js | 114 ++++++++++-- 5 files changed, 541 insertions(+), 34 deletions(-) diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index 57fb3f55e3..3e7c3a7a4c 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -2344,6 +2344,31 @@ function renderer3D(p5, fn) { return this._renderer.createStorage(dataOrCount); }; + /** + * Creates a variable-length GPU buffer that compute shaders can push elements + * into atomically, and that can drive instanced draw calls without CPU readback. + * + * @method createStorageList + * @for p5 + * @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. * diff --git a/src/strands/strands_api.js b/src/strands/strands_api.js index da61d5290a..03e3fc5110 100644 --- a/src/strands/strands_api.js +++ b/src/strands/strands_api.js @@ -1051,6 +1051,109 @@ 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) { + 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 +1163,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 +1176,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 +1198,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 +1209,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..5578ab7fad 100644 --- a/src/webgl/3d_primitives.js +++ b/src/webgl/3d_primitives.js @@ -2697,26 +2697,35 @@ 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 (!isList) { + if (typeof count !== 'number' || !isFinite(count) || count < 1) { + p5._friendlyError( + 'instances() requires a positive integer count 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..afa4edfbf2 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -387,6 +387,110 @@ function rendererWebGPU(p5, fn) { */ p5.StorageBuffer = StorageBuffer; + class StorageList { + constructor(buffer, lengthOffset, maxCapacity, renderer, schema = null) { + this._isStorageList = true; + this.buffer = buffer; + this._lengthOffset = lengthOffset; + this.maxCapacity = maxCapacity; + this._renderer = renderer; + this._schema = schema; + } + + /** + * Empties the list. + * + * @method clear + * @for p5.StorageList + * @beta + * @webgpu + * @webgpuOnly + */ + clear() { + this._renderer.device.queue.writeBuffer( + this.buffer, + this._lengthOffset, + new Uint32Array([0]) + ); + } + + /** + * 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 +545,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 +1908,11 @@ function rendererWebGPU(p5, fn) { // Submit the commands this.queue.submit(commandsToSubmit); + for (const buf of this._tempBuffers) { + buf.destroy(); + } + this._tempBuffers = []; + for (const buf of this.activeUniformBuffers) { // buf.buffer = this.device.createBuffer({ // size: buf.size, @@ -1944,10 +2056,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 +2102,101 @@ 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. + const indirectBuffer = this.device.createBuffer({ + size: 20, // 5 u32s: indexCount/vertexCount, instanceCount, firstIndex/firstVertex, baseVertex, firstInstance + usage: GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST + }); + this._tempBuffers.push(indirectBuffer); + + const isIndexed = + !!buffers.indexBuffer && currentShader.shaderType !== 'stroke'; + + if (currentShader.shaderType === 'stroke') { + // drawIndirect: [vertexCount, instanceCount, firstVertex, firstInstance] + const vertexCount = geometry.lineVertices + ? geometry.lineVertices.length / 3 + : 0; + this.device.queue.writeBuffer( + indirectBuffer, + 0, + new Uint32Array([vertexCount, 0, 0, 0]) + ); + } else if (isIndexed) { + // drawIndexedIndirect: [indexCount, instanceCount, firstIndex, baseVertex, firstInstance] + this.device.queue.writeBuffer( + indirectBuffer, + 0, + new Uint32Array([geometry.faces.length * 3, 0, 0, 0, 0]) + ); + } else { + // drawIndirect: [vertexCount, instanceCount, firstVertex, firstInstance] + this.device.queue.writeBuffer( + indirectBuffer, + 0, + new Uint32Array([geometry.vertices.length, 0, 0, 0]) + ); + } + + // 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 +2341,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 +2678,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) @@ -4015,6 +4232,68 @@ ${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 + ); + this._storageBuffers.add(storageList); + return storageList; + } + _getWebGPUColorFormat(framebuffer) { if (framebuffer.format === constants.FLOAT) { return framebuffer.channels === RGBA ? 'rgba32float' : 'rgba32float'; 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; From 976d897ba813a5755918c0d64d4f348703a3149b Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 16 Aug 2026 09:14:42 -0400 Subject: [PATCH 02/14] Add tests --- test/unit/visual/cases/webgpu.js | 73 ++++++++++++++++ .../000.png | Bin 0 -> 764 bytes .../metadata.json | 3 + test/unit/webgpu/p5.RendererWebGPU.js | 80 ++++++++++++++++++ 4 files changed, 156 insertions(+) create mode 100644 test/unit/visual/screenshots/WebGPU/Shaders/Writing to createStorageList() data/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Shaders/Writing to createStorageList() data/metadata.json diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index dd7ba1b742..733cbe6a97 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -458,6 +458,79 @@ 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( '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 0000000000000000000000000000000000000000..2bda6cb8883c35775e9fef42a4f8f515e1265d02 GIT binary patch literal 764 zcmVV_%5Jd?CY22hqoytXSpu_APbQs-1htUmEY?V4q8aFBIeS(IVC3eZ7Fv8G=4Sz4i z84mvw4RN5Sd7j@sgPzju=X82a)AY|xm&^ZKW&7judP&ot>8jJ;Q$p6&8H5FF?kQ?o zHQfM!K|qKsHC7;&1&_$VFy^;v6GK}uJr@oFK+7m`^0e`CJOoH->Ymr(AXd}EpUC0) zU}dT1g`Wr@sOoYF9)BE{zx%)8AXsaeY{jWD1PCXBS%fJO^XH%ERL)CWKQqYj_?*t? z?`}FAzUh6MW6YxY*PJjf0;aimNys3}II+2>sCkbtk9yW-5YN_xT0JIoE+4Z<&soMR z5YMKOCe&g;axgFb7SEU8FUfMF%lZr=6A@1gPfODXjNX55UgyYo7!WbE^#pQjUeASt z@UCk^d*kx7%Dixp($L=P;X!&oX7@lnPh@Womw7JBTA^?dPNZeB6$daJ1QQrVj7wQS zS(8CLTN7$gyhnhDFps3)vea3Dcs7kRp%w!=M?eGs7#**TGe|AzBg8TYy^&|Hbx-5S zUBNAmxG0hJP0S!HU~^AV(<;m(U=R=@ON|wXWx*qIFpT-F+QiUSOwWab0MIf@oIGv3 z91j6fn!4w8IEdBs@F&u9SJa@xPXrKDGf9lglINbnK`_=b*@{zR2oO#Lvj|fn=4n^T zd5P<12BA0d?6rCuN6ezHFX-fWJX;fLN!{KjAG4@;UH;c%bI+!cCbY*4=IOOWpXCd> z>w7?J0vM3jiOQdRB9)wnhcH~*1ejW!SU5-v0+Xn9D&ZiQv}kw`ED&I-h{X`~6Y7|=Naf_Vgt uj@QQT00030|Lx;5Gynhq21!IgR09CxcAH4ZW(z3*0000 { + 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); + }); + }); + suite('p5.strands', function () { test('a uniform whose name matches a hook parameter name does not break', async function () { myp5.pixelDensity(1); From 77fc29918b57aa96b986b6345adb04ccbc302d42 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 16 Aug 2026 09:19:51 -0400 Subject: [PATCH 03/14] Let you pass a StorageBuffer to instances() too --- src/webgl/3d_primitives.js | 11 +++-- test/unit/visual/cases/webgpu.js | 38 ++++++++++++++++++ .../000.png | Bin 0 -> 507 bytes .../metadata.json | 3 ++ 4 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 test/unit/visual/screenshots/WebGPU/Shaders/instances() using createStorage() element count/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Shaders/instances() using createStorage() element count/metadata.json diff --git a/src/webgl/3d_primitives.js b/src/webgl/3d_primitives.js index 5578ab7fad..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 @@ -2699,10 +2700,14 @@ function primitives3D(p5, fn) { 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 or a StorageList. Clamping to 1.', + 'instances() requires a positive integer count, a StorageBuffer, or a StorageList. Clamping to 1.', 'instances' ); count = 1; diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index 733cbe6a97..ef51cb07b1 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -531,6 +531,44 @@ visualSuite('WebGPU', function () { } ); + 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/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 0000000000000000000000000000000000000000..b9972bde63062f518ee4c8a48aeb67cf7ae2a2c2 GIT binary patch literal 507 zcmV@pEX5n{$*IUkSiwB5R&)vB-G{odi+GZ470q*vZrr_IKl4~JWBfb{1s zfVY$6O@CcMF9ktplF=y*9;GWS#Tx}O>=G8gFBLYImxA1fYyC3?nJyMI``2qw>@khfOMIPG(m_DYbLaoU5F_EHcvZYvESN>^HnHwr|J)1ffAyc7h1 zD%Q(OQxFV@?AwNe Date: Sun, 16 Aug 2026 09:44:31 -0400 Subject: [PATCH 04/14] Add webgpu build back to rolldown config --- rolldown.config.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/rolldown.config.js b/rolldown.config.js index 3bc9e19d88..b77936b86a 100644 --- a/rolldown.config.js +++ b/rolldown.config.js @@ -70,6 +70,27 @@ export default defineConfig([ // }) ] }, + //// Addon module builds (e.g. WebGPU) //// + ...['webgpu'].map(module => ({ + input: `src/${module}/index.js`, + output: [ + { + file: `./lib/p5.${module}.js`, + format: 'iife' + }, + { + file: `./lib/p5.${module}.min.js`, + format: 'iife', + minify: true + }, + { + file: `./lib/p5.${module}.esm.js`, + format: 'esm' + } + ], + external: ['../core/main'], + plugins + })), //// ESM source build //// { input: Object.fromEntries( From 796709d661ba49afef4b148fe69524876e759ae0 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 16 Aug 2026 09:57:41 -0400 Subject: [PATCH 05/14] Add type casting if they don't match --- src/strands/strands_api.js | 8 +++++++- test/unit/webgpu/p5.RendererWebGPU.js | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/strands/strands_api.js b/src/strands/strands_api.js index 03e3fc5110..d8d680f1f0 100644 --- a/src/strands/strands_api.js +++ b/src/strands/strands_api.js @@ -1092,7 +1092,13 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { // Float list const val = element; if (val?.isStrandsNode) { - argID = val.id; + 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, diff --git a/test/unit/webgpu/p5.RendererWebGPU.js b/test/unit/webgpu/p5.RendererWebGPU.js index a4307e9311..452e407ef3 100644 --- a/test/unit/webgpu/p5.RendererWebGPU.js +++ b/test/unit/webgpu/p5.RendererWebGPU.js @@ -414,6 +414,27 @@ suite('WebGPU p5.RendererWebGPU', function () { 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('p5.strands', function () { From 200bafae47365214e9f4b9ac7dd2abce468e60a6 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 16 Aug 2026 10:16:15 -0400 Subject: [PATCH 06/14] Try destroying only after the submit is complete --- src/webgpu/p5.RendererWebGPU.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index afa4edfbf2..6e23171c51 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -1908,10 +1908,15 @@ function rendererWebGPU(p5, fn) { // Submit the commands this.queue.submit(commandsToSubmit); - for (const buf of this._tempBuffers) { - buf.destroy(); - } + 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({ From f4a560c8f952f0d0dcf5cd3493e8c4759f9d504e Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 16 Aug 2026 13:21:08 -0400 Subject: [PATCH 07/14] Submti compute jobs the same as other draw jobs --- src/webgpu/p5.RendererWebGPU.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 6e23171c51..a608f4b2be 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -4823,7 +4823,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; } } From 5408ade52770d0797ed038ed94763ce8374af6a6 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 16 Aug 2026 13:23:20 -0400 Subject: [PATCH 08/14] Make sure clearing also uses the queue --- src/webgpu/p5.RendererWebGPU.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index a608f4b2be..deccc1186a 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -407,11 +407,10 @@ function rendererWebGPU(p5, fn) { * @webgpuOnly */ clear() { - this._renderer.device.queue.writeBuffer( - this.buffer, - this._lengthOffset, - new Uint32Array([0]) - ); + const encoder = this._renderer.device.createCommandEncoder(); + encoder.clearBuffer(this.buffer, this._lengthOffset, 4); + this._renderer._pendingCommandEncoders.push(encoder.finish()); + this._renderer._hasPendingDraws = true; } /** From 465d5443efc5fa69297fefe51420421fc77e16e6 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 16 Aug 2026 13:49:07 -0400 Subject: [PATCH 09/14] try mapAsync --- src/webgpu/p5.RendererWebGPU.js | 41 ++++++++++++++------------------- 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index deccc1186a..21d27cd159 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -2117,40 +2117,33 @@ function rendererWebGPU(p5, fn) { // indexCount and vertexCount are geometry-specific; write them to a // fresh indirect buffer so concurrent draws don't clobber each other. - const indirectBuffer = this.device.createBuffer({ - size: 20, // 5 u32s: indexCount/vertexCount, instanceCount, firstIndex/firstVertex, baseVertex, firstInstance - usage: GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST - }); - this._tempBuffers.push(indirectBuffer); - + // 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] - const vertexCount = geometry.lineVertices + // drawIndirect: [vertexCount, instanceCount, firstVertex, firstInstance, (padding)] + indirectInit[0] = geometry.lineVertices ? geometry.lineVertices.length / 3 : 0; - this.device.queue.writeBuffer( - indirectBuffer, - 0, - new Uint32Array([vertexCount, 0, 0, 0]) - ); } else if (isIndexed) { // drawIndexedIndirect: [indexCount, instanceCount, firstIndex, baseVertex, firstInstance] - this.device.queue.writeBuffer( - indirectBuffer, - 0, - new Uint32Array([geometry.faces.length * 3, 0, 0, 0, 0]) - ); + indirectInit[0] = geometry.faces.length * 3; } else { - // drawIndirect: [vertexCount, instanceCount, firstVertex, firstInstance] - this.device.queue.writeBuffer( - indirectBuffer, - 0, - new Uint32Array([geometry.vertices.length, 0, 0, 0]) - ); + // 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(); From 04d6d1f821c280206f9b1c5ef61a074c6ce2e1a6 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 16 Aug 2026 14:18:58 -0400 Subject: [PATCH 10/14] Fix bug where auto spreading creates duplicate threads --- src/webgpu/p5.RendererWebGPU.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 21d27cd159..aa9b70e5f1 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -4794,12 +4794,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, { From 4dfb672795f6cdbc0e2449b660d023d57dc9d132 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 16 Aug 2026 14:42:34 -0400 Subject: [PATCH 11/14] Add test for spreading bug + fix casting in another spot --- src/strands/ir_builders.js | 7 ++++-- src/webgpu/p5.RendererWebGPU.js | 7 +++--- test/unit/webgpu/p5.RendererWebGPU.js | 31 +++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) 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/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index aa9b70e5f1..bdf676322d 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; } @@ -4176,7 +4177,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; } @@ -4221,7 +4222,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); diff --git a/test/unit/webgpu/p5.RendererWebGPU.js b/test/unit/webgpu/p5.RendererWebGPU.js index 452e407ef3..3658362474 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])); From 47c3be168314ddc3edceb010ca9d5fa80d30b9c6 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 16 Aug 2026 15:49:46 -0400 Subject: [PATCH 12/14] Add CPU version of push and docs --- src/core/p5.Renderer3D.js | 159 +++++++++++++++++++++++- src/webgpu/p5.RendererWebGPU.js | 169 +++++++++++++++++++++++++- test/unit/webgpu/p5.RendererWebGPU.js | 100 +++++++++++++++ 3 files changed, 424 insertions(+), 4 deletions(-) diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index 3e7c3a7a4c..ecb15b9c0c 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -2345,8 +2345,163 @@ function renderer3D(p5, fn) { }; /** - * Creates a variable-length GPU buffer that compute shaders can push elements - * into atomically, and that can drive instanced draw calls without CPU readback. + * 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 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); + * } + * ``` + * + * 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 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); + * } + * ``` * * @method createStorageList * @for p5 diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index bdf676322d..7472786f3c 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -389,13 +389,15 @@ function rendererWebGPU(p5, fn) { p5.StorageBuffer = StorageBuffer; class StorageList { - constructor(buffer, lengthOffset, maxCapacity, renderer, schema = null) { + 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; } /** @@ -408,12 +410,174 @@ function rendererWebGPU(p5, fn) { * @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. * @@ -4286,7 +4450,8 @@ ${hookUniformFields}} lengthOffset, maxCapacity, this, - schema + schema, + initialCount ); this._storageBuffers.add(storageList); return storageList; diff --git a/test/unit/webgpu/p5.RendererWebGPU.js b/test/unit/webgpu/p5.RendererWebGPU.js index 3658362474..e1fd04eee6 100644 --- a/test/unit/webgpu/p5.RendererWebGPU.js +++ b/test/unit/webgpu/p5.RendererWebGPU.js @@ -468,6 +468,106 @@ suite('WebGPU p5.RendererWebGPU', function () { }); }); + 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); From aa9cea7aad588c434a37355d8d7aea770152a570 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 16 Aug 2026 15:57:10 -0400 Subject: [PATCH 13/14] Fix examples ordering, mark as being in the strands section --- src/core/p5.Renderer3D.js | 136 +++++++++++++++++++------------------- 1 file changed, 68 insertions(+), 68 deletions(-) diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index ecb15b9c0c..283fff172f 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -2364,73 +2364,6 @@ function renderer3D(p5, fn) { * to draw one instance per item in the list: * * ```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); - * } - * ``` - * - * 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 particles, nextParticles; // Data * let removeOld, emitNew; // Compute * let drawParticles; // Rendering @@ -2503,8 +2436,75 @@ function renderer3D(p5, fn) { * } * ``` * + * 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 - * @for p5 + * @submodule p5.strands * @beta * @webgpu * @webgpuOnly From 3fcddd8dc4f0d15b4e6a42796eabb4fcd312f401 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 16 Aug 2026 16:02:43 -0400 Subject: [PATCH 14/14] Reference storage lists in more spots --- src/core/p5.Renderer3D.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index 283fff172f..56c40e5510 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -2571,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`. *