fix: use WebGL fallback for Safari scenes

This commit is contained in:
134 2026-07-13 14:31:23 +08:00
parent 47067bf278
commit 62a6a4a7f0
4 changed files with 67 additions and 4 deletions

View File

@ -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<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 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<ViewerHandle, ViewerProps>(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) {

View File

@ -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

View File

@ -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,
)
})
})

View File

@ -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)
}