From ca5f9c6c4db0b8e8746fa03e9d33ebc3922a6745 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Mon, 28 Jul 2025 11:04:11 -0400 Subject: [PATCH 01/26] Refactor framebuffers --- src/image/pixels.js | 2 +- src/webgl/p5.Framebuffer.js | 540 ++------------------------ src/webgl/p5.RendererGL.js | 597 ++++++++++++++++++++++++++++- src/webgl/p5.Texture.js | 21 - src/webgl/utils.js | 21 + src/webgpu/p5.RendererWebGPU.js | 382 ++++++++++++++++++ test/unit/visual/cases/webgpu.js | 164 ++++++++ test/unit/webgl/p5.RendererGL.js | 3 +- test/unit/webgpu/p5.Framebuffer.js | 247 ++++++++++++ 9 files changed, 1454 insertions(+), 523 deletions(-) create mode 100644 test/unit/webgpu/p5.Framebuffer.js diff --git a/src/image/pixels.js b/src/image/pixels.js index ebea101273..6c2ea58115 100644 --- a/src/image/pixels.js +++ b/src/image/pixels.js @@ -933,7 +933,7 @@ function pixels(p5, fn){ */ fn.loadPixels = function(...args) { // p5._validateParameters('loadPixels', args); - this._renderer.loadPixels(); + return this._renderer.loadPixels(); }; /** diff --git a/src/webgl/p5.Framebuffer.js b/src/webgl/p5.Framebuffer.js index 0ebb3c0daa..af2ab279b5 100644 --- a/src/webgl/p5.Framebuffer.js +++ b/src/webgl/p5.Framebuffer.js @@ -5,11 +5,9 @@ import * as constants from '../core/constants'; import { RGB, RGBA } from '../color/creating_reading'; -import { checkWebGLCapabilities } from './p5.Texture'; -import { readPixelsWebGL, readPixelWebGL } from './utils'; +import { checkWebGLCapabilities } from './utils'; import { Camera } from './p5.Camera'; import { Texture } from './p5.Texture'; -import { Image } from '../image/p5.Image'; const constrain = (n, low, high) => Math.max(Math.min(n, high), low); @@ -52,7 +50,6 @@ class FramebufferTexture { } rawTexture() { - // TODO: handle webgpu texture handle return { texture: this.framebuffer[this.property] }; } } @@ -87,13 +84,11 @@ class Framebuffer { this.antialiasSamples = settings.antialias ? 2 : 0; } this.antialias = this.antialiasSamples > 0; - if (this.antialias && this.renderer.webglVersion !== constants.WEBGL2) { - console.warn('Antialiasing is unsupported in a WebGL 1 context'); + if (this.antialias && !this.renderer.supportsFramebufferAntialias()) { + console.warn('Framebuffer antialiasing is unsupported in this context'); this.antialias = false; } this.density = settings.density || this.renderer._pixelDensity; - const gl = this.renderer.GL; - this.gl = gl; if (settings.width && settings.height) { const dimensions = this.renderer._adjustDimensions(settings.width, settings.height); @@ -112,7 +107,8 @@ class Framebuffer { this.height = this.renderer.height; this._autoSized = true; } - this._checkIfFormatsAvailable(); + // Let renderer validate and adjust formats for this context + this.renderer.validateFramebufferFormats(this); if (settings.stencil && !this.useDepth) { console.warn('A stencil buffer can only be used if also using depth. Since the framebuffer has no depth buffer, the stencil buffer will be ignored.'); @@ -120,16 +116,8 @@ class Framebuffer { this.useStencil = this.useDepth && (settings.stencil === undefined ? true : settings.stencil); - this.framebuffer = gl.createFramebuffer(); - if (!this.framebuffer) { - throw new Error('Unable to create a framebuffer'); - } - if (this.antialias) { - this.aaFramebuffer = gl.createFramebuffer(); - if (!this.aaFramebuffer) { - throw new Error('Unable to create a framebuffer for antialiasing'); - } - } + // Let renderer create framebuffer resources with antialiasing support + this.renderer.createFramebufferResources(this); this._recreateTextures(); @@ -466,6 +454,10 @@ class Framebuffer { } } + _deleteTextures() { + this.renderer.deleteFramebufferTextures(this); + } + /** * Creates new textures and renderbuffers given the current size of the * framebuffer. @@ -473,117 +465,10 @@ class Framebuffer { * @private */ _recreateTextures() { - const gl = this.gl; - this._updateSize(); - const prevBoundTexture = gl.getParameter(gl.TEXTURE_BINDING_2D); - const prevBoundFramebuffer = gl.getParameter(gl.FRAMEBUFFER_BINDING); - - const colorTexture = gl.createTexture(); - if (!colorTexture) { - throw new Error('Unable to create color texture'); - } - gl.bindTexture(gl.TEXTURE_2D, colorTexture); - const colorFormat = this._glColorFormat(); - gl.texImage2D( - gl.TEXTURE_2D, - 0, - colorFormat.internalFormat, - this.width * this.density, - this.height * this.density, - 0, - colorFormat.format, - colorFormat.type, - null - ); - this.colorTexture = colorTexture; - gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer); - gl.framebufferTexture2D( - gl.FRAMEBUFFER, - gl.COLOR_ATTACHMENT0, - gl.TEXTURE_2D, - colorTexture, - 0 - ); - - if (this.useDepth) { - // Create the depth texture - const depthTexture = gl.createTexture(); - if (!depthTexture) { - throw new Error('Unable to create depth texture'); - } - const depthFormat = this._glDepthFormat(); - gl.bindTexture(gl.TEXTURE_2D, depthTexture); - gl.texImage2D( - gl.TEXTURE_2D, - 0, - depthFormat.internalFormat, - this.width * this.density, - this.height * this.density, - 0, - depthFormat.format, - depthFormat.type, - null - ); - - gl.framebufferTexture2D( - gl.FRAMEBUFFER, - this.useStencil ? gl.DEPTH_STENCIL_ATTACHMENT : gl.DEPTH_ATTACHMENT, - gl.TEXTURE_2D, - depthTexture, - 0 - ); - this.depthTexture = depthTexture; - } - - // Create separate framebuffer for antialiasing - if (this.antialias) { - this.colorRenderbuffer = gl.createRenderbuffer(); - gl.bindRenderbuffer(gl.RENDERBUFFER, this.colorRenderbuffer); - gl.renderbufferStorageMultisample( - gl.RENDERBUFFER, - Math.max( - 0, - Math.min(this.antialiasSamples, gl.getParameter(gl.MAX_SAMPLES)) - ), - colorFormat.internalFormat, - this.width * this.density, - this.height * this.density - ); - - if (this.useDepth) { - const depthFormat = this._glDepthFormat(); - this.depthRenderbuffer = gl.createRenderbuffer(); - gl.bindRenderbuffer(gl.RENDERBUFFER, this.depthRenderbuffer); - gl.renderbufferStorageMultisample( - gl.RENDERBUFFER, - Math.max( - 0, - Math.min(this.antialiasSamples, gl.getParameter(gl.MAX_SAMPLES)) - ), - depthFormat.internalFormat, - this.width * this.density, - this.height * this.density - ); - } - - gl.bindFramebuffer(gl.FRAMEBUFFER, this.aaFramebuffer); - gl.framebufferRenderbuffer( - gl.FRAMEBUFFER, - gl.COLOR_ATTACHMENT0, - gl.RENDERBUFFER, - this.colorRenderbuffer - ); - if (this.useDepth) { - gl.framebufferRenderbuffer( - gl.FRAMEBUFFER, - this.useStencil ? gl.DEPTH_STENCIL_ATTACHMENT : gl.DEPTH_ATTACHMENT, - gl.RENDERBUFFER, - this.depthRenderbuffer - ); - } - } + // Let renderer handle texture creation and framebuffer setup + this.renderer.recreateFramebufferTextures(this); if (this.useDepth) { this.depth = new FramebufferTexture(this, 'depthTexture'); @@ -612,131 +497,6 @@ class Framebuffer { } ); this.renderer.textures.set(this.color, this.colorP5Texture); - - gl.bindTexture(gl.TEXTURE_2D, prevBoundTexture); - gl.bindFramebuffer(gl.FRAMEBUFFER, prevBoundFramebuffer); - } - - /** - * To create a WebGL texture, one needs to supply three pieces of information: - * the type (the data type each channel will be stored as, e.g. int or float), - * the format (the color channels that will each be stored in the previously - * specified type, e.g. rgb or rgba), and the internal format (the specifics - * of how data for each channel, in the aforementioned type, will be packed - * together, such as how many bits to use, e.g. RGBA32F or RGB565.) - * - * The format and channels asked for by the user hint at what these values - * need to be, and the WebGL version affects what options are avaiable. - * This method returns the values for these three properties, given the - * framebuffer's settings. - * - * @private - */ - _glColorFormat() { - let type, format, internalFormat; - const gl = this.gl; - - if (this.format === constants.FLOAT) { - type = gl.FLOAT; - } else if (this.format === constants.HALF_FLOAT) { - type = this.renderer.webglVersion === constants.WEBGL2 - ? gl.HALF_FLOAT - : gl.getExtension('OES_texture_half_float').HALF_FLOAT_OES; - } else { - type = gl.UNSIGNED_BYTE; - } - - if (this.channels === RGBA) { - format = gl.RGBA; - } else { - format = gl.RGB; - } - - if (this.renderer.webglVersion === constants.WEBGL2) { - // https://webgl2fundamentals.org/webgl/lessons/webgl-data-textures.html - const table = { - [gl.FLOAT]: { - [gl.RGBA]: gl.RGBA32F - // gl.RGB32F is not available in Firefox without an alpha channel - }, - [gl.HALF_FLOAT]: { - [gl.RGBA]: gl.RGBA16F - // gl.RGB16F is not available in Firefox without an alpha channel - }, - [gl.UNSIGNED_BYTE]: { - [gl.RGBA]: gl.RGBA8, // gl.RGBA4 - [gl.RGB]: gl.RGB8 // gl.RGB565 - } - }; - internalFormat = table[type][format]; - } else if (this.format === constants.HALF_FLOAT) { - internalFormat = gl.RGBA; - } else { - internalFormat = format; - } - - return { internalFormat, format, type }; - } - - /** - * To create a WebGL texture, one needs to supply three pieces of information: - * the type (the data type each channel will be stored as, e.g. int or float), - * the format (the color channels that will each be stored in the previously - * specified type, e.g. rgb or rgba), and the internal format (the specifics - * of how data for each channel, in the aforementioned type, will be packed - * together, such as how many bits to use, e.g. RGBA32F or RGB565.) - * - * This method takes into account the settings asked for by the user and - * returns values for these three properties that can be used for the - * texture storing depth information. - * - * @private - */ - _glDepthFormat() { - let type, format, internalFormat; - const gl = this.gl; - - if (this.useStencil) { - if (this.depthFormat === constants.FLOAT) { - type = gl.FLOAT_32_UNSIGNED_INT_24_8_REV; - } else if (this.renderer.webglVersion === constants.WEBGL2) { - type = gl.UNSIGNED_INT_24_8; - } else { - type = gl.getExtension('WEBGL_depth_texture').UNSIGNED_INT_24_8_WEBGL; - } - } else { - if (this.depthFormat === constants.FLOAT) { - type = gl.FLOAT; - } else { - type = gl.UNSIGNED_INT; - } - } - - if (this.useStencil) { - format = gl.DEPTH_STENCIL; - } else { - format = gl.DEPTH_COMPONENT; - } - - if (this.useStencil) { - if (this.depthFormat === constants.FLOAT) { - internalFormat = gl.DEPTH32F_STENCIL8; - } else if (this.renderer.webglVersion === constants.WEBGL2) { - internalFormat = gl.DEPTH24_STENCIL8; - } else { - internalFormat = gl.DEPTH_STENCIL; - } - } else if (this.renderer.webglVersion === constants.WEBGL2) { - if (this.depthFormat === constants.FLOAT) { - internalFormat = gl.DEPTH_COMPONENT32F; - } else { - internalFormat = gl.DEPTH_COMPONENT24; - } - } else { - internalFormat = gl.DEPTH_COMPONENT; - } - - return { internalFormat, format, type }; } /** @@ -775,17 +535,7 @@ class Framebuffer { * @private */ _handleResize() { - const oldColor = this.color; - const oldDepth = this.depth; - const oldColorRenderbuffer = this.colorRenderbuffer; - const oldDepthRenderbuffer = this.depthRenderbuffer; - - this._deleteTexture(oldColor); - if (oldDepth) this._deleteTexture(oldDepth); - const gl = this.gl; - if (oldColorRenderbuffer) gl.deleteRenderbuffer(oldColorRenderbuffer); - if (oldDepthRenderbuffer) gl.deleteRenderbuffer(oldDepthRenderbuffer); - + this._deleteTextures(); this._recreateTextures(); this.defaultCamera._resize(); } @@ -913,20 +663,6 @@ class Framebuffer { return cam; } - /** - * Given a raw texture wrapper, delete its stored texture from WebGL memory, - * and remove it from p5's list of active textures. - * - * @param {p5.FramebufferTexture} texture - * @private - */ - _deleteTexture(texture) { - const gl = this.gl; - gl.deleteTexture(texture.rawTexture().texture); - - this.renderer.textures.delete(texture); - } - /** * Deletes the framebuffer from GPU memory. * @@ -996,19 +732,11 @@ class Framebuffer { * */ remove() { - const gl = this.gl; - this._deleteTexture(this.color); - if (this.depth) this._deleteTexture(this.depth); - gl.deleteFramebuffer(this.framebuffer); - if (this.aaFramebuffer) { - gl.deleteFramebuffer(this.aaFramebuffer); - } - if (this.depthRenderbuffer) { - gl.deleteRenderbuffer(this.depthRenderbuffer); - } - if (this.colorRenderbuffer) { - gl.deleteRenderbuffer(this.colorRenderbuffer); - } + this._deleteTextures(); + + // Let renderer clean up framebuffer resources + this.renderer.deleteFramebufferResources(this); + this.renderer.framebuffers.delete(this); } @@ -1095,14 +823,7 @@ class Framebuffer { * @private */ _framebufferToBind() { - if (this.antialias) { - // If antialiasing, draw to an antialiased renderbuffer rather - // than directly to the texture. In end() we will copy from the - // renderbuffer to the texture. - return this.aaFramebuffer; - } else { - return this.framebuffer; - } + return this.renderer.getFramebufferToBind(this); } /** @@ -1111,45 +832,9 @@ class Framebuffer { * @property {'colorTexutre'|'depthTexture'} property The property to update */ _update(property) { - if (this.dirty[property] && this.antialias) { - const gl = this.gl; - gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.aaFramebuffer); - gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.framebuffer); - const partsToCopy = { - colorTexture: [ - gl.COLOR_BUFFER_BIT, - // TODO: move to renderer - this.colorP5Texture.magFilter === constants.LINEAR ? gl.LINEAR : gl.NEAREST - ], - }; - if (this.useDepth) { - partsToCopy.depthTexture = [ - gl.DEPTH_BUFFER_BIT, - // TODO: move to renderer - this.depthP5Texture.magFilter === constants.LINEAR ? gl.LINEAR : gl.NEAREST - ]; - } - const [flag, filter] = partsToCopy[property]; - gl.blitFramebuffer( - 0, - 0, - this.width * this.density, - this.height * this.density, - 0, - 0, - this.width * this.density, - this.height * this.density, - flag, - filter - ); + if (this.dirty[property]) { + this.renderer.updateFramebufferTexture(this, property); this.dirty[property] = false; - - const activeFbo = this.renderer.activeFramebuffer(); - if (activeFbo) { - gl.bindFramebuffer(gl.FRAMEBUFFER, activeFbo._framebufferToBind()); - } else { - gl.bindFramebuffer(gl.FRAMEBUFFER, null); - } } } @@ -1159,8 +844,7 @@ class Framebuffer { * @private */ _beforeBegin() { - const gl = this.gl; - gl.bindFramebuffer(gl.FRAMEBUFFER, this._framebufferToBind()); + this.renderer.bindFramebuffer(this); this.renderer.viewport( this.width * this.density, this.height * this.density @@ -1236,7 +920,7 @@ class Framebuffer { if (this.prevFramebuffer) { this.prevFramebuffer._beforeBegin(); } else { - gl.bindFramebuffer(gl.FRAMEBUFFER, null); + this.renderer.bindFramebuffer(null); this.renderer.viewport( this.renderer._origViewport.width, this.renderer._origViewport.height @@ -1355,25 +1039,19 @@ class Framebuffer { */ loadPixels() { this._update('colorTexture'); - const gl = this.gl; - const prevFramebuffer = this.renderer.activeFramebuffer(); - gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer); - const colorFormat = this._glColorFormat(); - this.pixels = readPixelsWebGL( - this.pixels, - gl, - this.framebuffer, - 0, - 0, - this.width * this.density, - this.height * this.density, - colorFormat.format, - colorFormat.type - ); - if (prevFramebuffer) { - gl.bindFramebuffer(gl.FRAMEBUFFER, prevFramebuffer._framebufferToBind()); + const result = this.renderer.readFramebufferPixels(this); + + // Check if renderer returned a Promise (WebGPU) or data directly (WebGL) + if (result && typeof result.then === 'function') { + // WebGPU async case - return Promise + return result.then(pixels => { + this.pixels = pixels; + return pixels; + }); } else { - gl.bindFramebuffer(gl.FRAMEBUFFER, null); + // WebGL sync case - assign directly + this.pixels = result; + return result; } } @@ -1415,7 +1093,7 @@ class Framebuffer { get(x, y, w, h) { this._update('colorTexture'); // p5._validateParameters('p5.Framebuffer.get', arguments); - const colorFormat = this._glColorFormat(); + if (x === undefined && y === undefined) { x = 0; y = 0; @@ -1430,14 +1108,7 @@ class Framebuffer { y = constrain(y, 0, this.height - 1); } - return readPixelWebGL( - this.gl, - this.framebuffer, - x * this.density, - y * this.density, - colorFormat.format, - colorFormat.type - ); + return this.renderer.readFramebufferPixel(this, x * this.density, y * this.density); } x = constrain(x, 0, this.width - 1); @@ -1445,60 +1116,7 @@ class Framebuffer { w = constrain(w, 1, this.width - x); h = constrain(h, 1, this.height - y); - const rawData = readPixelsWebGL( - undefined, - this.gl, - this.framebuffer, - x * this.density, - y * this.density, - w * this.density, - h * this.density, - colorFormat.format, - colorFormat.type - ); - // Framebuffer data might be either a Uint8Array or Float32Array - // depending on its format, and it may or may not have an alpha channel. - // To turn it into an image, we have to normalize the data into a - // Uint8ClampedArray with alpha. - const fullData = new Uint8ClampedArray( - w * h * this.density * this.density * 4 - ); - - // Default channels that aren't in the framebuffer (e.g. alpha, if the - // framebuffer is in RGB mode instead of RGBA) to 255 - fullData.fill(255); - - const channels = colorFormat.type === this.gl.RGB ? 3 : 4; - for (let y = 0; y < h * this.density; y++) { - for (let x = 0; x < w * this.density; x++) { - for (let channel = 0; channel < 4; channel++) { - const idx = (y * w * this.density + x) * 4 + channel; - if (channel < channels) { - // Find the index of this pixel in `rawData`, which might have a - // different number of channels - const rawDataIdx = channels === 4 - ? idx - : (y * w * this.density + x) * channels + channel; - fullData[idx] = rawData[rawDataIdx]; - } - } - } - } - - // Create an image from the data - const region = new Image(w * this.density, h * this.density); - region.imageData = region.canvas.getContext('2d').createImageData( - region.width, - region.height - ); - region.imageData.data.set(fullData); - region.pixels = region.imageData.data; - region.updatePixels(); - if (this.density !== 1) { - // TODO: support get() at a pixel density > 1 - region.resize(w, h); - } - return region; + return this.renderer.readFramebufferRegion(this, x, y, w, h); } /** @@ -1550,85 +1168,9 @@ class Framebuffer { * */ updatePixels() { - const gl = this.gl; - this.colorP5Texture.bindTexture(); - const colorFormat = this._glColorFormat(); - - const channels = colorFormat.format === gl.RGBA ? 4 : 3; - const len = - this.width * this.height * this.density * this.density * channels; - const TypedArrayClass = colorFormat.type === gl.UNSIGNED_BYTE - ? Uint8Array - : Float32Array; - if ( - !(this.pixels instanceof TypedArrayClass) || this.pixels.length !== len - ) { - throw new Error( - 'The pixels array has not been set correctly. Please call loadPixels() before updatePixels().' - ); - } - - gl.texImage2D( - gl.TEXTURE_2D, - 0, - colorFormat.internalFormat, - this.width * this.density, - this.height * this.density, - 0, - colorFormat.format, - colorFormat.type, - this.pixels - ); - this.colorP5Texture.unbindTexture(); + // Let renderer handle the pixel update process + this.renderer.updateFramebufferPixels(this); this.dirty.colorTexture = false; - - const prevFramebuffer = this.renderer.activeFramebuffer(); - if (this.antialias) { - // We need to make sure the antialiased framebuffer also has the updated - // pixels so that if more is drawn to it, it goes on top of the updated - // pixels instead of replacing them. - // We can't blit the framebuffer to the multisampled antialias - // framebuffer to leave both in the same state, so instead we have - // to use image() to put the framebuffer texture onto the antialiased - // framebuffer. - this.begin(); - this.renderer.push(); - // this.renderer.imageMode(constants.CENTER); - this.renderer.states.setValue('imageMode', constants.CORNER); - this.renderer.setCamera(this.filterCamera); - this.renderer.resetMatrix(); - this.renderer.states.setValue('strokeColor', null); - this.renderer.clear(); - this.renderer._drawingFilter = true; - this.renderer.image( - this, - 0, 0, - this.width, this.height, - -this.renderer.width / 2, -this.renderer.height / 2, - this.renderer.width, this.renderer.height - ); - this.renderer._drawingFilter = false; - this.renderer.pop(); - if (this.useDepth) { - gl.clearDepth(1); - gl.clear(gl.DEPTH_BUFFER_BIT); - } - this.end(); - } else { - gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer); - if (this.useDepth) { - gl.clearDepth(1); - gl.clear(gl.DEPTH_BUFFER_BIT); - } - if (prevFramebuffer) { - gl.bindFramebuffer( - gl.FRAMEBUFFER, - prevFramebuffer._framebufferToBind() - ); - } else { - gl.bindFramebuffer(gl.FRAMEBUFFER, null); - } - } } } diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index 9025c0d31a..5a9482baa2 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -6,14 +6,17 @@ import { readPixelsWebGL, readPixelWebGL, setWebGLTextureParams, - setWebGLUniformValue + setWebGLUniformValue, + checkWebGLCapabilities } from './utils'; import { Renderer3D, getStrokeDefs } from "../core/p5.Renderer3D"; import { Shader } from "./p5.Shader"; import { Texture, MipmapTexture } from "./p5.Texture"; import { Framebuffer } from "./p5.Framebuffer"; import { Graphics } from "../core/p5.Graphics"; +import { RGB, RGBA } from '../color/creating_reading'; import { Element } from "../dom/p5.Element"; +import { Image } from '../image/p5.Image'; import filterBaseVert from "./shaders/filters/base.vert"; import lightingShader from "./shaders/lighting.glsl"; @@ -1386,6 +1389,598 @@ class RendererGL extends Renderer3D { populateHooks(shader, src, shaderType) { return populateGLSLHooks(shader, src, shaderType); } + + ////////////////////////////////////////////// + // Framebuffer methods + ////////////////////////////////////////////// + + supportsFramebufferAntialias() { + return this.webglVersion === constants.WEBGL2; + } + + createFramebufferResources(framebuffer) { + const gl = this.GL; + + framebuffer.framebuffer = gl.createFramebuffer(); + if (!framebuffer.framebuffer) { + throw new Error('Unable to create a framebuffer'); + } + + if (framebuffer.antialias) { + framebuffer.aaFramebuffer = gl.createFramebuffer(); + if (!framebuffer.aaFramebuffer) { + throw new Error('Unable to create a framebuffer for antialiasing'); + } + } + } + + validateFramebufferFormats(framebuffer) { + const gl = this.GL; + + if ( + framebuffer.useDepth && + this.webglVersion === constants.WEBGL && + !gl.getExtension('WEBGL_depth_texture') + ) { + console.warn( + 'Unable to create depth textures in this environment. Falling back ' + + 'to a framebuffer without depth.' + ); + framebuffer.useDepth = false; + } + + if ( + framebuffer.useDepth && + this.webglVersion === constants.WEBGL && + framebuffer.depthFormat === constants.FLOAT + ) { + console.warn( + 'FLOAT depth format is unavailable in WebGL 1. ' + + 'Defaulting to UNSIGNED_INT.' + ); + framebuffer.depthFormat = constants.UNSIGNED_INT; + } + + if (![ + constants.UNSIGNED_BYTE, + constants.FLOAT, + constants.HALF_FLOAT + ].includes(framebuffer.format)) { + console.warn( + 'Unknown Framebuffer format. ' + + 'Please use UNSIGNED_BYTE, FLOAT, or HALF_FLOAT. ' + + 'Defaulting to UNSIGNED_BYTE.' + ); + framebuffer.format = constants.UNSIGNED_BYTE; + } + if (framebuffer.useDepth && ![ + constants.UNSIGNED_INT, + constants.FLOAT + ].includes(framebuffer.depthFormat)) { + console.warn( + 'Unknown Framebuffer depth format. ' + + 'Please use UNSIGNED_INT or FLOAT. Defaulting to FLOAT.' + ); + framebuffer.depthFormat = constants.FLOAT; + } + + const support = checkWebGLCapabilities(this); + if (!support.float && framebuffer.format === constants.FLOAT) { + console.warn( + 'This environment does not support FLOAT textures. ' + + 'Falling back to UNSIGNED_BYTE.' + ); + framebuffer.format = constants.UNSIGNED_BYTE; + } + if ( + framebuffer.useDepth && + !support.float && + framebuffer.depthFormat === constants.FLOAT + ) { + console.warn( + 'This environment does not support FLOAT depth textures. ' + + 'Falling back to UNSIGNED_INT.' + ); + framebuffer.depthFormat = constants.UNSIGNED_INT; + } + if (!support.halfFloat && framebuffer.format === constants.HALF_FLOAT) { + console.warn( + 'This environment does not support HALF_FLOAT textures. ' + + 'Falling back to UNSIGNED_BYTE.' + ); + framebuffer.format = constants.UNSIGNED_BYTE; + } + + if ( + framebuffer.channels === RGB && + [constants.FLOAT, constants.HALF_FLOAT].includes(framebuffer.format) + ) { + console.warn( + 'FLOAT and HALF_FLOAT formats do not work cross-platform with only ' + + 'RGB channels. Falling back to RGBA.' + ); + framebuffer.channels = RGBA; + } + } + + recreateFramebufferTextures(framebuffer) { + const gl = this.GL; + + const prevBoundTexture = gl.getParameter(gl.TEXTURE_BINDING_2D); + const prevBoundFramebuffer = gl.getParameter(gl.FRAMEBUFFER_BINDING); + + const colorTexture = gl.createTexture(); + if (!colorTexture) { + throw new Error('Unable to create color texture'); + } + gl.bindTexture(gl.TEXTURE_2D, colorTexture); + const colorFormat = this._getFramebufferColorFormat(framebuffer); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + colorFormat.internalFormat, + framebuffer.width * framebuffer.density, + framebuffer.height * framebuffer.density, + 0, + colorFormat.format, + colorFormat.type, + null + ); + framebuffer.colorTexture = colorTexture; + gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer.framebuffer); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + colorTexture, + 0 + ); + + if (framebuffer.useDepth) { + // Create the depth texture + const depthTexture = gl.createTexture(); + if (!depthTexture) { + throw new Error('Unable to create depth texture'); + } + const depthFormat = this._getFramebufferDepthFormat(framebuffer); + gl.bindTexture(gl.TEXTURE_2D, depthTexture); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + depthFormat.internalFormat, + framebuffer.width * framebuffer.density, + framebuffer.height * framebuffer.density, + 0, + depthFormat.format, + depthFormat.type, + null + ); + + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + framebuffer.useStencil ? gl.DEPTH_STENCIL_ATTACHMENT : gl.DEPTH_ATTACHMENT, + gl.TEXTURE_2D, + depthTexture, + 0 + ); + framebuffer.depthTexture = depthTexture; + } + + // Create separate framebuffer for antialiasing + if (framebuffer.antialias) { + framebuffer.colorRenderbuffer = gl.createRenderbuffer(); + gl.bindRenderbuffer(gl.RENDERBUFFER, framebuffer.colorRenderbuffer); + gl.renderbufferStorageMultisample( + gl.RENDERBUFFER, + Math.max( + 0, + Math.min(framebuffer.antialiasSamples, gl.getParameter(gl.MAX_SAMPLES)) + ), + colorFormat.internalFormat, + framebuffer.width * framebuffer.density, + framebuffer.height * framebuffer.density + ); + + if (framebuffer.useDepth) { + const depthFormat = this._getFramebufferDepthFormat(framebuffer); + framebuffer.depthRenderbuffer = gl.createRenderbuffer(); + gl.bindRenderbuffer(gl.RENDERBUFFER, framebuffer.depthRenderbuffer); + gl.renderbufferStorageMultisample( + gl.RENDERBUFFER, + Math.max( + 0, + Math.min(framebuffer.antialiasSamples, gl.getParameter(gl.MAX_SAMPLES)) + ), + depthFormat.internalFormat, + framebuffer.width * framebuffer.density, + framebuffer.height * framebuffer.density + ); + } + + gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer.aaFramebuffer); + gl.framebufferRenderbuffer( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.RENDERBUFFER, + framebuffer.colorRenderbuffer + ); + if (framebuffer.useDepth) { + gl.framebufferRenderbuffer( + gl.FRAMEBUFFER, + framebuffer.useStencil ? gl.DEPTH_STENCIL_ATTACHMENT : gl.DEPTH_ATTACHMENT, + gl.RENDERBUFFER, + framebuffer.depthRenderbuffer + ); + } + } + + gl.bindTexture(gl.TEXTURE_2D, prevBoundTexture); + gl.bindFramebuffer(gl.FRAMEBUFFER, prevBoundFramebuffer); + } + + /** + * To create a WebGL texture, one needs to supply three pieces of information: + * the type (the data type each channel will be stored as, e.g. int or float), + * the format (the color channels that will each be stored in the previously + * specified type, e.g. rgb or rgba), and the internal format (the specifics + * of how data for each channel, in the aforementioned type, will be packed + * together, such as how many bits to use, e.g. RGBA32F or RGB565.) + * + * The format and channels asked for by the user hint at what these values + * need to be, and the WebGL version affects what options are avaiable. + * This method returns the values for these three properties, given the + * framebuffer's settings. + * + * @private + */ + _getFramebufferColorFormat(framebuffer) { + let type, format, internalFormat; + const gl = this.GL; + + if (framebuffer.format === constants.FLOAT) { + type = gl.FLOAT; + } else if (framebuffer.format === constants.HALF_FLOAT) { + type = this.webglVersion === constants.WEBGL2 + ? gl.HALF_FLOAT + : gl.getExtension('OES_texture_half_float').HALF_FLOAT_OES; + } else { + type = gl.UNSIGNED_BYTE; + } + + if (framebuffer.channels === RGBA) { + format = gl.RGBA; + } else { + format = gl.RGB; + } + + if (this.webglVersion === constants.WEBGL2) { + // https://webgl2fundamentals.org/webgl/lessons/webgl-data-textures.html + const table = { + [gl.FLOAT]: { + [gl.RGBA]: gl.RGBA32F + // gl.RGB32F is not available in Firefox without an alpha channel + }, + [gl.HALF_FLOAT]: { + [gl.RGBA]: gl.RGBA16F + // gl.RGB16F is not available in Firefox without an alpha channel + }, + [gl.UNSIGNED_BYTE]: { + [gl.RGBA]: gl.RGBA8, // gl.RGBA4 + [gl.RGB]: gl.RGB8 // gl.RGB565 + } + }; + internalFormat = table[type][format]; + } else if (framebuffer.format === constants.HALF_FLOAT) { + internalFormat = gl.RGBA; + } else { + internalFormat = format; + } + + return { internalFormat, format, type }; + } + + /** + * To create a WebGL texture, one needs to supply three pieces of information: + * the type (the data type each channel will be stored as, e.g. int or float), + * the format (the color channels that will each be stored in the previously + * specified type, e.g. rgb or rgba), and the internal format (the specifics + * of how data for each channel, in the aforementioned type, will be packed + * together, such as how many bits to use, e.g. RGBA32F or RGB565.) + * + * This method takes into account the settings asked for by the user and + * returns values for these three properties that can be used for the + * texture storing depth information. + * + * @private + */ + _getFramebufferDepthFormat(framebuffer) { + let type, format, internalFormat; + const gl = this.GL; + + if (framebuffer.useStencil) { + if (framebuffer.depthFormat === constants.FLOAT) { + type = gl.FLOAT_32_UNSIGNED_INT_24_8_REV; + } else if (this.webglVersion === constants.WEBGL2) { + type = gl.UNSIGNED_INT_24_8; + } else { + type = gl.getExtension('WEBGL_depth_texture').UNSIGNED_INT_24_8_WEBGL; + } + } else { + if (framebuffer.depthFormat === constants.FLOAT) { + type = gl.FLOAT; + } else { + type = gl.UNSIGNED_INT; + } + } + + if (framebuffer.useStencil) { + format = gl.DEPTH_STENCIL; + } else { + format = gl.DEPTH_COMPONENT; + } + + if (framebuffer.useStencil) { + if (framebuffer.depthFormat === constants.FLOAT) { + internalFormat = gl.DEPTH32F_STENCIL8; + } else if (this.webglVersion === constants.WEBGL2) { + internalFormat = gl.DEPTH24_STENCIL8; + } else { + internalFormat = gl.DEPTH_STENCIL; + } + } else if (this.webglVersion === constants.WEBGL2) { + if (framebuffer.depthFormat === constants.FLOAT) { + internalFormat = gl.DEPTH_COMPONENT32F; + } else { + internalFormat = gl.DEPTH_COMPONENT24; + } + } else { + internalFormat = gl.DEPTH_COMPONENT; + } + + return { internalFormat, format, type }; + } + + _deleteFramebufferTexture(texture) { + const gl = this.GL; + gl.deleteTexture(texture.rawTexture().texture); + this.textures.delete(texture); + } + + deleteFramebufferTextures(framebuffer) { + this._deleteFramebufferTexture(framebuffer.color) + if (framebuffer.depth) this._deleteFramebufferTexture(framebuffer.depth); + const gl = this.GL; + if (framebuffer.colorRenderbuffer) gl.deleteRenderbuffer(framebuffer.colorRenderbuffer); + if (framebuffer.depthRenderbuffer) gl.deleteRenderbuffer(framebuffer.depthRenderbuffer); + } + + deleteFramebufferResources(framebuffer) { + const gl = this.GL; + gl.deleteFramebuffer(framebuffer.framebuffer); + if (framebuffer.aaFramebuffer) { + gl.deleteFramebuffer(framebuffer.aaFramebuffer); + } + if (framebuffer.depthRenderbuffer) { + gl.deleteRenderbuffer(framebuffer.depthRenderbuffer); + } + if (framebuffer.colorRenderbuffer) { + gl.deleteRenderbuffer(framebuffer.colorRenderbuffer); + } + } + + getFramebufferToBind(framebuffer) { + if (framebuffer.antialias) { + return framebuffer.aaFramebuffer; + } else { + return framebuffer.framebuffer; + } + } + + updateFramebufferTexture(framebuffer, property) { + if (framebuffer.antialias) { + const gl = this.GL; + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, framebuffer.aaFramebuffer); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, framebuffer.framebuffer); + const partsToCopy = { + colorTexture: [ + gl.COLOR_BUFFER_BIT, + framebuffer.colorP5Texture.magFilter === constants.LINEAR ? gl.LINEAR : gl.NEAREST + ], + }; + if (framebuffer.useDepth) { + partsToCopy.depthTexture = [ + gl.DEPTH_BUFFER_BIT, + framebuffer.depthP5Texture.magFilter === constants.LINEAR ? gl.LINEAR : gl.NEAREST + ]; + } + const [flag, filter] = partsToCopy[property]; + gl.blitFramebuffer( + 0, + 0, + framebuffer.width * framebuffer.density, + framebuffer.height * framebuffer.density, + 0, + 0, + framebuffer.width * framebuffer.density, + framebuffer.height * framebuffer.density, + flag, + filter + ); + + const activeFbo = this.activeFramebuffer(); + this.bindFramebuffer(activeFbo); + } + } + + bindFramebuffer(framebuffer) { + const gl = this.GL; + gl.bindFramebuffer( + gl.FRAMEBUFFER, + framebuffer + ? this.getFramebufferToBind(framebuffer) + : null + ); + } + + readFramebufferPixels(framebuffer) { + const gl = this.GL; + const prevFramebuffer = this.activeFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer.framebuffer); + const colorFormat = this._getFramebufferColorFormat(framebuffer); + const pixels = readPixelsWebGL( + framebuffer.pixels, + gl, + framebuffer.framebuffer, + 0, + 0, + framebuffer.width * framebuffer.density, + framebuffer.height * framebuffer.density, + colorFormat.format, + colorFormat.type + ); + this.bindFramebuffer(prevFramebuffer); + return pixels; + } + + readFramebufferPixel(framebuffer, x, y) { + const colorFormat = this._getFramebufferColorFormat(framebuffer); + return readPixelWebGL( + this.GL, + framebuffer.framebuffer, + x, + y, + colorFormat.format, + colorFormat.type + ); + } + + readFramebufferRegion(framebuffer, x, y, w, h) { + const gl = this.GL; + const colorFormat = this._getFramebufferColorFormat(framebuffer); + + const rawData = readPixelsWebGL( + undefined, + gl, + framebuffer.framebuffer, + x * framebuffer.density, + y * framebuffer.density, + w * framebuffer.density, + h * framebuffer.density, + colorFormat.format, + colorFormat.type + ); + + // Framebuffer data might be either a Uint8Array or Float32Array + // depending on its format, and it may or may not have an alpha channel. + // To turn it into an image, we have to normalize the data into a + // Uint8ClampedArray with alpha. + const fullData = new Uint8ClampedArray( + w * h * framebuffer.density * framebuffer.density * 4 + ); + // Default channels that aren't in the framebuffer (e.g. alpha, if the + // framebuffer is in RGB mode instead of RGBA) to 255 + fullData.fill(255); + + const channels = colorFormat.format === gl.RGB ? 3 : 4; + for (let yPos = 0; yPos < h * framebuffer.density; yPos++) { + for (let xPos = 0; xPos < w * framebuffer.density; xPos++) { + for (let channel = 0; channel < 4; channel++) { + const idx = (yPos * w * framebuffer.density + xPos) * 4 + channel; + if (channel < channels) { + // Find the index of this pixel in `rawData`, which might have a + // different number of channels + const rawDataIdx = channels === 4 + ? idx + : (yPos * w * framebuffer.density + xPos) * channels + channel; + fullData[idx] = rawData[rawDataIdx]; + } + } + } + } + + // Create image from data + const region = new Image(w * framebuffer.density, h * framebuffer.density); + region.imageData = region.canvas.getContext('2d').createImageData( + region.width, + region.height + ); + region.imageData.data.set(fullData); + region.pixels = region.imageData.data; + region.updatePixels(); + if (framebuffer.density !== 1) { + region.pixelDensity(framebuffer.density); + } + return region; + } + + updateFramebufferPixels(framebuffer) { + const gl = this.GL; + framebuffer.colorP5Texture.bindTexture(); + const colorFormat = this._getFramebufferColorFormat(framebuffer); + + const channels = colorFormat.format === gl.RGBA ? 4 : 3; + const len = framebuffer.width * framebuffer.height * framebuffer.density * framebuffer.density * channels; + const TypedArrayClass = colorFormat.type === gl.UNSIGNED_BYTE ? Uint8Array : Float32Array; + + if (!(framebuffer.pixels instanceof TypedArrayClass) || framebuffer.pixels.length !== len) { + throw new Error( + 'The pixels array has not been set correctly. Please call loadPixels() before updatePixels().' + ); + } + + gl.texImage2D( + gl.TEXTURE_2D, + 0, + colorFormat.internalFormat, + framebuffer.width * framebuffer.density, + framebuffer.height * framebuffer.density, + 0, + colorFormat.format, + colorFormat.type, + framebuffer.pixels + ); + framebuffer.colorP5Texture.unbindTexture(); + + const prevFramebuffer = this.activeFramebuffer(); + if (framebuffer.antialias) { + // We need to make sure the antialiased framebuffer also has the updated + // pixels so that if more is drawn to it, it goes on top of the updated + // pixels instead of replacing them. + // We can't blit the framebuffer to the multisampled antialias + // framebuffer to leave both in the same state, so instead we have + // to use image() to put the framebuffer texture onto the antialiased + // framebuffer. + framebuffer.begin(); + this.push(); + this.states.setValue('imageMode', constants.CORNER); + this.setCamera(framebuffer.filterCamera); + this.resetMatrix(); + this.states.setValue('strokeColor', null); + this.clear(); + this._drawingFilter = true; + this.image( + framebuffer, + 0, 0, + framebuffer.width, framebuffer.height, + -this.width / 2, -this.height / 2, + this.width, this.height + ); + this._drawingFilter = false; + this.pop(); + if (framebuffer.useDepth) { + gl.clearDepth(1); + gl.clear(gl.DEPTH_BUFFER_BIT); + } + framebuffer.end(); + } else { + gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer.framebuffer); + if (framebuffer.useDepth) { + gl.clearDepth(1); + gl.clear(gl.DEPTH_BUFFER_BIT); + } + this.bindFramebuffer(prevFramebuffer); + } + } } function rendererGL(p5, fn) { diff --git a/src/webgl/p5.Texture.js b/src/webgl/p5.Texture.js index c88389bb8e..4ea07a1fba 100644 --- a/src/webgl/p5.Texture.js +++ b/src/webgl/p5.Texture.js @@ -382,27 +382,6 @@ function texture(p5, fn){ p5.MipmapTexture = MipmapTexture; } -export function checkWebGLCapabilities({ GL, webglVersion }) { - const gl = GL; - const supportsFloat = webglVersion === constants.WEBGL2 - ? (gl.getExtension('EXT_color_buffer_float') && - gl.getExtension('EXT_float_blend')) - : gl.getExtension('OES_texture_float'); - const supportsFloatLinear = supportsFloat && - gl.getExtension('OES_texture_float_linear'); - const supportsHalfFloat = webglVersion === constants.WEBGL2 - ? gl.getExtension('EXT_color_buffer_float') - : gl.getExtension('OES_texture_half_float'); - const supportsHalfFloatLinear = supportsHalfFloat && - gl.getExtension('OES_texture_half_float_linear'); - return { - float: supportsFloat, - floatLinear: supportsFloatLinear, - halfFloat: supportsHalfFloat, - halfFloatLinear: supportsHalfFloatLinear - }; -} - export default texture; export { Texture, MipmapTexture }; diff --git a/src/webgl/utils.js b/src/webgl/utils.js index 70766ac522..0727e91e1f 100644 --- a/src/webgl/utils.js +++ b/src/webgl/utils.js @@ -448,3 +448,24 @@ export function populateGLSLHooks(shader, src, shaderType) { return preMain + '\n' + defines + hooks + main + postMain; } + +export function checkWebGLCapabilities({ GL, webglVersion }) { + const gl = GL; + const supportsFloat = webglVersion === constants.WEBGL2 + ? (gl.getExtension('EXT_color_buffer_float') && + gl.getExtension('EXT_float_blend')) + : gl.getExtension('OES_texture_float'); + const supportsFloatLinear = supportsFloat && + gl.getExtension('OES_texture_float_linear'); + const supportsHalfFloat = webglVersion === constants.WEBGL2 + ? gl.getExtension('EXT_color_buffer_float') + : gl.getExtension('OES_texture_half_float'); + const supportsHalfFloatLinear = supportsHalfFloat && + gl.getExtension('OES_texture_half_float_linear'); + return { + float: supportsFloat, + floatLinear: supportsFloatLinear, + halfFloat: supportsHalfFloat, + halfFloatLinear: supportsHalfFloatLinear + }; +} diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 9ded1277d2..a8732d22dd 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -1,6 +1,8 @@ import { Renderer3D, getStrokeDefs } from '../core/p5.Renderer3D'; import { Shader } from '../webgl/p5.Shader'; import { Texture } from '../webgl/p5.Texture'; +import { Image } from '../image/p5.Image'; +import { RGB, RGBA } from '../color/creating_reading'; import * as constants from '../core/constants'; @@ -17,6 +19,10 @@ class RendererWebGPU extends Renderer3D { this.renderPass = {}; this.samplers = new Map(); + + // Single reusable staging buffer for pixel reading + this.pixelReadBuffer = null; + this.pixelReadBufferSize = 0; } async setupContext() { @@ -1120,6 +1126,382 @@ class RendererWebGPU extends Renderer3D { return preMain + '\n' + defines + hooks + main + postMain; } + + ////////////////////////////////////////////// + // Buffer management for pixel reading + ////////////////////////////////////////////// + + _ensurePixelReadBuffer(requiredSize) { + // Create or resize staging buffer if needed + if (!this.pixelReadBuffer || this.pixelReadBufferSize < requiredSize) { + // Clean up old buffer + if (this.pixelReadBuffer) { + this.pixelReadBuffer.destroy(); + } + + // Create new buffer with padding to avoid frequent recreations + // Scale by 2 to ensure integer size and reasonable headroom + const bufferSize = Math.max(requiredSize, this.pixelReadBufferSize * 2); + this.pixelReadBuffer = this.device.createBuffer({ + size: bufferSize, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }); + this.pixelReadBufferSize = bufferSize; + } + return this.pixelReadBuffer; + } + + ////////////////////////////////////////////// + // Framebuffer methods + ////////////////////////////////////////////// + + supportsFramebufferAntialias() { + return true; + } + + createFramebufferResources(framebuffer) { + } + + validateFramebufferFormats(framebuffer) { + if (![ + constants.UNSIGNED_BYTE, + constants.FLOAT, + constants.HALF_FLOAT + ].includes(framebuffer.format)) { + console.warn( + 'Unknown Framebuffer format. ' + + 'Please use UNSIGNED_BYTE, FLOAT, or HALF_FLOAT. ' + + 'Defaulting to UNSIGNED_BYTE.' + ); + framebuffer.format = constants.UNSIGNED_BYTE; + } + + if (framebuffer.useDepth && ![ + constants.UNSIGNED_INT, + constants.FLOAT + ].includes(framebuffer.depthFormat)) { + console.warn( + 'Unknown Framebuffer depth format. ' + + 'Please use UNSIGNED_INT or FLOAT. Defaulting to FLOAT.' + ); + framebuffer.depthFormat = constants.FLOAT; + } + } + + recreateFramebufferTextures(framebuffer) { + if (framebuffer.colorTexture && framebuffer.colorTexture.destroy) { + framebuffer.colorTexture.destroy(); + } + if (framebuffer.depthTexture && framebuffer.depthTexture.destroy) { + framebuffer.depthTexture.destroy(); + } + + const colorTextureDescriptor = { + size: { + width: framebuffer.width * framebuffer.density, + height: framebuffer.height * framebuffer.density, + depthOrArrayLayers: 1, + }, + format: this._getWebGPUColorFormat(framebuffer), + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC, + sampleCount: framebuffer.antialias ? framebuffer.antialiasSamples : 1, + }; + + framebuffer.colorTexture = this.device.createTexture(colorTextureDescriptor); + + if (framebuffer.useDepth) { + const depthTextureDescriptor = { + size: { + width: framebuffer.width * framebuffer.density, + height: framebuffer.height * framebuffer.density, + depthOrArrayLayers: 1, + }, + format: this._getWebGPUDepthFormat(framebuffer), + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, + sampleCount: framebuffer.antialias ? framebuffer.antialiasSamples : 1, + }; + + framebuffer.depthTexture = this.device.createTexture(depthTextureDescriptor); + } + } + + _getWebGPUColorFormat(framebuffer) { + if (framebuffer.format === constants.FLOAT) { + return framebuffer.channels === RGBA ? 'rgba32float' : 'rgba32float'; + } else if (framebuffer.format === constants.HALF_FLOAT) { + return framebuffer.channels === RGBA ? 'rgba16float' : 'rgba16float'; + } else { + return framebuffer.channels === RGBA ? 'rgba8unorm' : 'rgba8unorm'; + } + } + + _getWebGPUDepthFormat(framebuffer) { + if (framebuffer.useStencil) { + return framebuffer.depthFormat === constants.FLOAT ? 'depth32float-stencil8' : 'depth24plus-stencil8'; + } else { + return framebuffer.depthFormat === constants.FLOAT ? 'depth32float' : 'depth24plus'; + } + } + + _deleteFramebufferTexture(texture) { + const handle = texture.rawTexture(); + if (handle.texture && handle.texture.destroy) { + handle.texture.destroy(); + } + this.textures.delete(texture); + } + + deleteFramebufferTextures(framebuffer) { + this._deleteFramebufferTexture(framebuffer.color) + if (framebuffer.depth) this._deleteFramebufferTexture(framebuffer.depth); + } + + deleteFramebufferResources(framebuffer) { + if (framebuffer.colorTexture && framebuffer.colorTexture.destroy) { + framebuffer.colorTexture.destroy(); + } + if (framebuffer.depthTexture && framebuffer.depthTexture.destroy) { + framebuffer.depthTexture.destroy(); + } + } + + getFramebufferToBind(framebuffer) { + } + + updateFramebufferTexture(framebuffer, property) { + // No-op for WebGPU since antialiasing is handled at pipeline level + } + + bindFramebuffer(framebuffer) {} + + async readFramebufferPixels(framebuffer) { + const width = framebuffer.width * framebuffer.density; + const height = framebuffer.height * framebuffer.density; + const bytesPerPixel = 4; + const bufferSize = width * height * bytesPerPixel; + + const stagingBuffer = this._ensurePixelReadBuffer(bufferSize); + + const commandEncoder = this.device.createCommandEncoder(); + commandEncoder.copyTextureToBuffer( + { texture: framebuffer.colorTexture }, + { buffer: stagingBuffer, bytesPerRow: width * bytesPerPixel }, + { width, height, depthOrArrayLayers: 1 } + ); + + this.device.queue.submit([commandEncoder.finish()]); + + await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize); + const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); + const result = new Uint8Array(mappedRange.slice(0, bufferSize)); + + stagingBuffer.unmap(); + return result; + } + + async readFramebufferPixel(framebuffer, x, y) { + const bytesPerPixel = 4; + const stagingBuffer = this._ensurePixelReadBuffer(bytesPerPixel); + + const commandEncoder = this.device.createCommandEncoder(); + commandEncoder.copyTextureToBuffer( + { + texture: framebuffer.colorTexture, + origin: { x, y, z: 0 } + }, + { buffer: stagingBuffer, bytesPerRow: bytesPerPixel }, + { width: 1, height: 1, depthOrArrayLayers: 1 } + ); + + this.device.queue.submit([commandEncoder.finish()]); + + await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bytesPerPixel); + const mappedRange = stagingBuffer.getMappedRange(0, bytesPerPixel); + const pixelData = new Uint8Array(mappedRange); + const result = [pixelData[0], pixelData[1], pixelData[2], pixelData[3]]; + + stagingBuffer.unmap(); + return result; + } + + async readFramebufferRegion(framebuffer, x, y, w, h) { + const width = w * framebuffer.density; + const height = h * framebuffer.density; + const bytesPerPixel = 4; + const bufferSize = width * height * bytesPerPixel; + + const stagingBuffer = this._ensurePixelReadBuffer(bufferSize); + + const commandEncoder = this.device.createCommandEncoder(); + commandEncoder.copyTextureToBuffer( + { + texture: framebuffer.colorTexture, + origin: { x: x * framebuffer.density, y: y * framebuffer.density, z: 0 } + }, + { buffer: stagingBuffer, bytesPerRow: width * bytesPerPixel }, + { width, height, depthOrArrayLayers: 1 } + ); + + this.device.queue.submit([commandEncoder.finish()]); + + await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize); + const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); + const pixelData = new Uint8Array(mappedRange.slice(0, bufferSize)); + + // WebGPU doesn't need vertical flipping unlike WebGL + const region = new Image(width, height); + region.imageData = region.canvas.getContext('2d').createImageData(width, height); + region.imageData.data.set(pixelData); + region.pixels = region.imageData.data; + region.updatePixels(); + + if (framebuffer.density !== 1) { + region.pixelDensity(framebuffer.density); + } + + stagingBuffer.unmap(); + return region; + } + + updateFramebufferPixels(framebuffer) { + const width = framebuffer.width * framebuffer.density; + const height = framebuffer.height * framebuffer.density; + const bytesPerPixel = 4; + + const expectedLength = width * height * bytesPerPixel; + if (!framebuffer.pixels || framebuffer.pixels.length !== expectedLength) { + throw new Error( + 'The pixels array has not been set correctly. Please call loadPixels() before updatePixels().' + ); + } + + this.device.queue.writeTexture( + { texture: framebuffer.colorTexture }, + framebuffer.pixels, + { + bytesPerRow: width * bytesPerPixel, + rowsPerImage: height + }, + { width, height, depthOrArrayLayers: 1 } + ); + } + + ////////////////////////////////////////////// + // Main canvas pixel methods + ////////////////////////////////////////////// + + async loadPixels() { + const width = this.width * this._pixelDensity; + const height = this.height * this._pixelDensity; + const bytesPerPixel = 4; + const bufferSize = width * height * bytesPerPixel; + + const stagingBuffer = this._ensurePixelReadBuffer(bufferSize); + + // Get the current canvas texture + const canvasTexture = this.drawingContext.getCurrentTexture(); + + const commandEncoder = this.device.createCommandEncoder(); + commandEncoder.copyTextureToBuffer( + { texture: canvasTexture }, + { buffer: stagingBuffer, bytesPerRow: width * bytesPerPixel }, + { width, height, depthOrArrayLayers: 1 } + ); + + this.device.queue.submit([commandEncoder.finish()]); + + await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize); + const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); + this.pixels = new Uint8Array(mappedRange.slice(0, bufferSize)); + + stagingBuffer.unmap(); + return this.pixels; + } + + async _getPixel(x, y) { + const bytesPerPixel = 4; + const stagingBuffer = this._ensurePixelReadBuffer(bytesPerPixel); + + const canvasTexture = this.drawingContext.getCurrentTexture(); + const commandEncoder = this.device.createCommandEncoder(); + commandEncoder.copyTextureToBuffer( + { + texture: canvasTexture, + origin: { x, y, z: 0 } + }, + { buffer: stagingBuffer, bytesPerRow: bytesPerPixel }, + { width: 1, height: 1, depthOrArrayLayers: 1 } + ); + + this.device.queue.submit([commandEncoder.finish()]); + + await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bytesPerPixel); + const mappedRange = stagingBuffer.getMappedRange(0, bytesPerPixel); + const pixelData = new Uint8Array(mappedRange); + const result = [pixelData[0], pixelData[1], pixelData[2], pixelData[3]]; + + stagingBuffer.unmap(); + return result; + } + + async get(x, y, w, h) { + const pd = this._pixelDensity; + + if (typeof x === 'undefined' && typeof y === 'undefined') { + // get() - return entire canvas + x = y = 0; + w = this.width; + h = this.height; + } else { + x *= pd; + y *= pd; + + if (typeof w === 'undefined' && typeof h === 'undefined') { + // get(x,y) - single pixel + if (x < 0 || y < 0 || x >= this.width * pd || y >= this.height * pd) { + return [0, 0, 0, 0]; + } + + return this._getPixel(x, y); + } + // get(x,y,w,h) - region + } + + // Read region and create p5.Image + const width = w * pd; + const height = h * pd; + const bytesPerPixel = 4; + const bufferSize = width * height * bytesPerPixel; + + const stagingBuffer = this._ensurePixelReadBuffer(bufferSize); + + const canvasTexture = this.drawingContext.getCurrentTexture(); + const commandEncoder = this.device.createCommandEncoder(); + commandEncoder.copyTextureToBuffer( + { + texture: canvasTexture, + origin: { x, y, z: 0 } + }, + { buffer: stagingBuffer, bytesPerRow: width * bytesPerPixel }, + { width, height, depthOrArrayLayers: 1 } + ); + + this.device.queue.submit([commandEncoder.finish()]); + + await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize); + const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); + const pixelData = new Uint8Array(mappedRange.slice(0, bufferSize)); + + const region = new Image(width, height); + region.pixelDensity(pd); + region.imageData = region.canvas.getContext('2d').createImageData(width, height); + region.imageData.data.set(pixelData); + region.pixels = region.imageData.data; + region.updatePixels(); + + stagingBuffer.unmap(); + return region; + } } function rendererWebGPU(p5, fn) { diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index efd8cc7e93..363626807a 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -103,4 +103,168 @@ visualSuite('WebGPU', function() { screenshot(); }); }); + + visualSuite('Framebuffers', function() { + visualTest('Basic framebuffer draw to canvas', async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Create a framebuffer + const fbo = p5.createFramebuffer({ width: 25, height: 25 }); + + // Draw to the framebuffer + fbo.draw(() => { + p5.background(255, 0, 0); // Red background + p5.fill(0, 255, 0); // Green circle + p5.noStroke(); + p5.circle(12.5, 12.5, 20); + }); + + // Draw the framebuffer to the main canvas + p5.background(0, 0, 255); // Blue background + p5.texture(fbo); + p5.noStroke(); + p5.plane(25, 25); + + screenshot(); + }); + + visualTest('Framebuffer with different sizes', async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Create two different sized framebuffers + const fbo1 = p5.createFramebuffer({ width: 20, height: 20 }); + const fbo2 = p5.createFramebuffer({ width: 15, height: 15 }); + + // Draw to first framebuffer + fbo1.draw(() => { + p5.background(255, 100, 100); + p5.fill(255, 255, 0); + p5.noStroke(); + p5.rect(5, 5, 10, 10); + }); + + // Draw to second framebuffer + fbo2.draw(() => { + p5.background(100, 255, 100); + p5.fill(255, 0, 255); + p5.noStroke(); + p5.circle(7.5, 7.5, 10); + }); + + // Draw both to main canvas + p5.background(50); + p5.push(); + p5.translate(-12.5, -12.5); + p5.texture(fbo1); + p5.noStroke(); + p5.plane(20, 20); + p5.pop(); + + p5.push(); + p5.translate(12.5, 12.5); + p5.texture(fbo2); + p5.noStroke(); + p5.plane(15, 15); + p5.pop(); + + screenshot(); + }); + + visualTest('Auto-sized framebuffer', async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Create auto-sized framebuffer (should match canvas size) + const fbo = p5.createFramebuffer(); + + // Draw to the framebuffer + fbo.draw(() => { + p5.background(0); + p5.stroke(255); + p5.strokeWeight(2); + p5.noFill(); + // Draw a grid pattern to verify size + for (let x = 0; x < 50; x += 10) { + p5.line(x, 0, x, 50); + } + for (let y = 0; y < 50; y += 10) { + p5.line(0, y, 50, y); + } + p5.fill(255, 0, 0); + p5.noStroke(); + p5.circle(25, 25, 15); + }); + + // Draw the framebuffer to fill the main canvas + p5.texture(fbo); + p5.noStroke(); + p5.plane(50, 50); + + screenshot(); + }); + + visualTest('Auto-sized framebuffer after canvas resize', async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Create auto-sized framebuffer + const fbo = p5.createFramebuffer(); + + // Resize the canvas (framebuffer should auto-resize) + p5.resizeCanvas(30, 30); + + // Draw to the framebuffer after resize + fbo.draw(() => { + p5.background(100, 0, 100); + p5.fill(0, 255, 255); + p5.noStroke(); + // Draw a shape that fills the new size + p5.rect(5, 5, 20, 20); + p5.fill(255, 255, 0); + p5.circle(15, 15, 10); + }); + + // Draw the framebuffer to the main canvas + p5.texture(fbo); + p5.noStroke(); + p5.plane(30, 30); + + screenshot(); + }); + + visualTest('Fixed-size framebuffer after manual resize', async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Create fixed-size framebuffer + const fbo = p5.createFramebuffer({ width: 20, height: 20 }); + + // Draw initial content + fbo.draw(() => { + p5.background(255, 200, 100); + p5.fill(0, 100, 200); + p5.noStroke(); + p5.circle(10, 10, 15); + }); + + // Manually resize the framebuffer + fbo.resize(35, 25); + + // Draw new content to the resized framebuffer + fbo.draw(() => { + p5.background(200, 255, 100); + p5.fill(200, 0, 100); + p5.noStroke(); + // Draw content that uses the new size + p5.rect(5, 5, 25, 15); + p5.fill(0, 0, 255); + p5.circle(17.5, 12.5, 8); + }); + + // Draw the resized framebuffer to the main canvas + p5.background(50); + p5.texture(fbo); + p5.noStroke(); + p5.plane(35, 25); + + screenshot(); + }); + }); }); diff --git a/test/unit/webgl/p5.RendererGL.js b/test/unit/webgl/p5.RendererGL.js index 34b64abdfd..f437ac4c20 100644 --- a/test/unit/webgl/p5.RendererGL.js +++ b/test/unit/webgl/p5.RendererGL.js @@ -1098,7 +1098,7 @@ suite('p5.RendererGL', function() { assert.isTrue(img.length === 4); }); - test('updatePixels() matches 2D mode', function() { + test.only('updatePixels() matches 2D mode', function() { myp5.createCanvas(20, 20); myp5.pixelDensity(1); const getColors = function(mode) { @@ -1120,6 +1120,7 @@ suite('p5.RendererGL', function() { }; const p2d = getColors(myp5.P2D); + debugger const webgl = getColors(myp5.WEBGL); myp5.image(p2d, 0, 0); myp5.blendMode(myp5.DIFFERENCE); diff --git a/test/unit/webgpu/p5.Framebuffer.js b/test/unit/webgpu/p5.Framebuffer.js new file mode 100644 index 0000000000..9fec2f070d --- /dev/null +++ b/test/unit/webgpu/p5.Framebuffer.js @@ -0,0 +1,247 @@ +import p5 from '../../../src/app.js'; + +suite('WebGPU p5.Framebuffer', function() { + let myp5; + let prevPixelRatio; + + beforeAll(async function() { + prevPixelRatio = window.devicePixelRatio; + window.devicePixelRatio = 1; + myp5 = new p5(function(p) { + p.setup = function() {}; + p.draw = function() {}; + }); + }); + + afterAll(function() { + myp5.remove(); + window.devicePixelRatio = prevPixelRatio; + }); + + suite('Creation and basic properties', function() { + test('framebuffers can be created with WebGPU renderer', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + const fbo = myp5.createFramebuffer(); + + expect(fbo).to.be.an('object'); + expect(fbo.width).to.equal(10); + expect(fbo.height).to.equal(10); + expect(fbo.autoSized()).to.equal(true); + }); + + test('framebuffers can be created with custom dimensions', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + const fbo = myp5.createFramebuffer({ width: 20, height: 30 }); + + expect(fbo.width).to.equal(20); + expect(fbo.height).to.equal(30); + expect(fbo.autoSized()).to.equal(false); + }); + + test('framebuffers have color texture', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + const fbo = myp5.createFramebuffer(); + + expect(fbo.color).to.be.an('object'); + expect(fbo.color.rawTexture).to.be.a('function'); + }); + + test('framebuffers can specify different formats', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + const fbo = myp5.createFramebuffer({ + format: 'float', + channels: 'rgb' + }); + + expect(fbo).to.be.an('object'); + expect(fbo.width).to.equal(10); + expect(fbo.height).to.equal(10); + }); + }); + + suite('Auto-sizing behavior', function() { + test('auto-sized framebuffers change size with canvas', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + myp5.pixelDensity(1); + const fbo = myp5.createFramebuffer(); + + expect(fbo.autoSized()).to.equal(true); + expect(fbo.width).to.equal(10); + expect(fbo.height).to.equal(10); + expect(fbo.density).to.equal(1); + + myp5.resizeCanvas(15, 20); + myp5.pixelDensity(2); + expect(fbo.width).to.equal(15); + expect(fbo.height).to.equal(20); + expect(fbo.density).to.equal(2); + }); + + test('manually-sized framebuffers do not change size with canvas', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + myp5.pixelDensity(3); + const fbo = myp5.createFramebuffer({ width: 25, height: 30, density: 1 }); + + expect(fbo.autoSized()).to.equal(false); + expect(fbo.width).to.equal(25); + expect(fbo.height).to.equal(30); + expect(fbo.density).to.equal(1); + + myp5.resizeCanvas(5, 15); + myp5.pixelDensity(2); + expect(fbo.width).to.equal(25); + expect(fbo.height).to.equal(30); + expect(fbo.density).to.equal(1); + }); + + test('manually-sized framebuffers can be made auto-sized', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + myp5.pixelDensity(1); + const fbo = myp5.createFramebuffer({ width: 25, height: 30, density: 2 }); + + expect(fbo.autoSized()).to.equal(false); + expect(fbo.width).to.equal(25); + expect(fbo.height).to.equal(30); + expect(fbo.density).to.equal(2); + + // Make it auto-sized + fbo.autoSized(true); + expect(fbo.autoSized()).to.equal(true); + + myp5.resizeCanvas(8, 12); + myp5.pixelDensity(3); + expect(fbo.width).to.equal(8); + expect(fbo.height).to.equal(12); + expect(fbo.density).to.equal(3); + }); + }); + + suite('Manual resizing', function() { + test('framebuffers can be manually resized', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + myp5.pixelDensity(1); + const fbo = myp5.createFramebuffer(); + + expect(fbo.width).to.equal(10); + expect(fbo.height).to.equal(10); + expect(fbo.density).to.equal(1); + + fbo.resize(20, 25); + expect(fbo.width).to.equal(20); + expect(fbo.height).to.equal(25); + expect(fbo.autoSized()).to.equal(false); + }); + + test('resizing affects pixel density', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + myp5.pixelDensity(1); + const fbo = myp5.createFramebuffer(); + + fbo.pixelDensity(3); + expect(fbo.density).to.equal(3); + + fbo.resize(15, 20); + fbo.pixelDensity(2); + expect(fbo.width).to.equal(15); + expect(fbo.height).to.equal(20); + expect(fbo.density).to.equal(2); + }); + }); + + suite('Drawing functionality', function() { + test('can draw to framebuffer with draw() method', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + const fbo = myp5.createFramebuffer(); + + let drawCallbackExecuted = false; + fbo.draw(() => { + drawCallbackExecuted = true; + myp5.background(255, 0, 0); + myp5.fill(0, 255, 0); + myp5.noStroke(); + myp5.circle(5, 5, 8); + }); + + expect(drawCallbackExecuted).to.equal(true); + }); + + test('can use framebuffer as texture', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + const fbo = myp5.createFramebuffer(); + + fbo.draw(() => { + myp5.background(255, 0, 0); + }); + + // Should not throw when used as texture + expect(() => { + myp5.texture(fbo); + myp5.plane(10, 10); + }).to.not.throw(); + }); + }); + + suite('Pixel access', function() { + test('loadPixels returns a promise in WebGPU', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + const fbo = myp5.createFramebuffer(); + + fbo.draw(() => { + myp5.background(255, 0, 0); + }); + + const result = fbo.loadPixels(); + expect(result).to.be.a('promise'); + + const pixels = await result; + expect(pixels).to.be.an('array'); + expect(pixels.length).to.equal(10 * 10 * 4); + }); + + test('pixels property is set after loadPixels resolves', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + const fbo = myp5.createFramebuffer(); + + fbo.draw(() => { + myp5.background(100, 150, 200); + }); + + const pixels = await fbo.loadPixels(); + expect(fbo.pixels).to.equal(pixels); + expect(fbo.pixels.length).to.equal(10 * 10 * 4); + }); + + test('get() returns a promise for single pixel in WebGPU', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + const fbo = myp5.createFramebuffer(); + + fbo.draw(() => { + myp5.background(100, 150, 200); + }); + + const result = fbo.get(5, 5); + expect(result).to.be.a('promise'); + + const color = await result; + expect(color).to.be.an('array'); + expect(color).to.have.length(4); + }); + + test('get() returns a promise for region in WebGPU', async function() { + await myp5.createCanvas(10, 10, myp5.WEBGPU); + const fbo = myp5.createFramebuffer(); + + fbo.draw(() => { + myp5.background(100, 150, 200); + }); + + const result = fbo.get(2, 2, 4, 4); + expect(result).to.be.a('promise'); + + const region = await result; + expect(region).to.be.an('object'); // Should be a p5.Image + expect(region.width).to.equal(4); + expect(region.height).to.equal(4); + }); + }); +}); From f94f8981f051cfdc3299058500e48e8b1b59d2a3 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Mon, 28 Jul 2025 11:15:28 -0400 Subject: [PATCH 02/26] Fix ordering of dirty flag --- src/webgl/p5.Framebuffer.js | 1 - src/webgl/p5.RendererGL.js | 1 + test/unit/webgl/p5.RendererGL.js | 3 +-- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/webgl/p5.Framebuffer.js b/src/webgl/p5.Framebuffer.js index af2ab279b5..297e3dd09f 100644 --- a/src/webgl/p5.Framebuffer.js +++ b/src/webgl/p5.Framebuffer.js @@ -1170,7 +1170,6 @@ class Framebuffer { updatePixels() { // Let renderer handle the pixel update process this.renderer.updateFramebufferPixels(this); - this.dirty.colorTexture = false; } } diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index 5a9482baa2..507839e1fe 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -1940,6 +1940,7 @@ class RendererGL extends Renderer3D { framebuffer.pixels ); framebuffer.colorP5Texture.unbindTexture(); + framebuffer.dirty.colorTexture = false; const prevFramebuffer = this.activeFramebuffer(); if (framebuffer.antialias) { diff --git a/test/unit/webgl/p5.RendererGL.js b/test/unit/webgl/p5.RendererGL.js index f437ac4c20..34b64abdfd 100644 --- a/test/unit/webgl/p5.RendererGL.js +++ b/test/unit/webgl/p5.RendererGL.js @@ -1098,7 +1098,7 @@ suite('p5.RendererGL', function() { assert.isTrue(img.length === 4); }); - test.only('updatePixels() matches 2D mode', function() { + test('updatePixels() matches 2D mode', function() { myp5.createCanvas(20, 20); myp5.pixelDensity(1); const getColors = function(mode) { @@ -1120,7 +1120,6 @@ suite('p5.RendererGL', function() { }; const p2d = getColors(myp5.P2D); - debugger const webgl = getColors(myp5.WEBGL); myp5.image(p2d, 0, 0); myp5.blendMode(myp5.DIFFERENCE); From 5dfcd2456eee86e63ae2b8332b9c363bcab1dbd6 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Mon, 28 Jul 2025 17:04:02 -0400 Subject: [PATCH 03/26] Make sure textures are cleared at start --- preview/index.html | 7 +- src/webgl/p5.Framebuffer.js | 4 +- src/webgl/p5.RendererGL.js | 15 +++ src/webgl/p5.Texture.js | 2 + src/webgpu/p5.RendererWebGPU.js | 218 +++++++++++++++++++++++++++---- test/unit/visual/cases/webgpu.js | 72 +++++----- test/unit/visual/visualTest.js | 94 ++++++------- 7 files changed, 304 insertions(+), 108 deletions(-) diff --git a/preview/index.html b/preview/index.html index 6e4915ab34..4092992316 100644 --- a/preview/index.html +++ b/preview/index.html @@ -30,6 +30,7 @@ p.setup = async function () { await p.createCanvas(400, 400, p.WEBGPU); + fbo = p.createFramebuffer(); tex = p.createImage(100, 100); tex.loadPixels(); @@ -43,6 +44,10 @@ } } tex.updatePixels(); + fbo.draw(() => { + p.imageMode(p.CENTER); + p.image(tex, 0, 0, p.width, p.height); + }); sh = p.baseMaterialShader().modify({ uniforms: { @@ -87,7 +92,7 @@ 0, //p.width/3 * p.sin(t * 0.9 + i * Math.E + 0.2), p.width/3 * p.sin(t * 1.2 + i * Math.E + 0.3), ) - p.texture(tex) + p.texture(fbo) p.sphere(30); p.pop(); } diff --git a/src/webgl/p5.Framebuffer.js b/src/webgl/p5.Framebuffer.js index 297e3dd09f..0fb5504d25 100644 --- a/src/webgl/p5.Framebuffer.js +++ b/src/webgl/p5.Framebuffer.js @@ -67,7 +67,7 @@ class Framebuffer { this.format = settings.format || constants.UNSIGNED_BYTE; this.channels = settings.channels || ( - this.renderer._pInst._glAttributes.alpha + this.renderer.defaultFramebufferAlpha() ? RGBA : RGB ); @@ -75,7 +75,7 @@ class Framebuffer { this.depthFormat = settings.depthFormat || constants.FLOAT; this.textureFiltering = settings.textureFiltering || constants.LINEAR; if (settings.antialias === undefined) { - this.antialiasSamples = this.renderer._pInst._glAttributes.antialias + this.antialiasSamples = this.renderer.defaultFramebufferAntialias() ? 2 : 0; } else if (typeof settings.antialias === 'number') { diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index 507839e1fe..c6fbfa45a6 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -1302,6 +1302,11 @@ class RendererGL extends Renderer3D { return { texture: tex, glFormat: gl.RGBA, glDataType: gl.UNSIGNED_BYTE }; } + createFramebufferTextureHandle(framebufferTexture) { + // For WebGL, framebuffer texture handles are designed to be null + return null; + } + uploadTextureFromSource({ texture, glFormat, glDataType }, source) { const gl = this.GL; gl.texImage2D(gl.TEXTURE_2D, 0, glFormat, glFormat, glDataType, source); @@ -1394,6 +1399,16 @@ class RendererGL extends Renderer3D { // Framebuffer methods ////////////////////////////////////////////// + defaultFramebufferAlpha() { + return this._pInst._glAttributes.alpha; + } + + defaultFramebufferAntialias() { + return this.supportsFramebufferAntialias() + ? this._pInst._glAttributes.antialias + : false; + } + supportsFramebufferAntialias() { return this.webglVersion === constants.WEBGL2; } diff --git a/src/webgl/p5.Texture.js b/src/webgl/p5.Texture.js index 4ea07a1fba..d1c45b84f1 100644 --- a/src/webgl/p5.Texture.js +++ b/src/webgl/p5.Texture.js @@ -128,6 +128,8 @@ class Texture { width: textureData.width, height: textureData.height, }); + } else { + this.textureHandle = this._renderer.createFramebufferTextureHandle(this.src); } this._renderer.setTextureParams(this, { diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index a8732d22dd..29ffcbf40d 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -27,7 +27,10 @@ class RendererWebGPU extends Renderer3D { async setupContext() { this.adapter = await navigator.gpu?.requestAdapter(); - this.device = await this.adapter?.requestDevice(); + this.device = await this.adapter?.requestDevice({ + // Todo: check support + requiredFeatures: ['depth32float-stencil8'] + }); if (!this.device) { throw new Error('Your browser does not support WebGPU.'); } @@ -36,7 +39,8 @@ class RendererWebGPU extends Renderer3D { this.presentationFormat = navigator.gpu.getPreferredCanvasFormat(); this.drawingContext.configure({ device: this.device, - format: this.presentationFormat + format: this.presentationFormat, + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC, }); // TODO disablable stencil @@ -193,12 +197,34 @@ class RendererWebGPU extends Renderer3D { freeDefs(this.renderer.buffers.user); } + _getValidSampleCount(requestedCount) { + // WebGPU supports sample counts of 1, 4 (and sometimes 8) + if (requestedCount <= 1) return 1; + if (requestedCount <= 4) return 4; + return 4; // Cap at 4 for broader compatibility + } + _shaderOptions({ mode }) { + const activeFramebuffer = this.activeFramebuffer(); + const format = activeFramebuffer ? + this._getWebGPUColorFormat(activeFramebuffer) : + this.presentationFormat; + + const requestedSampleCount = activeFramebuffer ? + (activeFramebuffer.antialias ? activeFramebuffer.antialiasSamples : 1) : + (this.antialias || 1); + const sampleCount = this._getValidSampleCount(requestedSampleCount); + + const depthFormat = activeFramebuffer && activeFramebuffer.useDepth ? + this._getWebGPUDepthFormat(activeFramebuffer) : + this.depthFormat; + return { topology: mode === constants.TRIANGLE_STRIP ? 'triangle-strip' : 'triangle-list', blendMode: this.states.curBlendMode, - sampleCount: (this.activeFramebuffer() || this).antialias || 1, // TODO - format: this.activeFramebuffer()?.format || this.presentationFormat, // TODO + sampleCount, + format, + depthFormat, } } @@ -209,8 +235,8 @@ class RendererWebGPU extends Renderer3D { shader.fragModule = device.createShaderModule({ code: shader.fragSrc() }); shader._pipelineCache = new Map(); - shader.getPipeline = ({ topology, blendMode, sampleCount, format }) => { - const key = `${topology}_${blendMode}_${sampleCount}_${format}`; + shader.getPipeline = ({ topology, blendMode, sampleCount, format, depthFormat }) => { + const key = `${topology}_${blendMode}_${sampleCount}_${format}_${depthFormat}`; if (!shader._pipelineCache.has(key)) { const pipeline = device.createRenderPipeline({ layout: shader._pipelineLayout, @@ -230,7 +256,7 @@ class RendererWebGPU extends Renderer3D { primitive: { topology }, multisample: { count: sampleCount }, depthStencil: { - format: this.depthFormat, + format: depthFormat, depthWriteEnabled: true, depthCompare: 'less', stencilFront: { @@ -531,9 +557,15 @@ class RendererWebGPU extends Renderer3D { _useShader(shader, options) {} _updateViewport() { + this._origViewport = { + width: this.width, + height: this.height, + }; this._viewport = [0, 0, this.width, this.height]; } + viewport() {} + zClipRange() { return [0, 1]; } @@ -573,14 +605,27 @@ class RendererWebGPU extends Renderer3D { if (!buffers) return; const commandEncoder = this.device.createCommandEncoder(); - const currentTexture = this.drawingContext.getCurrentTexture(); + + // 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: currentTexture.createView(), + view: colorTexture.createView(), loadOp: "load", storeOp: "store", + // If using multisampled texture, resolve to non-multisampled texture + resolveTarget: activeFramebuffer && activeFramebuffer.aaColorTexture ? + activeFramebuffer.colorTexture.createView() : undefined, }; - const depthTextureView = this.depthTexture?.createView(); + // 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 renderPassDescriptor = { colorAttachments: [colorAttachment], depthStencilAttachment: depthTextureView @@ -1155,6 +1200,14 @@ class RendererWebGPU extends Renderer3D { // Framebuffer methods ////////////////////////////////////////////// + defaultFramebufferAlpha() { + return true + } + + defaultFramebufferAntialias() { + return true; + } + supportsFramebufferAntialias() { return true; } @@ -1189,40 +1242,138 @@ class RendererWebGPU extends Renderer3D { } recreateFramebufferTextures(framebuffer) { + // Clean up existing textures if (framebuffer.colorTexture && framebuffer.colorTexture.destroy) { framebuffer.colorTexture.destroy(); } + if (framebuffer.aaColorTexture && framebuffer.aaColorTexture.destroy) { + framebuffer.aaColorTexture.destroy(); + } if (framebuffer.depthTexture && framebuffer.depthTexture.destroy) { framebuffer.depthTexture.destroy(); } + if (framebuffer.aaDepthTexture && framebuffer.aaDepthTexture.destroy) { + framebuffer.aaDepthTexture.destroy(); + } + // Clear cached views when recreating textures + framebuffer._colorTextureView = null; - const colorTextureDescriptor = { + const baseDescriptor = { size: { width: framebuffer.width * framebuffer.density, height: framebuffer.height * framebuffer.density, depthOrArrayLayers: 1, }, format: this._getWebGPUColorFormat(framebuffer), - usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC, - sampleCount: framebuffer.antialias ? framebuffer.antialiasSamples : 1, }; + // Create non-multisampled texture for texture binding (always needed) + const colorTextureDescriptor = { + ...baseDescriptor, + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC, + sampleCount: 1, + }; framebuffer.colorTexture = this.device.createTexture(colorTextureDescriptor); + // Create multisampled texture for rendering if antialiasing is enabled + if (framebuffer.antialias) { + const aaColorTextureDescriptor = { + ...baseDescriptor, + usage: GPUTextureUsage.RENDER_ATTACHMENT, + sampleCount: this._getValidSampleCount(framebuffer.antialiasSamples), + }; + framebuffer.aaColorTexture = this.device.createTexture(aaColorTextureDescriptor); + } + if (framebuffer.useDepth) { - const depthTextureDescriptor = { + const depthBaseDescriptor = { size: { width: framebuffer.width * framebuffer.density, height: framebuffer.height * framebuffer.density, depthOrArrayLayers: 1, }, format: this._getWebGPUDepthFormat(framebuffer), - usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, - sampleCount: framebuffer.antialias ? framebuffer.antialiasSamples : 1, }; + // Create non-multisampled depth texture for texture binding (always needed) + const depthTextureDescriptor = { + ...depthBaseDescriptor, + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, + sampleCount: 1, + }; framebuffer.depthTexture = this.device.createTexture(depthTextureDescriptor); + + // Create multisampled depth texture for rendering if antialiasing is enabled + if (framebuffer.antialias) { + const aaDepthTextureDescriptor = { + ...depthBaseDescriptor, + usage: GPUTextureUsage.RENDER_ATTACHMENT, + sampleCount: this._getValidSampleCount(framebuffer.antialiasSamples), + }; + framebuffer.aaDepthTexture = this.device.createTexture(aaDepthTextureDescriptor); + } + } + + // Clear the framebuffer textures after creation + this._clearFramebufferTextures(framebuffer); + } + + _clearFramebufferTextures(framebuffer) { + 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(), + loadOp: "clear", + storeOp: "store", + clearValue: { r: 0, g: 0, b: 0, a: 0 }, + resolveTarget: framebuffer.aaColorTexture ? + framebuffer.colorTexture.createView() : undefined, + }; + + // Clear the depth texture if it exists + const depthTexture = framebuffer.aaDepthTexture || framebuffer.depthTexture; + const depthStencilAttachment = depthTexture ? { + view: depthTexture.createView(), + depthLoadOp: "clear", + depthStoreOp: "store", + depthClearValue: 1.0, + stencilLoadOp: "clear", + stencilStoreOp: "store", + depthReadOnly: false, + stencilReadOnly: false, + } : undefined; + + const renderPassDescriptor = { + colorAttachments: [colorAttachment], + depthStencilAttachment: depthStencilAttachment, + }; + + const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.end(); + + this.queue.submit([commandEncoder.finish()]); + } + + _getFramebufferColorTextureView(framebuffer) { + if (!framebuffer._colorTextureView && framebuffer.colorTexture) { + framebuffer._colorTextureView = framebuffer.colorTexture.createView(); } + return framebuffer._colorTextureView; + } + + createFramebufferTextureHandle(framebufferTexture) { + const src = framebufferTexture; + let renderer = this; + return { + get view() { + return renderer._getFramebufferColorTextureView(src.framebuffer); + }, + get gpuTexture() { + return src.framebuffer.colorTexture; + } + }; } _getWebGPUColorFormat(framebuffer) { @@ -1263,6 +1414,9 @@ class RendererWebGPU extends Renderer3D { if (framebuffer.depthTexture && framebuffer.depthTexture.destroy) { framebuffer.depthTexture.destroy(); } + if (framebuffer.aaDepthTexture && framebuffer.aaDepthTexture.destroy) { + framebuffer.aaDepthTexture.destroy(); + } } getFramebufferToBind(framebuffer) { @@ -1275,6 +1429,9 @@ class RendererWebGPU extends Renderer3D { bindFramebuffer(framebuffer) {} async readFramebufferPixels(framebuffer) { + // Ensure all pending GPU work is complete before reading pixels + await this.queue.onSubmittedWorkDone(); + const width = framebuffer.width * framebuffer.density; const height = framebuffer.height * framebuffer.density; const bytesPerPixel = 4; @@ -1284,8 +1441,8 @@ class RendererWebGPU extends Renderer3D { const commandEncoder = this.device.createCommandEncoder(); commandEncoder.copyTextureToBuffer( - { texture: framebuffer.colorTexture }, - { buffer: stagingBuffer, bytesPerRow: width * bytesPerPixel }, + { texture: framebuffer.colorTexture, origin: { x: 0, y: 0, z: 0 } }, + { buffer: stagingBuffer, bytesPerRow: width * bytesPerPixel, rowsPerImage: height }, { width, height, depthOrArrayLayers: 1 } ); @@ -1300,6 +1457,9 @@ class RendererWebGPU extends Renderer3D { } async readFramebufferPixel(framebuffer, x, y) { + // Ensure all pending GPU work is complete before reading pixels + await this.queue.onSubmittedWorkDone(); + const bytesPerPixel = 4; const stagingBuffer = this._ensurePixelReadBuffer(bytesPerPixel); @@ -1325,6 +1485,9 @@ class RendererWebGPU extends Renderer3D { } async readFramebufferRegion(framebuffer, x, y, w, h) { + // Ensure all pending GPU work is complete before reading pixels + await this.queue.onSubmittedWorkDone(); + const width = w * framebuffer.density; const height = h * framebuffer.density; const bytesPerPixel = 4; @@ -1391,6 +1554,9 @@ class RendererWebGPU extends Renderer3D { ////////////////////////////////////////////// async loadPixels() { + // Ensure all pending GPU work is complete before reading pixels + await this.queue.onSubmittedWorkDone(); + const width = this.width * this._pixelDensity; const height = this.height * this._pixelDensity; const bytesPerPixel = 4; @@ -1419,6 +1585,9 @@ class RendererWebGPU extends Renderer3D { } async _getPixel(x, y) { + // Ensure all pending GPU work is complete before reading pixels + await this.queue.onSubmittedWorkDone(); + const bytesPerPixel = 4; const stagingBuffer = this._ensurePixelReadBuffer(bytesPerPixel); @@ -1467,6 +1636,9 @@ class RendererWebGPU extends Renderer3D { // get(x,y,w,h) - region } + // Ensure all pending GPU work is complete before reading pixels + await this.queue.onSubmittedWorkDone(); + // Read region and create p5.Image const width = w * pd; const height = h * pd; @@ -1487,17 +1659,19 @@ class RendererWebGPU extends Renderer3D { ); this.device.queue.submit([commandEncoder.finish()]); + await this.queue.onSubmittedWorkDone(); await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize); const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); const pixelData = new Uint8Array(mappedRange.slice(0, bufferSize)); + console.log(pixelData) const region = new Image(width, height); region.pixelDensity(pd); - region.imageData = region.canvas.getContext('2d').createImageData(width, height); - region.imageData.data.set(pixelData); - region.pixels = region.imageData.data; - region.updatePixels(); + const ctx = region.canvas.getContext('2d'); + const imageData = ctx.createImageData(width, height); + imageData.data.set(pixelData); + ctx.putImageData(imageData, 0, 0); stagingBuffer.unmap(); return region; diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index 363626807a..9c0502ce39 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -19,7 +19,7 @@ visualSuite('WebGPU', function() { p5.circle(0, 0, 20); p5.pop(); } - screenshot(); + await screenshot(); }); visualTest('The stroke shader runs successfully', async function(p5, screenshot) { @@ -34,7 +34,7 @@ visualSuite('WebGPU', function() { p5.circle(0, 0, 20); p5.pop(); } - screenshot(); + await screenshot(); }); visualTest('The material shader runs successfully', async function(p5, screenshot) { @@ -54,7 +54,7 @@ visualSuite('WebGPU', function() { p5.sphere(10); p5.pop(); } - screenshot(); + await screenshot(); }); visualTest('Shader hooks can be used', async function(p5, screenshot) { @@ -80,7 +80,7 @@ visualSuite('WebGPU', function() { p5.stroke('white'); p5.strokeWeight(5); p5.circle(0, 0, 30); - screenshot(); + await screenshot(); }); visualTest('Textures in the material shader work', async function(p5, screenshot) { @@ -100,17 +100,17 @@ visualSuite('WebGPU', function() { p5.texture(tex); p5.plane(p5.width, p5.height); - screenshot(); + await screenshot(); }); }); visualSuite('Framebuffers', function() { visualTest('Basic framebuffer draw to canvas', async function(p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - + // Create a framebuffer const fbo = p5.createFramebuffer({ width: 25, height: 25 }); - + // Draw to the framebuffer fbo.draw(() => { p5.background(255, 0, 0); // Red background @@ -118,23 +118,23 @@ visualSuite('WebGPU', function() { p5.noStroke(); p5.circle(12.5, 12.5, 20); }); - + // Draw the framebuffer to the main canvas p5.background(0, 0, 255); // Blue background p5.texture(fbo); p5.noStroke(); p5.plane(25, 25); - - screenshot(); + + await screenshot(); }); visualTest('Framebuffer with different sizes', async function(p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - + // Create two different sized framebuffers const fbo1 = p5.createFramebuffer({ width: 20, height: 20 }); const fbo2 = p5.createFramebuffer({ width: 15, height: 15 }); - + // Draw to first framebuffer fbo1.draw(() => { p5.background(255, 100, 100); @@ -142,15 +142,15 @@ visualSuite('WebGPU', function() { p5.noStroke(); p5.rect(5, 5, 10, 10); }); - - // Draw to second framebuffer + + // Draw to second framebuffer fbo2.draw(() => { p5.background(100, 255, 100); p5.fill(255, 0, 255); p5.noStroke(); p5.circle(7.5, 7.5, 10); }); - + // Draw both to main canvas p5.background(50); p5.push(); @@ -159,23 +159,23 @@ visualSuite('WebGPU', function() { p5.noStroke(); p5.plane(20, 20); p5.pop(); - + p5.push(); p5.translate(12.5, 12.5); p5.texture(fbo2); p5.noStroke(); p5.plane(15, 15); p5.pop(); - - screenshot(); + + await screenshot(); }); visualTest('Auto-sized framebuffer', async function(p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - + // Create auto-sized framebuffer (should match canvas size) const fbo = p5.createFramebuffer(); - + // Draw to the framebuffer fbo.draw(() => { p5.background(0); @@ -193,24 +193,24 @@ visualSuite('WebGPU', function() { p5.noStroke(); p5.circle(25, 25, 15); }); - + // Draw the framebuffer to fill the main canvas p5.texture(fbo); p5.noStroke(); p5.plane(50, 50); - - screenshot(); + + await screenshot(); }); visualTest('Auto-sized framebuffer after canvas resize', async function(p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - + // Create auto-sized framebuffer const fbo = p5.createFramebuffer(); - + // Resize the canvas (framebuffer should auto-resize) p5.resizeCanvas(30, 30); - + // Draw to the framebuffer after resize fbo.draw(() => { p5.background(100, 0, 100); @@ -221,21 +221,21 @@ visualSuite('WebGPU', function() { p5.fill(255, 255, 0); p5.circle(15, 15, 10); }); - + // Draw the framebuffer to the main canvas p5.texture(fbo); p5.noStroke(); p5.plane(30, 30); - - screenshot(); + + await screenshot(); }); visualTest('Fixed-size framebuffer after manual resize', async function(p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - + // Create fixed-size framebuffer const fbo = p5.createFramebuffer({ width: 20, height: 20 }); - + // Draw initial content fbo.draw(() => { p5.background(255, 200, 100); @@ -243,10 +243,10 @@ visualSuite('WebGPU', function() { p5.noStroke(); p5.circle(10, 10, 15); }); - + // Manually resize the framebuffer fbo.resize(35, 25); - + // Draw new content to the resized framebuffer fbo.draw(() => { p5.background(200, 255, 100); @@ -257,14 +257,14 @@ visualSuite('WebGPU', function() { p5.fill(0, 0, 255); p5.circle(17.5, 12.5, 8); }); - + // Draw the resized framebuffer to the main canvas p5.background(50); p5.texture(fbo); p5.noStroke(); p5.plane(35, 25); - - screenshot(); + + await screenshot(); }); }); }); diff --git a/test/unit/visual/visualTest.js b/test/unit/visual/visualTest.js index 120ce79565..7d301d142b 100644 --- a/test/unit/visual/visualTest.js +++ b/test/unit/visual/visualTest.js @@ -89,43 +89,43 @@ export function visualSuite( /** * Image Diff Algorithm for p5.js Visual Tests - * + * * This algorithm addresses the challenge of cross-platform rendering differences in p5.js visual tests. * Different operating systems and browsers render graphics with subtle variations, particularly with * anti-aliasing, text rendering, and sub-pixel positioning. This can cause false negatives in tests * when the visual differences are acceptable rendering variations rather than actual bugs. - * + * * Key components of the approach: - * + * * 1. Initial pixel-by-pixel comparison: * - Uses pixelmatch to identify differences between expected and actual images * - Sets a moderate threshold (0.5) to filter out minor color/intensity variations * - Produces a diff image with red pixels marking differences - * + * * 2. Cluster identification using BFS (Breadth-First Search): * - Groups connected difference pixels into clusters * - Uses a queue-based BFS algorithm to find all connected pixels * - Defines connectivity based on 8-way adjacency (all surrounding pixels) - * + * * 3. Cluster categorization by type: * - Analyzes each pixel's neighborhood characteristics * - Specifically identifies "line shift" clusters - differences that likely represent * the same visual elements shifted by 1px due to platform rendering differences * - Line shifts are identified when >80% of pixels in a cluster have ≤2 neighboring diff pixels - * + * * 4. Intelligent failure criteria: * - Filters out clusters smaller than MIN_CLUSTER_SIZE pixels (noise reduction) * - Applies different thresholds for regular differences vs. line shifts * - Considers both the total number of significant pixels and number of distinct clusters - * - * This approach balances the need to catch genuine visual bugs (like changes to shape geometry, + * + * This approach balances the need to catch genuine visual bugs (like changes to shape geometry, * colors, or positioning) while tolerating acceptable cross-platform rendering variations. - * + * * Parameters: * - MIN_CLUSTER_SIZE: Minimum size for a cluster to be considered significant (default: 4) * - MAX_TOTAL_DIFF_PIXELS: Maximum allowed non-line-shift difference pixels (default: 40) * Note: These can be adjusted for further updation - * + * * Note for contributors: When running tests locally, you may not see these differences as they * mainly appear when tests run on different operating systems or browser rendering engines. * However, the same code may produce slightly different renderings on CI environments, particularly @@ -140,7 +140,7 @@ export async function checkMatch(actual, expected, p5) { if (narrow) { scale *= 2; } - + for (const img of [actual, expected]) { img.resize( Math.ceil(img.width * scale), @@ -151,28 +151,28 @@ export async function checkMatch(actual, expected, p5) { // Ensure both images have the same dimensions const width = expected.width; const height = expected.height; - + // Create canvases with background color const actualCanvas = p5.createGraphics(width, height); const expectedCanvas = p5.createGraphics(width, height); actualCanvas.pixelDensity(1); expectedCanvas.pixelDensity(1); - + actualCanvas.background(BG); expectedCanvas.background(BG); - + actualCanvas.image(actual, 0, 0); expectedCanvas.image(expected, 0, 0); - + // Load pixel data actualCanvas.loadPixels(); expectedCanvas.loadPixels(); - + // Create diff output canvas const diffCanvas = p5.createGraphics(width, height); diffCanvas.pixelDensity(1); diffCanvas.loadPixels(); - + // Run pixelmatch const diffCount = pixelmatch( actualCanvas.pixels, @@ -180,13 +180,13 @@ export async function checkMatch(actual, expected, p5) { diffCanvas.pixels, width, height, - { + { threshold: 0.5, includeAA: false, alpha: 0.1 } ); - + // If no differences, return early if (diffCount === 0) { actualCanvas.remove(); @@ -194,19 +194,19 @@ export async function checkMatch(actual, expected, p5) { diffCanvas.updatePixels(); return { ok: true, diff: diffCanvas }; } - + // Post-process to identify and filter out isolated differences const visited = new Set(); const clusterSizes = []; - + for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const pos = (y * width + x) * 4; - + // If this is a diff pixel (red in pixelmatch output) and not yet visited if ( - diffCanvas.pixels[pos] === 255 && - diffCanvas.pixels[pos + 1] === 0 && + diffCanvas.pixels[pos] === 255 && + diffCanvas.pixels[pos + 1] === 0 && diffCanvas.pixels[pos + 2] === 0 && !visited.has(pos) ) { @@ -216,37 +216,37 @@ export async function checkMatch(actual, expected, p5) { } } } - + // Define significance thresholds const MIN_CLUSTER_SIZE = 4; // Minimum pixels in a significant cluster const MAX_TOTAL_DIFF_PIXELS = 40; // Maximum total different pixels // Determine if the differences are significant const nonLineShiftClusters = clusterSizes.filter(c => !c.isLineShift && c.size >= MIN_CLUSTER_SIZE); - + // Calculate significant differences excluding line shifts const significantDiffPixels = nonLineShiftClusters.reduce((sum, c) => sum + c.size, 0); // Update the diff canvas diffCanvas.updatePixels(); - + // Clean up canvases actualCanvas.remove(); expectedCanvas.remove(); - + // Determine test result const ok = ( - diffCount === 0 || + diffCount === 0 || ( - significantDiffPixels === 0 || + significantDiffPixels === 0 || ( - (significantDiffPixels <= MAX_TOTAL_DIFF_PIXELS) && + (significantDiffPixels <= MAX_TOTAL_DIFF_PIXELS) && (nonLineShiftClusters.length <= 2) // Not too many significant clusters ) ) ); - return { + return { ok, diff: diffCanvas, details: { @@ -264,31 +264,31 @@ function findClusterSize(pixels, startX, startY, width, height, radius, visited) const queue = [{x: startX, y: startY}]; let size = 0; const clusterPixels = []; - + while (queue.length > 0) { const {x, y} = queue.shift(); const pos = (y * width + x) * 4; - + // Skip if already visited if (visited.has(pos)) continue; - + // Skip if not a diff pixel if (pixels[pos] !== 255 || pixels[pos + 1] !== 0 || pixels[pos + 2] !== 0) continue; - + // Mark as visited visited.add(pos); size++; clusterPixels.push({x, y}); - + // Add neighbors to queue for (let dy = -radius; dy <= radius; dy++) { for (let dx = -radius; dx <= radius; dx++) { const nx = x + dx; const ny = y + dy; - + // Skip if out of bounds if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; - + // Skip if already visited const npos = (ny * width + nx) * 4; if (!visited.has(npos)) { @@ -302,20 +302,20 @@ function findClusterSize(pixels, startX, startY, width, height, radius, visited) if (clusterPixels.length > 0) { // Count pixels with limited neighbors (line-like characteristic) let linelikePixels = 0; - + for (const {x, y} of clusterPixels) { // Count neighbors let neighbors = 0; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { if (dx === 0 && dy === 0) continue; // Skip self - + const nx = x + dx; const ny = y + dy; - + // Skip if out of bounds if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; - + const npos = (ny * width + nx) * 4; // Check if neighbor is a diff pixel if (pixels[npos] === 255 && pixels[npos + 1] === 0 && pixels[npos + 2] === 0) { @@ -323,13 +323,13 @@ function findClusterSize(pixels, startX, startY, width, height, radius, visited) } } } - + // Line-like pixels typically have 1-2 neighbors if (neighbors <= 2) { linelikePixels++; } } - + // If most pixels (>80%) in the cluster have ≤2 neighbors, it's likely a line shift isLineShift = linelikePixels / clusterPixels.length > 0.8; } @@ -407,8 +407,8 @@ export function visualTest( const actual = []; // Generate screenshots - await callback(myp5, () => { - const img = myp5.get(); + await callback(myp5, async () => { + const img = await myp5.get(); img.pixelDensity(1); actual.push(img); }); From 4fd6c19b68fee4632793ba5d23604165547f2eb7 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Mon, 28 Jul 2025 18:29:43 -0400 Subject: [PATCH 04/26] Add fixes for other gl-specific cases --- src/webgl/3d_primitives.js | 2 +- src/webgpu/p5.RendererWebGPU.js | 29 +- test/unit/visual/cases/webgpu.js | 424 ++++++++++++++++-------------- test/unit/webgl/p5.Framebuffer.js | 13 +- 4 files changed, 246 insertions(+), 222 deletions(-) diff --git a/src/webgl/3d_primitives.js b/src/webgl/3d_primitives.js index 8c29e3ea2d..386a64535d 100644 --- a/src/webgl/3d_primitives.js +++ b/src/webgl/3d_primitives.js @@ -1869,7 +1869,7 @@ function primitives3D(p5, fn){ if (typeof args[4] === 'undefined') { // Use the retained mode for drawing rectangle, // if args for rounding rectangle is not provided by user. - const perPixelLighting = this._pInst._glAttributes.perPixelLighting; + const perPixelLighting = this._pInst._glAttributes?.perPixelLighting; const detailX = args[4] || (perPixelLighting ? 1 : 24); const detailY = args[5] || (perPixelLighting ? 1 : 16); const gid = `rect|${detailX}|${detailY}`; diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 29ffcbf40d..cad16a1765 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -210,7 +210,7 @@ class RendererWebGPU extends Renderer3D { this._getWebGPUColorFormat(activeFramebuffer) : this.presentationFormat; - const requestedSampleCount = activeFramebuffer ? + const requestedSampleCount = activeFramebuffer ? (activeFramebuffer.antialias ? activeFramebuffer.antialiasSamples : 1) : (this.antialias || 1); const sampleCount = this._getValidSampleCount(requestedSampleCount); @@ -564,6 +564,12 @@ class RendererWebGPU extends Renderer3D { this._viewport = [0, 0, this.width, this.height]; } + _createPixelsArray() { + this.pixels = new Uint8Array( + this.width * this.pixelDensity() * this.height * this.pixelDensity() * 4 + ); + } + viewport() {} zClipRange() { @@ -605,25 +611,25 @@ class RendererWebGPU extends Renderer3D { if (!buffers) return; const commandEncoder = this.device.createCommandEncoder(); - + // Use framebuffer texture if active, otherwise use canvas texture const activeFramebuffer = this.activeFramebuffer(); - const colorTexture = activeFramebuffer ? - (activeFramebuffer.aaColorTexture || activeFramebuffer.colorTexture) : + const colorTexture = activeFramebuffer ? + (activeFramebuffer.aaColorTexture || activeFramebuffer.colorTexture) : this.drawingContext.getCurrentTexture(); - + const colorAttachment = { view: colorTexture.createView(), loadOp: "load", storeOp: "store", // If using multisampled texture, resolve to non-multisampled texture - resolveTarget: activeFramebuffer && activeFramebuffer.aaColorTexture ? + resolveTarget: activeFramebuffer && activeFramebuffer.aaColorTexture ? activeFramebuffer.colorTexture.createView() : undefined, }; // Use framebuffer depth texture if active, otherwise use canvas depth texture - const depthTexture = activeFramebuffer ? - (activeFramebuffer.aaDepthTexture || activeFramebuffer.depthTexture) : + const depthTexture = activeFramebuffer ? + (activeFramebuffer.aaDepthTexture || activeFramebuffer.depthTexture) : this.depthTexture; const depthTextureView = depthTexture?.createView(); const renderPassDescriptor = { @@ -1313,14 +1319,14 @@ class RendererWebGPU extends Renderer3D { framebuffer.aaDepthTexture = this.device.createTexture(aaDepthTextureDescriptor); } } - + // Clear the framebuffer textures after creation this._clearFramebufferTextures(framebuffer); } _clearFramebufferTextures(framebuffer) { const commandEncoder = this.device.createCommandEncoder(); - + // Clear the color texture (and multisampled texture if it exists) const colorTexture = framebuffer.aaColorTexture || framebuffer.colorTexture; const colorAttachment = { @@ -1328,7 +1334,7 @@ class RendererWebGPU extends Renderer3D { loadOp: "clear", storeOp: "store", clearValue: { r: 0, g: 0, b: 0, a: 0 }, - resolveTarget: framebuffer.aaColorTexture ? + resolveTarget: framebuffer.aaColorTexture ? framebuffer.colorTexture.createView() : undefined, }; @@ -1664,7 +1670,6 @@ class RendererWebGPU extends Renderer3D { await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize); const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); const pixelData = new Uint8Array(mappedRange.slice(0, bufferSize)); - console.log(pixelData) const region = new Image(width, height); region.pixelDensity(pd); diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index 9c0502ce39..334abc1be1 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -1,176 +1,194 @@ -import { vi } from 'vitest'; -import p5 from '../../../../src/app'; -import { visualSuite, visualTest } from '../visualTest'; -import rendererWebGPU from '../../../../src/webgpu/p5.RendererWebGPU'; +import { vi } from "vitest"; +import p5 from "../../../../src/app"; +import { visualSuite, visualTest } from "../visualTest"; +import rendererWebGPU from "../../../../src/webgpu/p5.RendererWebGPU"; p5.registerAddon(rendererWebGPU); -visualSuite('WebGPU', function() { - visualSuite('Shaders', function() { - visualTest('The color shader runs successfully', async function(p5, screenshot) { - await p5.createCanvas(50, 50, p5.WEBGPU); - p5.background('white'); - for (const [i, color] of ['red', 'lime', 'blue'].entries()) { - p5.push(); - p5.rotate(p5.TWO_PI * (i / 3)); - p5.fill(color); - p5.translate(15, 0); - p5.noStroke(); - p5.circle(0, 0, 20); - p5.pop(); - } - await screenshot(); - }); - - visualTest('The stroke shader runs successfully', async function(p5, screenshot) { - await p5.createCanvas(50, 50, p5.WEBGPU); - p5.background('white'); - for (const [i, color] of ['red', 'lime', 'blue'].entries()) { - p5.push(); - p5.rotate(p5.TWO_PI * (i / 3)); - p5.translate(15, 0); - p5.stroke(color); - p5.strokeWeight(2); - p5.circle(0, 0, 20); - p5.pop(); - } - await screenshot(); - }); - - visualTest('The material shader runs successfully', async function(p5, screenshot) { - await p5.createCanvas(50, 50, p5.WEBGPU); - p5.background('white'); - p5.ambientLight(50); - p5.directionalLight(100, 100, 100, 0, 1, -1); - p5.pointLight(155, 155, 155, 0, -200, 500); - p5.specularMaterial(255); - p5.shininess(300); - for (const [i, color] of ['red', 'lime', 'blue'].entries()) { - p5.push(); - p5.rotate(p5.TWO_PI * (i / 3)); - p5.fill(color); - p5.translate(15, 0); - p5.noStroke(); - p5.sphere(10); - p5.pop(); - } - await screenshot(); - }); +visualSuite("WebGPU", function () { + visualSuite("Shaders", function () { + visualTest( + "The color shader runs successfully", + async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + p5.background("white"); + for (const [i, color] of ["red", "lime", "blue"].entries()) { + p5.push(); + p5.rotate(p5.TWO_PI * (i / 3)); + p5.fill(color); + p5.translate(15, 0); + p5.noStroke(); + p5.circle(0, 0, 20); + p5.pop(); + } + await screenshot(); + }, + ); + + visualTest( + "The stroke shader runs successfully", + async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + p5.background("white"); + for (const [i, color] of ["red", "lime", "blue"].entries()) { + p5.push(); + p5.rotate(p5.TWO_PI * (i / 3)); + p5.translate(15, 0); + p5.stroke(color); + p5.strokeWeight(2); + p5.circle(0, 0, 20); + p5.pop(); + } + await screenshot(); + }, + ); + + visualTest( + "The material shader runs successfully", + async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + p5.background("white"); + p5.ambientLight(50); + p5.directionalLight(100, 100, 100, 0, 1, -1); + p5.pointLight(155, 155, 155, 0, -200, 500); + p5.specularMaterial(255); + p5.shininess(300); + for (const [i, color] of ["red", "lime", "blue"].entries()) { + p5.push(); + p5.rotate(p5.TWO_PI * (i / 3)); + p5.fill(color); + p5.translate(15, 0); + p5.noStroke(); + p5.sphere(10); + p5.pop(); + } + await screenshot(); + }, + ); - visualTest('Shader hooks can be used', async function(p5, screenshot) { + visualTest("Shader hooks can be used", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); const myFill = p5.baseMaterialShader().modify({ - 'Vertex getWorldInputs': `(inputs: Vertex) { + "Vertex getWorldInputs": `(inputs: Vertex) { var result = inputs; result.position.y += 10.0 * sin(inputs.position.x * 0.25); return result; }`, }); const myStroke = p5.baseStrokeShader().modify({ - 'StrokeVertex getWorldInputs': `(inputs: StrokeVertex) { + "StrokeVertex getWorldInputs": `(inputs: StrokeVertex) { var result = inputs; result.position.y += 10.0 * sin(inputs.position.x * 0.25); return result; }`, }); - p5.background('black'); + p5.background("black"); p5.shader(myFill); p5.strokeShader(myStroke); - p5.fill('red'); - p5.stroke('white'); + p5.fill("red"); + p5.stroke("white"); p5.strokeWeight(5); p5.circle(0, 0, 30); await screenshot(); }); - visualTest('Textures in the material shader work', async function(p5, screenshot) { - await p5.createCanvas(50, 50, p5.WEBGPU); - const tex = p5.createImage(50, 50); - tex.loadPixels(); - for (let x = 0; x < tex.width; x++) { - for (let y = 0; y < tex.height; y++) { - const off = (x + y * tex.width) * 4; - tex.pixels[off] = p5.round((x / tex.width) * 255); - tex.pixels[off + 1] = p5.round((y / tex.height) * 255); - tex.pixels[off + 2] = 0; - tex.pixels[off + 3] = 255; + visualTest( + "Textures in the material shader work", + async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + const tex = p5.createImage(50, 50); + tex.loadPixels(); + for (let x = 0; x < tex.width; x++) { + for (let y = 0; y < tex.height; y++) { + const off = (x + y * tex.width) * 4; + tex.pixels[off] = p5.round((x / tex.width) * 255); + tex.pixels[off + 1] = p5.round((y / tex.height) * 255); + tex.pixels[off + 2] = 0; + tex.pixels[off + 3] = 255; + } } - } - tex.updatePixels(); - p5.texture(tex); - p5.plane(p5.width, p5.height); + tex.updatePixels(); + p5.texture(tex); + p5.plane(p5.width, p5.height); - await screenshot(); - }); + await screenshot(); + }, + ); }); - visualSuite('Framebuffers', function() { - visualTest('Basic framebuffer draw to canvas', async function(p5, screenshot) { - await p5.createCanvas(50, 50, p5.WEBGPU); - - // Create a framebuffer - const fbo = p5.createFramebuffer({ width: 25, height: 25 }); - - // Draw to the framebuffer - fbo.draw(() => { - p5.background(255, 0, 0); // Red background - p5.fill(0, 255, 0); // Green circle + visualSuite("Framebuffers", function () { + visualTest( + "Basic framebuffer draw to canvas", + async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Create a framebuffer + const fbo = p5.createFramebuffer({ width: 25, height: 25 }); + + // Draw to the framebuffer + fbo.draw(() => { + p5.background(255, 0, 0); // Red background + p5.fill(0, 255, 0); // Green circle + p5.noStroke(); + p5.circle(12.5, 12.5, 20); + }); + + // Draw the framebuffer to the main canvas + p5.background(0, 0, 255); // Blue background + p5.texture(fbo); p5.noStroke(); - p5.circle(12.5, 12.5, 20); - }); - - // Draw the framebuffer to the main canvas - p5.background(0, 0, 255); // Blue background - p5.texture(fbo); - p5.noStroke(); - p5.plane(25, 25); - - await screenshot(); - }); - - visualTest('Framebuffer with different sizes', async function(p5, screenshot) { - await p5.createCanvas(50, 50, p5.WEBGPU); - - // Create two different sized framebuffers - const fbo1 = p5.createFramebuffer({ width: 20, height: 20 }); - const fbo2 = p5.createFramebuffer({ width: 15, height: 15 }); - - // Draw to first framebuffer - fbo1.draw(() => { - p5.background(255, 100, 100); - p5.fill(255, 255, 0); + p5.plane(25, 25); + + await screenshot(); + }, + ); + + visualTest( + "Framebuffer with different sizes", + async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Create two different sized framebuffers + const fbo1 = p5.createFramebuffer({ width: 20, height: 20 }); + const fbo2 = p5.createFramebuffer({ width: 15, height: 15 }); + + // Draw to first framebuffer + fbo1.draw(() => { + p5.background(255, 100, 100); + p5.fill(255, 255, 0); + p5.noStroke(); + p5.rect(5, 5, 10, 10); + }); + + // Draw to second framebuffer + fbo2.draw(() => { + p5.background(100, 255, 100); + p5.fill(255, 0, 255); + p5.noStroke(); + p5.circle(7.5, 7.5, 10); + }); + + // Draw both to main canvas + p5.background(50); + p5.push(); + p5.translate(-12.5, -12.5); + p5.texture(fbo1); p5.noStroke(); - p5.rect(5, 5, 10, 10); - }); + p5.plane(20, 20); + p5.pop(); - // Draw to second framebuffer - fbo2.draw(() => { - p5.background(100, 255, 100); - p5.fill(255, 0, 255); + p5.push(); + p5.translate(12.5, 12.5); + p5.texture(fbo2); p5.noStroke(); - p5.circle(7.5, 7.5, 10); - }); - - // Draw both to main canvas - p5.background(50); - p5.push(); - p5.translate(-12.5, -12.5); - p5.texture(fbo1); - p5.noStroke(); - p5.plane(20, 20); - p5.pop(); + p5.plane(15, 15); + p5.pop(); - p5.push(); - p5.translate(12.5, 12.5); - p5.texture(fbo2); - p5.noStroke(); - p5.plane(15, 15); - p5.pop(); + await screenshot(); + }, + ); - await screenshot(); - }); - - visualTest('Auto-sized framebuffer', async function(p5, screenshot) { + visualTest("Auto-sized framebuffer", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); // Create auto-sized framebuffer (should match canvas size) @@ -202,69 +220,75 @@ visualSuite('WebGPU', function() { await screenshot(); }); - visualTest('Auto-sized framebuffer after canvas resize', async function(p5, screenshot) { - await p5.createCanvas(50, 50, p5.WEBGPU); - - // Create auto-sized framebuffer - const fbo = p5.createFramebuffer(); - - // Resize the canvas (framebuffer should auto-resize) - p5.resizeCanvas(30, 30); - - // Draw to the framebuffer after resize - fbo.draw(() => { - p5.background(100, 0, 100); - p5.fill(0, 255, 255); + visualTest( + "Auto-sized framebuffer after canvas resize", + async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Create auto-sized framebuffer + const fbo = p5.createFramebuffer(); + + // Resize the canvas (framebuffer should auto-resize) + p5.resizeCanvas(30, 30); + + // Draw to the framebuffer after resize + fbo.draw(() => { + p5.background(100, 0, 100); + p5.fill(0, 255, 255); + p5.noStroke(); + // Draw a shape that fills the new size + p5.rect(5, 5, 20, 20); + p5.fill(255, 255, 0); + p5.circle(15, 15, 10); + }); + + // Draw the framebuffer to the main canvas + p5.texture(fbo); p5.noStroke(); - // Draw a shape that fills the new size - p5.rect(5, 5, 20, 20); - p5.fill(255, 255, 0); - p5.circle(15, 15, 10); - }); - - // Draw the framebuffer to the main canvas - p5.texture(fbo); - p5.noStroke(); - p5.plane(30, 30); - - await screenshot(); - }); - - visualTest('Fixed-size framebuffer after manual resize', async function(p5, screenshot) { - await p5.createCanvas(50, 50, p5.WEBGPU); - - // Create fixed-size framebuffer - const fbo = p5.createFramebuffer({ width: 20, height: 20 }); - - // Draw initial content - fbo.draw(() => { - p5.background(255, 200, 100); - p5.fill(0, 100, 200); - p5.noStroke(); - p5.circle(10, 10, 15); - }); - - // Manually resize the framebuffer - fbo.resize(35, 25); - - // Draw new content to the resized framebuffer - fbo.draw(() => { - p5.background(200, 255, 100); - p5.fill(200, 0, 100); + p5.plane(30, 30); + + await screenshot(); + }, + ); + + visualTest( + "Fixed-size framebuffer after manual resize", + async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Create fixed-size framebuffer + const fbo = p5.createFramebuffer({ width: 20, height: 20 }); + + // Draw initial content + fbo.draw(() => { + p5.background(255, 200, 100); + p5.fill(0, 100, 200); + p5.noStroke(); + p5.circle(10, 10, 15); + }); + + // Manually resize the framebuffer + fbo.resize(35, 25); + + // Draw new content to the resized framebuffer + fbo.draw(() => { + p5.background(200, 255, 100); + p5.fill(200, 0, 100); + p5.noStroke(); + // Draw content that uses the new size + p5.rect(5, 5, 25, 15); + p5.fill(0, 0, 255); + p5.circle(17.5, 12.5, 8); + }); + + // Draw the resized framebuffer to the main canvas + p5.background(50); + p5.texture(fbo); p5.noStroke(); - // Draw content that uses the new size - p5.rect(5, 5, 25, 15); - p5.fill(0, 0, 255); - p5.circle(17.5, 12.5, 8); - }); + p5.plane(35, 25); - // Draw the resized framebuffer to the main canvas - p5.background(50); - p5.texture(fbo); - p5.noStroke(); - p5.plane(35, 25); - - await screenshot(); - }); + await screenshot(); + }, + ); }); }); diff --git a/test/unit/webgl/p5.Framebuffer.js b/test/unit/webgl/p5.Framebuffer.js index f97cb6b57d..6a6d556351 100644 --- a/test/unit/webgl/p5.Framebuffer.js +++ b/test/unit/webgl/p5.Framebuffer.js @@ -461,7 +461,7 @@ suite('p5.Framebuffer', function() { } }); - test('get() creates a p5.Image with 1x pixel density', function() { + test('get() creates a p5.Image matching the source pixel density', function() { const mainCanvas = myp5.createCanvas(20, 20, myp5.WEBGL); myp5.pixelDensity(2); const fbo = myp5.createFramebuffer(); @@ -482,22 +482,17 @@ suite('p5.Framebuffer', function() { myp5.pop(); }); const img = fbo.get(); - const p2d = myp5.createGraphics(20, 20); - p2d.pixelDensity(1); myp5.image(fbo, -10, -10); - p2d.image(mainCanvas, 0, 0); fbo.loadPixels(); img.loadPixels(); - p2d.loadPixels(); expect(img.width).to.equal(fbo.width); expect(img.height).to.equal(fbo.height); - expect(img.pixels.length).to.equal(fbo.pixels.length / 4); - // The pixels should be approximately the same in the 1x image as when we - // draw the framebuffer onto a 1x canvas + expect(img.pixels.length).to.equal(fbo.pixels.length); + // The pixels should be approximately the same as the framebuffer's for (let i = 0; i < img.pixels.length; i++) { - expect(img.pixels[i]).to.be.closeTo(p2d.pixels[i], 2); + expect(img.pixels[i]).to.be.closeTo(fbo.pixels[i], 2); } }); }); From edce0299a0a6e89cbc867ad2b9d47f59df4aad57 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Mon, 28 Jul 2025 21:00:19 -0400 Subject: [PATCH 05/26] Fix canvas readback --- src/core/main.js | 4 +- src/webgpu/p5.RendererWebGPU.js | 253 ++++++++++++++++++++++++----- test/unit/webgpu/p5.Framebuffer.js | 35 ++-- 3 files changed, 235 insertions(+), 57 deletions(-) diff --git a/src/core/main.js b/src/core/main.js index a5c9a6c93d..f9fc1c6559 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -468,11 +468,11 @@ for (const k in constants) { * If `setup()` is declared `async` (e.g. `async function setup()`), * execution pauses at each `await` until its promise resolves. * For example, `font = await loadFont(...)` waits for the font asset - * to load because `loadFont()` function returns a promise, and the await + * to load because `loadFont()` function returns a promise, and the await * keyword means the program will wait for the promise to resolve. * This ensures that all assets are fully loaded before the sketch continues. - * + * * loading assets. * * Note: `setup()` doesn’t have to be declared, but it’s common practice to do so. diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index cad16a1765..383cf9f97f 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -23,6 +23,9 @@ class RendererWebGPU extends Renderer3D { // Single reusable staging buffer for pixel reading this.pixelReadBuffer = null; this.pixelReadBufferSize = 0; + + // Lazy readback texture for main canvas pixel reading + this.canvasReadbackTexture = null; } async setupContext() { @@ -62,6 +65,12 @@ class RendererWebGPU extends Renderer3D { format: this.depthFormat, usage: GPUTextureUsage.RENDER_ATTACHMENT, }); + + // Destroy existing readback texture when size changes + if (this.canvasReadbackTexture && this.canvasReadbackTexture.destroy) { + this.canvasReadbackTexture.destroy(); + this.canvasReadbackTexture = null; + } } clear(...args) { @@ -71,16 +80,28 @@ class RendererWebGPU extends Renderer3D { const _a = args[3] || 0; const commandEncoder = this.device.createCommandEncoder(); - const textureView = this.drawingContext.getCurrentTexture().createView(); + + // 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: textureView, + view: colorTexture.createView(), 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, }; - const depthTextureView = this.depthTexture?.createView(); + // 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 depthAttachment = depthTextureView ? { view: depthTextureView, @@ -1202,6 +1223,11 @@ class RendererWebGPU extends Renderer3D { return this.pixelReadBuffer; } + _alignBytesPerRow(bytesPerRow) { + // WebGPU requires bytesPerRow to be a multiple of 256 bytes for texture-to-buffer copies + return Math.ceil(bytesPerRow / 256) * 256; + } + ////////////////////////////////////////////// // Framebuffer methods ////////////////////////////////////////////// @@ -1435,31 +1461,56 @@ class RendererWebGPU extends Renderer3D { bindFramebuffer(framebuffer) {} async readFramebufferPixels(framebuffer) { - // Ensure all pending GPU work is complete before reading pixels - await this.queue.onSubmittedWorkDone(); - const width = framebuffer.width * framebuffer.density; const height = framebuffer.height * framebuffer.density; const bytesPerPixel = 4; - const bufferSize = width * height * bytesPerPixel; - - const stagingBuffer = this._ensurePixelReadBuffer(bufferSize); + const unalignedBytesPerRow = width * bytesPerPixel; + const alignedBytesPerRow = this._alignBytesPerRow(unalignedBytesPerRow); + const bufferSize = alignedBytesPerRow * height; + + // const stagingBuffer = this._ensurePixelReadBuffer(bufferSize); + const stagingBuffer = this.device.createBuffer({ + size: bufferSize, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }); const commandEncoder = this.device.createCommandEncoder(); commandEncoder.copyTextureToBuffer( - { texture: framebuffer.colorTexture, origin: { x: 0, y: 0, z: 0 } }, - { buffer: stagingBuffer, bytesPerRow: width * bytesPerPixel, rowsPerImage: height }, + { + texture: framebuffer.colorTexture, + origin: { x: 0, y: 0, z: 0 }, + mipLevel: 0, + aspect: 'all' + }, + { buffer: stagingBuffer, bytesPerRow: alignedBytesPerRow, rowsPerImage: height }, { width, height, depthOrArrayLayers: 1 } ); this.device.queue.submit([commandEncoder.finish()]); + // Wait for the copy operation to complete + // await this.queue.onSubmittedWorkDone(); + await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize); const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); - const result = new Uint8Array(mappedRange.slice(0, bufferSize)); - stagingBuffer.unmap(); - return result; + // If alignment was needed, extract the actual pixel data + if (alignedBytesPerRow === unalignedBytesPerRow) { + const 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); + const mappedData = new Uint8Array(mappedRange); + for (let y = 0; y < height; y++) { + const srcOffset = y * alignedBytesPerRow; + const dstOffset = y * unalignedBytesPerRow; + result.set(mappedData.subarray(srcOffset, srcOffset + unalignedBytesPerRow), dstOffset); + } + stagingBuffer.unmap(); + return result; + } } async readFramebufferPixel(framebuffer, x, y) { @@ -1467,7 +1518,10 @@ class RendererWebGPU extends Renderer3D { await this.queue.onSubmittedWorkDone(); const bytesPerPixel = 4; - const stagingBuffer = this._ensurePixelReadBuffer(bytesPerPixel); + const alignedBytesPerRow = this._alignBytesPerRow(bytesPerPixel); + const bufferSize = alignedBytesPerRow; + + const stagingBuffer = this._ensurePixelReadBuffer(bufferSize); const commandEncoder = this.device.createCommandEncoder(); commandEncoder.copyTextureToBuffer( @@ -1475,14 +1529,14 @@ class RendererWebGPU extends Renderer3D { texture: framebuffer.colorTexture, origin: { x, y, z: 0 } }, - { buffer: stagingBuffer, bytesPerRow: bytesPerPixel }, + { buffer: stagingBuffer, bytesPerRow: alignedBytesPerRow }, { width: 1, height: 1, depthOrArrayLayers: 1 } ); this.device.queue.submit([commandEncoder.finish()]); - await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bytesPerPixel); - const mappedRange = stagingBuffer.getMappedRange(0, bytesPerPixel); + await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize); + const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); const pixelData = new Uint8Array(mappedRange); const result = [pixelData[0], pixelData[1], pixelData[2], pixelData[3]]; @@ -1497,7 +1551,9 @@ class RendererWebGPU extends Renderer3D { const width = w * framebuffer.density; const height = h * framebuffer.density; const bytesPerPixel = 4; - const bufferSize = width * height * bytesPerPixel; + const unalignedBytesPerRow = width * bytesPerPixel; + const alignedBytesPerRow = this._alignBytesPerRow(unalignedBytesPerRow); + const bufferSize = alignedBytesPerRow * height; const stagingBuffer = this._ensurePixelReadBuffer(bufferSize); @@ -1505,9 +1561,10 @@ class RendererWebGPU extends Renderer3D { commandEncoder.copyTextureToBuffer( { texture: framebuffer.colorTexture, + mipLevel: 0, origin: { x: x * framebuffer.density, y: y * framebuffer.density, z: 0 } }, - { buffer: stagingBuffer, bytesPerRow: width * bytesPerPixel }, + { buffer: stagingBuffer, bytesPerRow: alignedBytesPerRow }, { width, height, depthOrArrayLayers: 1 } ); @@ -1515,7 +1572,20 @@ class RendererWebGPU extends Renderer3D { await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize); const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); - const pixelData = new Uint8Array(mappedRange.slice(0, bufferSize)); + + let pixelData; + if (alignedBytesPerRow === unalignedBytesPerRow) { + pixelData = new Uint8Array(mappedRange.slice(0, width * height * bytesPerPixel)); + } else { + // Need to extract pixel data from aligned buffer + pixelData = new Uint8Array(width * height * bytesPerPixel); + const mappedData = new Uint8Array(mappedRange); + for (let y = 0; y < height; y++) { + const srcOffset = y * alignedBytesPerRow; + const dstOffset = y * unalignedBytesPerRow; + pixelData.set(mappedData.subarray(srcOffset, srcOffset + unalignedBytesPerRow), dstOffset); + } + } // WebGPU doesn't need vertical flipping unlike WebGL const region = new Image(width, height); @@ -1559,24 +1629,75 @@ class RendererWebGPU extends Renderer3D { // Main canvas pixel methods ////////////////////////////////////////////// - async loadPixels() { - // Ensure all pending GPU work is complete before reading pixels - await this.queue.onSubmittedWorkDone(); + _ensureCanvasReadbackTexture() { + if (!this.canvasReadbackTexture) { + const width = Math.ceil(this.width * this._pixelDensity); + const height = Math.ceil(this.height * this._pixelDensity); + + this.canvasReadbackTexture = this.device.createTexture({ + size: { width, height, depthOrArrayLayers: 1 }, + format: this.presentationFormat, + usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC, + }); + } + return this.canvasReadbackTexture; + } + + _copyCanvasToReadbackTexture() { + // Get the current canvas texture BEFORE any awaiting + const canvasTexture = this.drawingContext.getCurrentTexture(); + + // Ensure readback texture exists + const readbackTexture = this._ensureCanvasReadbackTexture(); + + // Copy canvas texture to readback texture immediately + const copyEncoder = this.device.createCommandEncoder(); + copyEncoder.copyTextureToTexture( + { texture: canvasTexture }, + { texture: readbackTexture }, + { + width: Math.ceil(this.width * this._pixelDensity), + height: Math.ceil(this.height * this._pixelDensity), + depthOrArrayLayers: 1 + } + ); + this.device.queue.submit([copyEncoder.finish()]); + + return readbackTexture; + } + + _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 + } + return pixelData; + } + async loadPixels() { const width = this.width * this._pixelDensity; const height = this.height * this._pixelDensity; + + // Copy canvas to readback texture + const readbackTexture = this._copyCanvasToReadbackTexture(); + + // Now we can safely await + await this.queue.onSubmittedWorkDone(); + const bytesPerPixel = 4; - const bufferSize = width * height * bytesPerPixel; + const unalignedBytesPerRow = width * bytesPerPixel; + const alignedBytesPerRow = this._alignBytesPerRow(unalignedBytesPerRow); + const bufferSize = alignedBytesPerRow * height; const stagingBuffer = this._ensurePixelReadBuffer(bufferSize); - // Get the current canvas texture - const canvasTexture = this.drawingContext.getCurrentTexture(); - const commandEncoder = this.device.createCommandEncoder(); commandEncoder.copyTextureToBuffer( - { texture: canvasTexture }, - { buffer: stagingBuffer, bytesPerRow: width * bytesPerPixel }, + { texture: readbackTexture }, + { buffer: stagingBuffer, bytesPerRow: alignedBytesPerRow }, { width, height, depthOrArrayLayers: 1 } ); @@ -1584,36 +1705,58 @@ class RendererWebGPU extends Renderer3D { await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize); const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); - this.pixels = new Uint8Array(mappedRange.slice(0, bufferSize)); + + if (alignedBytesPerRow === unalignedBytesPerRow) { + this.pixels = new Uint8Array(mappedRange.slice(0, width * height * bytesPerPixel)); + } else { + // Need to extract pixel data from aligned buffer + this.pixels = new Uint8Array(width * height * bytesPerPixel); + const mappedData = new Uint8Array(mappedRange); + for (let y = 0; y < height; y++) { + const srcOffset = y * alignedBytesPerRow; + const dstOffset = y * unalignedBytesPerRow; + this.pixels.set(mappedData.subarray(srcOffset, srcOffset + unalignedBytesPerRow), dstOffset); + } + } + + // Convert BGR to RGB for main canvas + this._convertBGRtoRGB(this.pixels); stagingBuffer.unmap(); return this.pixels; } async _getPixel(x, y) { - // Ensure all pending GPU work is complete before reading pixels + // Copy canvas to readback texture + const readbackTexture = this._copyCanvasToReadbackTexture(); + + // Now we can safely await await this.queue.onSubmittedWorkDone(); const bytesPerPixel = 4; - const stagingBuffer = this._ensurePixelReadBuffer(bytesPerPixel); + const alignedBytesPerRow = this._alignBytesPerRow(bytesPerPixel); + const bufferSize = alignedBytesPerRow; + + const stagingBuffer = this._ensurePixelReadBuffer(bufferSize); - const canvasTexture = this.drawingContext.getCurrentTexture(); const commandEncoder = this.device.createCommandEncoder(); commandEncoder.copyTextureToBuffer( { - texture: canvasTexture, + texture: readbackTexture, origin: { x, y, z: 0 } }, - { buffer: stagingBuffer, bytesPerRow: bytesPerPixel }, + { buffer: stagingBuffer, bytesPerRow: alignedBytesPerRow }, { width: 1, height: 1, depthOrArrayLayers: 1 } ); this.device.queue.submit([commandEncoder.finish()]); - await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bytesPerPixel); - const mappedRange = stagingBuffer.getMappedRange(0, bytesPerPixel); + await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize); + const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); const pixelData = new Uint8Array(mappedRange); - const result = [pixelData[0], pixelData[1], pixelData[2], pixelData[3]]; + + // Convert BGR to RGB for main canvas - swap red and blue + const result = [pixelData[2], pixelData[1], pixelData[0], pixelData[3]]; stagingBuffer.unmap(); return result; @@ -1642,25 +1785,29 @@ class RendererWebGPU extends Renderer3D { // get(x,y,w,h) - region } - // Ensure all pending GPU work is complete before reading pixels + // Copy canvas to readback texture + const readbackTexture = this._copyCanvasToReadbackTexture(); + + // Now we can safely await await this.queue.onSubmittedWorkDone(); // Read region and create p5.Image const width = w * pd; const height = h * pd; const bytesPerPixel = 4; - const bufferSize = width * height * bytesPerPixel; + const unalignedBytesPerRow = width * bytesPerPixel; + const alignedBytesPerRow = this._alignBytesPerRow(unalignedBytesPerRow); + const bufferSize = alignedBytesPerRow * height; const stagingBuffer = this._ensurePixelReadBuffer(bufferSize); - const canvasTexture = this.drawingContext.getCurrentTexture(); const commandEncoder = this.device.createCommandEncoder(); commandEncoder.copyTextureToBuffer( { - texture: canvasTexture, + texture: readbackTexture, origin: { x, y, z: 0 } }, - { buffer: stagingBuffer, bytesPerRow: width * bytesPerPixel }, + { buffer: stagingBuffer, bytesPerRow: alignedBytesPerRow }, { width, height, depthOrArrayLayers: 1 } ); @@ -1669,7 +1816,23 @@ class RendererWebGPU extends Renderer3D { await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize); const mappedRange = stagingBuffer.getMappedRange(0, bufferSize); - const pixelData = new Uint8Array(mappedRange.slice(0, bufferSize)); + + let pixelData; + if (alignedBytesPerRow === unalignedBytesPerRow) { + pixelData = new Uint8Array(mappedRange.slice(0, width * height * bytesPerPixel)); + } else { + // Need to extract pixel data from aligned buffer + pixelData = new Uint8Array(width * height * bytesPerPixel); + const mappedData = new Uint8Array(mappedRange); + for (let y = 0; y < height; y++) { + const srcOffset = y * alignedBytesPerRow; + const dstOffset = y * unalignedBytesPerRow; + pixelData.set(mappedData.subarray(srcOffset, srcOffset + unalignedBytesPerRow), dstOffset); + } + } + + // Convert BGR to RGB for main canvas + this._convertBGRtoRGB(pixelData); const region = new Image(width, height); region.pixelDensity(pd); diff --git a/test/unit/webgpu/p5.Framebuffer.js b/test/unit/webgpu/p5.Framebuffer.js index 9fec2f070d..452585b6c8 100644 --- a/test/unit/webgpu/p5.Framebuffer.js +++ b/test/unit/webgpu/p5.Framebuffer.js @@ -1,4 +1,7 @@ import p5 from '../../../src/app.js'; +import rendererWebGPU from "../../../src/webgpu/p5.RendererWebGPU"; + +p5.registerAddon(rendererWebGPU); suite('WebGPU p5.Framebuffer', function() { let myp5; @@ -9,7 +12,6 @@ suite('WebGPU p5.Framebuffer', function() { window.devicePixelRatio = 1; myp5 = new p5(function(p) { p.setup = function() {}; - p.draw = function() {}; }); }); @@ -153,16 +155,26 @@ suite('WebGPU p5.Framebuffer', function() { await myp5.createCanvas(10, 10, myp5.WEBGPU); const fbo = myp5.createFramebuffer(); - let drawCallbackExecuted = false; + myp5.background(0, 255, 0); + fbo.draw(() => { - drawCallbackExecuted = true; - myp5.background(255, 0, 0); - myp5.fill(0, 255, 0); - myp5.noStroke(); - myp5.circle(5, 5, 8); + myp5.background(0, 0, 255); + // myp5.fill(0, 255, 0); }); - - expect(drawCallbackExecuted).to.equal(true); + await myp5.loadPixels(); + // Drawing should have gone to the framebuffer, leaving the main + // canvas the same + expect([...myp5.pixels.slice(0, 3)]).toEqual([0, 255, 0]); + await fbo.loadPixels(); + // The framebuffer should have content + expect([...fbo.pixels.slice(0, 3)]).toEqual([0, 0, 255]); + + // The content can be drawn back to the main canvas + myp5.imageMode(myp5.CENTER); + myp5.image(fbo, 0, 0); + await myp5.loadPixels(); + expect([...fbo.pixels.slice(0, 3)]).toEqual([0, 0, 255]); + expect([...myp5.pixels.slice(0, 3)]).toEqual([0, 0, 255]); }); test('can use framebuffer as texture', async function() { @@ -194,8 +206,9 @@ suite('WebGPU p5.Framebuffer', function() { expect(result).to.be.a('promise'); const pixels = await result; - expect(pixels).to.be.an('array'); + expect(pixels).toBeInstanceOf(Uint8Array); expect(pixels.length).to.equal(10 * 10 * 4); + expect([...pixels.slice(0, 4)]).toEqual([255, 0, 0, 255]); }); test('pixels property is set after loadPixels resolves', async function() { @@ -225,6 +238,7 @@ suite('WebGPU p5.Framebuffer', function() { const color = await result; expect(color).to.be.an('array'); expect(color).to.have.length(4); + expect([...color]).toEqual([100, 150, 200, 255]); }); test('get() returns a promise for region in WebGPU', async function() { @@ -242,6 +256,7 @@ suite('WebGPU p5.Framebuffer', function() { expect(region).to.be.an('object'); // Should be a p5.Image expect(region.width).to.equal(4); expect(region.height).to.equal(4); + expect([...region.pixels.slice(0, 4)]).toEqual([100, 150, 200, 255]); }); }); }); From 9fc319f996db258fbd6839ec5afb65682970d4f1 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Tue, 29 Jul 2025 11:32:18 -0400 Subject: [PATCH 06/26] Start adding tests --- test/unit/visual/cases/webgpu.js | 11 +++++++---- .../000.png | Bin 0 -> 132 bytes .../metadata.json | 3 +++ .../Framebuffers/Auto-sized framebuffer/000.png | Bin 0 -> 396 bytes .../Auto-sized framebuffer/metadata.json | 3 +++ .../Basic framebuffer draw to canvas/000.png | Bin 0 -> 521 bytes .../metadata.json | 3 +++ .../000.png | Bin 0 -> 291 bytes .../metadata.json | 3 +++ .../Framebuffer with different sizes/000.png | Bin 0 -> 402 bytes .../metadata.json | 3 +++ 11 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 test/unit/visual/screenshots/WebGPU/Framebuffers/Auto-sized framebuffer after canvas resize/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Framebuffers/Auto-sized framebuffer after canvas resize/metadata.json create mode 100644 test/unit/visual/screenshots/WebGPU/Framebuffers/Auto-sized framebuffer/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Framebuffers/Auto-sized framebuffer/metadata.json create mode 100644 test/unit/visual/screenshots/WebGPU/Framebuffers/Basic framebuffer draw to canvas/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Framebuffers/Basic framebuffer draw to canvas/metadata.json create mode 100644 test/unit/visual/screenshots/WebGPU/Framebuffers/Fixed-size framebuffer after manual resize/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Framebuffers/Fixed-size framebuffer after manual resize/metadata.json create mode 100644 test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with different sizes/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with different sizes/metadata.json diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index 334abc1be1..5613d39091 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -130,7 +130,7 @@ visualSuite("WebGPU", function () { p5.background(255, 0, 0); // Red background p5.fill(0, 255, 0); // Green circle p5.noStroke(); - p5.circle(12.5, 12.5, 20); + p5.circle(0, 0, 20); }); // Draw the framebuffer to the main canvas @@ -157,7 +157,7 @@ visualSuite("WebGPU", function () { p5.background(255, 100, 100); p5.fill(255, 255, 0); p5.noStroke(); - p5.rect(5, 5, 10, 10); + p5.rect(-5, -5, 10, 10); }); // Draw to second framebuffer @@ -165,7 +165,7 @@ visualSuite("WebGPU", function () { p5.background(100, 255, 100); p5.fill(255, 0, 255); p5.noStroke(); - p5.circle(7.5, 7.5, 10); + p5.circle(0, 0, 10); }); // Draw both to main canvas @@ -197,6 +197,7 @@ visualSuite("WebGPU", function () { // Draw to the framebuffer fbo.draw(() => { p5.background(0); + p5.translate(-fbo.width / 2, -fbo.height / 2) p5.stroke(255); p5.strokeWeight(2); p5.noFill(); @@ -234,6 +235,7 @@ visualSuite("WebGPU", function () { // Draw to the framebuffer after resize fbo.draw(() => { p5.background(100, 0, 100); + p5.translate(-fbo.width / 2, -fbo.height / 2) p5.fill(0, 255, 255); p5.noStroke(); // Draw a shape that fills the new size @@ -264,7 +266,7 @@ visualSuite("WebGPU", function () { p5.background(255, 200, 100); p5.fill(0, 100, 200); p5.noStroke(); - p5.circle(10, 10, 15); + p5.circle(0, 0, 15); }); // Manually resize the framebuffer @@ -273,6 +275,7 @@ visualSuite("WebGPU", function () { // Draw new content to the resized framebuffer fbo.draw(() => { p5.background(200, 255, 100); + p5.translate(-fbo.width / 2, -fbo.height / 2) p5.fill(200, 0, 100); p5.noStroke(); // Draw content that uses the new size diff --git a/test/unit/visual/screenshots/WebGPU/Framebuffers/Auto-sized framebuffer after canvas resize/000.png b/test/unit/visual/screenshots/WebGPU/Framebuffers/Auto-sized framebuffer after canvas resize/000.png new file mode 100644 index 0000000000000000000000000000000000000000..972571631e30265fef58735d0666a400049f596e GIT binary patch literal 132 zcmeAS@N?(olHy`uVBq!ia0vp^av;pX1|+Qw)-3{3jKx9jP7LeL$-D$|>^xl@Lp;3S zUf#&dV8Fp*c%we^*B5@*GYkrm?$Px$MoC0LRA@u(Spg1%FbIU7xtBR#Hh;!OQ;k_{hm~q|XJWEMHaWQC(6NXJGgIym zk#M=yxR4uwthDf~&Y>eIJ)t9s%2*2nYJpm@0#?dc<7R4Q-S8q8gCSld*KC?__O-O~B3B zp9{*+kCzz~sWmdwBU6=AG!hoJX>BtCQNX$PH-`fW-6#a29np!lXXSC5T6=)#6w~hm zNUVU1*wyr2cc50lPza@v9p^N*`NR894i`oqb7#+|sC?(bZZSYdw!F`G3hZ zzE)v!ly>ov=?2!_$UDw$MF9|}|1Qyyl<2&BKD1+58M<1l`k q9lxMfp*XGFx5nL1BP|--)|}p3hPVXA^49VI0000Px$!%0LzRA@u(m=DduFc8Jx9N+*PfCF#<4wwUQKyUyKh68W_4!{98z`@8%v#@;M zn(pu2SniT$BxP%V_x7&sA|OpMfQSsFXDXT^Jre|`h$+%h2JF@9D6zF4Yl_rPT}L-o zRwQM`?l>Y;-~Jg$NR;;o$hXlOm^=YqL!qt+Cw`x~_8J$1Odr7F>Y#j~3i6x)=M`{S zIuJq?1px>$zxjdGKnQV)fn*KvcbOnid;-?Ve|1zq>Of?mJ{T2{BnI}o@=E+wi!hrb z5GUV4APzz`2tk-8LC@^=&NB>gkfv&6@GQv$*&Y-?Se2;8VcSHD@4bW|45367VTmOO zOKuxMj}U~O5e2y}90;#%#OmxoX7?Wl$s1l&k+xqgHAMzvcKU)9i$3w~tIB$Z2B$x0 zc$IaeEyL$4mI?@!;G%fkG~1%&)ldQ9po7^o)`jWaHa;^nm7!;Db9eQfOSlE$qPM&N zEV)G|MbUM{SCKNH$JD{lN1S}t#xDoL<~zUl0we<@KoQ7Ke0(UoZiN0PqDW{GTK$zj z&}mIHG_HY!G9=w^ib!WXG@dCE%8+!wDI%Tm(0HauC_~cygHhxIAOktx0yLiA00000 LNkvXXu0mjfpm^EI literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Framebuffers/Basic framebuffer draw to canvas/metadata.json b/test/unit/visual/screenshots/WebGPU/Framebuffers/Basic framebuffer draw to canvas/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Framebuffers/Basic framebuffer draw to canvas/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Framebuffers/Fixed-size framebuffer after manual resize/000.png b/test/unit/visual/screenshots/WebGPU/Framebuffers/Fixed-size framebuffer after manual resize/000.png new file mode 100644 index 0000000000000000000000000000000000000000..1fb817b6b53c94fe1a9aa8d3412e4fc2a846cb6d GIT binary patch literal 291 zcmV+;0o?wHP)Px#-AP12RA@u(n9&V_Fc^e?R;mNwJT8GQ;XF8iuB7m2gf`_kLz<6QdRP9tuL;nl zl-|+6)iKDyTBMPK6%sT;Bc=pE%M|QpTN>FMok^H&3>r&m_QAoZDQ3L2U7 zYx|6h5%JJ4DXRO3IDfC&AFbQ8!L|4(+Jf1CV36Ms7taWdRL}-mD`A0F1x1TXK?}50 zhF0{T1zMn$Pv9?Sq?y-v6HO)C{l7;_JDTn@NK@bJCQ2-}{QI@UDwxuU5S~R!BbHbN pQyLM%vq)*g602ZJBSLr<=?C8Wx=p=J5DWkS002ovPDHLkV1j}$d4K=_ literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Framebuffers/Fixed-size framebuffer after manual resize/metadata.json b/test/unit/visual/screenshots/WebGPU/Framebuffers/Fixed-size framebuffer after manual resize/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Framebuffers/Fixed-size framebuffer after manual resize/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with different sizes/000.png b/test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with different sizes/000.png new file mode 100644 index 0000000000000000000000000000000000000000..155638a0c818aa5432045a7fc8a9c574122ea8e3 GIT binary patch literal 402 zcmV;D0d4+?P)Px$Oi4sRRA@u(n9UJ_Fc5{8GHk%yV*zRb&;=cESMPRU7f^s&0J+BoC<7;qGV;4l zjIzY##*k$4?R#&@B#=^;;K37k2a-$aRF{=fPS?X0s}*68fl{#|n1=?w|B1Ck0kACa z8r>EEdeIn1%UYoVh~DTvsRYQHU5`O<{d-4@XM`02U~93p9;e^lBMN7PGh&$#fbsV0 z&7a-6KGdwmAgqYB2mHHyur39WiL3|_A?j_gBCN=lD!|tIw6HR_ziU>j?#<3*ig*tq zE&{|=RD!3wv{!i2ibRJ9!hP7;c(%_vKx9FNAf8lh7_uOz;Dq*$xQy#DBtbAWp1VJu zkDxcYdYv`MtG!5qm@buU6VzJXfRN9Kkpj!fY`WGM%&h(+uK_XYm`AU5-KE+AA{llx wv^Nd9TV*yT%r+73jhEB07*qoM6N<$f)?JfjsO4v literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with different sizes/metadata.json b/test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with different sizes/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with different sizes/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file From 76c010898d0623ae5da5e7b383df936698165d82 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Tue, 29 Jul 2025 16:03:34 -0400 Subject: [PATCH 07/26] Add another test --- test/unit/visual/cases/webgpu.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index 5613d39091..a57caa1ff1 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -116,6 +116,23 @@ visualSuite("WebGPU", function () { ); }); + visualSuite("Canvas Resizing", function () { + visualTest( + "Main canvas drawing after resize", + async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + // Resize the canvas + p5.resizeCanvas(30, 30); + // Draw to the main canvas after resize + p5.background(100, 0, 100); + p5.fill(0, 255, 255); + p5.noStroke(); + p5.circle(0, 0, 20); + await screenshot(); + }, + ); + }); + visualSuite("Framebuffers", function () { visualTest( "Basic framebuffer draw to canvas", @@ -251,6 +268,7 @@ visualSuite("WebGPU", function () { await screenshot(); }, + { focus: true } ); visualTest( From a3daa14cb606b36a67cbb1436cd49043f9be0ad2 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Tue, 29 Jul 2025 16:13:43 -0400 Subject: [PATCH 08/26] Fix main canvas not being drawable after resizing --- src/webgpu/p5.RendererWebGPU.js | 11 ++++++----- test/unit/visual/cases/webgpu.js | 1 - .../000.png | Bin 132 -> 167 bytes 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 383cf9f97f..f85fd4607b 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -66,6 +66,9 @@ class RendererWebGPU extends Renderer3D { usage: GPUTextureUsage.RENDER_ATTACHMENT, }); + // Clear the main canvas after resize + this.clear(); + // Destroy existing readback texture when size changes if (this.canvasReadbackTexture && this.canvasReadbackTexture.destroy) { this.canvasReadbackTexture.destroy(); @@ -1287,8 +1290,6 @@ class RendererWebGPU extends Renderer3D { if (framebuffer.aaDepthTexture && framebuffer.aaDepthTexture.destroy) { framebuffer.aaDepthTexture.destroy(); } - // Clear cached views when recreating textures - framebuffer._colorTextureView = null; const baseDescriptor = { size: { @@ -1389,10 +1390,10 @@ class RendererWebGPU extends Renderer3D { } _getFramebufferColorTextureView(framebuffer) { - if (!framebuffer._colorTextureView && framebuffer.colorTexture) { - framebuffer._colorTextureView = framebuffer.colorTexture.createView(); + if (framebuffer.colorTexture) { + return framebuffer.colorTexture.createView(); } - return framebuffer._colorTextureView; + return null; } createFramebufferTextureHandle(framebufferTexture) { diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index a57caa1ff1..28382dda25 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -268,7 +268,6 @@ visualSuite("WebGPU", function () { await screenshot(); }, - { focus: true } ); visualTest( diff --git a/test/unit/visual/screenshots/WebGPU/Framebuffers/Auto-sized framebuffer after canvas resize/000.png b/test/unit/visual/screenshots/WebGPU/Framebuffers/Auto-sized framebuffer after canvas resize/000.png index 972571631e30265fef58735d0666a400049f596e..01be2eb74e88adf3364c6690d614b349cfa91f03 100644 GIT binary patch delta 125 zcmV-@0D}L70jB|wF?L}|L_t(YOJhu7Ncqn&0Dy7SVtR%8;0o$F|7TOx<0*`(80KO1 z@up(Xm%RSPNaswXaO=>fWXttXaPcM_CZ%qbatZDB4YFpu2v>7 fE~Zq?$n!A(ky>$a7wkmT00000NkvXXu0mjfc*i*f delta 90 zcmV-g0Hyz@0fYgNF;hNCL_t(YOYPIK4FE6*1ToluY5MdJMa%#oSx48=^wHgNcugKP w>X?AIVzlpK)TmDwV{wTqCh%We1L^kwA6Dmx82|tP07*qoM6N<$g69P$<^TWy From 01110c9f77923fd1b704b1a87efdd3152fed0567 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Tue, 29 Jul 2025 16:13:59 -0400 Subject: [PATCH 09/26] Add screenshots --- .../Main canvas drawing after resize/000.png | Bin 0 -> 225 bytes .../metadata.json | 3 +++ 2 files changed, 3 insertions(+) create mode 100644 test/unit/visual/screenshots/WebGPU/Canvas Resizing/Main canvas drawing after resize/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/Canvas Resizing/Main canvas drawing after resize/metadata.json diff --git a/test/unit/visual/screenshots/WebGPU/Canvas Resizing/Main canvas drawing after resize/000.png b/test/unit/visual/screenshots/WebGPU/Canvas Resizing/Main canvas drawing after resize/000.png new file mode 100644 index 0000000000000000000000000000000000000000..96849ce04c21325da234ba7192bc8c1bc63bce67 GIT binary patch literal 225 zcmeAS@N?(olHy`uVBq!ia0vp^av;pX1|+Qw)-3{3jKx9jP7LeL$-D$|W_!9ghIn|t zofgfFo`syt)gx-5OIFIp(nbZ(PLshJD(tw9jF0d>^YO=xU_ Date: Tue, 29 Jul 2025 18:06:51 -0400 Subject: [PATCH 10/26] Try setting different launch options --- vitest.workspace.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 7dfe0e6e82..e11dd11c53 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -38,7 +38,15 @@ export default defineWorkspace([ enabled: true, name: 'chrome', provider: 'webdriverio', - screenshotFailures: false + screenshotFailures: false, + launchOptions: { + args: [ + '--enable-unsafe-webgpu', + '--headless=new', + '--disable-gpu-sandbox', + '--no-sandbox', + ], + }, } } } From 02eef85af474bae627f5d4d8492b0db648b9da19 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Tue, 29 Jul 2025 18:13:01 -0400 Subject: [PATCH 11/26] Test different options --- vitest.workspace.mjs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index e11dd11c53..636e7a8db3 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -39,14 +39,17 @@ export default defineWorkspace([ name: 'chrome', provider: 'webdriverio', screenshotFailures: false, - launchOptions: { - args: [ - '--enable-unsafe-webgpu', - '--headless=new', - '--disable-gpu-sandbox', - '--no-sandbox', - ], - }, + providerOptions: { + capabilities: { + 'goog:chromeOptions': { + args: [ + '--enable-unsafe-webgpu', + '--enable-features=Vulkan', + '--disable-vulkan-fallback-to-gl-for-testing' + ] + } + } + } } } } From 3c6c19506f1543a7f07fa8abd77d586a2ca3e700 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Tue, 29 Jul 2025 18:31:00 -0400 Subject: [PATCH 12/26] Try sequential --- test/unit/visual/cases/webgpu.js | 2 +- test/unit/visual/visualTest.js | 5 ++++- test/unit/webgpu/p5.Framebuffer.js | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index 28382dda25..dde49a9d16 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -311,4 +311,4 @@ visualSuite("WebGPU", function () { }, ); }); -}); +}, { sequential: true }); diff --git a/test/unit/visual/visualTest.js b/test/unit/visual/visualTest.js index 7d301d142b..7841c5a84d 100644 --- a/test/unit/visual/visualTest.js +++ b/test/unit/visual/visualTest.js @@ -52,7 +52,7 @@ let shiftThreshold = 2; export function visualSuite( name, callback, - { focus = false, skip = false, shiftThreshold: newShiftThreshold } = {} + { focus = false, skip = false, sequential = false, shiftThreshold: newShiftThreshold } = {} ) { let suiteFn = describe; if (focus) { @@ -61,6 +61,9 @@ export function visualSuite( if (skip) { suiteFn = suiteFn.skip; } + if (sequential) { + suiteFn = suiteFn.sequential; + } suiteFn(name, () => { let lastShiftThreshold let lastPrefix; diff --git a/test/unit/webgpu/p5.Framebuffer.js b/test/unit/webgpu/p5.Framebuffer.js index 452585b6c8..08789e92d1 100644 --- a/test/unit/webgpu/p5.Framebuffer.js +++ b/test/unit/webgpu/p5.Framebuffer.js @@ -1,9 +1,10 @@ +import describe from '../../../src/accessibility/describe.js'; import p5 from '../../../src/app.js'; import rendererWebGPU from "../../../src/webgpu/p5.RendererWebGPU"; p5.registerAddon(rendererWebGPU); -suite('WebGPU p5.Framebuffer', function() { +suite.sequential('WebGPU p5.Framebuffer', function() { let myp5; let prevPixelRatio; From e4509570a43e8744ab8ab17e68fc6fd464eed227 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Tue, 29 Jul 2025 19:15:43 -0400 Subject: [PATCH 13/26] Attempt to install later chrome --- .github/workflows/ci-test.yml | 9 +++++++++ test/unit/visual/cases/webgpu.js | 2 +- test/unit/visual/visualTest.js | 5 +---- test/unit/webgpu/p5.Framebuffer.js | 3 +-- vitest.workspace.mjs | 10 +++++++--- 5 files changed, 19 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index bd3ca55ba0..31d8e36566 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -19,6 +19,15 @@ jobs: uses: actions/setup-node@v1 with: node-version: 20.x + - name: Install Chrome (latest stable) + run: | + sudo apt-get update + sudo apt-get install -y wget gnupg + wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add - + sudo sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list' + sudo apt-get update + sudo apt-get install -y google-chrome-stable + which google-chrome - name: Get node modules run: npm ci env: diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index dde49a9d16..28382dda25 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -311,4 +311,4 @@ visualSuite("WebGPU", function () { }, ); }); -}, { sequential: true }); +}); diff --git a/test/unit/visual/visualTest.js b/test/unit/visual/visualTest.js index 7841c5a84d..7d301d142b 100644 --- a/test/unit/visual/visualTest.js +++ b/test/unit/visual/visualTest.js @@ -52,7 +52,7 @@ let shiftThreshold = 2; export function visualSuite( name, callback, - { focus = false, skip = false, sequential = false, shiftThreshold: newShiftThreshold } = {} + { focus = false, skip = false, shiftThreshold: newShiftThreshold } = {} ) { let suiteFn = describe; if (focus) { @@ -61,9 +61,6 @@ export function visualSuite( if (skip) { suiteFn = suiteFn.skip; } - if (sequential) { - suiteFn = suiteFn.sequential; - } suiteFn(name, () => { let lastShiftThreshold let lastPrefix; diff --git a/test/unit/webgpu/p5.Framebuffer.js b/test/unit/webgpu/p5.Framebuffer.js index 08789e92d1..452585b6c8 100644 --- a/test/unit/webgpu/p5.Framebuffer.js +++ b/test/unit/webgpu/p5.Framebuffer.js @@ -1,10 +1,9 @@ -import describe from '../../../src/accessibility/describe.js'; import p5 from '../../../src/app.js'; import rendererWebGPU from "../../../src/webgpu/p5.RendererWebGPU"; p5.registerAddon(rendererWebGPU); -suite.sequential('WebGPU p5.Framebuffer', function() { +suite('WebGPU p5.Framebuffer', function() { let myp5; let prevPixelRatio; diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 636e7a8db3..611754fcdc 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -40,15 +40,19 @@ export default defineWorkspace([ provider: 'webdriverio', screenshotFailures: false, providerOptions: { - capabilities: { + capabilities: process.env.CI ? { 'goog:chromeOptions': { + binary: '/usr/bin/google-chrome', args: [ '--enable-unsafe-webgpu', + '--disable-dawn-features=disallow_unsafe_apis', + '--use-angle=default', '--enable-features=Vulkan', - '--disable-vulkan-fallback-to-gl-for-testing' + '--no-sandbox', + '--disable-dev-shm-usage', ] } - } + } : undefined } } } From f44629bac26cbc86c0eb04aca1549f19056da40e Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Tue, 29 Jul 2025 19:23:01 -0400 Subject: [PATCH 14/26] Add some debug info --- .github/workflows/ci-test.yml | 1 + vitest.workspace.mjs | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 31d8e36566..6a1bd0b549 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -28,6 +28,7 @@ jobs: sudo apt-get update sudo apt-get install -y google-chrome-stable which google-chrome + google-chrome --version - name: Get node modules run: npm ci env: diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 611754fcdc..2774fe1286 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -1,5 +1,6 @@ import { defineWorkspace } from 'vitest/config'; import vitePluginString from 'vite-plugin-string'; +console.log(`CI: ${process.env.CI}`) const plugins = [ vitePluginString({ From b281d332b5acfecacc32b3372f0929e2dc7ea226 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Tue, 29 Jul 2025 19:39:48 -0400 Subject: [PATCH 15/26] Try different flags --- vitest.workspace.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 2774fe1286..1aa54097e5 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -48,9 +48,10 @@ export default defineWorkspace([ '--enable-unsafe-webgpu', '--disable-dawn-features=disallow_unsafe_apis', '--use-angle=default', - '--enable-features=Vulkan', + '--enable-features=Vulkan,SharedArrayBuffer', '--no-sandbox', '--disable-dev-shm-usage', + '--headless=new', ] } } : undefined From 777334131df1ad9e122aaf809911909e71e91175 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Tue, 29 Jul 2025 19:47:19 -0400 Subject: [PATCH 16/26] Does it work in xvfb? --- .github/workflows/ci-test.yml | 2 +- vitest.workspace.mjs | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 6a1bd0b549..67684ad745 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -34,7 +34,7 @@ jobs: env: CI: true - name: build and test - run: npm test + run: xvfb-run --auto-servernum --server-args='-screen 0 1920x1080x24' npm test env: CI: true - name: report test coverage diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 1aa54097e5..9540289d24 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -46,12 +46,11 @@ export default defineWorkspace([ binary: '/usr/bin/google-chrome', args: [ '--enable-unsafe-webgpu', - '--disable-dawn-features=disallow_unsafe_apis', - '--use-angle=default', '--enable-features=Vulkan,SharedArrayBuffer', + '--disable-dawn-features=disallow_unsafe_apis', + '--disable-gpu-sandbox', '--no-sandbox', - '--disable-dev-shm-usage', - '--headless=new', + '--disable-dev-shm-usage' ] } } : undefined From cfeac932d62f32ea7cca9c88c52532d4a8432807 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Thu, 31 Jul 2025 20:42:15 -0400 Subject: [PATCH 17/26] Try enabling swiftshader --- vitest.workspace.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 9540289d24..d39212ef8e 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -45,12 +45,14 @@ export default defineWorkspace([ 'goog:chromeOptions': { binary: '/usr/bin/google-chrome', args: [ - '--enable-unsafe-webgpu', - '--enable-features=Vulkan,SharedArrayBuffer', '--disable-dawn-features=disallow_unsafe_apis', '--disable-gpu-sandbox', '--no-sandbox', - '--disable-dev-shm-usage' + '--disable-dev-shm-usage', + + '--enable-unsafe-webgpu', + '--use-angle=swiftshader', + '--enable-features=ReduceOpsTaskSplitting,Vulkan,VulkanFromANGLE,DefaultANGLEVulkan', ] } } : undefined From af5194a25a34ea21d2ba24c5b64ba99149cee4f4 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Thu, 31 Jul 2025 20:46:35 -0400 Subject: [PATCH 18/26] less flags --- vitest.workspace.mjs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index d39212ef8e..7fadc2434e 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -45,11 +45,6 @@ export default defineWorkspace([ 'goog:chromeOptions': { binary: '/usr/bin/google-chrome', args: [ - '--disable-dawn-features=disallow_unsafe_apis', - '--disable-gpu-sandbox', - '--no-sandbox', - '--disable-dev-shm-usage', - '--enable-unsafe-webgpu', '--use-angle=swiftshader', '--enable-features=ReduceOpsTaskSplitting,Vulkan,VulkanFromANGLE,DefaultANGLEVulkan', From 88b4fe4d31f604be349493cbb569f10b40b9d569 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Thu, 31 Jul 2025 20:53:42 -0400 Subject: [PATCH 19/26] Try disabling dawn --- vitest.workspace.mjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 7fadc2434e..4220f9aa26 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -46,8 +46,13 @@ export default defineWorkspace([ binary: '/usr/bin/google-chrome', args: [ '--enable-unsafe-webgpu', - '--use-angle=swiftshader', - '--enable-features=ReduceOpsTaskSplitting,Vulkan,VulkanFromANGLE,DefaultANGLEVulkan', + '--enable-features=Vulkan', + '--use-cmd-decoder=passthrough', + '--disable-gpu-sandbox', + '--disable-software-rasterizer=false', + '--disable-dawn-features=disallow_unsafe_apis', + '--use-angle=vulkan', + '--use-vulkan=swiftshader', ] } } : undefined From ff83226f86cd39817fea848732fc4bcb46cac391 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 1 Aug 2025 08:05:42 -0400 Subject: [PATCH 20/26] Try installing swiftshader? --- .github/workflows/ci-test.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 67684ad745..4e4c749ca1 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -19,6 +19,17 @@ jobs: uses: actions/setup-node@v1 with: node-version: 20.x + - name: Install Vulkan SwiftShader + run: | + sudo apt-get update + sudo apt-get install -y libvulkan1 vulkan-tools mesa-vulkan-drivers + mkdir -p $HOME/swiftshader + curl -L https://github.com/google/swiftshader/releases/download/latest/Linux.zip -o swiftshader.zip + unzip swiftshader.zip -d $HOME/swiftshader + export VK_ICD_FILENAMES=$HOME/swiftshader/Linux/vk_swiftshader_icd.json + export VK_LAYER_PATH=$HOME/swiftshader/Linux + echo "VK_ICD_FILENAMES=$VK_ICD_FILENAMES" >> $GITHUB_ENV + echo "VK_LAYER_PATH=$VK_LAYER_PATH" >> $GITHUB_ENV - name: Install Chrome (latest stable) run: | sudo apt-get update @@ -34,7 +45,7 @@ jobs: env: CI: true - name: build and test - run: xvfb-run --auto-servernum --server-args='-screen 0 1920x1080x24' npm test + run: npm test env: CI: true - name: report test coverage From 4314cf2d21d860cd0e41ae993b127c1c79bca201 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 1 Aug 2025 08:11:57 -0400 Subject: [PATCH 21/26] Just vulkan --- .github/workflows/ci-test.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 4e4c749ca1..12a6cde3b9 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -19,17 +19,10 @@ jobs: uses: actions/setup-node@v1 with: node-version: 20.x - - name: Install Vulkan SwiftShader + - name: Install Vulkan run: | sudo apt-get update sudo apt-get install -y libvulkan1 vulkan-tools mesa-vulkan-drivers - mkdir -p $HOME/swiftshader - curl -L https://github.com/google/swiftshader/releases/download/latest/Linux.zip -o swiftshader.zip - unzip swiftshader.zip -d $HOME/swiftshader - export VK_ICD_FILENAMES=$HOME/swiftshader/Linux/vk_swiftshader_icd.json - export VK_LAYER_PATH=$HOME/swiftshader/Linux - echo "VK_ICD_FILENAMES=$VK_ICD_FILENAMES" >> $GITHUB_ENV - echo "VK_LAYER_PATH=$VK_LAYER_PATH" >> $GITHUB_ENV - name: Install Chrome (latest stable) run: | sudo apt-get update From c01dee7285d2a90e5e535c9e2067c3c8b0307b23 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 1 Aug 2025 08:36:49 -0400 Subject: [PATCH 22/26] Try with xvfb --- .github/workflows/ci-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 12a6cde3b9..5289728040 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -38,7 +38,7 @@ jobs: env: CI: true - name: build and test - run: npm test + run: xvfb-run --auto-servernum --server-args='-screen 0 1280x1024x24' npm test env: CI: true - name: report test coverage From 7b3ed67261ce104e8ec1ad21aa7e5fafb2dc5dd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Wed, 6 Aug 2025 12:05:41 -0700 Subject: [PATCH 23/26] Test ci flow with warp. --- .github/workflows/ci-test.yml | 22 ++++++---------------- vitest.workspace.mjs | 14 ++++++-------- 2 files changed, 12 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 5289728040..0d3569d0a4 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -11,34 +11,24 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: windows-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Use Node.js 20.x - uses: actions/setup-node@v1 + uses: actions/setup-node@v4 with: node-version: 20.x - - name: Install Vulkan - run: | - sudo apt-get update - sudo apt-get install -y libvulkan1 vulkan-tools mesa-vulkan-drivers - name: Install Chrome (latest stable) run: | - sudo apt-get update - sudo apt-get install -y wget gnupg - wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add - - sudo sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list' - sudo apt-get update - sudo apt-get install -y google-chrome-stable - which google-chrome - google-chrome --version + choco install googlechrome + & "C:\Program Files\Google\Chrome\Application\chrome.exe" --version - name: Get node modules run: npm ci env: CI: true - name: build and test - run: xvfb-run --auto-servernum --server-args='-screen 0 1280x1024x24' npm test + run: npm test env: CI: true - name: report test coverage diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 4220f9aa26..943943eabd 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -43,16 +43,14 @@ export default defineWorkspace([ providerOptions: { capabilities: process.env.CI ? { 'goog:chromeOptions': { - binary: '/usr/bin/google-chrome', args: [ '--enable-unsafe-webgpu', - '--enable-features=Vulkan', - '--use-cmd-decoder=passthrough', - '--disable-gpu-sandbox', - '--disable-software-rasterizer=false', - '--disable-dawn-features=disallow_unsafe_apis', - '--use-angle=vulkan', - '--use-vulkan=swiftshader', + '--headless=new', + '--no-sandbox', + '--disable-dev-shm-usage', + '--use-gl=angle', + '--use-angle=d3d11-warp', + '--disable-gpu-sandbox' ] } } : undefined From 22f5294a35bc6a8ec9f6942dd173e15b9ed91a3f Mon Sep 17 00:00:00 2001 From: charlotte Date: Thu, 7 Aug 2025 16:44:09 -0700 Subject: [PATCH 24/26] Fixes for CI. --- .github/workflows/ci-test.yml | 41 ++++++++++++++++++---- src/webgpu/p5.RendererWebGPU.js | 2 +- src/webgpu/shaders/utils.js | 4 +-- vitest.workspace.mjs | 61 ++++++++++++++++++++++++++++----- 4 files changed, 90 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 0d3569d0a4..416d01777d 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -11,27 +11,54 @@ on: jobs: test: - runs-on: windows-latest + strategy: + matrix: + include: + - os: windows-latest + browser: firefox + test-workspace: unit-tests-firefox + - os: ubuntu-latest + browser: chrome + test-workspace: unit-tests-chrome + + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + - name: Use Node.js 20.x uses: actions/setup-node@v4 with: node-version: 20.x - - name: Install Chrome (latest stable) + + - name: Verify Firefox (Windows) + if: matrix.os == 'windows-latest' && matrix.browser == 'firefox' run: | - choco install googlechrome - & "C:\Program Files\Google\Chrome\Application\chrome.exe" --version + & "C:\Program Files\Mozilla Firefox\firefox.exe" --version + + - name: Verify Chrome (Ubuntu) + if: matrix.os == 'ubuntu-latest' && matrix.browser == 'chrome' + run: | + google-chrome --version + - name: Get node modules run: npm ci env: CI: true - - name: build and test - run: npm test + + - name: Build and test (Ubuntu) + if: matrix.os == 'ubuntu-latest' + run: npm test -- --project=${{ matrix.test-workspace }} + env: + CI: true + + - name: Build and test (Windows) + if: matrix.os == 'windows-latest' + run: npm test -- --project=${{ matrix.test-workspace }} env: CI: true - - name: report test coverage + + - name: Report test coverage run: bash <(curl -s https://codecov.io/bash) -f coverage/coverage-final.json env: CI: true diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index f85fd4607b..2a4a9b3e8d 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -631,7 +631,7 @@ class RendererWebGPU extends Renderer3D { ////////////////////////////////////////////// _drawBuffers(geometry, { mode = constants.TRIANGLES, count = 1 }) { - const buffers = this.geometryBufferCache.getCached(geometry); + const buffers = this.geometryBufferCache.ensureCached(geometry); if (!buffers) return; const commandEncoder = this.device.createCommandEncoder(); diff --git a/src/webgpu/shaders/utils.js b/src/webgpu/shaders/utils.js index 0a313dfaf8..a6b79426e9 100644 --- a/src/webgpu/shaders/utils.js +++ b/src/webgpu/shaders/utils.js @@ -1,6 +1,6 @@ export const getTexture = ` -fn getTexture(texture: texture_2d, sampler: sampler, coord: vec2) -> vec4 { - let color = textureSample(texture, sampler, coord); +fn getTexture(texture: texture_2d, texSampler: sampler, coord: vec2) -> vec4 { + let color = textureSample(texture, texSampler, coord); let alpha = color.a; return vec4( select(color.rgb / alpha, vec3(0.0), alpha == 0.0), diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 943943eabd..23055b6680 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -23,7 +23,7 @@ export default defineWorkspace([ ], }, test: { - name: 'unit', + name: 'unit-tests-chrome', root: './', include: [ './test/unit/**/*.js', @@ -33,7 +33,7 @@ export default defineWorkspace([ './test/unit/assets/**/*', './test/unit/visual/visualTest.js', ], - testTimeout: 1000, + testTimeout: 10000, globals: true, browser: { enabled: true, @@ -44,18 +44,63 @@ export default defineWorkspace([ capabilities: process.env.CI ? { 'goog:chromeOptions': { args: [ - '--enable-unsafe-webgpu', - '--headless=new', '--no-sandbox', - '--disable-dev-shm-usage', - '--use-gl=angle', - '--use-angle=d3d11-warp', - '--disable-gpu-sandbox' + '--headless=new', + '--use-angle=vulkan', + '--enable-features=Vulkan', + '--disable-vulkan-surface', + '--enable-unsafe-webgpu', ] } } : undefined } } } + }, + { + plugins, + publicDir: './test', + bench: { + name: 'bench', + root: './', + include: [ + './test/bench/**/*.js' + ], + }, + test: { + name: 'unit-tests-firefox', + root: './', + include: [ + './test/unit/**/*.js', + ], + exclude: [ + './test/unit/spec.js', + './test/unit/assets/**/*', + './test/unit/visual/visualTest.js', + ], + testTimeout: 10000, + globals: true, + browser: { + enabled: true, + name: 'firefox', + provider: 'webdriverio', + screenshotFailures: false, + providerOptions: { + capabilities: process.env.CI ? { + 'moz:firefoxOptions': { + args: [ + '--headless', + '--enable-webgpu', + ], + prefs: { + 'dom.webgpu.enabled': true, + 'gfx.webgpu.force-enabled': true, + 'dom.webgpu.testing.assert-on-warnings': false, + } + } + } : undefined + } + } + } } ]); \ No newline at end of file From a8dea1506b25e776d02024855bce3d5dd23c4dcb Mon Sep 17 00:00:00 2001 From: charlotte Date: Thu, 7 Aug 2025 16:48:56 -0700 Subject: [PATCH 25/26] Revert change. --- src/webgpu/p5.RendererWebGPU.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 2a4a9b3e8d..f85fd4607b 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -631,7 +631,7 @@ class RendererWebGPU extends Renderer3D { ////////////////////////////////////////////// _drawBuffers(geometry, { mode = constants.TRIANGLES, count = 1 }) { - const buffers = this.geometryBufferCache.ensureCached(geometry); + const buffers = this.geometryBufferCache.getCached(geometry); if (!buffers) return; const commandEncoder = this.device.createCommandEncoder(); From 9c49827445dfdcd1bf425d5bcda7a23c620331f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Fri, 8 Aug 2025 14:40:02 -0700 Subject: [PATCH 26/26] Add setAttributes api to WebGPU renderer. --- src/core/main.js | 1 + src/core/p5.Renderer3D.js | 75 ++++++++++++++++++++++++++++++ src/webgl/p5.RendererGL.js | 73 +---------------------------- src/webgpu/p5.RendererWebGPU.js | 71 +++++++++++++++++++++++++++- test/unit/visual/cases/webgpu.js | 38 +++++++++++++-- test/unit/webgpu/p5.Framebuffer.js | 22 +++------ 6 files changed, 187 insertions(+), 93 deletions(-) diff --git a/src/core/main.js b/src/core/main.js index f9fc1c6559..9a3d929f3f 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -58,6 +58,7 @@ class p5 { this._curElement = null; this._elements = []; this._glAttributes = null; + this._webgpuAttributes = null; this._requestAnimId = 0; this._isGlobal = false; this._loop = true; diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index e422c2940f..bbf42b330c 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -1,4 +1,5 @@ import * as constants from "../core/constants"; +import { Graphics } from "../core/p5.Graphics"; import { Renderer } from './p5.Renderer'; import GeometryBuilder from "../webgl/GeometryBuilder"; import { Matrix } from "../math/p5.Matrix"; @@ -350,6 +351,80 @@ export class Renderer3D extends Renderer { }; } + //This is helper function to reset the context anytime the attributes + //are changed with setAttributes() + + async _resetContext(options, callback, ctor = Renderer3D) { + const w = this.width; + const h = this.height; + const defaultId = this.canvas.id; + const isPGraphics = this._pInst instanceof Graphics; + + // Preserve existing position and styles before recreation + const prevStyle = { + position: this.canvas.style.position, + top: this.canvas.style.top, + left: this.canvas.style.left, + }; + + if (isPGraphics) { + // Handle PGraphics: remove and recreate the canvas + const pg = this._pInst; + pg.canvas.parentNode.removeChild(pg.canvas); + pg.canvas = document.createElement("canvas"); + const node = pg._pInst._userNode || document.body; + node.appendChild(pg.canvas); + Element.call(pg, pg.canvas, pg._pInst); + // Restore previous width and height + pg.width = w; + pg.height = h; + } else { + // Handle main canvas: remove and recreate it + let c = this.canvas; + if (c) { + c.parentNode.removeChild(c); + } + c = document.createElement("canvas"); + c.id = defaultId; + // Attach the new canvas to the correct parent node + if (this._pInst._userNode) { + this._pInst._userNode.appendChild(c); + } else { + document.body.appendChild(c); + } + this._pInst.canvas = c; + this.canvas = c; + + // Restore the saved position + this.canvas.style.position = prevStyle.position; + this.canvas.style.top = prevStyle.top; + this.canvas.style.left = prevStyle.left; + } + + const renderer = new ctor( + this._pInst, + w, + h, + !isPGraphics, + this._pInst.canvas + ); + this._pInst._renderer = renderer; + + renderer._applyDefaults(); + + if (renderer.contextReady) { + await renderer.contextReady + } + + if (typeof callback === "function") { + //setTimeout with 0 forces the task to the back of the queue, this ensures that + //we finish switching out the renderer + setTimeout(() => { + callback.apply(window._renderer, options); + }, 0); + } + } + remove() { this.wrappedElt.remove(); this.wrappedElt = null; diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index c6fbfa45a6..ab15ea3d81 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -13,7 +13,6 @@ import { Renderer3D, getStrokeDefs } from "../core/p5.Renderer3D"; import { Shader } from "./p5.Shader"; import { Texture, MipmapTexture } from "./p5.Texture"; import { Framebuffer } from "./p5.Framebuffer"; -import { Graphics } from "../core/p5.Graphics"; import { RGB, RGBA } from '../color/creating_reading'; import { Element } from "../dom/p5.Element"; import { Image } from '../image/p5.Image'; @@ -450,76 +449,6 @@ class RendererGL extends Renderer3D { return { adjustedWidth, adjustedHeight }; } - //This is helper function to reset the context anytime the attributes - //are changed with setAttributes() - - _resetContext(options, callback) { - const w = this.width; - const h = this.height; - const defaultId = this.canvas.id; - const isPGraphics = this._pInst instanceof Graphics; - - // Preserve existing position and styles before recreation - const prevStyle = { - position: this.canvas.style.position, - top: this.canvas.style.top, - left: this.canvas.style.left, - }; - - if (isPGraphics) { - // Handle PGraphics: remove and recreate the canvas - const pg = this._pInst; - pg.canvas.parentNode.removeChild(pg.canvas); - pg.canvas = document.createElement("canvas"); - const node = pg._pInst._userNode || document.body; - node.appendChild(pg.canvas); - Element.call(pg, pg.canvas, pg._pInst); - // Restore previous width and height - pg.width = w; - pg.height = h; - } else { - // Handle main canvas: remove and recreate it - let c = this.canvas; - if (c) { - c.parentNode.removeChild(c); - } - c = document.createElement("canvas"); - c.id = defaultId; - // Attach the new canvas to the correct parent node - if (this._pInst._userNode) { - this._pInst._userNode.appendChild(c); - } else { - document.body.appendChild(c); - } - this._pInst.canvas = c; - this.canvas = c; - - // Restore the saved position - this.canvas.style.position = prevStyle.position; - this.canvas.style.top = prevStyle.top; - this.canvas.style.left = prevStyle.left; - } - - const renderer = new RendererGL( - this._pInst, - w, - h, - !isPGraphics, - this._pInst.canvas - ); - this._pInst._renderer = renderer; - - renderer._applyDefaults(); - - if (typeof callback === "function") { - //setTimeout with 0 forces the task to the back of the queue, this ensures that - //we finish switching out the renderer - setTimeout(() => { - callback.apply(window._renderer, options); - }, 0); - } - } - _resetBuffersBeforeDraw() { this.GL.clearStencil(0); this.GL.clear(this.GL.DEPTH_BUFFER_BIT | this.GL.STENCIL_BUFFER_BIT); @@ -2196,7 +2125,7 @@ function rendererGL(p5, fn) { } } - this._renderer._resetContext(); + this._renderer._resetContext(null, null, RendererGL); if (this._renderer.states.curCamera) { this._renderer.states.curCamera._renderer = this._renderer; diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index f85fd4607b..58c17f35aa 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -9,6 +9,8 @@ import * as constants from '../core/constants'; import { colorVertexShader, colorFragmentShader } from './shaders/color'; import { lineVertexShader, lineFragmentShader} from './shaders/line'; import { materialVertexShader, materialFragmentShader } from './shaders/material'; +import {Graphics} from "../core/p5.Graphics"; +import {Element} from "../dom/p5.Element"; const { lineDefs } = getStrokeDefs((n, v, t) => `const ${n}: ${t} = ${v};\n`); @@ -29,7 +31,25 @@ class RendererWebGPU extends Renderer3D { } async setupContext() { - this.adapter = await navigator.gpu?.requestAdapter(); + this._setAttributeDefaults(this._pInst); + await this._initContext(); + } + + _setAttributeDefaults(pInst) { + const defaults = { + forceFallbackAdapter: false, + powerPreference: 'high-performance', + }; + if (pInst._webgpuAttributes === null) { + pInst._webgpuAttributes = defaults; + } else { + pInst._webgpuAttributes = Object.assign(defaults, pInst._webgpuAttributes); + } + return; + } + + async _initContext() { + this.adapter = await navigator.gpu?.requestAdapter(this._webgpuAttributes); this.device = await this.adapter?.requestDevice({ // Todo: check support requiredFeatures: ['depth32float-stencil8'] @@ -1854,6 +1874,55 @@ function rendererWebGPU(p5, fn) { fn.ensureTexture = function(source) { return this._renderer.ensureTexture(source); } + + fn.setAttributes = async function (key, value) { + if (typeof this._webgpuAttributes === "undefined") { + console.log( + "You are trying to use setAttributes on a p5.Graphics object " + + "that does not use a WebGPU renderer." + ); + return; + } + let unchanged = true; + + if (typeof value !== "undefined") { + //first time modifying the attributes + if (this._webgpuAttributes === null) { + this._webgpuAttributes = {}; + } + if (this._webgpuAttributes[key] !== value) { + //changing value of previously altered attribute + this._webgpuAttributes[key] = value; + unchanged = false; + } + //setting all attributes with some change + } else if (key instanceof Object) { + if (this._webgpuAttributes !== key) { + this._webgpuAttributes = key; + unchanged = false; + } + } + //@todo_FES + if (!this._renderer.isP3D || unchanged) { + return; + } + + if (!this._setupDone) { + if (this._renderer.geometryBufferCache.numCached() > 0) { + p5._friendlyError( + "Sorry, Could not set the attributes, you need to call setAttributes() " + + "before calling the other drawing methods in setup()" + ); + return; + } + } + + await this._renderer._resetContext(null, null, RendererWebGPU); + + if (this._renderer.states.curCamera) { + this._renderer.states.curCamera._renderer = this._renderer; + } + } } export default rendererWebGPU; diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index 28382dda25..130dabf83b 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -11,6 +11,9 @@ visualSuite("WebGPU", function () { "The color shader runs successfully", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); + await p5.setAttributes({ + forceFallbackAdapter: true + }); p5.background("white"); for (const [i, color] of ["red", "lime", "blue"].entries()) { p5.push(); @@ -29,6 +32,9 @@ visualSuite("WebGPU", function () { "The stroke shader runs successfully", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); + await p5.setAttributes({ + forceFallbackAdapter: true + }); p5.background("white"); for (const [i, color] of ["red", "lime", "blue"].entries()) { p5.push(); @@ -47,6 +53,9 @@ visualSuite("WebGPU", function () { "The material shader runs successfully", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); + await p5.setAttributes({ + forceFallbackAdapter: true + }); p5.background("white"); p5.ambientLight(50); p5.directionalLight(100, 100, 100, 0, 1, -1); @@ -68,6 +77,9 @@ visualSuite("WebGPU", function () { visualTest("Shader hooks can be used", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); + await p5.setAttributes({ + forceFallbackAdapter: true + }); const myFill = p5.baseMaterialShader().modify({ "Vertex getWorldInputs": `(inputs: Vertex) { var result = inputs; @@ -96,6 +108,9 @@ visualSuite("WebGPU", function () { "Textures in the material shader work", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); + await p5.setAttributes({ + forceFallbackAdapter: true + }); const tex = p5.createImage(50, 50); tex.loadPixels(); for (let x = 0; x < tex.width; x++) { @@ -121,6 +136,9 @@ visualSuite("WebGPU", function () { "Main canvas drawing after resize", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); + await p5.setAttributes({ + forceFallbackAdapter: true + }); // Resize the canvas p5.resizeCanvas(30, 30); // Draw to the main canvas after resize @@ -138,7 +156,9 @@ visualSuite("WebGPU", function () { "Basic framebuffer draw to canvas", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - + await p5.setAttributes({ + forceFallbackAdapter: true + }); // Create a framebuffer const fbo = p5.createFramebuffer({ width: 25, height: 25 }); @@ -164,7 +184,9 @@ visualSuite("WebGPU", function () { "Framebuffer with different sizes", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - + await p5.setAttributes({ + forceFallbackAdapter: true + }); // Create two different sized framebuffers const fbo1 = p5.createFramebuffer({ width: 20, height: 20 }); const fbo2 = p5.createFramebuffer({ width: 15, height: 15 }); @@ -207,7 +229,9 @@ visualSuite("WebGPU", function () { visualTest("Auto-sized framebuffer", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - + await p5.setAttributes({ + forceFallbackAdapter: true + }); // Create auto-sized framebuffer (should match canvas size) const fbo = p5.createFramebuffer(); @@ -242,7 +266,9 @@ visualSuite("WebGPU", function () { "Auto-sized framebuffer after canvas resize", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - + await p5.setAttributes({ + forceFallbackAdapter: true + }); // Create auto-sized framebuffer const fbo = p5.createFramebuffer(); @@ -274,7 +300,9 @@ visualSuite("WebGPU", function () { "Fixed-size framebuffer after manual resize", async function (p5, screenshot) { await p5.createCanvas(50, 50, p5.WEBGPU); - + await p5.setAttributes({ + forceFallbackAdapter: true + }); // Create fixed-size framebuffer const fbo = p5.createFramebuffer({ width: 20, height: 20 }); diff --git a/test/unit/webgpu/p5.Framebuffer.js b/test/unit/webgpu/p5.Framebuffer.js index 452585b6c8..97cb8a13dd 100644 --- a/test/unit/webgpu/p5.Framebuffer.js +++ b/test/unit/webgpu/p5.Framebuffer.js @@ -15,6 +15,13 @@ suite('WebGPU p5.Framebuffer', function() { }); }); + beforeEach(async function() { + const renderer = await myp5.createCanvas(10, 10, 'webgpu'); + await myp5.setAttributes({ + forceFallbackAdapter: true + }); + }) + afterAll(function() { myp5.remove(); window.devicePixelRatio = prevPixelRatio; @@ -22,7 +29,6 @@ suite('WebGPU p5.Framebuffer', function() { suite('Creation and basic properties', function() { test('framebuffers can be created with WebGPU renderer', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); const fbo = myp5.createFramebuffer(); expect(fbo).to.be.an('object'); @@ -32,7 +38,6 @@ suite('WebGPU p5.Framebuffer', function() { }); test('framebuffers can be created with custom dimensions', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); const fbo = myp5.createFramebuffer({ width: 20, height: 30 }); expect(fbo.width).to.equal(20); @@ -41,7 +46,6 @@ suite('WebGPU p5.Framebuffer', function() { }); test('framebuffers have color texture', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); const fbo = myp5.createFramebuffer(); expect(fbo.color).to.be.an('object'); @@ -49,7 +53,6 @@ suite('WebGPU p5.Framebuffer', function() { }); test('framebuffers can specify different formats', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); const fbo = myp5.createFramebuffer({ format: 'float', channels: 'rgb' @@ -63,7 +66,6 @@ suite('WebGPU p5.Framebuffer', function() { suite('Auto-sizing behavior', function() { test('auto-sized framebuffers change size with canvas', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); myp5.pixelDensity(1); const fbo = myp5.createFramebuffer(); @@ -80,7 +82,6 @@ suite('WebGPU p5.Framebuffer', function() { }); test('manually-sized framebuffers do not change size with canvas', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); myp5.pixelDensity(3); const fbo = myp5.createFramebuffer({ width: 25, height: 30, density: 1 }); @@ -97,7 +98,6 @@ suite('WebGPU p5.Framebuffer', function() { }); test('manually-sized framebuffers can be made auto-sized', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); myp5.pixelDensity(1); const fbo = myp5.createFramebuffer({ width: 25, height: 30, density: 2 }); @@ -120,7 +120,6 @@ suite('WebGPU p5.Framebuffer', function() { suite('Manual resizing', function() { test('framebuffers can be manually resized', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); myp5.pixelDensity(1); const fbo = myp5.createFramebuffer(); @@ -135,7 +134,6 @@ suite('WebGPU p5.Framebuffer', function() { }); test('resizing affects pixel density', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); myp5.pixelDensity(1); const fbo = myp5.createFramebuffer(); @@ -152,7 +150,6 @@ suite('WebGPU p5.Framebuffer', function() { suite('Drawing functionality', function() { test('can draw to framebuffer with draw() method', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); const fbo = myp5.createFramebuffer(); myp5.background(0, 255, 0); @@ -178,7 +175,6 @@ suite('WebGPU p5.Framebuffer', function() { }); test('can use framebuffer as texture', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); const fbo = myp5.createFramebuffer(); fbo.draw(() => { @@ -195,7 +191,6 @@ suite('WebGPU p5.Framebuffer', function() { suite('Pixel access', function() { test('loadPixels returns a promise in WebGPU', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); const fbo = myp5.createFramebuffer(); fbo.draw(() => { @@ -212,7 +207,6 @@ suite('WebGPU p5.Framebuffer', function() { }); test('pixels property is set after loadPixels resolves', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); const fbo = myp5.createFramebuffer(); fbo.draw(() => { @@ -225,7 +219,6 @@ suite('WebGPU p5.Framebuffer', function() { }); test('get() returns a promise for single pixel in WebGPU', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); const fbo = myp5.createFramebuffer(); fbo.draw(() => { @@ -242,7 +235,6 @@ suite('WebGPU p5.Framebuffer', function() { }); test('get() returns a promise for region in WebGPU', async function() { - await myp5.createCanvas(10, 10, myp5.WEBGPU); const fbo = myp5.createFramebuffer(); fbo.draw(() => {