Files
Genarrative/packages/model3d-viewer/src/loader.ts
T
k88936 f4083dcb6d 3D 查看器给签名地址的兄弟资源补回鉴权 query
- loader 新增 resolveModel3dViewerSourceQuery / resolveModel3dViewerResourceUrl,只补模型目录下的资源地址
- glTF / FBX 解析改用带 URL 修饰器的专用 LoadingManager,不动全局 DefaultLoadingManager
- 补测试:query 提取与片段剔除、目录内外的补与不补、多文件 glTF 兄弟资源解析
2026-09-23 11:48:48 +08:00

398 lines
13 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 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)}`,
);
}
}