diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 6e257b525..edf6d4aa5 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -8,7 +8,14 @@ import { useScene, } from '@pascal-app/core' import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber' -import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef } from 'react' +import { + forwardRef, + useEffect, + useImperativeHandle, + useLayoutEffect, + useRef, + useState, +} from 'react' import * as THREE from 'three/webgpu' import { hasDrawableGeometry } from '../../lib/drawable-geometry' import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf' @@ -67,6 +74,38 @@ 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. * @@ -349,6 +388,16 @@ 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) useLayoutEffect(() => { @@ -401,6 +450,17 @@ 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 + // 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. + useEffect(() => { + if (showGpuFallback) onSceneReadyChange?.(true) + }, [showGpuFallback, onSceneReadyChange]) + + if (showGpuFallback) { + return + } return ( (function Viewer( // rejection forever. if (canvas) WEBGPU_RENDERER_CACHE.delete(canvas) console.error('[viewer] WebGPURenderer init failed', err) + setRendererInitFailed(true) throw err } })()