diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index c1754da68..b64fe70b9 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({ + 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) + 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..ec0cf82c3 --- /dev/null +++ b/packages/viewer/src/lib/renderer-capability.test.tsx @@ -0,0 +1,229 @@ +// @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, + 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') + 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({ + 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) + 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..4eb529def --- /dev/null +++ b/packages/viewer/src/lib/renderer-capability.ts @@ -0,0 +1,146 @@ +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'; 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') +} + +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 device = await withTimeout( + requestWebGpuDevice(gpu), + webgpuTimeoutMs, + 'WebGPU adapter/device request', + ) + 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', status: 'supported' } + } catch (error) { + capabilityError ??= error + } + } + + return { error: capabilityError, status: 'unsupported' } +} + +export async function initializeGpuRenderer({ + createRenderer, + gpu, + probeCanvas = browserCanvas(), + webgpuTimeoutMs = WEBGPU_INITIALIZATION_TIMEOUT_MS, +}: { + createRenderer: (parameters: RendererBackendParameters) => Renderer + gpu?: RendererGpu | null + probeCanvas?: RendererCapabilityCanvas | null + webgpuTimeoutMs?: number +}): Promise> { + 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 } : { 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' } + + 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' } + } + } +}