refactor: reorganize package structure and decouple framework packages (#338)

* refactor: reorganize package structure and decouple framework packages

## Package Structure Reorganization
- Reorganized 55 packages into categorized subdirectories:
  - packages/framework/ - Generic framework (Laya/Cocos compatible)
  - packages/engine/ - ESEngine core modules
  - packages/rendering/ - Rendering modules (WASM dependent)
  - packages/physics/ - Physics modules
  - packages/streaming/ - World streaming
  - packages/network-ext/ - Network extensions
  - packages/editor/ - Editor framework and plugins
  - packages/rust/ - Rust WASM engine
  - packages/tools/ - Build tools and SDK

## Framework Package Decoupling
- Decoupled behavior-tree and blueprint packages from ESEngine dependencies
- Created abstracted interfaces (IBTAssetManager, IBehaviorTreeAssetContent)
- ESEngine-specific code moved to esengine/ subpath exports
- Framework packages now usable with Cocos/Laya without ESEngine

## CI Configuration
- Updated CI to only type-check and lint framework packages
- Added type-check:framework and lint:framework scripts

## Breaking Changes
- Package import paths changed due to directory reorganization
- ESEngine integrations now use subpath imports (e.g., '@esengine/behavior-tree/esengine')

* fix: update es-engine file path after directory reorganization

* docs: update README to focus on framework over engine

* ci: only build framework packages, remove Rust/WASM dependencies

* fix: remove esengine subpath from behavior-tree and blueprint builds

ESEngine integration code will only be available in full engine builds.
Framework packages are now purely engine-agnostic.

* fix: move network-protocols to framework, build both in CI

* fix: update workflow paths from packages/core to packages/framework/core

* fix: exclude esengine folder from type-check in behavior-tree and blueprint

* fix: update network tsconfig references to new paths

* fix: add test:ci:framework to only test framework packages in CI

* fix: only build core and math npm packages in CI

* fix: exclude test files from CodeQL and fix string escaping security issue
This commit is contained in:
YHH
2025-12-26 14:50:35 +08:00
committed by GitHub
parent a84ff902e4
commit 155411e743
1936 changed files with 4147 additions and 11578 deletions

View File

@@ -0,0 +1,285 @@
/**
* Asset loader factory implementation
* 资产加载器工厂实现
*/
import { AssetType } from '../types/AssetTypes';
import { IAssetLoader, IAssetLoaderFactory } from '../interfaces/IAssetLoader';
import { TextureLoader } from './TextureLoader';
import { JsonLoader } from './JsonLoader';
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();
}
/**
* Register default loaders
* 注册默认加载器
*/
private registerDefaultLoaders(): void {
// 纹理加载器 / Texture loader
this._loaders.set(AssetType.Texture, new TextureLoader());
// JSON加载器 / JSON loader
this._loaders.set(AssetType.Json, new JsonLoader());
// 文本加载器 / Text loader
this._loaders.set(AssetType.Text, new TextLoader());
// 二进制加载器 / Binary loader
this._loaders.set(AssetType.Binary, new BinaryLoader());
// 音频加载器 / Audio loader
this._loaders.set(AssetType.Audio, new AudioLoader());
// 预制体加载器 / 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
* 为特定资产类型创建加载器
*/
createLoader(type: AssetType): IAssetLoader | null {
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
* 注册自定义加载器
*/
registerLoader(type: AssetType, loader: IAssetLoader): void {
this._loaders.set(type, loader);
}
/**
* Unregister loader
* 注销加载器
*/
unregisterLoader(type: AssetType): void {
this._loaders.delete(type);
}
/**
* Check if loader exists for type
* 检查类型是否有加载器
*/
hasLoader(type: AssetType): boolean {
return this._loaders.has(type);
}
/**
* Get asset type by file extension
* 根据文件扩展名获取资产类型
*
* @param extension - File extension including dot (e.g., '.btree', '.png')
* @returns Asset type if a loader supports this extension, null otherwise
*/
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;
}
}
return null;
}
/**
* Get asset type by file path
* 根据文件路径获取资产类型
*
* Checks for compound extensions (like .tilemap.json) first, then simple extensions
*
* @param path - File path
* @returns Asset type if a loader supports this file, null otherwise
*/
getAssetTypeByPath(path: string): AssetType | null {
const lowerPath = path.toLowerCase();
// First check compound extensions (e.g., .tilemap.json)
for (const [type, loader] of this._loaders) {
for (const ext of loader.supportedExtensions) {
if (ext.includes('.') && ext.split('.').length > 2) {
// This is a compound extension like .tilemap.json
if (lowerPath.endsWith(ext.toLowerCase())) {
return type;
}
}
}
}
// Then check simple extensions
const lastDot = path.lastIndexOf('.');
if (lastDot !== -1) {
const ext = path.substring(lastDot).toLowerCase();
return this.getAssetTypeByExtension(ext);
}
return null;
}
/**
* Get all registered loaders
* 获取所有注册的加载器
*/
getRegisteredTypes(): AssetType[] {
return Array.from(this._loaders.keys());
}
/**
* Clear all loaders
* 清空所有加载器
*/
clear(): void {
this._loaders.clear();
}
/**
* Get all supported file extensions from all registered loaders.
* 获取所有注册加载器支持的文件扩展名。
*
* @returns Array of extension patterns (e.g., ['*.png', '*.jpg', '*.particle'])
*/
getAllSupportedExtensions(): string[] {
const extensions = new Set<string>();
// From type-based loaders
// 从基于类型的加载器
for (const loader of this._loaders.values()) {
for (const ext of loader.supportedExtensions) {
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);
}
/**
* Get extension to type mapping for all registered loaders.
* 获取所有注册加载器的扩展名到类型的映射。
*
* @returns Map of extension (without dot) to asset type string
*/
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;
map[cleanExt.toLowerCase()] = type;
}
}
// 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;
}
}

