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:
YHH
2025-12-23 15:34:01 +08:00
committed by GitHub
parent 49dd6a91c6
commit 828ff969e1
69 changed files with 16370 additions and 56 deletions

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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;
}
}

File diff suppressed because it is too large Load Diff

View 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]
};
}
}

View 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]
};
}
}

View File

@@ -52,6 +52,8 @@ export const AssetType = {
Texture: 'texture',
/** 网格 */
Mesh: 'mesh',
/** 3D模型 (GLTF/GLB) | 3D Model */
Model3D: 'model3d',
/** 材质 */
Material: 'material',
/** 着色器 */