Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/core/p5.Renderer3D.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions src/webgl/p5.RendererGL.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/webgl/p5.Shader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
10 changes: 7 additions & 3 deletions src/webgl/p5.Texture.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
);
Expand Down
55 changes: 48 additions & 7 deletions src/webgpu/p5.RendererWebGPU.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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();
}

Expand All @@ -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 ||
Expand All @@ -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;
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"numScreenshots": 1
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"numScreenshots": 1
}
16 changes: 16 additions & 0 deletions test/unit/webgl/p5.Texture.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
111 changes: 111 additions & 0 deletions test/unit/webgpu/p5.RendererWebGPU.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading