diff --git a/src/core/environment.js b/src/core/environment.js index da676c9066..b8928db14b 100644 --- a/src/core/environment.js +++ b/src/core/environment.js @@ -555,6 +555,29 @@ function environment(p5, fn, lifecycles) { */ fn.webglVersion = C.P2D; + /** + * A `String` variable with the renderer in use. + * + * `rendererType`'s value equals one of the following string constants: + * + * - `P2D` whose value is `'p2d'`. This is the default for 2D sketches. + * - `WEBGL` whose value is `'webgl'`. + * - `WEBGPU` whose value is `'webgpu'`. + * + * @property {(P2D|WEBGL|WEBGPU)} rendererType + * @readOnly + * @example + * function setup() { + * background(200); + * + * // Display the current renderer type. + * text(rendererType, 42, 54); + * + * describe('The text "p2d" written in black on a gray background.'); + * } + */ + fn.rendererType = C.P2D; + /** * A `Number` variable that stores the width of the screen display. * diff --git a/src/core/p5.Renderer.js b/src/core/p5.Renderer.js index c0252776c7..0b88973617 100644 --- a/src/core/p5.Renderer.js +++ b/src/core/p5.Renderer.js @@ -109,6 +109,9 @@ class Renderer { // constructor state when getSupportedIndividualVertexProperties() // is first consulted. this._supportsIndividualTextureCoordinates = undefined; + + this.rendererType = constants.P2D; + this._pInst.rendererType = this.rendererType; } get currentShape() { diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index 05c3e4e2e5..25e8d93d01 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -2351,11 +2351,9 @@ function renderer3D(p5, fn) { */ fn.createStorage = function (dataOrCount) { if (!this._renderer.createStorage) { - p5._friendlyError( - `createStorage() is only available with the WebGPU renderer. ${webGPUAddonMessage}`, - 'createStorage' + throw new Error( + `createStorage() is only available with the WebGPU renderer. ${webGPUAddonMessage}` ); - return; } return this._renderer.createStorage(dataOrCount); }; @@ -2532,11 +2530,9 @@ function renderer3D(p5, fn) { */ fn.createStorageList = function (maxCapacity, schemaOrData) { if (!this._renderer.createStorageList) { - p5._friendlyError( - `createStorageList() is only available with the WebGPU renderer. ${webGPUAddonMessage}`, - 'createStorageList' + throw new Error( + `createStorageList() is only available with the WebGPU renderer. ${webGPUAddonMessage}` ); - return; } return this._renderer.createStorageList(maxCapacity, schemaOrData); }; @@ -2560,11 +2556,9 @@ function renderer3D(p5, fn) { */ fn.baseComputeShader = function () { if (!this._renderer.baseComputeShader) { - p5._friendlyError( - `baseComputeShader() is only available with the WebGPU renderer. ${webGPUAddonMessage}`, - 'baseComputeShader' + throw new Error( + `baseComputeShader() is only available with the WebGPU renderer. ${webGPUAddonMessage}` ); - return; } return this._renderer.baseComputeShader(); }; @@ -2728,11 +2722,9 @@ function renderer3D(p5, fn) { */ fn.buildComputeShader = function (cb, context) { if (!this._renderer.baseComputeShader) { - p5._friendlyError( - `buildComputeShader() is only available with the WebGPU renderer. ${webGPUAddonMessage}`, - 'buildComputeShader' + throw new Error( + `buildComputeShader() is only available with the WebGPU renderer. ${webGPUAddonMessage}` ); - return; } return this.baseComputeShader().modify(cb, context, { hook: 'iteration' }); }; @@ -2907,11 +2899,9 @@ function renderer3D(p5, fn) { */ fn.compute = function (shader, x, y, z) { if (!this._renderer.compute) { - p5._friendlyError( - `compute() is only available with the WebGPU renderer. ${webGPUAddonMessage}`, - 'compute' + throw new Error( + `compute() is only available with the WebGPU renderer. ${webGPUAddonMessage}` ); - return; } this._renderer.compute(shader, x, y, z); }; diff --git a/src/core/rendering.js b/src/core/rendering.js index 255bff41b6..98ae36bf95 100644 --- a/src/core/rendering.js +++ b/src/core/rendering.js @@ -135,18 +135,28 @@ function rendering(p5, fn) { args.unshift(renderer); } - if (!renderers[selectedRenderer]) { - if (selectedRenderer === constants.WEBGPU) { - p5.FES - .log`To create a WEBGPU canvas, remember to add the WebGPU add-on to your project.`(); + if (!renderers[renderer] && typeof renderer === 'string') { + if (renderer === constants.WEBGPU) { + p5.FES.log`To create a WEBGPU canvas, add the WebGPU add-on to your project.`(); + if (renderers[constants.WEBGL]) { + p5.FES.log`Falling back to WebGL renderer.`(); + selectedRenderer = constants.WEBGL; + } + args.shift(); } else { p5.FES - .log`We weren't able to find a renderer called ${selectedRenderer}.`(); + .log`We weren't able to find a renderer called ${renderer}.`(); } } // Init our graphics renderer - if (this._renderer) this._renderer.remove(); + if (this._renderer) { + this._renderer.remove(); + const index = this._elements.indexOf(this._renderer); + if (index !== -1) { + this._elements.splice(index, 1); + } + } this._renderer = new renderers[selectedRenderer](this, w, h, true, ...args); this._defaultGraphicsCreated = true; this._elements.push(this._renderer); @@ -162,7 +172,13 @@ function rendering(p5, fn) { } if (this._renderer.contextReady) { - return this._renderer.contextReady.then(() => this._renderer); + return this._renderer.contextReady.then(() => this._renderer).catch((e) => { + if (selectedRenderer !== constants.WEBGPU) { + throw new Error('Failed to create canvas.'); + } + p5.FES.log`${e.message} Falling back to WebGL renderer.`(); + return this.createCanvas(w, h, constants.WEBGL, ...args); + }); } else { return this._renderer; } diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index 57d1b9726d..38440f6795 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -73,6 +73,9 @@ class RendererGL extends Renderer3D { constructor(pInst, w, h, isMainCanvas, elt) { super(pInst, w, h, isMainCanvas, elt); + this.rendererType = constants.WEBGL; + this._pInst.rendererType = this.rendererType; + if (this.webglVersion === constants.WEBGL2) { this.blendExt = this.GL; } else { diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 687d79257f..d4d26e1df2 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -763,6 +763,9 @@ function rendererWebGPU(p5, fn) { constructor(pInst, w, h, isMainCanvas, elt) { super(pInst, w, h, isMainCanvas, elt); + this.rendererType = constants.WEBGPU; + this._pInst.rendererType = this.rendererType; + warnExperimental(p5, pInst, 'webgpu'); // Used to group draws into one big render pass @@ -855,18 +858,35 @@ function rendererWebGPU(p5, fn) { } async _initContext() { - this.adapter = await navigator.gpu?.requestAdapter( + if (!navigator.gpu) { + throw new Error( + 'WebGPU is not supported by this browser.' + ); + } + this.adapter = await navigator.gpu.requestAdapter( this._webgpuAttributes ); - this.device = await this.adapter?.requestDevice({ - // Todo: check support + if (!this.adapter) { + throw new Error( + 'No compatible GPU could be found for WebGPU on this device.' + ); + } + this.device = await this.adapter.requestDevice({ requiredFeatures: ['depth32float-stencil8'] - }); + }).catch(() => null); if (!this.device) { - throw new Error('Your browser does not support WebGPU.'); + throw new Error( + 'WebGPU is available, but a WebGPU device could not be created on this system.' + ); } - this.queue = this.device.queue; this.drawingContext = this.canvas.getContext('webgpu'); + if (!this.drawingContext) { + throw new Error( + 'WebGPU is available, but the drawing context could not be created for this canvas.' + ); + } + + this.queue = this.device.queue; this.presentationFormat = navigator.gpu.getPreferredCanvasFormat(); this.drawingContext.configure({ device: this.device, diff --git a/test/unit/core/rendering.js b/test/unit/core/rendering.js index ee9b8d9d9e..14fc3dac48 100644 --- a/test/unit/core/rendering.js +++ b/test/unit/core/rendering.js @@ -375,4 +375,101 @@ suite('Rendering', function () { assert.equal(g.pixelDensity(), expectedDensity); }); }); + + suite('WebGPU to WebGL fallback', function () { + function fesMessages(fesSpy) { + return fesSpy.mock.calls.map(args => { + const [strings, ...values] = args; + return strings.reduce((acc, s, i) => acc + s + (values[i] ?? ''), ''); + }); + } + + test('rendererType defaults to P2D and tracks WEBGL', function () { + myp5.createCanvas(10, 10); + assert.equal(myp5.rendererType, myp5.P2D); + myp5.createCanvas(10, 10, myp5.WEBGL); + assert.equal(myp5.rendererType, myp5.WEBGL); + assert.equal(myp5._renderer.rendererType, myp5.WEBGL); + }); + + test('falls back to WEBGL when the WEBGPU addon is missing', function () { + const saved = p5.renderers[myp5.WEBGPU]; + delete p5.renderers[myp5.WEBGPU]; + const fesSpy = vi.spyOn(p5.FES, 'log'); + try { + const renderer = myp5.createCanvas(10, 10, myp5.WEBGPU); + assert.equal(renderer.rendererType, myp5.WEBGL); + assert.equal(myp5.rendererType, myp5.WEBGL); + const messages = fesMessages(fesSpy).join('\n'); + assert.isTrue(messages.includes('add-on')); + assert.isTrue(messages.includes('Falling back to WebGL')); + } finally { + if (saved) { + p5.renderers[myp5.WEBGPU] = saved; + } + fesSpy.mockRestore(); + } + }); + + test('falls back to WEBGL when WEBGPU context creation fails', async function () { + const failureReason = + 'No compatible GPU could be found for WebGPU on this device, so p5 cannot start it here.'; + class FailingWebGPU { + constructor(pInst) { + this._pInst = pInst; + this.canvas = document.createElement('canvas'); + this.elt = this.canvas; + this.contextReady = Promise.reject(new Error(failureReason)); + } + remove() {} + _applyDefaults() {} + } + const saved = p5.renderers[myp5.WEBGPU]; + p5.renderers[myp5.WEBGPU] = FailingWebGPU; + const fesSpy = vi.spyOn(p5.FES, 'log'); + try { + const renderer = await myp5.createCanvas(10, 10, myp5.WEBGPU); + assert.instanceOf(renderer, p5.RendererGL); + assert.equal(myp5.rendererType, myp5.WEBGL); + assert.isFalse( + myp5._elements.some(element => element instanceof FailingWebGPU) + ); + const messages = fesMessages(fesSpy).join('\n'); + assert.isTrue(messages.includes('Falling back to WebGL')); + assert.isTrue(messages.includes('No compatible GPU')); + } finally { + if (saved) { + p5.renderers[myp5.WEBGPU] = saved; + } else { + delete p5.renderers[myp5.WEBGPU]; + } + fesSpy.mockRestore(); + } + }); + + test('non-WEBGPU context failure still throws', async function () { + class FailingFake { + constructor() { + this.canvas = document.createElement('canvas'); + this.elt = this.canvas; + this.contextReady = Promise.reject(new Error('boom')); + } + remove() {} + _applyDefaults() {} + } + p5.renderers['fake-fail'] = FailingFake; + try { + let error = null; + try { + await myp5.createCanvas(10, 10, 'fake-fail'); + } catch (e) { + error = e; + } + expect(error).to.exist; + expect(error.message).to.match(/Failed to create canvas/); + } finally { + delete p5.renderers['fake-fail']; + } + }); + }); }); diff --git a/test/unit/webgpu/p5.RendererWebGPU.js b/test/unit/webgpu/p5.RendererWebGPU.js index 05b3f9c838..57aa98c397 100644 --- a/test/unit/webgpu/p5.RendererWebGPU.js +++ b/test/unit/webgpu/p5.RendererWebGPU.js @@ -1,5 +1,6 @@ import p5 from '../../../src/app.js'; import rendererWebGPU from '../../../src/webgpu/p5.RendererWebGPU'; +import { vi } from 'vitest'; p5.registerAddon(rendererWebGPU); @@ -738,4 +739,117 @@ suite('WebGPU p5.RendererWebGPU', function () { expect(pixel[3]).to.equal(255); }); }); + + suite('rendererType', function () { + test('reports WEBGPU after a WebGPU canvas is created', function () { + expect(myp5.rendererType).to.equal(myp5.WEBGPU); + expect(myp5._renderer.rendererType).to.equal(myp5.WEBGPU); + }); + + test('reports WEBGPU synchronously before context init resolves', function () { + const initStub = vi + .spyOn(p5.RendererWebGPU.prototype, '_initContext') + .mockImplementation(() => new Promise(() => {})); + myp5.rendererType = myp5.P2D; + let renderer = null; + try { + renderer = new p5.RendererWebGPU(myp5, 50, 50, false); + expect(renderer.rendererType).to.equal(myp5.WEBGPU); + expect(myp5.rendererType).to.equal(myp5.WEBGPU); + } finally { + initStub.mockRestore(); + if (renderer) { + renderer.canvas.remove(); + } + myp5.rendererType = myp5.WEBGPU; + } + }); + }); + + suite('_initContext error detection', function () { + function bareRenderer() { + const renderer = Object.create(p5.RendererWebGPU.prototype); + renderer._pInst = myp5; + renderer._webgpuAttributes = { + forceFallbackAdapter: false, + powerPreference: 'high-performance' + }; + renderer.canvas = document.createElement('canvas'); + return renderer; + } + + function stubGpu(impl) { + const descriptor = Object.getOwnPropertyDescriptor(navigator, 'gpu'); + Object.defineProperty(navigator, 'gpu', { + value: impl, + configurable: true + }); + return () => { + if (descriptor) { + Object.defineProperty(navigator, 'gpu', descriptor); + } else { + delete navigator.gpu; + } + }; + } + + test('throws a friendly error when WebGPU is not supported', async function () { + const restore = stubGpu(undefined); + try { + await expect(bareRenderer()._initContext()).rejects.toThrow( + /not supported by this browser/ + ); + } finally { + restore(); + } + }); + + test('throws when no adapter is found', async function () { + const restore = stubGpu({ + requestAdapter: vi.fn().mockResolvedValue(null) + }); + try { + await expect(bareRenderer()._initContext()).rejects.toThrow( + /No compatible GPU/ + ); + } finally { + restore(); + } + }); + + test('throws when no device can be created', async function () { + const restore = stubGpu({ + requestAdapter: vi.fn().mockResolvedValue({ + requestDevice: vi.fn().mockResolvedValue(null) + }) + }); + try { + await expect(bareRenderer()._initContext()).rejects.toThrow( + /device could not be created/ + ); + } finally { + restore(); + } + }); + + test('throws when the canvas context cannot be created', async function () { + const restore = stubGpu({ + requestAdapter: vi.fn().mockResolvedValue({ + requestDevice: vi.fn().mockResolvedValue({}) + }) + }); + const renderer = bareRenderer(); + const getContextSpy = vi + .spyOn(renderer.canvas, 'getContext') + .mockReturnValue(null); + try { + await expect(renderer._initContext()).rejects.toThrow( + /drawing context could not be created/ + ); + } finally { + getContextSpy.mockRestore(); + restore(); + } + }); + }); });