diff --git a/src/rendering/webgpu/WebGpuBackend.ts b/src/rendering/webgpu/WebGpuBackend.ts index 2260d0546..9df523e32 100644 --- a/src/rendering/webgpu/WebGpuBackend.ts +++ b/src/rendering/webgpu/WebGpuBackend.ts @@ -173,6 +173,71 @@ const MANAGED_TEXTURE_BYTES_PER_PIXEL = 4; /** WGSL source for the box-filter mipmap-generation pipeline. @internal */ export const mipmapWgsl: string = mipmapWgslModule; +/** + * The one `GPUAdapter` requested per `GPU` object, shared across every + * `WebGpuBackend` instance that initializes against that same `navigator.gpu`. + * + * `GPUDevice` has an explicit `destroy()` for exactly this reason - see the + * comment on {@link WebGpuBackend.destroy} - but the spec gives `GPUAdapter` + * no equivalent: an adapter is released only once every reference to it is + * garbage collected, and GC timing is not something a hot path can rely on. + * An application that creates one `Application` never notices, but a process + * that constructs many backends back to back (the rendering parity matrix + * does, once per scene per property) can request adapters faster than the + * browser reclaims the previous ones. Firefox in particular enforces a low + * ceiling on simultaneously live adapters/devices and fails the next + * `requestDevice()` with "not enough memory" well before anything has + * actually leaked. Mirroring a single adapter here removes the pile-up + * without changing behaviour: an adapter can mint any number of devices, so + * reuse costs nothing a fresh request would have bought. + * + * Keyed by the `GPU` object rather than held as one bare value so a real page + * - which keeps exactly one `navigator.gpu` for its whole life - shares one + * adapter, while a test that installs its own mock `GPU` object gets its own + * cache entry for free and cannot observe another test's adapter. A `WeakMap` + * also means a mock `GPU` object never outlives its test in this cache. + */ +const sharedAdapters = new WeakMap(); +const pendingAdapterRequests = new WeakMap>(); + +/** The `GPU` object's shared adapter, requesting it once if nothing has yet. */ +const requestSharedAdapter = async (gpu: GPU): Promise => { + const cached = sharedAdapters.get(gpu); + + if (cached !== undefined) return cached; + + let pending = pendingAdapterRequests.get(gpu); + + if (pending === undefined) { + pending = gpu.requestAdapter(); + pendingAdapterRequests.set(gpu, pending); + } + + const adapter = await pending; + + pendingAdapterRequests.delete(gpu); + + if (adapter !== null) { + sharedAdapters.set(gpu, adapter); + } + + return adapter; +}; + +/** + * Drops the `GPU` object's shared adapter so the next backend to initialize + * against it requests a new one. + * + * Called when there is a concrete reason to believe the cached adapter is no + * longer good: `requestDevice()` rejected on it, or a device it minted was + * lost for a reason other than an explicit `destroy()` - loss this backend + * did not cause is the strongest signal available that the adapter itself, + * not just one device, went away (a GPU reset invalidates both). + */ +const invalidateSharedAdapter = (gpu: GPU): void => { + sharedAdapters.delete(gpu); +}; + /** * WebGPU implementation of {@link RenderBackend}. Manages the GPU device, * canvas context configuration, format selection, managed-texture cache @@ -183,7 +248,8 @@ export const mipmapWgsl: string = mipmapWgslModule; * * Detects device loss via the platform's `device.lost` Promise and * automatically attempts recovery: drops dead GPU state, requests a - * fresh adapter+device with exponential backoff (up to 5 tries), then + * device from the shared adapter (a fresh one if the loss was not an + * explicit `destroy()`) with exponential backoff (up to 5 tries), then * fires {@link WebGpuBackend.onDeviceRestored}. While recovering, draw * submissions silently no-op so user code survives transient outages * without explicit error handling. If every retry fails, a @@ -2302,11 +2368,12 @@ export class WebGpuBackend implements RenderBackend { // Request the adapter AND the device before acquiring a WebGPU canvas // context - see the getContext('webgpu') call below for why the order - // matters. + // matters. The adapter is the process-wide shared one (see + // `requestSharedAdapter`), not a fresh request per backend. let adapter: GPUAdapter | null; try { - adapter = await gpuNavigator.gpu.requestAdapter(); + adapter = await requestSharedAdapter(gpuNavigator.gpu); } catch (error) { throw this._createInitializationError('Failed to request a WebGPU adapter.', error); } @@ -2315,63 +2382,33 @@ export class WebGpuBackend implements RenderBackend { throw new Error('Could not acquire a WebGPU adapter.'); } - if (typeof adapter.requestDevice !== 'function') { - throw new Error('WebGPU adapter does not expose requestDevice().'); - } - let device: GPUDevice | null; try { - // rgba16float and rgba32float are both core color-renderable in WebGPU (no - // feature needed). Opt into the optional float features the adapter offers - // so float32 targets can additionally be linear-sampled / blended when used - // that way (float RenderTextures default to nearest, so this is a bonus). - const floatFeatures = (['float32-filterable', 'float32-blendable'] as const).filter(feature => adapter.features?.has(feature) ?? false); - - // Compressed-format families are optional features, and a device only - // carries what the request asked for - so an adapter that supports BC - // still yields a device that rejects a BC texture unless it is requested - // here. Filtering against the adapter first keeps the request satisfiable: - // asking for a family the adapter lacks fails the whole `requestDevice`. - const compressedFeatures = webgpuCompressedTextureFeatures.filter(feature => adapter.features?.has(feature) ?? false); - - // The sprite batcher sizes its multi-texture bind-group layout from the - // GRANTED device limits (resolveSpriteBatchTextureSlots): request up to - // the 32-slot ceiling when the adapter offers more than the spec base of - // 16 texture/sampler bindings per stage. Requesting min(adapterLimit, - // ceiling) is always satisfiable, so this can never fail the request. - const requiredLimits: Record = {}; - const adapterLimits = (adapter as { limits?: GPUSupportedLimits }).limits; - - if (adapterLimits !== undefined) { - for (const limit of ['maxSampledTexturesPerShaderStage', 'maxSamplersPerShaderStage'] as const) { - const available = adapterLimits[limit]; - - if (typeof available === 'number' && available > baseSpriteBatchTextureSlots) { - requiredLimits[limit] = Math.min(maxSpriteBatchTextureSlots, available); - } - } - } - - // A device's feature set is fixed at creation, so `timestamp-query` has to - // be requested here or `setGpuTimingEnabled` can never succeed on this - // device. Requesting a feature nothing uses changes no rendering behaviour - // and costs nothing until a timer actually allocates a query set. - const timestampFeatures = (['timestamp-query'] as const).filter(feature => adapter.features?.has(feature) ?? false); - - const descriptor: GPUDeviceDescriptor = {}; + device = await this._requestDeviceFrom(adapter); + } catch (error) { + // The shared adapter may have gone stale since another backend last + // used it - released by the browser, or invalidated by a driver reset. + // One retry against a freshly requested adapter tells that apart from + // an ordinary request failure: a genuinely dead adapter fails again + // immediately, so the retry only ever costs one extra round trip. + invalidateSharedAdapter(gpuNavigator.gpu); - if (floatFeatures.length > 0 || compressedFeatures.length > 0 || timestampFeatures.length > 0) { - descriptor.requiredFeatures = [...floatFeatures, ...compressedFeatures, ...timestampFeatures]; + try { + adapter = await requestSharedAdapter(gpuNavigator.gpu); + } catch (retryError) { + throw this._createInitializationError('Failed to request a WebGPU adapter.', retryError); } - if (Object.keys(requiredLimits).length > 0) { - descriptor.requiredLimits = requiredLimits; + if (adapter === null) { + throw new Error('Could not acquire a WebGPU adapter.', { cause: error }); } - device = await adapter.requestDevice(Object.keys(descriptor).length > 0 ? descriptor : undefined); - } catch (error) { - throw this._createInitializationError('Failed to request a WebGPU device.', error); + try { + device = await this._requestDeviceFrom(adapter); + } catch (retryError) { + throw this._createInitializationError('Failed to request a WebGPU device.', retryError); + } } if (device === null) { @@ -2480,6 +2517,16 @@ export class WebGpuBackend implements RenderBackend { return; } + // A loss we did not cause is the strongest signal available that the + // adapter itself may be gone too (a GPU reset invalidates both), so + // recovery requests a fresh one rather than risk retrying against a dead + // cached adapter for every one of its attempts. + const gpuNavigator = this._getGpuNavigator(); + + if (gpuNavigator !== null) { + invalidateSharedAdapter(gpuNavigator.gpu); + } + void this._attemptRecovery(); } @@ -2706,6 +2753,62 @@ export class WebGpuBackend implements RenderBackend { await Promise.all(promises); } + /** Requests a device from the given adapter with the feature/limit set this backend needs. */ + private async _requestDeviceFrom(adapter: GPUAdapter): Promise { + if (typeof adapter.requestDevice !== 'function') { + throw new Error('WebGPU adapter does not expose requestDevice().'); + } + + // rgba16float and rgba32float are both core color-renderable in WebGPU (no + // feature needed). Opt into the optional float features the adapter offers + // so float32 targets can additionally be linear-sampled / blended when used + // that way (float RenderTextures default to nearest, so this is a bonus). + const floatFeatures = (['float32-filterable', 'float32-blendable'] as const).filter(feature => adapter.features?.has(feature) ?? false); + + // Compressed-format families are optional features, and a device only + // carries what the request asked for - so an adapter that supports BC + // still yields a device that rejects a BC texture unless it is requested + // here. Filtering against the adapter first keeps the request satisfiable: + // asking for a family the adapter lacks fails the whole `requestDevice`. + const compressedFeatures = webgpuCompressedTextureFeatures.filter(feature => adapter.features?.has(feature) ?? false); + + // The sprite batcher sizes its multi-texture bind-group layout from the + // GRANTED device limits (resolveSpriteBatchTextureSlots): request up to + // the 32-slot ceiling when the adapter offers more than the spec base of + // 16 texture/sampler bindings per stage. Requesting min(adapterLimit, + // ceiling) is always satisfiable, so this can never fail the request. + const requiredLimits: Record = {}; + const adapterLimits = (adapter as { limits?: GPUSupportedLimits }).limits; + + if (adapterLimits !== undefined) { + for (const limit of ['maxSampledTexturesPerShaderStage', 'maxSamplersPerShaderStage'] as const) { + const available = adapterLimits[limit]; + + if (typeof available === 'number' && available > baseSpriteBatchTextureSlots) { + requiredLimits[limit] = Math.min(maxSpriteBatchTextureSlots, available); + } + } + } + + // A device's feature set is fixed at creation, so `timestamp-query` has to + // be requested here or `setGpuTimingEnabled` can never succeed on this + // device. Requesting a feature nothing uses changes no rendering behaviour + // and costs nothing until a timer actually allocates a query set. + const timestampFeatures = (['timestamp-query'] as const).filter(feature => adapter.features?.has(feature) ?? false); + + const descriptor: GPUDeviceDescriptor = {}; + + if (floatFeatures.length > 0 || compressedFeatures.length > 0 || timestampFeatures.length > 0) { + descriptor.requiredFeatures = [...floatFeatures, ...compressedFeatures, ...timestampFeatures]; + } + + if (Object.keys(requiredLimits).length > 0) { + descriptor.requiredLimits = requiredLimits; + } + + return adapter.requestDevice(Object.keys(descriptor).length > 0 ? descriptor : undefined); + } + private _getGpuNavigator(): (Navigator & { gpu: GPU }) | null { const gpuNavigator = navigator as Navigator & Partial<{ gpu: GPU }>; diff --git a/test/rendering/parity/properties/crossBackendParity.ts b/test/rendering/parity/properties/crossBackendParity.ts index f2255fdb7..480cbaf5b 100644 --- a/test/rendering/parity/properties/crossBackendParity.ts +++ b/test/rendering/parity/properties/crossBackendParity.ts @@ -12,8 +12,7 @@ import { Color } from '#core/Color'; -import { readWebGl2Frame, readWebGpuFrame, renderWebGl2Once, renderWebGpuOnce, webGl2Available, webGpuAvailable } from '../../browser/_backendSetup'; -import { openWebGl2, openWebGpu } from '../backends'; +import { readWebGl2Frame, readWebGpuFrame, renderWebGl2Once, renderWebGpuOnce } from '../../browser/_backendSetup'; import { drawnPixelCount, maxChannelDelta, pixelsExceeding } from '../frames'; import type { CrossBackendProperty, PropertyResult } from '../types'; @@ -33,99 +32,91 @@ export const crossBackendParity: CrossBackendProperty = { scope: 'cross-backend', appliesTo: () => true, - run: async ({ scene, skip }): Promise => { + run: async ({ scene, skip, webgl2, webgpu }): Promise => { // A browser missing a backend cannot be compared across backends - that is // an answer about the browser, not a failure of the engine. - if (!webGl2Available()) { + if (webgl2 === null) { return { support: 'unavailable', evidence: 'none', delta: null, note: 'no WebGL2 context in this browser' }; } - if (!(await webGpuAvailable())) { + if (webgpu === null) { return { support: 'unavailable', evidence: 'none', delta: null, note: 'no WebGPU adapter in this browser' }; } - const gl = await openWebGl2(scene); - const gpu = await openWebGpu(scene); - - try { - renderWebGl2Once(gl, scene.build(), Color.black); - - // A dropped device is missing evidence, never satisfied evidence. - const rendered = await renderWebGpuOnce({ skip }, gpu, scene.build(), Color.black); - - if (!rendered) { - return { support: 'unknown', evidence: 'none', delta: null, note: 'WebGPU device lost mid-run' }; - } - - const glFrame = readWebGl2Frame(gl, scene.size); - const gpuFrame = readWebGpuFrame(gpu, scene.size); - - // Two empty frames are byte-identical, so the comparison below would - // report a perfect match about nothing at all. `renders-something` covers - // the same ground as its own row, but a green parity row claiming - // `traced` is the misleading one, so emptiness is a precondition here - // rather than a neighbouring property's business. - if (drawnPixelCount(glFrame) === 0 && drawnPixelCount(gpuFrame) === 0) { - return { support: 'divergent', evidence: 'none', delta: null, note: 'both backends rendered an empty frame - nothing was compared' }; - } - - const delta = maxChannelDelta(glFrame, gpuFrame); - - if (delta === 0) { - return { - support: 'supported', - // Whole-frame comparison; the runner decides whether the scene lets it - // count as `traced` rather than merely `frame-equal`. - evidence: 'traced', - delta, - }; - } - - if (delta <= LAST_BIT) { - // Equal to the last bit rather than bit-identical. Recorded as its own - // class instead of quietly passing: a reader can tell an adapter's - // rounding from a genuine match, and `tolerant` rows are exactly what - // to look at when a real difference is suspected. - return { - support: 'supported', - evidence: 'tolerant', - delta, - note: `backends agree within ${delta} of one channel step`, - }; - } - - const tolerance = scene.crossBackendTolerance; - - if (tolerance !== undefined) { - const differing = pixelsExceeding(glFrame, gpuFrame, LAST_BIT); - const fraction = differing / (scene.size * scene.size); - const within = delta <= tolerance.delta && fraction <= tolerance.maxPixelFraction; - const measured = `${delta} on ${differing} px (${(fraction * 100).toFixed(1)}% of the frame)`; - - return within - ? { - support: 'supported', - evidence: 'tolerant', - delta, - note: `backends differ by ${measured}, within this scene's declared tolerance`, - } - : { - support: 'divergent', - evidence: 'traced', - delta, - note: `backends differ by ${measured}, beyond this scene's tolerance of ${tolerance.delta} on ${(tolerance.maxPixelFraction * 100).toFixed(0)}% of the frame`, - }; - } + renderWebGl2Once(webgl2, scene.build(), Color.black); + // A dropped device is missing evidence, never satisfied evidence. + const rendered = await renderWebGpuOnce({ skip }, webgpu, scene.build(), Color.black); + + if (!rendered) { + return { support: 'unknown', evidence: 'none', delta: null, note: 'WebGPU device lost mid-run' }; + } + + const glFrame = readWebGl2Frame(webgl2, scene.size); + const gpuFrame = readWebGpuFrame(webgpu, scene.size); + + // Two empty frames are byte-identical, so the comparison below would + // report a perfect match about nothing at all. `renders-something` covers + // the same ground as its own row, but a green parity row claiming + // `traced` is the misleading one, so emptiness is a precondition here + // rather than a neighbouring property's business. + if (drawnPixelCount(glFrame) === 0 && drawnPixelCount(gpuFrame) === 0) { + return { support: 'divergent', evidence: 'none', delta: null, note: 'both backends rendered an empty frame - nothing was compared' }; + } + + const delta = maxChannelDelta(glFrame, gpuFrame); + + if (delta === 0) { return { - support: 'divergent', + support: 'supported', + // Whole-frame comparison; the runner decides whether the scene lets it + // count as `traced` rather than merely `frame-equal`. evidence: 'traced', delta, - note: `backends differ by ${delta} on at least one channel`, }; - } finally { - gl.destroy(); - gpu.destroy(); } + + if (delta <= LAST_BIT) { + // Equal to the last bit rather than bit-identical. Recorded as its own + // class instead of quietly passing: a reader can tell an adapter's + // rounding from a genuine match, and `tolerant` rows are exactly what + // to look at when a real difference is suspected. + return { + support: 'supported', + evidence: 'tolerant', + delta, + note: `backends agree within ${delta} of one channel step`, + }; + } + + const tolerance = scene.crossBackendTolerance; + + if (tolerance !== undefined) { + const differing = pixelsExceeding(glFrame, gpuFrame, LAST_BIT); + const fraction = differing / (scene.size * scene.size); + const within = delta <= tolerance.delta && fraction <= tolerance.maxPixelFraction; + const measured = `${delta} on ${differing} px (${(fraction * 100).toFixed(1)}% of the frame)`; + + return within + ? { + support: 'supported', + evidence: 'tolerant', + delta, + note: `backends differ by ${measured}, within this scene's declared tolerance`, + } + : { + support: 'divergent', + evidence: 'traced', + delta, + note: `backends differ by ${measured}, beyond this scene's tolerance of ${tolerance.delta} on ${(tolerance.maxPixelFraction * 100).toFixed(0)}% of the frame`, + }; + } + + return { + support: 'divergent', + evidence: 'traced', + delta, + note: `backends differ by ${delta} on at least one channel`, + }; }, }; diff --git a/test/rendering/parity/properties/determinism.ts b/test/rendering/parity/properties/determinism.ts index 10dc77f4a..c6b4852a4 100644 --- a/test/rendering/parity/properties/determinism.ts +++ b/test/rendering/parity/properties/determinism.ts @@ -9,8 +9,7 @@ import { Color } from '#core/Color'; -import { readWebGl2Frame, readWebGpuFrame, renderWebGl2Once, renderWebGpuOnce, webGl2Available, webGpuAvailable } from '../../browser/_backendSetup'; -import { openWebGl2, openWebGpu } from '../backends'; +import { readWebGl2Frame, readWebGpuFrame, renderWebGl2Once, renderWebGpuOnce } from '../../browser/_backendSetup'; import { maxChannelDelta } from '../frames'; import type { PerBackendProperty, PropertyResult } from '../types'; @@ -28,49 +27,37 @@ export const determinism: PerBackendProperty = { scope: 'per-backend', appliesTo: () => true, - run: async ({ scene, skip }, backend): Promise => { + run: async ({ scene, skip, webgl2, webgpu }, backend): Promise => { // A fresh graph per frame: reusing one would let retained state make the // second frame identical for the wrong reason. if (backend === 'webgl2') { - if (!webGl2Available()) { + if (webgl2 === null) { return { support: 'unavailable', evidence: 'none', delta: null, note: 'no WebGL2 context in this browser' }; } - const gl = await openWebGl2(scene); + renderWebGl2Once(webgl2, scene.build(), Color.black); - try { - renderWebGl2Once(gl, scene.build(), Color.black); + const first = readWebGl2Frame(webgl2, scene.size); - const first = readWebGl2Frame(gl, scene.size); + renderWebGl2Once(webgl2, scene.build(), Color.black); - renderWebGl2Once(gl, scene.build(), Color.black); - - return verdict(maxChannelDelta(first, readWebGl2Frame(gl, scene.size))); - } finally { - gl.destroy(); - } + return verdict(maxChannelDelta(first, readWebGl2Frame(webgl2, scene.size))); } - if (!(await webGpuAvailable())) { + if (webgpu === null) { return { support: 'unavailable', evidence: 'none', delta: null, note: 'no WebGPU adapter in this browser' }; } - const gpu = await openWebGpu(scene); - - try { - if (!(await renderWebGpuOnce({ skip }, gpu, scene.build(), Color.black))) { - return { support: 'unknown', evidence: 'none', delta: null, note: 'WebGPU device lost mid-run' }; - } - - const first = readWebGpuFrame(gpu, scene.size); + if (!(await renderWebGpuOnce({ skip }, webgpu, scene.build(), Color.black))) { + return { support: 'unknown', evidence: 'none', delta: null, note: 'WebGPU device lost mid-run' }; + } - if (!(await renderWebGpuOnce({ skip }, gpu, scene.build(), Color.black))) { - return { support: 'unknown', evidence: 'none', delta: null, note: 'WebGPU device lost mid-run' }; - } + const first = readWebGpuFrame(webgpu, scene.size); - return verdict(maxChannelDelta(first, readWebGpuFrame(gpu, scene.size))); - } finally { - gpu.destroy(); + if (!(await renderWebGpuOnce({ skip }, webgpu, scene.build(), Color.black))) { + return { support: 'unknown', evidence: 'none', delta: null, note: 'WebGPU device lost mid-run' }; } + + return verdict(maxChannelDelta(first, readWebGpuFrame(webgpu, scene.size))); }, }; diff --git a/test/rendering/parity/properties/oracleAgreement.ts b/test/rendering/parity/properties/oracleAgreement.ts index 1e3e779b2..a01ee5605 100644 --- a/test/rendering/parity/properties/oracleAgreement.ts +++ b/test/rendering/parity/properties/oracleAgreement.ts @@ -16,8 +16,7 @@ import { Color } from '#core/Color'; -import { readWebGl2Frame, readWebGpuFrame, renderWebGl2Once, renderWebGpuOnce, webGl2Available, webGpuAvailable } from '../../browser/_backendSetup'; -import { openWebGl2, openWebGpu } from '../backends'; +import { readWebGl2Frame, readWebGpuFrame, renderWebGl2Once, renderWebGpuOnce } from '../../browser/_backendSetup'; import type { OracleSample, PerBackendProperty, PropertyResult, SceneOracle } from '../types'; const channels = ['R', 'G', 'B', 'A'] as const; @@ -68,7 +67,7 @@ export const oracleAgreement: PerBackendProperty = { scope: 'per-backend', appliesTo: scene => scene.oracle !== undefined, - run: async ({ scene, skip }, backend): Promise => { + run: async ({ scene, skip, webgl2, webgpu }, backend): Promise => { // `appliesTo` already gated this, but the type has to be narrowed here too. const oracle = scene.oracle; @@ -77,35 +76,23 @@ export const oracleAgreement: PerBackendProperty = { } if (backend === 'webgl2') { - if (!webGl2Available()) { + if (webgl2 === null) { return { support: 'unavailable', evidence: 'none', delta: null, note: 'no WebGL2 context in this browser' }; } - const gl = await openWebGl2(scene); + renderWebGl2Once(webgl2, scene.build(), Color.black); - try { - renderWebGl2Once(gl, scene.build(), Color.black); - - return compare(readWebGl2Frame(gl, scene.size), scene.size, oracle); - } finally { - gl.destroy(); - } + return compare(readWebGl2Frame(webgl2, scene.size), scene.size, oracle); } - if (!(await webGpuAvailable())) { + if (webgpu === null) { return { support: 'unavailable', evidence: 'none', delta: null, note: 'no WebGPU adapter in this browser' }; } - const gpu = await openWebGpu(scene); - - try { - if (!(await renderWebGpuOnce({ skip }, gpu, scene.build(), Color.black))) { - return { support: 'unknown', evidence: 'none', delta: null, note: 'WebGPU device lost mid-run' }; - } - - return compare(readWebGpuFrame(gpu, scene.size), scene.size, oracle); - } finally { - gpu.destroy(); + if (!(await renderWebGpuOnce({ skip }, webgpu, scene.build(), Color.black))) { + return { support: 'unknown', evidence: 'none', delta: null, note: 'WebGPU device lost mid-run' }; } + + return compare(readWebGpuFrame(webgpu, scene.size), scene.size, oracle); }, }; diff --git a/test/rendering/parity/properties/rendersSomething.ts b/test/rendering/parity/properties/rendersSomething.ts index 055025f87..b40f0197d 100644 --- a/test/rendering/parity/properties/rendersSomething.ts +++ b/test/rendering/parity/properties/rendersSomething.ts @@ -13,8 +13,7 @@ import { Color } from '#core/Color'; -import { readWebGl2Frame, readWebGpuFrame, renderWebGl2Once, renderWebGpuOnce, webGl2Available, webGpuAvailable } from '../../browser/_backendSetup'; -import { openWebGl2, openWebGpu } from '../backends'; +import { readWebGl2Frame, readWebGpuFrame, renderWebGl2Once, renderWebGpuOnce } from '../../browser/_backendSetup'; import { drawnPixelCount } from '../frames'; import type { PerBackendProperty, PropertyResult } from '../types'; @@ -32,39 +31,27 @@ export const rendersSomething: PerBackendProperty = { scope: 'per-backend', appliesTo: () => true, - run: async ({ scene, skip }, backend): Promise => { + run: async ({ scene, skip, webgl2, webgpu }, backend): Promise => { const total = scene.size * scene.size; if (backend === 'webgl2') { - if (!webGl2Available()) { + if (webgl2 === null) { return { support: 'unavailable', evidence: 'none', delta: null, note: 'no WebGL2 context in this browser' }; } - const gl = await openWebGl2(scene); + renderWebGl2Once(webgl2, scene.build(), Color.black); - try { - renderWebGl2Once(gl, scene.build(), Color.black); - - return verdict(drawnPixelCount(readWebGl2Frame(gl, scene.size)), total); - } finally { - gl.destroy(); - } + return verdict(drawnPixelCount(readWebGl2Frame(webgl2, scene.size)), total); } - if (!(await webGpuAvailable())) { + if (webgpu === null) { return { support: 'unavailable', evidence: 'none', delta: null, note: 'no WebGPU adapter in this browser' }; } - const gpu = await openWebGpu(scene); - - try { - if (!(await renderWebGpuOnce({ skip }, gpu, scene.build(), Color.black))) { - return { support: 'unknown', evidence: 'none', delta: null, note: 'WebGPU device lost mid-run' }; - } - - return verdict(drawnPixelCount(readWebGpuFrame(gpu, scene.size)), total); - } finally { - gpu.destroy(); + if (!(await renderWebGpuOnce({ skip }, webgpu, scene.build(), Color.black))) { + return { support: 'unknown', evidence: 'none', delta: null, note: 'WebGPU device lost mid-run' }; } + + return verdict(drawnPixelCount(readWebGpuFrame(webgpu, scene.size)), total); }, }; diff --git a/test/rendering/parity/runner.ts b/test/rendering/parity/runner.ts index 493f66a4a..ea44e2366 100644 --- a/test/rendering/parity/runner.ts +++ b/test/rendering/parity/runner.ts @@ -8,9 +8,11 @@ * verification - the thing a suite of green tests structurally cannot. */ -import { afterAll, describe, expect, test } from 'vitest'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; import { commands } from 'vitest/browser'; +import { webGl2Available, webGpuAvailable } from '../browser/_backendSetup'; +import { openWebGl2, openWebGpu } from './backends'; import type { EvidenceRow } from './evidenceSink'; import { cappedEvidence, type Property, type Scene } from './types'; @@ -61,6 +63,32 @@ export const runParityMatrix = (scenes: readonly Scene[], properties: readonly P for (const scene of scenes) { describe(scene.name, () => { + // Opened once per scene and shared by every property that runs against + // it, rather than once per property: a fresh WebGpuBackend per property + // multiplied device/adapter construction by the property count, and a + // driver does not always reclaim a destroyed one before the next + // construction runs (see WebGpuBackend's shared-adapter comment for the + // same mechanism one level up, on GPUAdapter). `null` records "not + // available in this browser" for every property that checks it, rather + // than each of them probing separately. + let webgl2: Awaited> | null = null; + let webgpu: Awaited> | null = null; + + beforeAll(async () => { + if (webGl2Available()) { + webgl2 = await openWebGl2(scene); + } + + if (await webGpuAvailable()) { + webgpu = await openWebGpu(scene); + } + }); + + afterAll(() => { + webgl2?.destroy(); + webgpu?.destroy(); + }); + for (const property of properties) { if (!property.appliesTo(scene)) { // Not applicable is still information: it is why the matrix cell is @@ -86,7 +114,7 @@ export const runParityMatrix = (scenes: readonly Scene[], properties: readonly P test(`${property.name}`, async ctx => { // Runtime skip for a lost device, not a disabled test. // eslint-disable-next-line vitest/no-disabled-tests - const result = await property.run({ scene, skip: reason => ctx.skip(reason) }); + const result = await property.run({ scene, skip: reason => ctx.skip(reason), webgl2, webgpu }); for (const backend of BACKENDS) record(scene, property, backend, result); @@ -105,7 +133,7 @@ export const runParityMatrix = (scenes: readonly Scene[], properties: readonly P test(`${property.name} [${backend}]`, async ctx => { // Runtime skip for a lost device, not a disabled test. // eslint-disable-next-line vitest/no-disabled-tests - const result = await property.run({ scene, skip: reason => ctx.skip(reason) }, backend); + const result = await property.run({ scene, skip: reason => ctx.skip(reason), webgl2, webgpu }, backend); record(scene, property, backend, result); diff --git a/test/rendering/parity/types.ts b/test/rendering/parity/types.ts index b875a84c2..be0761e88 100644 --- a/test/rendering/parity/types.ts +++ b/test/rendering/parity/types.ts @@ -11,6 +11,8 @@ import type { Container } from '#rendering/Container'; import type { RenderBackend } from '#rendering/RenderBackend'; +import type { WebGl2Backend } from '#rendering/webgl2/WebGl2Backend'; +import type { WebGpuBackend } from '#rendering/webgpu/WebGpuBackend'; import type { EvidenceClass, SupportState } from './evidenceSink'; @@ -133,6 +135,18 @@ export interface PropertyContext { readonly scene: Scene; /** Skips the run when the software adapter drops the device mid-test. */ readonly skip: (reason: string) => void; + /** + * The scene's backends, opened once and shared by every property that runs + * against this scene - `null` where this browser has no such backend. + * + * Owned by the runner, not by the property: opening a fresh backend per + * property multiplied `WebGpuBackend` construction by the property count, + * and a driver does not always reclaim a destroyed one before the next + * construction (see `WebGpuBackend`'s shared-adapter comment for the same + * mechanism one level up). A property must not call `destroy()` on either. + */ + readonly webgl2: WebGl2Backend | null; + readonly webgpu: WebGpuBackend | null; } export interface PerBackendProperty { diff --git a/test/rendering/webgpu-adapter-sharing.test.ts b/test/rendering/webgpu-adapter-sharing.test.ts new file mode 100644 index 000000000..40225a9fa --- /dev/null +++ b/test/rendering/webgpu-adapter-sharing.test.ts @@ -0,0 +1,172 @@ +/** + * Adapter reuse across `WebGpuBackend` instances. + * + * `GPUDevice` has an explicit `destroy()`; `GPUAdapter` does not, so an + * adapter is released only once garbage collection gets to it. A process that + * constructs many backends in quick succession - the rendering parity matrix + * does, once per scene per property - can outrun that collection and hit a + * driver's live-adapter ceiling well before anything has actually leaked + * (observed on Firefox as `requestDevice()` rejecting with "not enough memory + * left"). `WebGpuBackend` shares one adapter per `GPU` object instead of + * requesting a fresh one per instance; these are the guarantees that fix + * rests on. + */ + +import { Color } from '#core/Color'; +import { WebGpuBackend } from '#rendering/webgpu/WebGpuBackend'; + +interface MockGpuEnvironment { + readonly gpu: GPU; + readonly requestAdapter: ReturnType; + /** The next `requestDevice()` call on this GPU's adapter rejects, as a stale/dead adapter would. */ + failNextDeviceRequest(): void; +} + +/** A fresh, independent mock `GPU` object: its own `requestAdapter` spy, its own device chain. */ +const createMockGpu = (): MockGpuEnvironment => { + let failNext = false; + + const device = { + createShaderModule: vi.fn(() => ({}) as GPUShaderModule), + createBindGroupLayout: vi.fn(() => ({}) as GPUBindGroupLayout), + createPipelineLayout: vi.fn(() => ({}) as GPUPipelineLayout), + createBindGroup: vi.fn(() => ({}) as GPUBindGroup), + createRenderPipeline: vi.fn(() => ({}) as GPURenderPipeline), + createCommandEncoder: vi.fn(), + createBuffer: vi.fn(() => ({ destroy: vi.fn() }) as unknown as GPUBuffer), + createTexture: vi.fn(() => ({ destroy: vi.fn(), createView: vi.fn(() => ({})) }) as unknown as GPUTexture), + createSampler: vi.fn(() => ({}) as GPUSampler), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + // Never resolves within a test's lifetime: nothing here drives real device loss. + lost: new Promise(() => undefined), + destroy: vi.fn(), + queue: { writeBuffer: vi.fn(), submit: vi.fn(), copyExternalImageToTexture: vi.fn(), writeTexture: vi.fn() }, + } as unknown as GPUDevice; + + const requestDevice = vi.fn(async () => { + if (failNext) { + failNext = false; + + throw new DOMException('Not enough memory left.', 'OperationError'); + } + + return device; + }); + + const requestAdapter = vi.fn(async () => ({ requestDevice, features: { has: () => false } }) as unknown as GPUAdapter); + + const gpu = { requestAdapter, getPreferredCanvasFormat: vi.fn(() => 'bgra8unorm' as GPUTextureFormat) } as unknown as GPU; + + return { + gpu, + requestAdapter, + failNextDeviceRequest: (): void => { + failNext = true; + }, + }; +}; + +/** Installs `gpu` as `navigator.gpu`, plus the globals every backend init reads, for the duration of `run`. */ +const withGpu = async (gpu: GPU, run: () => Promise): Promise => { + const previousGpu = Object.getOwnPropertyDescriptor(navigator, 'gpu'); + const previousTextureUsage = Object.getOwnPropertyDescriptor(globalThis, 'GPUTextureUsage'); + + Object.defineProperty(navigator, 'gpu', { configurable: true, value: gpu }); + Object.defineProperty(globalThis, 'GPUTextureUsage', { + configurable: true, + value: { COPY_DST: 1, TEXTURE_BINDING: 2, RENDER_ATTACHMENT: 4, COPY_SRC: 8 }, + }); + + try { + await run(); + } finally { + if (previousGpu) Object.defineProperty(navigator, 'gpu', previousGpu); + else Object.defineProperty(navigator, 'gpu', { configurable: true, value: undefined }); + + if (previousTextureUsage) Object.defineProperty(globalThis, 'GPUTextureUsage', previousTextureUsage); + else Object.defineProperty(globalThis, 'GPUTextureUsage', { configurable: true, value: undefined }); + } +}; + +const makeCanvas = (): HTMLCanvasElement => { + const canvas = document.createElement('canvas'); + const context = { + configure: vi.fn(), + unconfigure: vi.fn(), + getCurrentTexture: vi.fn(() => ({ createView: vi.fn(() => ({})) }) as unknown as GPUTexture), + } as unknown as GPUCanvasContext; + + Object.defineProperty(canvas, 'getContext', { configurable: true, value: (type: string) => (type === 'webgpu' ? context : null) }); + + return canvas; +}; + +const makeBackend = (canvas: HTMLCanvasElement): WebGpuBackend => + new WebGpuBackend({ canvas, options: { canvas: { width: 4, height: 4 }, clearColor: Color.black } } as never); + +describe('WebGpuBackend adapter sharing', () => { + it('requests the adapter once and reuses it across sequential backends on the same GPU object', async () => { + const environment = createMockGpu(); + + await withGpu(environment.gpu, async () => { + const first = makeBackend(makeCanvas()); + + await first.initialize(); + first.destroy(); + + const second = makeBackend(makeCanvas()); + + await second.initialize(); + second.destroy(); + + expect(environment.requestAdapter).toHaveBeenCalledTimes(1); + }); + }); + + it('keeps each GPU object on its own adapter', async () => { + const environmentA = createMockGpu(); + const environmentB = createMockGpu(); + + await withGpu(environmentA.gpu, async () => { + const backend = makeBackend(makeCanvas()); + + await backend.initialize(); + backend.destroy(); + }); + + await withGpu(environmentB.gpu, async () => { + const backend = makeBackend(makeCanvas()); + + await backend.initialize(); + backend.destroy(); + }); + + expect(environmentA.requestAdapter).toHaveBeenCalledTimes(1); + expect(environmentB.requestAdapter).toHaveBeenCalledTimes(1); + }); + + it('re-requests the adapter once when requestDevice rejects on the cached one, and still initializes', async () => { + const environment = createMockGpu(); + + await withGpu(environment.gpu, async () => { + const first = makeBackend(makeCanvas()); + + await first.initialize(); + first.destroy(); + + // The cached adapter is now stale, as a driver-reset one would be: the + // next requestDevice() call on it rejects. + environment.failNextDeviceRequest(); + + const second = makeBackend(makeCanvas()); + + await expect(second.initialize()).resolves.toBe(second); + second.destroy(); + + // One request for the stale adapter's rejection to surface, one retry + // that succeeded - not a silent swallow, and not a loop. + expect(environment.requestAdapter).toHaveBeenCalledTimes(2); + }); + }); +});