diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 2de9cade69..bf873a64c2 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, 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' @@ -36,6 +37,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' // Must be in place before any node material builds — a null texture pulled by @@ -87,38 +89,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. * @@ -454,14 +424,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) @@ -523,7 +485,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,29 +505,43 @@ 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 () => { - 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({ + // 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), + ...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 } + + 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) 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 9f360c0a7a..8494f1e3af 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -121,6 +121,15 @@ 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, + type RendererPowerPreference, +} 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..b52e6fcaa5 --- /dev/null +++ b/packages/viewer/src/lib/renderer-capability.test.tsx @@ -0,0 +1,325 @@ +// @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 { + initializeGpuRenderer, + type RendererBackendParameters, + 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({ + 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({ + createRenderer, + gpu: null, + probeCanvas: canvasWithContexts({}), + }) + + 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({ + createRenderer, + gpu: { + requestAdapter: async () => ({ + requestDevice: async () => { + throw new Error('device unavailable') + }, + }), + }, + probeCanvas: canvasWithContexts({ webgl2: webglContext }), + }) + + expect(result.status).toBe('ready') + 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({ + createRenderer: (backendParameters) => { + parameters.push(backendParameters) + if (backendParameters.device) { + return { + dispose: webgpuDispose, + init: async () => { + throw new Error('WebGPU renderer init failed') + }, + } + } + return { + init: async () => { + if (!displayGetContext('webgl2', { antialias: true })) { + throw new Error('WebGL context unavailable') + } + await 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 }, { 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') + }) + + 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({ + createRenderer: () => ({ init: async () => undefined }), + gpu: { + requestAdapter: async () => null, + }, + probeCanvas: canvasWithContexts({}), + }) + + expect(result.status).toBe('unsupported') + }) + + test('catches renderer initialization failure and selects the fallback UI', async () => { + const dispose = mock(() => undefined) + + const result = await initializeGpuRenderer({ + createRenderer: () => ({ + dispose, + init: async () => { + throw new Error('getSupportedExtensions on null context') + }, + }), + gpu: null, + probeCanvas: canvasWithContexts({ webgl2: {} }), + }) + + expect(result.status).toBe('unsupported') + expect(dispose).toHaveBeenCalledTimes(1) + }) + + 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 new file mode 100644 index 0000000000..a528fda857 --- /dev/null +++ b/packages/viewer/src/lib/renderer-capability.ts @@ -0,0 +1,173 @@ +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 +} + +type RendererGpu = { + requestAdapter(options?: Record): Promise +} + +export type RendererCapability = + | { backend: 'webgpu'; device: unknown; status: 'supported' } + | { backend: 'webgl'; status: 'supported' } + | { error?: unknown; status: 'unsupported' } + +export type RendererBackendParameters = { + device?: unknown + forceWebGL?: boolean +} + +type InitializableRenderer = { + dispose?: () => void + init: () => Promise +} + +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 +} + +function browserCanvas(): RendererCapabilityCanvas | null { + if (typeof document === 'undefined') return 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) => { + 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, powerPreference?: RendererPowerPreference) { + const adapter = await gpu.requestAdapter({ + featureLevel: 'compatibility', + ...(powerPreference ? { powerPreference } : {}), + }) + 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(), + 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(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(() => {}) + } + } + + if (canvas) { + try { + const context = canvas.getContext('webgl2') + if (context) return { backend: 'webgl', status: 'supported' } + } catch (error) { + capabilityError ??= error + } + } + + return { error: capabilityError, status: 'unsupported' } +} + +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 + + let renderer: Renderer | undefined + try { + renderer = createRenderer( + capability.backend === 'webgpu' ? { device: capability.device } : { forceWebGL: true }, + ) + 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 { + renderer?.dispose?.() + } catch {} + if (capability.backend !== 'webgpu') return { error, status: 'unsupported' } + + releaseDevice(capability.device) + + renderer = undefined + try { + renderer = createRenderer({ forceWebGL: true }) + await renderer.init() + return { backend: 'webgl', renderer, status: 'ready' } + } catch (fallbackError) { + try { + renderer?.dispose?.() + } catch {} + return { error: fallbackError, status: 'unsupported' } + } + } +}