311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
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<string, unknown>): 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<HTMLDivElement | null>(null);
|
||
const [state, setState] = useState<PreviewState>({ 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<string, unknown> = 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 (
|
||
<div className="vrm-panel">
|
||
<div className="vrm-panel-head">
|
||
<div>
|
||
<strong>3D 预览</strong>
|
||
<span className="muted">{name || '未选择模型'}</span>
|
||
</div>
|
||
{hasModel ? (
|
||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setReloadKey((v) => v + 1)}>
|
||
重新加载
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="vrm-stage" style={{ height }}>
|
||
<div className="vrm-canvas" ref={mountRef} />
|
||
{state.kind === 'idle' ? (
|
||
<div className="vrm-overlay">
|
||
<p>尚未选择模型</p>
|
||
<p className="muted">在左侧列表点击「预览」以加载 VRM。</p>
|
||
</div>
|
||
) : null}
|
||
{state.kind === 'loading' ? (
|
||
<div className="vrm-overlay">
|
||
<span className="spinner" aria-hidden="true" />
|
||
<p>加载模型中… {state.ratio !== null ? `${Math.round(state.ratio * 100)}%` : ''}</p>
|
||
</div>
|
||
) : null}
|
||
{state.kind === 'error' ? (
|
||
<div className="vrm-overlay vrm-overlay-error">
|
||
<p>⚠ {state.message}</p>
|
||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setReloadKey((v) => v + 1)}>
|
||
重试
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="vrm-panel-foot">
|
||
<span className="muted">
|
||
拖拽旋转 · 滚轮缩放
|
||
{state.kind === 'ready' ? ` · VRM 版本:${state.metaVersion}` : ''}
|
||
</span>
|
||
{fileUrl ? (
|
||
<a className="ext-link" href={fileUrl} target="_blank" rel="noreferrer noopener">
|
||
模型文件
|
||
</a>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|