Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 25 additions & 58 deletions packages/viewer/src/components/viewer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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' {
Expand Down Expand Up @@ -82,38 +84,6 @@ 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.
*
Expand Down Expand Up @@ -449,14 +419,6 @@ 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)
Expand Down Expand Up @@ -518,7 +480,7 @@ 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
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.
Expand All @@ -543,24 +505,29 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(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<never>(() => undefined)
Comment thread
cursor[bot] marked this conversation as resolved.
})()
if (canvas) WEBGPU_RENDERER_CACHE.set(canvas, promise)
return promise
Expand Down
13 changes: 13 additions & 0 deletions packages/viewer/src/components/viewer/unsupported-gpu-fallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export 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 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.
</p>
</div>
</div>
)
}
8 changes: 8 additions & 0 deletions packages/viewer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
229 changes: 229 additions & 0 deletions packages/viewer/src/lib/renderer-capability.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<'webgl2', unknown>>) {
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<never>(() => 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<never>(() => 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<never>(() => 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')
})
})
Loading
Loading