fix(viewer): graceful fallback when WebGPU/WebGL is unavailable (#403)

Adds a capability check and an UnsupportedGpuViewerFallback so the viewer
renders an informative panel instead of crashing on environments that expose
neither WebGPU nor WebGL. Capability detection runs post-mount to stay
hydration-safe, the renderer-init failure path swaps to the fallback, and the
fallback signals scene-readiness so the host editor loader does not hang.

Reworked from #403 (anton-pascal) onto current main; resolves the import
conflict and the SSR/scene-ready gotchas surfaced in review.

Co-authored-by: Open Pascal <open@pascal.app>
This commit is contained in:
Anton
2026-06-24 15:56:26 -04:00
committed by GitHub
co-authored by Open Pascal
parent 8593c55afe
commit 5fe0965297
@@ -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<object>()
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 (
<div className="flex h-full min-h-64 w-full items-center justify-center bg-[#fafafa] p-6 text-center text-neutral-900">
<div className="max-w-md rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm">
<h2 className="font-semibold text-lg">3D viewer unavailable</h2>
<p className="mt-2 text-neutral-600 text-sm">
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.
</p>
</div>
</div>
)
}
/**
* Renderer-level safety net against the empty-vertex-buffer crash.
*
@@ -349,6 +388,16 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(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<ViewerHandle, ViewerProps>(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 <UnsupportedGpuViewerFallback />
}
return (
<Canvas
camera={{ position: [50, 50, 50], fov: 50 }}
@@ -430,6 +490,7 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
// rejection forever.
if (canvas) WEBGPU_RENDERER_CACHE.delete(canvas)
console.error('[viewer] WebGPURenderer init failed', err)
setRendererInitFailed(true)
throw err
}
})()