feat(3d): FBX/GLTF/OBJ 加载器与骨骼动画支持 (#315)
* feat(3d): FBX/GLTF/OBJ 加载器与骨骼动画支持 * chore: 更新 pnpm-lock.yaml * fix: 移除未使用的变量和方法 * fix: 修复 mesh-3d-editor tsconfig 引用路径 * fix: 修复正则表达式 ReDoS 漏洞
This commit is contained in:
@@ -30,9 +30,9 @@
|
||||
"author": "yhh",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@esengine/build-config": "workspace:*",
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/build-config": "workspace:*",
|
||||
"rimraf": "^5.0.0",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.8.3"
|
||||
@@ -44,5 +44,9 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/esengine/esengine.git",
|
||||
"directory": "packages/asset-system"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/pako": "^2.0.4",
|
||||
"pako": "^2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +176,11 @@ export class AssetManager implements IAssetManager {
|
||||
}
|
||||
|
||||
// 创建加载器 / Create loader
|
||||
let loader = this._loaderFactory.createLoader(metadata.type);
|
||||
// 优先使用基于路径的加载器选择,支持多个加载器对应同一资产类型
|
||||
// 例如 Model3D 类型支持 GLTF/FBX/OBJ,根据扩展名选择正确的加载器
|
||||
// Prefer path-based loader selection, supports multiple loaders for same asset type
|
||||
// e.g., Model3D type supports GLTF/FBX/OBJ, selects correct loader by extension
|
||||
let loader = this._loaderFactory.createLoaderForPath(metadata.path);
|
||||
|
||||
// 如果没有找到 loader 且类型是 Custom,尝试重新解析类型
|
||||
// If no loader found and type is Custom, try to re-resolve the type
|
||||
|
||||
@@ -57,6 +57,11 @@ export { BinaryLoader } from './loaders/BinaryLoader';
|
||||
export { AudioLoader } from './loaders/AudioLoader';
|
||||
export { PrefabLoader } from './loaders/PrefabLoader';
|
||||
|
||||
// 3D Model Loaders | 3D 模型加载器
|
||||
export { GLTFLoader } from './loaders/GLTFLoader';
|
||||
export { OBJLoader } from './loaders/OBJLoader';
|
||||
export { FBXLoader } from './loaders/FBXLoader';
|
||||
|
||||
// Integration
|
||||
export { EngineIntegration } from './integration/EngineIntegration';
|
||||
export type { ITextureEngineBridge, TextureLoadCallback } from './integration/EngineIntegration';
|
||||
|
||||
@@ -80,12 +80,29 @@ export interface IAssetLoaderFactory {
|
||||
*/
|
||||
createLoader(type: AssetType): IAssetLoader | null;
|
||||
|
||||
/**
|
||||
* Create loader for a specific file path (selects by extension)
|
||||
* 为特定文件路径创建加载器(按扩展名选择)
|
||||
*
|
||||
* This method is preferred over createLoader() when multiple loaders
|
||||
* support the same asset type (e.g., Model3D with GLTF/OBJ/FBX).
|
||||
* 当多个加载器支持相同资产类型时(如 Model3D 的 GLTF/OBJ/FBX),
|
||||
* 优先使用此方法而非 createLoader()。
|
||||
*/
|
||||
createLoaderForPath(path: string): IAssetLoader | null;
|
||||
|
||||
/**
|
||||
* Register custom loader
|
||||
* 注册自定义加载器
|
||||
*/
|
||||
registerLoader(type: AssetType, loader: IAssetLoader): void;
|
||||
|
||||
/**
|
||||
* Register a loader for a specific file extension
|
||||
* 为特定文件扩展名注册加载器
|
||||
*/
|
||||
registerExtensionLoader(extension: string, loader: IAssetLoader): void;
|
||||
|
||||
/**
|
||||
* Unregister loader
|
||||
* 注销加载器
|
||||
@@ -365,3 +382,235 @@ export interface IBinaryAsset {
|
||||
/** MIME类型 / MIME type */
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
// ===== GLTF/GLB 3D Model Types =====
|
||||
// ===== GLTF/GLB 3D 模型类型 =====
|
||||
|
||||
/**
|
||||
* Bounding box interface
|
||||
* 边界盒接口
|
||||
*/
|
||||
export interface IBoundingBox {
|
||||
/** 最小坐标 [x, y, z] | Minimum coordinates */
|
||||
min: [number, number, number];
|
||||
/** 最大坐标 [x, y, z] | Maximum coordinates */
|
||||
max: [number, number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended mesh data with name and material reference
|
||||
* 扩展的网格数据,包含名称和材质引用
|
||||
*/
|
||||
export interface IMeshData extends IMeshAsset {
|
||||
/** 网格名称 | Mesh name */
|
||||
name: string;
|
||||
/** 引用的材质索引 | Referenced material index */
|
||||
materialIndex: number;
|
||||
/** 顶点颜色(如果有)| Vertex colors if available */
|
||||
colors?: Float32Array;
|
||||
|
||||
// ===== Skinning data for skeletal animation =====
|
||||
// ===== 骨骼动画蒙皮数据 =====
|
||||
|
||||
/**
|
||||
* Joint indices per vertex (4 influences, GLTF JOINTS_0)
|
||||
* 每顶点的关节索引(4 个影响,GLTF JOINTS_0)
|
||||
* Format: [j0, j1, j2, j3] for each vertex
|
||||
*/
|
||||
joints?: Uint8Array | Uint16Array;
|
||||
|
||||
/**
|
||||
* Joint weights per vertex (4 influences, GLTF WEIGHTS_0)
|
||||
* 每顶点的关节权重(4 个影响,GLTF WEIGHTS_0)
|
||||
* Format: [w0, w1, w2, w3] for each vertex, should sum to 1.0
|
||||
*/
|
||||
weights?: Float32Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* GLTF material definition
|
||||
* GLTF 材质定义
|
||||
*/
|
||||
export interface IGLTFMaterial {
|
||||
/** 材质名称 | Material name */
|
||||
name: string;
|
||||
/** 基础颜色 [r, g, b, a] | Base color factor */
|
||||
baseColorFactor: [number, number, number, number];
|
||||
/** 基础颜色纹理索引 | Base color texture index (-1 if none) */
|
||||
baseColorTextureIndex: number;
|
||||
/** 金属度 (0-1) | Metallic factor */
|
||||
metallicFactor: number;
|
||||
/** 粗糙度 (0-1) | Roughness factor */
|
||||
roughnessFactor: number;
|
||||
/** 金属粗糙度纹理索引 | Metallic-roughness texture index */
|
||||
metallicRoughnessTextureIndex: number;
|
||||
/** 法线纹理索引 | Normal texture index */
|
||||
normalTextureIndex: number;
|
||||
/** 法线缩放 | Normal scale */
|
||||
normalScale: number;
|
||||
/** 遮挡纹理索引 | Occlusion texture index */
|
||||
occlusionTextureIndex: number;
|
||||
/** 遮挡强度 | Occlusion strength */
|
||||
occlusionStrength: number;
|
||||
/** 自发光因子 [r, g, b] | Emissive factor */
|
||||
emissiveFactor: [number, number, number];
|
||||
/** 自发光纹理索引 | Emissive texture index */
|
||||
emissiveTextureIndex: number;
|
||||
/** Alpha 模式 | Alpha mode */
|
||||
alphaMode: 'OPAQUE' | 'MASK' | 'BLEND';
|
||||
/** Alpha 剔除阈值 | Alpha cutoff */
|
||||
alphaCutoff: number;
|
||||
/** 是否双面 | Double sided */
|
||||
doubleSided: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* GLTF texture info
|
||||
* GLTF 纹理信息
|
||||
*/
|
||||
export interface IGLTFTextureInfo {
|
||||
/** 纹理名称 | Texture name */
|
||||
name?: string;
|
||||
/** 图像数据(嵌入式)| Image data (embedded) */
|
||||
imageData?: ArrayBuffer;
|
||||
/** 图像 MIME 类型 | Image MIME type */
|
||||
mimeType?: string;
|
||||
/** 外部 URI(非嵌入)| External URI (non-embedded) */
|
||||
uri?: string;
|
||||
/** 加载后的纹理资产 GUID | Loaded texture asset GUID */
|
||||
textureGuid?: AssetGUID;
|
||||
}
|
||||
|
||||
/**
|
||||
* GLTF node (scene hierarchy)
|
||||
* GLTF 节点(场景层级)
|
||||
*/
|
||||
export interface IGLTFNode {
|
||||
/** 节点名称 | Node name */
|
||||
name: string;
|
||||
/** 网格索引(可选)| Mesh index (optional) */
|
||||
meshIndex?: number;
|
||||
/** 子节点索引列表 | Child node indices */
|
||||
children: number[];
|
||||
/** 变换信息 | Transform info */
|
||||
transform: {
|
||||
/** 位置 [x, y, z] | Position */
|
||||
position: [number, number, number];
|
||||
/** 旋转四元数 [x, y, z, w] | Rotation quaternion */
|
||||
rotation: [number, number, number, number];
|
||||
/** 缩放 [x, y, z] | Scale */
|
||||
scale: [number, number, number];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Animation channel target
|
||||
* 动画通道目标
|
||||
*/
|
||||
export interface IAnimationChannelTarget {
|
||||
/** 目标节点索引 | Target node index */
|
||||
nodeIndex: number;
|
||||
/** 目标属性 | Target property */
|
||||
path: 'translation' | 'rotation' | 'scale' | 'weights';
|
||||
}
|
||||
|
||||
/**
|
||||
* Animation sampler
|
||||
* 动画采样器
|
||||
*/
|
||||
export interface IAnimationSampler {
|
||||
/** 输入时间数组 | Input time array */
|
||||
input: Float32Array;
|
||||
/** 输出值数组 | Output values array */
|
||||
output: Float32Array;
|
||||
/** 插值类型 | Interpolation type */
|
||||
interpolation: 'LINEAR' | 'STEP' | 'CUBICSPLINE';
|
||||
}
|
||||
|
||||
/**
|
||||
* Animation channel
|
||||
* 动画通道
|
||||
*/
|
||||
export interface IAnimationChannel {
|
||||
/** 采样器索引 | Sampler index */
|
||||
samplerIndex: number;
|
||||
/** 目标 | Target */
|
||||
target: IAnimationChannelTarget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animation clip from GLTF
|
||||
* GLTF 动画片段
|
||||
*/
|
||||
export interface IGLTFAnimationClip {
|
||||
/** 动画名称 | Animation name */
|
||||
name: string;
|
||||
/** 动画时长(秒)| Duration in seconds */
|
||||
duration: number;
|
||||
/** 采样器列表 | Sampler list */
|
||||
samplers: IAnimationSampler[];
|
||||
/** 通道列表 | Channel list */
|
||||
channels: IAnimationChannel[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton joint
|
||||
* 骨骼关节
|
||||
*/
|
||||
export interface ISkeletonJoint {
|
||||
/** 关节名称 | Joint name */
|
||||
name: string;
|
||||
/** 节点索引 | Node index */
|
||||
nodeIndex: number;
|
||||
/** 父关节索引(-1 表示根)| Parent joint index (-1 for root) */
|
||||
parentIndex: number;
|
||||
/** 逆绑定矩阵 (4x4) | Inverse bind matrix */
|
||||
inverseBindMatrix: Float32Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton data
|
||||
* 骨骼数据
|
||||
*/
|
||||
export interface ISkeletonData {
|
||||
/** 关节列表 | Joint list */
|
||||
joints: ISkeletonJoint[];
|
||||
/** 根关节索引 | Root joint index */
|
||||
rootJointIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* GLTF/GLB 3D model asset interface
|
||||
* GLTF/GLB 3D 模型资产接口
|
||||
*/
|
||||
export interface IGLTFAsset {
|
||||
/** 模型名称 | Model name */
|
||||
name: string;
|
||||
|
||||
/** 网格数据列表 | Mesh data list */
|
||||
meshes: IMeshData[];
|
||||
|
||||
/** 材质列表 | Material list */
|
||||
materials: IGLTFMaterial[];
|
||||
|
||||
/** 纹理信息列表 | Texture info list */
|
||||
textures: IGLTFTextureInfo[];
|
||||
|
||||
/** 场景层级节点 | Scene hierarchy nodes */
|
||||
nodes: IGLTFNode[];
|
||||
|
||||
/** 根节点索引列表 | Root node indices */
|
||||
rootNodes: number[];
|
||||
|
||||
/** 动画片段列表(可选)| Animation clips (optional) */
|
||||
animations?: IGLTFAnimationClip[];
|
||||
|
||||
/** 骨骼数据(可选)| Skeleton data (optional) */
|
||||
skeleton?: ISkeletonData;
|
||||
|
||||
/** 整体边界盒 | Overall bounding box */
|
||||
bounds: IBoundingBox;
|
||||
|
||||
/** 源文件路径 | Source file path */
|
||||
sourcePath?: string;
|
||||
}
|
||||
|
||||
@@ -11,14 +11,24 @@ import { TextLoader } from './TextLoader';
|
||||
import { BinaryLoader } from './BinaryLoader';
|
||||
import { AudioLoader } from './AudioLoader';
|
||||
import { PrefabLoader } from './PrefabLoader';
|
||||
import { GLTFLoader } from './GLTFLoader';
|
||||
import { OBJLoader } from './OBJLoader';
|
||||
import { FBXLoader } from './FBXLoader';
|
||||
|
||||
/**
|
||||
* Asset loader factory
|
||||
* 资产加载器工厂
|
||||
*
|
||||
* Supports multiple loaders per asset type (selected by file extension).
|
||||
* 支持每种资产类型的多个加载器(按文件扩展名选择)。
|
||||
*/
|
||||
export class AssetLoaderFactory implements IAssetLoaderFactory {
|
||||
private readonly _loaders = new Map<AssetType, IAssetLoader>();
|
||||
|
||||
/** Extension -> Loader map for precise loader selection */
|
||||
/** 扩展名 -> 加载器映射,用于精确选择加载器 */
|
||||
private readonly _extensionLoaders = new Map<string, IAssetLoader>();
|
||||
|
||||
constructor() {
|
||||
// 注册默认加载器 / Register default loaders
|
||||
this.registerDefaultLoaders();
|
||||
@@ -47,10 +57,35 @@ export class AssetLoaderFactory implements IAssetLoaderFactory {
|
||||
// 预制体加载器 / Prefab loader
|
||||
this._loaders.set(AssetType.Prefab, new PrefabLoader());
|
||||
|
||||
// 3D模型加载器 / 3D Model loaders
|
||||
// Default is GLTF, but OBJ and FBX are also supported
|
||||
// 默认是 GLTF,但也支持 OBJ 和 FBX
|
||||
const gltfLoader = new GLTFLoader();
|
||||
const objLoader = new OBJLoader();
|
||||
const fbxLoader = new FBXLoader();
|
||||
|
||||
this._loaders.set(AssetType.Model3D, gltfLoader);
|
||||
|
||||
// Register extension-specific loaders
|
||||
// 注册特定扩展名的加载器
|
||||
this.registerExtensionLoader('.gltf', gltfLoader);
|
||||
this.registerExtensionLoader('.glb', gltfLoader);
|
||||
this.registerExtensionLoader('.obj', objLoader);
|
||||
this.registerExtensionLoader('.fbx', fbxLoader);
|
||||
|
||||
// 注:Shader 和 Material 加载器由 material-system 模块注册
|
||||
// Note: Shader and Material loaders are registered by material-system module
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a loader for a specific file extension
|
||||
* 为特定文件扩展名注册加载器
|
||||
*/
|
||||
registerExtensionLoader(extension: string, loader: IAssetLoader): void {
|
||||
const ext = extension.toLowerCase().startsWith('.') ? extension.toLowerCase() : `.${extension.toLowerCase()}`;
|
||||
this._extensionLoaders.set(ext, loader);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create loader for specific asset type
|
||||
* 为特定资产类型创建加载器
|
||||
@@ -59,6 +94,38 @@ export class AssetLoaderFactory implements IAssetLoaderFactory {
|
||||
return this._loaders.get(type) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create loader for a specific file path (selects by extension)
|
||||
* 为特定文件路径创建加载器(按扩展名选择)
|
||||
*
|
||||
* This method is preferred over createLoader() when multiple loaders
|
||||
* support the same asset type (e.g., Model3D with GLTF/OBJ/FBX).
|
||||
* 当多个加载器支持相同资产类型时(如 Model3D 的 GLTF/OBJ/FBX),
|
||||
* 优先使用此方法而非 createLoader()。
|
||||
*/
|
||||
createLoaderForPath(path: string): IAssetLoader | null {
|
||||
const lastDot = path.lastIndexOf('.');
|
||||
if (lastDot !== -1) {
|
||||
const ext = path.substring(lastDot).toLowerCase();
|
||||
|
||||
// First try extension-specific loader
|
||||
// 首先尝试特定扩展名的加载器
|
||||
const extLoader = this._extensionLoaders.get(ext);
|
||||
if (extLoader) {
|
||||
return extLoader;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to type-based lookup
|
||||
// 回退到基于类型的查找
|
||||
const type = this.getAssetTypeByPath(path);
|
||||
if (type) {
|
||||
return this.createLoader(type);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register custom loader
|
||||
* 注册自定义加载器
|
||||
@@ -92,6 +159,16 @@ export class AssetLoaderFactory implements IAssetLoaderFactory {
|
||||
*/
|
||||
getAssetTypeByExtension(extension: string): AssetType | null {
|
||||
const ext = extension.toLowerCase();
|
||||
|
||||
// Check extension-specific loaders first
|
||||
// 首先检查特定扩展名的加载器
|
||||
const extLoader = this._extensionLoaders.get(ext);
|
||||
if (extLoader) {
|
||||
return extLoader.supportedType;
|
||||
}
|
||||
|
||||
// Fall back to type-based loaders
|
||||
// 回退到基于类型的加载器
|
||||
for (const [type, loader] of this._loaders) {
|
||||
if (loader.supportedExtensions.some(e => e.toLowerCase() === ext)) {
|
||||
return type;
|
||||
@@ -159,14 +236,22 @@ export class AssetLoaderFactory implements IAssetLoaderFactory {
|
||||
getAllSupportedExtensions(): string[] {
|
||||
const extensions = new Set<string>();
|
||||
|
||||
// From type-based loaders
|
||||
// 从基于类型的加载器
|
||||
for (const loader of this._loaders.values()) {
|
||||
for (const ext of loader.supportedExtensions) {
|
||||
// 转换为 glob 模式 | Convert to glob pattern
|
||||
const cleanExt = ext.startsWith('.') ? ext.substring(1) : ext;
|
||||
extensions.add(`*.${cleanExt}`);
|
||||
}
|
||||
}
|
||||
|
||||
// From extension-specific loaders
|
||||
// 从特定扩展名的加载器
|
||||
for (const ext of this._extensionLoaders.keys()) {
|
||||
const cleanExt = ext.startsWith('.') ? ext.substring(1) : ext;
|
||||
extensions.add(`*.${cleanExt}`);
|
||||
}
|
||||
|
||||
return Array.from(extensions);
|
||||
}
|
||||
|
||||
@@ -179,6 +264,8 @@ export class AssetLoaderFactory implements IAssetLoaderFactory {
|
||||
getExtensionTypeMap(): Record<string, string> {
|
||||
const map: Record<string, string> = {};
|
||||
|
||||
// From type-based loaders
|
||||
// 从基于类型的加载器
|
||||
for (const [type, loader] of this._loaders) {
|
||||
for (const ext of loader.supportedExtensions) {
|
||||
const cleanExt = ext.startsWith('.') ? ext.substring(1) : ext;
|
||||
@@ -186,6 +273,13 @@ export class AssetLoaderFactory implements IAssetLoaderFactory {
|
||||
}
|
||||
}
|
||||
|
||||
// From extension-specific loaders
|
||||
// 从特定扩展名的加载器
|
||||
for (const [ext, loader] of this._extensionLoaders) {
|
||||
const cleanExt = ext.startsWith('.') ? ext.substring(1) : ext;
|
||||
map[cleanExt.toLowerCase()] = loader.supportedType;
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
2193
packages/asset-system/src/loaders/FBXLoader.ts
Normal file
2193
packages/asset-system/src/loaders/FBXLoader.ts
Normal file
File diff suppressed because it is too large
Load Diff
994
packages/asset-system/src/loaders/GLTFLoader.ts
Normal file
994
packages/asset-system/src/loaders/GLTFLoader.ts
Normal file
@@ -0,0 +1,994 @@
|
||||
/**
|
||||
* GLTF/GLB model loader implementation
|
||||
* GLTF/GLB 模型加载器实现
|
||||
*
|
||||
* Supports:
|
||||
* - GLTF 2.0 (.gltf with external/embedded resources)
|
||||
* - GLB (.glb binary format)
|
||||
* - PBR materials
|
||||
* - Scene hierarchy
|
||||
* - Animations (basic)
|
||||
* - Skinning (basic)
|
||||
*/
|
||||
|
||||
import { AssetType } from '../types/AssetTypes';
|
||||
import type { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
|
||||
import type {
|
||||
IAssetLoader,
|
||||
IAssetParseContext,
|
||||
IGLTFAsset,
|
||||
IMeshData,
|
||||
IGLTFMaterial,
|
||||
IGLTFTextureInfo,
|
||||
IGLTFNode,
|
||||
IGLTFAnimationClip,
|
||||
IAnimationSampler,
|
||||
IAnimationChannel,
|
||||
IBoundingBox,
|
||||
ISkeletonData,
|
||||
ISkeletonJoint
|
||||
} from '../interfaces/IAssetLoader';
|
||||
|
||||
// ===== GLTF JSON Schema Types =====
|
||||
|
||||
interface GLTFJson {
|
||||
asset: { version: string; generator?: string };
|
||||
scene?: number;
|
||||
scenes?: GLTFScene[];
|
||||
nodes?: GLTFNodeDef[];
|
||||
meshes?: GLTFMeshDef[];
|
||||
accessors?: GLTFAccessor[];
|
||||
bufferViews?: GLTFBufferView[];
|
||||
buffers?: GLTFBuffer[];
|
||||
materials?: GLTFMaterialDef[];
|
||||
textures?: GLTFTextureDef[];
|
||||
images?: GLTFImage[];
|
||||
samplers?: GLTFSampler[];
|
||||
animations?: GLTFAnimation[];
|
||||
skins?: GLTFSkin[];
|
||||
}
|
||||
|
||||
interface GLTFScene {
|
||||
name?: string;
|
||||
nodes?: number[];
|
||||
}
|
||||
|
||||
interface GLTFNodeDef {
|
||||
name?: string;
|
||||
mesh?: number;
|
||||
children?: number[];
|
||||
translation?: [number, number, number];
|
||||
rotation?: [number, number, number, number];
|
||||
scale?: [number, number, number];
|
||||
matrix?: number[];
|
||||
skin?: number;
|
||||
}
|
||||
|
||||
interface GLTFMeshDef {
|
||||
name?: string;
|
||||
primitives: GLTFPrimitive[];
|
||||
}
|
||||
|
||||
interface GLTFPrimitive {
|
||||
attributes: Record<string, number>;
|
||||
indices?: number;
|
||||
material?: number;
|
||||
mode?: number;
|
||||
}
|
||||
|
||||
interface GLTFAccessor {
|
||||
bufferView?: number;
|
||||
byteOffset?: number;
|
||||
componentType: number;
|
||||
count: number;
|
||||
type: string;
|
||||
min?: number[];
|
||||
max?: number[];
|
||||
normalized?: boolean;
|
||||
}
|
||||
|
||||
interface GLTFBufferView {
|
||||
buffer: number;
|
||||
byteOffset?: number;
|
||||
byteLength: number;
|
||||
byteStride?: number;
|
||||
target?: number;
|
||||
}
|
||||
|
||||
interface GLTFBuffer {
|
||||
uri?: string;
|
||||
byteLength: number;
|
||||
}
|
||||
|
||||
interface GLTFMaterialDef {
|
||||
name?: string;
|
||||
pbrMetallicRoughness?: {
|
||||
baseColorFactor?: [number, number, number, number];
|
||||
baseColorTexture?: { index: number };
|
||||
metallicFactor?: number;
|
||||
roughnessFactor?: number;
|
||||
metallicRoughnessTexture?: { index: number };
|
||||
};
|
||||
normalTexture?: { index: number; scale?: number };
|
||||
occlusionTexture?: { index: number; strength?: number };
|
||||
emissiveFactor?: [number, number, number];
|
||||
emissiveTexture?: { index: number };
|
||||
alphaMode?: 'OPAQUE' | 'MASK' | 'BLEND';
|
||||
alphaCutoff?: number;
|
||||
doubleSided?: boolean;
|
||||
}
|
||||
|
||||
interface GLTFTextureDef {
|
||||
source?: number;
|
||||
sampler?: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface GLTFImage {
|
||||
uri?: string;
|
||||
mimeType?: string;
|
||||
bufferView?: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface GLTFSampler {
|
||||
magFilter?: number;
|
||||
minFilter?: number;
|
||||
wrapS?: number;
|
||||
wrapT?: number;
|
||||
}
|
||||
|
||||
interface GLTFAnimation {
|
||||
name?: string;
|
||||
channels: GLTFAnimationChannel[];
|
||||
samplers: GLTFAnimationSampler[];
|
||||
}
|
||||
|
||||
interface GLTFAnimationChannel {
|
||||
sampler: number;
|
||||
target: {
|
||||
node?: number;
|
||||
path: 'translation' | 'rotation' | 'scale' | 'weights';
|
||||
};
|
||||
}
|
||||
|
||||
interface GLTFAnimationSampler {
|
||||
input: number;
|
||||
output: number;
|
||||
interpolation?: 'LINEAR' | 'STEP' | 'CUBICSPLINE';
|
||||
}
|
||||
|
||||
interface GLTFSkin {
|
||||
name?: string;
|
||||
inverseBindMatrices?: number;
|
||||
skeleton?: number;
|
||||
joints: number[];
|
||||
}
|
||||
|
||||
// ===== Component Type Constants =====
|
||||
const COMPONENT_TYPE_BYTE = 5120;
|
||||
const COMPONENT_TYPE_UNSIGNED_BYTE = 5121;
|
||||
const COMPONENT_TYPE_SHORT = 5122;
|
||||
const COMPONENT_TYPE_UNSIGNED_SHORT = 5123;
|
||||
const COMPONENT_TYPE_UNSIGNED_INT = 5125;
|
||||
const COMPONENT_TYPE_FLOAT = 5126;
|
||||
|
||||
// ===== GLB Constants =====
|
||||
const GLB_MAGIC = 0x46546C67; // 'glTF'
|
||||
const GLB_VERSION = 2;
|
||||
const GLB_CHUNK_TYPE_JSON = 0x4E4F534A; // 'JSON'
|
||||
const GLB_CHUNK_TYPE_BIN = 0x004E4942; // 'BIN\0'
|
||||
|
||||
/**
|
||||
* GLTF/GLB model loader
|
||||
* GLTF/GLB 模型加载器
|
||||
*/
|
||||
export class GLTFLoader implements IAssetLoader<IGLTFAsset> {
|
||||
readonly supportedType = AssetType.Model3D;
|
||||
readonly supportedExtensions = ['.gltf', '.glb'];
|
||||
readonly contentType: AssetContentType = 'binary';
|
||||
|
||||
/**
|
||||
* Parse GLTF/GLB content
|
||||
* 解析 GLTF/GLB 内容
|
||||
*/
|
||||
async parse(content: IAssetContent, context: IAssetParseContext): Promise<IGLTFAsset> {
|
||||
const binary = content.binary;
|
||||
if (!binary) {
|
||||
throw new Error('GLTF loader requires binary content');
|
||||
}
|
||||
|
||||
const isGLB = this.isGLB(binary);
|
||||
let json: GLTFJson;
|
||||
let binaryChunk: ArrayBuffer | null = null;
|
||||
|
||||
if (isGLB) {
|
||||
const glbData = this.parseGLB(binary);
|
||||
json = glbData.json;
|
||||
binaryChunk = glbData.binary;
|
||||
} else {
|
||||
// GLTF is JSON text
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
const text = decoder.decode(binary);
|
||||
json = JSON.parse(text) as GLTFJson;
|
||||
}
|
||||
|
||||
// Validate GLTF version
|
||||
if (!json.asset?.version?.startsWith('2.')) {
|
||||
throw new Error(`Unsupported GLTF version: ${json.asset?.version}. Only GLTF 2.x is supported.`);
|
||||
}
|
||||
|
||||
// Load external buffers if needed
|
||||
const buffers = await this.loadBuffers(json, binaryChunk, context);
|
||||
|
||||
// Parse all components
|
||||
const meshes = this.parseMeshes(json, buffers);
|
||||
const materials = this.parseMaterials(json);
|
||||
const textures = await this.parseTextures(json, buffers, context);
|
||||
const nodes = this.parseNodes(json);
|
||||
const rootNodes = this.getRootNodes(json);
|
||||
const animations = this.parseAnimations(json, buffers);
|
||||
const skeleton = this.parseSkeleton(json, buffers);
|
||||
const bounds = this.calculateBounds(meshes);
|
||||
|
||||
// Get model name from file path
|
||||
const pathParts = context.metadata.path.split(/[\\/]/);
|
||||
const fileName = pathParts[pathParts.length - 1];
|
||||
const name = fileName.replace(/\.(gltf|glb)$/i, '');
|
||||
|
||||
return {
|
||||
name,
|
||||
meshes,
|
||||
materials,
|
||||
textures,
|
||||
nodes,
|
||||
rootNodes,
|
||||
animations: animations.length > 0 ? animations : undefined,
|
||||
skeleton,
|
||||
bounds,
|
||||
sourcePath: context.metadata.path
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose GLTF asset
|
||||
* 释放 GLTF 资产
|
||||
*/
|
||||
dispose(asset: IGLTFAsset): void {
|
||||
// Clear mesh data
|
||||
for (const mesh of asset.meshes) {
|
||||
(mesh as { vertices: Float32Array | null }).vertices = null!;
|
||||
(mesh as { indices: Uint16Array | Uint32Array | null }).indices = null!;
|
||||
if (mesh.normals) (mesh as { normals: Float32Array | null }).normals = null;
|
||||
if (mesh.uvs) (mesh as { uvs: Float32Array | null }).uvs = null;
|
||||
if (mesh.colors) (mesh as { colors: Float32Array | null }).colors = null;
|
||||
}
|
||||
asset.meshes.length = 0;
|
||||
asset.materials.length = 0;
|
||||
asset.textures.length = 0;
|
||||
asset.nodes.length = 0;
|
||||
}
|
||||
|
||||
// ===== Private Methods =====
|
||||
|
||||
/**
|
||||
* Check if content is GLB format
|
||||
*/
|
||||
private isGLB(data: ArrayBuffer): boolean {
|
||||
if (data.byteLength < 12) return false;
|
||||
const view = new DataView(data);
|
||||
return view.getUint32(0, true) === GLB_MAGIC;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse GLB binary format
|
||||
*/
|
||||
private parseGLB(data: ArrayBuffer): { json: GLTFJson; binary: ArrayBuffer | null } {
|
||||
const view = new DataView(data);
|
||||
|
||||
// Header
|
||||
const magic = view.getUint32(0, true);
|
||||
const version = view.getUint32(4, true);
|
||||
const length = view.getUint32(8, true);
|
||||
|
||||
if (magic !== GLB_MAGIC) {
|
||||
throw new Error('Invalid GLB magic number');
|
||||
}
|
||||
if (version !== GLB_VERSION) {
|
||||
throw new Error(`Unsupported GLB version: ${version}`);
|
||||
}
|
||||
if (length !== data.byteLength) {
|
||||
throw new Error('GLB length mismatch');
|
||||
}
|
||||
|
||||
let json: GLTFJson | null = null;
|
||||
let binary: ArrayBuffer | null = null;
|
||||
let offset = 12;
|
||||
|
||||
// Parse chunks
|
||||
while (offset < length) {
|
||||
const chunkLength = view.getUint32(offset, true);
|
||||
const chunkType = view.getUint32(offset + 4, true);
|
||||
const chunkData = data.slice(offset + 8, offset + 8 + chunkLength);
|
||||
|
||||
if (chunkType === GLB_CHUNK_TYPE_JSON) {
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
json = JSON.parse(decoder.decode(chunkData)) as GLTFJson;
|
||||
} else if (chunkType === GLB_CHUNK_TYPE_BIN) {
|
||||
binary = chunkData;
|
||||
}
|
||||
|
||||
offset += 8 + chunkLength;
|
||||
}
|
||||
|
||||
if (!json) {
|
||||
throw new Error('GLB missing JSON chunk');
|
||||
}
|
||||
|
||||
return { json, binary };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load buffer data
|
||||
*/
|
||||
private async loadBuffers(
|
||||
json: GLTFJson,
|
||||
binaryChunk: ArrayBuffer | null,
|
||||
_context: IAssetParseContext
|
||||
): Promise<ArrayBuffer[]> {
|
||||
const buffers: ArrayBuffer[] = [];
|
||||
|
||||
if (!json.buffers) return buffers;
|
||||
|
||||
for (let i = 0; i < json.buffers.length; i++) {
|
||||
const bufferDef = json.buffers[i];
|
||||
|
||||
if (!bufferDef.uri) {
|
||||
// GLB embedded binary chunk
|
||||
if (binaryChunk && i === 0) {
|
||||
buffers.push(binaryChunk);
|
||||
} else {
|
||||
throw new Error(`Buffer ${i} has no URI and no binary chunk available`);
|
||||
}
|
||||
} else if (bufferDef.uri.startsWith('data:')) {
|
||||
// Data URI
|
||||
buffers.push(this.decodeDataUri(bufferDef.uri));
|
||||
} else {
|
||||
// External file - not supported yet, would need asset loader context
|
||||
throw new Error(`External buffer URIs not supported yet: ${bufferDef.uri}`);
|
||||
}
|
||||
}
|
||||
|
||||
return buffers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode base64 data URI
|
||||
*/
|
||||
private decodeDataUri(uri: string): ArrayBuffer {
|
||||
const match = uri.match(/^data:[^;]*;base64,(.*)$/);
|
||||
if (!match) {
|
||||
throw new Error('Invalid data URI format');
|
||||
}
|
||||
|
||||
const base64 = match[1];
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accessor data as typed array
|
||||
*/
|
||||
private getAccessorData(
|
||||
json: GLTFJson,
|
||||
buffers: ArrayBuffer[],
|
||||
accessorIndex: number
|
||||
): { data: ArrayBufferView; count: number; componentCount: number } {
|
||||
const accessor = json.accessors![accessorIndex];
|
||||
const bufferView = json.bufferViews![accessor.bufferView!];
|
||||
const buffer = buffers[bufferView.buffer];
|
||||
|
||||
const byteOffset = (bufferView.byteOffset || 0) + (accessor.byteOffset || 0);
|
||||
const componentCount = this.getComponentCount(accessor.type);
|
||||
const elementCount = accessor.count * componentCount;
|
||||
|
||||
let data: ArrayBufferView;
|
||||
|
||||
switch (accessor.componentType) {
|
||||
case COMPONENT_TYPE_BYTE:
|
||||
data = new Int8Array(buffer, byteOffset, elementCount);
|
||||
break;
|
||||
case COMPONENT_TYPE_UNSIGNED_BYTE:
|
||||
data = new Uint8Array(buffer, byteOffset, elementCount);
|
||||
break;
|
||||
case COMPONENT_TYPE_SHORT:
|
||||
data = new Int16Array(buffer, byteOffset, elementCount);
|
||||
break;
|
||||
case COMPONENT_TYPE_UNSIGNED_SHORT:
|
||||
data = new Uint16Array(buffer, byteOffset, elementCount);
|
||||
break;
|
||||
case COMPONENT_TYPE_UNSIGNED_INT:
|
||||
data = new Uint32Array(buffer, byteOffset, elementCount);
|
||||
break;
|
||||
case COMPONENT_TYPE_FLOAT:
|
||||
data = new Float32Array(buffer, byteOffset, elementCount);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported component type: ${accessor.componentType}`);
|
||||
}
|
||||
|
||||
return { data, count: accessor.count, componentCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get component count from accessor type
|
||||
*/
|
||||
private getComponentCount(type: string): number {
|
||||
switch (type) {
|
||||
case 'SCALAR': return 1;
|
||||
case 'VEC2': return 2;
|
||||
case 'VEC3': return 3;
|
||||
case 'VEC4': return 4;
|
||||
case 'MAT2': return 4;
|
||||
case 'MAT3': return 9;
|
||||
case 'MAT4': return 16;
|
||||
default:
|
||||
throw new Error(`Unknown accessor type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all meshes
|
||||
*/
|
||||
private parseMeshes(json: GLTFJson, buffers: ArrayBuffer[]): IMeshData[] {
|
||||
const meshes: IMeshData[] = [];
|
||||
|
||||
if (!json.meshes) return meshes;
|
||||
|
||||
for (const meshDef of json.meshes) {
|
||||
for (const primitive of meshDef.primitives) {
|
||||
// Only support triangles (mode 4 or undefined)
|
||||
if (primitive.mode !== undefined && primitive.mode !== 4) {
|
||||
console.warn('Skipping non-triangle primitive');
|
||||
continue;
|
||||
}
|
||||
|
||||
const mesh = this.parsePrimitive(json, buffers, primitive, meshDef.name || 'Mesh');
|
||||
meshes.push(mesh);
|
||||
}
|
||||
}
|
||||
|
||||
return meshes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single primitive
|
||||
*/
|
||||
private parsePrimitive(
|
||||
json: GLTFJson,
|
||||
buffers: ArrayBuffer[],
|
||||
primitive: GLTFPrimitive,
|
||||
name: string
|
||||
): IMeshData {
|
||||
// Position (required)
|
||||
const positionAccessor = primitive.attributes['POSITION'];
|
||||
if (positionAccessor === undefined) {
|
||||
throw new Error('Mesh primitive missing POSITION attribute');
|
||||
}
|
||||
const positionData = this.getAccessorData(json, buffers, positionAccessor);
|
||||
const vertices = new Float32Array(positionData.data.buffer, (positionData.data as Float32Array).byteOffset, positionData.count * 3);
|
||||
|
||||
// Indices (optional, generate sequential if missing)
|
||||
let indices: Uint16Array | Uint32Array;
|
||||
if (primitive.indices !== undefined) {
|
||||
const indexData = this.getAccessorData(json, buffers, primitive.indices);
|
||||
if (indexData.data instanceof Uint32Array) {
|
||||
indices = indexData.data;
|
||||
} else if (indexData.data instanceof Uint16Array) {
|
||||
indices = indexData.data;
|
||||
} else {
|
||||
// Convert to Uint32Array
|
||||
indices = new Uint32Array(indexData.count);
|
||||
for (let i = 0; i < indexData.count; i++) {
|
||||
indices[i] = (indexData.data as Uint8Array)[i];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Generate sequential indices
|
||||
indices = new Uint32Array(positionData.count);
|
||||
for (let i = 0; i < positionData.count; i++) {
|
||||
indices[i] = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Normals (optional)
|
||||
let normals: Float32Array | undefined;
|
||||
const normalAccessor = primitive.attributes['NORMAL'];
|
||||
if (normalAccessor !== undefined) {
|
||||
const normalData = this.getAccessorData(json, buffers, normalAccessor);
|
||||
normals = new Float32Array(normalData.data.buffer, (normalData.data as Float32Array).byteOffset, normalData.count * 3);
|
||||
}
|
||||
|
||||
// UVs (optional, TEXCOORD_0)
|
||||
let uvs: Float32Array | undefined;
|
||||
const uvAccessor = primitive.attributes['TEXCOORD_0'];
|
||||
if (uvAccessor !== undefined) {
|
||||
const uvData = this.getAccessorData(json, buffers, uvAccessor);
|
||||
uvs = new Float32Array(uvData.data.buffer, (uvData.data as Float32Array).byteOffset, uvData.count * 2);
|
||||
}
|
||||
|
||||
// Vertex colors (optional, COLOR_0)
|
||||
let colors: Float32Array | undefined;
|
||||
const colorAccessor = primitive.attributes['COLOR_0'];
|
||||
if (colorAccessor !== undefined) {
|
||||
const colorData = this.getAccessorData(json, buffers, colorAccessor);
|
||||
// Normalize if needed
|
||||
if (colorData.data instanceof Float32Array) {
|
||||
colors = colorData.data;
|
||||
} else {
|
||||
// Convert from normalized bytes
|
||||
colors = new Float32Array(colorData.count * colorData.componentCount);
|
||||
const source = colorData.data as Uint8Array;
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
colors[i] = source[i] / 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tangents (optional)
|
||||
let tangents: Float32Array | undefined;
|
||||
const tangentAccessor = primitive.attributes['TANGENT'];
|
||||
if (tangentAccessor !== undefined) {
|
||||
const tangentData = this.getAccessorData(json, buffers, tangentAccessor);
|
||||
tangents = new Float32Array(tangentData.data.buffer, (tangentData.data as Float32Array).byteOffset, tangentData.count * 4);
|
||||
}
|
||||
|
||||
// Skinning: JOINTS_0 (bone indices per vertex)
|
||||
// 蒙皮:JOINTS_0(每顶点的骨骼索引)
|
||||
let joints: Uint8Array | Uint16Array | undefined;
|
||||
const jointsAccessor = primitive.attributes['JOINTS_0'];
|
||||
if (jointsAccessor !== undefined) {
|
||||
const jointsData = this.getAccessorData(json, buffers, jointsAccessor);
|
||||
if (jointsData.data instanceof Uint8Array) {
|
||||
joints = new Uint8Array(jointsData.data.buffer, jointsData.data.byteOffset, jointsData.count * 4);
|
||||
} else if (jointsData.data instanceof Uint16Array) {
|
||||
joints = new Uint16Array(jointsData.data.buffer, jointsData.data.byteOffset, jointsData.count * 4);
|
||||
}
|
||||
}
|
||||
|
||||
// Skinning: WEIGHTS_0 (bone weights per vertex)
|
||||
// 蒙皮:WEIGHTS_0(每顶点的骨骼权重)
|
||||
let weights: Float32Array | undefined;
|
||||
const weightsAccessor = primitive.attributes['WEIGHTS_0'];
|
||||
if (weightsAccessor !== undefined) {
|
||||
const weightsData = this.getAccessorData(json, buffers, weightsAccessor);
|
||||
if (weightsData.data instanceof Float32Array) {
|
||||
weights = new Float32Array(weightsData.data.buffer, weightsData.data.byteOffset, weightsData.count * 4);
|
||||
} else if (weightsData.data instanceof Uint8Array) {
|
||||
// Convert from normalized Uint8 to floats
|
||||
weights = new Float32Array(weightsData.count * 4);
|
||||
const source = weightsData.data;
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
weights[i] = source[i] / 255;
|
||||
}
|
||||
} else if (weightsData.data instanceof Uint16Array) {
|
||||
// Convert from normalized Uint16 to floats
|
||||
weights = new Float32Array(weightsData.count * 4);
|
||||
const source = weightsData.data;
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
weights[i] = source[i] / 65535;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate bounds
|
||||
const bounds = this.calculateMeshBounds(vertices);
|
||||
|
||||
return {
|
||||
name,
|
||||
vertices,
|
||||
indices,
|
||||
normals,
|
||||
uvs,
|
||||
tangents,
|
||||
colors,
|
||||
joints,
|
||||
weights,
|
||||
bounds,
|
||||
materialIndex: primitive.material ?? -1
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate mesh bounding box
|
||||
*/
|
||||
private calculateMeshBounds(vertices: Float32Array): IBoundingBox {
|
||||
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
||||
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
||||
|
||||
for (let i = 0; i < vertices.length; i += 3) {
|
||||
const x = vertices[i];
|
||||
const y = vertices[i + 1];
|
||||
const z = vertices[i + 2];
|
||||
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
minZ = Math.min(minZ, z);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
maxZ = Math.max(maxZ, z);
|
||||
}
|
||||
|
||||
return {
|
||||
min: [minX, minY, minZ],
|
||||
max: [maxX, maxY, maxZ]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all materials
|
||||
*/
|
||||
private parseMaterials(json: GLTFJson): IGLTFMaterial[] {
|
||||
const materials: IGLTFMaterial[] = [];
|
||||
|
||||
if (!json.materials) {
|
||||
// Add default material
|
||||
materials.push(this.createDefaultMaterial());
|
||||
return materials;
|
||||
}
|
||||
|
||||
for (const matDef of json.materials) {
|
||||
const pbr = matDef.pbrMetallicRoughness || {};
|
||||
|
||||
materials.push({
|
||||
name: matDef.name || 'Material',
|
||||
baseColorFactor: pbr.baseColorFactor || [1, 1, 1, 1],
|
||||
baseColorTextureIndex: pbr.baseColorTexture?.index ?? -1,
|
||||
metallicFactor: pbr.metallicFactor ?? 1,
|
||||
roughnessFactor: pbr.roughnessFactor ?? 1,
|
||||
metallicRoughnessTextureIndex: pbr.metallicRoughnessTexture?.index ?? -1,
|
||||
normalTextureIndex: matDef.normalTexture?.index ?? -1,
|
||||
normalScale: matDef.normalTexture?.scale ?? 1,
|
||||
occlusionTextureIndex: matDef.occlusionTexture?.index ?? -1,
|
||||
occlusionStrength: matDef.occlusionTexture?.strength ?? 1,
|
||||
emissiveFactor: matDef.emissiveFactor || [0, 0, 0],
|
||||
emissiveTextureIndex: matDef.emissiveTexture?.index ?? -1,
|
||||
alphaMode: matDef.alphaMode || 'OPAQUE',
|
||||
alphaCutoff: matDef.alphaCutoff ?? 0.5,
|
||||
doubleSided: matDef.doubleSided ?? false
|
||||
});
|
||||
}
|
||||
|
||||
return materials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default material
|
||||
*/
|
||||
private createDefaultMaterial(): IGLTFMaterial {
|
||||
return {
|
||||
name: 'Default',
|
||||
baseColorFactor: [0.8, 0.8, 0.8, 1],
|
||||
baseColorTextureIndex: -1,
|
||||
metallicFactor: 0,
|
||||
roughnessFactor: 0.5,
|
||||
metallicRoughnessTextureIndex: -1,
|
||||
normalTextureIndex: -1,
|
||||
normalScale: 1,
|
||||
occlusionTextureIndex: -1,
|
||||
occlusionStrength: 1,
|
||||
emissiveFactor: [0, 0, 0],
|
||||
emissiveTextureIndex: -1,
|
||||
alphaMode: 'OPAQUE',
|
||||
alphaCutoff: 0.5,
|
||||
doubleSided: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse textures
|
||||
*/
|
||||
private async parseTextures(
|
||||
json: GLTFJson,
|
||||
buffers: ArrayBuffer[],
|
||||
_context: IAssetParseContext
|
||||
): Promise<IGLTFTextureInfo[]> {
|
||||
const textures: IGLTFTextureInfo[] = [];
|
||||
|
||||
if (!json.textures || !json.images) return textures;
|
||||
|
||||
for (const texDef of json.textures) {
|
||||
if (texDef.source === undefined) {
|
||||
textures.push({});
|
||||
continue;
|
||||
}
|
||||
|
||||
const imageDef = json.images[texDef.source];
|
||||
const textureInfo: IGLTFTextureInfo = {
|
||||
name: imageDef.name || texDef.name
|
||||
};
|
||||
|
||||
if (imageDef.bufferView !== undefined) {
|
||||
// Embedded image
|
||||
const bufferView = json.bufferViews![imageDef.bufferView];
|
||||
const buffer = buffers[bufferView.buffer];
|
||||
const byteOffset = bufferView.byteOffset || 0;
|
||||
textureInfo.imageData = buffer.slice(byteOffset, byteOffset + bufferView.byteLength);
|
||||
textureInfo.mimeType = imageDef.mimeType;
|
||||
} else if (imageDef.uri) {
|
||||
if (imageDef.uri.startsWith('data:')) {
|
||||
// Data URI
|
||||
textureInfo.imageData = this.decodeDataUri(imageDef.uri);
|
||||
const mimeMatch = imageDef.uri.match(/^data:(.*?);/);
|
||||
textureInfo.mimeType = mimeMatch?.[1];
|
||||
} else {
|
||||
// External URI
|
||||
textureInfo.uri = imageDef.uri;
|
||||
}
|
||||
}
|
||||
|
||||
textures.push(textureInfo);
|
||||
}
|
||||
|
||||
return textures;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse scene nodes
|
||||
*/
|
||||
private parseNodes(json: GLTFJson): IGLTFNode[] {
|
||||
const nodes: IGLTFNode[] = [];
|
||||
|
||||
if (!json.nodes) return nodes;
|
||||
|
||||
for (const nodeDef of json.nodes) {
|
||||
let position: [number, number, number] = [0, 0, 0];
|
||||
let rotation: [number, number, number, number] = [0, 0, 0, 1];
|
||||
let scale: [number, number, number] = [1, 1, 1];
|
||||
|
||||
if (nodeDef.matrix) {
|
||||
// Decompose matrix
|
||||
const m = nodeDef.matrix;
|
||||
// Extract translation
|
||||
position = [m[12], m[13], m[14]];
|
||||
// Extract scale
|
||||
scale = [
|
||||
Math.sqrt(m[0] * m[0] + m[1] * m[1] + m[2] * m[2]),
|
||||
Math.sqrt(m[4] * m[4] + m[5] * m[5] + m[6] * m[6]),
|
||||
Math.sqrt(m[8] * m[8] + m[9] * m[9] + m[10] * m[10])
|
||||
];
|
||||
// Extract rotation (simplified, assumes no shear)
|
||||
rotation = this.matrixToQuaternion(m, scale);
|
||||
} else {
|
||||
if (nodeDef.translation) {
|
||||
position = nodeDef.translation;
|
||||
}
|
||||
if (nodeDef.rotation) {
|
||||
rotation = nodeDef.rotation;
|
||||
}
|
||||
if (nodeDef.scale) {
|
||||
scale = nodeDef.scale;
|
||||
}
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
name: nodeDef.name || 'Node',
|
||||
meshIndex: nodeDef.mesh,
|
||||
children: nodeDef.children || [],
|
||||
transform: { position, rotation, scale }
|
||||
});
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract quaternion from matrix
|
||||
*/
|
||||
private matrixToQuaternion(m: number[], scale: [number, number, number]): [number, number, number, number] {
|
||||
// Normalize rotation matrix
|
||||
const sx = scale[0], sy = scale[1], sz = scale[2];
|
||||
const m00 = m[0] / sx, m01 = m[4] / sy, m02 = m[8] / sz;
|
||||
const m10 = m[1] / sx, m11 = m[5] / sy, m12 = m[9] / sz;
|
||||
const m20 = m[2] / sx, m21 = m[6] / sy, m22 = m[10] / sz;
|
||||
|
||||
const trace = m00 + m11 + m22;
|
||||
let x: number, y: number, z: number, w: number;
|
||||
|
||||
if (trace > 0) {
|
||||
const s = 0.5 / Math.sqrt(trace + 1.0);
|
||||
w = 0.25 / s;
|
||||
x = (m21 - m12) * s;
|
||||
y = (m02 - m20) * s;
|
||||
z = (m10 - m01) * s;
|
||||
} else if (m00 > m11 && m00 > m22) {
|
||||
const s = 2.0 * Math.sqrt(1.0 + m00 - m11 - m22);
|
||||
w = (m21 - m12) / s;
|
||||
x = 0.25 * s;
|
||||
y = (m01 + m10) / s;
|
||||
z = (m02 + m20) / s;
|
||||
} else if (m11 > m22) {
|
||||
const s = 2.0 * Math.sqrt(1.0 + m11 - m00 - m22);
|
||||
w = (m02 - m20) / s;
|
||||
x = (m01 + m10) / s;
|
||||
y = 0.25 * s;
|
||||
z = (m12 + m21) / s;
|
||||
} else {
|
||||
const s = 2.0 * Math.sqrt(1.0 + m22 - m00 - m11);
|
||||
w = (m10 - m01) / s;
|
||||
x = (m02 + m20) / s;
|
||||
y = (m12 + m21) / s;
|
||||
z = 0.25 * s;
|
||||
}
|
||||
|
||||
return [x, y, z, w];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get root node indices
|
||||
*/
|
||||
private getRootNodes(json: GLTFJson): number[] {
|
||||
const sceneIndex = json.scene ?? 0;
|
||||
const scene = json.scenes?.[sceneIndex];
|
||||
return scene?.nodes || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse animations
|
||||
*/
|
||||
private parseAnimations(json: GLTFJson, buffers: ArrayBuffer[]): IGLTFAnimationClip[] {
|
||||
const animations: IGLTFAnimationClip[] = [];
|
||||
|
||||
if (!json.animations) return animations;
|
||||
|
||||
for (const animDef of json.animations) {
|
||||
const samplers: IAnimationSampler[] = [];
|
||||
const channels: IAnimationChannel[] = [];
|
||||
let duration = 0;
|
||||
|
||||
// Parse samplers
|
||||
for (const samplerDef of animDef.samplers) {
|
||||
const inputData = this.getAccessorData(json, buffers, samplerDef.input);
|
||||
const outputData = this.getAccessorData(json, buffers, samplerDef.output);
|
||||
|
||||
const input = new Float32Array(inputData.data.buffer, (inputData.data as Float32Array).byteOffset, inputData.count);
|
||||
const output = new Float32Array(outputData.data.buffer, (outputData.data as Float32Array).byteOffset, outputData.count * outputData.componentCount);
|
||||
|
||||
// Update duration
|
||||
if (input.length > 0) {
|
||||
duration = Math.max(duration, input[input.length - 1]);
|
||||
}
|
||||
|
||||
samplers.push({
|
||||
input,
|
||||
output,
|
||||
interpolation: samplerDef.interpolation || 'LINEAR'
|
||||
});
|
||||
}
|
||||
|
||||
// Parse channels
|
||||
for (const channelDef of animDef.channels) {
|
||||
if (channelDef.target.node === undefined) continue;
|
||||
|
||||
channels.push({
|
||||
samplerIndex: channelDef.sampler,
|
||||
target: {
|
||||
nodeIndex: channelDef.target.node,
|
||||
path: channelDef.target.path
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
animations.push({
|
||||
name: animDef.name || 'Animation',
|
||||
duration,
|
||||
samplers,
|
||||
channels
|
||||
});
|
||||
}
|
||||
|
||||
return animations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse skeleton/skin data
|
||||
*/
|
||||
private parseSkeleton(json: GLTFJson, buffers: ArrayBuffer[]): ISkeletonData | undefined {
|
||||
if (!json.skins || json.skins.length === 0) return undefined;
|
||||
|
||||
// Use first skin
|
||||
const skin = json.skins[0];
|
||||
const joints: ISkeletonJoint[] = [];
|
||||
|
||||
// Load inverse bind matrices
|
||||
let inverseBindMatrices: Float32Array | null = null;
|
||||
if (skin.inverseBindMatrices !== undefined) {
|
||||
const ibmData = this.getAccessorData(json, buffers, skin.inverseBindMatrices);
|
||||
inverseBindMatrices = new Float32Array(ibmData.data.buffer, (ibmData.data as Float32Array).byteOffset, ibmData.count * 16);
|
||||
}
|
||||
|
||||
// Build joint hierarchy
|
||||
const jointIndexMap = new Map<number, number>();
|
||||
for (let i = 0; i < skin.joints.length; i++) {
|
||||
jointIndexMap.set(skin.joints[i], i);
|
||||
}
|
||||
|
||||
for (let i = 0; i < skin.joints.length; i++) {
|
||||
const nodeIndex = skin.joints[i];
|
||||
const node = json.nodes![nodeIndex];
|
||||
|
||||
// Find parent
|
||||
let parentIndex = -1;
|
||||
for (const [idx, jointIdx] of jointIndexMap) {
|
||||
if (jointIdx !== i) {
|
||||
const parentNode = json.nodes![idx];
|
||||
if (parentNode.children?.includes(nodeIndex)) {
|
||||
parentIndex = jointIdx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ibm = new Float32Array(16);
|
||||
if (inverseBindMatrices) {
|
||||
for (let j = 0; j < 16; j++) {
|
||||
ibm[j] = inverseBindMatrices[i * 16 + j];
|
||||
}
|
||||
} else {
|
||||
// Identity matrix
|
||||
ibm[0] = ibm[5] = ibm[10] = ibm[15] = 1;
|
||||
}
|
||||
|
||||
joints.push({
|
||||
name: node.name || `Joint_${i}`,
|
||||
nodeIndex,
|
||||
parentIndex,
|
||||
inverseBindMatrix: ibm
|
||||
});
|
||||
}
|
||||
|
||||
// Find root joint
|
||||
let rootJointIndex = 0;
|
||||
for (let i = 0; i < joints.length; i++) {
|
||||
if (joints[i].parentIndex === -1) {
|
||||
rootJointIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
joints,
|
||||
rootJointIndex
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate combined bounds for all meshes
|
||||
*/
|
||||
private calculateBounds(meshes: IMeshData[]): IBoundingBox {
|
||||
if (meshes.length === 0) {
|
||||
return { min: [0, 0, 0], max: [0, 0, 0] };
|
||||
}
|
||||
|
||||
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
||||
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
||||
|
||||
for (const mesh of meshes) {
|
||||
minX = Math.min(minX, mesh.bounds.min[0]);
|
||||
minY = Math.min(minY, mesh.bounds.min[1]);
|
||||
minZ = Math.min(minZ, mesh.bounds.min[2]);
|
||||
maxX = Math.max(maxX, mesh.bounds.max[0]);
|
||||
maxY = Math.max(maxY, mesh.bounds.max[1]);
|
||||
maxZ = Math.max(maxZ, mesh.bounds.max[2]);
|
||||
}
|
||||
|
||||
return {
|
||||
min: [minX, minY, minZ],
|
||||
max: [maxX, maxY, maxZ]
|
||||
};
|
||||
}
|
||||
}
|
||||
553
packages/asset-system/src/loaders/OBJLoader.ts
Normal file
553
packages/asset-system/src/loaders/OBJLoader.ts
Normal file
@@ -0,0 +1,553 @@
|
||||
/**
|
||||
* OBJ model loader implementation
|
||||
* OBJ 模型加载器实现
|
||||
*
|
||||
* Supports:
|
||||
* - Wavefront OBJ format (.obj)
|
||||
* - Vertices, normals, texture coordinates
|
||||
* - Triangular and quad faces (quads are triangulated)
|
||||
* - Multiple objects/groups
|
||||
* - MTL material references (materials loaded separately)
|
||||
*/
|
||||
|
||||
import { AssetType } from '../types/AssetTypes';
|
||||
import type { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
|
||||
import type {
|
||||
IAssetLoader,
|
||||
IAssetParseContext,
|
||||
IGLTFAsset,
|
||||
IMeshData,
|
||||
IGLTFMaterial,
|
||||
IGLTFNode,
|
||||
IBoundingBox
|
||||
} from '../interfaces/IAssetLoader';
|
||||
|
||||
/**
|
||||
* Parsed OBJ data structure
|
||||
* 解析后的 OBJ 数据结构
|
||||
*/
|
||||
interface OBJParseResult {
|
||||
positions: number[];
|
||||
normals: number[];
|
||||
uvs: number[];
|
||||
objects: OBJObject[];
|
||||
mtlLib?: string;
|
||||
}
|
||||
|
||||
interface OBJObject {
|
||||
name: string;
|
||||
material?: string;
|
||||
faces: OBJFace[];
|
||||
}
|
||||
|
||||
interface OBJFace {
|
||||
vertices: OBJVertex[];
|
||||
}
|
||||
|
||||
interface OBJVertex {
|
||||
positionIndex: number;
|
||||
uvIndex?: number;
|
||||
normalIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* OBJ model loader
|
||||
* OBJ 模型加载器
|
||||
*/
|
||||
export class OBJLoader implements IAssetLoader<IGLTFAsset> {
|
||||
readonly supportedType = AssetType.Model3D;
|
||||
readonly supportedExtensions = ['.obj'];
|
||||
readonly contentType: AssetContentType = 'text';
|
||||
|
||||
/**
|
||||
* Parse OBJ content
|
||||
* 解析 OBJ 内容
|
||||
*/
|
||||
async parse(content: IAssetContent, context: IAssetParseContext): Promise<IGLTFAsset> {
|
||||
const text = content.text;
|
||||
if (!text) {
|
||||
throw new Error('OBJ loader requires text content');
|
||||
}
|
||||
|
||||
// Parse OBJ text
|
||||
// 解析 OBJ 文本
|
||||
const objData = this.parseOBJ(text);
|
||||
|
||||
// Convert to meshes
|
||||
// 转换为网格
|
||||
const meshes = this.buildMeshes(objData);
|
||||
|
||||
// Create default materials
|
||||
// 创建默认材质
|
||||
const materials = this.buildMaterials(objData);
|
||||
|
||||
// Build nodes (one per object)
|
||||
// 构建节点(每个对象一个)
|
||||
const nodes: IGLTFNode[] = meshes.map((mesh, index) => ({
|
||||
name: mesh.name,
|
||||
meshIndex: index,
|
||||
children: [],
|
||||
transform: {
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1]
|
||||
}
|
||||
}));
|
||||
|
||||
// Calculate overall bounds
|
||||
// 计算总边界
|
||||
const bounds = this.calculateBounds(meshes);
|
||||
|
||||
// Get model name from file path
|
||||
// 从文件路径获取模型名称
|
||||
const pathParts = context.metadata.path.split(/[\\/]/);
|
||||
const fileName = pathParts[pathParts.length - 1];
|
||||
const name = fileName.replace(/\.obj$/i, '');
|
||||
|
||||
return {
|
||||
name,
|
||||
meshes,
|
||||
materials,
|
||||
textures: [],
|
||||
nodes,
|
||||
rootNodes: nodes.map((_, i) => i),
|
||||
bounds,
|
||||
sourcePath: context.metadata.path
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose OBJ asset
|
||||
* 释放 OBJ 资产
|
||||
*/
|
||||
dispose(asset: IGLTFAsset): void {
|
||||
for (const mesh of asset.meshes) {
|
||||
(mesh as { vertices: Float32Array | null }).vertices = null!;
|
||||
(mesh as { indices: Uint16Array | Uint32Array | null }).indices = null!;
|
||||
}
|
||||
asset.meshes.length = 0;
|
||||
}
|
||||
|
||||
// ===== Private Methods =====
|
||||
|
||||
/**
|
||||
* Parse OBJ text format
|
||||
* 解析 OBJ 文本格式
|
||||
*/
|
||||
private parseOBJ(text: string): OBJParseResult {
|
||||
const lines = text.split('\n');
|
||||
|
||||
const positions: number[] = [];
|
||||
const normals: number[] = [];
|
||||
const uvs: number[] = [];
|
||||
const objects: OBJObject[] = [];
|
||||
|
||||
let currentObject: OBJObject = { name: 'default', faces: [] };
|
||||
let mtlLib: string | undefined;
|
||||
|
||||
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
||||
const line = lines[lineNum].trim();
|
||||
|
||||
// Skip comments and empty lines
|
||||
// 跳过注释和空行
|
||||
if (line.length === 0 || line.startsWith('#')) continue;
|
||||
|
||||
const parts = line.split(/\s+/);
|
||||
const keyword = parts[0];
|
||||
|
||||
switch (keyword) {
|
||||
case 'v': // Vertex position
|
||||
positions.push(
|
||||
parseFloat(parts[1]) || 0,
|
||||
parseFloat(parts[2]) || 0,
|
||||
parseFloat(parts[3]) || 0
|
||||
);
|
||||
break;
|
||||
|
||||
case 'vn': // Vertex normal
|
||||
normals.push(
|
||||
parseFloat(parts[1]) || 0,
|
||||
parseFloat(parts[2]) || 0,
|
||||
parseFloat(parts[3]) || 0
|
||||
);
|
||||
break;
|
||||
|
||||
case 'vt': // Texture coordinate
|
||||
uvs.push(
|
||||
parseFloat(parts[1]) || 0,
|
||||
parseFloat(parts[2]) || 0
|
||||
);
|
||||
break;
|
||||
|
||||
case 'f': // Face
|
||||
const face = this.parseFace(parts.slice(1));
|
||||
if (face.vertices.length >= 3) {
|
||||
// Triangulate if more than 3 vertices (fan triangulation)
|
||||
// 如果超过 3 个顶点则三角化(扇形三角化)
|
||||
for (let i = 1; i < face.vertices.length - 1; i++) {
|
||||
currentObject.faces.push({
|
||||
vertices: [
|
||||
face.vertices[0],
|
||||
face.vertices[i],
|
||||
face.vertices[i + 1]
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'o': // Object name
|
||||
case 'g': // Group name
|
||||
if (currentObject.faces.length > 0) {
|
||||
objects.push(currentObject);
|
||||
}
|
||||
currentObject = {
|
||||
name: parts.slice(1).join(' ') || 'unnamed',
|
||||
faces: []
|
||||
};
|
||||
break;
|
||||
|
||||
case 'usemtl': // Material reference
|
||||
// If current object has faces with different material, split it
|
||||
// 如果当前对象有不同材质的面,则拆分
|
||||
if (currentObject.faces.length > 0 && currentObject.material) {
|
||||
objects.push(currentObject);
|
||||
currentObject = {
|
||||
name: `${currentObject.name}_${parts[1]}`,
|
||||
faces: [],
|
||||
material: parts[1]
|
||||
};
|
||||
} else {
|
||||
currentObject.material = parts[1];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'mtllib': // MTL library reference
|
||||
mtlLib = parts[1];
|
||||
break;
|
||||
|
||||
case 's': // Smoothing group (ignored)
|
||||
case 'l': // Line (ignored)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Push last object
|
||||
// 推送最后一个对象
|
||||
if (currentObject.faces.length > 0) {
|
||||
objects.push(currentObject);
|
||||
}
|
||||
|
||||
// If no objects were created, create one from default
|
||||
// 如果没有创建对象,从默认创建一个
|
||||
if (objects.length === 0 && currentObject.faces.length === 0) {
|
||||
throw new Error('OBJ file contains no geometry');
|
||||
}
|
||||
|
||||
return { positions, normals, uvs, objects, mtlLib };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a face definition
|
||||
* 解析面定义
|
||||
*
|
||||
* Format: v, v/vt, v/vt/vn, v//vn
|
||||
*/
|
||||
private parseFace(parts: string[]): OBJFace {
|
||||
const vertices: OBJVertex[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
const indices = part.split('/');
|
||||
const vertex: OBJVertex = {
|
||||
positionIndex: parseInt(indices[0], 10) - 1 // OBJ is 1-indexed
|
||||
};
|
||||
|
||||
if (indices.length > 1 && indices[1]) {
|
||||
vertex.uvIndex = parseInt(indices[1], 10) - 1;
|
||||
}
|
||||
|
||||
if (indices.length > 2 && indices[2]) {
|
||||
vertex.normalIndex = parseInt(indices[2], 10) - 1;
|
||||
}
|
||||
|
||||
vertices.push(vertex);
|
||||
}
|
||||
|
||||
return { vertices };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build mesh data from parsed OBJ
|
||||
* 从解析的 OBJ 构建网格数据
|
||||
*/
|
||||
private buildMeshes(objData: OBJParseResult): IMeshData[] {
|
||||
const meshes: IMeshData[] = [];
|
||||
|
||||
for (const obj of objData.objects) {
|
||||
const mesh = this.buildMesh(obj, objData);
|
||||
meshes.push(mesh);
|
||||
}
|
||||
|
||||
return meshes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single mesh from OBJ object
|
||||
* 从 OBJ 对象构建单个网格
|
||||
*/
|
||||
private buildMesh(obj: OBJObject, objData: OBJParseResult): IMeshData {
|
||||
// OBJ uses indexed vertices, but indices can reference different
|
||||
// position/uv/normal combinations, so we need to expand
|
||||
// OBJ 使用索引顶点,但索引可以引用不同的 position/uv/normal 组合,所以需要展开
|
||||
|
||||
const positions: number[] = [];
|
||||
const normals: number[] = [];
|
||||
const uvs: number[] = [];
|
||||
const indices: number[] = [];
|
||||
|
||||
// Map to track unique vertex combinations
|
||||
// 用于跟踪唯一顶点组合的映射
|
||||
const vertexMap = new Map<string, number>();
|
||||
let vertexIndex = 0;
|
||||
|
||||
for (const face of obj.faces) {
|
||||
const faceIndices: number[] = [];
|
||||
|
||||
for (const vertex of face.vertices) {
|
||||
// Create unique key for this vertex combination
|
||||
// 为此顶点组合创建唯一键
|
||||
const key = `${vertex.positionIndex}/${vertex.uvIndex ?? ''}/${vertex.normalIndex ?? ''}`;
|
||||
|
||||
let index = vertexMap.get(key);
|
||||
if (index === undefined) {
|
||||
// New unique vertex - add to arrays
|
||||
// 新的唯一顶点 - 添加到数组
|
||||
index = vertexIndex++;
|
||||
vertexMap.set(key, index);
|
||||
|
||||
// Position
|
||||
const pi = vertex.positionIndex * 3;
|
||||
positions.push(
|
||||
objData.positions[pi] ?? 0,
|
||||
objData.positions[pi + 1] ?? 0,
|
||||
objData.positions[pi + 2] ?? 0
|
||||
);
|
||||
|
||||
// UV
|
||||
if (vertex.uvIndex !== undefined) {
|
||||
const ui = vertex.uvIndex * 2;
|
||||
uvs.push(
|
||||
objData.uvs[ui] ?? 0,
|
||||
1 - (objData.uvs[ui + 1] ?? 0) // Flip V coordinate
|
||||
);
|
||||
} else {
|
||||
uvs.push(0, 0);
|
||||
}
|
||||
|
||||
// Normal
|
||||
if (vertex.normalIndex !== undefined) {
|
||||
const ni = vertex.normalIndex * 3;
|
||||
normals.push(
|
||||
objData.normals[ni] ?? 0,
|
||||
objData.normals[ni + 1] ?? 0,
|
||||
objData.normals[ni + 2] ?? 0
|
||||
);
|
||||
} else {
|
||||
normals.push(0, 1, 0); // Default up normal
|
||||
}
|
||||
}
|
||||
|
||||
faceIndices.push(index);
|
||||
}
|
||||
|
||||
// Add triangle indices
|
||||
// 添加三角形索引
|
||||
if (faceIndices.length === 3) {
|
||||
indices.push(faceIndices[0], faceIndices[1], faceIndices[2]);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate bounds
|
||||
// 计算边界
|
||||
const bounds = this.calculateMeshBounds(positions);
|
||||
|
||||
// Generate normals if not provided
|
||||
// 如果未提供法线则生成
|
||||
const hasValidNormals = objData.normals.length > 0;
|
||||
const finalNormals = hasValidNormals
|
||||
? new Float32Array(normals)
|
||||
: this.generateNormals(positions, indices);
|
||||
|
||||
return {
|
||||
name: obj.name,
|
||||
vertices: new Float32Array(positions),
|
||||
indices: new Uint32Array(indices),
|
||||
normals: finalNormals,
|
||||
uvs: new Float32Array(uvs),
|
||||
bounds,
|
||||
materialIndex: -1 // Material resolved by name
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate flat normals for mesh
|
||||
* 为网格生成平面法线
|
||||
*/
|
||||
private generateNormals(positions: number[], indices: number[]): Float32Array {
|
||||
const normals = new Float32Array(positions.length);
|
||||
|
||||
for (let i = 0; i < indices.length; i += 3) {
|
||||
const i0 = indices[i] * 3;
|
||||
const i1 = indices[i + 1] * 3;
|
||||
const i2 = indices[i + 2] * 3;
|
||||
|
||||
// Get triangle vertices
|
||||
const v0x = positions[i0], v0y = positions[i0 + 1], v0z = positions[i0 + 2];
|
||||
const v1x = positions[i1], v1y = positions[i1 + 1], v1z = positions[i1 + 2];
|
||||
const v2x = positions[i2], v2y = positions[i2 + 1], v2z = positions[i2 + 2];
|
||||
|
||||
// Calculate edge vectors
|
||||
const e1x = v1x - v0x, e1y = v1y - v0y, e1z = v1z - v0z;
|
||||
const e2x = v2x - v0x, e2y = v2y - v0y, e2z = v2z - v0z;
|
||||
|
||||
// Cross product
|
||||
const nx = e1y * e2z - e1z * e2y;
|
||||
const ny = e1z * e2x - e1x * e2z;
|
||||
const nz = e1x * e2y - e1y * e2x;
|
||||
|
||||
// Add to vertex normals (will be normalized later or kept as-is for flat shading)
|
||||
normals[i0] += nx; normals[i0 + 1] += ny; normals[i0 + 2] += nz;
|
||||
normals[i1] += nx; normals[i1 + 1] += ny; normals[i1 + 2] += nz;
|
||||
normals[i2] += nx; normals[i2 + 1] += ny; normals[i2 + 2] += nz;
|
||||
}
|
||||
|
||||
// Normalize
|
||||
for (let i = 0; i < normals.length; i += 3) {
|
||||
const len = Math.sqrt(normals[i] ** 2 + normals[i + 1] ** 2 + normals[i + 2] ** 2);
|
||||
if (len > 0) {
|
||||
normals[i] /= len;
|
||||
normals[i + 1] /= len;
|
||||
normals[i + 2] /= len;
|
||||
}
|
||||
}
|
||||
|
||||
return normals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build default materials
|
||||
* 构建默认材质
|
||||
*/
|
||||
private buildMaterials(objData: OBJParseResult): IGLTFMaterial[] {
|
||||
// Create one default material per unique material name
|
||||
// 为每个唯一的材质名称创建一个默认材质
|
||||
const materialNames = new Set<string>();
|
||||
for (const obj of objData.objects) {
|
||||
if (obj.material) {
|
||||
materialNames.add(obj.material);
|
||||
}
|
||||
}
|
||||
|
||||
const materials: IGLTFMaterial[] = [];
|
||||
|
||||
// Default material
|
||||
materials.push({
|
||||
name: 'Default',
|
||||
baseColorFactor: [0.8, 0.8, 0.8, 1],
|
||||
baseColorTextureIndex: -1,
|
||||
metallicFactor: 0,
|
||||
roughnessFactor: 0.5,
|
||||
metallicRoughnessTextureIndex: -1,
|
||||
normalTextureIndex: -1,
|
||||
normalScale: 1,
|
||||
occlusionTextureIndex: -1,
|
||||
occlusionStrength: 1,
|
||||
emissiveFactor: [0, 0, 0],
|
||||
emissiveTextureIndex: -1,
|
||||
alphaMode: 'OPAQUE',
|
||||
alphaCutoff: 0.5,
|
||||
doubleSided: false
|
||||
});
|
||||
|
||||
// Named materials (with placeholder values)
|
||||
for (const name of materialNames) {
|
||||
materials.push({
|
||||
name,
|
||||
baseColorFactor: [0.8, 0.8, 0.8, 1],
|
||||
baseColorTextureIndex: -1,
|
||||
metallicFactor: 0,
|
||||
roughnessFactor: 0.5,
|
||||
metallicRoughnessTextureIndex: -1,
|
||||
normalTextureIndex: -1,
|
||||
normalScale: 1,
|
||||
occlusionTextureIndex: -1,
|
||||
occlusionStrength: 1,
|
||||
emissiveFactor: [0, 0, 0],
|
||||
emissiveTextureIndex: -1,
|
||||
alphaMode: 'OPAQUE',
|
||||
alphaCutoff: 0.5,
|
||||
doubleSided: false
|
||||
});
|
||||
}
|
||||
|
||||
return materials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate mesh bounding box
|
||||
* 计算网格边界盒
|
||||
*/
|
||||
private calculateMeshBounds(positions: number[]): IBoundingBox {
|
||||
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
||||
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
||||
|
||||
for (let i = 0; i < positions.length; i += 3) {
|
||||
const x = positions[i];
|
||||
const y = positions[i + 1];
|
||||
const z = positions[i + 2];
|
||||
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
minZ = Math.min(minZ, z);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
maxZ = Math.max(maxZ, z);
|
||||
}
|
||||
|
||||
if (!isFinite(minX)) {
|
||||
return { min: [0, 0, 0], max: [0, 0, 0] };
|
||||
}
|
||||
|
||||
return {
|
||||
min: [minX, minY, minZ],
|
||||
max: [maxX, maxY, maxZ]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate combined bounds for all meshes
|
||||
* 计算所有网格的组合边界
|
||||
*/
|
||||
private calculateBounds(meshes: IMeshData[]): IBoundingBox {
|
||||
if (meshes.length === 0) {
|
||||
return { min: [0, 0, 0], max: [0, 0, 0] };
|
||||
}
|
||||
|
||||
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
||||
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
||||
|
||||
for (const mesh of meshes) {
|
||||
minX = Math.min(minX, mesh.bounds.min[0]);
|
||||
minY = Math.min(minY, mesh.bounds.min[1]);
|
||||
minZ = Math.min(minZ, mesh.bounds.min[2]);
|
||||
maxX = Math.max(maxX, mesh.bounds.max[0]);
|
||||
maxY = Math.max(maxY, mesh.bounds.max[1]);
|
||||
maxZ = Math.max(maxZ, mesh.bounds.max[2]);
|
||||
}
|
||||
|
||||
return {
|
||||
min: [minX, minY, minZ],
|
||||
max: [maxX, maxY, maxZ]
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,8 @@ export const AssetType = {
|
||||
Texture: 'texture',
|
||||
/** 网格 */
|
||||
Mesh: 'mesh',
|
||||
/** 3D模型 (GLTF/GLB) | 3D Model */
|
||||
Model3D: 'model3d',
|
||||
/** 材质 */
|
||||
Material: 'material',
|
||||
/** 着色器 */
|
||||
|
||||
Reference in New Issue
Block a user