import { useEffect, useRef, useState } from 'react'; import * as THREE from 'three'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { VRM, VRMLoaderPlugin, VRMUtils } from '@pixiv/three-vrm'; type PreviewState = | { kind: 'idle' } | { kind: 'loading'; ratio: number | null } | { kind: 'ready'; metaVersion: string } | { kind: 'error'; message: string }; /** 从 GLTFLoader 的 userData 中安全取出 VRM 实例(three 的 userData 是 any)。 */ function extractVrm(userData: Record): VRM | null { const candidate = userData['vrm']; return candidate instanceof VRM ? candidate : null; } /** 兜底释放:VRMUtils.deepDispose 之外的几何/材质/贴图。 */ function disposeObject(root: THREE.Object3D): void { root.traverse((object) => { const mesh = object as THREE.Mesh; if (mesh.geometry) mesh.geometry.dispose(); const material = mesh.material; if (!material) return; const list: THREE.Material[] = Array.isArray(material) ? material : [material]; for (const item of list) { for (const value of Object.values(item)) { if (value instanceof THREE.Texture) value.dispose(); } item.dispose(); } }); } interface VrmPreviewProps { fileUrl: string | null; name: string; /** 非 VRM 且无法用 GLTFLoader 解析时给出提示(如 fbx)。 */ unsupportedReason?: string | null; height?: number; } /** * VRM 3D 预览:GLTFLoader + VRMLoaderPlugin。 * - 处理 VRM 0.x / 1.0(vrm.meta.metaVersion) * - 处理「未选择 / 加载中 / 加载失败」状态 * - 鼠标拖拽旋转 + 滚轮缩放(自实现,未引入 OrbitControls) * - requestAnimationFrame 循环中调用 vrm.update(delta) * - 卸载时释放 renderer / geometry / material / texture */ export function VrmPreview({ fileUrl, name, unsupportedReason = null, height = 420 }: VrmPreviewProps) { const mountRef = useRef(null); const [state, setState] = useState({ kind: 'idle' }); const [reloadKey, setReloadKey] = useState(0); useEffect(() => { const mount = mountRef.current; if (!mount) return; if (!fileUrl) { setState({ kind: 'idle' }); return; } if (unsupportedReason) { setState({ kind: 'error', message: unsupportedReason }); return; } setState({ kind: 'loading', ratio: null }); const width = Math.max(1, mount.clientWidth); const height2 = Math.max(1, mount.clientHeight || height); const scene = new THREE.Scene(); scene.background = new THREE.Color(0x14181f); const camera = new THREE.PerspectiveCamera(30, width / height2, 0.1, 1000); let renderer: THREE.WebGLRenderer; try { renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); } catch (cause) { // 无 WebGL 支持时不要让整个页面崩溃 const detail = cause instanceof Error ? cause.message : String(cause); setState({ kind: 'error', message: `当前浏览器无法创建 WebGL 上下文:${detail}` }); return; } renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(width, height2, false); renderer.outputColorSpace = THREE.SRGBColorSpace; mount.appendChild(renderer.domElement); scene.add(new THREE.AmbientLight(0xffffff, 1.6)); const keyLight = new THREE.DirectionalLight(0xffffff, 2.2); keyLight.position.set(1, 2, 1.5); scene.add(keyLight); const rimLight = new THREE.DirectionalLight(0x88bbff, 1.0); rimLight.position.set(-1.2, 1.2, -1.5); scene.add(rimLight); const grid = new THREE.GridHelper(4, 8, 0x2a3340, 0x1e2530); scene.add(grid); // 轨道参数(自实现的 drag rotate / wheel zoom) const orbit = { theta: 0, phi: Math.PI / 2.2, radius: 3, targetY: 1 }; const updateCamera = (): void => { const sinPhi = Math.sin(orbit.phi); camera.position.set( orbit.radius * sinPhi * Math.sin(orbit.theta), orbit.targetY + orbit.radius * Math.cos(orbit.phi), orbit.radius * sinPhi * Math.cos(orbit.theta), ); camera.lookAt(0, orbit.targetY, 0); }; updateCamera(); let dragging = false; let lastX = 0; let lastY = 0; const onPointerDown = (event: PointerEvent): void => { dragging = true; lastX = event.clientX; lastY = event.clientY; renderer.domElement.setPointerCapture(event.pointerId); }; const onPointerMove = (event: PointerEvent): void => { if (!dragging) return; const dx = event.clientX - lastX; const dy = event.clientY - lastY; lastX = event.clientX; lastY = event.clientY; orbit.theta -= dx * 0.01; orbit.phi = Math.min(Math.PI - 0.15, Math.max(0.15, orbit.phi - dy * 0.01)); updateCamera(); }; const onPointerUp = (event: PointerEvent): void => { dragging = false; if (renderer.domElement.hasPointerCapture(event.pointerId)) { renderer.domElement.releasePointerCapture(event.pointerId); } }; const onWheel = (event: WheelEvent): void => { event.preventDefault(); orbit.radius = Math.min(20, Math.max(0.4, orbit.radius * (1 + event.deltaY * 0.001))); updateCamera(); }; const canvas = renderer.domElement; canvas.addEventListener('pointerdown', onPointerDown); canvas.addEventListener('pointermove', onPointerMove); canvas.addEventListener('pointerup', onPointerUp); canvas.addEventListener('pointercancel', onPointerUp); canvas.addEventListener('wheel', onWheel, { passive: false }); const resizeObserver = new ResizeObserver(() => { const w = Math.max(1, mount.clientWidth); const h = Math.max(1, mount.clientHeight || height); camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h, false); }); resizeObserver.observe(mount); const clock = new THREE.Clock(); let rafId = 0; let vrm: VRM | null = null; let disposed = false; let loadedModel: THREE.Object3D | null = null; const tick = (): void => { rafId = requestAnimationFrame(tick); const delta = clock.getDelta(); if (vrm) vrm.update(delta); renderer.render(scene, camera); }; rafId = requestAnimationFrame(tick); const loader = new GLTFLoader(); loader.register((parser) => new VRMLoaderPlugin(parser)); loader.load( fileUrl, (gltf) => { if (disposed) { disposeObject(gltf.scene); return; } const bag: Record = gltf.userData; const loadedVrm = extractVrm(bag); const root: THREE.Object3D = loadedVrm ? loadedVrm.scene : gltf.scene; loadedModel = root; vrm = loadedVrm; if (loadedVrm) { // VRM 0.x 面向 +Z,需要转 180°;1.0 不需要。 VRMUtils.rotateVRM0(loadedVrm); } scene.add(root); // 按包围盒自动取景:兼容米制与厘米制模型 const box = new THREE.Box3().setFromObject(root); if (!box.isEmpty()) { const size = box.getSize(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3()); const maxDim = Math.max(size.x, size.y, size.z) || 1; orbit.targetY = center.y; orbit.radius = maxDim * 1.9; grid.position.y = box.min.y; updateCamera(); } const meta = loadedVrm ? loadedVrm.meta.metaVersion : null; setState({ kind: 'ready', metaVersion: meta === '1' ? '1.0' : meta === '0' ? '0.x' : '非 VRM(glTF)' }); }, (event) => { if (disposed) return; const ratio = event.total > 0 ? event.loaded / event.total : null; setState({ kind: 'loading', ratio }); }, (error: unknown) => { if (disposed) return; const message = error instanceof Error ? error.message : String(error); setState({ kind: 'error', message: `模型加载失败:${message}` }); }, ); return () => { disposed = true; cancelAnimationFrame(rafId); resizeObserver.disconnect(); canvas.removeEventListener('pointerdown', onPointerDown); canvas.removeEventListener('pointermove', onPointerMove); canvas.removeEventListener('pointerup', onPointerUp); canvas.removeEventListener('pointercancel', onPointerUp); canvas.removeEventListener('wheel', onWheel); if (loadedModel) { scene.remove(loadedModel); if (vrm) { try { VRMUtils.deepDispose(vrm.scene); } catch { disposeObject(loadedModel); } } else { disposeObject(loadedModel); } } scene.clear(); renderer.dispose(); if (canvas.parentNode === mount) mount.removeChild(canvas); }; }, [fileUrl, unsupportedReason, reloadKey, height]); const hasModel = fileUrl !== null && fileUrl !== ''; return (
3D 预览 {name || '未选择模型'}
{hasModel ? ( ) : null}
{state.kind === 'idle' ? (

尚未选择模型

在左侧列表点击「预览」以加载 VRM。

) : null} {state.kind === 'loading' ? (
) : null} {state.kind === 'error' ? (

⚠ {state.message}

) : null}
拖拽旋转 · 滚轮缩放 {state.kind === 'ready' ? ` · VRM 版本:${state.metaVersion}` : ''} {fileUrl ? ( 模型文件 ) : null}
); }