From 81b166132bb94f5a416904f7ea6f5030337cbed3 Mon Sep 17 00:00:00 2001 From: Anton Pascal Date: Wed, 1 Jul 2026 04:07:17 +0000 Subject: [PATCH 1/3] fix(viewer): centralize renderer capability fallback --- .../viewer/src/components/viewer/index.tsx | 83 ++++---------- .../viewer/unsupported-gpu-fallback.tsx | 13 +++ packages/viewer/src/index.ts | 8 ++ .../src/lib/renderer-capability.test.tsx | 97 ++++++++++++++++ .../viewer/src/lib/renderer-capability.ts | 107 ++++++++++++++++++ 5 files changed, 250 insertions(+), 58 deletions(-) create mode 100644 packages/viewer/src/components/viewer/unsupported-gpu-fallback.tsx create mode 100644 packages/viewer/src/lib/renderer-capability.test.tsx create mode 100644 packages/viewer/src/lib/renderer-capability.ts diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index c1754da68..838f82751 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -22,6 +22,7 @@ import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf' import { applyIsolation, clearIsolation } from '../../lib/isolation' import { ensureKtx2Support } from '../../lib/ktx2-loader' import type { ColorPreset, RenderShading } from '../../lib/materials' +import { initializeGpuRenderer } from '../../lib/renderer-capability' import { getSceneTheme } from '../../lib/scene-themes' import useViewer, { type RenderContext } from '../../store/use-viewer' import { FloorElevationSystem } from '../../systems/floor-elevation/floor-elevation-system' @@ -35,6 +36,7 @@ import PostProcessing, { DEFAULT_HOVER_STYLES, type HoverStyles } from './post-p import { RegisteredSystems } from './registered-systems' import { SceneBvh } from './scene-bvh' import { SelectionManager } from './selection-manager' +import { UnsupportedGpuViewerFallback } from './unsupported-gpu-fallback' import { ViewerCamera } from './viewer-camera' declare module '@react-three/fiber' { @@ -82,38 +84,6 @@ const DIRTY_BUILD_KINDS = new Set([ const warnedEmptyDraw = process.env.NODE_ENV === 'production' ? null : new WeakSet() -function canCreateWebGLContext() { - if (typeof document === 'undefined') return false - - const canvas = document.createElement('canvas') - try { - return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl')) - } catch { - return false - } -} - -function canMountGpuViewer() { - if (typeof window === 'undefined') return false - if (!('gpu' in navigator) && !canCreateWebGLContext()) return false - - return true -} - -function UnsupportedGpuViewerFallback() { - return ( -
-
-

3D viewer unavailable

-

- This browser or environment does not expose WebGPU or WebGL, so Pascal cannot render the - 3D scene here. Try opening the editor in a browser with hardware acceleration enabled. -

-
-
- ) -} - /** * Renderer-level safety net against the empty-vertex-buffer crash. * @@ -449,14 +419,6 @@ const Viewer = forwardRef(function Viewer( }, [isolate]) const [rendererInitFailed, setRendererInitFailed] = useState(false) - // Capability detection runs after mount. We start optimistic (true) so the - // server-rendered markup and the first client render agree (no hydration - // mismatch); the effect flips it to false only on environments that expose - // neither WebGPU nor WebGL. - const [canMountViewer, setCanMountViewer] = useState(true) - useEffect(() => { - if (!canMountGpuViewer()) setCanMountViewer(false) - }, []) const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') const transparentBackground = useViewer((state) => state.transparentBackground) @@ -518,7 +480,7 @@ const Viewer = forwardRef(function Viewer( // Desktops (fine pointer) keep the original 1.5 cap. const maxDpr = typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches ? 1.25 : 1.5 - const showGpuFallback = !canMountViewer || rendererInitFailed + const showGpuFallback = rendererInitFailed // When we can't mount the GPU canvas, the SceneReadyTracker never mounts and // the host editor would otherwise wait on its scene-readiness timeout. Signal // readiness explicitly so the host can drop its loader immediately. @@ -543,24 +505,29 @@ const Viewer = forwardRef(function Viewer( const cached = canvas ? WEBGPU_RENDERER_CACHE.get(canvas) : undefined if (cached) return cached const promise = (async () => { - try { - const renderer = new THREE.WebGPURenderer({ ...(props as any), alpha: true }) - renderer.toneMapping = THREE.ACESFilmicToneMapping - renderer.toneMappingExposure = getSceneTheme( - useViewer.getState().sceneTheme, - ).toneMappingExposure - await renderer.init() - installEmptyDrawGuard(renderer) - return renderer - } catch (err) { - // Drop the failed promise from the cache so a future Canvas - // mount on the same DOM can retry instead of inheriting the - // rejection forever. - if (canvas) WEBGPU_RENDERER_CACHE.delete(canvas) - console.error('[viewer] WebGPURenderer init failed', err) - setRendererInitFailed(true) - throw err + const result = await initializeGpuRenderer({ + canvas, + createRenderer: (backendParameters) => { + const renderer = new THREE.WebGPURenderer({ + ...(props as any), + ...backendParameters, + alpha: true, + }) + renderer.toneMapping = THREE.ACESFilmicToneMapping + renderer.toneMappingExposure = getSceneTheme( + useViewer.getState().sceneTheme, + ).toneMappingExposure + return renderer + }, + }) + if (result.status === 'ready') { + installEmptyDrawGuard(result.renderer) + return result.renderer } + + console.error('[viewer] WebGPURenderer init failed', result.error) + setRendererInitFailed(true) + return new Promise(() => undefined) })() if (canvas) WEBGPU_RENDERER_CACHE.set(canvas, promise) return promise diff --git a/packages/viewer/src/components/viewer/unsupported-gpu-fallback.tsx b/packages/viewer/src/components/viewer/unsupported-gpu-fallback.tsx new file mode 100644 index 000000000..41837584c --- /dev/null +++ b/packages/viewer/src/components/viewer/unsupported-gpu-fallback.tsx @@ -0,0 +1,13 @@ +export function UnsupportedGpuViewerFallback() { + return ( +
+
+

3D viewer unavailable

+

+ This browser or environment could not initialize WebGPU or WebGL, so Pascal cannot render + the 3D scene here. Try opening the editor in a browser with hardware acceleration enabled. +

+
+
+ ) +} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index b07526108..607f6bba3 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -102,6 +102,14 @@ export { } from './lib/materials' export { mergedOutline } from './lib/merged-outline-node' export { unionPolygons } from './lib/polygon-union' +export { + detectRendererCapability, + initializeGpuRenderer, + type RendererBackendParameters, + type RendererCapability, + type RendererCapabilityCanvas, + type RendererInitializationResult, +} from './lib/renderer-capability' export { getSceneTheme, SCENE_THEME_IDS, diff --git a/packages/viewer/src/lib/renderer-capability.test.tsx b/packages/viewer/src/lib/renderer-capability.test.tsx new file mode 100644 index 000000000..98a51bd71 --- /dev/null +++ b/packages/viewer/src/lib/renderer-capability.test.tsx @@ -0,0 +1,97 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// include Bun ambient types in its production declaration build. +import { describe, expect, mock, test } from 'bun:test' +import { UnsupportedGpuViewerFallback } from '../components/viewer/unsupported-gpu-fallback' +import { initializeGpuRenderer, type RendererCapabilityCanvas } from './renderer-capability' + +function canvasWithContexts(contexts: Partial>) { + return { + getContext: (contextId: 'webgl2') => contexts[contextId] ?? null, + } satisfies RendererCapabilityCanvas +} + +describe('GPU renderer capability and initialization', () => { + test('uses a working WebGPU device without requiring WebGL', async () => { + const device = {} + const createRenderer = mock(() => ({ init: async () => undefined })) + + const result = await initializeGpuRenderer({ + canvas: canvasWithContexts({}), + createRenderer, + gpu: { + requestAdapter: async () => ({ + requestDevice: async () => device, + }), + }, + }) + + expect(result.status).toBe('ready') + expect(createRenderer).toHaveBeenCalledWith({ device }) + }) + + test('reports unsupported when neither WebGPU nor WebGL is available', async () => { + const createRenderer = mock(() => ({ init: async () => undefined })) + + const result = await initializeGpuRenderer({ + canvas: canvasWithContexts({}), + createRenderer, + gpu: null, + }) + + expect(result.status).toBe('unsupported') + expect(createRenderer).not.toHaveBeenCalled() + }) + + test('falls back to WebGL when WebGPU cannot provide a device', async () => { + const webglContext = {} + const init = mock(async () => undefined) + const createRenderer = mock(() => ({ init })) + + const result = await initializeGpuRenderer({ + canvas: canvasWithContexts({ webgl2: webglContext }), + createRenderer, + gpu: { + requestAdapter: async () => ({ + requestDevice: async () => { + throw new Error('device unavailable') + }, + }), + }, + }) + + expect(result.status).toBe('ready') + expect(createRenderer).toHaveBeenCalledWith({ context: webglContext, forceWebGL: true }) + expect(init).toHaveBeenCalledTimes(1) + }) + + test('reports unsupported when WebGPU device and WebGL are unavailable', async () => { + const result = await initializeGpuRenderer({ + canvas: canvasWithContexts({}), + createRenderer: () => ({ init: async () => undefined }), + gpu: { + requestAdapter: async () => null, + }, + }) + + expect(result.status).toBe('unsupported') + }) + + test('catches renderer initialization failure and selects the fallback UI', async () => { + const dispose = mock(() => undefined) + + const result = await initializeGpuRenderer({ + canvas: canvasWithContexts({ webgl2: {} }), + createRenderer: () => ({ + dispose, + init: async () => { + throw new Error('getSupportedExtensions on null context') + }, + }), + gpu: null, + }) + + expect(result.status).toBe('unsupported') + expect(dispose).toHaveBeenCalledTimes(1) + expect(JSON.stringify(UnsupportedGpuViewerFallback())).toContain('3D viewer unavailable') + }) +}) diff --git a/packages/viewer/src/lib/renderer-capability.ts b/packages/viewer/src/lib/renderer-capability.ts new file mode 100644 index 000000000..159f93620 --- /dev/null +++ b/packages/viewer/src/lib/renderer-capability.ts @@ -0,0 +1,107 @@ +export type RendererCapabilityCanvas = { + getContext(contextId: 'webgl2'): unknown +} + +type RendererGpuAdapter = { + features?: Iterable + requestDevice(descriptor?: { requiredFeatures?: string[] }): Promise +} + +type RendererGpu = { + requestAdapter(options?: Record): Promise +} + +export type RendererCapability = + | { backend: 'webgpu'; device: unknown; status: 'supported' } + | { backend: 'webgl'; context: unknown; status: 'supported' } + | { error?: unknown; status: 'unsupported' } + +export type RendererBackendParameters = { + context?: unknown + device?: unknown + forceWebGL?: boolean +} + +type InitializableRenderer = { + dispose?: () => void + init: () => Promise +} + +export type RendererInitializationResult = + | { backend: 'webgpu' | 'webgl'; renderer: Renderer; status: 'ready' } + | { error?: unknown; status: 'unsupported' } + +function browserGpu(): RendererGpu | null { + if (typeof navigator === 'undefined') return null + return (navigator as Navigator & { gpu?: RendererGpu }).gpu ?? null +} + +function browserCanvas(): RendererCapabilityCanvas | null { + if (typeof document === 'undefined') return null + return document.createElement('canvas') +} + +export async function detectRendererCapability({ + canvas = browserCanvas(), + gpu = browserGpu(), +}: { + canvas?: RendererCapabilityCanvas | null + gpu?: RendererGpu | null +} = {}): Promise { + let capabilityError: unknown + + if (gpu) { + try { + const adapter = await gpu.requestAdapter({ featureLevel: 'compatibility' }) + if (adapter) { + const requiredFeatures = adapter.features ? Array.from(adapter.features) : undefined + const device = await adapter.requestDevice( + requiredFeatures?.length ? { requiredFeatures } : undefined, + ) + if (device) return { backend: 'webgpu', device, status: 'supported' } + } + } catch (error) { + capabilityError = error + } + } + + if (canvas) { + try { + const context = canvas.getContext('webgl2') + if (context) return { backend: 'webgl', context, status: 'supported' } + } catch (error) { + capabilityError ??= error + } + } + + return { error: capabilityError, status: 'unsupported' } +} + +export async function initializeGpuRenderer({ + canvas, + createRenderer, + gpu, +}: { + canvas?: RendererCapabilityCanvas | null + createRenderer: (parameters: RendererBackendParameters) => Renderer + gpu?: RendererGpu | null +}): Promise> { + const capability = await detectRendererCapability({ canvas, gpu }) + if (capability.status === 'unsupported') return capability + + let renderer: Renderer | undefined + try { + renderer = createRenderer( + capability.backend === 'webgpu' + ? { device: capability.device } + : { context: capability.context, forceWebGL: true }, + ) + await renderer.init() + return { backend: capability.backend, renderer, status: 'ready' } + } catch (error) { + try { + renderer?.dispose?.() + } catch {} + return { error, status: 'unsupported' } + } +} From dbb89a62d79f4c30ce819a1793d820434c987bb1 Mon Sep 17 00:00:00 2001 From: Anton Pascal Date: Mon, 20 Jul 2026 13:18:21 +0000 Subject: [PATCH 2/3] fix(viewer): recover from renderer initialization failures --- .../viewer/src/components/viewer/index.tsx | 1 + .../src/lib/renderer-capability.test.tsx | 41 ++++++++++++++++++- .../viewer/src/lib/renderer-capability.ts | 25 ++++++++++- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 838f82751..babedae85 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -525,6 +525,7 @@ const Viewer = forwardRef(function Viewer( return result.renderer } + if (canvas) WEBGPU_RENDERER_CACHE.delete(canvas) console.error('[viewer] WebGPURenderer init failed', result.error) setRendererInitFailed(true) return new Promise(() => undefined) diff --git a/packages/viewer/src/lib/renderer-capability.test.tsx b/packages/viewer/src/lib/renderer-capability.test.tsx index 98a51bd71..309cb4474 100644 --- a/packages/viewer/src/lib/renderer-capability.test.tsx +++ b/packages/viewer/src/lib/renderer-capability.test.tsx @@ -2,7 +2,11 @@ // include Bun ambient types in its production declaration build. import { describe, expect, mock, test } from 'bun:test' import { UnsupportedGpuViewerFallback } from '../components/viewer/unsupported-gpu-fallback' -import { initializeGpuRenderer, type RendererCapabilityCanvas } from './renderer-capability' +import { + initializeGpuRenderer, + type RendererBackendParameters, + type RendererCapabilityCanvas, +} from './renderer-capability' function canvasWithContexts(contexts: Partial>) { return { @@ -64,6 +68,41 @@ describe('GPU renderer capability and initialization', () => { expect(init).toHaveBeenCalledTimes(1) }) + test('falls back to WebGL when WebGPU renderer initialization fails', async () => { + const device = {} + const webglContext = {} + const webgpuDispose = mock(() => undefined) + const webglInit = mock(async () => undefined) + const parameters: RendererBackendParameters[] = [] + + const result = await initializeGpuRenderer({ + canvas: canvasWithContexts({ webgl2: webglContext }), + createRenderer: (backendParameters) => { + parameters.push(backendParameters) + if (backendParameters.device) { + return { + dispose: webgpuDispose, + init: async () => { + throw new Error('WebGPU renderer init failed') + }, + } + } + return { init: webglInit } + }, + gpu: { + requestAdapter: async () => ({ + requestDevice: async () => device, + }), + }, + }) + + expect(result.status).toBe('ready') + if (result.status === 'ready') expect(result.backend).toBe('webgl') + expect(parameters).toEqual([{ device }, { context: webglContext, forceWebGL: true }]) + expect(webgpuDispose).toHaveBeenCalledTimes(1) + expect(webglInit).toHaveBeenCalledTimes(1) + }) + test('reports unsupported when WebGPU device and WebGL are unavailable', async () => { const result = await initializeGpuRenderer({ canvas: canvasWithContexts({}), diff --git a/packages/viewer/src/lib/renderer-capability.ts b/packages/viewer/src/lib/renderer-capability.ts index 159f93620..cb1c18dce 100644 --- a/packages/viewer/src/lib/renderer-capability.ts +++ b/packages/viewer/src/lib/renderer-capability.ts @@ -86,7 +86,8 @@ export async function initializeGpuRenderer Renderer gpu?: RendererGpu | null }): Promise> { - const capability = await detectRendererCapability({ canvas, gpu }) + const resolvedCanvas = canvas === undefined ? browserCanvas() : canvas + const capability = await detectRendererCapability({ canvas: resolvedCanvas, gpu }) if (capability.status === 'unsupported') return capability let renderer: Renderer | undefined @@ -102,6 +103,26 @@ export async function initializeGpuRenderer Date: Mon, 20 Jul 2026 13:31:40 +0000 Subject: [PATCH 3/3] fix(viewer): harden GPU renderer fallback --- .../viewer/src/components/viewer/index.tsx | 1 - .../src/lib/renderer-capability.test.tsx | 111 ++++++++++++++++-- .../viewer/src/lib/renderer-capability.ts | 74 +++++++----- 3 files changed, 148 insertions(+), 38 deletions(-) diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index babedae85..b64fe70b9 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -506,7 +506,6 @@ const Viewer = forwardRef(function Viewer( if (cached) return cached const promise = (async () => { const result = await initializeGpuRenderer({ - canvas, createRenderer: (backendParameters) => { const renderer = new THREE.WebGPURenderer({ ...(props as any), diff --git a/packages/viewer/src/lib/renderer-capability.test.tsx b/packages/viewer/src/lib/renderer-capability.test.tsx index 309cb4474..ec0cf82c3 100644 --- a/packages/viewer/src/lib/renderer-capability.test.tsx +++ b/packages/viewer/src/lib/renderer-capability.test.tsx @@ -20,7 +20,6 @@ describe('GPU renderer capability and initialization', () => { const createRenderer = mock(() => ({ init: async () => undefined })) const result = await initializeGpuRenderer({ - canvas: canvasWithContexts({}), createRenderer, gpu: { requestAdapter: async () => ({ @@ -37,9 +36,9 @@ describe('GPU renderer capability and initialization', () => { const createRenderer = mock(() => ({ init: async () => undefined })) const result = await initializeGpuRenderer({ - canvas: canvasWithContexts({}), createRenderer, gpu: null, + probeCanvas: canvasWithContexts({}), }) expect(result.status).toBe('unsupported') @@ -52,7 +51,6 @@ describe('GPU renderer capability and initialization', () => { const createRenderer = mock(() => ({ init })) const result = await initializeGpuRenderer({ - canvas: canvasWithContexts({ webgl2: webglContext }), createRenderer, gpu: { requestAdapter: async () => ({ @@ -61,22 +59,25 @@ describe('GPU renderer capability and initialization', () => { }, }), }, + probeCanvas: canvasWithContexts({ webgl2: webglContext }), }) expect(result.status).toBe('ready') - expect(createRenderer).toHaveBeenCalledWith({ context: webglContext, forceWebGL: true }) + expect(createRenderer).toHaveBeenCalledWith({ forceWebGL: true }) expect(init).toHaveBeenCalledTimes(1) }) test('falls back to WebGL when WebGPU renderer initialization fails', async () => { const device = {} const webglContext = {} + const displayGetContext = mock((_contextId: 'webgl2', attributes?: { antialias?: boolean }) => + attributes?.antialias ? webglContext : null, + ) const webgpuDispose = mock(() => undefined) const webglInit = mock(async () => undefined) const parameters: RendererBackendParameters[] = [] const result = await initializeGpuRenderer({ - canvas: canvasWithContexts({ webgl2: webglContext }), createRenderer: (backendParameters) => { parameters.push(backendParameters) if (backendParameters.device) { @@ -87,7 +88,14 @@ describe('GPU renderer capability and initialization', () => { }, } } - return { init: webglInit } + return { + init: async () => { + if (!displayGetContext('webgl2', { antialias: true })) { + throw new Error('WebGL context unavailable') + } + await webglInit() + }, + } }, gpu: { requestAdapter: async () => ({ @@ -98,18 +106,103 @@ describe('GPU renderer capability and initialization', () => { expect(result.status).toBe('ready') if (result.status === 'ready') expect(result.backend).toBe('webgl') - expect(parameters).toEqual([{ device }, { context: webglContext, forceWebGL: true }]) + expect(parameters).toEqual([{ device }, { forceWebGL: true }]) + expect(displayGetContext).toHaveBeenCalledWith('webgl2', { antialias: true }) expect(webgpuDispose).toHaveBeenCalledTimes(1) expect(webglInit).toHaveBeenCalledTimes(1) }) + test('isolates WebGL capability probing from the display canvas', async () => { + const probeContext = {} + const displayContext = {} + const probeGetContext = mock(() => probeContext) + const displayGetContext = mock((_contextId: 'webgl2', attributes?: { antialias?: boolean }) => + attributes?.antialias ? displayContext : null, + ) + + const result = await initializeGpuRenderer({ + createRenderer: (backendParameters) => ({ + init: async () => { + expect(backendParameters).toEqual({ forceWebGL: true }) + expect(displayGetContext('webgl2', { antialias: true })).toBe(displayContext) + }, + }), + gpu: null, + probeCanvas: { getContext: probeGetContext }, + }) + + expect(result.status).toBe('ready') + expect(probeGetContext).toHaveBeenCalledTimes(1) + expect(probeGetContext).toHaveBeenCalledWith('webgl2') + expect(displayGetContext).toHaveBeenCalledTimes(1) + expect(displayGetContext).toHaveBeenCalledWith('webgl2', { antialias: true }) + }) + + test('times out a hung WebGPU adapter request and falls back to WebGL', async () => { + const createRenderer = mock(() => ({ init: async () => undefined })) + + const result = await initializeGpuRenderer({ + createRenderer, + gpu: { + requestAdapter: () => new Promise(() => undefined), + }, + probeCanvas: canvasWithContexts({ webgl2: {} }), + webgpuTimeoutMs: 10, + }) + + expect(result.status).toBe('ready') + if (result.status === 'ready') expect(result.backend).toBe('webgl') + expect(createRenderer).toHaveBeenCalledWith({ forceWebGL: true }) + }) + + test('reports unsupported after a hung WebGPU adapter times out without WebGL', async () => { + const result = await initializeGpuRenderer({ + createRenderer: () => ({ init: async () => undefined }), + gpu: { + requestAdapter: () => new Promise(() => undefined), + }, + probeCanvas: canvasWithContexts({}), + webgpuTimeoutMs: 10, + }) + + expect(result.status).toBe('unsupported') + expect(JSON.stringify(UnsupportedGpuViewerFallback())).toContain('3D viewer unavailable') + }) + + test('times out hung WebGPU renderer initialization and falls back to WebGL', async () => { + const device = {} + const webgpuDispose = mock(() => undefined) + const parameters: RendererBackendParameters[] = [] + + const result = await initializeGpuRenderer({ + createRenderer: (backendParameters) => { + parameters.push(backendParameters) + return backendParameters.device + ? { + dispose: webgpuDispose, + init: () => new Promise(() => undefined), + } + : { init: async () => undefined } + }, + gpu: { + requestAdapter: async () => ({ requestDevice: async () => device }), + }, + webgpuTimeoutMs: 10, + }) + + expect(result.status).toBe('ready') + if (result.status === 'ready') expect(result.backend).toBe('webgl') + expect(parameters).toEqual([{ device }, { forceWebGL: true }]) + expect(webgpuDispose).toHaveBeenCalledTimes(1) + }) + test('reports unsupported when WebGPU device and WebGL are unavailable', async () => { const result = await initializeGpuRenderer({ - canvas: canvasWithContexts({}), createRenderer: () => ({ init: async () => undefined }), gpu: { requestAdapter: async () => null, }, + probeCanvas: canvasWithContexts({}), }) expect(result.status).toBe('unsupported') @@ -119,7 +212,6 @@ describe('GPU renderer capability and initialization', () => { const dispose = mock(() => undefined) const result = await initializeGpuRenderer({ - canvas: canvasWithContexts({ webgl2: {} }), createRenderer: () => ({ dispose, init: async () => { @@ -127,6 +219,7 @@ describe('GPU renderer capability and initialization', () => { }, }), gpu: null, + probeCanvas: canvasWithContexts({ webgl2: {} }), }) expect(result.status).toBe('unsupported') diff --git a/packages/viewer/src/lib/renderer-capability.ts b/packages/viewer/src/lib/renderer-capability.ts index cb1c18dce..4eb529def 100644 --- a/packages/viewer/src/lib/renderer-capability.ts +++ b/packages/viewer/src/lib/renderer-capability.ts @@ -13,11 +13,10 @@ type RendererGpu = { export type RendererCapability = | { backend: 'webgpu'; device: unknown; status: 'supported' } - | { backend: 'webgl'; context: unknown; status: 'supported' } + | { backend: 'webgl'; status: 'supported' } | { error?: unknown; status: 'unsupported' } export type RendererBackendParameters = { - context?: unknown device?: unknown forceWebGL?: boolean } @@ -31,6 +30,8 @@ export type RendererInitializationResult = | { backend: 'webgpu' | 'webgl'; renderer: Renderer; status: 'ready' } | { error?: unknown; status: 'unsupported' } +const WEBGPU_INITIALIZATION_TIMEOUT_MS = 4000 + function browserGpu(): RendererGpu | null { if (typeof navigator === 'undefined') return null return (navigator as Navigator & { gpu?: RendererGpu }).gpu ?? null @@ -41,25 +42,44 @@ function browserCanvas(): RendererCapabilityCanvas | null { return document.createElement('canvas') } +function withTimeout(promise: Promise, timeoutMs: number, operation: string) { + let timeout: ReturnType + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error(`${operation} timed out after ${timeoutMs}ms`)) + }, timeoutMs) + }) + + return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timeout)) +} + +async function requestWebGpuDevice(gpu: RendererGpu) { + const adapter = await gpu.requestAdapter({ featureLevel: 'compatibility' }) + if (!adapter) return null + + const requiredFeatures = adapter.features ? Array.from(adapter.features) : undefined + return adapter.requestDevice(requiredFeatures?.length ? { requiredFeatures } : undefined) +} + export async function detectRendererCapability({ canvas = browserCanvas(), gpu = browserGpu(), + webgpuTimeoutMs = WEBGPU_INITIALIZATION_TIMEOUT_MS, }: { canvas?: RendererCapabilityCanvas | null gpu?: RendererGpu | null + webgpuTimeoutMs?: number } = {}): Promise { let capabilityError: unknown if (gpu) { try { - const adapter = await gpu.requestAdapter({ featureLevel: 'compatibility' }) - if (adapter) { - const requiredFeatures = adapter.features ? Array.from(adapter.features) : undefined - const device = await adapter.requestDevice( - requiredFeatures?.length ? { requiredFeatures } : undefined, - ) - if (device) return { backend: 'webgpu', device, status: 'supported' } - } + const device = await withTimeout( + requestWebGpuDevice(gpu), + webgpuTimeoutMs, + 'WebGPU adapter/device request', + ) + if (device) return { backend: 'webgpu', device, status: 'supported' } } catch (error) { capabilityError = error } @@ -68,7 +88,7 @@ export async function detectRendererCapability({ if (canvas) { try { const context = canvas.getContext('webgl2') - if (context) return { backend: 'webgl', context, status: 'supported' } + if (context) return { backend: 'webgl', status: 'supported' } } catch (error) { capabilityError ??= error } @@ -78,26 +98,32 @@ export async function detectRendererCapability({ } export async function initializeGpuRenderer({ - canvas, createRenderer, gpu, + probeCanvas = browserCanvas(), + webgpuTimeoutMs = WEBGPU_INITIALIZATION_TIMEOUT_MS, }: { - canvas?: RendererCapabilityCanvas | null createRenderer: (parameters: RendererBackendParameters) => Renderer gpu?: RendererGpu | null + probeCanvas?: RendererCapabilityCanvas | null + webgpuTimeoutMs?: number }): Promise> { - const resolvedCanvas = canvas === undefined ? browserCanvas() : canvas - const capability = await detectRendererCapability({ canvas: resolvedCanvas, gpu }) + const capability = await detectRendererCapability({ + canvas: probeCanvas, + gpu, + webgpuTimeoutMs, + }) if (capability.status === 'unsupported') return capability let renderer: Renderer | undefined try { renderer = createRenderer( - capability.backend === 'webgpu' - ? { device: capability.device } - : { context: capability.context, forceWebGL: true }, + capability.backend === 'webgpu' ? { device: capability.device } : { forceWebGL: true }, ) - await renderer.init() + const initPromise = renderer.init() + await (capability.backend === 'webgpu' + ? withTimeout(initPromise, webgpuTimeoutMs, 'WebGPU renderer initialization') + : initPromise) return { backend: capability.backend, renderer, status: 'ready' } } catch (error) { try { @@ -105,17 +131,9 @@ export async function initializeGpuRenderer