70271b408e
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m19s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m35s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m34s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m39s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m13s
Project CI / AI game creator shell Rust crates (push) Successful in 3m18s
Project CI / Backend tests (push) Successful in 9m16s
Project CI / Repository checks (push) Successful in 8m11s
Project CI / Native shell tests (push) Successful in 11m56s
Project CI / Frontend tests (push) Successful in 11m49s
Project CI / AI game creator shell web tests (push) Successful in 6m37s
Reviewed-on: #355 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
193 lines
6.3 KiB
TypeScript
193 lines
6.3 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
|
||
import {
|
||
addResourceModelLights,
|
||
disposeResourceModelObject,
|
||
frameResourceModelInCamera,
|
||
loadResourceModelObject,
|
||
type ResourceModelSource,
|
||
} from './resourceModelScene';
|
||
|
||
export type ResourceModelViewerHandle = {
|
||
/** 复位视角(回到打开时的取景);供浮层上的「复位视角」按钮调用。 */
|
||
resetView: () => void;
|
||
};
|
||
|
||
type ResourceModelViewerProps = {
|
||
source: ResourceModelSource;
|
||
/** 复位视角按钮的点击计数:变化即复位,避免把 three 对象提到 React 状态里。 */
|
||
resetSignal: number;
|
||
onReady: (handle: ResourceModelViewerHandle) => void;
|
||
};
|
||
|
||
/**
|
||
* 交互式模型预览画布:视角操作与 3D 建模软件一致。
|
||
*
|
||
* - 左键拖拽:旋转(Orbit)
|
||
* - 右键 / 中键拖拽:平移(Pan)
|
||
* - 滚轮:推拉(Zoom)
|
||
*
|
||
* 与缩略图共用 `resourceModelScene` 的加载 / 取景 / 释放口径,所以「卡片上看得见、放大后
|
||
* 看不见」这类分叉不会出现。渲染器是本浮层自己的 WebGL 上下文,关闭时立即 `dispose`:
|
||
* 资源画布上十几张模型卡仍然只共用缩略图那一个上下文(见 `resourceModelThumbnail`)。
|
||
*/
|
||
export function ResourceModelViewer({
|
||
source,
|
||
resetSignal,
|
||
onReady,
|
||
}: ResourceModelViewerProps) {
|
||
const hostRef = useRef<HTMLDivElement>(null);
|
||
const resetRef = useRef<(() => void) | null>(null);
|
||
/**
|
||
* 用 ref 保存 source:调用方通常直接传对象字面量,把它放进依赖数组会让模型每次渲染
|
||
* 都重建一次 WebGL 上下文;真正的重建条件只有「换了模型」。
|
||
*/
|
||
const sourceRef = useRef(source);
|
||
sourceRef.current = source;
|
||
const [status, setStatus] = useState<'loading' | 'ready' | 'failed'>(
|
||
'loading',
|
||
);
|
||
const [error, setError] = useState('');
|
||
|
||
useEffect(() => {
|
||
let disposed = false;
|
||
let cleanup: (() => void) | undefined;
|
||
setStatus('loading');
|
||
setError('');
|
||
const host = hostRef.current;
|
||
if (!host) {
|
||
return undefined;
|
||
}
|
||
void (async () => {
|
||
try {
|
||
const THREE = await import('three');
|
||
const { OrbitControls } = await import(
|
||
'three/examples/jsm/controls/OrbitControls.js'
|
||
);
|
||
if (disposed) {
|
||
return;
|
||
}
|
||
const width = Math.max(240, Math.round(host.clientWidth));
|
||
const height = Math.max(200, Math.round(host.clientHeight));
|
||
const modelSource = {
|
||
sourceUrl: sourceRef.current.sourceUrl,
|
||
mediaType: sourceRef.current.mediaType,
|
||
};
|
||
const renderer = new THREE.WebGLRenderer({
|
||
alpha: true,
|
||
antialias: true,
|
||
});
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||
renderer.setSize(width, height, false);
|
||
renderer.domElement.className = 'game-resource-model-viewer__canvas';
|
||
host.appendChild(renderer.domElement);
|
||
|
||
const scene = new THREE.Scene();
|
||
addResourceModelLights(THREE, scene);
|
||
const camera = new THREE.PerspectiveCamera(
|
||
35,
|
||
width / height,
|
||
0.01,
|
||
10_000,
|
||
);
|
||
|
||
const controls = new OrbitControls(camera, renderer.domElement);
|
||
// 阻尼让拖动有惯性,手感与建模软件一致;它要求每帧 update,因此下面常驻 rAF。
|
||
controls.enableDamping = true;
|
||
controls.dampingFactor = 0.08;
|
||
controls.screenSpacePanning = true;
|
||
controls.zoomSpeed = 0.9;
|
||
// 允许贴到模型内部看细节,但不允许转到背面时被裁剪面切掉模型。
|
||
controls.minDistance = 0.01;
|
||
controls.maxDistance = Number.POSITIVE_INFINITY;
|
||
|
||
const object = await loadResourceModelObject(modelSource);
|
||
if (disposed) {
|
||
disposeResourceModelObject(object);
|
||
renderer.dispose();
|
||
renderer.domElement.remove();
|
||
return;
|
||
}
|
||
scene.add(object);
|
||
const frame = () => {
|
||
const { center } = frameResourceModelInCamera(THREE, object, camera);
|
||
// 轨道中心 = 模型包围盒中心:缩放与旋转都绕着模型本身,和建模软件的取景一致。
|
||
controls.target.copy(center);
|
||
controls.update();
|
||
};
|
||
frame();
|
||
controls.update();
|
||
|
||
const resizeObserver = new ResizeObserver(() => {
|
||
const nextWidth = Math.max(240, Math.round(host.clientWidth));
|
||
const nextHeight = Math.max(200, Math.round(host.clientHeight));
|
||
camera.aspect = nextWidth / nextHeight;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(nextWidth, nextHeight, false);
|
||
});
|
||
resizeObserver.observe(host);
|
||
|
||
let frameHandle = 0;
|
||
const renderLoop = () => {
|
||
controls.update();
|
||
renderer.render(scene, camera);
|
||
frameHandle = window.requestAnimationFrame(renderLoop);
|
||
};
|
||
frameHandle = window.requestAnimationFrame(renderLoop);
|
||
|
||
resetRef.current = () => {
|
||
frame();
|
||
};
|
||
onReady({ resetView: () => resetRef.current?.() });
|
||
setStatus('ready');
|
||
|
||
cleanup = () => {
|
||
window.cancelAnimationFrame(frameHandle);
|
||
resizeObserver.disconnect();
|
||
controls.dispose();
|
||
scene.remove(object);
|
||
disposeResourceModelObject(object);
|
||
renderer.dispose();
|
||
renderer.domElement.remove();
|
||
resetRef.current = null;
|
||
};
|
||
} catch (viewerError) {
|
||
if (!disposed) {
|
||
setStatus('failed');
|
||
setError(
|
||
viewerError instanceof Error
|
||
? viewerError.message
|
||
: String(viewerError),
|
||
);
|
||
}
|
||
}
|
||
})();
|
||
return () => {
|
||
disposed = true;
|
||
cleanup?.();
|
||
};
|
||
}, [onReady, source.mediaType, source.sourceUrl]);
|
||
|
||
useEffect(() => {
|
||
if (resetSignal > 0) {
|
||
resetRef.current?.();
|
||
}
|
||
}, [resetSignal]);
|
||
|
||
return (
|
||
<div
|
||
ref={hostRef}
|
||
className="game-resource-model-viewer"
|
||
data-model-viewer-status={status}
|
||
>
|
||
{status === 'ready' ? null : (
|
||
<p className="game-resource-model-viewer__notice" role="status">
|
||
{status === 'loading'
|
||
? '正在加载模型…'
|
||
: `当前环境无法渲染三维预览${error ? `:${error}` : ''}`}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|