From 1139997040e9fe3d8b18092b55c43534b83d0830 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 30 Oct 2026 16:20:28 -0400 Subject: [PATCH 1/5] Fix rendering of video elements in WebGPU --- src/webgl/p5.RendererGL.js | 4 ++++ src/webgl/p5.Texture.js | 8 ++++++-- src/webgpu/p5.RendererWebGPU.js | 28 +++++++++++++++++++++++----- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index 38440f6795..d4061c7454 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -635,6 +635,10 @@ class RendererGL extends Renderer3D { return true; } + supportsVideoUpload() { + return true; + } + viewport(w, h) { this._viewport = [0, 0, w, h]; this.GL.viewport(0, 0, w, h); diff --git a/src/webgl/p5.Texture.js b/src/webgl/p5.Texture.js index 339787af40..ebb481b2a6 100644 --- a/src/webgl/p5.Texture.js +++ b/src/webgl/p5.Texture.js @@ -100,8 +100,12 @@ class Texture { textureData = this.src.canvas; } else if (this.isSrcMediaElement || this.isSrcHTMLElement) { // createCapture elements that are flipped need - // to go through a canvas - if (this.isSrcMediaElement && this.src.flipped) { + // to go through a canvas, as do all videos when the renderer can't + // upload from a video element directly + if ( + this.isSrcMediaElement && + (this.src.flipped || !this._renderer.supportsVideoUpload()) + ) { this.src._ensureCanvas(); textureData = this.src.canvas; } else { diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index d4d26e1df2..f7e502e63d 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -1787,6 +1787,10 @@ function rendererWebGPU(p5, fn) { return false; } + supportsVideoUpload() { + return this._supportsVideoUpload !== false; + } + viewport() {} zClipRange() { @@ -3130,11 +3134,25 @@ function rendererWebGPU(p5, fn) { } uploadTextureFromSource({ gpuTexture }, source) { - this.queue.copyExternalImageToTexture( - { source }, - { texture: gpuTexture }, - [source.width, source.height] - ); + const isVideo = source instanceof HTMLVideoElement; + // Chrome throws if a video has no decoded frame yet + if (isVideo && source.readyState < 2) return; + + try { + this.queue.copyExternalImageToTexture( + { source }, + { texture: gpuTexture }, + [source.videoWidth || source.width, source.videoHeight || source.height] + ); + } catch (e) { + // As of 10/30/2026, Firefox doesn't accept video elements as a copy + // source. Texture will hand us a canvas from the next update onward. + if (isVideo && e instanceof TypeError) { + this._supportsVideoUpload = false; + return; + } + throw e; + } // Force submission to ensure texture upload completes before usage this._hasPendingDraws = true; From f2dd3c85d9ebf70ae5209bf56804e26c5c663932 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 30 Oct 2026 16:26:05 -0400 Subject: [PATCH 2/5] Add paletteLerp tests that I guess weren't committed before --- .../000.png | Bin 0 -> 330 bytes .../metadata.json | 3 +++ .../000.png | Bin 0 -> 383 bytes .../metadata.json | 3 +++ 4 files changed, 6 insertions(+) create mode 100644 test/unit/visual/screenshots/WebGPU/paletteLerp() handles uneven stop positions and more than 4 stops (WebGPU)/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/paletteLerp() handles uneven stop positions and more than 4 stops (WebGPU)/metadata.json create mode 100644 test/unit/visual/screenshots/WebGPU/paletteLerp() maps texCoord through a multi-stop gradient in a filter (WebGPU)/000.png create mode 100644 test/unit/visual/screenshots/WebGPU/paletteLerp() maps texCoord through a multi-stop gradient in a filter (WebGPU)/metadata.json diff --git a/test/unit/visual/screenshots/WebGPU/paletteLerp() handles uneven stop positions and more than 4 stops (WebGPU)/000.png b/test/unit/visual/screenshots/WebGPU/paletteLerp() handles uneven stop positions and more than 4 stops (WebGPU)/000.png new file mode 100644 index 0000000000000000000000000000000000000000..1f6023c8ddfb0494ed73f30fc7d02135ff24ba50 GIT binary patch literal 330 zcmeAS@N?(olHy`uVBq!ia0vp^Mj*_=1|;R|J2nET|DG<6Ar*{oCN1P;aujei*Wh7I z5aftDpfJIt;ee8%NQ3JI23{b?f!V}?F@lAchu^;Gu7PCH&z5wx?VERgUl;DRF<;`t z9f`CJExIxfSi*N4=3Wzjrp=&HQzwB{`o=*)y@F=;8|@Qko=xPfwm8f?@59~ypOuz! z+wVQGKQu&8l+$Q|zqUut=P1$4F!7QA*QO(8>q94|l{&JAsvrr=Y+R`<>Us5j%sQpM zDOV63b7IfI-0q3JPY1#(R}V*izDD PfFcZ@u6{1-oD!M literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/paletteLerp() handles uneven stop positions and more than 4 stops (WebGPU)/metadata.json b/test/unit/visual/screenshots/WebGPU/paletteLerp() handles uneven stop positions and more than 4 stops (WebGPU)/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/paletteLerp() handles uneven stop positions and more than 4 stops (WebGPU)/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/paletteLerp() maps texCoord through a multi-stop gradient in a filter (WebGPU)/000.png b/test/unit/visual/screenshots/WebGPU/paletteLerp() maps texCoord through a multi-stop gradient in a filter (WebGPU)/000.png new file mode 100644 index 0000000000000000000000000000000000000000..c2a7850f77ba30dac00956e57ed884d9903c35e9 GIT binary patch literal 383 zcmeAS@N?(olHy`uVBq!ia0vp^Mj*_=1|;R|J2o;fFq(L}IEGX(zL})x=Nu^DnxFMU z`@wpKU5qc-6k-{ca5t1NPPpP6#J@<&#dJoL!PW&@j-gGfnag)_OMH%>v1i#%-@cho z&MtZWM^1Xe+85o85?vD|x;tld&79HQ8qqa1qPtgNhvE*k9YWq41&R%YgAE0f4TYQy z1!^yLxjiWF|8Y;m{qc5!%b;^A99pAb$ z>TVqF`}4eC9UhYy>!UoAy07o@*tly+yWS*~Z53;~MOJ%Py--3D7V21QRWxzc zuja>kLLMtgVE#NG_&iqcQe@;OW^qrkJC%F8OTwo8Ip8{pwLEUWvFkrz=>7jMIM4Sw fkiiBDKV}AQ*OIcQ?5FnwMHoC?{an^LB{Ts5Z8@PA literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGPU/paletteLerp() maps texCoord through a multi-stop gradient in a filter (WebGPU)/metadata.json b/test/unit/visual/screenshots/WebGPU/paletteLerp() maps texCoord through a multi-stop gradient in a filter (WebGPU)/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/paletteLerp() maps texCoord through a multi-stop gradient in a filter (WebGPU)/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file From 97ea887c7ac516633f231a66779657f5dc416d05 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 30 Oct 2026 16:26:17 -0400 Subject: [PATCH 3/5] Add unit tests for texture issues --- test/unit/webgl/p5.Texture.js | 11 +++++ test/unit/webgpu/p5.RendererWebGPU.js | 62 +++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/test/unit/webgl/p5.Texture.js b/test/unit/webgl/p5.Texture.js index a3c53da6fc..5ea68239cd 100644 --- a/test/unit/webgl/p5.Texture.js +++ b/test/unit/webgl/p5.Texture.js @@ -308,6 +308,17 @@ suite('p5.Texture', function () { expect(tex.width).to.equal(texImg1.width); expect(tex.height).to.equal(texImg1.height); }); + test('Reads from the video element when video upload is supported', function () { + const video = myp5.createVideo(''); + const tex = myp5._renderer.getTexture(video); + expect(tex._getTextureDataFromSource()).to.equal(video.elt); + }); + test('Reads from a canvas when video upload is not supported', function () { + const video = myp5.createVideo(''); + const tex = myp5._renderer.getTexture(video); + vi.spyOn(myp5._renderer, 'supportsVideoUpload').mockReturnValue(false); + expect(tex._getTextureDataFromSource()).to.equal(video.canvas); + }); test('Handles changes to p5.Renderer size', function () { const tex = texImg1._renderer.getTexture(canvas); expect(tex.width).to.equal(canvas.width); diff --git a/test/unit/webgpu/p5.RendererWebGPU.js b/test/unit/webgpu/p5.RendererWebGPU.js index 57aa98c397..5cd90ea232 100644 --- a/test/unit/webgpu/p5.RendererWebGPU.js +++ b/test/unit/webgpu/p5.RendererWebGPU.js @@ -146,6 +146,68 @@ suite('WebGPU p5.RendererWebGPU', function () { }); }); + suite('uploadTextureFromSource() with video', function () { + let copySpy; + + const makeVideo = readyState => { + const video = document.createElement('video'); + Object.defineProperties(video, { + readyState: { value: readyState }, + videoWidth: { value: 640 }, + videoHeight: { value: 480 } + }); + return video; + }; + + beforeEach(function () { + copySpy = vi + .spyOn(myp5._renderer.queue, 'copyExternalImageToTexture') + .mockImplementation(() => {}); + }); + + afterEach(function () { + copySpy.mockRestore(); + }); + + test('skips videos that have no decoded frame yet', function () { + const handle = { gpuTexture: {} }; + myp5._renderer.uploadTextureFromSource(handle, makeVideo(1)); + expect(copySpy).not.toHaveBeenCalled(); + expect(myp5._renderer.supportsVideoUpload()).to.equal(true); + }); + + test('copies using the real video size', function () { + const handle = { gpuTexture: {} }; + const video = makeVideo(4); + myp5._renderer.uploadTextureFromSource(handle, video); + expect(copySpy).toHaveBeenCalledWith( + { source: video }, + { texture: handle.gpuTexture }, + [640, 480] + ); + }); + + test('stops uploading video directly if the browser rejects it', function () { + const handle = { gpuTexture: {} }; + copySpy.mockImplementation(() => { + throw new TypeError('source could not be converted'); + }); + myp5._renderer.uploadTextureFromSource(handle, makeVideo(4)); + expect(myp5._renderer.supportsVideoUpload()).to.equal(false); + }); + + test('rethrows errors that are not about the source type', function () { + const handle = { gpuTexture: {} }; + copySpy.mockImplementation(() => { + throw new Error('device lost'); + }); + expect(() => { + myp5._renderer.uploadTextureFromSource(handle, makeVideo(4)); + }).toThrow('device lost'); + expect(myp5._renderer.supportsVideoUpload()).to.equal(true); + }); + }); + suite('Stability', function () { test('pixelDensity() after setAttributes() should not crash', async function () { // This test simulates the issue where a synchronous call (pixelDensity) From 6979685e23bc07e0256f8b466e1c6023eb5ddc0d Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 30 Oct 2026 16:48:12 -0400 Subject: [PATCH 4/5] Fix texture size changing when content becomes available --- src/core/p5.Renderer3D.js | 6 +++++- src/webgl/p5.RendererGL.js | 4 ++++ src/webgl/p5.Shader.js | 2 +- src/webgl/p5.Texture.js | 2 +- src/webgpu/p5.RendererWebGPU.js | 27 +++++++++++++++++++++++++-- 5 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index d6cd1ce5f5..e0cc133429 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -1796,9 +1796,13 @@ export class Renderer3D extends Renderer { } const texture = this.textures.get(src); - if (texture) { + if (texture && !this.textureNeedsRecreating(texture)) { return texture; } + if (texture) { + texture.remove(); + this.textures.delete(src); + } const tex = new Texture(this, src); this.textures.set(src, tex); diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index d4061c7454..c3e6d344c9 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -639,6 +639,10 @@ class RendererGL extends Renderer3D { return true; } + textureNeedsRecreating(_texture) { + return false; + } + viewport(w, h) { this._viewport = [0, 0, w, h]; this.GL.viewport(0, 0, w, h); diff --git a/src/webgl/p5.Shader.js b/src/webgl/p5.Shader.js index ef055744d4..d4e6d4d2b7 100644 --- a/src/webgl/p5.Shader.js +++ b/src/webgl/p5.Shader.js @@ -887,7 +887,7 @@ class Shader { // so we supply a default texture instead. uniform.texture = tex = empty; } - this._renderer._updateTexture(uniform, tex); + this._renderer._updateTexture(uniform, tex, this); } } diff --git a/src/webgl/p5.Texture.js b/src/webgl/p5.Texture.js index ebb481b2a6..6fbda960fd 100644 --- a/src/webgl/p5.Texture.js +++ b/src/webgl/p5.Texture.js @@ -154,7 +154,7 @@ class Texture { if (this._shouldDeferUpload()) { this._renderer.uploadTextureFromData( this.textureHandle, - new Uint8Array(1, 1, 1, 1), + new Uint8Array(4), 1, 1 ); diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index f7e502e63d..83c6644ae1 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -1791,6 +1791,20 @@ function rendererWebGPU(p5, fn) { return this._supportsVideoUpload !== false; } + // GPU textures have a fixed size, so a source that has changed size + // needs a new texture instead of being reuploaded into the old one + textureNeedsRecreating(texture) { + if (texture.isFramebufferTexture) return false; + const gpuTexture = texture.textureHandle.gpuTexture; + const data = texture._getTextureDataFromSource(); + // Compare against the GPU texture itself: Texture.update() overwrites + // texture.width/height before the GPU texture has been replaced + if (!data || !data.width || !data.height) return false; + return ( + data.width !== gpuTexture.width || data.height !== gpuTexture.height + ); + } + viewport() {} zClipRange() { @@ -3112,7 +3126,14 @@ function rendererWebGPU(p5, fn) { shader.buffersDirty.add(uniform.group * 1000 + uniform.binding); } - _updateTexture(uniform, tex) { + _updateTexture(uniform, tex, shader) { + // The uniform keeps pointing at the texture it was first given, so + // swap in the replacement here and rebuild the bind group around it + if (this.textureNeedsRecreating(tex)) { + tex = this.getTexture(tex.src); + uniform.texture = tex; + shader.buffersDirty.add(uniform.group * 1000 + uniform.binding); + } tex.update(); } @@ -3122,7 +3143,9 @@ function rendererWebGPU(p5, fn) { createTexture({ width, height, format = 'rgba8unorm', usage }) { const gpuTexture = this.device.createTexture({ - size: [width, height], + // Sources like videos report 0 until their metadata loads, and + // zero-sized textures are invalid + size: [Math.max(1, width), Math.max(1, height)], format, usage: usage || From e23d0897b69191b96a5243efdffb75587f856df4 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 30 Oct 2026 16:53:48 -0400 Subject: [PATCH 5/5] Add tests for texture size changes --- test/unit/webgl/p5.Texture.js | 5 +++ test/unit/webgpu/p5.RendererWebGPU.js | 49 +++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/test/unit/webgl/p5.Texture.js b/test/unit/webgl/p5.Texture.js index 5ea68239cd..719161a87a 100644 --- a/test/unit/webgl/p5.Texture.js +++ b/test/unit/webgl/p5.Texture.js @@ -299,6 +299,11 @@ suite('p5.Texture', function () { expect(tex.width).to.equal(texImg2.width); expect(tex.height).to.equal(texImg2.height); }); + test('Keeps the same texture when the p5.Image size changes', function () { + const tex = myp5._renderer.getTexture(texImg2); + texImg2.resize(texImg2.width * 2, texImg2.height * 2); + expect(myp5._renderer.getTexture(texImg2)).to.equal(tex); + }); test('Handles changes to p5.Graphics size', function () { const tex = myp5._renderer.getTexture(texImg1); expect(tex.width).to.equal(texImg1.width); diff --git a/test/unit/webgpu/p5.RendererWebGPU.js b/test/unit/webgpu/p5.RendererWebGPU.js index 5cd90ea232..5c421cf0c2 100644 --- a/test/unit/webgpu/p5.RendererWebGPU.js +++ b/test/unit/webgpu/p5.RendererWebGPU.js @@ -208,6 +208,55 @@ suite('WebGPU p5.RendererWebGPU', function () { }); }); + suite('texture recreation', function () { + afterEach(function () { + vi.restoreAllMocks(); + }); + + test('getTexture returns a new texture after the source is resized', function () { + const img = myp5.createImage(10, 10); + const before = myp5._renderer.getTexture(img); + img.resize(20, 20); + const after = myp5._renderer.getTexture(img); + expect(after).not.to.equal(before); + expect(after.textureHandle.gpuTexture.width).to.equal(20); + expect(after.textureHandle.gpuTexture.height).to.equal(20); + }); + + test('getTexture reuses the texture when the source is unchanged', function () { + const img = myp5.createImage(10, 10); + const before = myp5._renderer.getTexture(img); + expect(myp5._renderer.getTexture(img)).to.equal(before); + }); + + test('does not recreate textures for sources with no size yet', function () { + const video = myp5.createVideo(''); + const tex = myp5._renderer.getTexture(video); + expect(myp5._renderer.textureNeedsRecreating(tex)).to.equal(false); + expect(tex.textureHandle.gpuTexture.width).to.equal(1); + expect(tex.textureHandle.gpuTexture.height).to.equal(1); + }); + + test('_updateTexture swaps in the new texture and dirties the bind group', function () { + const img = myp5.createImage(10, 10); + const tex = myp5._renderer.getTexture(img); + const uniform = { group: 0, binding: 2, texture: tex }; + const shader = { buffersDirty: new Set() }; + img.resize(20, 20); + myp5._renderer._updateTexture(uniform, tex, shader); + expect(uniform.texture).not.to.equal(tex); + expect(uniform.texture.textureHandle.gpuTexture.width).to.equal(20); + expect(shader.buffersDirty.has(2)).to.equal(true); + }); + + test('placeholder upload for an unloaded source is a full pixel', function () { + const writeSpy = vi.spyOn(myp5._renderer.queue, 'writeTexture'); + myp5._renderer.getTexture(myp5.createVideo('')); + expect(writeSpy).toHaveBeenCalled(); + expect(writeSpy.mock.calls[0][1].byteLength).to.equal(4); + }); + }); + suite('Stability', function () { test('pixelDensity() after setAttributes() should not crash', async function () { // This test simulates the issue where a synchronous call (pixelDensity)