diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index 5f4004e227..280aa31c28 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 38440f6795..c3e6d344c9 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -635,6 +635,14 @@ class RendererGL extends Renderer3D { return true; } + supportsVideoUpload() { + 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 339787af40..6fbda960fd 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 { @@ -150,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 5107d068ba..8e1e1e9c32 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -1787,6 +1787,24 @@ function rendererWebGPU(p5, fn) { return false; } + supportsVideoUpload() { + 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() { @@ -3113,7 +3131,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(); } @@ -3123,7 +3148,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 || @@ -3135,11 +3162,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; 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 0000000000..1f6023c8dd Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/paletteLerp() handles uneven stop positions and more than 4 stops (WebGPU)/000.png differ 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 0000000000..c2a7850f77 Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/paletteLerp() maps texCoord through a multi-stop gradient in a filter (WebGPU)/000.png differ 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 diff --git a/test/unit/webgl/p5.Texture.js b/test/unit/webgl/p5.Texture.js index 02c69cafb5..53c4bce59e 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); @@ -308,6 +313,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 bb5f8acc96..e7a0f025e6 100644 --- a/test/unit/webgpu/p5.RendererWebGPU.js +++ b/test/unit/webgpu/p5.RendererWebGPU.js @@ -146,6 +146,117 @@ 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('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)