From 49eb05186064b0aaefe4d0d792aaed2c3b746fdf Mon Sep 17 00:00:00 2001 From: Exoridus Date: Tue, 22 Sep 2026 18:17:16 +0200 Subject: [PATCH 1/2] fix(rendering): share one WebGPU adapter per GPU object WebGpuBackend requested a fresh GPUAdapter on every initialize(); the spec gives adapters no destroy() equivalent to GPUDevice's, so release depends entirely on garbage collection. A process that constructs many backends in quick succession - the rendering parity matrix does, once per scene per property - can request adapters faster than the browser reclaims the previous ones. On Firefox this hit a driver ceiling well before anything had actually leaked, surfacing as requestDevice() rejecting with 'not enough memory left' partway through a run, at a boundary that moved between runs depending on GC timing. WebGpuBackend now shares one adapter per GPU object (an adapter can mint any number of devices, so reuse changes nothing about device lifetime) and self-heals: a requestDevice() rejection on the cached adapter retries once against a freshly requested one, and a device loss for a reason other than an explicit destroy() invalidates the cache before recovery, since a loss the backend did not cause is the strongest available signal that the adapter itself is gone too. --- src/rendering/webgpu/WebGpuBackend.ts | 207 +++++++++++++----- test/rendering/webgpu-adapter-sharing.test.ts | 172 +++++++++++++++ 2 files changed, 327 insertions(+), 52 deletions(-) create mode 100644 test/rendering/webgpu-adapter-sharing.test.ts 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/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); + }); + }); +}); From 7169ec1142b77fbc6d94c937ee8d7c8ef77e12fa Mon Sep 17 00:00:00 2001 From: Exoridus Date: Tue, 22 Sep 2026 18:52:16 +0200 Subject: [PATCH 2/2] fix(test): share one backend per scene across parity properties The shared-adapter fix alone did not resolve the Firefox 'not enough memory left' failure: two verification runs failed identically at the same test, and instrumented Firefox process memory climbed continuously and without a plateau throughout the run - the profile of a real, growing footprint rather than a fixed device/adapter-count ceiling. Every property (renders-something, oracle-agreement, determinism, cross-backend-parity) opened and destroyed its own WebGl2Backend and WebGpuBackend, so one scene's four properties constructed up to four separate WebGPU backends where one would do. The parity matrix runs scenes and properties in strict sequence, so nothing reused an already open backend between them. The runner now opens each scene's backends once, in a per-describe beforeAll, and shares them across every property that runs against that scene; ownership - and destroy() - moves from the properties to the runner's afterAll. This cuts WebGPU backend construction by roughly the property count per scene. Verified on Windows Firefox: two runs before this change failed identically (130/162 tests, same test, 'not enough memory left', peak single-process working set ~1988 MB); two runs after - one before and one after re-confirming on Chromium - completed all 162 tests with full 240-row evidence, peak working set ~1079 MB, and roughly half the wall-clock time. --- .../parity/properties/crossBackendParity.ts | 155 +++++++++--------- .../parity/properties/determinism.ts | 45 ++--- .../parity/properties/oracleAgreement.ts | 33 ++-- .../parity/properties/rendersSomething.ts | 33 ++-- test/rendering/parity/runner.ts | 34 +++- test/rendering/parity/types.ts | 14 ++ 6 files changed, 154 insertions(+), 160 deletions(-) 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 {