From 62a6a4a7f03c22b0f88bcfda1dc3776e9c075f1f Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 13 Jul 2026 14:31:23 +0800 Subject: [PATCH] fix: use WebGL fallback for Safari scenes --- .../viewer/src/components/viewer/index.tsx | 17 ++++++++++- .../src/components/viewer/post-processing.tsx | 6 ++-- .../viewer/src/lib/renderer-backend.test.ts | 30 +++++++++++++++++++ .../viewer/src/lib/renderer-backend.ts | 18 +++++++++++ 4 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 services/pascal-editor/packages/viewer/src/lib/renderer-backend.test.ts create mode 100644 services/pascal-editor/packages/viewer/src/lib/renderer-backend.ts diff --git a/services/pascal-editor/packages/viewer/src/components/viewer/index.tsx b/services/pascal-editor/packages/viewer/src/components/viewer/index.tsx index f6f4dcc9d..cd994c03d 100644 --- a/services/pascal-editor/packages/viewer/src/components/viewer/index.tsx +++ b/services/pascal-editor/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 { shouldForceWebGLRenderer } from '../../lib/renderer-backend' import { getSceneTheme } from '../../lib/scene-themes' import useViewer, { type RenderContext } from '../../store/use-viewer' import { FloorElevationSystem } from '../../systems/floor-elevation/floor-elevation-system' @@ -496,6 +497,12 @@ 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 forceWebGL = + typeof window !== 'undefined' && + shouldForceWebGLRenderer({ + search: window.location.search, + userAgent: window.navigator.userAgent, + }) 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 @@ -522,12 +529,20 @@ const Viewer = forwardRef(function Viewer( if (cached) return cached const promise = (async () => { try { - const renderer = new THREE.WebGPURenderer({ ...(props as any), alpha: true }) + const renderer = new THREE.WebGPURenderer({ + ...(props as any), + alpha: true, + forceWebGL, + }) renderer.toneMapping = THREE.ACESFilmicToneMapping renderer.toneMappingExposure = getSceneTheme( useViewer.getState().sceneTheme, ).toneMappingExposure await renderer.init() + if (canvas) { + canvas.dataset.pascalRenderer = + (renderer as any).backend?.isWebGPUBackend === true ? 'webgpu' : 'webgl' + } installEmptyDrawGuard(renderer) return renderer } catch (err) { diff --git a/services/pascal-editor/packages/viewer/src/components/viewer/post-processing.tsx b/services/pascal-editor/packages/viewer/src/components/viewer/post-processing.tsx index e0988a03d..96bb199dd 100644 --- a/services/pascal-editor/packages/viewer/src/components/viewer/post-processing.tsx +++ b/services/pascal-editor/packages/viewer/src/components/viewer/post-processing.tsx @@ -327,14 +327,14 @@ const PostProcessingPasses = ({ hasPipelineErrorRef.current = false // WebGPU availability check: SSGI, denoise, and RenderPipeline are all - // WebGPU-only APIs. When the browser falls back to WebGL2 (no - // `navigator.gpu`, or the device couldn't be created), building the + // WebGPU-only APIs. When the renderer uses its WebGL2 fallback (because + // WebGPU is unavailable, unstable, or explicitly disabled), building the // pipeline either throws silently or produces a broken output where // the scene renders for a few frames and then goes black as the retry // loop fights the direct-render fallback path. Short-circuit here so // `useFrame` uses the direct `renderer.render(scene, camera)` path // exclusively and never attempts the TSL pipeline. - const hasWebGPU = typeof navigator !== 'undefined' && 'gpu' in navigator + const hasWebGPU = (renderer as any).backend?.isWebGPUBackend === true if (!hasWebGPU) { hasPipelineErrorRef.current = true renderPipelineRef.current = null diff --git a/services/pascal-editor/packages/viewer/src/lib/renderer-backend.test.ts b/services/pascal-editor/packages/viewer/src/lib/renderer-backend.test.ts new file mode 100644 index 000000000..e48fdd7f3 --- /dev/null +++ b/services/pascal-editor/packages/viewer/src/lib/renderer-backend.test.ts @@ -0,0 +1,30 @@ +// @ts-expect-error - bun:test is provided by the Bun runtime; viewer does not +// depend on @types/bun so the import type is unresolved at compile time. +import { describe, expect, test } from 'bun:test' +import { isSafariUserAgent, shouldForceWebGLRenderer } from './renderer-backend' + +const SAFARI_MAC = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15' +const CHROME_MAC = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36' + +describe('renderer backend selection', () => { + test('identifies desktop Safari without classifying Chrome as Safari', () => { + expect(isSafariUserAgent(SAFARI_MAC)).toBe(true) + expect(isSafariUserAgent(CHROME_MAC)).toBe(false) + }) + + test('uses WebGL for Safari and leaves Chrome on the default backend', () => { + expect(shouldForceWebGLRenderer({ search: '', userAgent: SAFARI_MAC })).toBe(true) + expect(shouldForceWebGLRenderer({ search: '', userAgent: CHROME_MAC })).toBe(false) + }) + + test('honors an explicit renderer override', () => { + expect(shouldForceWebGLRenderer({ search: '?renderer=webgpu', userAgent: SAFARI_MAC })).toBe( + false, + ) + expect(shouldForceWebGLRenderer({ search: '?renderer=webgl', userAgent: CHROME_MAC })).toBe( + true, + ) + }) +}) diff --git a/services/pascal-editor/packages/viewer/src/lib/renderer-backend.ts b/services/pascal-editor/packages/viewer/src/lib/renderer-backend.ts new file mode 100644 index 000000000..96f72402a --- /dev/null +++ b/services/pascal-editor/packages/viewer/src/lib/renderer-backend.ts @@ -0,0 +1,18 @@ +export interface RendererBackendContext { + search: string + userAgent: string +} + +const SAFARI_USER_AGENT = /Safari\//i +const NON_SAFARI_BROWSER = /(?:Chrome|Chromium|CriOS|Edg|EdgiOS|OPR|OPiOS|Android)\//i + +export function isSafariUserAgent(userAgent: string): boolean { + return SAFARI_USER_AGENT.test(userAgent) && !NON_SAFARI_BROWSER.test(userAgent) +} + +export function shouldForceWebGLRenderer({ search, userAgent }: RendererBackendContext): boolean { + const override = new URLSearchParams(search).get('renderer')?.toLowerCase() + if (override === 'webgl') return true + if (override === 'webgpu') return false + return isSafariUserAgent(userAgent) +}