Files
editor/packages/viewer/src/components/viewer/index.tsx
T

259 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { Bvh } from '@react-three/drei'
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three/webgpu'
import useViewer from '../../store/use-viewer'
import { CeilingSystem } from '../../systems/ceiling/ceiling-system'
import { DoorAnimationSystem } from '../../systems/door/door-animation-system'
import { DoorSystem } from '../../systems/door/door-system'
import { FenceSystem } from '../../systems/fence/fence-system'
import { GuideSystem } from '../../systems/guide/guide-system'
import { ItemSystem } from '../../systems/item/item-system'
import { ItemLightSystem } from '../../systems/item-light/item-light-system'
import { LevelSystem } from '../../systems/level/level-system'
import { RoofSystem } from '../../systems/roof/roof-system'
import { ScanSystem } from '../../systems/scan/scan-system'
import { SlabSystem } from '../../systems/slab/slab-system'
import { StairSystem } from '../../systems/stair/stair-system'
import { WallCutout } from '../../systems/wall/wall-cutout'
import { WallSystem } from '../../systems/wall/wall-system'
import { WindowSystem } from '../../systems/window/window-system'
import { ZoneSystem } from '../../systems/zone/zone-system'
import { ErrorBoundary } from '../error-boundary'
import { SceneRenderer } from '../renderers/scene-renderer'
import FrameLimiter from './frame-limiter'
import { Lights } from './lights'
import { PerfMonitor } from './perf-monitor'
import PostProcessing, { DEFAULT_HOVER_STYLES, type HoverStyles } from './post-processing'
import { SelectionManager } from './selection-manager'
import { ViewerCamera } from './viewer-camera'
function AnimatedBackground({ isDark }: { isDark: boolean }) {
const targetColor = useMemo(() => new THREE.Color(), [])
const initialized = useRef(false)
useFrame(({ scene }, delta) => {
const dt = Math.min(delta, 0.1) * 4
const targetHex = isDark ? '#1f2433' : '#ffffff'
if (!(scene.background && scene.background instanceof THREE.Color)) {
scene.background = new THREE.Color(targetHex)
initialized.current = true
return
}
if (!initialized.current) {
scene.background.set(targetHex)
initialized.current = true
return
}
targetColor.set(targetHex)
scene.background.lerp(targetColor, dt)
})
return null
}
declare module '@react-three/fiber' {
interface ThreeElements extends ThreeToJSXElements<typeof THREE> {}
}
extend(THREE as any)
// R3F's <Canvas> useLayoutEffect has no deps, so any re-render (theme switch,
// parent re-render, StrictMode double-mount) re-invokes `configure()`. With a
// sync `gl` factory that's harmless — the renderer is created once and reused.
// With an async factory (WebGPURenderer needs `await init()`), two configure
// calls can race: both see `state.gl == null` and both create a renderer. The
// first to resolve gets `setSize`/`setDpr` called on it; the second overwrites
// `state.gl` but R3F's store already holds the new size/dpr, so the new
// renderer is never resized and stays at the canvas's 300×150 default.
//
// Caching by canvas guarantees both branches return the same instance, so
// "duplicate" configure calls become no-ops on an already-sized renderer.
// We cache the in-flight Promise (not just the resolved renderer) so two
// concurrent configure() calls await the same init instead of creating two
// renderers in parallel and only caching the second.
const WEBGPU_RENDERER_CACHE = new WeakMap<HTMLCanvasElement, Promise<THREE.WebGPURenderer>>()
/**
* Monitors the WebGPU device for loss events and logs them.
* WebGPU device loss can happen when:
* - Tab is backgrounded and OS reclaims GPU
* - Driver crash or GPU reset
* - Browser security policy kills the context
*/
type WebGPUDeviceLossInfo = {
reason?: string
message?: string
}
type WebGPUDeviceLike = {
lost: Promise<WebGPUDeviceLossInfo>
label?: string
features?: Set<string>
addEventListener?: (type: string, listener: EventListener) => void
removeEventListener?: (type: string, listener: EventListener) => void
}
function GPUDeviceWatcher() {
const gl = useThree((s) => s.gl)
useEffect(() => {
const backend = (gl as any).backend
const device = backend?.device as WebGPUDeviceLike | undefined
if (!device) {
console.warn('[viewer] No WebGPU device on backend — running on a fallback renderer.', {
backend: backend?.constructor?.name ?? 'unknown',
rendererType: (gl as any).constructor?.name ?? 'unknown',
})
return
}
console.log('[viewer] WebGPU device ready', {
label: device.label,
features: device.features ? Array.from(device.features) : [],
})
device.lost.then((info: WebGPUDeviceLossInfo) => {
console.error(
`[viewer] WebGPU device lost: reason="${info.reason ?? 'unknown'}", message="${info.message ?? ''}". ` +
'The page must be reloaded to recover the GPU context.',
)
})
// Uncaptured errors are normally silent (only console-warned by Chrome at
// best). Pipe them to console.error so silent mobile crashes show up.
const onUncapturedError = (event: any) => {
console.error('[viewer] WebGPU uncaptured error:', event?.error?.message, event?.error)
}
device.addEventListener?.('uncapturederror', onUncapturedError)
return () => {
device.removeEventListener?.('uncapturederror', onUncapturedError)
}
}, [gl])
return null
}
interface ViewerProps {
children?: React.ReactNode
hoverStyles?: HoverStyles
selectionManager?: 'default' | 'custom'
perf?: boolean
}
const Viewer: React.FC<ViewerProps> = ({
children,
hoverStyles = DEFAULT_HOVER_STYLES,
selectionManager = 'default',
perf = false,
}) => {
const theme = useViewer((state) => state.theme)
return (
<Canvas
camera={{ position: [50, 50, 50], fov: 50 }}
className={`transition-colors duration-700 ${theme === 'dark' ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`}
dpr={[1, 1.5]}
frameloop="never"
gl={
((props: { canvas?: HTMLCanvasElement }) => {
const canvas = props.canvas
const cached = canvas ? WEBGPU_RENDERER_CACHE.get(canvas) : undefined
if (cached) return cached
// Surface the env we're about to ask WebGPU for — catches "no
// navigator.gpu" / "adapter request failed" silently failing in
// mobile WebViews where WebGPU is gated behind flags.
const hasGpu = typeof navigator !== 'undefined' && 'gpu' in navigator
console.log('[viewer] Creating WebGPURenderer', {
hasNavigatorGPU: hasGpu,
ua: typeof navigator !== 'undefined' ? navigator.userAgent : 'n/a',
})
const promise = (async () => {
try {
const renderer = new THREE.WebGPURenderer(props as any)
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 0.9
await renderer.init()
console.log('[viewer] WebGPURenderer ready', {
backend: (renderer as any).backend?.constructor?.name,
isWebGPU: (renderer as any).isWebGPURenderer === true,
})
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)
throw err
}
})()
if (canvas) WEBGPU_RENDERER_CACHE.set(canvas, promise)
return promise
}) as any
}
resize={{
debounce: 100,
}}
shadows={{
type: THREE.PCFShadowMap,
enabled: true,
}}
>
<FrameLimiter fps={50} />
{/* <AnimatedBackground isDark={theme === 'dark'} /> */}
<ViewerCamera />
<GPUDeviceWatcher />
<ErrorBoundary fallback={null} scope="viewer-scene">
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
/> */}
<Lights />
<Bvh>
<SceneRenderer />
</Bvh>
{/* Default Systems */}
<LevelSystem />
<GuideSystem />
<ScanSystem />
<WallCutout />
{/* Core systems */}
<CeilingSystem />
<DoorAnimationSystem />
<DoorSystem />
<FenceSystem />
<ItemSystem />
<RoofSystem />
<SlabSystem />
<StairSystem />
<WallSystem />
<WindowSystem />
<ZoneSystem />
<PostProcessing hoverStyles={hoverStyles} />
{/* <DebugRenderer /> */}
<ItemLightSystem />
{selectionManager === 'default' && <SelectionManager />}
{perf && <PerfMonitor />}
{children}
</ErrorBoundary>
</Canvas>
)
}
const DebugRenderer = () => {
useFrame(({ gl, scene, camera }) => {
gl.render(scene, camera)
})
return null
}
export default Viewer