From b91a4c5737e8da3f02fc887da3cbdd8fecd94673 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sat, 31 Jan 2026 10:18:58 -0500 Subject: [PATCH 1/8] No MSAA on main canvas, simpler blit shader --- src/webgpu/p5.RendererWebGPU.js | 16 +++++++++-- src/webgpu/shaders/blit.js | 48 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 src/webgpu/shaders/blit.js diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 5d14807460..a2643b6c98 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -6,6 +6,7 @@ import { colorVertexShader, colorFragmentShader } from './shaders/color'; import { lineVertexShader, lineFragmentShader} from './shaders/line'; import { materialVertexShader, materialFragmentShader } from './shaders/material'; import { fontVertexShader, fontFragmentShader } from './shaders/font'; +import { blitVertexShader, blitFragmentShader } from './shaders/blit'; import { wgslBackend } from './strands_wgslBackend'; import noiseWGSL from './shaders/functions/noise3DWGSL'; import { baseFilterVertexShader, baseFilterFragmentShader } from './shaders/filters/base'; @@ -381,7 +382,7 @@ function rendererWebGPU(p5, fn) { const requestedSampleCount = activeFramebuffer ? (activeFramebuffer.antialias ? activeFramebuffer.antialiasSamples : 1) : - (this.antialias || 1); + 1; // No MSAA needed when blitting already-antialiased textures to canvas const sampleCount = this._getValidSampleCount(requestedSampleCount); const depthFormat = activeFramebuffer && activeFramebuffer.useDepth ? @@ -1012,7 +1013,7 @@ function rendererWebGPU(p5, fn) { this.states.setValue('enableLighting', false); this.states.setValue('activeImageLight', null); this._pInst.setCamera(this.finalCamera); - this._pInst.resetShader(); + this._pInst.shader(this._getBlitShader()); this._pInst.resetMatrix(); this._pInst.imageMode(this._pInst.CENTER); this._pInst.image(this.mainFramebuffer, 0, 0); @@ -1619,6 +1620,17 @@ function rendererWebGPU(p5, fn) { return this._defaultFontShader; } + _getBlitShader() { + if (!this._defaultBlitShader) { + this._defaultBlitShader = new Shader( + this, + blitVertexShader, + blitFragmentShader + ); + } + return this._defaultBlitShader; + } + ////////////////////////////////////////////// // Setting ////////////////////////////////////////////// diff --git a/src/webgpu/shaders/blit.js b/src/webgpu/shaders/blit.js new file mode 100644 index 0000000000..806636b6dd --- /dev/null +++ b/src/webgpu/shaders/blit.js @@ -0,0 +1,48 @@ +const uniforms = ` +struct Uniforms { + uModelViewMatrix: mat4x4, + uProjectionMatrix: mat4x4, +}; +`; + +export const blitVertexShader = ` +struct VertexInput { + @location(0) aPosition: vec3, + @location(1) aNormal: vec3, + @location(2) aTexCoord: vec2, + @location(3) aVertexColor: vec4, +}; + +struct VertexOutput { + @builtin(position) Position: vec4, + @location(0) vTexCoord: vec2, +}; + +${uniforms} +@group(0) @binding(0) var uniforms: Uniforms; + +@vertex +fn main(input: VertexInput) -> VertexOutput { + var output: VertexOutput; + output.vTexCoord = input.aTexCoord; + let positionVec4 = vec4(input.aPosition, 1.0); + output.Position = uniforms.uProjectionMatrix * uniforms.uModelViewMatrix * positionVec4; + return output; +} +`; + +export const blitFragmentShader = ` +struct FragmentInput { + @location(0) vTexCoord: vec2, +}; + +${uniforms} +@group(0) @binding(0) var uniforms: Uniforms; +@group(0) @binding(1) var uSampler: texture_2d; +@group(0) @binding(2) var uSampler_sampler: sampler; + +@fragment +fn main(input: FragmentInput) -> @location(0) vec4 { + return textureSample(uSampler, uSampler_sampler, input.vTexCoord); +} +`; From 4cba57e0dda5b21cea02ed04b5991191b6fd12a3 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sat, 31 Jan 2026 10:38:22 -0500 Subject: [PATCH 2/8] Cache data views --- src/webgpu/p5.RendererWebGPU.js | 125 +++++++++++++++++++++----------- 1 file changed, 84 insertions(+), 41 deletions(-) diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index a2643b6c98..508e03f200 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -35,6 +35,10 @@ function rendererWebGPU(p5, fn) { this.samplers = new Map(); + // Cache for current frame's canvas texture view + this.currentCanvasColorTexture = null; + this.currentCanvasColorTextureView = null; + // Single reusable staging buffer for pixel reading this.pixelReadBuffer = null; this.pixelReadBufferSize = 0; @@ -160,6 +164,7 @@ function rendererWebGPU(p5, fn) { _updateSize() { if (this.depthTexture && this.depthTexture.destroy) { this.depthTexture.destroy(); + this.depthTextureView = null; } this.depthTexture = this.device.createTexture({ size: { @@ -170,11 +175,22 @@ function rendererWebGPU(p5, fn) { format: this.depthFormat, usage: GPUTextureUsage.RENDER_ATTACHMENT, }); + this.depthTextureView = this.depthTexture.createView(); // Clear the main canvas after resize this.clear(); } + _getCanvasColorTextureView() { + const canvasTexture = this.drawingContext.getCurrentTexture(); + // If texture changed (new frame), update cache + if (this.currentCanvasColorTexture !== canvasTexture) { + this.currentCanvasColorTexture = canvasTexture; + this.currentCanvasColorTextureView = canvasTexture.createView(); + } + return this.currentCanvasColorTextureView; + } + clear(...args) { const _r = args[0] || 0; const _g = args[1] || 0; @@ -185,25 +201,28 @@ function rendererWebGPU(p5, fn) { // Use framebuffer texture if active, otherwise use canvas texture const activeFramebuffer = this.activeFramebuffer(); - const colorTexture = activeFramebuffer ? - (activeFramebuffer.aaColorTexture || activeFramebuffer.colorTexture) : - this.drawingContext.getCurrentTexture(); const colorAttachment = { - view: colorTexture.createView(), + view: activeFramebuffer + ? (activeFramebuffer.aaColorTexture + ? activeFramebuffer.aaColorTextureView + : activeFramebuffer.colorTextureView) + : this._getCanvasColorTextureView(), clearValue: { r: _r * _a, g: _g * _a, b: _b * _a, a: _a }, loadOp: 'clear', storeOp: 'store', // If using multisampled texture, resolve to non-multisampled texture - resolveTarget: activeFramebuffer && activeFramebuffer.aaColorTexture ? - activeFramebuffer.colorTexture.createView() : undefined, + resolveTarget: activeFramebuffer && activeFramebuffer.aaColorTexture + ? activeFramebuffer.colorTextureView + : undefined, }; // Use framebuffer depth texture if active, otherwise use canvas depth texture - const depthTexture = activeFramebuffer ? - (activeFramebuffer.aaDepthTexture || activeFramebuffer.depthTexture) : - this.depthTexture; - const depthTextureView = depthTexture?.createView(); + const depthTextureView = activeFramebuffer + ? (activeFramebuffer.aaDepthTexture + ? activeFramebuffer.aaDepthTextureView + : activeFramebuffer.depthTextureView) + : this.depthTextureView; const depthAttachment = depthTextureView ? { view: depthTextureView, @@ -238,10 +257,11 @@ function rendererWebGPU(p5, fn) { const activeFramebuffer = this.activeFramebuffer(); // Use framebuffer depth texture if active, otherwise use canvas depth texture - const depthTexture = activeFramebuffer ? - (activeFramebuffer.aaDepthTexture || activeFramebuffer.depthTexture) : - this.depthTexture; - const depthTextureView = depthTexture?.createView(); + const depthTextureView = activeFramebuffer + ? (activeFramebuffer.aaDepthTexture + ? activeFramebuffer.aaDepthTextureView + : activeFramebuffer.depthTextureView) + : this.depthTextureView; if (!depthTextureView) { // No depth buffer to clear @@ -786,7 +806,7 @@ function rendererWebGPU(p5, fn) { } const commandEncoder = this.device.createCommandEncoder(); - const depthTextureView = this.depthTexture?.createView(); + const depthTextureView = this.depthTextureView; const depthAttachment = depthTextureView ? { view: depthTextureView, @@ -974,6 +994,10 @@ function rendererWebGPU(p5, fn) { // Submit the commands this.queue.submit(commandsToSubmit); + + // Reset canvas texture cache for next frame + this.currentCanvasColorTexture = null; + this.currentCanvasColorTextureView = null; } } @@ -1059,24 +1083,27 @@ function rendererWebGPU(p5, fn) { // Use framebuffer texture if active, otherwise use canvas texture const activeFramebuffer = this.activeFramebuffer(); - const colorTexture = activeFramebuffer ? - (activeFramebuffer.aaColorTexture || activeFramebuffer.colorTexture) : - this.drawingContext.getCurrentTexture(); const colorAttachment = { - view: colorTexture.createView(), + view: activeFramebuffer + ? (activeFramebuffer.aaColorTexture + ? activeFramebuffer.aaColorTextureView + : activeFramebuffer.colorTextureView) + : this._getCanvasColorTextureView(), loadOp: "load", storeOp: "store", // If using multisampled texture, resolve to non-multisampled texture - resolveTarget: activeFramebuffer && activeFramebuffer.aaColorTexture ? - activeFramebuffer.colorTexture.createView() : undefined, + resolveTarget: activeFramebuffer && activeFramebuffer.aaColorTexture + ? activeFramebuffer.colorTextureView + : undefined, }; // Use framebuffer depth texture if active, otherwise use canvas depth texture - const depthTexture = activeFramebuffer ? - (activeFramebuffer.aaDepthTexture || activeFramebuffer.depthTexture) : - this.depthTexture; - const depthTextureView = depthTexture?.createView(); + const depthTextureView = activeFramebuffer + ? (activeFramebuffer.aaDepthTexture + ? activeFramebuffer.aaDepthTextureView + : activeFramebuffer.depthTextureView) + : this.depthTextureView; const renderPassDescriptor = { colorAttachments: [colorAttachment], depthStencilAttachment: depthTextureView @@ -1643,16 +1670,18 @@ function rendererWebGPU(p5, fn) { const commandEncoder = this.device.createCommandEncoder(); const activeFramebuffer = this.activeFramebuffer(); - const depthTexture = activeFramebuffer ? - (activeFramebuffer.aaDepthTexture || activeFramebuffer.depthTexture) : - this.depthTexture; + const depthTextureView = activeFramebuffer + ? (activeFramebuffer.aaDepthTexture + ? activeFramebuffer.aaDepthTextureView + : activeFramebuffer.depthTextureView) + : this.depthTextureView; - if (!depthTexture) { + if (!depthTextureView) { return; } const depthStencilAttachment = { - view: depthTexture.createView(), + view: depthTextureView, stencilLoadOp: 'clear', stencilStoreOp: 'store', stencilClearValue: 0, @@ -1682,16 +1711,18 @@ function rendererWebGPU(p5, fn) { const commandEncoder = this.device.createCommandEncoder(); const activeFramebuffer = this.activeFramebuffer(); - const depthTexture = activeFramebuffer ? - (activeFramebuffer.aaDepthTexture || activeFramebuffer.depthTexture) : - this.depthTexture; + const depthTextureView = activeFramebuffer + ? (activeFramebuffer.aaDepthTexture + ? activeFramebuffer.aaDepthTextureView + : activeFramebuffer.depthTextureView) + : this.depthTextureView; - if (!depthTexture) { + if (!depthTextureView) { return; } const depthStencilAttachment = { - view: depthTexture.createView(), + view: depthTextureView, stencilLoadOp: 'clear', stencilStoreOp: 'store', stencilClearValue: 1, @@ -2145,15 +2176,19 @@ function rendererWebGPU(p5, fn) { // Clean up existing textures if (framebuffer.colorTexture && framebuffer.colorTexture.destroy) { framebuffer.colorTexture.destroy(); + framebuffer.colorTextureView = null; } if (framebuffer.aaColorTexture && framebuffer.aaColorTexture.destroy) { framebuffer.aaColorTexture.destroy(); + framebuffer.aaColorTextureView = null; } if (framebuffer.depthTexture && framebuffer.depthTexture.destroy) { framebuffer.depthTexture.destroy(); + framebuffer.depthTextureView = null; } if (framebuffer.aaDepthTexture && framebuffer.aaDepthTexture.destroy) { framebuffer.aaDepthTexture.destroy(); + framebuffer.aaDepthTextureView = null; } const baseDescriptor = { @@ -2172,6 +2207,7 @@ function rendererWebGPU(p5, fn) { sampleCount: 1, }; framebuffer.colorTexture = this.device.createTexture(colorTextureDescriptor); + framebuffer.colorTextureView = framebuffer.colorTexture.createView(); // Create multisampled texture for rendering if antialiasing is enabled if (framebuffer.antialias) { @@ -2181,6 +2217,7 @@ function rendererWebGPU(p5, fn) { sampleCount: this._getValidSampleCount(framebuffer.antialiasSamples), }; framebuffer.aaColorTexture = this.device.createTexture(aaColorTextureDescriptor); + framebuffer.aaColorTextureView = framebuffer.aaColorTexture.createView(); } if (framebuffer.useDepth) { @@ -2200,6 +2237,7 @@ function rendererWebGPU(p5, fn) { sampleCount: 1, }; framebuffer.depthTexture = this.device.createTexture(depthTextureDescriptor); + framebuffer.depthTextureView = framebuffer.depthTexture.createView(); // Create multisampled depth texture for rendering if antialiasing is enabled if (framebuffer.antialias) { @@ -2209,6 +2247,7 @@ function rendererWebGPU(p5, fn) { sampleCount: this._getValidSampleCount(framebuffer.antialiasSamples), }; framebuffer.aaDepthTexture = this.device.createTexture(aaDepthTextureDescriptor); + framebuffer.aaDepthTextureView = framebuffer.aaDepthTexture.createView(); } } @@ -2220,20 +2259,24 @@ function rendererWebGPU(p5, fn) { const commandEncoder = this.device.createCommandEncoder(); // Clear the color texture (and multisampled texture if it exists) - const colorTexture = framebuffer.aaColorTexture || framebuffer.colorTexture; const colorAttachment = { - view: colorTexture.createView(), + view: framebuffer.aaColorTexture + ? framebuffer.aaColorTextureView + : framebuffer.colorTextureView, loadOp: "clear", storeOp: "store", clearValue: { r: 0, g: 0, b: 0, a: 0 }, - resolveTarget: framebuffer.aaColorTexture ? - framebuffer.colorTexture.createView() : undefined, + resolveTarget: framebuffer.aaColorTexture + ? framebuffer.colorTextureView + : undefined, }; // Clear the depth texture if it exists const depthTexture = framebuffer.aaDepthTexture || framebuffer.depthTexture; const depthStencilAttachment = depthTexture ? { - view: depthTexture.createView(), + view: framebuffer.aaDepthTexture + ? framebuffer.aaDepthTextureView + : framebuffer.depthTextureView, depthLoadOp: "clear", depthStoreOp: "store", depthClearValue: 1.0, @@ -2257,7 +2300,7 @@ function rendererWebGPU(p5, fn) { _getFramebufferColorTextureView(framebuffer) { if (framebuffer.colorTexture) { - return framebuffer.colorTexture.createView(); + return framebuffer.colorTextureView; } return null; } From 606b135872b0610e4aadf141eddfae4a139c9d4a Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sat, 31 Jan 2026 20:57:27 -0500 Subject: [PATCH 3/8] Only use a main canvas framebuffer when necessary --- src/webgl/p5.Framebuffer.js | 1 + src/webgpu/p5.RendererWebGPU.js | 159 +++++++++++++++++++++++++------- 2 files changed, 128 insertions(+), 32 deletions(-) diff --git a/src/webgl/p5.Framebuffer.js b/src/webgl/p5.Framebuffer.js index 8f9e1ef259..3711eaaff4 100644 --- a/src/webgl/p5.Framebuffer.js +++ b/src/webgl/p5.Framebuffer.js @@ -62,6 +62,7 @@ class Framebuffer { this.renderer.framebuffers.add(this); this._isClipApplied = false; + this._useCanvasFormat = settings._useCanvasFormat || false; this.dirty = { colorTexture: false, depthTexture: false }; diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 508e03f200..fc66412595 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -62,6 +62,7 @@ function rendererWebGPU(p5, fn) { this._pixelReadCanvas = null; this._pixelReadCtx = null; this.mainFramebuffer = null; + this._framePromotedToFramebuffer = false; this.finalCamera = new Camera(this); this.finalCamera._computeCameraDefaultSettings(); @@ -107,7 +108,7 @@ function rendererWebGPU(p5, fn) { // TODO disablable stencil this.depthFormat = 'depth24plus-stencil8'; - this.mainFramebuffer = this.createFramebuffer(); + this.mainFramebuffer = this.createFramebuffer({ _useCanvasFormat: true }); this._updateSize(); this._update(); } @@ -801,7 +802,8 @@ function rendererWebGPU(p5, fn) { } _resetBuffersBeforeDraw() { - if (!this.activeFramebuffer()) { + // Only use mainFramebuffer if promotion has occurred + if (!this.activeFramebuffer() && this._framePromotedToFramebuffer) { this.mainFramebuffer.begin(); } const commandEncoder = this.device.createCommandEncoder(); @@ -830,6 +832,64 @@ function rendererWebGPU(p5, fn) { this._hasPendingDraws = true; } + /** + * Promotes the current frame to use mainFramebuffer. + * Copies current canvas content to mainFramebuffer, then switches to rendering there. + * @private + */ + _promoteToFramebuffer() { + // Already promoted this frame + if (this._framePromotedToFramebuffer) { + return; + } + + // Already drawing to a custom framebuffer, no promotion needed + if (this.activeFramebuffer()) { + return; + } + + // Flush any pending draws to canvas first + this.flushDraw(); + + // Mark as promoted + this._framePromotedToFramebuffer = true; + + // Get current canvas texture + const canvasTexture = this.drawingContext.getCurrentTexture(); + + // Ensure mainFramebuffer matches canvas size + if (this.mainFramebuffer.width !== this.width || + this.mainFramebuffer.height !== this.height) { + this.mainFramebuffer.resize(this.width, this.height); + } + + // Copy canvas texture to mainFramebuffer + const commandEncoder = this.device.createCommandEncoder(); + commandEncoder.copyTextureToTexture( + { + texture: canvasTexture, + origin: { x: 0, y: 0, z: 0 }, + mipLevel: 0, + }, + { + texture: this.mainFramebuffer.colorTexture, + origin: { x: 0, y: 0, z: 0 }, + mipLevel: 0, + }, + { + width: Math.ceil(this.width * this._pixelDensity), + height: Math.ceil(this.height * this._pixelDensity), + depthOrArrayLayers: 1, + } + ); + + this._pendingCommandEncoders.push(commandEncoder.finish()); + this._hasPendingDraws = true; + + // Activate mainFramebuffer for subsequent draws + this.mainFramebuffer.begin(); + } + ////////////////////////////////////////////// // Geometry buffer pool management ////////////////////////////////////////////// @@ -1025,24 +1085,29 @@ function rendererWebGPU(p5, fn) { async finishDraw() { this.flushDraw(); + const states = []; - while (this.activeFramebuffers.length > 0) { - const fbo = this.activeFramebuffers.pop(); - states.unshift({ fbo, diff: { ...this.states } }); - } - this.flushDraw(); - // this._pInst.background('red'); - this._pInst.push(); - this.states.setValue('enableLighting', false); - this.states.setValue('activeImageLight', null); - this._pInst.setCamera(this.finalCamera); - this._pInst.shader(this._getBlitShader()); - this._pInst.resetMatrix(); - this._pInst.imageMode(this._pInst.CENTER); - this._pInst.image(this.mainFramebuffer, 0, 0); - this._pInst.pop(); - this.flushDraw(); + // Only blit if we promoted to framebuffer this frame + if (this._framePromotedToFramebuffer) { + while (this.activeFramebuffers.length > 0) { + const fbo = this.activeFramebuffers.pop(); + states.unshift({ fbo, diff: { ...this.states } }); + } + this.flushDraw(); + + // this._pInst.background('red'); + this._pInst.push(); + this.states.setValue('enableLighting', false); + this.states.setValue('activeImageLight', null); + this._pInst.setCamera(this.finalCamera); + this._pInst.shader(this._getBlitShader()); + this._pInst.resetMatrix(); + this._pInst.imageMode(this._pInst.CENTER); + this._pInst.image(this.mainFramebuffer, 0, 0); + this._pInst.pop(); + this.flushDraw(); + } // Return all uniform buffers to their pools this._returnUniformBuffersToPool(); @@ -1063,12 +1128,19 @@ function rendererWebGPU(p5, fn) { } this._retiredBuffers = []; - for (const { fbo, diff } of states) { - fbo.begin(); - for (const key in diff) { - this.states.setValue(key, diff[key]); + if (this._framePromotedToFramebuffer) { + for (const { fbo, diff } of states) { + if (fbo !== this.mainFramebuffer || !this._framePromotedToFramebuffer) { + fbo.begin(); + } + for (const key in diff) { + this.states.setValue(key, diff[key]); + } } } + + // Reset promotion state for next frame + this._framePromotedToFramebuffer = false; } ////////////////////////////////////////////// @@ -2203,7 +2275,10 @@ function rendererWebGPU(p5, fn) { // Create non-multisampled texture for texture binding (always needed) const colorTextureDescriptor = { ...baseDescriptor, - usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC, + usage: GPUTextureUsage.RENDER_ATTACHMENT | + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_SRC | + (framebuffer._useCanvasFormat ? GPUTextureUsage.COPY_DST : 0), sampleCount: 1, }; framebuffer.colorTexture = this.device.createTexture(colorTextureDescriptor); @@ -2324,6 +2399,11 @@ function rendererWebGPU(p5, fn) { } else if (framebuffer.format === constants.HALF_FLOAT) { return framebuffer.channels === RGBA ? 'rgba16float' : 'rgba16float'; } else { + // Framebuffer with _useCanvasFormat should match canvas presentation format + if (framebuffer._useCanvasFormat) { + return this.presentationFormat; + } + // Other framebuffers use standard RGBA format return framebuffer.channels === RGBA ? 'rgba8unorm' : 'rgba8unorm'; } } @@ -2413,13 +2493,13 @@ function rendererWebGPU(p5, fn) { const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); // If alignment was needed, extract the actual pixel data + let result; if (alignedBytesPerRow === unalignedBytesPerRow) { - const result = new Uint8Array(mappedRange.slice(0, width * height * bytesPerPixel)); + result = new Uint8Array(mappedRange.slice(0, width * height * bytesPerPixel)); stagingBuffer.unmap(); - return result; } else { // Need to extract pixel data from aligned buffer - const result = new Uint8Array(width * height * bytesPerPixel); + result = new Uint8Array(width * height * bytesPerPixel); const mappedData = new Uint8Array(mappedRange); for (let y = 0; y < height; y++) { const srcOffset = y * alignedBytesPerRow; @@ -2427,8 +2507,14 @@ function rendererWebGPU(p5, fn) { result.set(mappedData.subarray(srcOffset, srcOffset + unalignedBytesPerRow), dstOffset); } stagingBuffer.unmap(); - return result; } + + // Convert BGRA to RGBA if reading from canvas-format framebuffer on BGRA systems + if (framebuffer._useCanvasFormat && this.presentationFormat === 'bgra8unorm') { + this._convertBGRtoRGB(result); + } + + return result; } async readFramebufferPixel(framebuffer, x, y) { @@ -2556,25 +2642,34 @@ function rendererWebGPU(p5, fn) { ////////////////////////////////////////////// _convertBGRtoRGB(pixelData) { - // Convert BGR to RGB by swapping red and blue channels for (let i = 0; i < pixelData.length; i += 4) { - const temp = pixelData[i]; // Store red - pixelData[i] = pixelData[i + 2]; // Red = Blue - pixelData[i + 2] = temp; // Blue = Red - // Green (i + 1) and Alpha (i + 3) stay the same + const temp = pixelData[i]; + pixelData[i] = pixelData[i + 2]; + pixelData[i + 2] = temp; } return pixelData; } async loadPixels() { + this._promoteToFramebuffer(); await this.mainFramebuffer.loadPixels(); this.pixels = this.mainFramebuffer.pixels.slice(); } async get(x, y, w, h) { + this._promoteToFramebuffer(); return this.mainFramebuffer.get(x, y, w, h); } + filter(...args) { + // If no custom framebuffer is active, promote to mainFramebuffer + if (!this.activeFramebuffer()) { + this._promoteToFramebuffer(); + } + + return super.filter(...args); + } + getNoiseShaderSnippet() { return noiseWGSL; } From 8e99389c288b154646d459b09835eb3d99632982 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 1 Feb 2026 09:29:26 -0500 Subject: [PATCH 4/8] Make sure we flush commands before destroying textures --- src/core/main.js | 1 + src/webgpu/p5.RendererWebGPU.js | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/core/main.js b/src/core/main.js index 8e2d292f1d..e063a5e637 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -272,6 +272,7 @@ class p5 { if (this._accessibleOutputs.grid || this._accessibleOutputs.text) { this._updateAccsOutput(); } + this._renderer.finishSetup?.(); // Run `postsetup` hooks await this._runLifecycleHook('postsetup'); diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index fc66412595..f6d3ade66b 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -164,6 +164,7 @@ function rendererWebGPU(p5, fn) { _updateSize() { if (this.depthTexture && this.depthTexture.destroy) { + this.flushDraw(); this.depthTexture.destroy(); this.depthTextureView = null; } @@ -886,8 +887,17 @@ function rendererWebGPU(p5, fn) { this._pendingCommandEncoders.push(commandEncoder.finish()); this._hasPendingDraws = true; + // Save current transformation state + const savedModelMatrix = this.states.uModelMatrix.copy(); + + // Copy current camera state to framebuffer's camera + this.mainFramebuffer.defaultCamera.set(this.states.curCamera); + // Activate mainFramebuffer for subsequent draws this.mainFramebuffer.begin(); + + // Restore transformation state + this.states.uModelMatrix.set(savedModelMatrix); } ////////////////////////////////////////////// @@ -1048,7 +1058,7 @@ function rendererWebGPU(p5, fn) { // Only submit if we actually had any draws if (this._hasPendingDraws) { // Create a copy of pending command encoders - const commandsToSubmit = this._pendingCommandEncoders.slice(); + const commandsToSubmit = this._pendingCommandEncoders; this._pendingCommandEncoders = []; this._hasPendingDraws = false; @@ -1083,6 +1093,10 @@ function rendererWebGPU(p5, fn) { this.flushDraw(); } + finishSetup() { + this.flushDraw(); + } + async finishDraw() { this.flushDraw(); From d7acc844a6a648b44a5ffc9d0245fa3dd1dcbdb5 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 1 Feb 2026 09:59:32 -0500 Subject: [PATCH 5/8] Fix not all BGR textures getting updated to RGB, make sure depth texture is updated too --- src/webgpu/p5.RendererWebGPU.js | 57 +++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index f6d3ade66b..ccfe7d9472 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -175,7 +175,7 @@ function rendererWebGPU(p5, fn) { depthOrArrayLayers: 1, }, format: this.depthFormat, - usage: GPUTextureUsage.RENDER_ATTACHMENT, + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC, }); this.depthTextureView = this.depthTexture.createView(); @@ -864,8 +864,10 @@ function rendererWebGPU(p5, fn) { this.mainFramebuffer.resize(this.width, this.height); } - // Copy canvas texture to mainFramebuffer + // Copy canvas textures to mainFramebuffer const commandEncoder = this.device.createCommandEncoder(); + + // Copy color texture commandEncoder.copyTextureToTexture( { texture: canvasTexture, @@ -884,19 +886,36 @@ function rendererWebGPU(p5, fn) { } ); + // Copy depth texture + commandEncoder.copyTextureToTexture( + { + texture: this.depthTexture, + origin: { x: 0, y: 0, z: 0 }, + mipLevel: 0, + }, + { + texture: this.mainFramebuffer.depthTexture, + origin: { x: 0, y: 0, z: 0 }, + mipLevel: 0, + }, + { + width: Math.ceil(this.width * this._pixelDensity), + height: Math.ceil(this.height * this._pixelDensity), + depthOrArrayLayers: 1, + } + ); + this._pendingCommandEncoders.push(commandEncoder.finish()); this._hasPendingDraws = true; - // Save current transformation state + // We want to make sure the transformation state is the same + // once we're drawing to the framebuffer, because normally + // those are reset. const savedModelMatrix = this.states.uModelMatrix.copy(); - - // Copy current camera state to framebuffer's camera this.mainFramebuffer.defaultCamera.set(this.states.curCamera); - // Activate mainFramebuffer for subsequent draws this.mainFramebuffer.begin(); - // Restore transformation state this.states.uModelMatrix.set(savedModelMatrix); } @@ -2322,7 +2341,9 @@ function rendererWebGPU(p5, fn) { // Create non-multisampled depth texture for texture binding (always needed) const depthTextureDescriptor = { ...depthBaseDescriptor, - usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, + usage: GPUTextureUsage.RENDER_ATTACHMENT | + GPUTextureUsage.TEXTURE_BINDING | + (framebuffer._useCanvasFormat ? GPUTextureUsage.COPY_DST : 0), sampleCount: 1, }; framebuffer.depthTexture = this.device.createTexture(depthTextureDescriptor); @@ -2423,6 +2444,9 @@ function rendererWebGPU(p5, fn) { } _getWebGPUDepthFormat(framebuffer) { + if (framebuffer._useCanvasFormat) { + return this.depthFormat; + } if (framebuffer.useStencil) { return framebuffer.depthFormat === constants.FLOAT ? 'depth32float-stencil8' : 'depth24plus-stencil8'; } else { @@ -2523,10 +2547,7 @@ function rendererWebGPU(p5, fn) { stagingBuffer.unmap(); } - // Convert BGRA to RGBA if reading from canvas-format framebuffer on BGRA systems - if (framebuffer._useCanvasFormat && this.presentationFormat === 'bgra8unorm') { - this._convertBGRtoRGB(result); - } + this._ensurePixelsAreRGBA(framebuffer, result); return result; } @@ -2559,6 +2580,9 @@ function rendererWebGPU(p5, fn) { const pixelData = new Uint8Array(mappedRange); const result = [pixelData[0], pixelData[1], pixelData[2], pixelData[3]]; + + this._ensurePixelsAreRGBA(framebuffer, result); + stagingBuffer.unmap(); return result; } @@ -2611,6 +2635,7 @@ function rendererWebGPU(p5, fn) { pixelData.set(mappedData.subarray(srcOffset, srcOffset + unalignedBytesPerRow), dstOffset); } } + this._ensurePixelsAreRGBA(framebuffer, pixelData); // WebGPU doesn't need vertical flipping unlike WebGL const region = new Image(width, height); @@ -2655,13 +2680,19 @@ function rendererWebGPU(p5, fn) { // Main canvas pixel methods ////////////////////////////////////////////// + _ensurePixelsAreRGBA(framebuffer, result) { + // Convert BGRA to RGBA if reading from canvas-format framebuffer on BGRA systems + if (framebuffer._useCanvasFormat && this.presentationFormat === 'bgra8unorm') { + this._convertBGRtoRGB(result); + } + } + _convertBGRtoRGB(pixelData) { for (let i = 0; i < pixelData.length; i += 4) { const temp = pixelData[i]; pixelData[i] = pixelData[i + 2]; pixelData[i + 2] = temp; } - return pixelData; } async loadPixels() { From 113e4bfa001a9590dbb6eafe2c6c3b2123fcca82 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 1 Feb 2026 10:34:14 -0500 Subject: [PATCH 6/8] Fix most tests --- src/core/main.js | 1 - src/webgpu/p5.RendererWebGPU.js | 52 ++++++++++++++++++++++++--------- vitest.workspace.mjs | 2 +- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/src/core/main.js b/src/core/main.js index e063a5e637..8e2d292f1d 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -272,7 +272,6 @@ class p5 { if (this._accessibleOutputs.grid || this._accessibleOutputs.text) { this._updateAccsOutput(); } - this._renderer.finishSetup?.(); // Run `postsetup` hooks await this._runLifecycleHook('postsetup'); diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index ccfe7d9472..07aed13a1b 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -55,6 +55,9 @@ function rendererWebGPU(p5, fn) { this._hasPendingDraws = false; this._pendingCommandEncoders = []; + // Queue of callbacks to run after next submit (mainly for safe texture deletion) + this._postSubmitCallbacks = []; + // Retired buffers to destroy at end of frame this._retiredBuffers = []; @@ -111,6 +114,7 @@ function rendererWebGPU(p5, fn) { this.mainFramebuffer = this.createFramebuffer({ _useCanvasFormat: true }); this._updateSize(); this._update(); + this.flushDraw(); } async _setAttributes(key, value) { @@ -165,7 +169,8 @@ function rendererWebGPU(p5, fn) { _updateSize() { if (this.depthTexture && this.depthTexture.destroy) { this.flushDraw(); - this.depthTexture.destroy(); + const textureToDestroy = this.depthTexture; + this._postSubmitCallbacks.push(() => textureToDestroy.destroy()); this.depthTextureView = null; } this.depthTexture = this.device.createTexture({ @@ -1084,6 +1089,17 @@ function rendererWebGPU(p5, fn) { // Submit the commands this.queue.submit(commandsToSubmit); + // Execute post-submit callbacks after GPU work completes + if (this._postSubmitCallbacks.length > 0) { + const callbacks = this._postSubmitCallbacks; + this._postSubmitCallbacks = []; + this.device.queue.onSubmittedWorkDone().then(() => { + for (const callback of callbacks) { + callback(); + } + }); + } + // Reset canvas texture cache for next frame this.currentCanvasColorTexture = null; this.currentCanvasColorTextureView = null; @@ -1112,10 +1128,6 @@ function rendererWebGPU(p5, fn) { this.flushDraw(); } - finishSetup() { - this.flushDraw(); - } - async finishDraw() { this.flushDraw(); @@ -1652,7 +1664,7 @@ function rendererWebGPU(p5, fn) { bindTextureToShader(_texture, _sampler, _uniformName, _unit) {} deleteTexture({ gpuTexture }) { - gpuTexture.destroy(); + this._postSubmitCallbacks.push(() => gpuTexture.destroy()); } _getLightShader() { @@ -2212,6 +2224,7 @@ function rendererWebGPU(p5, fn) { if (!this.pixelReadBuffer || this.pixelReadBufferSize < requiredSize) { // Clean up old buffer if (this.pixelReadBuffer) { + this.flushDraw(); this.pixelReadBuffer.destroy(); } @@ -2278,21 +2291,26 @@ function rendererWebGPU(p5, fn) { } recreateFramebufferTextures(framebuffer) { + this.flushDraw(); // Clean up existing textures if (framebuffer.colorTexture && framebuffer.colorTexture.destroy) { - framebuffer.colorTexture.destroy(); + const tex = framebuffer.colorTexture; + this._postSubmitCallbacks.push(() => tex.destroy()); framebuffer.colorTextureView = null; } if (framebuffer.aaColorTexture && framebuffer.aaColorTexture.destroy) { - framebuffer.aaColorTexture.destroy(); + const tex = framebuffer.aaColorTexture; + this._postSubmitCallbacks.push(() => tex.destroy()); framebuffer.aaColorTextureView = null; } if (framebuffer.depthTexture && framebuffer.depthTexture.destroy) { - framebuffer.depthTexture.destroy(); + const tex = framebuffer.depthTexture; + this._postSubmitCallbacks.push(() => tex.destroy()); framebuffer.depthTextureView = null; } if (framebuffer.aaDepthTexture && framebuffer.aaDepthTexture.destroy) { - framebuffer.aaDepthTexture.destroy(); + const tex = framebuffer.aaDepthTexture; + this._postSubmitCallbacks.push(() => tex.destroy()); framebuffer.aaDepthTextureView = null; } @@ -2455,9 +2473,11 @@ function rendererWebGPU(p5, fn) { } _deleteFramebufferTexture(texture) { + this.flushDraw(); const handle = texture.rawTexture(); if (handle.texture && handle.texture.destroy) { - handle.texture.destroy(); + const tex = handle.texture; + this._postSubmitCallbacks.push(() => tex.destroy()); } this.textures.delete(texture); } @@ -2468,14 +2488,18 @@ function rendererWebGPU(p5, fn) { } deleteFramebufferResources(framebuffer) { + this.flushDraw(); if (framebuffer.colorTexture && framebuffer.colorTexture.destroy) { - framebuffer.colorTexture.destroy(); + const tex = framebuffer.colorTexture; + this._postSubmitCallbacks.push(() => tex.destroy()); } if (framebuffer.depthTexture && framebuffer.depthTexture.destroy) { - framebuffer.depthTexture.destroy(); + const tex = framebuffer.depthTexture; + this._postSubmitCallbacks.push(() => tex.destroy()); } if (framebuffer.aaDepthTexture && framebuffer.aaDepthTexture.destroy) { - framebuffer.aaDepthTexture.destroy(); + const tex = framebuffer.aaDepthTexture; + this._postSubmitCallbacks.push(() => tex.destroy()); } } diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 08b80a2917..d5b5698da5 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -87,7 +87,7 @@ export default defineWorkspace([ // './test/unit/visual/cases/webgpu.js', './test/types/**/*' ], - testTimeout: 1000, + testTimeout: 10000, globals: true, browser: { enabled: true, From d26e80405f4c96e3dedecdee4125a31be7ea1e70 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 1 Feb 2026 10:56:46 -0500 Subject: [PATCH 7/8] Fix double-reading in tests. Accumulation is broken though. --- src/webgpu/p5.RendererWebGPU.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 07aed13a1b..570746fb4f 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -2517,7 +2517,8 @@ function rendererWebGPU(p5, fn) { } async readFramebufferPixels(framebuffer) { - await this.finishDraw(); + this.flushDraw(); + // await this.finishDraw(); // Ensure all pending GPU work is complete before reading pixels // await this.queue.onSubmittedWorkDone(); @@ -2577,7 +2578,8 @@ function rendererWebGPU(p5, fn) { } async readFramebufferPixel(framebuffer, x, y) { - await this.finishDraw(); + this.flushDraw(); + // await this.finishDraw(); // Ensure all pending GPU work is complete before reading pixels // await this.queue.onSubmittedWorkDone(); @@ -2612,7 +2614,8 @@ function rendererWebGPU(p5, fn) { } async readFramebufferRegion(framebuffer, x, y, w, h) { - await this.finishDraw(); + this.flushDraw(); + // await this.finishDraw(); // const wasActive = this.activeFramebuffer() === framebuffer; // if (wasActive) { // framebuffer.end(); From 718698e25ea3d79e14292678c73949134d6fb0c7 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 1 Feb 2026 11:33:24 -0500 Subject: [PATCH 8/8] Introduce a PENDING state to make accumulation still work, even though it's slower --- src/webgpu/p5.RendererWebGPU.js | 99 +++++++++++++++++++++++---------- 1 file changed, 69 insertions(+), 30 deletions(-) diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 570746fb4f..46db2b3925 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -12,6 +12,12 @@ import noiseWGSL from './shaders/functions/noise3DWGSL'; import { baseFilterVertexShader, baseFilterFragmentShader } from './shaders/filters/base'; import { imageLightVertexShader, imageLightDiffusedFragmentShader, imageLightSpecularFragmentShader } from './shaders/imageLight'; +const FRAME_STATE = { + PENDING: 0, + UNPROMOTED: 1, + PROMOTED: 2 +}; + function rendererWebGPU(p5, fn) { const { lineDefs } = getStrokeDefs((n, v, t) => `const ${n}: ${t} = ${v};\n`); @@ -65,7 +71,7 @@ function rendererWebGPU(p5, fn) { this._pixelReadCanvas = null; this._pixelReadCtx = null; this.mainFramebuffer = null; - this._framePromotedToFramebuffer = false; + this._frameState = FRAME_STATE.PENDING; this.finalCamera = new Camera(this); this.finalCamera._computeCameraDefaultSettings(); @@ -204,6 +210,11 @@ function rendererWebGPU(p5, fn) { const _b = args[2] || 0; const _a = args[3] || 0; + // If PENDING and no custom framebuffer, clear means stay UNPROMOTED + if (this._frameState === FRAME_STATE.PENDING && !this.activeFramebuffer()) { + this._frameState = FRAME_STATE.UNPROMOTED; + } + const commandEncoder = this.device.createCommandEncoder(); // Use framebuffer texture if active, otherwise use canvas texture @@ -808,34 +819,37 @@ function rendererWebGPU(p5, fn) { } _resetBuffersBeforeDraw() { - // Only use mainFramebuffer if promotion has occurred - if (!this.activeFramebuffer() && this._framePromotedToFramebuffer) { - this.mainFramebuffer.begin(); - } + // Set state to PENDING - we'll decide on first draw + this._frameState = FRAME_STATE.PENDING; + + // Clear depth buffer but DON'T start any render pass yet + const activeFramebuffer = this.activeFramebuffer(); const commandEncoder = this.device.createCommandEncoder(); - const depthTextureView = this.depthTextureView; - const depthAttachment = depthTextureView - ? { + const depthTextureView = activeFramebuffer + ? (activeFramebuffer.aaDepthTexture + ? activeFramebuffer.aaDepthTextureView + : activeFramebuffer.depthTextureView) + : this.depthTextureView; + + if (depthTextureView) { + const depthAttachment = { view: depthTextureView, depthClearValue: 1.0, depthLoadOp: 'clear', depthStoreOp: 'store', stencilLoadOp: 'load', - stencilStoreOp: "store", - } - : undefined; - - const renderPassDescriptor = { - colorAttachments: [], - ...(depthAttachment ? { depthStencilAttachment: depthAttachment } : {}), - }; - - const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); - passEncoder.end(); - - this._pendingCommandEncoders.push(commandEncoder.finish()); - this._hasPendingDraws = true; + stencilStoreOp: 'store', + }; + const renderPassDescriptor = { + colorAttachments: [], + depthStencilAttachment: depthAttachment, + }; + const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.end(); + this._pendingCommandEncoders.push(commandEncoder.finish()); + this._hasPendingDraws = true; + } } /** @@ -845,7 +859,7 @@ function rendererWebGPU(p5, fn) { */ _promoteToFramebuffer() { // Already promoted this frame - if (this._framePromotedToFramebuffer) { + if (this._frameState === FRAME_STATE.PROMOTED) { return; } @@ -858,7 +872,7 @@ function rendererWebGPU(p5, fn) { this.flushDraw(); // Mark as promoted - this._framePromotedToFramebuffer = true; + this._frameState = FRAME_STATE.PROMOTED; // Get current canvas texture const canvasTexture = this.drawingContext.getCurrentTexture(); @@ -924,6 +938,29 @@ function rendererWebGPU(p5, fn) { this.states.uModelMatrix.set(savedModelMatrix); } + _promoteToFramebufferWithoutCopy() { + // Ensure mainFramebuffer matches canvas size + if (this.mainFramebuffer.width !== this.width || + this.mainFramebuffer.height !== this.height) { + this.mainFramebuffer.resize(this.width, this.height); + } + + // Mark as promoted WITHOUT copying canvas content + this._frameState = FRAME_STATE.PROMOTED; + + // Flush any pending draws first + this.flushDraw(); + + // Preserve transformation state + const savedModelMatrix = this.states.uModelMatrix.copy(); + this.mainFramebuffer.defaultCamera.set(this.states.curCamera); + + // Begin rendering to mainFramebuffer + this.mainFramebuffer.begin(); + + this.states.uModelMatrix.set(savedModelMatrix); + } + ////////////////////////////////////////////// // Geometry buffer pool management ////////////////////////////////////////////// @@ -1134,7 +1171,7 @@ function rendererWebGPU(p5, fn) { const states = []; // Only blit if we promoted to framebuffer this frame - if (this._framePromotedToFramebuffer) { + if (this._frameState === FRAME_STATE.PROMOTED) { while (this.activeFramebuffers.length > 0) { const fbo = this.activeFramebuffers.pop(); states.unshift({ fbo, diff: { ...this.states } }); @@ -1173,9 +1210,9 @@ function rendererWebGPU(p5, fn) { } this._retiredBuffers = []; - if (this._framePromotedToFramebuffer) { + if (this._frameState === FRAME_STATE.PROMOTED) { for (const { fbo, diff } of states) { - if (fbo !== this.mainFramebuffer || !this._framePromotedToFramebuffer) { + if (fbo !== this.mainFramebuffer || this._frameState !== FRAME_STATE.PROMOTED) { fbo.begin(); } for (const key in diff) { @@ -1183,9 +1220,6 @@ function rendererWebGPU(p5, fn) { } } } - - // Reset promotion state for next frame - this._framePromotedToFramebuffer = false; } ////////////////////////////////////////////// @@ -1196,6 +1230,11 @@ function rendererWebGPU(p5, fn) { const buffers = this.geometryBufferCache.getCached(geometry); if (!buffers) return; + // If PENDING and no custom framebuffer, regular draw means PROMOTE + if (this._frameState === FRAME_STATE.PENDING && !this.activeFramebuffer()) { + this._promoteToFramebufferWithoutCopy(); + } + const commandEncoder = this.device.createCommandEncoder(); // Use framebuffer texture if active, otherwise use canvas texture