Files
opendance-web/web-server/admin/src/components/VrmPreview.tsx
T

311 lines
11 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.
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>
);
}