新增 3D 模型查看器独立包

- 新增 packages/model3d-viewer(@genarrative/model3d-viewer),含 Model3DViewer、scene、loader、framing、status 与 index
- 组件只走 WebGL2 单一路径渲染 glb、单文件 gltf 与 fbx,未承诺格式或超过 32 MiB 时只渲染一行状态文案
- 取景按真实顶点做投影拟合,与模型尺度无关,复位回到打开时的取景
- 新增 framing 与 loader 的纯函数单测
- 根 package.json 的 workspaces 增加 packages/model3d-viewer
- scripts/check-npm-workspaces.mjs 与 scripts/check-npm-workspaces.test.mjs 同步工作区清单
- vitest.config.ts 增加新包测试匹配
- 同步 package-lock.json 的工作区链接
This commit is contained in:
2026-09-19 16:13:55 +08:00
parent 0c4b1c34d9
commit b0054fd51c
14 changed files with 1344 additions and 77 deletions
+67 -77
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -13,6 +13,7 @@
"packages/image-canvas-core",
"packages/image-canvas-react",
"packages/agc-plugin-sdk",
"packages/model3d-viewer",
"packages/shared",
"plugins/agc-cocos-editor",
"tools/spine-json-export-validator"
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@genarrative/model3d-viewer",
"private": true,
"version": "0.1.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"three": "^0.184.0"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/three": "^0.184.1",
"vitest": "^0.34.6"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
@@ -0,0 +1,180 @@
import {
type Ref,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react';
import {
describeModel3dViewerError,
Model3dViewerAbortedError,
Model3dViewerLoadError,
type Model3DViewerSource,
} from './loader';
import { createModel3dViewerScene, type Model3dViewerScene } from './scene';
import {
MODEL3D_VIEWER_LOADING_TEXT,
MODEL3D_VIEWER_STATUS_ATTRIBUTE,
type Model3DViewerStatus,
type Model3dViewerStatusDetail,
resolveModel3dViewerErrorText,
} from './status';
export type Model3DViewerHandle = {
/** 回到打开模型时的归一化取景。 */
resetView(): void;
};
export type Model3DViewerProps = {
/** 模型来源:宿主已解析好的签名地址,或已读入的字节。 */
source: Model3DViewerSource;
autoRotate?: boolean;
/** 失败时那一行状态文案;不传用内置文案。 */
errorText?: string;
onStatusChange?: (
status: Model3DViewerStatus,
detail: Model3dViewerStatusDetail,
) => void;
className?: string;
ref?: Ref<Model3DViewerHandle>;
};
/**
* WebGL2 模型查看器:只负责加载与交互查看,不取业务数据、不产出缩略图。
* 宿主要给容器一个非零尺寸,并在祖先上带 platform-theme 主题类才能取到平台色值。
*/
export function Model3DViewer({
source,
autoRotate = false,
errorText,
onStatusChange,
className,
ref,
}: Model3DViewerProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const sceneRef = useRef<Model3dViewerScene | null>(null);
const onStatusChangeRef = useRef(onStatusChange);
const autoRotateRef = useRef(autoRotate);
const [status, setStatus] = useState<Model3DViewerStatus>('idle');
useEffect(() => {
onStatusChangeRef.current = onStatusChange;
}, [onStatusChange]);
const reportStatus = useCallback(
(next: Model3DViewerStatus, detail: Model3dViewerStatusDetail) => {
setStatus(next);
onStatusChangeRef.current?.(next, detail);
},
[],
);
useImperativeHandle(
ref,
() => ({
resetView() {
sceneRef.current?.resetView();
},
}),
[],
);
const sourceKind = source.kind;
const sourceFormat = source.format;
const sourceUrl = source.kind === 'url' ? source.url : null;
const sourceData = source.kind === 'bytes' ? source.data : null;
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) {
return;
}
let aborted = false;
let createdScene: Model3dViewerScene | null = null;
const activeSource: Model3DViewerSource = sourceData
? { kind: 'bytes', data: sourceData, format: sourceFormat }
: { kind: 'url', url: sourceUrl ?? '', format: sourceFormat };
reportStatus('loading', {});
createModel3dViewerScene({
canvas,
container,
source: activeSource,
autoRotate: autoRotateRef.current,
isAborted: () => aborted,
})
.then((scene) => {
if (aborted) {
scene.dispose();
return;
}
createdScene = scene;
sceneRef.current = scene;
reportStatus('ready', {});
})
.catch((error: unknown) => {
if (aborted || error instanceof Model3dViewerAbortedError) {
return;
}
if (error instanceof Model3dViewerLoadError) {
reportStatus('failed', {
reason: error.reason,
message: error.message,
byteLength: error.byteLength,
});
return;
}
reportStatus('failed', {
reason: 'load-failed',
message: describeModel3dViewerError(error),
});
});
return () => {
aborted = true;
createdScene?.dispose();
if (sceneRef.current === createdScene) {
sceneRef.current = null;
}
};
}, [sourceKind, sourceFormat, sourceUrl, sourceData, reportStatus]);
useEffect(() => {
autoRotateRef.current = autoRotate;
sceneRef.current?.setAutoRotate(autoRotate);
}, [autoRotate]);
return (
<div
ref={containerRef}
className={['relative h-full w-full overflow-hidden', className]
.filter(Boolean)
.join(' ')}
{...{ [MODEL3D_VIEWER_STATUS_ATTRIBUTE]: status }}
>
<canvas
ref={canvasRef}
className={[
'absolute inset-0 block h-full w-full',
status === 'ready' ? 'opacity-100' : 'opacity-0',
].join(' ')}
/>
{status === 'loading' ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-xs text-[var(--platform-text-soft)]">
{MODEL3D_VIEWER_LOADING_TEXT}
</div>
) : null}
{status === 'failed' ? (
<div className="absolute inset-0 flex items-center justify-center px-4 text-center text-xs text-rose-700">
{resolveModel3dViewerErrorText(errorText)}
</div>
) : null}
</div>
);
}
+211
View File
@@ -0,0 +1,211 @@
import { describe, expect, it } from 'vitest';
import {
computeModel3dViewerBoxCenter,
computeModel3dViewerBoxRadius,
computeModel3dViewerFitDistance,
computeModel3dViewerFraming,
createModel3dViewerFitSolver,
MODEL3D_VIEWER_CAMERA_FOV_DEGREES,
MODEL3D_VIEWER_FIT_FRACTION,
type Model3dViewerBox,
} from './framing';
type Point = [number, number, number];
function box(min: Point, max: Point): Model3dViewerBox {
return {
min: { x: min[0], y: min[1], z: min[2] },
max: { x: max[0], y: max[1], z: max[2] },
};
}
function scaleBox(source: Model3dViewerBox, factor: number): Model3dViewerBox {
return {
min: {
x: source.min.x * factor,
y: source.min.y * factor,
z: source.min.z * factor,
},
max: {
x: source.max.x * factor,
y: source.max.y * factor,
z: source.max.z * factor,
},
};
}
function solveForPoints(
source: Model3dViewerBox,
points: Point[],
aspect: number,
) {
const solver = createModel3dViewerFitSolver({
center: computeModel3dViewerBoxCenter(source),
aspect,
fallbackRadius: computeModel3dViewerBoxRadius(source),
});
for (const [x, y, z] of points) {
solver.add(x, y, z);
}
return solver.resolve();
}
/** 旧口径的包围球拟合距离,作为「不该退回到这么远」的对照基线。 */
function sphereBaselineDistance(source: Model3dViewerBox): number {
const halfFovY = (MODEL3D_VIEWER_CAMERA_FOV_DEGREES * Math.PI) / 360;
return (
computeModel3dViewerBoxRadius(source) /
(MODEL3D_VIEWER_FIT_FRACTION * Math.tan(halfFovY))
);
}
const CUBE = box([-1, -1, -1], [1, 1, 1]);
const WINGS = box([-4, -0.4, -0.4], [4, 0.4, 0.4]);
const CUBE_CORNERS: Point[] = [
[-1, -1, -1],
[-1, -1, 1],
[-1, 1, -1],
[-1, 1, 1],
[1, -1, -1],
[1, -1, 1],
[1, 1, -1],
[1, 1, 1],
];
describe('computeModel3dViewerFitDistance', () => {
it('与模型尺度成正比,任意尺度都占同样的视野比例', () => {
const small = computeModel3dViewerFitDistance({ box: CUBE, aspect: 1.5 });
const large = computeModel3dViewerFitDistance({
box: scaleBox(CUBE, 10000),
aspect: 1.5,
});
expect(small).toBeGreaterThan(0);
expect(large / small).toBeCloseTo(10000, 3);
});
it('按包围盒投影取景,明显近于按包围球取景', () => {
for (const source of [CUBE, WINGS]) {
const boxFit = computeModel3dViewerFitDistance({
box: source,
aspect: 2,
});
expect(boxFit).toBeLessThan(sphereBaselineDistance(source) * 0.9);
}
});
it('竖屏时相机更远,避免横向出框', () => {
const landscape = computeModel3dViewerFitDistance({ box: CUBE, aspect: 2 });
const portrait = computeModel3dViewerFitDistance({
box: CUBE,
aspect: 0.5,
});
expect(portrait).toBeGreaterThan(landscape);
});
it('退化或非法包围盒回落到可用的默认取景', () => {
const degenerate = [
box([0, 0, 0], [0, 0, 0]),
box([Number.NaN, 0, 0], [1, 1, 1]),
box([Number.POSITIVE_INFINITY, 0, 0], [Number.NEGATIVE_INFINITY, 1, 1]),
];
for (const candidate of degenerate) {
const distance = computeModel3dViewerFitDistance({
box: candidate,
aspect: 1,
});
expect(Number.isFinite(distance)).toBe(true);
expect(distance).toBeGreaterThan(0);
}
});
it('fitFraction 越小相机越远', () => {
const near = computeModel3dViewerFitDistance({ box: CUBE, aspect: 1 });
const far = computeModel3dViewerFitDistance({
box: CUBE,
aspect: 1,
fitFraction: 0.31,
});
expect(far).toBeGreaterThan(near);
});
it('传入已算好的距离时直接采用,不再按角点重算', () => {
const distance = computeModel3dViewerFitDistance({
box: CUBE,
aspect: 1,
distance: 7,
});
expect(distance).toBe(7);
});
});
describe('createModel3dViewerFitSolver', () => {
it('喂包围盒角点时与包围盒口径一致', () => {
const expected = computeModel3dViewerFitDistance({ box: CUBE, aspect: 2 });
expect(solveForPoints(CUBE, CUBE_CORNERS, 2)).toBeCloseTo(expected, 6);
});
it('喂真实顶点时避开包围盒空角,取景更近', () => {
// 斜向细杆:包围盒是 -1..1 的立方体,但 6 个角点都是空的。
const rod: Point[] = [
[-1, -1, -1],
[1, 1, 1],
];
const boxFit = computeModel3dViewerFitDistance({ box: CUBE, aspect: 2 });
const vertexFit = solveForPoints(CUBE, rod, 2);
expect(vertexFit).toBeLessThan(boxFit * 0.9);
});
it('没有任何顶点时回落到与尺度相关的可用距离', () => {
const fit = solveForPoints(CUBE, [], 1);
expect(Number.isFinite(fit)).toBe(true);
expect(fit).toBeGreaterThan(0);
});
});
describe('computeModel3dViewerFraming', () => {
it('推导出合法的裁剪面与缩放范围', () => {
const framing = computeModel3dViewerFraming({
box: box([-1, -1, -1], [3, 5, 7]),
aspect: 1.6,
});
expect(framing.center).toEqual({ x: 1, y: 2, z: 3 });
expect(framing.near).toBeGreaterThan(0);
expect(framing.near).toBeLessThan(framing.far);
expect(framing.minDistance).toBeLessThan(framing.distance);
expect(framing.maxDistance).toBeGreaterThan(framing.distance);
});
it('复位方向是单位向量且带正向仰角', () => {
const { resetDirection } = computeModel3dViewerFraming({
box: CUBE,
aspect: 1,
});
expect(
Math.hypot(resetDirection.x, resetDirection.y, resetDirection.z),
).toBeCloseTo(1, 6);
expect(resetDirection.y).toBeGreaterThan(0);
expect(resetDirection.z).toBeGreaterThan(0);
});
});
describe('computeModel3dViewerBoxCenter', () => {
it('非法包围盒回落到原点,避免相机拿到 NaN', () => {
expect(
computeModel3dViewerBoxCenter({
min: { x: Number.POSITIVE_INFINITY, y: 0, z: 0 },
max: { x: Number.NEGATIVE_INFINITY, y: 0, z: 0 },
}),
).toEqual({ x: 0, y: 0, z: 0 });
});
});
+246
View File
@@ -0,0 +1,246 @@
export const MODEL3D_VIEWER_CAMERA_FOV_DEGREES = 45;
/**
* 顶点在视野较短一边上占据的比例:与模型真实尺度无关的归一化取景。
* 取 0.8 是因为包围盒对角线方向与屏幕上下轴并不重合,实际观感约为六成。
*/
export const MODEL3D_VIEWER_FIT_FRACTION = 0.8;
const FALLBACK_RADIUS = 1;
const FALLBACK_ASPECT = 1;
const NEAR_RADIUS_RATIO = 0.01;
const FAR_RADIUS_RATIO = 12;
const MIN_DISTANCE_RATIO = 0.2;
const MAX_DISTANCE_RATIO = 8;
/** 复位视角仰角:过度抬高会把「上轴投影范围」放大,导致模型看起来比目标比例小。 */
const RESET_ELEVATION = 0.22;
const FIT_SAFETY = 1.02;
export type Model3dViewerVector3 = {
x: number;
y: number;
z: number;
};
export type Model3dViewerBox = {
min: Model3dViewerVector3;
max: Model3dViewerVector3;
};
export type Model3dViewerFramingInput = {
box: Model3dViewerBox;
aspect: number;
direction?: Model3dViewerVector3;
fovDegrees?: number;
fitFraction?: number;
/** 已按实际顶点算好的取景距离;缺省时退回包围盒 8 个角点。 */
distance?: number;
};
export type Model3dViewerFitSolverInput = {
center: Model3dViewerVector3;
aspect: number;
direction?: Model3dViewerVector3;
fovDegrees?: number;
fitFraction?: number;
fallbackRadius?: number;
};
export type Model3dViewerFitSolver = {
add(x: number, y: number, z: number): void;
resolve(): number;
};
export type Model3dViewerFraming = {
center: Model3dViewerVector3;
radius: number;
distance: number;
near: number;
far: number;
minDistance: number;
maxDistance: number;
/** 复位视角时的相机方向(已归一化,带少量仰角)。 */
resetDirection: Model3dViewerVector3;
};
function normalizePositive(value: number, fallback: number): number {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
function subtract(
left: Model3dViewerVector3,
right: Model3dViewerVector3,
): Model3dViewerVector3 {
return {
x: left.x - right.x,
y: left.y - right.y,
z: left.z - right.z,
};
}
function dot(left: Model3dViewerVector3, right: Model3dViewerVector3): number {
return left.x * right.x + left.y * right.y + left.z * right.z;
}
function cross(
left: Model3dViewerVector3,
right: Model3dViewerVector3,
): Model3dViewerVector3 {
return {
x: left.y * right.z - left.z * right.y,
y: left.z * right.x - left.x * right.z,
z: left.x * right.y - left.y * right.x,
};
}
function normalize(
value: Model3dViewerVector3,
fallback: Model3dViewerVector3,
): Model3dViewerVector3 {
const length = Math.hypot(value.x, value.y, value.z);
if (!Number.isFinite(length) || length <= Number.EPSILON) {
return fallback;
}
return { x: value.x / length, y: value.y / length, z: value.z / length };
}
/** 复位视角的默认方向:从正前方略微抬高,避免正投影视角。 */
export function resolveModel3dViewerResetDirection(
direction?: Model3dViewerVector3,
): Model3dViewerVector3 {
if (direction) {
return normalize(direction, { x: 0, y: 0, z: 1 });
}
return normalize({ x: 0, y: RESET_ELEVATION, z: 1 }, { x: 0, y: 0, z: 1 });
}
export function computeModel3dViewerBoxCenter(
box: Model3dViewerBox,
): Model3dViewerVector3 {
const center = {
x: (box.min.x + box.max.x) / 2,
y: (box.min.y + box.max.y) / 2,
z: (box.min.z + box.max.z) / 2,
};
const valid =
Number.isFinite(center.x) &&
Number.isFinite(center.y) &&
Number.isFinite(center.z);
return valid ? center : { x: 0, y: 0, z: 0 };
}
export function computeModel3dViewerBoxRadius(box: Model3dViewerBox): number {
return (
Math.hypot(
box.max.x - box.min.x,
box.max.y - box.min.y,
box.max.z - box.min.z,
) / 2
);
}
/**
* 取景求解器:把每个采样点投影到相机右轴与上轴,取满足「点落在 fitFraction
* 视野内」的最小距离。逐点求解而不是用包围球,因为包围球会把「翅膀斜向展开」
* 这类形状的取景距离放大一倍以上;用真实顶点又可以避开包围盒空角。
*/
export function createModel3dViewerFitSolver(
input: Model3dViewerFitSolverInput,
): Model3dViewerFitSolver {
const aspect = normalizePositive(input.aspect, FALLBACK_ASPECT);
const fovDegrees = normalizePositive(
input.fovDegrees ?? MODEL3D_VIEWER_CAMERA_FOV_DEGREES,
MODEL3D_VIEWER_CAMERA_FOV_DEGREES,
);
const fitFraction = normalizePositive(
input.fitFraction ?? MODEL3D_VIEWER_FIT_FRACTION,
MODEL3D_VIEWER_FIT_FRACTION,
);
const center = input.center;
const fallbackRadius = normalizePositive(
input.fallbackRadius ?? FALLBACK_RADIUS,
FALLBACK_RADIUS,
);
const direction = resolveModel3dViewerResetDirection(input.direction);
const forward = { x: -direction.x, y: -direction.y, z: -direction.z };
const right = normalize(cross(forward, { x: 0, y: 1, z: 0 }), {
x: 1,
y: 0,
z: 0,
});
const up = cross(right, forward);
const halfFovY = (fovDegrees * Math.PI) / 360;
const limitY = fitFraction * Math.tan(halfFovY);
const limitX = limitY * aspect;
let required = 0;
const fallbackDistance = fallbackRadius / (fitFraction * Math.tan(halfFovY));
return {
add(x: number, y: number, z: number) {
const corner = subtract({ x, y, z }, center);
const depth = dot(corner, forward);
required = Math.max(
required,
Math.abs(dot(corner, up)) / limitY - depth,
Math.abs(dot(corner, right)) / limitX - depth,
);
},
resolve() {
if (!Number.isFinite(required) || required <= 0) {
return fallbackDistance;
}
return Math.max(required * FIT_SAFETY, fallbackRadius);
},
};
}
/** 没有顶点可用时,用包围盒 8 个角点求解取景距离。 */
export function computeModel3dViewerFitDistance(
input: Model3dViewerFramingInput,
): number {
if (input.distance !== undefined) {
return input.distance;
}
const solver = createModel3dViewerFitSolver({
center: computeModel3dViewerBoxCenter(input.box),
aspect: input.aspect,
direction: input.direction,
fovDegrees: input.fovDegrees,
fitFraction: input.fitFraction,
fallbackRadius: computeModel3dViewerBoxRadius(input.box),
});
for (const x of [input.box.min.x, input.box.max.x]) {
for (const y of [input.box.min.y, input.box.max.y]) {
for (const z of [input.box.min.z, input.box.max.z]) {
solver.add(x, y, z);
}
}
}
return solver.resolve();
}
/** 由包围盒推导取景、裁剪面与轨道缩放范围。 */
export function computeModel3dViewerFraming(
input: Model3dViewerFramingInput,
): Model3dViewerFraming {
const radius = normalizePositive(
computeModel3dViewerBoxRadius(input.box),
FALLBACK_RADIUS,
);
const distance = computeModel3dViewerFitDistance({
...input,
box: input.box,
});
return {
center: computeModel3dViewerBoxCenter(input.box),
radius,
distance,
near: Math.max(radius * NEAR_RADIUS_RATIO, distance / 1000),
far: Math.max(distance + radius * FAR_RADIUS_RATIO, distance * 10),
minDistance: radius * MIN_DISTANCE_RATIO,
maxDistance: distance * MAX_DISTANCE_RATIO,
resetDirection: resolveModel3dViewerResetDirection(input.direction),
};
}
+18
View File
@@ -0,0 +1,18 @@
export type { Model3DViewerFormat, Model3DViewerSource } from './loader';
export {
isModel3dViewerFormatSupported,
isModel3dViewerOverSize,
MODEL3D_VIEWER_MAX_MODEL_BYTES,
} from './loader';
export type { Model3DViewerHandle, Model3DViewerProps } from './Model3DViewer';
export { Model3DViewer } from './Model3DViewer';
export type {
Model3dViewerFailureReason,
Model3DViewerStatus,
Model3dViewerStatusDetail,
} from './status';
export {
MODEL3D_VIEWER_ERROR_TEXT,
MODEL3D_VIEWER_STATUS_ATTRIBUTE,
resolveModel3dViewerErrorText,
} from './status';
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import {
isModel3dViewerFormatSupported,
isModel3dViewerOverSize,
MODEL3D_VIEWER_MAX_MODEL_BYTES,
} from './loader';
describe('模型格式承诺范围', () => {
it('只承诺 glb / gltf / fbx', () => {
for (const format of ['glb', 'gltf', 'fbx']) {
expect(isModel3dViewerFormatSupported(format)).toBe(true);
}
for (const format of [
'obj',
'stl',
'usdz',
'3mf',
'',
null,
undefined,
7,
]) {
expect(isModel3dViewerFormatSupported(format)).toBe(false);
}
});
});
describe('模型体积上限', () => {
it('超过 32 MiB 判定为超限', () => {
expect(isModel3dViewerOverSize(0)).toBe(false);
expect(isModel3dViewerOverSize(MODEL3D_VIEWER_MAX_MODEL_BYTES)).toBe(false);
expect(isModel3dViewerOverSize(MODEL3D_VIEWER_MAX_MODEL_BYTES + 1)).toBe(
true,
);
});
it('非有限值不算超限,由加载流程自行失败', () => {
expect(isModel3dViewerOverSize(Number.NaN)).toBe(false);
});
});
+167
View File
@@ -0,0 +1,167 @@
import type * as ThreeTypes from 'three';
import type { Model3dViewerFailureReason } from './status';
export type Model3DViewerFormat = 'glb' | 'gltf' | 'fbx';
export type Model3DViewerSource =
| { kind: 'url'; url: string; format: Model3DViewerFormat }
| { kind: 'bytes'; data: ArrayBuffer; format: Model3DViewerFormat };
const SUPPORTED_FORMATS: readonly Model3DViewerFormat[] = [
'glb',
'gltf',
'fbx',
];
/** 单模型可渲染上限;超过后整体失败,不做半渲染。 */
export const MODEL3D_VIEWER_MAX_MODEL_BYTES = 32 * 1024 * 1024;
export function isModel3dViewerFormatSupported(
value: unknown,
): value is Model3DViewerFormat {
return SUPPORTED_FORMATS.includes(value as Model3DViewerFormat);
}
export function isModel3dViewerOverSize(byteLength: number): boolean {
return (
Number.isFinite(byteLength) && byteLength > MODEL3D_VIEWER_MAX_MODEL_BYTES
);
}
export function describeModel3dViewerError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return typeof error === 'string' ? error : '未知错误';
}
export class Model3dViewerLoadError extends Error {
readonly reason: Model3dViewerFailureReason;
readonly byteLength?: number;
constructor(
reason: Model3dViewerFailureReason,
message: string,
byteLength?: number,
) {
super(message);
this.name = 'Model3dViewerLoadError';
this.reason = reason;
this.byteLength = byteLength;
}
}
export class Model3dViewerAbortedError extends Error {
constructor() {
super('模型加载已取消');
this.name = 'Model3dViewerAbortedError';
}
}
function assertModel3dViewerByteLength(byteLength: number) {
if (isModel3dViewerOverSize(byteLength)) {
throw new Model3dViewerLoadError(
'too-large',
`模型体积 ${byteLength} 字节,超过 ${MODEL3D_VIEWER_MAX_MODEL_BYTES} 字节上限`,
byteLength,
);
}
}
/** 取模型字节:url 走一次 fetch,bytes 直接复用调用方已读入的字节。 */
export async function readModel3dViewerSourceBytes(
source: Model3DViewerSource,
): Promise<ArrayBuffer> {
if (source.kind === 'bytes') {
assertModel3dViewerByteLength(source.data.byteLength);
return source.data;
}
let response: Response;
try {
response = await fetch(source.url);
} catch (error) {
throw new Model3dViewerLoadError(
'load-failed',
`模型地址不可读取:${describeModel3dViewerError(error)}`,
);
}
if (!response.ok) {
throw new Model3dViewerLoadError(
'load-failed',
`模型地址返回 ${response.status}`,
);
}
const declaredLength = Number(response.headers.get('content-length'));
if (Number.isFinite(declaredLength)) {
assertModel3dViewerByteLength(declaredLength);
}
const data = await response.arrayBuffer();
assertModel3dViewerByteLength(data.byteLength);
return data;
}
function resolveModel3dViewerBasePath(source: Model3DViewerSource): string {
if (source.kind !== 'url') {
return '';
}
const separatorIndex = source.url.lastIndexOf('/');
return separatorIndex >= 0 ? source.url.slice(0, separatorIndex + 1) : '';
}
/**
* 解析模型字节为 three 对象。glb / 单文件 gltf 走 GLTFLoaderfbx 走 FBXLoader
* 多文件 gltf 的兄弟资源按模型地址目录解析,取不到时按加载失败处理。
*/
export async function loadModel3dViewerObject(
source: Model3DViewerSource,
isAborted: () => boolean = () => false,
): Promise<ThreeTypes.Object3D> {
if (!isModel3dViewerFormatSupported(source.format)) {
throw new Model3dViewerLoadError(
'unsupported-format',
`不支持的模型格式:${String(source.format)}`,
);
}
const data = await readModel3dViewerSourceBytes(source);
if (isAborted()) {
throw new Model3dViewerAbortedError();
}
const basePath = resolveModel3dViewerBasePath(source);
try {
if (source.format === 'fbx') {
const { FBXLoader } = await import(
'three/examples/jsm/loaders/FBXLoader.js'
);
return new FBXLoader().parse(data, basePath);
}
const { GLTFLoader } = await import(
'three/examples/jsm/loaders/GLTFLoader.js'
);
const loader = new GLTFLoader();
const payload: string | ArrayBuffer =
source.format === 'gltf'
? new TextDecoder().decode(new Uint8Array(data))
: data;
const gltf = await new Promise<{ scene: ThreeTypes.Object3D }>(
(resolve, reject) => {
loader.parse(payload, basePath, resolve, reject);
},
);
return gltf.scene;
} catch (error) {
if (error instanceof Model3dViewerLoadError) {
throw error;
}
throw new Model3dViewerLoadError(
'load-failed',
`模型解析失败:${describeModel3dViewerError(error)}`,
);
}
}
+361
View File
@@ -0,0 +1,361 @@
import type * as ThreeTypes from 'three';
import {
computeModel3dViewerBoxCenter,
computeModel3dViewerBoxRadius,
computeModel3dViewerFraming,
createModel3dViewerFitSolver,
MODEL3D_VIEWER_CAMERA_FOV_DEGREES,
} from './framing';
import {
describeModel3dViewerError,
loadModel3dViewerObject,
Model3dViewerAbortedError,
Model3dViewerLoadError,
type Model3DViewerSource,
} from './loader';
const MAX_PIXEL_RATIO = 2;
/** 与共享组件同口径的半球光 / 主光,保证 PBR 材质可见;场景背景交给宿主。 */
const HEMISPHERE_LIGHT_SKY = 0xffffff;
const HEMISPHERE_LIGHT_GROUND = 0x445566;
const HEMISPHERE_LIGHT_INTENSITY = 2.2;
const KEY_LIGHT_INTENSITY = 2.0;
const ENVIRONMENT_INTENSITY = 0.9;
const TONE_MAPPING_EXPOSURE = 1.1;
const DAMPING_FACTOR = 0.08;
const AUTO_ROTATE_SPEED = 1.6;
/** 单个几何最多采样多少顶点,避免超大模型在打开瞬间卡住主线程。 */
const MAX_FIT_SAMPLES_PER_GEOMETRY = 200_000;
export type Model3dViewerScene = {
resetView(): void;
setAutoRotate(enabled: boolean): void;
dispose(): void;
};
export type Model3dViewerSceneInput = {
canvas: HTMLCanvasElement;
container: HTMLElement;
source: Model3DViewerSource;
autoRotate: boolean;
isAborted: () => boolean;
};
/** 释放模型与场景里所有几何、材质、贴图,避免反复开关时显存只涨不降。 */
export function disposeModel3dViewerObject(object: ThreeTypes.Object3D) {
object.traverse((child) => {
const mesh = child as ThreeTypes.Mesh;
mesh.geometry?.dispose?.();
const material = mesh.material;
const materials = Array.isArray(material)
? material
: material
? [material]
: [];
for (const entry of materials) {
for (const value of Object.values(
entry as unknown as Record<string, unknown>,
)) {
const texture = value as
| { isTexture?: boolean; dispose?: () => void }
| undefined;
if (texture?.isTexture && typeof texture.dispose === 'function') {
texture.dispose();
}
}
(entry as { dispose?: () => void }).dispose?.();
}
});
}
/** 把模型真实顶点按世界坐标喂给取景求解器,避免包围球/包围盒空角带来的过度取景。 */
function feedModel3dViewerFitSolver(
object: ThreeTypes.Object3D,
add: (x: number, y: number, z: number) => void,
) {
const readElement = (source: ArrayLike<number>, index: number) =>
source[index] ?? 0;
object.updateMatrixWorld(true);
object.traverse((child) => {
const mesh = child as ThreeTypes.Mesh;
const position = mesh.geometry?.attributes?.position;
if (!position || position.count === 0) {
return;
}
const elements = mesh.matrixWorld.elements;
const m0 = readElement(elements, 0);
const m1 = readElement(elements, 1);
const m2 = readElement(elements, 2);
const m4 = readElement(elements, 4);
const m5 = readElement(elements, 5);
const m6 = readElement(elements, 6);
const m8 = readElement(elements, 8);
const m9 = readElement(elements, 9);
const m10 = readElement(elements, 10);
const m12 = readElement(elements, 12);
const m13 = readElement(elements, 13);
const m14 = readElement(elements, 14);
const push = (index: number) => {
const x = position.getX(index);
const y = position.getY(index);
const z = position.getZ(index);
add(
m0 * x + m4 * y + m8 * z + m12,
m1 * x + m5 * y + m9 * z + m13,
m2 * x + m6 * y + m10 * z + m14,
);
};
const stride = Math.max(
1,
Math.ceil(position.count / MAX_FIT_SAMPLES_PER_GEOMETRY),
);
for (let index = 0; index < position.count; index += stride) {
push(index);
}
if (stride > 1) {
// 抽样可能漏掉端点的极值,补最后一个顶点。
push(position.count - 1);
}
});
}
/**
* 创建 WebGL2 交互场景:加载模型、归一化取景、轨道操作、按需释放。
* 未挂载即完成加载时(React StrictMode 双调用)整体丢弃并释放,不留下第二个上下文。
*/
export async function createModel3dViewerScene(
input: Model3dViewerSceneInput,
): Promise<Model3dViewerScene> {
const { canvas, container, source, isAborted } = input;
const [THREE, orbitControlsModule] = await Promise.all([
import('three'),
import('three/examples/jsm/controls/OrbitControls.js'),
]);
let renderer: ThreeTypes.WebGLRenderer | null = null;
let scene: ThreeTypes.Scene | null = null;
let model: ThreeTypes.Object3D | null = null;
let environmentTexture: ThreeTypes.Texture | null = null;
let pmremGenerator: ThreeTypes.PMREMGenerator | null = null;
let roomEnvironment: ThreeTypes.Scene | null = null;
let controls: { dispose: () => void } | null = null;
let resizeObserver: ResizeObserver | null = null;
let frameHandle = 0;
let disposed = false;
function teardown() {
if (disposed) {
return;
}
disposed = true;
if (frameHandle !== 0) {
window.cancelAnimationFrame(frameHandle);
frameHandle = 0;
}
resizeObserver?.disconnect();
resizeObserver = null;
controls?.dispose();
controls = null;
if (model) {
disposeModel3dViewerObject(model);
model = null;
}
environmentTexture?.dispose();
environmentTexture = null;
pmremGenerator?.dispose();
pmremGenerator = null;
if (roomEnvironment) {
disposeModel3dViewerObject(roomEnvironment);
roomEnvironment = null;
}
if (renderer) {
renderer.dispose();
try {
renderer.forceContextLoss();
} catch {
// 上下文已经丢失时忽略,释放流程继续。
}
renderer = null;
}
}
function abortIfNeeded() {
if (!isAborted()) {
return;
}
teardown();
throw new Model3dViewerAbortedError();
}
try {
renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
});
} catch (error) {
throw new Model3dViewerLoadError(
'webgl-unavailable',
`WebGL2 上下文不可用:${describeModel3dViewerError(error)}`,
);
}
scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
MODEL3D_VIEWER_CAMERA_FOV_DEGREES,
1,
0.01,
1000,
);
renderer.setPixelRatio(
Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO),
);
renderer.toneMapping = THREE.NeutralToneMapping;
renderer.toneMappingExposure = TONE_MAPPING_EXPOSURE;
const { OrbitControls } = orbitControlsModule;
const orbitControls = new OrbitControls(camera, canvas);
controls = orbitControls;
orbitControls.enableDamping = true;
orbitControls.dampingFactor = DAMPING_FACTOR;
orbitControls.screenSpacePanning = true;
orbitControls.autoRotate = input.autoRotate;
orbitControls.autoRotateSpeed = AUTO_ROTATE_SPEED;
scene.add(
new THREE.HemisphereLight(
HEMISPHERE_LIGHT_SKY,
HEMISPHERE_LIGHT_GROUND,
HEMISPHERE_LIGHT_INTENSITY,
),
);
const keyLight = new THREE.DirectionalLight(0xffffff, KEY_LIGHT_INTENSITY);
keyLight.position.set(2, 3, 4);
scene.add(keyLight);
try {
const { RoomEnvironment } = await import(
'three/examples/jsm/environments/RoomEnvironment.js'
);
roomEnvironment = new RoomEnvironment();
pmremGenerator = new THREE.PMREMGenerator(renderer);
environmentTexture = pmremGenerator.fromScene(
roomEnvironment,
0.04,
).texture;
scene.environment = environmentTexture;
scene.environmentIntensity = ENVIRONMENT_INTENSITY;
} catch {
// 环境贴图只影响材质观感,取不到时退化为纯光照渲染。
roomEnvironment = null;
}
abortIfNeeded();
const model3d = await loadModel3dViewerObject(source, isAborted).catch(
(error: unknown) => {
teardown();
throw error;
},
);
model = model3d;
scene.add(model3d);
abortIfNeeded();
const bounds = new THREE.Box3().setFromObject(model3d);
const size = measureViewport();
const fitBox = {
min: { x: bounds.min.x, y: bounds.min.y, z: bounds.min.z },
max: { x: bounds.max.x, y: bounds.max.y, z: bounds.max.z },
};
const fitCenter = computeModel3dViewerBoxCenter(fitBox);
const fitSolver = createModel3dViewerFitSolver({
center: fitCenter,
aspect: size.aspect,
fovDegrees: MODEL3D_VIEWER_CAMERA_FOV_DEGREES,
fallbackRadius: computeModel3dViewerBoxRadius(fitBox),
});
feedModel3dViewerFitSolver(model3d, (x, y, z) => fitSolver.add(x, y, z));
const framing = computeModel3dViewerFraming({
box: fitBox,
aspect: size.aspect,
fovDegrees: MODEL3D_VIEWER_CAMERA_FOV_DEGREES,
distance: fitSolver.resolve(),
});
function measureViewport(): {
width: number;
height: number;
aspect: number;
} {
const rect = container.getBoundingClientRect();
const width = Math.max(Math.round(rect.width), 0);
const height = Math.max(Math.round(rect.height), 0);
return {
width,
height,
aspect: width > 0 && height > 0 ? width / height : 1,
};
}
function applyViewport() {
const current = measureViewport();
if (current.width === 0 || current.height === 0 || !renderer) {
return;
}
renderer.setSize(current.width, current.height, false);
camera.aspect = current.aspect;
camera.updateProjectionMatrix();
}
function resetView() {
camera.near = framing.near;
camera.far = framing.far;
camera.updateProjectionMatrix();
orbitControls.minDistance = framing.minDistance;
orbitControls.maxDistance = framing.maxDistance;
orbitControls.target.set(
framing.center.x,
framing.center.y,
framing.center.z,
);
camera.position.set(
framing.center.x + framing.resetDirection.x * framing.distance,
framing.center.y + framing.resetDirection.y * framing.distance,
framing.center.z + framing.resetDirection.z * framing.distance,
);
orbitControls.update();
}
resetView();
applyViewport();
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => {
applyViewport();
});
resizeObserver.observe(container);
}
function renderFrame() {
if (disposed || !renderer || !scene) {
return;
}
frameHandle = window.requestAnimationFrame(renderFrame);
orbitControls.update();
renderer.render(scene, camera);
}
frameHandle = window.requestAnimationFrame(renderFrame);
return {
resetView,
setAutoRotate(enabled: boolean) {
orbitControls.autoRotate = enabled;
},
dispose: teardown,
};
}
+26
View File
@@ -0,0 +1,26 @@
export type Model3DViewerStatus = 'idle' | 'loading' | 'ready' | 'failed';
export type Model3dViewerFailureReason =
| 'unsupported-format'
| 'too-large'
| 'load-failed'
| 'webgl-unavailable';
export type Model3dViewerStatusDetail = {
reason?: Model3dViewerFailureReason;
message?: string;
byteLength?: number;
};
/** 容器上的状态属性,宿主与测试按它判定渲染结果。 */
export const MODEL3D_VIEWER_STATUS_ATTRIBUTE = 'data-3d-viewer-status';
export const MODEL3D_VIEWER_LOADING_TEXT = '加载中';
export const MODEL3D_VIEWER_ERROR_TEXT = '模型加载失败';
/** 失败时只渲染一行状态文案:宿主可覆盖,空串回落到内置文案。 */
export function resolveModel3dViewerErrorText(value?: string | null): string {
const trimmed = value?.trim();
return trimmed ? trimmed : MODEL3D_VIEWER_ERROR_TEXT;
}
+2
View File
@@ -13,6 +13,7 @@ export const REQUIRED_WORKSPACES = Object.freeze([
'packages/image-canvas-core',
'packages/image-canvas-react',
'packages/agc-plugin-sdk',
'packages/model3d-viewer',
'packages/shared',
'plugins/agc-cocos-editor',
'tools/spine-json-export-validator',
@@ -27,6 +28,7 @@ const WORKSPACE_NAMES = Object.freeze({
'packages/image-canvas-core': '@genarrative/image-canvas-core',
'packages/image-canvas-react': '@genarrative/image-canvas-react',
'packages/agc-plugin-sdk': '@genarrative/agc-plugin-sdk',
'packages/model3d-viewer': '@genarrative/model3d-viewer',
'packages/shared': '@genarrative/shared',
'plugins/agc-cocos-editor': '@genarrative/agc-plugin-cocos-editor',
'tools/spine-json-export-validator':
+1
View File
@@ -21,6 +21,7 @@ const workspaceNames = {
'packages/image-canvas-core': '@genarrative/image-canvas-core',
'packages/image-canvas-react': '@genarrative/image-canvas-react',
'packages/agc-plugin-sdk': '@genarrative/agc-plugin-sdk',
'packages/model3d-viewer': '@genarrative/model3d-viewer',
'packages/shared': '@genarrative/shared',
'plugins/agc-cocos-editor': '@genarrative/agc-plugin-cocos-editor',
'tools/spine-json-export-validator':
+2
View File
@@ -142,6 +142,8 @@ export default defineConfig({
'packages/image-canvas-react/src/**/*.test.ts',
'packages/image-canvas-react/src/**/*.test.tsx',
'packages/agc-plugin-sdk/src/**/*.test.ts',
'packages/model3d-viewer/src/**/*.test.ts',
'packages/model3d-viewer/src/**/*.test.tsx',
],
exclude: ['scripts/loadtest/**'],
},