View File

@@ -0,0 +1,109 @@
/**
* Audio asset loader
* 音频资产加载器
*/
import { AssetType } from '../types/AssetTypes';
import { IAssetLoader, IAudioAsset, IAssetParseContext } from '../interfaces/IAssetLoader';
import { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
/**
* Audio loader implementation
* 音频加载器实现
*
* Uses Web Audio API to decode audio data into AudioBuffer.
* 使用 Web Audio API 将音频数据解码为 AudioBuffer。
*/
export class AudioLoader implements IAssetLoader<IAudioAsset> {
readonly supportedType = AssetType.Audio;
readonly supportedExtensions = ['.mp3', '.wav', '.ogg', '.m4a', '.flac', '.aac'];
readonly contentType: AssetContentType = 'audio';
private static _audioContext: AudioContext | null = null;
/**
* Get or create shared AudioContext
* 获取或创建共享的 AudioContext
*/
private static getAudioContext(): AudioContext {
if (!AudioLoader._audioContext) {
// 兼容旧版 Safari 的 webkitAudioContext
// Support legacy Safari webkitAudioContext
const AudioContextClass = window.AudioContext ||
(window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioContextClass) {
throw new Error('AudioContext is not supported in this browser');
}
AudioLoader._audioContext = new AudioContextClass();
}
return AudioLoader._audioContext;
}
/**
* Parse audio from content.
* 从内容解析音频。
*/
async parse(content: IAssetContent, _context: IAssetParseContext): Promise<IAudioAsset> {
if (!content.audioBuffer) {
throw new Error('Audio content is empty');
}
const audioBuffer = content.audioBuffer;
const audioAsset: IAudioAsset = {
buffer: audioBuffer,
duration: audioBuffer.duration,
sampleRate: audioBuffer.sampleRate,
channels: audioBuffer.numberOfChannels
};
return audioAsset;
}
/**
* Dispose loaded asset
* 释放已加载的资产
*/
dispose(_asset: IAudioAsset): void {
// AudioBuffer doesn't need explicit cleanup in most browsers
// AudioBuffer 在大多数浏览器中不需要显式清理
// The garbage collector will handle it when no references remain
// 当没有引用时,垃圾回收器会处理它
}
/**
* Close the shared AudioContext
* 关闭共享的 AudioContext
*
* Call this when completely shutting down audio system.
* 在完全关闭音频系统时调用。
*/
static closeAudioContext(): void {
if (AudioLoader._audioContext) {
AudioLoader._audioContext.close();
AudioLoader._audioContext = null;
}
}
/**
* Resume AudioContext after user interaction
* 用户交互后恢复 AudioContext
*
* Browsers require user interaction before audio can play.
* 浏览器要求用户交互后才能播放音频。
*/
static async resumeAudioContext(): Promise<void> {
const ctx = AudioLoader.getAudioContext();
if (ctx.state === 'suspended') {
await ctx.resume();
}
}
/**
* Get the shared AudioContext instance
* 获取共享的 AudioContext 实例
*/
static get audioContext(): AudioContext {
return AudioLoader.getAudioContext();
}
}

View File

@@ -0,0 +1,45 @@
/**
* Binary asset loader
* 二进制资产加载器
*/
import { AssetType } from '../types/AssetTypes';
import { IAssetLoader, IBinaryAsset, IAssetParseContext } from '../interfaces/IAssetLoader';
import { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
/**
* Binary loader implementation
* 二进制加载器实现
*/
export class BinaryLoader implements IAssetLoader<IBinaryAsset> {
readonly supportedType = AssetType.Binary;
readonly supportedExtensions = [
'.bin', '.dat', '.raw', '.bytes',
'.wasm', '.so', '.dll', '.dylib'
];
readonly contentType: AssetContentType = 'binary';
/**
* Parse binary from content.
* 从内容解析二进制。
*/
async parse(content: IAssetContent, _context: IAssetParseContext): Promise<IBinaryAsset> {
if (!content.binary) {
throw new Error('Binary content is empty');
}
return {
data: content.binary
};
}
/**
* Dispose loaded asset
* 释放已加载的资产
*/
dispose(asset: IBinaryAsset): void {
// 释放二进制数据引用以允许垃圾回收
// Release binary data reference to allow garbage collection
(asset as { data: ArrayBuffer | null }).data = null;
}
}

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,41 @@
/**
* JSON asset loader
* JSON资产加载器
*/
import { AssetType } from '../types/AssetTypes';
import { IAssetLoader, IJsonAsset, IAssetParseContext } from '../interfaces/IAssetLoader';
import { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
/**
* JSON loader implementation
* JSON加载器实现
*/
export class JsonLoader implements IAssetLoader<IJsonAsset> {
readonly supportedType = AssetType.Json;
readonly supportedExtensions = ['.json', '.jsonc'];
readonly contentType: AssetContentType = 'text';
/**
* Parse JSON from text content.
* 从文本内容解析JSON。
*/
async parse(content: IAssetContent, _context: IAssetParseContext): Promise<IJsonAsset> {
if (!content.text) {
throw new Error('JSON content is empty');
}
return {
data: JSON.parse(content.text)
};
}
/**
* Dispose loaded asset
* 释放已加载的资产
*/
dispose(asset: IJsonAsset): void {
// 清空 JSON 数据 | Clear JSON data
asset.data = null;
}
}

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

@@ -0,0 +1,156 @@
/**
* 预制体资产加载器
* Prefab asset loader
*/
import { AssetType } from '../types/AssetTypes';
import type { IAssetLoader, IAssetParseContext } from '../interfaces/IAssetLoader';
import type { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
import type {
IPrefabAsset,
IPrefabData,
SerializedPrefabEntity
} from '../interfaces/IPrefabAsset';
import { PREFAB_FORMAT_VERSION } from '../interfaces/IPrefabAsset';
/**
* 预制体加载器实现
* Prefab loader implementation
*/
export class PrefabLoader implements IAssetLoader<IPrefabAsset> {
readonly supportedType = AssetType.Prefab;
readonly supportedExtensions = ['.prefab'];
readonly contentType: AssetContentType = 'text';
/**
* 从文本内容解析预制体
* Parse prefab from text content
*/
async parse(content: IAssetContent, context: IAssetParseContext): Promise<IPrefabAsset> {
if (!content.text) {
throw new Error('Prefab content is empty');
}
let prefabData: IPrefabData;
try {
prefabData = JSON.parse(content.text) as IPrefabData;
} catch (error) {
throw new Error(`Failed to parse prefab JSON: ${(error as Error).message}`);
}
// 验证预制体格式 | Validate prefab format
this.validatePrefabData(prefabData);
// 版本兼容性检查 | Version compatibility check
if (prefabData.version > PREFAB_FORMAT_VERSION) {
console.warn(
`Prefab version ${prefabData.version} is newer than supported version ${PREFAB_FORMAT_VERSION}. ` +
`Some features may not work correctly.`
);
}
// 构建资产对象 | Build asset object
const prefabAsset: IPrefabAsset = {
data: prefabData,
guid: context.metadata.guid,
path: context.metadata.path,
// 快捷访问属性 | Quick access properties
get root(): SerializedPrefabEntity {
return prefabData.root;
},
get componentTypes(): string[] {
return prefabData.metadata.componentTypes;
},
get referencedAssets(): string[] {
return prefabData.metadata.referencedAssets;
}
};
return prefabAsset;
}
/**
* 释放已加载的资产
* Dispose loaded asset
*/
dispose(asset: IPrefabAsset): void {
// 清空预制体数据 | Clear prefab data
(asset as { data: IPrefabData | null }).data = null;
}
/**
* 验证预制体数据格式
* Validate prefab data format
*/
private validatePrefabData(data: unknown): asserts data is IPrefabData {
if (!data || typeof data !== 'object') {
throw new Error('Invalid prefab data: expected object');
}
const prefab = data as Partial<IPrefabData>;
// 验证版本号 | Validate version
if (typeof prefab.version !== 'number') {
throw new Error('Invalid prefab data: missing or invalid version');
}
// 验证元数据 | Validate metadata
if (!prefab.metadata || typeof prefab.metadata !== 'object') {
throw new Error('Invalid prefab data: missing or invalid metadata');
}
const metadata = prefab.metadata;
if (typeof metadata.name !== 'string') {
throw new Error('Invalid prefab data: missing or invalid metadata.name');
}
if (!Array.isArray(metadata.componentTypes)) {
throw new Error('Invalid prefab data: missing or invalid metadata.componentTypes');
}
if (!Array.isArray(metadata.referencedAssets)) {
throw new Error('Invalid prefab data: missing or invalid metadata.referencedAssets');
}
// 验证根实体 | Validate root entity
if (!prefab.root || typeof prefab.root !== 'object') {
throw new Error('Invalid prefab data: missing or invalid root entity');
}
this.validateSerializedEntity(prefab.root);
// 验证组件类型注册表 | Validate component type registry
if (!Array.isArray(prefab.componentTypeRegistry)) {
throw new Error('Invalid prefab data: missing or invalid componentTypeRegistry');
}
}
/**
* 验证序列化实体格式
* Validate serialized entity format
*/
private validateSerializedEntity(entity: unknown): void {
if (!entity || typeof entity !== 'object') {
throw new Error('Invalid entity data: expected object');
}
const e = entity as Partial<SerializedPrefabEntity>;
if (typeof e.id !== 'number') {
throw new Error('Invalid entity data: missing or invalid id');
}
if (typeof e.name !== 'string') {
throw new Error('Invalid entity data: missing or invalid name');
}
if (!Array.isArray(e.components)) {
throw new Error('Invalid entity data: missing or invalid components array');
}
if (!Array.isArray(e.children)) {
throw new Error('Invalid entity data: missing or invalid children array');
}
// 递归验证子实体 | Recursively validate child entities
for (const child of e.children) {
this.validateSerializedEntity(child);
}
}
}

View File

@@ -0,0 +1,56 @@
/**
* Text asset loader
* 文本资产加载器
*/
import { AssetType } from '../types/AssetTypes';
import { IAssetLoader, ITextAsset, IAssetParseContext } from '../interfaces/IAssetLoader';
import { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
/**
* Text loader implementation
* 文本加载器实现
*/
export class TextLoader implements IAssetLoader<ITextAsset> {
readonly supportedType = AssetType.Text;
readonly supportedExtensions = ['.txt', '.text', '.md', '.csv', '.xml', '.html', '.css', '.js', '.ts'];
readonly contentType: AssetContentType = 'text';
/**
* Parse text from content.
* 从内容解析文本。
*/
async parse(content: IAssetContent, _context: IAssetParseContext): Promise<ITextAsset> {
if (!content.text) {
throw new Error('Text content is empty');
}
return {
content: content.text,
encoding: this.detectEncoding(content.text)
};
}
/**
* Detect text encoding
* 检测文本编码
*/
private detectEncoding(content: string): 'utf8' | 'utf16' | 'ascii' {
for (let i = 0; i < content.length; i++) {
const charCode = content.charCodeAt(i);
if (charCode > 127) {
return charCode > 255 ? 'utf16' : 'utf8';
}
}
return 'ascii';
}
/**
* Dispose loaded asset
* 释放已加载的资产
*/
dispose(asset: ITextAsset): void {
// 清空文本内容 | Clear text content
asset.content = '';
}
}

View File

@@ -0,0 +1,117 @@
/**
* Texture asset loader
* 纹理资产加载器
*/
import { AssetType } from '../types/AssetTypes';
import { IAssetLoader, ITextureAsset, IAssetParseContext } from '../interfaces/IAssetLoader';
import { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
/**
* 全局引擎桥接接口(运行时挂载到 window
* Global engine bridge interface (mounted to window at runtime)
*/
interface IEngineBridgeGlobal {
loadTexture?(textureId: number, path: string): Promise<void>;
unloadTexture?(textureId: number): void;
}
/**
* Sprite settings from texture meta
* 纹理 meta 中的 Sprite 设置
*/
interface ISpriteSettings {
sliceBorder?: [number, number, number, number];
pivot?: [number, number];
pixelsPerUnit?: number;
}
/**
* 获取全局引擎桥接
* Get global engine bridge
*/
function getEngineBridge(): IEngineBridgeGlobal | undefined {
if (typeof window !== 'undefined' && 'engineBridge' in window) {
return (window as Window & { engineBridge?: IEngineBridgeGlobal }).engineBridge;
}
return undefined;
}
/**
* Texture loader implementation
* 纹理加载器实现
*/
export class TextureLoader implements IAssetLoader<ITextureAsset> {
readonly supportedType = AssetType.Texture;
readonly supportedExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'];
readonly contentType: AssetContentType = 'image';
private static _nextTextureId = 1;
/**
* Reset texture ID counter
* 重置纹理 ID 计数器
*
* This should be called when restoring scene snapshots to ensure
* textures start with fresh IDs.
* 在恢复场景快照时应调用此方法,以确保纹理从新 ID 开始。
*/
static resetTextureIdCounter(): void {
TextureLoader._nextTextureId = 1;
}
/**
* Parse texture from image content.
* 从图片内容解析纹理。
*/
async parse(content: IAssetContent, context: IAssetParseContext): Promise<ITextureAsset> {
if (!content.image) {
throw new Error('Texture content is empty');
}
const image = content.image;
// Read sprite settings from import settings
// 从导入设置读取 sprite 设置
const importSettings = context.metadata.importSettings as Record<string, unknown> | undefined;
const spriteSettings = importSettings?.spriteSettings as ISpriteSettings | undefined;
const textureAsset: ITextureAsset = {
textureId: TextureLoader._nextTextureId++,
width: image.width,
height: image.height,
format: 'rgba',
hasMipmaps: false,
data: image,
// Include sprite settings if available
// 如果有则包含 sprite 设置
sliceBorder: spriteSettings?.sliceBorder,
pivot: spriteSettings?.pivot
};
// Upload to GPU if bridge exists.
const bridge = getEngineBridge();
if (bridge?.loadTexture) {
await bridge.loadTexture(textureAsset.textureId, context.metadata.path);
}
return textureAsset;
}
/**
* Dispose loaded asset
* 释放已加载的资产
*/
dispose(asset: ITextureAsset): void {
// Release GPU resources.
const bridge = getEngineBridge();
if (bridge?.unloadTexture) {
bridge.unloadTexture(asset.textureId);
}
// Clean up image data.
if (asset.data instanceof HTMLImageElement) {
asset.data.src = '';
}
}
}