f4083dcb6d
- loader 新增 resolveModel3dViewerSourceQuery / resolveModel3dViewerResourceUrl,只补模型目录下的资源地址 - glTF / FBX 解析改用带 URL 修饰器的专用 LoadingManager,不动全局 DefaultLoadingManager - 补测试:query 提取与片段剔除、目录内外的补与不补、多文件 glTF 兄弟资源解析
398 lines
13 KiB
TypeScript
398 lines
13 KiB
TypeScript
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 }
|
||
/**
|
||
* 直接给模型字节;调用方负责持有并与生命周期一致地释放这份 buffer。
|
||
*
|
||
* **引用必须稳定**:查看器的加载 effect 依赖 `data` 的对象引用,只比对内容。
|
||
* 父层每次渲染都新建一份同内容的 ArrayBuffer 会触发「销毁旧场景 → 重新解析 →
|
||
* 重新上传 GPU」,表现为闪烁与资源抖动;因此请在父层缓存 buffer,或只在内容
|
||
* 真的变化时才换新引用。
|
||
*/
|
||
| { kind: 'bytes'; data: ArrayBuffer; format?: Model3DViewerFormat };
|
||
|
||
export const MODEL3D_VIEWER_SUPPORTED_FORMATS: readonly Model3DViewerFormat[] =
|
||
['glb', 'gltf', 'fbx'];
|
||
|
||
/**
|
||
* 声明类型里的模型格式判据。
|
||
*
|
||
* 声明来自对象自己的内容类型:read-bytes 响应头、OSS 对象 metadata 或
|
||
* `asset_object.content_type`,它们都是同一份值。provider 仍会写别的拼法
|
||
* (`model/gltf+json`、`application/x-fbx`、`binary/octet-stream`),所以按子串匹配。
|
||
*/
|
||
const MODEL3D_VIEWER_MIME_MARKERS: ReadonlyArray<
|
||
readonly [marker: string, format: Model3DViewerFormat]
|
||
> = [
|
||
['gltf-binary', 'glb'],
|
||
['glb', 'glb'],
|
||
['gltf', 'gltf'],
|
||
['fbx', 'fbx'],
|
||
];
|
||
|
||
const MODEL3D_VIEWER_GLB_MAGIC = [0x67, 0x6c, 0x54, 0x46];
|
||
const MODEL3D_VIEWER_FBX_MAGIC = [
|
||
0x4b, 0x61, 0x79, 0x64, 0x61, 0x72, 0x61, 0x20, 0x46, 0x42, 0x58, 0x20, 0x42,
|
||
0x69, 0x6e, 0x61, 0x72, 0x79,
|
||
];
|
||
const MODEL3D_VIEWER_SUPPORTED_FORMAT_TEXT =
|
||
MODEL3D_VIEWER_SUPPORTED_FORMATS.join(' / ');
|
||
|
||
function matchesModel3dViewerMagic(
|
||
bytes: Uint8Array,
|
||
magic: readonly number[],
|
||
) {
|
||
if (bytes.length < magic.length) {
|
||
return false;
|
||
}
|
||
return magic.every((value, index) => bytes[index] === value);
|
||
}
|
||
|
||
function isModel3dViewerJsonStart(bytes: Uint8Array) {
|
||
for (const byte of bytes) {
|
||
// BOM 与空白之后是 `{` 才当 glTF JSON:多文件 glTF 的正文就是一个 JSON 文档。
|
||
if (byte === 0xef || byte === 0xbb || byte === 0xbf) {
|
||
continue;
|
||
}
|
||
if (byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d) {
|
||
continue;
|
||
}
|
||
return byte === 0x7b;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** 声明类型里的模型格式;不是可渲染格式时返回 null。 */
|
||
export function resolveModel3dViewerFormatFromMimeType(
|
||
declaredType: string | null | undefined,
|
||
): Model3DViewerFormat | null {
|
||
const normalized = declaredType?.split(';')[0]?.trim().toLowerCase() ?? '';
|
||
if (!normalized) {
|
||
return null;
|
||
}
|
||
return (
|
||
MODEL3D_VIEWER_MIME_MARKERS.find(([marker]) =>
|
||
normalized.includes(marker),
|
||
)?.[1] ?? null
|
||
);
|
||
}
|
||
|
||
/** 按字节魔数识别格式;声明不可信时以它为准,识别不出返回 null。 */
|
||
export function resolveModel3dViewerFormatFromBytes(
|
||
bytes: ArrayBuffer | Uint8Array,
|
||
): Model3DViewerFormat | null {
|
||
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||
if (matchesModel3dViewerMagic(view, MODEL3D_VIEWER_GLB_MAGIC)) {
|
||
return 'glb';
|
||
}
|
||
if (matchesModel3dViewerMagic(view, MODEL3D_VIEWER_FBX_MAGIC)) {
|
||
return 'fbx';
|
||
}
|
||
if (isModel3dViewerJsonStart(view)) {
|
||
return 'gltf';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 地址扩展名里的模型格式;它只是派生线索,不是格式真相。 */
|
||
export function resolveModel3dViewerFormatFromUrl(
|
||
url: string | null | undefined,
|
||
): Model3DViewerFormat | null {
|
||
const withoutQuery =
|
||
(url?.trim() ?? '').split(/[?#]/u)[0]?.toLowerCase() ?? '';
|
||
const match = /\.([a-z0-9]+)$/u.exec(withoutQuery);
|
||
const extension = match?.[1] ?? null;
|
||
return isModel3dViewerFormatSupported(extension) ? extension : null;
|
||
}
|
||
|
||
/**
|
||
* 模型格式的判定顺序:声明 → 字节魔数 → 地址扩展名。
|
||
*
|
||
* 声明(响应内容类型 / OSS metadata / `asset_object.content_type`)是主线索,魔数兜住
|
||
* 写歪的声明,扩展名是同源派生出来的最后一条线索。三处都判不出就返回 null,由加载流程
|
||
* 报 `unsupported-format`,宿主把原因显示给用户 —— 不猜、不半渲染。
|
||
*/
|
||
export function resolveModel3dViewerFormat({
|
||
declaredType,
|
||
url,
|
||
bytes,
|
||
}: {
|
||
declaredType?: string | null;
|
||
url?: string | null;
|
||
bytes?: ArrayBuffer | Uint8Array | null;
|
||
}): Model3DViewerFormat | null {
|
||
const fromDeclaredType = resolveModel3dViewerFormatFromMimeType(declaredType);
|
||
if (fromDeclaredType) {
|
||
return fromDeclaredType;
|
||
}
|
||
const fromBytes = bytes ? resolveModel3dViewerFormatFromBytes(bytes) : null;
|
||
if (fromBytes) {
|
||
return fromBytes;
|
||
}
|
||
return resolveModel3dViewerFormatFromUrl(url);
|
||
}
|
||
|
||
export function isModel3dViewerFormatSupported(
|
||
value: unknown,
|
||
): value is Model3DViewerFormat {
|
||
return MODEL3D_VIEWER_SUPPORTED_FORMATS.includes(
|
||
value as Model3DViewerFormat,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 单模型可渲染上限;超过后整体失败,不做半渲染。
|
||
*
|
||
* 64 MiB 而不是更小:3D 生成产物带 HD 纹理时本来就在几十 MB 量级,卡在 32 MiB
|
||
* 会让一整档结果永远预览不到;而完整读入后送进 WebGL 的内存代价仍在可控范围。
|
||
*/
|
||
export const MODEL3D_VIEWER_MAX_MODEL_BYTES = 64 * 1024 * 1024;
|
||
|
||
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(顺带取响应的 `Content-Type`,它就是对象
|
||
* 自己的内容类型),bytes 直接复用调用方已读入的字节。
|
||
*
|
||
* 宿主给了 `signal` 时把它交给 fetch:宿主取消(卸载 / 换源)后立刻停掉在途下载,
|
||
* 不再把整份字节(上限 64 MiB)读完才判断是否已取消。
|
||
*/
|
||
export async function readModel3dViewerSource(
|
||
source: Model3DViewerSource,
|
||
signal?: AbortSignal,
|
||
): Promise<{ data: ArrayBuffer; declaredType: string | null }> {
|
||
if (source.kind === 'bytes') {
|
||
assertModel3dViewerByteLength(source.data.byteLength);
|
||
return { data: source.data, declaredType: null };
|
||
}
|
||
|
||
let response: Response;
|
||
try {
|
||
response = await fetch(source.url, signal ? { signal } : undefined);
|
||
} catch (error) {
|
||
if (signal?.aborted) {
|
||
throw new Model3dViewerAbortedError();
|
||
}
|
||
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);
|
||
}
|
||
|
||
let data: ArrayBuffer;
|
||
try {
|
||
data = await response.arrayBuffer();
|
||
} catch (error) {
|
||
if (signal?.aborted) {
|
||
throw new Model3dViewerAbortedError();
|
||
}
|
||
throw new Model3dViewerLoadError(
|
||
'load-failed',
|
||
`模型数据读取失败:${describeModel3dViewerError(error)}`,
|
||
);
|
||
}
|
||
assertModel3dViewerByteLength(data.byteLength);
|
||
return { data, declaredType: response.headers.get('content-type') };
|
||
}
|
||
|
||
function resolveModel3dViewerBasePath(source: Model3DViewerSource): string {
|
||
if (source.kind !== 'url') {
|
||
return '';
|
||
}
|
||
const separatorIndex = source.url.lastIndexOf('/');
|
||
return separatorIndex >= 0 ? source.url.slice(0, separatorIndex + 1) : '';
|
||
}
|
||
|
||
/**
|
||
* 模型地址 query 里的鉴权参数(含 `?`,丢掉 `#` 之后的部分);没有 query 返回 null。
|
||
*
|
||
* 宿主给的多半是私有对象的签名地址(见 `getSignedAssetReadUrl`),token 就在 query 里。
|
||
*/
|
||
export function resolveModel3dViewerSourceQuery(
|
||
url: string | null | undefined,
|
||
): string | null {
|
||
const value = url?.trim() ?? '';
|
||
const queryIndex = value.indexOf('?');
|
||
if (queryIndex < 0) {
|
||
return null;
|
||
}
|
||
const query = value.slice(queryIndex + 1).split('#')[0] ?? '';
|
||
return query ? `?${query}` : null;
|
||
}
|
||
|
||
/**
|
||
* 给模型目录下的兄弟资源地址补回模型地址的鉴权 query。
|
||
*
|
||
* `GLTFLoader` / `FBXLoader` 用 `path + uri` 拼多文件模型的兄弟 buffer 与贴图地址,
|
||
* 地址落在模型目录里,但签名 token 在这一步丢掉了(目录本身不带 query),私有对象
|
||
* 于是 401/403。这里只补模型目录下的地址:既不把 token 外发到第三方 CDN,也不把
|
||
* 「为某个对象签出的 token」贴到同源的另一个对象上(OSS 会以 SignatureDoesNotMatch
|
||
* 拒绝),资源地址自己带的 query 也保留。
|
||
*/
|
||
export function resolveModel3dViewerResourceUrl(input: {
|
||
url: string;
|
||
/** 模型地址所在目录,由 `resolveModel3dViewerBasePath` 得到;空串表示没有目录可补。 */
|
||
basePath: string;
|
||
/** 模型地址的鉴权 query,由 `resolveModel3dViewerSourceQuery` 得到。 */
|
||
query: string;
|
||
}): string {
|
||
const { url, basePath, query } = input;
|
||
if (!url || !basePath || !url.startsWith(basePath)) {
|
||
return url;
|
||
}
|
||
const hashIndex = url.indexOf('#');
|
||
const withoutHash = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
|
||
const hash = hashIndex >= 0 ? url.slice(hashIndex) : '';
|
||
const separator = withoutHash.includes('?') ? '&' : '?';
|
||
return `${withoutHash}${separator}${query.slice(1)}${hash}`;
|
||
}
|
||
|
||
/**
|
||
* 解析模型字节为 three 对象。glb / 单文件 gltf 走 GLTFLoader,fbx 走 FBXLoader;
|
||
* 多文件 gltf 的兄弟资源按模型地址目录解析,取不到时按加载失败处理。
|
||
*/
|
||
export async function loadModel3dViewerObject(
|
||
source: Model3DViewerSource,
|
||
isAborted: () => boolean = () => false,
|
||
signal?: AbortSignal,
|
||
): Promise<ThreeTypes.Object3D> {
|
||
const { data, declaredType } = await readModel3dViewerSource(source, signal);
|
||
if (isAborted()) {
|
||
throw new Model3dViewerAbortedError();
|
||
}
|
||
|
||
// 调用方声明的格式就是最明确的一层声明;没给才落回「响应内容类型 → 魔数 → 扩展名」。
|
||
const format = isModel3dViewerFormatSupported(source.format)
|
||
? source.format
|
||
: resolveModel3dViewerFormat({
|
||
declaredType,
|
||
url: source.kind === 'url' ? source.url : null,
|
||
bytes: data,
|
||
});
|
||
if (!format) {
|
||
throw new Model3dViewerLoadError(
|
||
'unsupported-format',
|
||
`无法识别模型格式,只支持 ${MODEL3D_VIEWER_SUPPORTED_FORMAT_TEXT}`,
|
||
);
|
||
}
|
||
|
||
const basePath = resolveModel3dViewerBasePath(source);
|
||
const resourceQuery =
|
||
source.kind === 'url' ? resolveModel3dViewerSourceQuery(source.url) : null;
|
||
|
||
/**
|
||
* 兄弟资源(buffer / 贴图)的加载管理器:模型地址带 query 时才建,把同一份鉴权
|
||
* query 补到模型目录下的资源地址上。用专用管理器而不是默认的那个,避免改动全局
|
||
* `DefaultLoadingManager` 影响宿主其它加载器。
|
||
*/
|
||
async function createResourceLoadingManager() {
|
||
if (!resourceQuery || !basePath) {
|
||
return undefined;
|
||
}
|
||
const { LoadingManager } = await import('three');
|
||
const manager = new LoadingManager();
|
||
manager.setURLModifier((url: string) =>
|
||
resolveModel3dViewerResourceUrl({
|
||
url,
|
||
basePath,
|
||
query: resourceQuery,
|
||
}),
|
||
);
|
||
return manager;
|
||
}
|
||
|
||
try {
|
||
if (format === 'fbx') {
|
||
const { FBXLoader } = await import(
|
||
'three/examples/jsm/loaders/FBXLoader.js'
|
||
);
|
||
const manager = await createResourceLoadingManager();
|
||
return new FBXLoader(manager).parse(data, basePath);
|
||
}
|
||
|
||
const { GLTFLoader } = await import(
|
||
'three/examples/jsm/loaders/GLTFLoader.js'
|
||
);
|
||
const loader = new GLTFLoader(await createResourceLoadingManager());
|
||
const payload: string | ArrayBuffer =
|
||
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)}`,
|
||
);
|
||
}
|
||
}
|