From 81b166132bb94f5a416904f7ea6f5030337cbed3 Mon Sep 17 00:00:00 2001 From: Anton Pascal Date: Wed, 1 Jul 2026 04:07:17 +0000 Subject: [PATCH 1/4] 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 c1754da68f..838f82751c 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 0000000000..41837584c7 --- /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 b075261089..607f6bba3b 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 0000000000..98a51bd71b --- /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 0000000000..159f936209 --- /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/4] 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 838f82751c..babedae859 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 98a51bd71b..309cb44746 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 159f936209..cb1c18dce9 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/4] 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 babedae859..b64fe70b91 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 309cb44746..ec0cf82c33 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 cb1c18dce9..4eb529defd 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 Date: Tue, 4 Aug 2026 14:05:08 -0400 Subject: [PATCH 4/4] fix(viewer): keep the discrete-GPU hint and release abandoned WebGPU devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supplying `device` to WebGPURenderer makes three skip its own `requestAdapter` (three.webgpu.js:83922 `if (parameters.device === undefined)`), so R3F's `powerPreference: 'high-performance'` default was silently dropped and dual-GPU users could land on the integrated GPU. Thread it from the props R3F hands the gl factory into the adapter request instead of hardcoding it. Owning the device also means owning its lifetime: three deliberately declines to destroy a caller-supplied device, and `Renderer.dispose()` early-returns when `init()` never completed, so the WebGPU->WebGL fallback path leaked the device. Destroy it explicitly there, and reclaim a device that resolves after the request has already timed out. Also document why the gl factory returns a never-settling promise — that is the part that actually silences MONOREPO-EDITOR-59, since the old code rethrew into R3F's uncaught `await glConfig(...)`. --- .../viewer/src/components/viewer/index.tsx | 13 ++- packages/viewer/src/index.ts | 1 + .../src/lib/renderer-capability.test.tsx | 102 +++++++++++++++++- .../viewer/src/lib/renderer-capability.ts | 41 +++++-- 4 files changed, 145 insertions(+), 12 deletions(-) diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 522c99c36b..bf873a64c2 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -22,7 +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 { initializeGpuRenderer, type RendererPowerPreference } from '../../lib/renderer-capability' import { getSceneTheme } from '../../lib/scene-themes' import { installTextureNodeNullGuard } from '../../lib/texture-node-guard' import useViewer, { type RenderContext } from '../../store/use-viewer' @@ -505,12 +505,15 @@ const Viewer = forwardRef(function Viewer( dpr={[1, maxDpr]} frameloop="never" gl={ - ((props: { canvas?: HTMLCanvasElement }) => { + ((props: { canvas?: HTMLCanvasElement; powerPreference?: RendererPowerPreference }) => { const canvas = props.canvas const cached = canvas ? WEBGPU_RENDERER_CACHE.get(canvas) : undefined if (cached) return cached const promise = (async () => { const result = await initializeGpuRenderer({ + // Supplying `device` makes three skip its own `requestAdapter`, + // so R3F's `powerPreference` only reaches the GPU if we forward it. + powerPreference: props.powerPreference, createRenderer: (backendParameters) => { const renderer = new THREE.WebGPURenderer({ ...(props as any), @@ -532,6 +535,12 @@ const Viewer = forwardRef(function Viewer( if (canvas) WEBGPU_RENDERER_CACHE.delete(canvas) console.error('[viewer] WebGPURenderer init failed', result.error) setRendererInitFailed(true) + // Never settles on purpose. Rejecting is what produced + // MONOREPO-EDITOR-59: R3F awaits this inside its own configure() + // with no catch, so a rejection surfaces as an unhandled rejection. + // Resolving is worse still — R3F would call render() on a renderer + // that has no context. The state update above unmounts this Canvas, + // which is what releases the pending configure(). return new Promise(() => undefined) })() if (canvas) WEBGPU_RENDERER_CACHE.set(canvas, promise) diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index d9f12a0c9d..8494f1e3af 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -128,6 +128,7 @@ export { type RendererCapability, type RendererCapabilityCanvas, type RendererInitializationResult, + type RendererPowerPreference, } from './lib/renderer-capability' export { getSceneTheme, diff --git a/packages/viewer/src/lib/renderer-capability.test.tsx b/packages/viewer/src/lib/renderer-capability.test.tsx index ec0cf82c33..b52e6fcaa5 100644 --- a/packages/viewer/src/lib/renderer-capability.test.tsx +++ b/packages/viewer/src/lib/renderer-capability.test.tsx @@ -1,7 +1,6 @@ // @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 RendererBackendParameters, @@ -166,7 +165,6 @@ describe('GPU renderer capability and initialization', () => { }) 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 () => { @@ -224,6 +222,104 @@ describe('GPU renderer capability and initialization', () => { expect(result.status).toBe('unsupported') expect(dispose).toHaveBeenCalledTimes(1) - expect(JSON.stringify(UnsupportedGpuViewerFallback())).toContain('3D viewer unavailable') + }) + + test('forwards powerPreference to the adapter request', async () => { + const requestAdapter = mock(async () => ({ requestDevice: async () => ({}) })) + + const result = await initializeGpuRenderer({ + createRenderer: () => ({ init: async () => undefined }), + gpu: { requestAdapter }, + powerPreference: 'high-performance', + }) + + expect(result.status).toBe('ready') + // Supplying `device` makes three skip its own requestAdapter, so losing the + // hint here means dual-GPU users silently get the integrated GPU. + expect(requestAdapter).toHaveBeenCalledWith({ + featureLevel: 'compatibility', + powerPreference: 'high-performance', + }) + }) + + test('omits powerPreference when the caller does not supply one', async () => { + const requestAdapter = mock(async () => ({ requestDevice: async () => ({}) })) + + await initializeGpuRenderer({ + createRenderer: () => ({ init: async () => undefined }), + gpu: { requestAdapter }, + }) + + expect(requestAdapter).toHaveBeenCalledWith({ featureLevel: 'compatibility' }) + }) + + test('destroys the owned device when the WebGPU renderer falls back to WebGL', async () => { + const destroy = mock(() => undefined) + const device = { destroy } + + const result = await initializeGpuRenderer({ + createRenderer: (backendParameters) => + backendParameters.device + ? { + init: async () => { + throw new Error('WebGPU renderer init failed') + }, + } + : { init: async () => undefined }, + gpu: { requestAdapter: async () => ({ requestDevice: async () => device }) }, + }) + + expect(result.status).toBe('ready') + if (result.status === 'ready') expect(result.backend).toBe('webgl') + // three never destroys a caller-supplied device, and dispose() is a no-op + // after a failed init, so this is the only thing reclaiming it. + expect(destroy).toHaveBeenCalledTimes(1) + }) + + test('destroys a device that resolves after the request timed out', async () => { + const destroy = mock(() => undefined) + let resolveDevice: (device: unknown) => void = () => undefined + + const result = await initializeGpuRenderer({ + createRenderer: () => ({ init: async () => undefined }), + gpu: { + requestAdapter: async () => ({ + requestDevice: () => + new Promise((resolve) => { + resolveDevice = resolve + }), + }), + }, + probeCanvas: canvasWithContexts({ webgl2: {} }), + webgpuTimeoutMs: 10, + }) + + expect(result.status).toBe('ready') + if (result.status === 'ready') expect(result.backend).toBe('webgl') + + resolveDevice({ destroy }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(destroy).toHaveBeenCalledTimes(1) + }) + + test('reports unsupported when the probe canvas has a context but the display canvas does not', async () => { + const dispose = mock(() => undefined) + + const result = await initializeGpuRenderer({ + createRenderer: () => ({ + dispose, + // Chrome's live-context cap means the probe can succeed while the + // display canvas — created with different attributes — returns null. + init: async () => { + throw new TypeError("Cannot read properties of null (reading 'getSupportedExtensions')") + }, + }), + gpu: null, + probeCanvas: canvasWithContexts({ webgl2: {} }), + }) + + expect(result.status).toBe('unsupported') + if (result.status === 'unsupported') expect(result.error).toBeInstanceOf(TypeError) + expect(dispose).toHaveBeenCalledTimes(1) }) }) diff --git a/packages/viewer/src/lib/renderer-capability.ts b/packages/viewer/src/lib/renderer-capability.ts index 4eb529defd..a528fda857 100644 --- a/packages/viewer/src/lib/renderer-capability.ts +++ b/packages/viewer/src/lib/renderer-capability.ts @@ -2,6 +2,9 @@ export type RendererCapabilityCanvas = { getContext(contextId: 'webgl2'): unknown } +/** Mirrors `GPUPowerPreference` without pulling WebGPU ambient types into the declaration build. */ +export type RendererPowerPreference = 'high-performance' | 'low-power' + type RendererGpuAdapter = { features?: Iterable requestDevice(descriptor?: { requiredFeatures?: string[] }): Promise @@ -42,6 +45,19 @@ function browserCanvas(): RendererCapabilityCanvas | null { return document.createElement('canvas') } +/** + * Because we hand the device to `WebGPURenderer` ourselves, three treats it as + * caller-owned and never destroys it, and `Renderer.dispose()` is a no-op when + * `init()` failed. Any device we request and then abandon has to be released + * here or it counts against the browser's concurrent-device limit for the rest + * of the page's life. + */ +function releaseDevice(device: unknown) { + try { + ;(device as { destroy?: () => void } | null)?.destroy?.() + } catch {} +} + function withTimeout(promise: Promise, timeoutMs: number, operation: string) { let timeout: ReturnType const timeoutPromise = new Promise((_, reject) => { @@ -53,8 +69,11 @@ function withTimeout(promise: Promise, timeoutMs: number, operat return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timeout)) } -async function requestWebGpuDevice(gpu: RendererGpu) { - const adapter = await gpu.requestAdapter({ featureLevel: 'compatibility' }) +async function requestWebGpuDevice(gpu: RendererGpu, powerPreference?: RendererPowerPreference) { + const adapter = await gpu.requestAdapter({ + featureLevel: 'compatibility', + ...(powerPreference ? { powerPreference } : {}), + }) if (!adapter) return null const requiredFeatures = adapter.features ? Array.from(adapter.features) : undefined @@ -64,24 +83,27 @@ async function requestWebGpuDevice(gpu: RendererGpu) { export async function detectRendererCapability({ canvas = browserCanvas(), gpu = browserGpu(), + powerPreference, webgpuTimeoutMs = WEBGPU_INITIALIZATION_TIMEOUT_MS, }: { canvas?: RendererCapabilityCanvas | null gpu?: RendererGpu | null + powerPreference?: RendererPowerPreference webgpuTimeoutMs?: number } = {}): Promise { let capabilityError: unknown if (gpu) { + const pending = requestWebGpuDevice(gpu, powerPreference) try { - const device = await withTimeout( - requestWebGpuDevice(gpu), - webgpuTimeoutMs, - 'WebGPU adapter/device request', - ) + const device = await withTimeout(pending, webgpuTimeoutMs, 'WebGPU adapter/device request') if (device) return { backend: 'webgpu', device, status: 'supported' } } catch (error) { capabilityError = error + // Losing the race means a device may still arrive after we have given up + // on it, so reclaim it. Attaching the handler only on this path keeps + // ownership of a device we did return unambiguous. + pending.then(releaseDevice).catch(() => {}) } } @@ -100,17 +122,20 @@ export async function detectRendererCapability({ export async function initializeGpuRenderer({ createRenderer, gpu, + powerPreference, probeCanvas = browserCanvas(), webgpuTimeoutMs = WEBGPU_INITIALIZATION_TIMEOUT_MS, }: { createRenderer: (parameters: RendererBackendParameters) => Renderer gpu?: RendererGpu | null + powerPreference?: RendererPowerPreference probeCanvas?: RendererCapabilityCanvas | null webgpuTimeoutMs?: number }): Promise> { const capability = await detectRendererCapability({ canvas: probeCanvas, gpu, + powerPreference, webgpuTimeoutMs, }) if (capability.status === 'unsupported') return capability @@ -131,6 +156,8 @@ export async function initializeGpuRenderer