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:
71
packages/engine/asset-system/module.json
Normal file
71
packages/engine/asset-system/module.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"id": "asset-system",
|
||||
"name": "@esengine/asset-system",
|
||||
"globalKey": "assetSystem",
|
||||
"displayName": "Asset System",
|
||||
"description": "Asset loading, caching and management | 资源加载、缓存和管理",
|
||||
"version": "1.0.0",
|
||||
"category": "Core",
|
||||
"icon": "FolderOpen",
|
||||
"tags": [
|
||||
"asset",
|
||||
"resource",
|
||||
"loader"
|
||||
],
|
||||
"isCore": true,
|
||||
"defaultEnabled": true,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": false,
|
||||
"platforms": [
|
||||
"web",
|
||||
"desktop",
|
||||
"mobile"
|
||||
],
|
||||
"dependencies": [
|
||||
"core"
|
||||
],
|
||||
"exports": {
|
||||
"loaders": [
|
||||
"TextureLoader",
|
||||
"JsonLoader",
|
||||
"TextLoader",
|
||||
"BinaryLoader",
|
||||
"AudioLoader",
|
||||
"PrefabLoader"
|
||||
],
|
||||
"other": [
|
||||
"AssetManager",
|
||||
"AssetDatabase",
|
||||
"AssetCache"
|
||||
]
|
||||
},
|
||||
"assetExtensions": {
|
||||
".png": "texture",
|
||||
".jpg": "texture",
|
||||
".jpeg": "texture",
|
||||
".gif": "texture",
|
||||
".webp": "texture",
|
||||
".bmp": "texture",
|
||||
".svg": "texture",
|
||||
".mp3": "audio",
|
||||
".ogg": "audio",
|
||||
".wav": "audio",
|
||||
".m4a": "audio",
|
||||
".aac": "audio",
|
||||
".flac": "audio",
|
||||
".json": "data",
|
||||
".xml": "data",
|
||||
".yaml": "data",
|
||||
".yml": "data",
|
||||
".txt": "text",
|
||||
".ttf": "font",
|
||||
".woff": "font",
|
||||
".woff2": "font",
|
||||
".otf": "font",
|
||||
".fnt": "font",
|
||||
".atlas": "atlas",
|
||||
".prefab": "prefab"
|
||||
},
|
||||
"requiresWasm": false,
|
||||
"outputPath": "dist/index.js"
|
||||
}
|
||||
52
packages/engine/asset-system/package.json
Normal file
52
packages/engine/asset-system/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@esengine/asset-system",
|
||||
"version": "1.0.0",
|
||||
"description": "Asset management system for ES Engine",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"build:watch": "tsup --watch",
|
||||
"clean": "rimraf dist",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [
|
||||
"ecs",
|
||||
"asset",
|
||||
"resource",
|
||||
"bundle"
|
||||
],
|
||||
"author": "yhh",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@esengine/build-config": "workspace:*",
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"rimraf": "^5.0.0",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/esengine/esengine.git",
|
||||
"directory": "packages/asset-system"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/pako": "^2.0.4",
|
||||
"pako": "^2.1.0"
|
||||
}
|
||||
}
|
||||
278
packages/engine/asset-system/src/bundle/BundleFormat.ts
Normal file
278
packages/engine/asset-system/src/bundle/BundleFormat.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Asset Bundle Format Definitions
|
||||
* 资产包格式定义
|
||||
*
|
||||
* Binary format for efficient asset storage and loading.
|
||||
* 用于高效资产存储和加载的二进制格式。
|
||||
*/
|
||||
|
||||
import { AssetGUID, AssetType } from '../types/AssetTypes';
|
||||
|
||||
/**
|
||||
* Bundle file magic number
|
||||
* 包文件魔数
|
||||
*/
|
||||
export const BUNDLE_MAGIC = 'ESBNDL';
|
||||
|
||||
/**
|
||||
* Bundle format version
|
||||
* 包格式版本
|
||||
*/
|
||||
export const BUNDLE_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Bundle compression types
|
||||
* 包压缩类型
|
||||
*/
|
||||
export enum BundleCompression {
|
||||
None = 0,
|
||||
Gzip = 1,
|
||||
Brotli = 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle flags
|
||||
* 包标志
|
||||
*/
|
||||
export enum BundleFlags {
|
||||
None = 0,
|
||||
Compressed = 1 << 0,
|
||||
Encrypted = 1 << 1,
|
||||
Streaming = 1 << 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset type codes for binary serialization
|
||||
* 用于二进制序列化的资产类型代码
|
||||
*/
|
||||
export const AssetTypeCode: Record<string, number> = {
|
||||
texture: 1,
|
||||
audio: 2,
|
||||
json: 3,
|
||||
text: 4,
|
||||
binary: 5,
|
||||
scene: 6,
|
||||
prefab: 7,
|
||||
font: 8,
|
||||
shader: 9,
|
||||
material: 10,
|
||||
mesh: 11,
|
||||
animation: 12,
|
||||
tilemap: 20,
|
||||
tileset: 21,
|
||||
'behavior-tree': 22,
|
||||
blueprint: 23
|
||||
};
|
||||
|
||||
/**
|
||||
* Bundle header structure (32 bytes)
|
||||
* 包头结构 (32 字节)
|
||||
*/
|
||||
export interface IBundleHeader {
|
||||
/** Magic number "ESBNDL" | 魔数 */
|
||||
magic: string;
|
||||
/** Format version | 格式版本 */
|
||||
version: number;
|
||||
/** Bundle flags | 包标志 */
|
||||
flags: BundleFlags;
|
||||
/** Compression type | 压缩类型 */
|
||||
compression: BundleCompression;
|
||||
/** Number of assets | 资产数量 */
|
||||
assetCount: number;
|
||||
/** TOC offset from start | TOC 偏移量 */
|
||||
tocOffset: number;
|
||||
/** Data offset from start | 数据偏移量 */
|
||||
dataOffset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Table of Contents entry (40 bytes per entry)
|
||||
* 目录条目 (每条 40 字节)
|
||||
*/
|
||||
export interface IBundleTocEntry {
|
||||
/** Asset GUID (16 bytes as UUID binary) | 资产 GUID */
|
||||
guid: AssetGUID;
|
||||
/** Asset type code | 资产类型代码 */
|
||||
typeCode: number;
|
||||
/** Offset from data section start | 相对于数据段起始的偏移 */
|
||||
offset: number;
|
||||
/** Compressed size in bytes | 压缩后大小 */
|
||||
compressedSize: number;
|
||||
/** Uncompressed size in bytes | 未压缩大小 */
|
||||
uncompressedSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle manifest (JSON sidecar file)
|
||||
* 包清单 (JSON 附属文件)
|
||||
*/
|
||||
export interface IBundleManifest {
|
||||
/** Bundle name | 包名称 */
|
||||
name: string;
|
||||
/** Bundle version | 包版本 */
|
||||
version: string;
|
||||
/** Content hash for integrity | 内容哈希 */
|
||||
hash: string;
|
||||
/** Compression type | 压缩类型 */
|
||||
compression: 'none' | 'gzip' | 'brotli';
|
||||
/** Total bundle size | 包总大小 */
|
||||
size: number;
|
||||
/** Assets in this bundle | 包含的资产 */
|
||||
assets: IBundleAssetInfo[];
|
||||
/** Dependencies on other bundles | 依赖的其他包 */
|
||||
dependencies: string[];
|
||||
/** Creation timestamp | 创建时间戳 */
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset info in bundle manifest
|
||||
* 包清单中的资产信息
|
||||
*/
|
||||
export interface IBundleAssetInfo {
|
||||
/** Asset GUID | 资产 GUID */
|
||||
guid: AssetGUID;
|
||||
/** Asset name (for debugging) | 资产名称 (用于调试) */
|
||||
name: string;
|
||||
/** Asset type | 资产类型 */
|
||||
type: AssetType;
|
||||
/** Offset in bundle | 包内偏移 */
|
||||
offset: number;
|
||||
/** Size in bytes | 大小 */
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime catalog format (loaded in browser)
|
||||
* 运行时目录格式 (在浏览器中加载)
|
||||
*/
|
||||
export interface IRuntimeCatalog {
|
||||
/** Catalog version | 目录版本 */
|
||||
version: string;
|
||||
/** Creation timestamp | 创建时间戳 */
|
||||
createdAt: number;
|
||||
/** Available bundles | 可用的包 */
|
||||
bundles: Record<string, IRuntimeBundleInfo>;
|
||||
/** Asset GUID to location mapping | 资产 GUID 到位置的映射 */
|
||||
assets: Record<AssetGUID, IRuntimeAssetLocation>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle info in runtime catalog
|
||||
* 运行时目录中的包信息
|
||||
*/
|
||||
export interface IRuntimeBundleInfo {
|
||||
/** Bundle URL (relative to catalog) | 包 URL */
|
||||
url: string;
|
||||
/** Bundle size in bytes | 包大小 */
|
||||
size: number;
|
||||
/** Content hash | 内容哈希 */
|
||||
hash: string;
|
||||
/** Whether bundle is preloaded | 是否预加载 */
|
||||
preload?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset location in runtime catalog
|
||||
* 运行时目录中的资产位置
|
||||
*/
|
||||
export interface IRuntimeAssetLocation {
|
||||
/** Bundle name containing this asset | 包含此资产的包名 */
|
||||
bundle: string;
|
||||
/** Offset within bundle | 包内偏移 */
|
||||
offset: number;
|
||||
/** Size in bytes | 大小 */
|
||||
size: number;
|
||||
/** Asset type | 资产类型 */
|
||||
type: AssetType;
|
||||
/** Asset name (for debugging) | 资产名称 */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle packing options
|
||||
* 包打包选项
|
||||
*/
|
||||
export interface IBundlePackOptions {
|
||||
/** Bundle name | 包名称 */
|
||||
name: string;
|
||||
/** Compression type | 压缩类型 */
|
||||
compression?: BundleCompression;
|
||||
/** Maximum bundle size (split if exceeded) | 最大包大小 */
|
||||
maxSize?: number;
|
||||
/** Group assets by type | 按类型分组资产 */
|
||||
groupByType?: boolean;
|
||||
/** Include asset names in bundle | 在包中包含资产名称 */
|
||||
includeNames?: boolean;
|
||||
/**
|
||||
* 需要预加载的包名列表 | List of bundle names to preload
|
||||
* 如果未指定,默认预加载 'core' 和 'main' 包
|
||||
* If not specified, defaults to preloading 'core' and 'main' bundles
|
||||
*/
|
||||
preloadBundles?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset to pack
|
||||
* 要打包的资产
|
||||
*/
|
||||
export interface IAssetToPack {
|
||||
/** Asset GUID | 资产 GUID */
|
||||
guid: AssetGUID;
|
||||
/** Asset path (for reading) | 资产路径 */
|
||||
path: string;
|
||||
/** Asset type | 资产类型 */
|
||||
type: AssetType;
|
||||
/** Asset name | 资产名称 */
|
||||
name: string;
|
||||
/** Raw data (or null to read from path) | 原始数据 */
|
||||
data?: ArrayBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse GUID from 16-byte binary
|
||||
* 从 16 字节二进制解析 GUID
|
||||
*/
|
||||
export function parseGUIDFromBinary(bytes: Uint8Array): AssetGUID {
|
||||
const hex = Array.from(bytes)
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize GUID to 16-byte binary
|
||||
* 将 GUID 序列化为 16 字节二进制
|
||||
*/
|
||||
export function serializeGUIDToBinary(guid: AssetGUID): Uint8Array {
|
||||
const hex = guid.replace(/-/g, '');
|
||||
const bytes = new Uint8Array(16);
|
||||
|
||||
for (let i = 0; i < 16; i++) {
|
||||
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get type code from asset type string
|
||||
* 从资产类型字符串获取类型代码
|
||||
*/
|
||||
export function getAssetTypeCode(type: AssetType): number {
|
||||
return AssetTypeCode[type] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset type string from type code
|
||||
* 从类型代码获取资产类型字符串
|
||||
*/
|
||||
export function getAssetTypeFromCode(code: number): AssetType {
|
||||
for (const [type, typeCode] of Object.entries(AssetTypeCode)) {
|
||||
if (typeCode === code) {
|
||||
return type as AssetType;
|
||||
}
|
||||
}
|
||||
return 'binary';
|
||||
}
|
||||
130
packages/engine/asset-system/src/core/AssetCache.ts
Normal file
130
packages/engine/asset-system/src/core/AssetCache.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Asset cache implementation
|
||||
* 资产缓存实现
|
||||
*/
|
||||
|
||||
import { AssetGUID } from '../types/AssetTypes';
|
||||
|
||||
/**
|
||||
* Cache entry
|
||||
* 缓存条目
|
||||
*/
|
||||
interface CacheEntry {
|
||||
guid: AssetGUID;
|
||||
asset: unknown;
|
||||
lastAccessTime: number;
|
||||
accessCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset cache implementation
|
||||
* 资产缓存实现
|
||||
*/
|
||||
export class AssetCache {
|
||||
private readonly _cache = new Map<AssetGUID, CacheEntry>();
|
||||
|
||||
constructor() {
|
||||
// 无配置,无限制缓存 / No config, unlimited cache
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached asset
|
||||
* 获取缓存的资产
|
||||
*/
|
||||
get<T = unknown>(guid: AssetGUID): T | null {
|
||||
const entry = this._cache.get(guid);
|
||||
if (!entry) return null;
|
||||
|
||||
// 更新访问信息 / Update access info
|
||||
entry.lastAccessTime = Date.now();
|
||||
entry.accessCount++;
|
||||
|
||||
return entry.asset as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cached asset
|
||||
* 设置缓存的资产
|
||||
*/
|
||||
set<T = unknown>(guid: AssetGUID, asset: T): void {
|
||||
const now = Date.now();
|
||||
const entry: CacheEntry = {
|
||||
guid,
|
||||
asset,
|
||||
lastAccessTime: now,
|
||||
accessCount: 1
|
||||
};
|
||||
|
||||
// 如果已存在,更新 / Update if exists
|
||||
const oldEntry = this._cache.get(guid);
|
||||
if (oldEntry) {
|
||||
entry.accessCount = oldEntry.accessCount + 1;
|
||||
}
|
||||
|
||||
this._cache.set(guid, entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if asset is cached
|
||||
* 检查资产是否缓存
|
||||
*/
|
||||
has(guid: AssetGUID): boolean {
|
||||
return this._cache.has(guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove asset from cache
|
||||
* 从缓存移除资产
|
||||
*/
|
||||
remove(guid: AssetGUID): void {
|
||||
this._cache.delete(guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cache
|
||||
* 清空所有缓存
|
||||
*/
|
||||
clear(): void {
|
||||
this._cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache size
|
||||
* 获取缓存大小
|
||||
*/
|
||||
getSize(): number {
|
||||
return this._cache.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached GUIDs
|
||||
* 获取所有缓存的GUID
|
||||
*/
|
||||
getAllGuids(): AssetGUID[] {
|
||||
return Array.from(this._cache.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
* 获取缓存统计
|
||||
*/
|
||||
getStatistics(): {
|
||||
count: number;
|
||||
entries: Array<{
|
||||
guid: AssetGUID;
|
||||
accessCount: number;
|
||||
lastAccessTime: number;
|
||||
}>;
|
||||
} {
|
||||
const entries = Array.from(this._cache.values()).map((entry) => ({
|
||||
guid: entry.guid,
|
||||
accessCount: entry.accessCount,
|
||||
lastAccessTime: entry.lastAccessTime
|
||||
}));
|
||||
|
||||
return {
|
||||
count: this._cache.size,
|
||||
entries
|
||||
};
|
||||
}
|
||||
}
|
||||
577
packages/engine/asset-system/src/core/AssetDatabase.ts
Normal file
577
packages/engine/asset-system/src/core/AssetDatabase.ts
Normal file
@@ -0,0 +1,577 @@
|
||||
/**
|
||||
* Asset database for managing asset metadata
|
||||
* 用于管理资产元数据的资产数据库
|
||||
*/
|
||||
|
||||
import {
|
||||
AssetGUID,
|
||||
AssetType,
|
||||
IAssetMetadata,
|
||||
IAssetCatalogEntry
|
||||
} from '../types/AssetTypes';
|
||||
|
||||
/**
|
||||
* 纹理 Sprite 信息(从 meta 文件的 importSettings 读取)
|
||||
* Texture sprite info (read from meta file's importSettings)
|
||||
*/
|
||||
export interface ITextureSpriteInfo {
|
||||
/**
|
||||
* 九宫格切片边距 [top, right, bottom, left]
|
||||
* Nine-patch slice border
|
||||
*/
|
||||
sliceBorder?: [number, number, number, number];
|
||||
/**
|
||||
* Sprite 锚点 [x, y](0-1 归一化)
|
||||
* Sprite pivot point (0-1 normalized)
|
||||
*/
|
||||
pivot?: [number, number];
|
||||
/**
|
||||
* 纹理宽度(可选,需要纹理已加载)
|
||||
* Texture width (optional, requires texture to be loaded)
|
||||
*/
|
||||
width?: number;
|
||||
/**
|
||||
* 纹理高度(可选,需要纹理已加载)
|
||||
* Texture height (optional, requires texture to be loaded)
|
||||
*/
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprite settings in import settings
|
||||
* 导入设置中的 Sprite 设置
|
||||
*/
|
||||
interface ISpriteSettings {
|
||||
sliceBorder?: [number, number, number, number];
|
||||
pivot?: [number, number];
|
||||
pixelsPerUnit?: number;
|
||||
/** Texture width (from import settings) | 纹理宽度(来自导入设置) */
|
||||
width?: number;
|
||||
/** Texture height (from import settings) | 纹理高度(来自导入设置) */
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset database implementation
|
||||
* 资产数据库实现
|
||||
*/
|
||||
export class AssetDatabase {
|
||||
private readonly _metadata = new Map<AssetGUID, IAssetMetadata>();
|
||||
private readonly _pathToGuid = new Map<string, AssetGUID>();
|
||||
private readonly _typeToGuids = new Map<AssetType, Set<AssetGUID>>();
|
||||
private readonly _labelToGuids = new Map<string, Set<AssetGUID>>();
|
||||
private readonly _dependencies = new Map<AssetGUID, Set<AssetGUID>>();
|
||||
private readonly _dependents = new Map<AssetGUID, Set<AssetGUID>>();
|
||||
|
||||
/** Project root path for resolving relative paths. | 项目根路径,用于解析相对路径。 */
|
||||
private _projectRoot: string | null = null;
|
||||
|
||||
/**
|
||||
* Set project root path.
|
||||
* 设置项目根路径。
|
||||
*
|
||||
* @param path - Absolute path to project root. | 项目根目录的绝对路径。
|
||||
*/
|
||||
setProjectRoot(path: string): void {
|
||||
this._projectRoot = path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project root path.
|
||||
* 获取项目根路径。
|
||||
*/
|
||||
getProjectRoot(): string | null {
|
||||
return this._projectRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve relative path to absolute path.
|
||||
* 将相对路径解析为绝对路径。
|
||||
*
|
||||
* @param relativePath - Relative asset path (e.g., "assets/texture.png"). | 相对资产路径。
|
||||
* @returns Absolute file system path. | 绝对文件系统路径。
|
||||
*/
|
||||
resolveAbsolutePath(relativePath: string): string {
|
||||
// Already absolute path (Windows or Unix).
|
||||
// 已经是绝对路径。
|
||||
if (relativePath.match(/^[a-zA-Z]:/) || relativePath.startsWith('/')) {
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
// No project root set, return as-is.
|
||||
// 未设置项目根路径,原样返回。
|
||||
if (!this._projectRoot) {
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
// Join with project root.
|
||||
// 与项目根路径拼接。
|
||||
const separator = this._projectRoot.includes('\\') ? '\\' : '/';
|
||||
const normalizedPath = relativePath.replace(/[/\\]/g, separator);
|
||||
return `${this._projectRoot}${separator}${normalizedPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert absolute path to relative path.
|
||||
* 将绝对路径转换为相对路径。
|
||||
*
|
||||
* @param absolutePath - Absolute file system path. | 绝对文件系统路径。
|
||||
* @returns Relative asset path, or null if not under project root. | 相对资产路径。
|
||||
*/
|
||||
toRelativePath(absolutePath: string): string | null {
|
||||
if (!this._projectRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedAbs = absolutePath.replace(/\\/g, '/');
|
||||
const normalizedRoot = this._projectRoot.replace(/\\/g, '/');
|
||||
|
||||
if (normalizedAbs.startsWith(normalizedRoot)) {
|
||||
return normalizedAbs.substring(normalizedRoot.length + 1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add asset to database
|
||||
* 添加资产到数据库
|
||||
*/
|
||||
addAsset(metadata: IAssetMetadata): void {
|
||||
const { guid, path, type, labels, dependencies } = metadata;
|
||||
|
||||
// 存储元数据 / Store metadata
|
||||
this._metadata.set(guid, metadata);
|
||||
this._pathToGuid.set(path, guid);
|
||||
|
||||
// 按类型索引 / Index by type
|
||||
if (!this._typeToGuids.has(type)) {
|
||||
this._typeToGuids.set(type, new Set());
|
||||
}
|
||||
this._typeToGuids.get(type)!.add(guid);
|
||||
|
||||
// 按标签索引 / Index by labels
|
||||
labels.forEach((label) => {
|
||||
if (!this._labelToGuids.has(label)) {
|
||||
this._labelToGuids.set(label, new Set());
|
||||
}
|
||||
this._labelToGuids.get(label)!.add(guid);
|
||||
});
|
||||
|
||||
// 建立依赖关系 / Establish dependencies
|
||||
this.updateDependencies(guid, dependencies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove asset from database
|
||||
* 从数据库移除资产
|
||||
*/
|
||||
removeAsset(guid: AssetGUID): void {
|
||||
const metadata = this._metadata.get(guid);
|
||||
if (!metadata) return;
|
||||
|
||||
// 清理元数据 / Clean up metadata
|
||||
this._metadata.delete(guid);
|
||||
this._pathToGuid.delete(metadata.path);
|
||||
|
||||
// 清理类型索引 / Clean up type index
|
||||
const typeSet = this._typeToGuids.get(metadata.type);
|
||||
if (typeSet) {
|
||||
typeSet.delete(guid);
|
||||
if (typeSet.size === 0) {
|
||||
this._typeToGuids.delete(metadata.type);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理标签索引 / Clean up label indices
|
||||
metadata.labels.forEach((label) => {
|
||||
const labelSet = this._labelToGuids.get(label);
|
||||
if (labelSet) {
|
||||
labelSet.delete(guid);
|
||||
if (labelSet.size === 0) {
|
||||
this._labelToGuids.delete(label);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 清理依赖关系 / Clean up dependencies
|
||||
this.clearDependencies(guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update asset metadata
|
||||
* 更新资产元数据
|
||||
*/
|
||||
updateAsset(guid: AssetGUID, updates: Partial<IAssetMetadata>): void {
|
||||
const metadata = this._metadata.get(guid);
|
||||
if (!metadata) return;
|
||||
|
||||
// 如果路径改变,更新索引 / Update index if path changed
|
||||
if (updates.path && updates.path !== metadata.path) {
|
||||
this._pathToGuid.delete(metadata.path);
|
||||
this._pathToGuid.set(updates.path, guid);
|
||||
}
|
||||
|
||||
// 如果类型改变,更新索引 / Update index if type changed
|
||||
if (updates.type && updates.type !== metadata.type) {
|
||||
const oldTypeSet = this._typeToGuids.get(metadata.type);
|
||||
if (oldTypeSet) {
|
||||
oldTypeSet.delete(guid);
|
||||
}
|
||||
|
||||
if (!this._typeToGuids.has(updates.type)) {
|
||||
this._typeToGuids.set(updates.type, new Set());
|
||||
}
|
||||
this._typeToGuids.get(updates.type)!.add(guid);
|
||||
}
|
||||
|
||||
// 如果依赖改变,更新关系 / Update relations if dependencies changed
|
||||
if (updates.dependencies) {
|
||||
this.updateDependencies(guid, updates.dependencies);
|
||||
}
|
||||
|
||||
// 合并更新 / Merge updates
|
||||
Object.assign(metadata, updates);
|
||||
metadata.lastModified = Date.now();
|
||||
metadata.version++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset metadata
|
||||
* 获取资产元数据
|
||||
*/
|
||||
getMetadata(guid: AssetGUID): IAssetMetadata | undefined {
|
||||
return this._metadata.get(guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata by path
|
||||
* 通过路径获取元数据
|
||||
*/
|
||||
getMetadataByPath(path: string): IAssetMetadata | undefined {
|
||||
const guid = this._pathToGuid.get(path);
|
||||
return guid ? this._metadata.get(guid) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get texture sprite info from metadata
|
||||
* 从元数据获取纹理 Sprite 信息
|
||||
*
|
||||
* Extracts spriteSettings from importSettings if available.
|
||||
* 如果可用,从 importSettings 提取 spriteSettings。
|
||||
*
|
||||
* @param guid - Texture asset GUID | 纹理资产 GUID
|
||||
* @returns Sprite info or undefined if not found/not a texture | Sprite 信息或未找到/非纹理则为 undefined
|
||||
*/
|
||||
getTextureSpriteInfo(guid: AssetGUID): ITextureSpriteInfo | undefined {
|
||||
const metadata = this._metadata.get(guid);
|
||||
if (!metadata) return undefined;
|
||||
|
||||
// Check if it's a texture asset
|
||||
// 检查是否是纹理资产
|
||||
if (metadata.type !== AssetType.Texture) return undefined;
|
||||
|
||||
// Extract spriteSettings from importSettings
|
||||
// 从 importSettings 提取 spriteSettings
|
||||
const importSettings = metadata.importSettings as Record<string, unknown> | undefined;
|
||||
const spriteSettings = importSettings?.spriteSettings as ISpriteSettings | undefined;
|
||||
|
||||
if (!spriteSettings) return undefined;
|
||||
|
||||
return {
|
||||
sliceBorder: spriteSettings.sliceBorder,
|
||||
pivot: spriteSettings.pivot,
|
||||
// Include dimensions from import settings if available
|
||||
// 如果可用,包含来自导入设置的尺寸
|
||||
width: spriteSettings.width,
|
||||
height: spriteSettings.height
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find assets by type
|
||||
* 按类型查找资产
|
||||
*/
|
||||
findAssetsByType(type: AssetType): AssetGUID[] {
|
||||
const guids = this._typeToGuids.get(type);
|
||||
return guids ? Array.from(guids) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find assets by label
|
||||
* 按标签查找资产
|
||||
*/
|
||||
findAssetsByLabel(label: string): AssetGUID[] {
|
||||
const guids = this._labelToGuids.get(label);
|
||||
return guids ? Array.from(guids) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find assets by multiple labels (AND operation)
|
||||
* 按多个标签查找资产(AND操作)
|
||||
*/
|
||||
findAssetsByLabels(labels: string[]): AssetGUID[] {
|
||||
if (labels.length === 0) return [];
|
||||
|
||||
let result: Set<AssetGUID> | null = null;
|
||||
|
||||
for (const label of labels) {
|
||||
const labelGuids = this._labelToGuids.get(label);
|
||||
if (!labelGuids || labelGuids.size === 0) return [];
|
||||
|
||||
if (!result) {
|
||||
result = new Set(labelGuids);
|
||||
} else {
|
||||
// 交集 / Intersection
|
||||
const intersection = new Set<AssetGUID>();
|
||||
labelGuids.forEach((guid) => {
|
||||
if (result!.has(guid)) {
|
||||
intersection.add(guid);
|
||||
}
|
||||
});
|
||||
result = intersection;
|
||||
}
|
||||
}
|
||||
|
||||
return result ? Array.from(result) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Search assets by query
|
||||
* 通过查询搜索资产
|
||||
*/
|
||||
searchAssets(query: {
|
||||
name?: string;
|
||||
type?: AssetType;
|
||||
labels?: string[];
|
||||
path?: string;
|
||||
}): AssetGUID[] {
|
||||
let results = Array.from(this._metadata.keys());
|
||||
|
||||
// 按名称过滤 / Filter by name
|
||||
if (query.name) {
|
||||
const nameLower = query.name.toLowerCase();
|
||||
results = results.filter((guid) => {
|
||||
const metadata = this._metadata.get(guid)!;
|
||||
return metadata.name.toLowerCase().includes(nameLower);
|
||||
});
|
||||
}
|
||||
|
||||
// 按类型过滤 / Filter by type
|
||||
if (query.type) {
|
||||
const typeGuids = this._typeToGuids.get(query.type);
|
||||
if (!typeGuids) return [];
|
||||
results = results.filter((guid) => typeGuids.has(guid));
|
||||
}
|
||||
|
||||
// 按标签过滤 / Filter by labels
|
||||
if (query.labels && query.labels.length > 0) {
|
||||
const labelResults = this.findAssetsByLabels(query.labels);
|
||||
const labelSet = new Set(labelResults);
|
||||
results = results.filter((guid) => labelSet.has(guid));
|
||||
}
|
||||
|
||||
// 按路径过滤 / Filter by path
|
||||
if (query.path) {
|
||||
const pathLower = query.path.toLowerCase();
|
||||
results = results.filter((guid) => {
|
||||
const metadata = this._metadata.get(guid)!;
|
||||
return metadata.path.toLowerCase().includes(pathLower);
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset dependencies
|
||||
* 获取资产依赖
|
||||
*/
|
||||
getDependencies(guid: AssetGUID): AssetGUID[] {
|
||||
const deps = this._dependencies.get(guid);
|
||||
return deps ? Array.from(deps) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset dependents (assets that depend on this one)
|
||||
* 获取资产的依赖者(依赖此资产的其他资产)
|
||||
*/
|
||||
getDependents(guid: AssetGUID): AssetGUID[] {
|
||||
const deps = this._dependents.get(guid);
|
||||
return deps ? Array.from(deps) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all dependencies recursively
|
||||
* 递归获取所有依赖
|
||||
*/
|
||||
getAllDependencies(guid: AssetGUID, visited = new Set<AssetGUID>()): AssetGUID[] {
|
||||
if (visited.has(guid)) return [];
|
||||
visited.add(guid);
|
||||
|
||||
const result: AssetGUID[] = [];
|
||||
const directDeps = this.getDependencies(guid);
|
||||
|
||||
for (const dep of directDeps) {
|
||||
result.push(dep);
|
||||
const transitiveDeps = this.getAllDependencies(dep, visited);
|
||||
result.push(...transitiveDeps);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for circular dependencies
|
||||
* 检查循环依赖
|
||||
*/
|
||||
hasCircularDependency(guid: AssetGUID): boolean {
|
||||
const visited = new Set<AssetGUID>();
|
||||
const recursionStack = new Set<AssetGUID>();
|
||||
|
||||
const checkCycle = (current: AssetGUID): boolean => {
|
||||
visited.add(current);
|
||||
recursionStack.add(current);
|
||||
|
||||
const deps = this.getDependencies(current);
|
||||
for (const dep of deps) {
|
||||
if (!visited.has(dep)) {
|
||||
if (checkCycle(dep)) return true;
|
||||
} else if (recursionStack.has(dep)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
recursionStack.delete(current);
|
||||
return false;
|
||||
};
|
||||
|
||||
return checkCycle(guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update dependencies
|
||||
* 更新依赖关系
|
||||
*/
|
||||
private updateDependencies(guid: AssetGUID, newDependencies: AssetGUID[]): void {
|
||||
// 清除旧的依赖关系 / Clear old dependencies
|
||||
this.clearDependencies(guid);
|
||||
|
||||
// 建立新的依赖关系 / Establish new dependencies
|
||||
if (newDependencies.length > 0) {
|
||||
this._dependencies.set(guid, new Set(newDependencies));
|
||||
|
||||
// 更新被依赖关系 / Update dependent relations
|
||||
newDependencies.forEach((dep) => {
|
||||
if (!this._dependents.has(dep)) {
|
||||
this._dependents.set(dep, new Set());
|
||||
}
|
||||
this._dependents.get(dep)!.add(guid);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear dependencies
|
||||
* 清除依赖关系
|
||||
*/
|
||||
private clearDependencies(guid: AssetGUID): void {
|
||||
// 清除依赖 / Clear dependencies
|
||||
const deps = this._dependencies.get(guid);
|
||||
if (deps) {
|
||||
deps.forEach((dep) => {
|
||||
const dependents = this._dependents.get(dep);
|
||||
if (dependents) {
|
||||
dependents.delete(guid);
|
||||
if (dependents.size === 0) {
|
||||
this._dependents.delete(dep);
|
||||
}
|
||||
}
|
||||
});
|
||||
this._dependencies.delete(guid);
|
||||
}
|
||||
|
||||
// 清除被依赖 / Clear dependents
|
||||
const dependents = this._dependents.get(guid);
|
||||
if (dependents) {
|
||||
dependents.forEach((dependent) => {
|
||||
const dependencies = this._dependencies.get(dependent);
|
||||
if (dependencies) {
|
||||
dependencies.delete(guid);
|
||||
if (dependencies.size === 0) {
|
||||
this._dependencies.delete(dependent);
|
||||
}
|
||||
}
|
||||
});
|
||||
this._dependents.delete(guid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database statistics
|
||||
* 获取数据库统计
|
||||
*/
|
||||
getStatistics(): {
|
||||
totalAssets: number;
|
||||
assetsByType: Map<AssetType, number>;
|
||||
totalDependencies: number;
|
||||
assetsWithDependencies: number;
|
||||
circularDependencies: number;
|
||||
} {
|
||||
const assetsByType = new Map<AssetType, number>();
|
||||
this._typeToGuids.forEach((guids, type) => {
|
||||
assetsByType.set(type, guids.size);
|
||||
});
|
||||
|
||||
let circularDependencies = 0;
|
||||
this._metadata.forEach((_, guid) => {
|
||||
if (this.hasCircularDependency(guid)) {
|
||||
circularDependencies++;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
totalAssets: this._metadata.size,
|
||||
assetsByType,
|
||||
totalDependencies: Array.from(this._dependencies.values()).reduce(
|
||||
(sum, deps) => sum + deps.size,
|
||||
0
|
||||
),
|
||||
assetsWithDependencies: this._dependencies.size,
|
||||
circularDependencies
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export to catalog entries
|
||||
* 导出为目录条目
|
||||
*/
|
||||
exportToCatalog(): IAssetCatalogEntry[] {
|
||||
const entries: IAssetCatalogEntry[] = [];
|
||||
|
||||
this._metadata.forEach((metadata) => {
|
||||
entries.push({
|
||||
guid: metadata.guid,
|
||||
path: metadata.path,
|
||||
type: metadata.type,
|
||||
size: metadata.size,
|
||||
hash: metadata.hash
|
||||
});
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear database
|
||||
* 清空数据库
|
||||
*/
|
||||
clear(): void {
|
||||
this._metadata.clear();
|
||||
this._pathToGuid.clear();
|
||||
this._typeToGuids.clear();
|
||||
this._labelToGuids.clear();
|
||||
this._dependencies.clear();
|
||||
this._dependents.clear();
|
||||
}
|
||||
}
|
||||
193
packages/engine/asset-system/src/core/AssetLoadQueue.ts
Normal file
193
packages/engine/asset-system/src/core/AssetLoadQueue.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Priority-based asset loading queue
|
||||
* 基于优先级的资产加载队列
|
||||
*/
|
||||
|
||||
import { AssetGUID, IAssetLoadOptions } from '../types/AssetTypes';
|
||||
import { IAssetLoadQueue } from '../interfaces/IAssetManager';
|
||||
|
||||
/**
|
||||
* Queue item
|
||||
* 队列项
|
||||
*/
|
||||
interface QueueItem {
|
||||
guid: AssetGUID;
|
||||
priority: number;
|
||||
options?: IAssetLoadOptions;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset load queue implementation
|
||||
* 资产加载队列实现
|
||||
*/
|
||||
export class AssetLoadQueue implements IAssetLoadQueue {
|
||||
private readonly _queue: QueueItem[] = [];
|
||||
private readonly _guidToIndex = new Map<AssetGUID, number>();
|
||||
|
||||
/**
|
||||
* Add to queue
|
||||
* 添加到队列
|
||||
*/
|
||||
enqueue(guid: AssetGUID, priority: number, options?: IAssetLoadOptions): void {
|
||||
// 检查是否已在队列中 / Check if already in queue
|
||||
if (this._guidToIndex.has(guid)) {
|
||||
this.reprioritize(guid, priority);
|
||||
return;
|
||||
}
|
||||
|
||||
const item: QueueItem = {
|
||||
guid,
|
||||
priority,
|
||||
options,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// 二分查找插入位置 / Binary search for insertion position
|
||||
const index = this.findInsertIndex(priority);
|
||||
this._queue.splice(index, 0, item);
|
||||
|
||||
// 更新索引映射 / Update index mapping
|
||||
this.updateIndices(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove from queue
|
||||
* 从队列移除
|
||||
*/
|
||||
dequeue(): { guid: AssetGUID; options?: IAssetLoadOptions } | null {
|
||||
if (this._queue.length === 0) return null;
|
||||
|
||||
const item = this._queue.shift();
|
||||
if (!item) return null;
|
||||
|
||||
// 更新索引映射 / Update index mapping
|
||||
this._guidToIndex.delete(item.guid);
|
||||
this.updateIndices(0);
|
||||
|
||||
return {
|
||||
guid: item.guid,
|
||||
options: item.options
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if queue is empty
|
||||
* 检查队列是否为空
|
||||
*/
|
||||
isEmpty(): boolean {
|
||||
return this._queue.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue size
|
||||
* 获取队列大小
|
||||
*/
|
||||
getSize(): number {
|
||||
return this._queue.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear queue
|
||||
* 清空队列
|
||||
*/
|
||||
clear(): void {
|
||||
this._queue.length = 0;
|
||||
this._guidToIndex.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reprioritize item
|
||||
* 重新设置优先级
|
||||
*/
|
||||
reprioritize(guid: AssetGUID, newPriority: number): void {
|
||||
const index = this._guidToIndex.get(guid);
|
||||
if (index === undefined) return;
|
||||
|
||||
const item = this._queue[index];
|
||||
if (!item || item.priority === newPriority) return;
|
||||
|
||||
// 移除旧项 / Remove old item
|
||||
this._queue.splice(index, 1);
|
||||
this._guidToIndex.delete(guid);
|
||||
|
||||
// 重新插入 / Reinsert with new priority
|
||||
item.priority = newPriority;
|
||||
const newIndex = this.findInsertIndex(newPriority);
|
||||
this._queue.splice(newIndex, 0, item);
|
||||
|
||||
// 更新索引 / Update indices
|
||||
this.updateIndices(Math.min(index, newIndex));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find insertion index for priority
|
||||
* 查找优先级的插入索引
|
||||
*/
|
||||
private findInsertIndex(priority: number): number {
|
||||
let left = 0;
|
||||
let right = this._queue.length;
|
||||
|
||||
while (left < right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
// 高优先级在前 / Higher priority first
|
||||
if (this._queue[mid].priority >= priority) {
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid;
|
||||
}
|
||||
}
|
||||
|
||||
return left;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update indices after modification
|
||||
* 修改后更新索引
|
||||
*/
|
||||
private updateIndices(startIndex: number): void {
|
||||
for (let i = startIndex; i < this._queue.length; i++) {
|
||||
this._guidToIndex.set(this._queue[i].guid, i);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue items (for debugging)
|
||||
* 获取队列项(用于调试)
|
||||
*/
|
||||
getItems(): ReadonlyArray<{
|
||||
guid: AssetGUID;
|
||||
priority: number;
|
||||
waitTime: number;
|
||||
}> {
|
||||
const now = Date.now();
|
||||
return this._queue.map((item) => ({
|
||||
guid: item.guid,
|
||||
priority: item.priority,
|
||||
waitTime: now - item.timestamp
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove specific item from queue
|
||||
* 从队列中移除特定项
|
||||
*/
|
||||
remove(guid: AssetGUID): boolean {
|
||||
const index = this._guidToIndex.get(guid);
|
||||
if (index === undefined) return false;
|
||||
|
||||
this._queue.splice(index, 1);
|
||||
this._guidToIndex.delete(guid);
|
||||
this.updateIndices(index);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if guid is in queue
|
||||
* 检查guid是否在队列中
|
||||
*/
|
||||
contains(guid: AssetGUID): boolean {
|
||||
return this._guidToIndex.has(guid);
|
||||
}
|
||||
}
|
||||
738
packages/engine/asset-system/src/core/AssetManager.ts
Normal file
738
packages/engine/asset-system/src/core/AssetManager.ts
Normal file
@@ -0,0 +1,738 @@
|
||||
/**
|
||||
* Asset manager implementation
|
||||
* 资产管理器实现
|
||||
*/
|
||||
|
||||
import {
|
||||
AssetGUID,
|
||||
AssetHandle,
|
||||
AssetType,
|
||||
AssetState,
|
||||
IAssetLoadOptions,
|
||||
IAssetLoadResult,
|
||||
IAssetReferenceInfo,
|
||||
IAssetPreloadGroup,
|
||||
IAssetLoadProgress,
|
||||
IAssetMetadata,
|
||||
AssetLoadError,
|
||||
IAssetCatalog
|
||||
} from '../types/AssetTypes';
|
||||
import {
|
||||
IAssetManager,
|
||||
IAssetLoadQueue
|
||||
} from '../interfaces/IAssetManager';
|
||||
import { IAssetLoader, IAssetLoaderFactory, IAssetParseContext } from '../interfaces/IAssetLoader';
|
||||
import { IAssetReader, IAssetContent } from '../interfaces/IAssetReader';
|
||||
import { AssetCache } from './AssetCache';
|
||||
import { AssetLoadQueue } from './AssetLoadQueue';
|
||||
import { AssetLoaderFactory } from '../loaders/AssetLoaderFactory';
|
||||
import { AssetDatabase } from './AssetDatabase';
|
||||
|
||||
/**
|
||||
* Asset entry in the manager
|
||||
* 管理器中的资产条目
|
||||
*/
|
||||
interface AssetEntry {
|
||||
guid: AssetGUID;
|
||||
handle: AssetHandle;
|
||||
asset: unknown;
|
||||
metadata: IAssetMetadata;
|
||||
state: AssetState;
|
||||
referenceCount: number;
|
||||
lastAccessTime: number;
|
||||
loadPromise?: Promise<IAssetLoadResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset manager implementation
|
||||
* 资产管理器实现
|
||||
*/
|
||||
export class AssetManager implements IAssetManager {
|
||||
private readonly _assets = new Map<AssetGUID, AssetEntry>();
|
||||
private readonly _handleToGuid = new Map<AssetHandle, AssetGUID>();
|
||||
private readonly _pathToGuid = new Map<string, AssetGUID>();
|
||||
private readonly _cache: AssetCache;
|
||||
private readonly _loadQueue: IAssetLoadQueue;
|
||||
private readonly _loaderFactory: IAssetLoaderFactory;
|
||||
private readonly _database: AssetDatabase;
|
||||
|
||||
/** Asset reader for file operations. | 用于文件操作的资产读取器。 */
|
||||
private _reader: IAssetReader | null = null;
|
||||
|
||||
private _nextHandle: AssetHandle = 1;
|
||||
|
||||
private _statistics = {
|
||||
loadedCount: 0,
|
||||
failedCount: 0
|
||||
};
|
||||
|
||||
private _isDisposed = false;
|
||||
private _loadingCount = 0;
|
||||
|
||||
constructor(catalog?: IAssetCatalog) {
|
||||
this._cache = new AssetCache();
|
||||
this._loadQueue = new AssetLoadQueue();
|
||||
this._loaderFactory = new AssetLoaderFactory();
|
||||
this._database = new AssetDatabase();
|
||||
|
||||
if (catalog) {
|
||||
this.initializeFromCatalog(catalog);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set asset reader.
|
||||
* 设置资产读取器。
|
||||
*/
|
||||
setReader(reader: IAssetReader): void {
|
||||
this._reader = reader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set project root path for resolving relative paths.
|
||||
* 设置项目根路径用于解析相对路径。
|
||||
*/
|
||||
setProjectRoot(path: string): void {
|
||||
this._database.setProjectRoot(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the asset database.
|
||||
* 获取资产数据库。
|
||||
*/
|
||||
getDatabase(): AssetDatabase {
|
||||
return this._database;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the loader factory.
|
||||
* 获取加载器工厂。
|
||||
*/
|
||||
getLoaderFactory(): AssetLoaderFactory {
|
||||
return this._loaderFactory as AssetLoaderFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize from catalog
|
||||
* 从目录初始化
|
||||
*
|
||||
* Can be called after construction to load catalog entries.
|
||||
* 可在构造后调用以加载目录条目。
|
||||
*/
|
||||
initializeFromCatalog(catalog: IAssetCatalog): void {
|
||||
for (const [guid, entry] of Object.entries(catalog.entries)) {
|
||||
const metadata: IAssetMetadata = {
|
||||
guid,
|
||||
path: entry.path,
|
||||
type: entry.type,
|
||||
name: entry.path.split('/').pop() || '',
|
||||
size: entry.size,
|
||||
hash: entry.hash,
|
||||
dependencies: [],
|
||||
labels: [],
|
||||
tags: new Map(),
|
||||
lastModified: Date.now(),
|
||||
version: 1,
|
||||
// Include importSettings for sprite slicing (nine-patch), etc.
|
||||
// 包含 importSettings 以支持精灵切片(九宫格)等功能
|
||||
importSettings: entry.importSettings
|
||||
};
|
||||
|
||||
this._database.addAsset(metadata);
|
||||
this._pathToGuid.set(entry.path, guid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load asset by GUID
|
||||
* 通过GUID加载资产
|
||||
*/
|
||||
async loadAsset<T = unknown>(
|
||||
guid: AssetGUID,
|
||||
options?: IAssetLoadOptions
|
||||
): Promise<IAssetLoadResult<T>> {
|
||||
// 检查是否已加载 / Check if already loaded
|
||||
const entry = this._assets.get(guid);
|
||||
if (entry) {
|
||||
if (entry.state === AssetState.Loaded && !options?.forceReload) {
|
||||
entry.lastAccessTime = Date.now();
|
||||
return {
|
||||
asset: entry.asset as T,
|
||||
handle: entry.handle,
|
||||
metadata: entry.metadata,
|
||||
loadTime: 0
|
||||
};
|
||||
}
|
||||
|
||||
if (entry.state === AssetState.Loading && entry.loadPromise) {
|
||||
return entry.loadPromise as Promise<IAssetLoadResult<T>>;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取元数据 / Get metadata
|
||||
const metadata = this._database.getMetadata(guid);
|
||||
if (!metadata) {
|
||||
throw AssetLoadError.fileNotFound(guid, 'Unknown');
|
||||
}
|
||||
|
||||
// 创建加载器 / Create loader
|
||||
// 优先使用基于路径的加载器选择,支持多个加载器对应同一资产类型
|
||||
// 例如 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
|
||||
if (!loader && metadata.type === AssetType.Custom) {
|
||||
const newType = this.resolveAssetType(metadata.path);
|
||||
if (newType !== AssetType.Custom) {
|
||||
// 更新 metadata 类型 / Update metadata type
|
||||
this._database.updateAsset(guid, { type: newType });
|
||||
loader = this._loaderFactory.createLoader(newType);
|
||||
}
|
||||
}
|
||||
|
||||
if (!loader) {
|
||||
throw AssetLoadError.unsupportedType(guid, metadata.type);
|
||||
}
|
||||
|
||||
// 开始加载 / Start loading
|
||||
const loadStartTime = performance.now();
|
||||
const newEntry: AssetEntry = {
|
||||
guid,
|
||||
handle: this._nextHandle++,
|
||||
asset: null,
|
||||
metadata,
|
||||
state: AssetState.Loading,
|
||||
referenceCount: 0,
|
||||
lastAccessTime: Date.now()
|
||||
};
|
||||
|
||||
this._assets.set(guid, newEntry);
|
||||
this._handleToGuid.set(newEntry.handle, guid);
|
||||
this._loadingCount++;
|
||||
|
||||
// 创建加载Promise / Create loading promise
|
||||
const loadPromise = this.performLoad<T>(loader, metadata, options, loadStartTime, newEntry);
|
||||
newEntry.loadPromise = loadPromise;
|
||||
|
||||
try {
|
||||
const result = await loadPromise;
|
||||
return result;
|
||||
} catch (error) {
|
||||
this._statistics.failedCount++;
|
||||
newEntry.state = AssetState.Failed;
|
||||
throw error;
|
||||
} finally {
|
||||
this._loadingCount--;
|
||||
delete newEntry.loadPromise;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform asset loading
|
||||
* 执行资产加载
|
||||
*/
|
||||
private async performLoad<T>(
|
||||
loader: IAssetLoader,
|
||||
metadata: IAssetMetadata,
|
||||
options: IAssetLoadOptions | undefined,
|
||||
startTime: number,
|
||||
entry: AssetEntry
|
||||
): Promise<IAssetLoadResult<T>> {
|
||||
if (!this._reader) {
|
||||
throw new Error('Asset reader not set. Call setReader() first.');
|
||||
}
|
||||
|
||||
// Load dependencies first.
|
||||
// 先加载依赖。
|
||||
if (metadata.dependencies.length > 0) {
|
||||
await this.loadDependencies(metadata.dependencies, options);
|
||||
}
|
||||
|
||||
// Resolve absolute path.
|
||||
// 解析绝对路径。
|
||||
const absolutePath = this._database.resolveAbsolutePath(metadata.path);
|
||||
|
||||
// Read content based on loader's content type.
|
||||
// 根据加载器的内容类型读取内容。
|
||||
const content = await this.readContent(loader.contentType, absolutePath);
|
||||
|
||||
// Create parse context.
|
||||
// 创建解析上下文。
|
||||
const context: IAssetParseContext = {
|
||||
metadata,
|
||||
options,
|
||||
loadDependency: async <D>(relativePath: string) => {
|
||||
const result = await this.loadAssetByPath<D>(relativePath, options);
|
||||
return result.asset;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse asset.
|
||||
// 解析资产。
|
||||
const asset = await loader.parse(content, context);
|
||||
|
||||
// Update entry.
|
||||
// 更新条目。
|
||||
entry.asset = asset;
|
||||
entry.state = AssetState.Loaded;
|
||||
|
||||
// Cache asset.
|
||||
// 缓存资产。
|
||||
this._cache.set(metadata.guid, asset);
|
||||
|
||||
// Update statistics.
|
||||
// 更新统计。
|
||||
this._statistics.loadedCount++;
|
||||
|
||||
return {
|
||||
asset: asset as T,
|
||||
handle: entry.handle,
|
||||
metadata,
|
||||
loadTime: performance.now() - startTime
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read content based on content type.
|
||||
* 根据内容类型读取内容。
|
||||
*/
|
||||
private async readContent(contentType: string, absolutePath: string): Promise<IAssetContent> {
|
||||
if (!this._reader) {
|
||||
throw new Error('Asset reader not set');
|
||||
}
|
||||
|
||||
switch (contentType) {
|
||||
case 'text': {
|
||||
const text = await this._reader.readText(absolutePath);
|
||||
return { type: 'text', text };
|
||||
}
|
||||
case 'binary': {
|
||||
const binary = await this._reader.readBinary(absolutePath);
|
||||
return { type: 'binary', binary };
|
||||
}
|
||||
case 'image': {
|
||||
const image = await this._reader.loadImage(absolutePath);
|
||||
return { type: 'image', image };
|
||||
}
|
||||
case 'audio': {
|
||||
const audioBuffer = await this._reader.loadAudio(absolutePath);
|
||||
return { type: 'audio', audioBuffer };
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown content type: ${contentType}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load dependencies
|
||||
* 加载依赖
|
||||
*/
|
||||
private async loadDependencies(
|
||||
dependencies: AssetGUID[],
|
||||
options?: IAssetLoadOptions
|
||||
): Promise<void> {
|
||||
const promises = dependencies.map((dep) => this.loadAsset(dep, options));
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load asset by path
|
||||
* 通过路径加载资产
|
||||
*/
|
||||
async loadAssetByPath<T = unknown>(
|
||||
path: string,
|
||||
options?: IAssetLoadOptions
|
||||
): Promise<IAssetLoadResult<T>> {
|
||||
const guid = this._pathToGuid.get(path);
|
||||
if (!guid) {
|
||||
// 尝试从数据库查找 / Try to find from database
|
||||
let metadata = this._database.getMetadataByPath(path);
|
||||
if (!metadata) {
|
||||
// 动态创建元数据 / Create metadata dynamically
|
||||
const assetType = this.resolveAssetType(path);
|
||||
|
||||
// 生成唯一GUID / Generate unique GUID
|
||||
const dynamicGuid = `dynamic_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
metadata = {
|
||||
guid: dynamicGuid,
|
||||
path: path,
|
||||
type: assetType,
|
||||
name: path.split('/').pop() || path.split('\\').pop() || 'unnamed',
|
||||
size: 0, // 动态加载时未知大小 / Unknown size for dynamic loading
|
||||
hash: '',
|
||||
dependencies: [],
|
||||
labels: [],
|
||||
tags: new Map(),
|
||||
lastModified: Date.now(),
|
||||
version: 1
|
||||
};
|
||||
|
||||
// 注册到数据库 / Register to database
|
||||
this._database.addAsset(metadata);
|
||||
this._pathToGuid.set(path, metadata.guid);
|
||||
} else {
|
||||
// 如果之前缓存的类型是 Custom,检查是否现在有注册的 loader 可以处理
|
||||
// If previously cached as Custom, check if a registered loader can now handle it
|
||||
if (metadata.type === AssetType.Custom) {
|
||||
const newType = this.resolveAssetType(path);
|
||||
if (newType !== AssetType.Custom) {
|
||||
metadata.type = newType;
|
||||
}
|
||||
}
|
||||
this._pathToGuid.set(path, metadata.guid);
|
||||
}
|
||||
|
||||
return this.loadAsset<T>(metadata.guid, options);
|
||||
}
|
||||
|
||||
// 同样检查已缓存的资产,如果类型是 Custom 但现在有 loader 可以处理
|
||||
// Also check cached assets, if type is Custom but now a loader can handle it
|
||||
const entry = this._assets.get(guid);
|
||||
if (entry && entry.metadata.type === AssetType.Custom) {
|
||||
const newType = this.resolveAssetType(path);
|
||||
if (newType !== AssetType.Custom) {
|
||||
entry.metadata.type = newType;
|
||||
}
|
||||
}
|
||||
|
||||
return this.loadAsset<T>(guid, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve asset type from path
|
||||
* 从路径解析资产类型
|
||||
*/
|
||||
private resolveAssetType(path: string): AssetType {
|
||||
// 首先尝试从已注册的加载器获取资产类型 / First try to get asset type from registered loaders
|
||||
const loaderType = (this._loaderFactory as AssetLoaderFactory).getAssetTypeByPath(path);
|
||||
if (loaderType !== null) {
|
||||
return loaderType;
|
||||
}
|
||||
|
||||
// 如果没有找到匹配的加载器,使用默认的扩展名映射 / Fallback to default extension mapping
|
||||
const fileExt = path.substring(path.lastIndexOf('.')).toLowerCase();
|
||||
|
||||
// 默认支持的基础类型 / Default supported basic types
|
||||
if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(fileExt)) {
|
||||
return AssetType.Texture;
|
||||
} else if (['.json'].includes(fileExt)) {
|
||||
return AssetType.Json;
|
||||
} else if (['.txt', '.md', '.xml', '.yaml'].includes(fileExt)) {
|
||||
return AssetType.Text;
|
||||
}
|
||||
|
||||
return AssetType.Custom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load multiple assets
|
||||
* 批量加载资产
|
||||
*/
|
||||
async loadAssets(
|
||||
guids: AssetGUID[],
|
||||
options?: IAssetLoadOptions
|
||||
): Promise<Map<AssetGUID, IAssetLoadResult>> {
|
||||
const results = new Map<AssetGUID, IAssetLoadResult>();
|
||||
|
||||
// 并行加载所有资产 / Load all assets in parallel
|
||||
const promises = guids.map(async (guid) => {
|
||||
try {
|
||||
const result = await this.loadAsset(guid, options);
|
||||
results.set(guid, result);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load asset ${guid}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload asset group
|
||||
* 预加载资产组
|
||||
*/
|
||||
async preloadGroup(
|
||||
group: IAssetPreloadGroup,
|
||||
onProgress?: (progress: IAssetLoadProgress) => void
|
||||
): Promise<void> {
|
||||
const totalCount = group.assets.length;
|
||||
let loadedCount = 0;
|
||||
let loadedBytes = 0;
|
||||
let totalBytes = 0;
|
||||
|
||||
// 计算总大小 / Calculate total size
|
||||
for (const guid of group.assets) {
|
||||
const metadata = this._database.getMetadata(guid);
|
||||
if (metadata) {
|
||||
totalBytes += metadata.size;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载每个资产 / Load each asset
|
||||
for (const guid of group.assets) {
|
||||
const metadata = this._database.getMetadata(guid);
|
||||
if (!metadata) continue;
|
||||
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
currentAsset: metadata.name,
|
||||
loadedCount,
|
||||
totalCount,
|
||||
loadedBytes,
|
||||
totalBytes,
|
||||
progress: loadedCount / totalCount
|
||||
});
|
||||
}
|
||||
|
||||
await this.loadAsset(guid, { priority: group.priority });
|
||||
|
||||
loadedCount++;
|
||||
loadedBytes += metadata.size;
|
||||
}
|
||||
|
||||
// 最终进度 / Final progress
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
currentAsset: '',
|
||||
loadedCount: totalCount,
|
||||
totalCount,
|
||||
loadedBytes: totalBytes,
|
||||
totalBytes,
|
||||
progress: 1
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get loaded asset
|
||||
* 获取已加载的资产
|
||||
*/
|
||||
getAsset<T = unknown>(guid: AssetGUID): T | null {
|
||||
const entry = this._assets.get(guid);
|
||||
if (entry && entry.state === AssetState.Loaded) {
|
||||
entry.lastAccessTime = Date.now();
|
||||
return entry.asset as T;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset by handle
|
||||
* 通过句柄获取资产
|
||||
*/
|
||||
getAssetByHandle<T = unknown>(handle: AssetHandle): T | null {
|
||||
const guid = this._handleToGuid.get(handle);
|
||||
if (!guid) return null;
|
||||
return this.getAsset<T>(guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get loaded asset by path (synchronous)
|
||||
* 通过路径获取已加载的资产(同步)
|
||||
*
|
||||
* Returns the asset if it's already loaded, null otherwise.
|
||||
* 如果资产已加载则返回资产,否则返回 null。
|
||||
*/
|
||||
getAssetByPath<T = unknown>(path: string): T | null {
|
||||
const guid = this._pathToGuid.get(path);
|
||||
if (!guid) return null;
|
||||
return this.getAsset<T>(guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if asset is loaded
|
||||
* 检查资产是否已加载
|
||||
*/
|
||||
isLoaded(guid: AssetGUID): boolean {
|
||||
const entry = this._assets.get(guid);
|
||||
return entry?.state === AssetState.Loaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset state
|
||||
* 获取资产状态
|
||||
*/
|
||||
getAssetState(guid: AssetGUID): AssetState {
|
||||
const entry = this._assets.get(guid);
|
||||
return entry?.state || AssetState.Unloaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload asset
|
||||
* 卸载资产
|
||||
*/
|
||||
unloadAsset(guid: AssetGUID): void {
|
||||
const entry = this._assets.get(guid);
|
||||
if (!entry) return;
|
||||
|
||||
// 检查引用计数 / Check reference count
|
||||
if (entry.referenceCount > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取加载器以释放资源 / Get loader to dispose resources
|
||||
const loader = this._loaderFactory.createLoader(entry.metadata.type);
|
||||
if (loader) {
|
||||
loader.dispose(entry.asset);
|
||||
}
|
||||
|
||||
// 清理条目 / Clean up entry
|
||||
this._handleToGuid.delete(entry.handle);
|
||||
this._assets.delete(guid);
|
||||
this._cache.remove(guid);
|
||||
|
||||
// 更新统计 / Update statistics
|
||||
this._statistics.loadedCount--;
|
||||
|
||||
entry.state = AssetState.Unloaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload all assets
|
||||
* 卸载所有资产
|
||||
*/
|
||||
unloadAllAssets(): void {
|
||||
const guids = Array.from(this._assets.keys());
|
||||
guids.forEach((guid) => this.unloadAsset(guid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload unused assets
|
||||
* 卸载未使用的资产
|
||||
*/
|
||||
unloadUnusedAssets(): void {
|
||||
const guids = Array.from(this._assets.keys());
|
||||
guids.forEach((guid) => {
|
||||
const entry = this._assets.get(guid);
|
||||
if (entry && entry.referenceCount === 0) {
|
||||
this.unloadAsset(guid);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload assets by type
|
||||
* 按类型卸载资产
|
||||
*
|
||||
* This is useful for clearing texture caches when restoring scene snapshots.
|
||||
* 在恢复场景快照时清除纹理缓存时很有用。
|
||||
*
|
||||
* @param assetType 要卸载的资产类型 / Asset type to unload
|
||||
* @param bForce 是否强制卸载(忽略引用计数)/ Whether to force unload (ignore reference count)
|
||||
*/
|
||||
unloadAssetsByType(assetType: AssetType, bForce: boolean = false): void {
|
||||
const guids = Array.from(this._assets.keys());
|
||||
guids.forEach((guid) => {
|
||||
const entry = this._assets.get(guid);
|
||||
if (entry && entry.metadata.type === assetType) {
|
||||
if (bForce || entry.referenceCount === 0) {
|
||||
// 获取加载器以释放资源 / Get loader to dispose resources
|
||||
const loader = this._loaderFactory.createLoader(entry.metadata.type);
|
||||
if (loader) {
|
||||
loader.dispose(entry.asset);
|
||||
}
|
||||
|
||||
// 清理条目 / Clean up entry
|
||||
this._handleToGuid.delete(entry.handle);
|
||||
this._assets.delete(guid);
|
||||
this._cache.remove(guid);
|
||||
|
||||
// 更新统计 / Update statistics
|
||||
this._statistics.loadedCount--;
|
||||
|
||||
entry.state = AssetState.Unloaded;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add reference to asset
|
||||
* 增加资产引用
|
||||
*/
|
||||
addReference(guid: AssetGUID): void {
|
||||
const entry = this._assets.get(guid);
|
||||
if (entry) {
|
||||
entry.referenceCount++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove reference from asset
|
||||
* 移除资产引用
|
||||
*/
|
||||
removeReference(guid: AssetGUID): void {
|
||||
const entry = this._assets.get(guid);
|
||||
if (entry && entry.referenceCount > 0) {
|
||||
entry.referenceCount--;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reference info
|
||||
* 获取引用信息
|
||||
*/
|
||||
getReferenceInfo(guid: AssetGUID): IAssetReferenceInfo | null {
|
||||
const entry = this._assets.get(guid);
|
||||
if (!entry) return null;
|
||||
|
||||
return {
|
||||
guid,
|
||||
handle: entry.handle,
|
||||
referenceCount: entry.referenceCount,
|
||||
lastAccessTime: entry.lastAccessTime,
|
||||
state: entry.state
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Register custom loader
|
||||
* 注册自定义加载器
|
||||
*/
|
||||
registerLoader(type: AssetType, loader: IAssetLoader): void {
|
||||
this._loaderFactory.registerLoader(type, loader);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset statistics
|
||||
* 获取资产统计信息
|
||||
*/
|
||||
getStatistics(): { loadedCount: number; loadQueue: number; failedCount: number } {
|
||||
return {
|
||||
loadedCount: this._statistics.loadedCount,
|
||||
loadQueue: this._loadQueue.getSize(),
|
||||
failedCount: this._statistics.failedCount
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache
|
||||
* 清空缓存
|
||||
*/
|
||||
clearCache(): void {
|
||||
this._cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose manager
|
||||
* 释放管理器
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
|
||||
this.unloadAllAssets();
|
||||
this._cache.clear();
|
||||
this._loadQueue.clear();
|
||||
this._assets.clear();
|
||||
this._handleToGuid.clear();
|
||||
this._pathToGuid.clear();
|
||||
|
||||
this._isDisposed = true;
|
||||
}
|
||||
}
|
||||
235
packages/engine/asset-system/src/core/AssetPathResolver.ts
Normal file
235
packages/engine/asset-system/src/core/AssetPathResolver.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Asset path resolver for different platforms and protocols
|
||||
* 不同平台和协议的资产路径解析器
|
||||
*/
|
||||
|
||||
import { AssetPlatform } from '../types/AssetTypes';
|
||||
import { PathValidator } from '../utils/PathValidator';
|
||||
|
||||
/**
|
||||
* Asset path resolver configuration
|
||||
* 资产路径解析器配置
|
||||
*/
|
||||
export interface IAssetPathConfig {
|
||||
/** Base URL for web assets | Web资产的基础URL */
|
||||
baseUrl?: string;
|
||||
|
||||
/** Asset directory path | 资产目录路径 */
|
||||
assetDir?: string;
|
||||
|
||||
/** Asset host for asset:// protocol | 资产协议的主机名 */
|
||||
assetHost?: string;
|
||||
|
||||
/** Current platform | 当前平台 */
|
||||
platform?: AssetPlatform;
|
||||
|
||||
/** Custom path transformer | 自定义路径转换器 */
|
||||
pathTransformer?: (path: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset path resolver
|
||||
* 资产路径解析器
|
||||
*/
|
||||
export class AssetPathResolver {
|
||||
private config: IAssetPathConfig;
|
||||
|
||||
constructor(config: IAssetPathConfig = {}) {
|
||||
this.config = {
|
||||
baseUrl: '',
|
||||
assetDir: 'assets',
|
||||
platform: AssetPlatform.H5,
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
* 更新配置
|
||||
*/
|
||||
updateConfig(config: Partial<IAssetPathConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve asset path to full URL
|
||||
* 解析资产路径为完整URL
|
||||
*/
|
||||
resolve(path: string): string {
|
||||
// Validate input path
|
||||
const validation = PathValidator.validate(path);
|
||||
if (!validation.valid) {
|
||||
console.warn(`Invalid asset path: ${path} - ${validation.reason}`);
|
||||
// Sanitize the path instead of throwing
|
||||
path = PathValidator.sanitize(path);
|
||||
if (!path) {
|
||||
throw new Error(`Cannot resolve invalid path: ${validation.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Already a full URL
|
||||
// 已经是完整URL
|
||||
if (this.isAbsoluteUrl(path)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Data URL
|
||||
// 数据URL
|
||||
if (path.startsWith('data:')) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Normalize the path
|
||||
path = PathValidator.normalize(path);
|
||||
|
||||
// Apply custom transformer if provided
|
||||
// 应用自定义转换器(如果提供)
|
||||
if (this.config.pathTransformer) {
|
||||
path = this.config.pathTransformer(path);
|
||||
// Transformer output is trusted (may be absolute path or asset:// URL)
|
||||
// 转换器输出是可信的(可能是绝对路径或 asset:// URL)
|
||||
return path;
|
||||
}
|
||||
|
||||
// Platform-specific resolution
|
||||
// 平台特定解析
|
||||
switch (this.config.platform) {
|
||||
case AssetPlatform.H5:
|
||||
return this.resolveH5Path(path);
|
||||
|
||||
case AssetPlatform.WeChat:
|
||||
return this.resolveWeChatPath(path);
|
||||
|
||||
case AssetPlatform.Playable:
|
||||
return this.resolvePlayablePath(path);
|
||||
|
||||
case AssetPlatform.Android:
|
||||
case AssetPlatform.iOS:
|
||||
return this.resolveMobilePath(path);
|
||||
|
||||
case AssetPlatform.Editor:
|
||||
return this.resolveEditorPath(path);
|
||||
|
||||
default:
|
||||
return this.resolveH5Path(path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve path for H5 platform
|
||||
* 解析H5平台路径
|
||||
*/
|
||||
private resolveH5Path(path: string): string {
|
||||
// Remove leading slash if present
|
||||
// 移除开头的斜杠(如果存在)
|
||||
path = path.replace(/^\//, '');
|
||||
|
||||
// Combine with base URL and asset directory
|
||||
// 与基础URL和资产目录结合
|
||||
const base = this.config.baseUrl || (typeof window !== 'undefined' ? window.location.origin : '');
|
||||
const assetDir = this.config.assetDir || 'assets';
|
||||
|
||||
return `${base}/${assetDir}/${path}`.replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve path for WeChat Mini Game
|
||||
* 解析微信小游戏路径
|
||||
*/
|
||||
private resolveWeChatPath(path: string): string {
|
||||
// WeChat mini games use relative paths
|
||||
// 微信小游戏使用相对路径
|
||||
return `${this.config.assetDir}/${path}`.replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve path for Playable Ads platform
|
||||
* 解析试玩广告平台路径
|
||||
*/
|
||||
private resolvePlayablePath(path: string): string {
|
||||
// Playable ads typically use base64 embedded resources or relative paths
|
||||
// 试玩广告通常使用base64内嵌资源或相对路径
|
||||
|
||||
// If custom transformer is provided (e.g., for base64 encoding)
|
||||
// 如果提供了自定义转换器(例如用于base64编码)
|
||||
if (this.config.pathTransformer) {
|
||||
return this.config.pathTransformer(path);
|
||||
}
|
||||
|
||||
// Default to relative path without directory prefix
|
||||
// 默认使用不带目录前缀的相对路径
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve path for mobile platform (Android/iOS)
|
||||
* 解析移动平台路径(Android/iOS)
|
||||
*/
|
||||
private resolveMobilePath(path: string): string {
|
||||
// Mobile platforms use relative paths or file:// protocol
|
||||
// 移动平台使用相对路径或file://协议
|
||||
return `./${this.config.assetDir}/${path}`.replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve path for Editor platform (Tauri)
|
||||
* 解析编辑器平台路径(Tauri)
|
||||
*/
|
||||
private resolveEditorPath(path: string): string {
|
||||
// For Tauri editor, use pathTransformer if provided
|
||||
// 对于Tauri编辑器,使用pathTransformer(如果提供)
|
||||
if (this.config.pathTransformer) {
|
||||
return this.config.pathTransformer(path);
|
||||
}
|
||||
|
||||
// Use configurable asset host or default to 'localhost'
|
||||
// 使用可配置的资产主机或默认为 'localhost'
|
||||
const host = this.config.assetHost || 'localhost';
|
||||
const sanitizedPath = PathValidator.sanitize(path);
|
||||
return `asset://${host}/${sanitizedPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path is absolute URL
|
||||
* 检查路径是否为绝对URL
|
||||
*/
|
||||
private isAbsoluteUrl(path: string): boolean {
|
||||
return /^(https?:\/\/|file:\/\/|asset:\/\/)/.test(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset directory from path
|
||||
* 从路径获取资产目录
|
||||
*/
|
||||
getAssetDirectory(path: string): string {
|
||||
const resolved = this.resolve(path);
|
||||
const lastSlash = resolved.lastIndexOf('/');
|
||||
return lastSlash >= 0 ? resolved.substring(0, lastSlash) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset filename from path
|
||||
* 从路径获取资产文件名
|
||||
*/
|
||||
getAssetFilename(path: string): string {
|
||||
const resolved = this.resolve(path);
|
||||
const lastSlash = resolved.lastIndexOf('/');
|
||||
return lastSlash >= 0 ? resolved.substring(lastSlash + 1) : resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join paths
|
||||
* 连接路径
|
||||
*/
|
||||
join(...paths: string[]): string {
|
||||
return paths.join('/').replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize path
|
||||
* 规范化路径
|
||||
*/
|
||||
normalize(path: string): string {
|
||||
return path.replace(/\\/g, '/').replace(/\/+/g, '/');
|
||||
}
|
||||
}
|
||||
338
packages/engine/asset-system/src/core/AssetReference.ts
Normal file
338
packages/engine/asset-system/src/core/AssetReference.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Asset reference for lazy loading
|
||||
* 用于懒加载的资产引用
|
||||
*/
|
||||
|
||||
import { AssetGUID, IAssetLoadOptions, AssetState } from '../types/AssetTypes';
|
||||
import { IAssetManager } from '../interfaces/IAssetManager';
|
||||
|
||||
/**
|
||||
* Asset reference class for lazy loading
|
||||
* 懒加载资产引用类
|
||||
*/
|
||||
export class AssetReference<T = unknown> {
|
||||
private _guid: AssetGUID;
|
||||
private _asset?: T;
|
||||
private _loadPromise?: Promise<T>;
|
||||
private _manager?: IAssetManager;
|
||||
private _isReleased = false;
|
||||
private _autoRelease = false;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
* 构造函数
|
||||
*/
|
||||
constructor(guid: AssetGUID, manager?: IAssetManager) {
|
||||
this._guid = guid;
|
||||
this._manager = manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset GUID
|
||||
* 获取资产GUID
|
||||
*/
|
||||
get guid(): AssetGUID {
|
||||
return this._guid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if asset is loaded
|
||||
* 检查资产是否已加载
|
||||
*/
|
||||
get isLoaded(): boolean {
|
||||
return this._asset !== undefined && !this._isReleased;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset synchronously (returns null if not loaded)
|
||||
* 同步获取资产(如果未加载则返回null)
|
||||
*/
|
||||
get asset(): T | null {
|
||||
return this._asset ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set asset manager
|
||||
* 设置资产管理器
|
||||
*/
|
||||
setManager(manager: IAssetManager): void {
|
||||
this._manager = manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load asset asynchronously
|
||||
* 异步加载资产
|
||||
*/
|
||||
async loadAsync(options?: IAssetLoadOptions): Promise<T> {
|
||||
if (this._isReleased) {
|
||||
throw new Error(`Asset reference ${this._guid} has been released`);
|
||||
}
|
||||
|
||||
// 如果已经加载,直接返回 / Return if already loaded
|
||||
if (this._asset !== undefined) {
|
||||
return this._asset;
|
||||
}
|
||||
|
||||
// 如果正在加载,返回加载Promise / Return loading promise if loading
|
||||
if (this._loadPromise) {
|
||||
return this._loadPromise;
|
||||
}
|
||||
|
||||
if (!this._manager) {
|
||||
throw new Error('Asset manager not set for AssetReference');
|
||||
}
|
||||
|
||||
// 开始加载 / Start loading
|
||||
this._loadPromise = this.performLoad(options);
|
||||
|
||||
try {
|
||||
const asset = await this._loadPromise;
|
||||
return asset;
|
||||
} finally {
|
||||
this._loadPromise = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform asset loading
|
||||
* 执行资产加载
|
||||
*/
|
||||
private async performLoad(options?: IAssetLoadOptions): Promise<T> {
|
||||
if (!this._manager) {
|
||||
throw new Error('Asset manager not set');
|
||||
}
|
||||
|
||||
const result = await this._manager.loadAsset<T>(this._guid, options);
|
||||
this._asset = result.asset;
|
||||
|
||||
// 增加引用计数 / Increase reference count
|
||||
this._manager.addReference(this._guid);
|
||||
|
||||
return this._asset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release asset reference
|
||||
* 释放资产引用
|
||||
*/
|
||||
release(): void {
|
||||
if (this._isReleased) return;
|
||||
|
||||
if (this._manager && this._asset !== undefined) {
|
||||
// 减少引用计数 / Decrease reference count
|
||||
this._manager.removeReference(this._guid);
|
||||
|
||||
// 如果引用计数为0,可以考虑卸载 / Consider unloading if reference count is 0
|
||||
const refInfo = this._manager.getReferenceInfo(this._guid);
|
||||
if (refInfo && refInfo.referenceCount === 0 && this._autoRelease) {
|
||||
this._manager.unloadAsset(this._guid);
|
||||
}
|
||||
}
|
||||
|
||||
this._asset = undefined;
|
||||
this._isReleased = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set auto-release mode
|
||||
* 设置自动释放模式
|
||||
*/
|
||||
setAutoRelease(autoRelease: boolean): void {
|
||||
this._autoRelease = autoRelease;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate reference
|
||||
* 验证引用
|
||||
*/
|
||||
validate(): boolean {
|
||||
if (!this._manager) return false;
|
||||
|
||||
const state = this._manager.getAssetState(this._guid);
|
||||
return state !== AssetState.Failed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset state
|
||||
* 获取资产状态
|
||||
*/
|
||||
getState(): AssetState {
|
||||
if (this._isReleased) return AssetState.Unloaded;
|
||||
if (!this._manager) return AssetState.Unloaded;
|
||||
|
||||
return this._manager.getAssetState(this._guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone reference
|
||||
* 克隆引用
|
||||
*/
|
||||
clone(): AssetReference<T> {
|
||||
const newRef = new AssetReference<T>(this._guid, this._manager);
|
||||
newRef.setAutoRelease(this._autoRelease);
|
||||
return newRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to JSON
|
||||
* 转换为JSON
|
||||
*/
|
||||
toJSON(): { guid: AssetGUID } {
|
||||
return { guid: this._guid };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from JSON
|
||||
* 从JSON创建
|
||||
*/
|
||||
static fromJSON<T = unknown>(
|
||||
json: { guid: AssetGUID },
|
||||
manager?: IAssetManager
|
||||
): AssetReference<T> {
|
||||
return new AssetReference<T>(json.guid, manager);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Weak asset reference that doesn't prevent unloading
|
||||
* 不阻止卸载的弱资产引用
|
||||
*/
|
||||
export class WeakAssetReference<T = unknown> {
|
||||
private _guid: AssetGUID;
|
||||
private _manager?: IAssetManager;
|
||||
|
||||
constructor(guid: AssetGUID, manager?: IAssetManager) {
|
||||
this._guid = guid;
|
||||
this._manager = manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset GUID
|
||||
* 获取资产GUID
|
||||
*/
|
||||
get guid(): AssetGUID {
|
||||
return this._guid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try get asset without loading
|
||||
* 尝试获取资产而不加载
|
||||
*/
|
||||
tryGet(): T | null {
|
||||
if (!this._manager) return null;
|
||||
return this._manager.getAsset<T>(this._guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load asset if not loaded
|
||||
* 如果未加载则加载资产
|
||||
*/
|
||||
async loadAsync(options?: IAssetLoadOptions): Promise<T> {
|
||||
if (!this._manager) {
|
||||
throw new Error('Asset manager not set');
|
||||
}
|
||||
|
||||
const result = await this._manager.loadAsset<T>(this._guid, options);
|
||||
// 不增加引用计数 / Don't increase reference count for weak reference
|
||||
return result.asset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if asset is loaded
|
||||
* 检查资产是否已加载
|
||||
*/
|
||||
isLoaded(): boolean {
|
||||
if (!this._manager) return false;
|
||||
return this._manager.isLoaded(this._guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set asset manager
|
||||
* 设置资产管理器
|
||||
*/
|
||||
setManager(manager: IAssetManager): void {
|
||||
this._manager = manager;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset reference array for managing multiple references
|
||||
* 用于管理多个引用的资产引用数组
|
||||
*/
|
||||
export class AssetReferenceArray<T = unknown> {
|
||||
private _references: AssetReference<T>[] = [];
|
||||
private _manager?: IAssetManager;
|
||||
|
||||
constructor(guids: AssetGUID[] = [], manager?: IAssetManager) {
|
||||
this._manager = manager;
|
||||
this._references = guids.map((guid) => new AssetReference<T>(guid, manager));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add reference
|
||||
* 添加引用
|
||||
*/
|
||||
add(guid: AssetGUID): void {
|
||||
this._references.push(new AssetReference<T>(guid, this._manager));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove reference
|
||||
* 移除引用
|
||||
*/
|
||||
remove(guid: AssetGUID): boolean {
|
||||
const index = this._references.findIndex((ref) => ref.guid === guid);
|
||||
if (index >= 0) {
|
||||
this._references[index].release();
|
||||
this._references.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all assets
|
||||
* 加载所有资产
|
||||
*/
|
||||
async loadAllAsync(options?: IAssetLoadOptions): Promise<T[]> {
|
||||
const promises = this._references.map((ref) => ref.loadAsync(options));
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Release all references
|
||||
* 释放所有引用
|
||||
*/
|
||||
releaseAll(): void {
|
||||
this._references.forEach((ref) => ref.release());
|
||||
this._references = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all loaded assets
|
||||
* 获取所有已加载的资产
|
||||
*/
|
||||
getLoadedAssets(): T[] {
|
||||
return this._references
|
||||
.filter((ref) => ref.isLoaded)
|
||||
.map((ref) => ref.asset!)
|
||||
.filter((asset) => asset !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reference count
|
||||
* 获取引用数量
|
||||
*/
|
||||
get count(): number {
|
||||
return this._references.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set asset manager
|
||||
* 设置资产管理器
|
||||
*/
|
||||
setManager(manager: IAssetManager): void {
|
||||
this._manager = manager;
|
||||
this._references.forEach((ref) => ref.setManager(manager));
|
||||
}
|
||||
}
|
||||
116
packages/engine/asset-system/src/index.ts
Normal file
116
packages/engine/asset-system/src/index.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Asset System for ECS Framework
|
||||
* ECS框架的资产系统
|
||||
*
|
||||
* Runtime-focused asset management:
|
||||
* - Asset loading and caching
|
||||
* - GUID-based asset resolution
|
||||
* - Bundle loading
|
||||
*
|
||||
* For editor-side functionality (meta files, packing), use @esengine/asset-system-editor
|
||||
*/
|
||||
|
||||
// Service tokens (谁定义接口,谁导出 Token)
|
||||
export {
|
||||
AssetManagerToken,
|
||||
PrefabServiceToken,
|
||||
PathResolutionServiceToken,
|
||||
type IAssetManager,
|
||||
type IPrefabService,
|
||||
type IPrefabAsset,
|
||||
type IPrefabData,
|
||||
type IPrefabMetadata,
|
||||
type IPathResolutionService
|
||||
} from './tokens';
|
||||
|
||||
// Types
|
||||
export * from './types/AssetTypes';
|
||||
|
||||
// Bundle format (shared types for runtime and editor)
|
||||
export * from './bundle/BundleFormat';
|
||||
|
||||
// Runtime catalog
|
||||
export { RuntimeCatalog, runtimeCatalog } from './runtime/RuntimeCatalog';
|
||||
|
||||
// Interfaces
|
||||
export * from './interfaces/IAssetLoader';
|
||||
export * from './interfaces/IAssetManager';
|
||||
export * from './interfaces/IAssetReader';
|
||||
export * from './interfaces/IAssetFileLoader';
|
||||
export * from './interfaces/IResourceComponent';
|
||||
|
||||
// Core
|
||||
export { AssetManager } from './core/AssetManager';
|
||||
export { AssetCache } from './core/AssetCache';
|
||||
export { AssetDatabase } from './core/AssetDatabase';
|
||||
export { AssetLoadQueue } from './core/AssetLoadQueue';
|
||||
export { AssetReference, WeakAssetReference, AssetReferenceArray } from './core/AssetReference';
|
||||
export { AssetPathResolver } from './core/AssetPathResolver';
|
||||
export type { IAssetPathConfig } from './core/AssetPathResolver';
|
||||
|
||||
// Loaders
|
||||
export { AssetLoaderFactory } from './loaders/AssetLoaderFactory';
|
||||
export { TextureLoader } from './loaders/TextureLoader';
|
||||
export { JsonLoader } from './loaders/JsonLoader';
|
||||
export { TextLoader } from './loaders/TextLoader';
|
||||
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';
|
||||
|
||||
// Services
|
||||
export { SceneResourceManager } from './services/SceneResourceManager';
|
||||
export type { IResourceLoader } from './services/SceneResourceManager';
|
||||
export { PathResolutionService } from './services/PathResolutionService';
|
||||
|
||||
// Asset Metadata Service (primary API for sprite info)
|
||||
// 资产元数据服务(sprite 信息的主要 API)
|
||||
export {
|
||||
setGlobalAssetDatabase,
|
||||
getGlobalAssetDatabase,
|
||||
setGlobalEngineBridge,
|
||||
getGlobalEngineBridge,
|
||||
getTextureSpriteInfo
|
||||
} from './services/AssetMetadataService';
|
||||
export type { ITextureSpriteInfo } from './core/AssetDatabase';
|
||||
|
||||
// Utils
|
||||
export { UVHelper } from './utils/UVHelper';
|
||||
export {
|
||||
isValidGUID,
|
||||
generateGUID,
|
||||
hashBuffer,
|
||||
hashString,
|
||||
hashFileInfo
|
||||
} from './utils/AssetUtils';
|
||||
export {
|
||||
collectAssetReferences,
|
||||
extractUniqueGuids,
|
||||
groupByComponentType,
|
||||
DEFAULT_ASSET_PATTERNS,
|
||||
type SceneAssetRef,
|
||||
type AssetFieldPattern
|
||||
} from './utils/AssetCollector';
|
||||
|
||||
// Re-export for initializeAssetSystem
|
||||
import { AssetManager } from './core/AssetManager';
|
||||
import type { IAssetCatalog } from './types/AssetTypes';
|
||||
|
||||
/**
|
||||
* Initialize asset system with catalog
|
||||
* 使用目录初始化资产系统
|
||||
*
|
||||
* @param catalog 资产目录 | Asset catalog
|
||||
* @returns 新的 AssetManager 实例 | New AssetManager instance
|
||||
*/
|
||||
export function initializeAssetSystem(catalog?: IAssetCatalog): AssetManager {
|
||||
return new AssetManager(catalog);
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
/**
|
||||
* Engine integration for asset system
|
||||
* 资产系统的引擎集成
|
||||
*/
|
||||
|
||||
import { AssetManager } from '../core/AssetManager';
|
||||
import { AssetGUID, AssetType } from '../types/AssetTypes';
|
||||
import { ITextureAsset, IAudioAsset, IJsonAsset } from '../interfaces/IAssetLoader';
|
||||
import { PathResolutionService, type IPathResolutionService } from '../services/PathResolutionService';
|
||||
|
||||
/**
|
||||
* Texture engine bridge interface (for asset system)
|
||||
* 纹理引擎桥接接口(用于资产系统)
|
||||
*/
|
||||
export interface ITextureEngineBridge {
|
||||
/**
|
||||
* Load texture to GPU
|
||||
* 加载纹理到GPU
|
||||
*/
|
||||
loadTexture(id: number, url: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Load multiple textures
|
||||
* 批量加载纹理
|
||||
*/
|
||||
loadTextures(requests: Array<{ id: number; url: string }>): Promise<void>;
|
||||
|
||||
/**
|
||||
* Unload texture from GPU
|
||||
* 从GPU卸载纹理
|
||||
*/
|
||||
unloadTexture(id: number): void;
|
||||
|
||||
/**
|
||||
* Get or load texture by path.
|
||||
* 按路径获取或加载纹理。
|
||||
*
|
||||
* This is the preferred method for getting texture IDs.
|
||||
* The Rust engine is the single source of truth for texture ID allocation.
|
||||
* 这是获取纹理 ID 的首选方法。
|
||||
* Rust 引擎是纹理 ID 分配的唯一事实来源。
|
||||
*
|
||||
* @param path Image path/URL | 图片路径/URL
|
||||
* @returns Texture ID allocated by Rust engine | Rust 引擎分配的纹理 ID
|
||||
*/
|
||||
getOrLoadTextureByPath?(path: string): number;
|
||||
|
||||
/**
|
||||
* Clear the texture path cache (optional).
|
||||
* 清除纹理路径缓存(可选)。
|
||||
*
|
||||
* This should be called when restoring scene snapshots to ensure
|
||||
* textures are reloaded with correct IDs.
|
||||
* 在恢复场景快照时应调用此方法,以确保纹理使用正确的ID重新加载。
|
||||
*/
|
||||
clearTexturePathCache?(): void;
|
||||
|
||||
/**
|
||||
* Clear all textures and reset state (optional).
|
||||
* 清除所有纹理并重置状态(可选)。
|
||||
*/
|
||||
clearAllTextures?(): void;
|
||||
|
||||
// ===== Texture State API =====
|
||||
// ===== 纹理状态 API =====
|
||||
|
||||
/**
|
||||
* Get texture loading state.
|
||||
* 获取纹理加载状态。
|
||||
*
|
||||
* @param id Texture ID | 纹理 ID
|
||||
* @returns State string: 'loading', 'ready', or 'failed:reason' | 状态字符串
|
||||
*/
|
||||
getTextureState?(id: number): string;
|
||||
|
||||
/**
|
||||
* Check if texture is ready for rendering.
|
||||
* 检查纹理是否已就绪可渲染。
|
||||
*
|
||||
* @param id Texture ID | 纹理 ID
|
||||
* @returns true if texture data is loaded | 纹理数据已加载则返回 true
|
||||
*/
|
||||
isTextureReady?(id: number): boolean;
|
||||
|
||||
/**
|
||||
* Get count of textures currently loading.
|
||||
* 获取当前正在加载的纹理数量。
|
||||
*
|
||||
* @returns Number of textures in 'loading' state | 处于加载状态的纹理数量
|
||||
*/
|
||||
getTextureLoadingCount?(): number;
|
||||
|
||||
/**
|
||||
* Load texture asynchronously with Promise.
|
||||
* 使用 Promise 异步加载纹理。
|
||||
*
|
||||
* Unlike loadTexture which returns immediately, this method
|
||||
* waits until the texture is actually loaded and ready.
|
||||
* 与 loadTexture 立即返回不同,此方法会等待纹理实际加载完成。
|
||||
*
|
||||
* @param id Texture ID | 纹理 ID
|
||||
* @param url Image URL | 图片 URL
|
||||
* @returns Promise that resolves when texture is ready | 纹理就绪时解析的 Promise
|
||||
*/
|
||||
loadTextureAsync?(id: number, url: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get texture info by path.
|
||||
* 通过路径获取纹理信息。
|
||||
*
|
||||
* This is the primary API for getting texture dimensions.
|
||||
* The Rust engine is the single source of truth for texture dimensions.
|
||||
* 这是获取纹理尺寸的主要 API。
|
||||
* Rust 引擎是纹理尺寸的唯一事实来源。
|
||||
*
|
||||
* @param path Image path/URL | 图片路径/URL
|
||||
* @returns Texture info or null if not loaded | 纹理信息或未加载则为 null
|
||||
*/
|
||||
getTextureInfoByPath?(path: string): { width: number; height: number } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio asset with runtime ID
|
||||
* 带运行时 ID 的音频资产
|
||||
*/
|
||||
interface AudioAssetEntry {
|
||||
id: number;
|
||||
asset: IAudioAsset;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data asset with runtime ID
|
||||
* 带运行时 ID 的数据资产
|
||||
*/
|
||||
interface DataAssetEntry {
|
||||
id: number;
|
||||
data: unknown;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Texture load callback type
|
||||
* 纹理加载回调类型
|
||||
*/
|
||||
export type TextureLoadCallback = (guid: string, path: string, textureId: number) => void;
|
||||
|
||||
/**
|
||||
* Asset system engine integration
|
||||
* 资产系统引擎集成
|
||||
*/
|
||||
/**
|
||||
* Texture sprite info (nine-patch border, pivot, etc.)
|
||||
* 纹理 Sprite 信息(九宫格边距、锚点等)
|
||||
*/
|
||||
export interface ITextureSpriteInfo {
|
||||
/**
|
||||
* 九宫格切片边距 [top, right, bottom, left]
|
||||
* Nine-patch slice border
|
||||
*/
|
||||
sliceBorder?: [number, number, number, number];
|
||||
/**
|
||||
* Sprite 锚点 [x, y](0-1 归一化)
|
||||
* Sprite pivot point (0-1 normalized)
|
||||
*/
|
||||
pivot?: [number, number];
|
||||
/**
|
||||
* 纹理宽度
|
||||
* Texture width
|
||||
*/
|
||||
width: number;
|
||||
/**
|
||||
* 纹理高度
|
||||
* Texture height
|
||||
*/
|
||||
height: number;
|
||||
}
|
||||
|
||||
export class EngineIntegration {
|
||||
private _assetManager: AssetManager;
|
||||
private _engineBridge?: ITextureEngineBridge;
|
||||
private _pathResolver: IPathResolutionService;
|
||||
private _textureIdMap = new Map<AssetGUID, number>();
|
||||
private _pathToTextureId = new Map<string, number>();
|
||||
|
||||
// 路径稳定 ID 缓存(跨 Play/Stop 循环保持稳定)
|
||||
// Path-stable ID cache (persists across Play/Stop cycles)
|
||||
private static _pathIdCache = new Map<string, number>();
|
||||
|
||||
// 纹理 Sprite 信息缓存(全局静态,可供渲染系统访问)
|
||||
// Texture sprite info cache (global static, accessible by render systems)
|
||||
private static _textureSpriteInfoCache = new Map<AssetGUID, ITextureSpriteInfo>();
|
||||
|
||||
// 纹理加载回调(用于动态图集集成等)
|
||||
// Texture load callback (for dynamic atlas integration, etc.)
|
||||
private static _textureLoadCallbacks: TextureLoadCallback[] = [];
|
||||
|
||||
/**
|
||||
* Register a callback to be called when textures are loaded
|
||||
* 注册纹理加载时调用的回调
|
||||
*
|
||||
* This can be used for dynamic atlas integration.
|
||||
* 可用于动态图集集成。
|
||||
*
|
||||
* @param callback - Callback function | 回调函数
|
||||
*/
|
||||
static onTextureLoad(callback: TextureLoadCallback): void {
|
||||
if (!EngineIntegration._textureLoadCallbacks.includes(callback)) {
|
||||
EngineIntegration._textureLoadCallbacks.push(callback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a texture load callback
|
||||
* 移除纹理加载回调
|
||||
*/
|
||||
static removeTextureLoadCallback(callback: TextureLoadCallback): void {
|
||||
const index = EngineIntegration._textureLoadCallbacks.indexOf(callback);
|
||||
if (index >= 0) {
|
||||
EngineIntegration._textureLoadCallbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all callbacks of a texture load
|
||||
* 通知所有回调纹理已加载
|
||||
*/
|
||||
private static notifyTextureLoad(guid: string, path: string, textureId: number): void {
|
||||
for (const callback of EngineIntegration._textureLoadCallbacks) {
|
||||
try {
|
||||
callback(guid, path, textureId);
|
||||
} catch (e) {
|
||||
console.error('[EngineIntegration] Error in texture load callback:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Audio resource mappings | 音频资源映射
|
||||
private _audioIdMap = new Map<AssetGUID, number>();
|
||||
private _pathToAudioId = new Map<string, number>();
|
||||
private _audioAssets = new Map<number, AudioAssetEntry>();
|
||||
private static _nextAudioId = 1;
|
||||
|
||||
// Data resource mappings | 数据资源映射
|
||||
private _dataIdMap = new Map<AssetGUID, number>();
|
||||
private _pathToDataId = new Map<string, number>();
|
||||
private _dataAssets = new Map<number, DataAssetEntry>();
|
||||
private static _nextDataId = 1;
|
||||
|
||||
/**
|
||||
* 根据路径生成稳定的 ID(使用 FNV-1a hash)
|
||||
* Generate stable ID from path (using FNV-1a hash)
|
||||
*
|
||||
* 相同路径永远返回相同 ID,即使在 clearTextureMappings 后
|
||||
* Same path always returns same ID, even after clearTextureMappings
|
||||
*
|
||||
* @param path 资源路径 | Resource path
|
||||
* @param type 资源类型 | Resource type
|
||||
* @returns 稳定的运行时 ID | Stable runtime ID
|
||||
*/
|
||||
private static getStableIdForPath(path: string, type: 'texture' | 'audio'): number {
|
||||
const cacheKey = `${type}:${path}`;
|
||||
const cached = EngineIntegration._pathIdCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// FNV-1a hash 算法 | FNV-1a hash algorithm
|
||||
let hash = 2166136261; // FNV offset basis
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
hash ^= path.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619); // FNV prime
|
||||
hash = hash >>> 0; // Keep as uint32
|
||||
}
|
||||
|
||||
// 确保 ID > 0(0 保留给默认纹理)
|
||||
// Ensure ID > 0 (0 is reserved for default texture)
|
||||
const id = (hash % 0x7FFFFFFF) + 1;
|
||||
EngineIntegration._pathIdCache.set(cacheKey, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
constructor(assetManager: AssetManager, engineBridge?: ITextureEngineBridge, pathResolver?: IPathResolutionService) {
|
||||
this._assetManager = assetManager;
|
||||
this._engineBridge = engineBridge;
|
||||
this._pathResolver = pathResolver ?? new PathResolutionService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set path resolver
|
||||
* 设置路径解析器
|
||||
*/
|
||||
setPathResolver(resolver: IPathResolutionService): void {
|
||||
this._pathResolver = resolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set engine bridge
|
||||
* 设置引擎桥接
|
||||
*/
|
||||
setEngineBridge(bridge: ITextureEngineBridge): void {
|
||||
this._engineBridge = bridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load texture for component
|
||||
* 为组件加载纹理
|
||||
*
|
||||
* 使用路径稳定 ID 确保相同路径在 Play/Stop 循环后返回相同 ID。
|
||||
* 这样组件保存的 textureId 在恢复场景后仍然有效。
|
||||
*
|
||||
* Uses path-stable ID to ensure same path returns same ID across Play/Stop cycles.
|
||||
* This ensures component's saved textureId remains valid after scene restore.
|
||||
*
|
||||
* AssetManager 内部会处理路径解析,这里只需传入原始路径。
|
||||
* AssetManager handles path resolution internally, just pass the original path here.
|
||||
*/
|
||||
async loadTextureForComponent(texturePath: string): Promise<number> {
|
||||
// 生成路径稳定 ID(相同路径永远返回相同 ID)
|
||||
// Generate path-stable ID (same path always returns same ID)
|
||||
const stableId = EngineIntegration.getStableIdForPath(texturePath, 'texture');
|
||||
|
||||
// 检查是否已加载到 GPU
|
||||
// Check if already loaded to GPU
|
||||
const existingId = this._pathToTextureId.get(texturePath);
|
||||
if (existingId === stableId) {
|
||||
return stableId; // 已加载,直接返回 | Already loaded, return directly
|
||||
}
|
||||
|
||||
// 解析路径为引擎可用的 URL
|
||||
// Resolve path to engine-compatible URL
|
||||
const engineUrl = this._pathResolver.catalogToRuntime(texturePath);
|
||||
|
||||
// 使用稳定 ID 加载纹理到 GPU
|
||||
// Load texture to GPU with stable ID
|
||||
if (this._engineBridge) {
|
||||
// 优先使用异步加载(支持加载状态追踪)
|
||||
// Prefer async loading (supports loading state tracking)
|
||||
if (this._engineBridge.loadTextureAsync) {
|
||||
await this._engineBridge.loadTextureAsync(stableId, engineUrl);
|
||||
} else {
|
||||
await this._engineBridge.loadTexture(stableId, engineUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存映射
|
||||
// Cache mapping
|
||||
this._pathToTextureId.set(texturePath, stableId);
|
||||
|
||||
return stableId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load texture by GUID
|
||||
* 通过GUID加载纹理
|
||||
*
|
||||
* 使用路径稳定 ID 确保相同路径在 Play/Stop 循环后返回相同 ID。
|
||||
* Uses path-stable ID to ensure same path returns same ID across Play/Stop cycles.
|
||||
*/
|
||||
async loadTextureByGuid(guid: AssetGUID): Promise<number> {
|
||||
// 检查是否已有纹理ID / Check if texture ID exists
|
||||
const existingId = this._textureIdMap.get(guid);
|
||||
if (existingId) {
|
||||
return existingId;
|
||||
}
|
||||
|
||||
// 通过资产系统加载获取元数据和路径 / Load through asset system to get metadata and path
|
||||
const result = await this._assetManager.loadAsset<ITextureAsset>(guid);
|
||||
const metadata = result.metadata;
|
||||
const assetPath = metadata.path;
|
||||
const textureAsset = result.asset;
|
||||
|
||||
// 缓存 sprite 信息(九宫格边距等)到静态缓存
|
||||
// Cache sprite info (slice border, etc.) to static cache
|
||||
EngineIntegration._textureSpriteInfoCache.set(guid, {
|
||||
sliceBorder: textureAsset.sliceBorder,
|
||||
pivot: textureAsset.pivot,
|
||||
width: textureAsset.width,
|
||||
height: textureAsset.height
|
||||
});
|
||||
|
||||
// 生成路径稳定 ID
|
||||
// Generate path-stable ID
|
||||
const stableId = EngineIntegration.getStableIdForPath(assetPath, 'texture');
|
||||
|
||||
// 检查是否已加载到 GPU
|
||||
// Check if already loaded to GPU
|
||||
if (this._pathToTextureId.get(assetPath) === stableId) {
|
||||
this._textureIdMap.set(guid, stableId);
|
||||
return stableId;
|
||||
}
|
||||
|
||||
// 解析路径为引擎可用的 URL
|
||||
// Resolve path to engine-compatible URL
|
||||
const engineUrl = this._pathResolver.catalogToRuntime(assetPath);
|
||||
|
||||
// 使用稳定 ID 加载纹理到 GPU
|
||||
// Load texture to GPU with stable ID
|
||||
if (this._engineBridge) {
|
||||
if (this._engineBridge.loadTextureAsync) {
|
||||
await this._engineBridge.loadTextureAsync(stableId, engineUrl);
|
||||
} else {
|
||||
await this._engineBridge.loadTexture(stableId, engineUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存映射 / Cache mapping
|
||||
this._textureIdMap.set(guid, stableId);
|
||||
this._pathToTextureId.set(assetPath, stableId);
|
||||
|
||||
// 通知回调(用于动态图集等)
|
||||
// Notify callbacks (for dynamic atlas, etc.)
|
||||
EngineIntegration.notifyTextureLoad(guid, engineUrl, stableId);
|
||||
|
||||
return stableId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get texture sprite info by GUID (static method for render system access)
|
||||
* 通过 GUID 获取纹理 Sprite 信息(静态方法,供渲染系统访问)
|
||||
*
|
||||
* Returns cached sprite info including nine-patch slice border.
|
||||
* Must call loadTextureByGuid first to populate the cache.
|
||||
* 返回缓存的 sprite 信息,包括九宫格边距。
|
||||
* 必须先调用 loadTextureByGuid 来填充缓存。
|
||||
*
|
||||
* @param guid - Texture asset GUID | 纹理资产 GUID
|
||||
* @returns Sprite info or undefined if not loaded | Sprite 信息或未加载则为 undefined
|
||||
*/
|
||||
static getTextureSpriteInfo(guid: AssetGUID): ITextureSpriteInfo | undefined {
|
||||
return EngineIntegration._textureSpriteInfoCache.get(guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear texture sprite info cache
|
||||
* 清除纹理 Sprite 信息缓存
|
||||
*/
|
||||
static clearTextureSpriteInfoCache(): void {
|
||||
EngineIntegration._textureSpriteInfoCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch load textures
|
||||
* 批量加载纹理
|
||||
*/
|
||||
async loadTexturesBatch(paths: string[]): Promise<Map<string, number>> {
|
||||
const results = new Map<string, number>();
|
||||
|
||||
// 收集需要加载的纹理 / Collect textures to load
|
||||
const toLoad: string[] = [];
|
||||
for (const path of paths) {
|
||||
const existingId = this._pathToTextureId.get(path);
|
||||
if (existingId) {
|
||||
results.set(path, existingId);
|
||||
} else {
|
||||
toLoad.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
if (toLoad.length === 0) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// 并行加载所有纹理 / Load all textures in parallel
|
||||
const loadPromises = toLoad.map(async (path) => {
|
||||
try {
|
||||
const id = await this.loadTextureForComponent(path);
|
||||
results.set(path, id);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load texture: ${path}`, error);
|
||||
results.set(path, 0); // 使用默认纹理ID / Use default texture ID
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(loadPromises);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量加载资源(通用方法,支持 IResourceLoader 接口)
|
||||
* Load resources in batch (generic method for IResourceLoader interface)
|
||||
*
|
||||
* @param paths 资源路径数组 / Array of resource paths
|
||||
* @param type 资源类型 / Resource type
|
||||
* @returns 路径到运行时 ID 的映射 / Map of paths to runtime IDs
|
||||
*/
|
||||
async loadResourcesBatch(paths: string[], type: 'texture' | 'audio' | 'font' | 'data'): Promise<Map<string, number>> {
|
||||
switch (type) {
|
||||
case 'texture':
|
||||
return this.loadTexturesBatch(paths);
|
||||
case 'audio':
|
||||
return this.loadAudioBatch(paths);
|
||||
case 'data':
|
||||
return this.loadDataBatch(paths);
|
||||
case 'font':
|
||||
// 字体资源暂未实现 / Font resources not yet implemented
|
||||
console.warn('[EngineIntegration] Font resource loading not yet implemented');
|
||||
return new Map();
|
||||
default:
|
||||
console.warn(`[EngineIntegration] Unknown resource type '${type}'`);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
// ============= Audio Resource Methods =============
|
||||
// ============= 音频资源方法 =============
|
||||
|
||||
/**
|
||||
* Load audio for component
|
||||
* 为组件加载音频
|
||||
*
|
||||
* @param audioPath 音频文件路径 / Audio file path
|
||||
* @returns 运行时音频 ID / Runtime audio ID
|
||||
*/
|
||||
async loadAudioForComponent(audioPath: string): Promise<number> {
|
||||
// 检查缓存 / Check cache
|
||||
const existingId = this._pathToAudioId.get(audioPath);
|
||||
if (existingId) {
|
||||
return existingId;
|
||||
}
|
||||
|
||||
// 通过资产系统加载 / Load through asset system
|
||||
const result = await this._assetManager.loadAssetByPath<IAudioAsset>(audioPath);
|
||||
const audioAsset = result.asset;
|
||||
|
||||
// 分配运行时 ID / Assign runtime ID
|
||||
const audioId = EngineIntegration._nextAudioId++;
|
||||
|
||||
// 缓存映射 / Cache mapping
|
||||
this._pathToAudioId.set(audioPath, audioId);
|
||||
this._audioAssets.set(audioId, {
|
||||
id: audioId,
|
||||
asset: audioAsset,
|
||||
path: audioPath
|
||||
});
|
||||
|
||||
return audioId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch load audio files
|
||||
* 批量加载音频文件
|
||||
*/
|
||||
async loadAudioBatch(paths: string[]): Promise<Map<string, number>> {
|
||||
const results = new Map<string, number>();
|
||||
|
||||
// 收集需要加载的音频 / Collect audio to load
|
||||
const toLoad: string[] = [];
|
||||
for (const path of paths) {
|
||||
const existingId = this._pathToAudioId.get(path);
|
||||
if (existingId) {
|
||||
results.set(path, existingId);
|
||||
} else {
|
||||
toLoad.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
if (toLoad.length === 0) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// 并行加载所有音频 / Load all audio in parallel
|
||||
const loadPromises = toLoad.map(async (path) => {
|
||||
try {
|
||||
const id = await this.loadAudioForComponent(path);
|
||||
results.set(path, id);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load audio: ${path}`, error);
|
||||
results.set(path, 0);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(loadPromises);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audio asset by ID
|
||||
* 通过 ID 获取音频资产
|
||||
*/
|
||||
getAudioAsset(audioId: number): IAudioAsset | null {
|
||||
const entry = this._audioAssets.get(audioId);
|
||||
return entry?.asset || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audio ID for path
|
||||
* 获取路径的音频 ID
|
||||
*/
|
||||
getAudioId(path: string): number | null {
|
||||
return this._pathToAudioId.get(path) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload audio
|
||||
* 卸载音频
|
||||
*/
|
||||
unloadAudio(audioId: number): void {
|
||||
const entry = this._audioAssets.get(audioId);
|
||||
if (entry) {
|
||||
this._pathToAudioId.delete(entry.path);
|
||||
this._audioAssets.delete(audioId);
|
||||
|
||||
// 从 GUID 映射中清理 / Clean up GUID mapping
|
||||
for (const [guid, id] of this._audioIdMap.entries()) {
|
||||
if (id === audioId) {
|
||||
this._audioIdMap.delete(guid);
|
||||
this._assetManager.unloadAsset(guid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============= Data Resource Methods =============
|
||||
// ============= 数据资源方法 =============
|
||||
|
||||
/**
|
||||
* Load data (JSON) for component
|
||||
* 为组件加载数据(JSON)
|
||||
*
|
||||
* @param dataPath 数据文件路径 / Data file path
|
||||
* @returns 运行时数据 ID / Runtime data ID
|
||||
*/
|
||||
async loadDataForComponent(dataPath: string): Promise<number> {
|
||||
// 检查缓存 / Check cache
|
||||
const existingId = this._pathToDataId.get(dataPath);
|
||||
if (existingId) {
|
||||
return existingId;
|
||||
}
|
||||
|
||||
// 通过资产系统加载 / Load through asset system
|
||||
const result = await this._assetManager.loadAssetByPath<IJsonAsset>(dataPath);
|
||||
const jsonAsset = result.asset;
|
||||
|
||||
// 分配运行时 ID / Assign runtime ID
|
||||
const dataId = EngineIntegration._nextDataId++;
|
||||
|
||||
// 缓存映射 / Cache mapping
|
||||
this._pathToDataId.set(dataPath, dataId);
|
||||
this._dataAssets.set(dataId, {
|
||||
id: dataId,
|
||||
data: jsonAsset.data,
|
||||
path: dataPath
|
||||
});
|
||||
|
||||
return dataId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch load data files
|
||||
* 批量加载数据文件
|
||||
*/
|
||||
async loadDataBatch(paths: string[]): Promise<Map<string, number>> {
|
||||
const results = new Map<string, number>();
|
||||
|
||||
// 收集需要加载的数据 / Collect data to load
|
||||
const toLoad: string[] = [];
|
||||
for (const path of paths) {
|
||||
const existingId = this._pathToDataId.get(path);
|
||||
if (existingId) {
|
||||
results.set(path, existingId);
|
||||
} else {
|
||||
toLoad.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
if (toLoad.length === 0) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// 并行加载所有数据 / Load all data in parallel
|
||||
const loadPromises = toLoad.map(async (path) => {
|
||||
try {
|
||||
const id = await this.loadDataForComponent(path);
|
||||
results.set(path, id);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load data: ${path}`, error);
|
||||
results.set(path, 0);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(loadPromises);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data by ID
|
||||
* 通过 ID 获取数据
|
||||
*/
|
||||
getData<T = unknown>(dataId: number): T | null {
|
||||
const entry = this._dataAssets.get(dataId);
|
||||
return (entry?.data as T) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data ID for path
|
||||
* 获取路径的数据 ID
|
||||
*/
|
||||
getDataId(path: string): number | null {
|
||||
return this._pathToDataId.get(path) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload data
|
||||
* 卸载数据
|
||||
*/
|
||||
unloadData(dataId: number): void {
|
||||
const entry = this._dataAssets.get(dataId);
|
||||
if (entry) {
|
||||
this._pathToDataId.delete(entry.path);
|
||||
this._dataAssets.delete(dataId);
|
||||
|
||||
// 从 GUID 映射中清理 / Clean up GUID mapping
|
||||
for (const [guid, id] of this._dataIdMap.entries()) {
|
||||
if (id === dataId) {
|
||||
this._dataIdMap.delete(guid);
|
||||
this._assetManager.unloadAsset(guid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload texture
|
||||
* 卸载纹理
|
||||
*/
|
||||
unloadTexture(textureId: number): void {
|
||||
// 从引擎卸载 / Unload from engine
|
||||
if (this._engineBridge) {
|
||||
this._engineBridge.unloadTexture(textureId);
|
||||
}
|
||||
|
||||
// 清理映射 / Clean up mappings
|
||||
for (const [path, id] of this._pathToTextureId.entries()) {
|
||||
if (id === textureId) {
|
||||
this._pathToTextureId.delete(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [guid, id] of this._textureIdMap.entries()) {
|
||||
if (id === textureId) {
|
||||
this._textureIdMap.delete(guid);
|
||||
// 也从资产管理器卸载 / Also unload from asset manager
|
||||
this._assetManager.unloadAsset(guid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get texture ID for path
|
||||
* 获取路径的纹理ID
|
||||
*/
|
||||
getTextureId(path: string): number | null {
|
||||
return this._pathToTextureId.get(path) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload textures for scene
|
||||
* 为场景预加载纹理
|
||||
*/
|
||||
async preloadSceneTextures(texturePaths: string[]): Promise<void> {
|
||||
await this.loadTexturesBatch(texturePaths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all texture mappings (for scene switching)
|
||||
* 清空所有纹理映射(用于场景切换)
|
||||
*
|
||||
* 注意:使用路径稳定 ID 后,不应在 Play/Stop 循环中调用此方法。
|
||||
* 此方法仅用于场景切换时释放旧场景的纹理资源。
|
||||
*
|
||||
* NOTE: With path-stable IDs, this should NOT be called during Play/Stop cycle.
|
||||
* This method is only for releasing old scene's texture resources during scene switching.
|
||||
*
|
||||
* _pathIdCache 不会被清除,确保相同路径始终返回相同 ID。
|
||||
* _pathIdCache is NOT cleared, ensuring same path always returns same ID.
|
||||
*/
|
||||
clearTextureMappings(): void {
|
||||
// 1. 清除加载状态映射(不清除 _pathIdCache)
|
||||
// Clear load state mappings (NOT clearing _pathIdCache)
|
||||
this._textureIdMap.clear();
|
||||
this._pathToTextureId.clear();
|
||||
|
||||
// 2. 清除 Rust 引擎的 GPU 纹理资源
|
||||
// Clear Rust engine's GPU texture resources
|
||||
if (this._engineBridge?.clearAllTextures) {
|
||||
this._engineBridge.clearAllTextures();
|
||||
}
|
||||
|
||||
// 3. 清除 AssetManager 中的纹理资产缓存
|
||||
// Clear texture asset cache in AssetManager
|
||||
this._assetManager.unloadAssetsByType(AssetType.Texture, true);
|
||||
|
||||
// 注意:不再重置 TextureLoader 的 ID 计数器,因为现在使用路径稳定 ID
|
||||
// NOTE: No longer reset TextureLoader's ID counter as we now use path-stable IDs
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all audio mappings
|
||||
* 清空所有音频映射
|
||||
*/
|
||||
clearAudioMappings(): void {
|
||||
this._audioIdMap.clear();
|
||||
this._pathToAudioId.clear();
|
||||
this._audioAssets.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all data mappings
|
||||
* 清空所有数据映射
|
||||
*/
|
||||
clearDataMappings(): void {
|
||||
this._dataIdMap.clear();
|
||||
this._pathToDataId.clear();
|
||||
this._dataAssets.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all resource mappings
|
||||
* 清空所有资源映射
|
||||
*/
|
||||
clearAllMappings(): void {
|
||||
this.clearTextureMappings();
|
||||
this.clearAudioMappings();
|
||||
this.clearDataMappings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics
|
||||
* 获取统计信息
|
||||
*/
|
||||
getStatistics(): {
|
||||
loadedTextures: number;
|
||||
loadedAudio: number;
|
||||
loadedData: number;
|
||||
} {
|
||||
return {
|
||||
loadedTextures: this._pathToTextureId.size,
|
||||
loadedAudio: this._audioAssets.size,
|
||||
loadedData: this._dataAssets.size
|
||||
};
|
||||
}
|
||||
}
|
||||
103
packages/engine/asset-system/src/interfaces/IAssetFileLoader.ts
Normal file
103
packages/engine/asset-system/src/interfaces/IAssetFileLoader.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Asset File Loader Interface
|
||||
* 资产文件加载器接口
|
||||
*
|
||||
* High-level file loading abstraction that combines path resolution
|
||||
* with platform-specific file reading.
|
||||
* 高级文件加载抽象,结合路径解析和平台特定的文件读取。
|
||||
*
|
||||
* This is the unified entry point for all file loading in the engine.
|
||||
* Different from IAssetLoader (which parses content), this interface
|
||||
* handles the actual file fetching from asset paths.
|
||||
* 这是引擎中所有文件加载的统一入口。
|
||||
* 与 IAssetLoader(解析内容)不同,此接口处理从资产路径获取文件。
|
||||
*/
|
||||
|
||||
/**
|
||||
* Asset file loader interface.
|
||||
* 资产文件加载器接口。
|
||||
*
|
||||
* Provides a unified API for loading files from asset paths (relative to project).
|
||||
* Different platforms provide their own implementations.
|
||||
* 提供从资产路径(相对于项目)加载文件的统一 API。
|
||||
* 不同平台提供各自的实现。
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Get global loader
|
||||
* const loader = getGlobalAssetFileLoader();
|
||||
*
|
||||
* // Load image from asset path (relative to project)
|
||||
* const image = await loader.loadImage('assets/demo/button.png');
|
||||
*
|
||||
* // Load text content
|
||||
* const json = await loader.loadText('assets/config.json');
|
||||
* ```
|
||||
*/
|
||||
export interface IAssetFileLoader {
|
||||
/**
|
||||
* Load image from asset path.
|
||||
* 从资产路径加载图片。
|
||||
*
|
||||
* @param assetPath - Asset path relative to project (e.g., "assets/demo/button.png").
|
||||
* 相对于项目的资产路径。
|
||||
* @returns Promise resolving to HTMLImageElement. | 返回 HTMLImageElement 的 Promise。
|
||||
*/
|
||||
loadImage(assetPath: string): Promise<HTMLImageElement>;
|
||||
|
||||
/**
|
||||
* Load text content from asset path.
|
||||
* 从资产路径加载文本内容。
|
||||
*
|
||||
* @param assetPath - Asset path relative to project. | 相对于项目的资产路径。
|
||||
* @returns Promise resolving to text content. | 返回文本内容的 Promise。
|
||||
*/
|
||||
loadText(assetPath: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Load binary data from asset path.
|
||||
* 从资产路径加载二进制数据。
|
||||
*
|
||||
* @param assetPath - Asset path relative to project. | 相对于项目的资产路径。
|
||||
* @returns Promise resolving to ArrayBuffer. | 返回 ArrayBuffer 的 Promise。
|
||||
*/
|
||||
loadBinary(assetPath: string): Promise<ArrayBuffer>;
|
||||
|
||||
/**
|
||||
* Check if asset file exists.
|
||||
* 检查资产文件是否存在。
|
||||
*
|
||||
* @param assetPath - Asset path relative to project. | 相对于项目的资产路径。
|
||||
* @returns Promise resolving to boolean. | 返回布尔值的 Promise。
|
||||
*/
|
||||
exists(assetPath: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global asset file loader instance.
|
||||
* 全局资产文件加载器实例。
|
||||
*/
|
||||
let globalAssetFileLoader: IAssetFileLoader | null = null;
|
||||
|
||||
/**
|
||||
* Set the global asset file loader.
|
||||
* 设置全局资产文件加载器。
|
||||
*
|
||||
* Should be called during engine initialization with platform-specific implementation.
|
||||
* 应在引擎初始化期间使用平台特定的实现调用。
|
||||
*
|
||||
* @param loader - Asset file loader instance or null. | 资产文件加载器实例或 null。
|
||||
*/
|
||||
export function setGlobalAssetFileLoader(loader: IAssetFileLoader | null): void {
|
||||
globalAssetFileLoader = loader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the global asset file loader.
|
||||
* 获取全局资产文件加载器。
|
||||
*
|
||||
* @returns Asset file loader instance or null. | 资产文件加载器实例或 null。
|
||||
*/
|
||||
export function getGlobalAssetFileLoader(): IAssetFileLoader | null {
|
||||
return globalAssetFileLoader;
|
||||
}
|
||||
616
packages/engine/asset-system/src/interfaces/IAssetLoader.ts
Normal file
616
packages/engine/asset-system/src/interfaces/IAssetLoader.ts
Normal file
@@ -0,0 +1,616 @@
|
||||
/**
|
||||
* Asset loader interfaces
|
||||
* 资产加载器接口
|
||||
*/
|
||||
|
||||
import {
|
||||
AssetType,
|
||||
AssetGUID,
|
||||
IAssetLoadOptions,
|
||||
IAssetMetadata
|
||||
} from '../types/AssetTypes';
|
||||
import type { IAssetContent, AssetContentType } from './IAssetReader';
|
||||
|
||||
/**
|
||||
* Parse context provided to loaders.
|
||||
* 提供给加载器的解析上下文。
|
||||
*/
|
||||
export interface IAssetParseContext {
|
||||
/** Asset metadata. | 资产元数据。 */
|
||||
metadata: IAssetMetadata;
|
||||
/** Load options. | 加载选项。 */
|
||||
options?: IAssetLoadOptions;
|
||||
/**
|
||||
* Load a dependency asset by relative path.
|
||||
* 通过相对路径加载依赖资产。
|
||||
*/
|
||||
loadDependency<D = unknown>(relativePath: string): Promise<D>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset loader interface.
|
||||
* 资产加载器接口。
|
||||
*
|
||||
* Loaders only parse content, file reading is handled by AssetManager.
|
||||
* 加载器只负责解析内容,文件读取由 AssetManager 处理。
|
||||
*/
|
||||
export interface IAssetLoader<T = unknown> {
|
||||
/** Supported asset type. | 支持的资产类型。 */
|
||||
readonly supportedType: AssetType;
|
||||
|
||||
/** Supported file extensions. | 支持的文件扩展名。 */
|
||||
readonly supportedExtensions: string[];
|
||||
|
||||
/**
|
||||
* Required content type for this loader.
|
||||
* 此加载器需要的内容类型。
|
||||
*
|
||||
* - 'text': For JSON, shader, material files
|
||||
* - 'binary': For binary formats
|
||||
* - 'image': For textures
|
||||
* - 'audio': For audio files
|
||||
*/
|
||||
readonly contentType: AssetContentType;
|
||||
|
||||
/**
|
||||
* Parse asset from content.
|
||||
* 从内容解析资产。
|
||||
*
|
||||
* @param content - File content. | 文件内容。
|
||||
* @param context - Parse context. | 解析上下文。
|
||||
* @returns Parsed asset. | 解析后的资产。
|
||||
*/
|
||||
parse(content: IAssetContent, context: IAssetParseContext): Promise<T>;
|
||||
|
||||
/**
|
||||
* Dispose loaded asset and free resources.
|
||||
* 释放已加载的资产。
|
||||
*/
|
||||
dispose(asset: T): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset loader factory interface
|
||||
* 资产加载器工厂接口
|
||||
*/
|
||||
export interface IAssetLoaderFactory {
|
||||
/**
|
||||
* Create loader for specific asset type
|
||||
* 为特定资产类型创建加载器
|
||||
*/
|
||||
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
|
||||
* 注销加载器
|
||||
*/
|
||||
unregisterLoader(type: AssetType): void;
|
||||
|
||||
/**
|
||||
* Check if loader exists for type
|
||||
* 检查类型是否有加载器
|
||||
*/
|
||||
hasLoader(type: AssetType): boolean;
|
||||
|
||||
/**
|
||||
* Get asset type by file extension
|
||||
* 根据文件扩展名获取资产类型
|
||||
*/
|
||||
getAssetTypeByExtension(extension: string): AssetType | null;
|
||||
|
||||
/**
|
||||
* Get asset type by file path
|
||||
* 根据文件路径获取资产类型
|
||||
*/
|
||||
getAssetTypeByPath(path: string): AssetType | null;
|
||||
|
||||
/**
|
||||
* Get all supported file extensions from all registered loaders.
|
||||
* 获取所有注册加载器支持的文件扩展名。
|
||||
*
|
||||
* @returns Array of extension patterns (e.g., ['*.png', '*.jpg', '*.particle'])
|
||||
*/
|
||||
getAllSupportedExtensions(): string[];
|
||||
|
||||
/**
|
||||
* Get extension to type mapping for all registered loaders.
|
||||
* 获取所有注册加载器的扩展名到类型的映射。
|
||||
*
|
||||
* @returns Map of extension (without dot) to asset type string
|
||||
*/
|
||||
getExtensionTypeMap(): Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Texture asset interface
|
||||
* 纹理资产接口
|
||||
*/
|
||||
export interface ITextureAsset {
|
||||
/** WebGL纹理ID / WebGL texture ID */
|
||||
textureId: number;
|
||||
/** 宽度 / Width */
|
||||
width: number;
|
||||
/** 高度 / Height */
|
||||
height: number;
|
||||
/** 格式 / Format */
|
||||
format: 'rgba' | 'rgb' | 'alpha';
|
||||
/** 是否有Mipmap / Has mipmaps */
|
||||
hasMipmaps: boolean;
|
||||
/** 原始数据(如果可用) / Raw image data if available */
|
||||
data?: ImageData | HTMLImageElement;
|
||||
|
||||
// ===== Sprite Settings =====
|
||||
// ===== Sprite 设置 =====
|
||||
|
||||
/**
|
||||
* 九宫格切片边距 [top, right, bottom, left]
|
||||
* Nine-patch slice border
|
||||
*
|
||||
* Defines the non-stretchable borders for nine-patch rendering.
|
||||
* 定义九宫格渲染时不可拉伸的边框区域。
|
||||
*/
|
||||
sliceBorder?: [number, number, number, number];
|
||||
|
||||
/**
|
||||
* Sprite 锚点 [x, y](0-1 归一化)
|
||||
* Sprite pivot point (0-1 normalized)
|
||||
*/
|
||||
pivot?: [number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mesh asset interface
|
||||
* 网格资产接口
|
||||
*/
|
||||
export interface IMeshAsset {
|
||||
/** 顶点数据 / Vertex data */
|
||||
vertices: Float32Array;
|
||||
/** 索引数据 / Index data */
|
||||
indices: Uint16Array | Uint32Array;
|
||||
/** 法线数据 / Normal data */
|
||||
normals?: Float32Array;
|
||||
/** UV坐标 / UV coordinates */
|
||||
uvs?: Float32Array;
|
||||
/** 切线数据 / Tangent data */
|
||||
tangents?: Float32Array;
|
||||
/** 边界盒 / Axis-aligned bounding box */
|
||||
bounds: {
|
||||
min: [number, number, number];
|
||||
max: [number, number, number];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio asset interface
|
||||
* 音频资产接口
|
||||
*/
|
||||
export interface IAudioAsset {
|
||||
/** 音频缓冲区 / Audio buffer */
|
||||
buffer: AudioBuffer;
|
||||
/** 时长(秒) / Duration in seconds */
|
||||
duration: number;
|
||||
/** 采样率 / Sample rate */
|
||||
sampleRate: number;
|
||||
/** 声道数 / Number of channels */
|
||||
channels: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shader property type
|
||||
* 着色器属性类型
|
||||
*/
|
||||
export type ShaderPropertyType = 'float' | 'vec2' | 'vec3' | 'vec4' | 'int' | 'sampler2D' | 'mat3' | 'mat4';
|
||||
|
||||
/**
|
||||
* Shader property definition
|
||||
* 着色器属性定义
|
||||
*/
|
||||
export interface IShaderProperty {
|
||||
/** 属性名称(uniform 名) / Property name (uniform name) */
|
||||
name: string;
|
||||
/** 属性类型 / Property type */
|
||||
type: ShaderPropertyType;
|
||||
/** 默认值 / Default value */
|
||||
default: number | number[];
|
||||
/** 显示名称(编辑器用) / Display name for editor */
|
||||
displayName?: string;
|
||||
/** 值范围(用于 float/int) / Value range for float/int */
|
||||
range?: [number, number];
|
||||
/** 是否隐藏(内部使用) / Hidden from inspector */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shader asset interface
|
||||
* 着色器资产接口
|
||||
*
|
||||
* Shader assets contain GLSL source code and property definitions.
|
||||
* 着色器资产包含 GLSL 源代码和属性定义。
|
||||
*/
|
||||
export interface IShaderAsset {
|
||||
/** 着色器名称 / Shader name (e.g., "UI/Shiny") */
|
||||
name: string;
|
||||
/** 顶点着色器源代码 / Vertex shader GLSL source */
|
||||
vertex: string;
|
||||
/** 片段着色器源代码 / Fragment shader GLSL source */
|
||||
fragment: string;
|
||||
/** 属性定义列表 / Property definitions */
|
||||
properties: IShaderProperty[];
|
||||
/** 编译后的着色器 ID(运行时填充) / Compiled shader ID (runtime) */
|
||||
shaderId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Material property value
|
||||
* 材质属性值
|
||||
*/
|
||||
export type MaterialPropertyValue = number | number[] | string;
|
||||
|
||||
/**
|
||||
* Material animator configuration
|
||||
* 材质动画器配置
|
||||
*/
|
||||
export interface IMaterialAnimator {
|
||||
/** 要动画的属性名 / Property to animate */
|
||||
property: string;
|
||||
/** 起始值 / Start value */
|
||||
from: number;
|
||||
/** 结束值 / End value */
|
||||
to: number;
|
||||
/** 持续时间(秒) / Duration in seconds */
|
||||
duration: number;
|
||||
/** 是否循环 / Loop animation */
|
||||
loop?: boolean;
|
||||
/** 循环间隔(秒) / Delay between loops */
|
||||
loopDelay?: number;
|
||||
/** 缓动函数 / Easing function */
|
||||
easing?: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut';
|
||||
/** 是否自动播放 / Auto play on start */
|
||||
autoPlay?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Material asset interface
|
||||
* 材质资产接口
|
||||
*
|
||||
* Material assets reference a shader and define property values.
|
||||
* 材质资产引用着色器并定义属性值。
|
||||
*/
|
||||
export interface IMaterialAsset {
|
||||
/** 材质名称 / Material name */
|
||||
name: string;
|
||||
/** 着色器 GUID 或内置路径 / Shader GUID or built-in path (e.g., "builtin://shaders/Shiny") */
|
||||
shader: string;
|
||||
/** 材质属性值 / Material property values */
|
||||
properties: Record<string, MaterialPropertyValue>;
|
||||
/** 纹理映射 / Texture slot mappings (property name -> texture GUID) */
|
||||
textures?: Record<string, AssetGUID>;
|
||||
/** 渲染状态 / Render states */
|
||||
renderStates?: {
|
||||
cullMode?: 'none' | 'front' | 'back';
|
||||
blendMode?: 'none' | 'alpha' | 'additive' | 'multiply' | 'screen';
|
||||
depthTest?: boolean;
|
||||
depthWrite?: boolean;
|
||||
};
|
||||
/** 动画器配置(可选) / Animator configuration (optional) */
|
||||
animator?: IMaterialAnimator;
|
||||
/** 运行时:编译后的着色器 ID / Runtime: compiled shader ID */
|
||||
_shaderId?: number;
|
||||
/** 运行时:引擎材质 ID / Runtime: engine material ID */
|
||||
_materialId?: number;
|
||||
}
|
||||
|
||||
// 预制体资产接口从专用文件导出 | Prefab asset interface exported from dedicated file
|
||||
export type { IPrefabAsset, IPrefabData, IPrefabMetadata, IPrefabService } from './IPrefabAsset';
|
||||
|
||||
/**
|
||||
* Scene asset interface
|
||||
* 场景资产接口
|
||||
*/
|
||||
export interface ISceneAsset {
|
||||
/** 场景名称 / Scene name */
|
||||
name: string;
|
||||
/** 实体列表 / Serialized entity list */
|
||||
entities: unknown[];
|
||||
/** 场景设置 / Scene settings */
|
||||
settings: {
|
||||
/** 环境光 / Ambient light */
|
||||
ambientLight?: [number, number, number];
|
||||
/** 雾效 / Fog settings */
|
||||
fog?: {
|
||||
enabled: boolean;
|
||||
color: [number, number, number];
|
||||
density: number;
|
||||
};
|
||||
/** 天空盒 / Skybox asset */
|
||||
skybox?: AssetGUID;
|
||||
};
|
||||
/** 引用的资产 / All referenced assets */
|
||||
referencedAssets: AssetGUID[];
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON asset interface
|
||||
* JSON资产接口
|
||||
*/
|
||||
export interface IJsonAsset {
|
||||
/** JSON数据 / JSON data */
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text asset interface
|
||||
* 文本资产接口
|
||||
*/
|
||||
export interface ITextAsset {
|
||||
/** 文本内容 / Text content */
|
||||
content: string;
|
||||
/** 编码格式 / Encoding */
|
||||
encoding: 'utf8' | 'utf16' | 'ascii';
|
||||
}
|
||||
|
||||
/**
|
||||
* Binary asset interface
|
||||
* 二进制资产接口
|
||||
*/
|
||||
export interface IBinaryAsset {
|
||||
/** 二进制数据 / Binary data */
|
||||
data: ArrayBuffer;
|
||||
/** 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;
|
||||
}
|
||||
370
packages/engine/asset-system/src/interfaces/IAssetManager.ts
Normal file
370
packages/engine/asset-system/src/interfaces/IAssetManager.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* Asset manager interfaces
|
||||
* 资产管理器接口
|
||||
*/
|
||||
|
||||
import {
|
||||
AssetGUID,
|
||||
AssetHandle,
|
||||
AssetType,
|
||||
AssetState,
|
||||
IAssetLoadOptions,
|
||||
IAssetLoadResult,
|
||||
IAssetReferenceInfo,
|
||||
IAssetPreloadGroup,
|
||||
IAssetLoadProgress,
|
||||
IAssetCatalog
|
||||
} from '../types/AssetTypes';
|
||||
import { IAssetLoader, IAssetLoaderFactory } from './IAssetLoader';
|
||||
import { IAssetReader } from './IAssetReader';
|
||||
import type { AssetDatabase } from '../core/AssetDatabase';
|
||||
|
||||
/**
|
||||
* Asset manager interface
|
||||
* 资产管理器接口
|
||||
*/
|
||||
export interface IAssetManager {
|
||||
/**
|
||||
* Load asset by GUID
|
||||
* 通过GUID加载资产
|
||||
*/
|
||||
loadAsset<T = unknown>(
|
||||
guid: AssetGUID,
|
||||
options?: IAssetLoadOptions
|
||||
): Promise<IAssetLoadResult<T>>;
|
||||
|
||||
/**
|
||||
* Load asset by path
|
||||
* 通过路径加载资产
|
||||
*/
|
||||
loadAssetByPath<T = unknown>(
|
||||
path: string,
|
||||
options?: IAssetLoadOptions
|
||||
): Promise<IAssetLoadResult<T>>;
|
||||
|
||||
/**
|
||||
* Load multiple assets
|
||||
* 批量加载资产
|
||||
*/
|
||||
loadAssets(
|
||||
guids: AssetGUID[],
|
||||
options?: IAssetLoadOptions
|
||||
): Promise<Map<AssetGUID, IAssetLoadResult>>;
|
||||
|
||||
/**
|
||||
* Preload asset group
|
||||
* 预加载资产组
|
||||
*/
|
||||
preloadGroup(
|
||||
group: IAssetPreloadGroup,
|
||||
onProgress?: (progress: IAssetLoadProgress) => void
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get loaded asset
|
||||
* 获取已加载的资产
|
||||
*/
|
||||
getAsset<T = unknown>(guid: AssetGUID): T | null;
|
||||
|
||||
/**
|
||||
* Get asset by handle
|
||||
* 通过句柄获取资产
|
||||
*/
|
||||
getAssetByHandle<T = unknown>(handle: AssetHandle): T | null;
|
||||
|
||||
/**
|
||||
* Get loaded asset by path (synchronous)
|
||||
* 通过路径获取已加载的资产(同步)
|
||||
*/
|
||||
getAssetByPath<T = unknown>(path: string): T | null;
|
||||
|
||||
/**
|
||||
* Check if asset is loaded
|
||||
* 检查资产是否已加载
|
||||
*/
|
||||
isLoaded(guid: AssetGUID): boolean;
|
||||
|
||||
/**
|
||||
* Get asset state
|
||||
* 获取资产状态
|
||||
*/
|
||||
getAssetState(guid: AssetGUID): AssetState;
|
||||
|
||||
/**
|
||||
* Unload asset
|
||||
* 卸载资产
|
||||
*/
|
||||
unloadAsset(guid: AssetGUID): void;
|
||||
|
||||
/**
|
||||
* Unload all assets
|
||||
* 卸载所有资产
|
||||
*/
|
||||
unloadAllAssets(): void;
|
||||
|
||||
/**
|
||||
* Unload unused assets
|
||||
* 卸载未使用的资产
|
||||
*/
|
||||
unloadUnusedAssets(): void;
|
||||
|
||||
/**
|
||||
* Add reference to asset
|
||||
* 增加资产引用
|
||||
*/
|
||||
addReference(guid: AssetGUID): void;
|
||||
|
||||
/**
|
||||
* Remove reference from asset
|
||||
* 移除资产引用
|
||||
*/
|
||||
removeReference(guid: AssetGUID): void;
|
||||
|
||||
/**
|
||||
* Get reference info
|
||||
* 获取引用信息
|
||||
*/
|
||||
getReferenceInfo(guid: AssetGUID): IAssetReferenceInfo | null;
|
||||
|
||||
/**
|
||||
* Register custom loader
|
||||
* 注册自定义加载器
|
||||
*/
|
||||
registerLoader(type: AssetType, loader: IAssetLoader): void;
|
||||
|
||||
/**
|
||||
* Get asset statistics
|
||||
* 获取资产统计信息
|
||||
*/
|
||||
getStatistics(): {
|
||||
loadedCount: number;
|
||||
loadQueue: number;
|
||||
failedCount: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear cache
|
||||
* 清空缓存
|
||||
*/
|
||||
clearCache(): void;
|
||||
|
||||
/**
|
||||
* Dispose manager
|
||||
* 释放管理器
|
||||
*/
|
||||
dispose(): void;
|
||||
|
||||
/**
|
||||
* Set asset reader
|
||||
* 设置资产读取器
|
||||
*/
|
||||
setReader(reader: IAssetReader): void;
|
||||
|
||||
/**
|
||||
* Initialize from catalog
|
||||
* 从目录初始化
|
||||
*
|
||||
* Loads asset metadata from a catalog for runtime asset resolution.
|
||||
* 从目录加载资产元数据,用于运行时资产解析。
|
||||
*/
|
||||
initializeFromCatalog(catalog: IAssetCatalog): void;
|
||||
|
||||
/**
|
||||
* Get the asset database
|
||||
* 获取资产数据库
|
||||
*/
|
||||
getDatabase(): AssetDatabase;
|
||||
|
||||
/**
|
||||
* Get the loader factory
|
||||
* 获取加载器工厂
|
||||
*/
|
||||
getLoaderFactory(): IAssetLoaderFactory;
|
||||
|
||||
/**
|
||||
* Set project root path
|
||||
* 设置项目根路径
|
||||
*/
|
||||
setProjectRoot(path: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset cache interface
|
||||
* 资产缓存接口
|
||||
*/
|
||||
export interface IAssetCache {
|
||||
/**
|
||||
* Get cached asset
|
||||
* 获取缓存的资产
|
||||
*/
|
||||
get<T = unknown>(guid: AssetGUID): T | null;
|
||||
|
||||
/**
|
||||
* Set cached asset
|
||||
* 设置缓存的资产
|
||||
*/
|
||||
set<T = unknown>(guid: AssetGUID, asset: T, size: number): void;
|
||||
|
||||
/**
|
||||
* Check if asset is cached
|
||||
* 检查资产是否已缓存
|
||||
*/
|
||||
has(guid: AssetGUID): boolean;
|
||||
|
||||
/**
|
||||
* Remove from cache
|
||||
* 从缓存中移除
|
||||
*/
|
||||
remove(guid: AssetGUID): void;
|
||||
|
||||
/**
|
||||
* Clear all cache
|
||||
* 清空所有缓存
|
||||
*/
|
||||
clear(): void;
|
||||
|
||||
/**
|
||||
* Get cache size
|
||||
* 获取缓存大小
|
||||
*/
|
||||
getSize(): number;
|
||||
|
||||
/**
|
||||
* Get cached asset count
|
||||
* 获取缓存资产数量
|
||||
*/
|
||||
getCount(): number;
|
||||
|
||||
/**
|
||||
* Evict assets based on policy
|
||||
* 根据策略驱逐资产
|
||||
*/
|
||||
evict(targetSize: number): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset loading queue interface
|
||||
* 资产加载队列接口
|
||||
*/
|
||||
export interface IAssetLoadQueue {
|
||||
/**
|
||||
* Add to queue
|
||||
* 添加到队列
|
||||
*/
|
||||
enqueue(
|
||||
guid: AssetGUID,
|
||||
priority: number,
|
||||
options?: IAssetLoadOptions
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Remove from queue
|
||||
* 从队列移除
|
||||
*/
|
||||
dequeue(): {
|
||||
guid: AssetGUID;
|
||||
options?: IAssetLoadOptions;
|
||||
} | null;
|
||||
|
||||
/**
|
||||
* Check if queue is empty
|
||||
* 检查队列是否为空
|
||||
*/
|
||||
isEmpty(): boolean;
|
||||
|
||||
/**
|
||||
* Get queue size
|
||||
* 获取队列大小
|
||||
*/
|
||||
getSize(): number;
|
||||
|
||||
/**
|
||||
* Clear queue
|
||||
* 清空队列
|
||||
*/
|
||||
clear(): void;
|
||||
|
||||
/**
|
||||
* Reprioritize item
|
||||
* 重新设置优先级
|
||||
*/
|
||||
reprioritize(guid: AssetGUID, newPriority: number): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset dependency resolver interface
|
||||
* 资产依赖解析器接口
|
||||
*/
|
||||
export interface IAssetDependencyResolver {
|
||||
/**
|
||||
* Resolve dependencies for asset
|
||||
* 解析资产的依赖
|
||||
*/
|
||||
resolveDependencies(guid: AssetGUID): Promise<AssetGUID[]>;
|
||||
|
||||
/**
|
||||
* Get direct dependencies
|
||||
* 获取直接依赖
|
||||
*/
|
||||
getDirectDependencies(guid: AssetGUID): AssetGUID[];
|
||||
|
||||
/**
|
||||
* Get all dependencies recursively
|
||||
* 递归获取所有依赖
|
||||
*/
|
||||
getAllDependencies(guid: AssetGUID): AssetGUID[];
|
||||
|
||||
/**
|
||||
* Check for circular dependencies
|
||||
* 检查循环依赖
|
||||
*/
|
||||
hasCircularDependency(guid: AssetGUID): boolean;
|
||||
|
||||
/**
|
||||
* Build dependency graph
|
||||
* 构建依赖图
|
||||
*/
|
||||
buildDependencyGraph(guids: AssetGUID[]): Map<AssetGUID, AssetGUID[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset streaming interface
|
||||
* 资产流式加载接口
|
||||
*/
|
||||
export interface IAssetStreaming {
|
||||
/**
|
||||
* Start streaming assets
|
||||
* 开始流式加载资产
|
||||
*/
|
||||
startStreaming(guids: AssetGUID[]): void;
|
||||
|
||||
/**
|
||||
* Stop streaming
|
||||
* 停止流式加载
|
||||
*/
|
||||
stopStreaming(): void;
|
||||
|
||||
/**
|
||||
* Pause streaming
|
||||
* 暂停流式加载
|
||||
*/
|
||||
pauseStreaming(): void;
|
||||
|
||||
/**
|
||||
* Resume streaming
|
||||
* 恢复流式加载
|
||||
*/
|
||||
resumeStreaming(): void;
|
||||
|
||||
/**
|
||||
* Set streaming budget per frame
|
||||
* 设置每帧流式加载预算
|
||||
*/
|
||||
setFrameBudget(milliseconds: number): void;
|
||||
|
||||
/**
|
||||
* Get streaming progress
|
||||
* 获取流式加载进度
|
||||
*/
|
||||
getProgress(): IAssetLoadProgress;
|
||||
}
|
||||
90
packages/engine/asset-system/src/interfaces/IAssetReader.ts
Normal file
90
packages/engine/asset-system/src/interfaces/IAssetReader.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Asset Reader Interface
|
||||
* 资产读取器接口
|
||||
*
|
||||
* Provides unified file reading abstraction across different platforms.
|
||||
* 提供跨平台的统一文件读取抽象。
|
||||
*/
|
||||
|
||||
/**
|
||||
* Asset content types.
|
||||
* 资产内容类型。
|
||||
*/
|
||||
export type AssetContentType = 'text' | 'binary' | 'image' | 'audio';
|
||||
|
||||
/**
|
||||
* Asset content result.
|
||||
* 资产内容结果。
|
||||
*/
|
||||
export interface IAssetContent {
|
||||
/** Content type. | 内容类型。 */
|
||||
type: AssetContentType;
|
||||
/** Text content (for text/json files). | 文本内容。 */
|
||||
text?: string;
|
||||
/** Binary content. | 二进制内容。 */
|
||||
binary?: ArrayBuffer;
|
||||
/** Image element (for textures). | 图片元素。 */
|
||||
image?: HTMLImageElement;
|
||||
/** Audio buffer (for audio files). | 音频缓冲区。 */
|
||||
audioBuffer?: AudioBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset reader interface.
|
||||
* 资产读取器接口。
|
||||
*
|
||||
* Abstracts platform-specific file reading operations.
|
||||
* 抽象平台特定的文件读取操作。
|
||||
*/
|
||||
export interface IAssetReader {
|
||||
/**
|
||||
* Read file as text.
|
||||
* 读取文件为文本。
|
||||
*
|
||||
* @param absolutePath - Absolute file path. | 绝对文件路径。
|
||||
* @returns Text content. | 文本内容。
|
||||
*/
|
||||
readText(absolutePath: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Read file as binary.
|
||||
* 读取文件为二进制。
|
||||
*
|
||||
* @param absolutePath - Absolute file path. | 绝对文件路径。
|
||||
* @returns Binary content. | 二进制内容。
|
||||
*/
|
||||
readBinary(absolutePath: string): Promise<ArrayBuffer>;
|
||||
|
||||
/**
|
||||
* Load image from file.
|
||||
* 从文件加载图片。
|
||||
*
|
||||
* @param absolutePath - Absolute file path. | 绝对文件路径。
|
||||
* @returns Image element. | 图片元素。
|
||||
*/
|
||||
loadImage(absolutePath: string): Promise<HTMLImageElement>;
|
||||
|
||||
/**
|
||||
* Load audio from file.
|
||||
* 从文件加载音频。
|
||||
*
|
||||
* @param absolutePath - Absolute file path. | 绝对文件路径。
|
||||
* @returns Audio buffer. | 音频缓冲区。
|
||||
*/
|
||||
loadAudio(absolutePath: string): Promise<AudioBuffer>;
|
||||
|
||||
/**
|
||||
* Check if file exists.
|
||||
* 检查文件是否存在。
|
||||
*
|
||||
* @param absolutePath - Absolute file path. | 绝对文件路径。
|
||||
* @returns True if exists. | 是否存在。
|
||||
*/
|
||||
exists(absolutePath: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service identifier for IAssetReader.
|
||||
* IAssetReader 的服务标识符。
|
||||
*/
|
||||
export const IAssetReaderService = Symbol.for('IAssetReaderService');
|
||||
405
packages/engine/asset-system/src/interfaces/IPrefabAsset.ts
Normal file
405
packages/engine/asset-system/src/interfaces/IPrefabAsset.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* 预制体资产接口定义
|
||||
* Prefab asset interface definitions
|
||||
*
|
||||
* 定义预制体系统的核心类型,包括预制体数据格式、元数据、实例化选项等。
|
||||
* Defines core types for the prefab system including data format, metadata, instantiation options, etc.
|
||||
*/
|
||||
|
||||
import type { AssetGUID } from '../types/AssetTypes';
|
||||
import type { SerializedEntity } from '@esengine/ecs-framework';
|
||||
|
||||
/**
|
||||
* 预制体序列化实体(扩展自 SerializedEntity)
|
||||
* Serialized prefab entity (extends SerializedEntity)
|
||||
*
|
||||
* 在标准 SerializedEntity 基础上添加预制体特定属性。
|
||||
* Adds prefab-specific properties on top of standard SerializedEntity.
|
||||
*/
|
||||
export interface SerializedPrefabEntity extends SerializedEntity {
|
||||
/**
|
||||
* 是否为预制体根节点
|
||||
* Whether this is the prefab root entity
|
||||
*/
|
||||
isPrefabRoot?: boolean;
|
||||
|
||||
/**
|
||||
* 嵌套预制体的 GUID(如果此实体是另一个预制体的实例)
|
||||
* GUID of nested prefab (if this entity is an instance of another prefab)
|
||||
*/
|
||||
nestedPrefabGuid?: AssetGUID;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预制体元数据
|
||||
* Prefab metadata
|
||||
*/
|
||||
export interface IPrefabMetadata {
|
||||
/**
|
||||
* 预制体名称
|
||||
* Prefab name
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* 资产 GUID(在保存为资产后填充)
|
||||
* Asset GUID (populated after saving as asset)
|
||||
*/
|
||||
guid?: AssetGUID;
|
||||
|
||||
/**
|
||||
* 创建时间戳
|
||||
* Creation timestamp
|
||||
*/
|
||||
createdAt: number;
|
||||
|
||||
/**
|
||||
* 最后修改时间戳
|
||||
* Last modification timestamp
|
||||
*/
|
||||
modifiedAt: number;
|
||||
|
||||
/**
|
||||
* 使用的组件类型列表
|
||||
* List of component types used
|
||||
*/
|
||||
componentTypes: string[];
|
||||
|
||||
/**
|
||||
* 引用的资产 GUID 列表
|
||||
* List of referenced asset GUIDs
|
||||
*/
|
||||
referencedAssets: AssetGUID[];
|
||||
|
||||
/**
|
||||
* 预制体描述
|
||||
* Prefab description
|
||||
*/
|
||||
description?: string;
|
||||
|
||||
/**
|
||||
* 预制体标签(用于分类和搜索)
|
||||
* Prefab tags (for categorization and search)
|
||||
*/
|
||||
tags?: string[];
|
||||
|
||||
/**
|
||||
* 缩略图数据(Base64 编码)
|
||||
* Thumbnail data (Base64 encoded)
|
||||
*/
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件类型注册条目
|
||||
* Component type registry entry
|
||||
*/
|
||||
export interface IPrefabComponentTypeEntry {
|
||||
/**
|
||||
* 组件类型名称
|
||||
* Component type name
|
||||
*/
|
||||
typeName: string;
|
||||
|
||||
/**
|
||||
* 组件版本号
|
||||
* Component version number
|
||||
*/
|
||||
version: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预制体文件数据格式
|
||||
* Prefab file data format
|
||||
*
|
||||
* 这是 .prefab 文件的完整结构。
|
||||
* This is the complete structure of a .prefab file.
|
||||
*/
|
||||
export interface IPrefabData {
|
||||
/**
|
||||
* 预制体格式版本号
|
||||
* Prefab format version number
|
||||
*/
|
||||
version: number;
|
||||
|
||||
/**
|
||||
* 预制体元数据
|
||||
* Prefab metadata
|
||||
*/
|
||||
metadata: IPrefabMetadata;
|
||||
|
||||
/**
|
||||
* 根实体数据(包含完整的实体层级)
|
||||
* Root entity data (contains full entity hierarchy)
|
||||
*/
|
||||
root: SerializedPrefabEntity;
|
||||
|
||||
/**
|
||||
* 组件类型注册表(用于版本管理和兼容性检查)
|
||||
* Component type registry (for versioning and compatibility checks)
|
||||
*/
|
||||
componentTypeRegistry: IPrefabComponentTypeEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 预制体资产(加载后的内存表示)
|
||||
* Prefab asset (in-memory representation after loading)
|
||||
*/
|
||||
export interface IPrefabAsset {
|
||||
/**
|
||||
* 预制体数据
|
||||
* Prefab data
|
||||
*/
|
||||
data: IPrefabData;
|
||||
|
||||
/**
|
||||
* 资产 GUID
|
||||
* Asset GUID
|
||||
*/
|
||||
guid: AssetGUID;
|
||||
|
||||
/**
|
||||
* 资产路径
|
||||
* Asset path
|
||||
*/
|
||||
path: string;
|
||||
|
||||
/**
|
||||
* 根实体数据(快捷访问)
|
||||
* Root entity data (quick access)
|
||||
*/
|
||||
readonly root: SerializedPrefabEntity;
|
||||
|
||||
/**
|
||||
* 包含的组件类型列表(快捷访问)
|
||||
* List of component types used (quick access)
|
||||
*/
|
||||
readonly componentTypes: string[];
|
||||
|
||||
/**
|
||||
* 引用的资产列表(快捷访问)
|
||||
* List of referenced assets (quick access)
|
||||
*/
|
||||
readonly referencedAssets: AssetGUID[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 预制体实例化选项
|
||||
* Prefab instantiation options
|
||||
*/
|
||||
export interface IPrefabInstantiateOptions {
|
||||
/**
|
||||
* 父实体 ID(可选)
|
||||
* Parent entity ID (optional)
|
||||
*/
|
||||
parentId?: number;
|
||||
|
||||
/**
|
||||
* 位置覆盖
|
||||
* Position override
|
||||
*/
|
||||
position?: { x: number; y: number };
|
||||
|
||||
/**
|
||||
* 旋转覆盖(角度)
|
||||
* Rotation override (in degrees)
|
||||
*/
|
||||
rotation?: number;
|
||||
|
||||
/**
|
||||
* 缩放覆盖
|
||||
* Scale override
|
||||
*/
|
||||
scale?: { x: number; y: number };
|
||||
|
||||
/**
|
||||
* 实体名称覆盖
|
||||
* Entity name override
|
||||
*/
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* 是否保留原始实体 ID(默认 false,生成新 ID)
|
||||
* Whether to preserve original entity IDs (default false, generate new IDs)
|
||||
*/
|
||||
preserveIds?: boolean;
|
||||
|
||||
/**
|
||||
* 是否标记为预制体实例(默认 true)
|
||||
* Whether to mark as prefab instance (default true)
|
||||
*/
|
||||
trackInstance?: boolean;
|
||||
|
||||
/**
|
||||
* 属性覆盖(组件属性覆盖)
|
||||
* Property overrides (component property overrides)
|
||||
*/
|
||||
propertyOverrides?: IPrefabPropertyOverride[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 预制体属性覆盖
|
||||
* Prefab property override
|
||||
*
|
||||
* 用于记录预制体实例对原始预制体属性的修改。
|
||||
* Used to record modifications to prefab properties in instances.
|
||||
*/
|
||||
export interface IPrefabPropertyOverride {
|
||||
/**
|
||||
* 目标实体路径(从根节点的相对路径,如 "Root/Child/GrandChild")
|
||||
* Target entity path (relative path from root, e.g., "Root/Child/GrandChild")
|
||||
*/
|
||||
entityPath: string;
|
||||
|
||||
/**
|
||||
* 组件类型名称
|
||||
* Component type name
|
||||
*/
|
||||
componentType: string;
|
||||
|
||||
/**
|
||||
* 属性路径(支持嵌套,如 "position.x")
|
||||
* Property path (supports nesting, e.g., "position.x")
|
||||
*/
|
||||
propertyPath: string;
|
||||
|
||||
/**
|
||||
* 覆盖值
|
||||
* Override value
|
||||
*/
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预制体创建选项
|
||||
* Prefab creation options
|
||||
*/
|
||||
export interface IPrefabCreateOptions {
|
||||
/**
|
||||
* 预制体名称
|
||||
* Prefab name
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* 预制体描述
|
||||
* Prefab description
|
||||
*/
|
||||
description?: string;
|
||||
|
||||
/**
|
||||
* 预制体标签
|
||||
* Prefab tags
|
||||
*/
|
||||
tags?: string[];
|
||||
|
||||
/**
|
||||
* 是否包含子实体
|
||||
* Whether to include child entities
|
||||
*/
|
||||
includeChildren?: boolean;
|
||||
|
||||
/**
|
||||
* 保存路径(可选,用于指定保存位置)
|
||||
* Save path (optional, for specifying save location)
|
||||
*/
|
||||
savePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预制体服务接口
|
||||
* Prefab service interface
|
||||
*
|
||||
* 提供预制体的创建、实例化、管理等功能。
|
||||
* Provides prefab creation, instantiation, management, etc.
|
||||
*/
|
||||
export interface IPrefabService {
|
||||
/**
|
||||
* 从实体创建预制体数据
|
||||
* Create prefab data from entity
|
||||
*
|
||||
* @param entity - 源实体 | Source entity
|
||||
* @param options - 创建选项 | Creation options
|
||||
* @returns 预制体数据 | Prefab data
|
||||
*/
|
||||
createPrefab(entity: unknown, options: IPrefabCreateOptions): IPrefabData;
|
||||
|
||||
/**
|
||||
* 实例化预制体
|
||||
* Instantiate prefab
|
||||
*
|
||||
* @param prefab - 预制体资产 | Prefab asset
|
||||
* @param scene - 目标场景 | Target scene
|
||||
* @param options - 实例化选项 | Instantiation options
|
||||
* @returns 创建的根实体 | Created root entity
|
||||
*/
|
||||
instantiate(prefab: IPrefabAsset, scene: unknown, options?: IPrefabInstantiateOptions): unknown;
|
||||
|
||||
/**
|
||||
* 通过 GUID 实例化预制体
|
||||
* Instantiate prefab by GUID
|
||||
*
|
||||
* @param guid - 预制体资产 GUID | Prefab asset GUID
|
||||
* @param scene - 目标场景 | Target scene
|
||||
* @param options - 实例化选项 | Instantiation options
|
||||
* @returns 创建的根实体 | Created root entity
|
||||
*/
|
||||
instantiateByGuid(guid: AssetGUID, scene: unknown, options?: IPrefabInstantiateOptions): Promise<unknown>;
|
||||
|
||||
/**
|
||||
* 检查实体是否为预制体实例
|
||||
* Check if entity is a prefab instance
|
||||
*
|
||||
* @param entity - 要检查的实体 | Entity to check
|
||||
* @returns 是否为预制体实例 | Whether it's a prefab instance
|
||||
*/
|
||||
isPrefabInstance(entity: unknown): boolean;
|
||||
|
||||
/**
|
||||
* 获取预制体实例的源预制体 GUID
|
||||
* Get source prefab GUID of a prefab instance
|
||||
*
|
||||
* @param entity - 预制体实例 | Prefab instance
|
||||
* @returns 源预制体 GUID,如果不是实例则返回 null | Source prefab GUID, null if not an instance
|
||||
*/
|
||||
getSourcePrefabGuid(entity: unknown): AssetGUID | null;
|
||||
|
||||
/**
|
||||
* 将实例的修改应用到源预制体
|
||||
* Apply instance modifications to source prefab
|
||||
*
|
||||
* @param instance - 预制体实例 | Prefab instance
|
||||
* @returns 是否成功应用 | Whether application was successful
|
||||
*/
|
||||
applyToPrefab?(instance: unknown): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 将实例还原为源预制体的状态
|
||||
* Revert instance to source prefab state
|
||||
*
|
||||
* @param instance - 预制体实例 | Prefab instance
|
||||
* @returns 是否成功还原 | Whether revert was successful
|
||||
*/
|
||||
revertToPrefab?(instance: unknown): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 获取实例相对于源预制体的属性覆盖
|
||||
* Get property overrides of instance relative to source prefab
|
||||
*
|
||||
* @param instance - 预制体实例 | Prefab instance
|
||||
* @returns 属性覆盖列表 | List of property overrides
|
||||
*/
|
||||
getPropertyOverrides?(instance: unknown): IPrefabPropertyOverride[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 预制体文件格式版本
|
||||
* Prefab file format version
|
||||
*/
|
||||
export const PREFAB_FORMAT_VERSION = 1;
|
||||
|
||||
/**
|
||||
* 预制体文件扩展名
|
||||
* Prefab file extension
|
||||
*/
|
||||
export const PREFAB_FILE_EXTENSION = '.prefab';
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 资源组件接口 - 用于依赖运行时资源的组件(纹理、音频等)
|
||||
* Interface for components that depend on runtime resources (textures, audio, etc.)
|
||||
*
|
||||
* 实现此接口的组件可以参与 SceneResourceManager 管理的集中式资源加载
|
||||
* Components implementing this interface can participate in centralized resource loading managed by SceneResourceManager
|
||||
*/
|
||||
|
||||
/**
|
||||
* 资源引用 - 包含路径和运行时 ID
|
||||
* Resource reference with path and runtime ID
|
||||
*/
|
||||
export interface ResourceReference {
|
||||
/** 资源路径(例如 "assets/sprites/player.png")/ Asset path (e.g., "assets/sprites/player.png") */
|
||||
path: string;
|
||||
/** 引擎分配的运行时资源 ID(例如 GPU 上的纹理 ID)/ Runtime resource ID assigned by engine (e.g., texture ID on GPU) */
|
||||
runtimeId?: number;
|
||||
/** 资源类型标识符 / Resource type identifier */
|
||||
type: 'texture' | 'audio' | 'font' | 'data';
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源组件接口
|
||||
* Resource component interface
|
||||
*
|
||||
* 实现此接口的组件可以在场景启动前由 SceneResourceManager 集中加载资源
|
||||
* Components implementing this interface can have their resources loaded centrally by SceneResourceManager before the scene starts
|
||||
*/
|
||||
export interface IResourceComponent {
|
||||
/**
|
||||
* 获取此组件需要的所有资源引用
|
||||
* Get all resource references needed by this component
|
||||
*
|
||||
* 在场景加载期间调用以收集资源路径
|
||||
* Called during scene loading to collect resource paths
|
||||
*/
|
||||
getResourceReferences(): ResourceReference[];
|
||||
|
||||
/**
|
||||
* 设置已加载资源的运行时 ID
|
||||
* Set runtime IDs for loaded resources
|
||||
*
|
||||
* 在 SceneResourceManager 加载资源后调用
|
||||
* Called after resources are loaded by SceneResourceManager
|
||||
*
|
||||
* @param pathToId 资源路径到运行时 ID 的映射 / Map of resource paths to runtime IDs
|
||||
*/
|
||||
setResourceIds(pathToId: Map<string, number>): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型守卫 - 检查组件是否实现了 IResourceComponent
|
||||
* Type guard to check if a component implements IResourceComponent
|
||||
*/
|
||||
export function isResourceComponent(component: any): component is IResourceComponent {
|
||||
return (
|
||||
component !== null &&
|
||||
typeof component === 'object' &&
|
||||
typeof component.getResourceReferences === 'function' &&
|
||||
typeof component.setResourceIds === 'function'
|
||||
);
|
||||
}
|
||||
285
packages/engine/asset-system/src/loaders/AssetLoaderFactory.ts
Normal file
285
packages/engine/asset-system/src/loaders/AssetLoaderFactory.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
109
packages/engine/asset-system/src/loaders/AudioLoader.ts
Normal file
109
packages/engine/asset-system/src/loaders/AudioLoader.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
45
packages/engine/asset-system/src/loaders/BinaryLoader.ts
Normal file
45
packages/engine/asset-system/src/loaders/BinaryLoader.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
2193
packages/engine/asset-system/src/loaders/FBXLoader.ts
Normal file
2193
packages/engine/asset-system/src/loaders/FBXLoader.ts
Normal file
File diff suppressed because it is too large
Load Diff
994
packages/engine/asset-system/src/loaders/GLTFLoader.ts
Normal file
994
packages/engine/asset-system/src/loaders/GLTFLoader.ts
Normal file
@@ -0,0 +1,994 @@
|
||||
/**
|
||||
* GLTF/GLB model loader implementation
|
||||
* GLTF/GLB 模型加载器实现
|
||||
*
|
||||
* Supports:
|
||||
* - GLTF 2.0 (.gltf with external/embedded resources)
|
||||
* - GLB (.glb binary format)
|
||||
* - PBR materials
|
||||
* - Scene hierarchy
|
||||
* - Animations (basic)
|
||||
* - Skinning (basic)
|
||||
*/
|
||||
|
||||
import { AssetType } from '../types/AssetTypes';
|
||||
import type { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
|
||||
import type {
|
||||
IAssetLoader,
|
||||
IAssetParseContext,
|
||||
IGLTFAsset,
|
||||
IMeshData,
|
||||
IGLTFMaterial,
|
||||
IGLTFTextureInfo,
|
||||
IGLTFNode,
|
||||
IGLTFAnimationClip,
|
||||
IAnimationSampler,
|
||||
IAnimationChannel,
|
||||
IBoundingBox,
|
||||
ISkeletonData,
|
||||
ISkeletonJoint
|
||||
} from '../interfaces/IAssetLoader';
|
||||
|
||||
// ===== GLTF JSON Schema Types =====
|
||||
|
||||
interface GLTFJson {
|
||||
asset: { version: string; generator?: string };
|
||||
scene?: number;
|
||||
scenes?: GLTFScene[];
|
||||
nodes?: GLTFNodeDef[];
|
||||
meshes?: GLTFMeshDef[];
|
||||
accessors?: GLTFAccessor[];
|
||||
bufferViews?: GLTFBufferView[];
|
||||
buffers?: GLTFBuffer[];
|
||||
materials?: GLTFMaterialDef[];
|
||||
textures?: GLTFTextureDef[];
|
||||
images?: GLTFImage[];
|
||||
samplers?: GLTFSampler[];
|
||||
animations?: GLTFAnimation[];
|
||||
skins?: GLTFSkin[];
|
||||
}
|
||||
|
||||
interface GLTFScene {
|
||||
name?: string;
|
||||
nodes?: number[];
|
||||
}
|
||||
|
||||
interface GLTFNodeDef {
|
||||
name?: string;
|
||||
mesh?: number;
|
||||
children?: number[];
|
||||
translation?: [number, number, number];
|
||||
rotation?: [number, number, number, number];
|
||||
scale?: [number, number, number];
|
||||
matrix?: number[];
|
||||
skin?: number;
|
||||
}
|
||||
|
||||
interface GLTFMeshDef {
|
||||
name?: string;
|
||||
primitives: GLTFPrimitive[];
|
||||
}
|
||||
|
||||
interface GLTFPrimitive {
|
||||
attributes: Record<string, number>;
|
||||
indices?: number;
|
||||
material?: number;
|
||||
mode?: number;
|
||||
}
|
||||
|
||||
interface GLTFAccessor {
|
||||
bufferView?: number;
|
||||
byteOffset?: number;
|
||||
componentType: number;
|
||||
count: number;
|
||||
type: string;
|
||||
min?: number[];
|
||||
max?: number[];
|
||||
normalized?: boolean;
|
||||
}
|
||||
|
||||
interface GLTFBufferView {
|
||||
buffer: number;
|
||||
byteOffset?: number;
|
||||
byteLength: number;
|
||||
byteStride?: number;
|
||||
target?: number;
|
||||
}
|
||||
|
||||
interface GLTFBuffer {
|
||||
uri?: string;
|
||||
byteLength: number;
|
||||
}
|
||||
|
||||
interface GLTFMaterialDef {
|
||||
name?: string;
|
||||
pbrMetallicRoughness?: {
|
||||
baseColorFactor?: [number, number, number, number];
|
||||
baseColorTexture?: { index: number };
|
||||
metallicFactor?: number;
|
||||
roughnessFactor?: number;
|
||||
metallicRoughnessTexture?: { index: number };
|
||||
};
|
||||
normalTexture?: { index: number; scale?: number };
|
||||
occlusionTexture?: { index: number; strength?: number };
|
||||
emissiveFactor?: [number, number, number];
|
||||
emissiveTexture?: { index: number };
|
||||
alphaMode?: 'OPAQUE' | 'MASK' | 'BLEND';
|
||||
alphaCutoff?: number;
|
||||
doubleSided?: boolean;
|
||||
}
|
||||
|
||||
interface GLTFTextureDef {
|
||||
source?: number;
|
||||
sampler?: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface GLTFImage {
|
||||
uri?: string;
|
||||
mimeType?: string;
|
||||
bufferView?: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface GLTFSampler {
|
||||
magFilter?: number;
|
||||
minFilter?: number;
|
||||
wrapS?: number;
|
||||
wrapT?: number;
|
||||
}
|
||||
|
||||
interface GLTFAnimation {
|
||||
name?: string;
|
||||
channels: GLTFAnimationChannel[];
|
||||
samplers: GLTFAnimationSampler[];
|
||||
}
|
||||
|
||||
interface GLTFAnimationChannel {
|
||||
sampler: number;
|
||||
target: {
|
||||
node?: number;
|
||||
path: 'translation' | 'rotation' | 'scale' | 'weights';
|
||||
};
|
||||
}
|
||||
|
||||
interface GLTFAnimationSampler {
|
||||
input: number;
|
||||
output: number;
|
||||
interpolation?: 'LINEAR' | 'STEP' | 'CUBICSPLINE';
|
||||
}
|
||||
|
||||
interface GLTFSkin {
|
||||
name?: string;
|
||||
inverseBindMatrices?: number;
|
||||
skeleton?: number;
|
||||
joints: number[];
|
||||
}
|
||||
|
||||
// ===== Component Type Constants =====
|
||||
const COMPONENT_TYPE_BYTE = 5120;
|
||||
const COMPONENT_TYPE_UNSIGNED_BYTE = 5121;
|
||||
const COMPONENT_TYPE_SHORT = 5122;
|
||||
const COMPONENT_TYPE_UNSIGNED_SHORT = 5123;
|
||||
const COMPONENT_TYPE_UNSIGNED_INT = 5125;
|
||||
const COMPONENT_TYPE_FLOAT = 5126;
|
||||
|
||||
// ===== GLB Constants =====
|
||||
const GLB_MAGIC = 0x46546C67; // 'glTF'
|
||||
const GLB_VERSION = 2;
|
||||
const GLB_CHUNK_TYPE_JSON = 0x4E4F534A; // 'JSON'
|
||||
const GLB_CHUNK_TYPE_BIN = 0x004E4942; // 'BIN\0'
|
||||
|
||||
/**
|
||||
* GLTF/GLB model loader
|
||||
* GLTF/GLB 模型加载器
|
||||
*/
|
||||
export class GLTFLoader implements IAssetLoader<IGLTFAsset> {
|
||||
readonly supportedType = AssetType.Model3D;
|
||||
readonly supportedExtensions = ['.gltf', '.glb'];
|
||||
readonly contentType: AssetContentType = 'binary';
|
||||
|
||||
/**
|
||||
* Parse GLTF/GLB content
|
||||
* 解析 GLTF/GLB 内容
|
||||
*/
|
||||
async parse(content: IAssetContent, context: IAssetParseContext): Promise<IGLTFAsset> {
|
||||
const binary = content.binary;
|
||||
if (!binary) {
|
||||
throw new Error('GLTF loader requires binary content');
|
||||
}
|
||||
|
||||
const isGLB = this.isGLB(binary);
|
||||
let json: GLTFJson;
|
||||
let binaryChunk: ArrayBuffer | null = null;
|
||||
|
||||
if (isGLB) {
|
||||
const glbData = this.parseGLB(binary);
|
||||
json = glbData.json;
|
||||
binaryChunk = glbData.binary;
|
||||
} else {
|
||||
// GLTF is JSON text
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
const text = decoder.decode(binary);
|
||||
json = JSON.parse(text) as GLTFJson;
|
||||
}
|
||||
|
||||
// Validate GLTF version
|
||||
if (!json.asset?.version?.startsWith('2.')) {
|
||||
throw new Error(`Unsupported GLTF version: ${json.asset?.version}. Only GLTF 2.x is supported.`);
|
||||
}
|
||||
|
||||
// Load external buffers if needed
|
||||
const buffers = await this.loadBuffers(json, binaryChunk, context);
|
||||
|
||||
// Parse all components
|
||||
const meshes = this.parseMeshes(json, buffers);
|
||||
const materials = this.parseMaterials(json);
|
||||
const textures = await this.parseTextures(json, buffers, context);
|
||||
const nodes = this.parseNodes(json);
|
||||
const rootNodes = this.getRootNodes(json);
|
||||
const animations = this.parseAnimations(json, buffers);
|
||||
const skeleton = this.parseSkeleton(json, buffers);
|
||||
const bounds = this.calculateBounds(meshes);
|
||||
|
||||
// Get model name from file path
|
||||
const pathParts = context.metadata.path.split(/[\\/]/);
|
||||
const fileName = pathParts[pathParts.length - 1];
|
||||
const name = fileName.replace(/\.(gltf|glb)$/i, '');
|
||||
|
||||
return {
|
||||
name,
|
||||
meshes,
|
||||
materials,
|
||||
textures,
|
||||
nodes,
|
||||
rootNodes,
|
||||
animations: animations.length > 0 ? animations : undefined,
|
||||
skeleton,
|
||||
bounds,
|
||||
sourcePath: context.metadata.path
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose GLTF asset
|
||||
* 释放 GLTF 资产
|
||||
*/
|
||||
dispose(asset: IGLTFAsset): void {
|
||||
// Clear mesh data
|
||||
for (const mesh of asset.meshes) {
|
||||
(mesh as { vertices: Float32Array | null }).vertices = null!;
|
||||
(mesh as { indices: Uint16Array | Uint32Array | null }).indices = null!;
|
||||
if (mesh.normals) (mesh as { normals: Float32Array | null }).normals = null;
|
||||
if (mesh.uvs) (mesh as { uvs: Float32Array | null }).uvs = null;
|
||||
if (mesh.colors) (mesh as { colors: Float32Array | null }).colors = null;
|
||||
}
|
||||
asset.meshes.length = 0;
|
||||
asset.materials.length = 0;
|
||||
asset.textures.length = 0;
|
||||
asset.nodes.length = 0;
|
||||
}
|
||||
|
||||
// ===== Private Methods =====
|
||||
|
||||
/**
|
||||
* Check if content is GLB format
|
||||
*/
|
||||
private isGLB(data: ArrayBuffer): boolean {
|
||||
if (data.byteLength < 12) return false;
|
||||
const view = new DataView(data);
|
||||
return view.getUint32(0, true) === GLB_MAGIC;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse GLB binary format
|
||||
*/
|
||||
private parseGLB(data: ArrayBuffer): { json: GLTFJson; binary: ArrayBuffer | null } {
|
||||
const view = new DataView(data);
|
||||
|
||||
// Header
|
||||
const magic = view.getUint32(0, true);
|
||||
const version = view.getUint32(4, true);
|
||||
const length = view.getUint32(8, true);
|
||||
|
||||
if (magic !== GLB_MAGIC) {
|
||||
throw new Error('Invalid GLB magic number');
|
||||
}
|
||||
if (version !== GLB_VERSION) {
|
||||
throw new Error(`Unsupported GLB version: ${version}`);
|
||||
}
|
||||
if (length !== data.byteLength) {
|
||||
throw new Error('GLB length mismatch');
|
||||
}
|
||||
|
||||
let json: GLTFJson | null = null;
|
||||
let binary: ArrayBuffer | null = null;
|
||||
let offset = 12;
|
||||
|
||||
// Parse chunks
|
||||
while (offset < length) {
|
||||
const chunkLength = view.getUint32(offset, true);
|
||||
const chunkType = view.getUint32(offset + 4, true);
|
||||
const chunkData = data.slice(offset + 8, offset + 8 + chunkLength);
|
||||
|
||||
if (chunkType === GLB_CHUNK_TYPE_JSON) {
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
json = JSON.parse(decoder.decode(chunkData)) as GLTFJson;
|
||||
} else if (chunkType === GLB_CHUNK_TYPE_BIN) {
|
||||
binary = chunkData;
|
||||
}
|
||||
|
||||
offset += 8 + chunkLength;
|
||||
}
|
||||
|
||||
if (!json) {
|
||||
throw new Error('GLB missing JSON chunk');
|
||||
}
|
||||
|
||||
return { json, binary };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load buffer data
|
||||
*/
|
||||
private async loadBuffers(
|
||||
json: GLTFJson,
|
||||
binaryChunk: ArrayBuffer | null,
|
||||
_context: IAssetParseContext
|
||||
): Promise<ArrayBuffer[]> {
|
||||
const buffers: ArrayBuffer[] = [];
|
||||
|
||||
if (!json.buffers) return buffers;
|
||||
|
||||
for (let i = 0; i < json.buffers.length; i++) {
|
||||
const bufferDef = json.buffers[i];
|
||||
|
||||
if (!bufferDef.uri) {
|
||||
// GLB embedded binary chunk
|
||||
if (binaryChunk && i === 0) {
|
||||
buffers.push(binaryChunk);
|
||||
} else {
|
||||
throw new Error(`Buffer ${i} has no URI and no binary chunk available`);
|
||||
}
|
||||
} else if (bufferDef.uri.startsWith('data:')) {
|
||||
// Data URI
|
||||
buffers.push(this.decodeDataUri(bufferDef.uri));
|
||||
} else {
|
||||
// External file - not supported yet, would need asset loader context
|
||||
throw new Error(`External buffer URIs not supported yet: ${bufferDef.uri}`);
|
||||
}
|
||||
}
|
||||
|
||||
return buffers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode base64 data URI
|
||||
*/
|
||||
private decodeDataUri(uri: string): ArrayBuffer {
|
||||
const match = uri.match(/^data:[^;]*;base64,(.*)$/);
|
||||
if (!match) {
|
||||
throw new Error('Invalid data URI format');
|
||||
}
|
||||
|
||||
const base64 = match[1];
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accessor data as typed array
|
||||
*/
|
||||
private getAccessorData(
|
||||
json: GLTFJson,
|
||||
buffers: ArrayBuffer[],
|
||||
accessorIndex: number
|
||||
): { data: ArrayBufferView; count: number; componentCount: number } {
|
||||
const accessor = json.accessors![accessorIndex];
|
||||
const bufferView = json.bufferViews![accessor.bufferView!];
|
||||
const buffer = buffers[bufferView.buffer];
|
||||
|
||||
const byteOffset = (bufferView.byteOffset || 0) + (accessor.byteOffset || 0);
|
||||
const componentCount = this.getComponentCount(accessor.type);
|
||||
const elementCount = accessor.count * componentCount;
|
||||
|
||||
let data: ArrayBufferView;
|
||||
|
||||
switch (accessor.componentType) {
|
||||
case COMPONENT_TYPE_BYTE:
|
||||
data = new Int8Array(buffer, byteOffset, elementCount);
|
||||
break;
|
||||
case COMPONENT_TYPE_UNSIGNED_BYTE:
|
||||
data = new Uint8Array(buffer, byteOffset, elementCount);
|
||||
break;
|
||||
case COMPONENT_TYPE_SHORT:
|
||||
data = new Int16Array(buffer, byteOffset, elementCount);
|
||||
break;
|
||||
case COMPONENT_TYPE_UNSIGNED_SHORT:
|
||||
data = new Uint16Array(buffer, byteOffset, elementCount);
|
||||
break;
|
||||
case COMPONENT_TYPE_UNSIGNED_INT:
|
||||
data = new Uint32Array(buffer, byteOffset, elementCount);
|
||||
break;
|
||||
case COMPONENT_TYPE_FLOAT:
|
||||
data = new Float32Array(buffer, byteOffset, elementCount);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported component type: ${accessor.componentType}`);
|
||||
}
|
||||
|
||||
return { data, count: accessor.count, componentCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get component count from accessor type
|
||||
*/
|
||||
private getComponentCount(type: string): number {
|
||||
switch (type) {
|
||||
case 'SCALAR': return 1;
|
||||
case 'VEC2': return 2;
|
||||
case 'VEC3': return 3;
|
||||
case 'VEC4': return 4;
|
||||
case 'MAT2': return 4;
|
||||
case 'MAT3': return 9;
|
||||
case 'MAT4': return 16;
|
||||
default:
|
||||
throw new Error(`Unknown accessor type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all meshes
|
||||
*/
|
||||
private parseMeshes(json: GLTFJson, buffers: ArrayBuffer[]): IMeshData[] {
|
||||
const meshes: IMeshData[] = [];
|
||||
|
||||
if (!json.meshes) return meshes;
|
||||
|
||||
for (const meshDef of json.meshes) {
|
||||
for (const primitive of meshDef.primitives) {
|
||||
// Only support triangles (mode 4 or undefined)
|
||||
if (primitive.mode !== undefined && primitive.mode !== 4) {
|
||||
console.warn('Skipping non-triangle primitive');
|
||||
continue;
|
||||
}
|
||||
|
||||
const mesh = this.parsePrimitive(json, buffers, primitive, meshDef.name || 'Mesh');
|
||||
meshes.push(mesh);
|
||||
}
|
||||
}
|
||||
|
||||
return meshes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single primitive
|
||||
*/
|
||||
private parsePrimitive(
|
||||
json: GLTFJson,
|
||||
buffers: ArrayBuffer[],
|
||||
primitive: GLTFPrimitive,
|
||||
name: string
|
||||
): IMeshData {
|
||||
// Position (required)
|
||||
const positionAccessor = primitive.attributes['POSITION'];
|
||||
if (positionAccessor === undefined) {
|
||||
throw new Error('Mesh primitive missing POSITION attribute');
|
||||
}
|
||||
const positionData = this.getAccessorData(json, buffers, positionAccessor);
|
||||
const vertices = new Float32Array(positionData.data.buffer, (positionData.data as Float32Array).byteOffset, positionData.count * 3);
|
||||
|
||||
// Indices (optional, generate sequential if missing)
|
||||
let indices: Uint16Array | Uint32Array;
|
||||
if (primitive.indices !== undefined) {
|
||||
const indexData = this.getAccessorData(json, buffers, primitive.indices);
|
||||
if (indexData.data instanceof Uint32Array) {
|
||||
indices = indexData.data;
|
||||
} else if (indexData.data instanceof Uint16Array) {
|
||||
indices = indexData.data;
|
||||
} else {
|
||||
// Convert to Uint32Array
|
||||
indices = new Uint32Array(indexData.count);
|
||||
for (let i = 0; i < indexData.count; i++) {
|
||||
indices[i] = (indexData.data as Uint8Array)[i];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Generate sequential indices
|
||||
indices = new Uint32Array(positionData.count);
|
||||
for (let i = 0; i < positionData.count; i++) {
|
||||
indices[i] = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Normals (optional)
|
||||
let normals: Float32Array | undefined;
|
||||
const normalAccessor = primitive.attributes['NORMAL'];
|
||||
if (normalAccessor !== undefined) {
|
||||
const normalData = this.getAccessorData(json, buffers, normalAccessor);
|
||||
normals = new Float32Array(normalData.data.buffer, (normalData.data as Float32Array).byteOffset, normalData.count * 3);
|
||||
}
|
||||
|
||||
// UVs (optional, TEXCOORD_0)
|
||||
let uvs: Float32Array | undefined;
|
||||
const uvAccessor = primitive.attributes['TEXCOORD_0'];
|
||||
if (uvAccessor !== undefined) {
|
||||
const uvData = this.getAccessorData(json, buffers, uvAccessor);
|
||||
uvs = new Float32Array(uvData.data.buffer, (uvData.data as Float32Array).byteOffset, uvData.count * 2);
|
||||
}
|
||||
|
||||
// Vertex colors (optional, COLOR_0)
|
||||
let colors: Float32Array | undefined;
|
||||
const colorAccessor = primitive.attributes['COLOR_0'];
|
||||
if (colorAccessor !== undefined) {
|
||||
const colorData = this.getAccessorData(json, buffers, colorAccessor);
|
||||
// Normalize if needed
|
||||
if (colorData.data instanceof Float32Array) {
|
||||
colors = colorData.data;
|
||||
} else {
|
||||
// Convert from normalized bytes
|
||||
colors = new Float32Array(colorData.count * colorData.componentCount);
|
||||
const source = colorData.data as Uint8Array;
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
colors[i] = source[i] / 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tangents (optional)
|
||||
let tangents: Float32Array | undefined;
|
||||
const tangentAccessor = primitive.attributes['TANGENT'];
|
||||
if (tangentAccessor !== undefined) {
|
||||
const tangentData = this.getAccessorData(json, buffers, tangentAccessor);
|
||||
tangents = new Float32Array(tangentData.data.buffer, (tangentData.data as Float32Array).byteOffset, tangentData.count * 4);
|
||||
}
|
||||
|
||||
// Skinning: JOINTS_0 (bone indices per vertex)
|
||||
// 蒙皮:JOINTS_0(每顶点的骨骼索引)
|
||||
let joints: Uint8Array | Uint16Array | undefined;
|
||||
const jointsAccessor = primitive.attributes['JOINTS_0'];
|
||||
if (jointsAccessor !== undefined) {
|
||||
const jointsData = this.getAccessorData(json, buffers, jointsAccessor);
|
||||
if (jointsData.data instanceof Uint8Array) {
|
||||
joints = new Uint8Array(jointsData.data.buffer, jointsData.data.byteOffset, jointsData.count * 4);
|
||||
} else if (jointsData.data instanceof Uint16Array) {
|
||||
joints = new Uint16Array(jointsData.data.buffer, jointsData.data.byteOffset, jointsData.count * 4);
|
||||
}
|
||||
}
|
||||
|
||||
// Skinning: WEIGHTS_0 (bone weights per vertex)
|
||||
// 蒙皮:WEIGHTS_0(每顶点的骨骼权重)
|
||||
let weights: Float32Array | undefined;
|
||||
const weightsAccessor = primitive.attributes['WEIGHTS_0'];
|
||||
if (weightsAccessor !== undefined) {
|
||||
const weightsData = this.getAccessorData(json, buffers, weightsAccessor);
|
||||
if (weightsData.data instanceof Float32Array) {
|
||||
weights = new Float32Array(weightsData.data.buffer, weightsData.data.byteOffset, weightsData.count * 4);
|
||||
} else if (weightsData.data instanceof Uint8Array) {
|
||||
// Convert from normalized Uint8 to floats
|
||||
weights = new Float32Array(weightsData.count * 4);
|
||||
const source = weightsData.data;
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
weights[i] = source[i] / 255;
|
||||
}
|
||||
} else if (weightsData.data instanceof Uint16Array) {
|
||||
// Convert from normalized Uint16 to floats
|
||||
weights = new Float32Array(weightsData.count * 4);
|
||||
const source = weightsData.data;
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
weights[i] = source[i] / 65535;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate bounds
|
||||
const bounds = this.calculateMeshBounds(vertices);
|
||||
|
||||
return {
|
||||
name,
|
||||
vertices,
|
||||
indices,
|
||||
normals,
|
||||
uvs,
|
||||
tangents,
|
||||
colors,
|
||||
joints,
|
||||
weights,
|
||||
bounds,
|
||||
materialIndex: primitive.material ?? -1
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate mesh bounding box
|
||||
*/
|
||||
private calculateMeshBounds(vertices: Float32Array): IBoundingBox {
|
||||
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
||||
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
||||
|
||||
for (let i = 0; i < vertices.length; i += 3) {
|
||||
const x = vertices[i];
|
||||
const y = vertices[i + 1];
|
||||
const z = vertices[i + 2];
|
||||
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
minZ = Math.min(minZ, z);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
maxZ = Math.max(maxZ, z);
|
||||
}
|
||||
|
||||
return {
|
||||
min: [minX, minY, minZ],
|
||||
max: [maxX, maxY, maxZ]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all materials
|
||||
*/
|
||||
private parseMaterials(json: GLTFJson): IGLTFMaterial[] {
|
||||
const materials: IGLTFMaterial[] = [];
|
||||
|
||||
if (!json.materials) {
|
||||
// Add default material
|
||||
materials.push(this.createDefaultMaterial());
|
||||
return materials;
|
||||
}
|
||||
|
||||
for (const matDef of json.materials) {
|
||||
const pbr = matDef.pbrMetallicRoughness || {};
|
||||
|
||||
materials.push({
|
||||
name: matDef.name || 'Material',
|
||||
baseColorFactor: pbr.baseColorFactor || [1, 1, 1, 1],
|
||||
baseColorTextureIndex: pbr.baseColorTexture?.index ?? -1,
|
||||
metallicFactor: pbr.metallicFactor ?? 1,
|
||||
roughnessFactor: pbr.roughnessFactor ?? 1,
|
||||
metallicRoughnessTextureIndex: pbr.metallicRoughnessTexture?.index ?? -1,
|
||||
normalTextureIndex: matDef.normalTexture?.index ?? -1,
|
||||
normalScale: matDef.normalTexture?.scale ?? 1,
|
||||
occlusionTextureIndex: matDef.occlusionTexture?.index ?? -1,
|
||||
occlusionStrength: matDef.occlusionTexture?.strength ?? 1,
|
||||
emissiveFactor: matDef.emissiveFactor || [0, 0, 0],
|
||||
emissiveTextureIndex: matDef.emissiveTexture?.index ?? -1,
|
||||
alphaMode: matDef.alphaMode || 'OPAQUE',
|
||||
alphaCutoff: matDef.alphaCutoff ?? 0.5,
|
||||
doubleSided: matDef.doubleSided ?? false
|
||||
});
|
||||
}
|
||||
|
||||
return materials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default material
|
||||
*/
|
||||
private createDefaultMaterial(): IGLTFMaterial {
|
||||
return {
|
||||
name: 'Default',
|
||||
baseColorFactor: [0.8, 0.8, 0.8, 1],
|
||||
baseColorTextureIndex: -1,
|
||||
metallicFactor: 0,
|
||||
roughnessFactor: 0.5,
|
||||
metallicRoughnessTextureIndex: -1,
|
||||
normalTextureIndex: -1,
|
||||
normalScale: 1,
|
||||
occlusionTextureIndex: -1,
|
||||
occlusionStrength: 1,
|
||||
emissiveFactor: [0, 0, 0],
|
||||
emissiveTextureIndex: -1,
|
||||
alphaMode: 'OPAQUE',
|
||||
alphaCutoff: 0.5,
|
||||
doubleSided: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse textures
|
||||
*/
|
||||
private async parseTextures(
|
||||
json: GLTFJson,
|
||||
buffers: ArrayBuffer[],
|
||||
_context: IAssetParseContext
|
||||
): Promise<IGLTFTextureInfo[]> {
|
||||
const textures: IGLTFTextureInfo[] = [];
|
||||
|
||||
if (!json.textures || !json.images) return textures;
|
||||
|
||||
for (const texDef of json.textures) {
|
||||
if (texDef.source === undefined) {
|
||||
textures.push({});
|
||||
continue;
|
||||
}
|
||||
|
||||
const imageDef = json.images[texDef.source];
|
||||
const textureInfo: IGLTFTextureInfo = {
|
||||
name: imageDef.name || texDef.name
|
||||
};
|
||||
|
||||
if (imageDef.bufferView !== undefined) {
|
||||
// Embedded image
|
||||
const bufferView = json.bufferViews![imageDef.bufferView];
|
||||
const buffer = buffers[bufferView.buffer];
|
||||
const byteOffset = bufferView.byteOffset || 0;
|
||||
textureInfo.imageData = buffer.slice(byteOffset, byteOffset + bufferView.byteLength);
|
||||
textureInfo.mimeType = imageDef.mimeType;
|
||||
} else if (imageDef.uri) {
|
||||
if (imageDef.uri.startsWith('data:')) {
|
||||
// Data URI
|
||||
textureInfo.imageData = this.decodeDataUri(imageDef.uri);
|
||||
const mimeMatch = imageDef.uri.match(/^data:(.*?);/);
|
||||
textureInfo.mimeType = mimeMatch?.[1];
|
||||
} else {
|
||||
// External URI
|
||||
textureInfo.uri = imageDef.uri;
|
||||
}
|
||||
}
|
||||
|
||||
textures.push(textureInfo);
|
||||
}
|
||||
|
||||
return textures;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse scene nodes
|
||||
*/
|
||||
private parseNodes(json: GLTFJson): IGLTFNode[] {
|
||||
const nodes: IGLTFNode[] = [];
|
||||
|
||||
if (!json.nodes) return nodes;
|
||||
|
||||
for (const nodeDef of json.nodes) {
|
||||
let position: [number, number, number] = [0, 0, 0];
|
||||
let rotation: [number, number, number, number] = [0, 0, 0, 1];
|
||||
let scale: [number, number, number] = [1, 1, 1];
|
||||
|
||||
if (nodeDef.matrix) {
|
||||
// Decompose matrix
|
||||
const m = nodeDef.matrix;
|
||||
// Extract translation
|
||||
position = [m[12], m[13], m[14]];
|
||||
// Extract scale
|
||||
scale = [
|
||||
Math.sqrt(m[0] * m[0] + m[1] * m[1] + m[2] * m[2]),
|
||||
Math.sqrt(m[4] * m[4] + m[5] * m[5] + m[6] * m[6]),
|
||||
Math.sqrt(m[8] * m[8] + m[9] * m[9] + m[10] * m[10])
|
||||
];
|
||||
// Extract rotation (simplified, assumes no shear)
|
||||
rotation = this.matrixToQuaternion(m, scale);
|
||||
} else {
|
||||
if (nodeDef.translation) {
|
||||
position = nodeDef.translation;
|
||||
}
|
||||
if (nodeDef.rotation) {
|
||||
rotation = nodeDef.rotation;
|
||||
}
|
||||
if (nodeDef.scale) {
|
||||
scale = nodeDef.scale;
|
||||
}
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
name: nodeDef.name || 'Node',
|
||||
meshIndex: nodeDef.mesh,
|
||||
children: nodeDef.children || [],
|
||||
transform: { position, rotation, scale }
|
||||
});
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract quaternion from matrix
|
||||
*/
|
||||
private matrixToQuaternion(m: number[], scale: [number, number, number]): [number, number, number, number] {
|
||||
// Normalize rotation matrix
|
||||
const sx = scale[0], sy = scale[1], sz = scale[2];
|
||||
const m00 = m[0] / sx, m01 = m[4] / sy, m02 = m[8] / sz;
|
||||
const m10 = m[1] / sx, m11 = m[5] / sy, m12 = m[9] / sz;
|
||||
const m20 = m[2] / sx, m21 = m[6] / sy, m22 = m[10] / sz;
|
||||
|
||||
const trace = m00 + m11 + m22;
|
||||
let x: number, y: number, z: number, w: number;
|
||||
|
||||
if (trace > 0) {
|
||||
const s = 0.5 / Math.sqrt(trace + 1.0);
|
||||
w = 0.25 / s;
|
||||
x = (m21 - m12) * s;
|
||||
y = (m02 - m20) * s;
|
||||
z = (m10 - m01) * s;
|
||||
} else if (m00 > m11 && m00 > m22) {
|
||||
const s = 2.0 * Math.sqrt(1.0 + m00 - m11 - m22);
|
||||
w = (m21 - m12) / s;
|
||||
x = 0.25 * s;
|
||||
y = (m01 + m10) / s;
|
||||
z = (m02 + m20) / s;
|
||||
} else if (m11 > m22) {
|
||||
const s = 2.0 * Math.sqrt(1.0 + m11 - m00 - m22);
|
||||
w = (m02 - m20) / s;
|
||||
x = (m01 + m10) / s;
|
||||
y = 0.25 * s;
|
||||
z = (m12 + m21) / s;
|
||||
} else {
|
||||
const s = 2.0 * Math.sqrt(1.0 + m22 - m00 - m11);
|
||||
w = (m10 - m01) / s;
|
||||
x = (m02 + m20) / s;
|
||||
y = (m12 + m21) / s;
|
||||
z = 0.25 * s;
|
||||
}
|
||||
|
||||
return [x, y, z, w];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get root node indices
|
||||
*/
|
||||
private getRootNodes(json: GLTFJson): number[] {
|
||||
const sceneIndex = json.scene ?? 0;
|
||||
const scene = json.scenes?.[sceneIndex];
|
||||
return scene?.nodes || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse animations
|
||||
*/
|
||||
private parseAnimations(json: GLTFJson, buffers: ArrayBuffer[]): IGLTFAnimationClip[] {
|
||||
const animations: IGLTFAnimationClip[] = [];
|
||||
|
||||
if (!json.animations) return animations;
|
||||
|
||||
for (const animDef of json.animations) {
|
||||
const samplers: IAnimationSampler[] = [];
|
||||
const channels: IAnimationChannel[] = [];
|
||||
let duration = 0;
|
||||
|
||||
// Parse samplers
|
||||
for (const samplerDef of animDef.samplers) {
|
||||
const inputData = this.getAccessorData(json, buffers, samplerDef.input);
|
||||
const outputData = this.getAccessorData(json, buffers, samplerDef.output);
|
||||
|
||||
const input = new Float32Array(inputData.data.buffer, (inputData.data as Float32Array).byteOffset, inputData.count);
|
||||
const output = new Float32Array(outputData.data.buffer, (outputData.data as Float32Array).byteOffset, outputData.count * outputData.componentCount);
|
||||
|
||||
// Update duration
|
||||
if (input.length > 0) {
|
||||
duration = Math.max(duration, input[input.length - 1]);
|
||||
}
|
||||
|
||||
samplers.push({
|
||||
input,
|
||||
output,
|
||||
interpolation: samplerDef.interpolation || 'LINEAR'
|
||||
});
|
||||
}
|
||||
|
||||
// Parse channels
|
||||
for (const channelDef of animDef.channels) {
|
||||
if (channelDef.target.node === undefined) continue;
|
||||
|
||||
channels.push({
|
||||
samplerIndex: channelDef.sampler,
|
||||
target: {
|
||||
nodeIndex: channelDef.target.node,
|
||||
path: channelDef.target.path
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
animations.push({
|
||||
name: animDef.name || 'Animation',
|
||||
duration,
|
||||
samplers,
|
||||
channels
|
||||
});
|
||||
}
|
||||
|
||||
return animations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse skeleton/skin data
|
||||
*/
|
||||
private parseSkeleton(json: GLTFJson, buffers: ArrayBuffer[]): ISkeletonData | undefined {
|
||||
if (!json.skins || json.skins.length === 0) return undefined;
|
||||
|
||||
// Use first skin
|
||||
const skin = json.skins[0];
|
||||
const joints: ISkeletonJoint[] = [];
|
||||
|
||||
// Load inverse bind matrices
|
||||
let inverseBindMatrices: Float32Array | null = null;
|
||||
if (skin.inverseBindMatrices !== undefined) {
|
||||
const ibmData = this.getAccessorData(json, buffers, skin.inverseBindMatrices);
|
||||
inverseBindMatrices = new Float32Array(ibmData.data.buffer, (ibmData.data as Float32Array).byteOffset, ibmData.count * 16);
|
||||
}
|
||||
|
||||
// Build joint hierarchy
|
||||
const jointIndexMap = new Map<number, number>();
|
||||
for (let i = 0; i < skin.joints.length; i++) {
|
||||
jointIndexMap.set(skin.joints[i], i);
|
||||
}
|
||||
|
||||
for (let i = 0; i < skin.joints.length; i++) {
|
||||
const nodeIndex = skin.joints[i];
|
||||
const node = json.nodes![nodeIndex];
|
||||
|
||||
// Find parent
|
||||
let parentIndex = -1;
|
||||
for (const [idx, jointIdx] of jointIndexMap) {
|
||||
if (jointIdx !== i) {
|
||||
const parentNode = json.nodes![idx];
|
||||
if (parentNode.children?.includes(nodeIndex)) {
|
||||
parentIndex = jointIdx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ibm = new Float32Array(16);
|
||||
if (inverseBindMatrices) {
|
||||
for (let j = 0; j < 16; j++) {
|
||||
ibm[j] = inverseBindMatrices[i * 16 + j];
|
||||
}
|
||||
} else {
|
||||
// Identity matrix
|
||||
ibm[0] = ibm[5] = ibm[10] = ibm[15] = 1;
|
||||
}
|
||||
|
||||
joints.push({
|
||||
name: node.name || `Joint_${i}`,
|
||||
nodeIndex,
|
||||
parentIndex,
|
||||
inverseBindMatrix: ibm
|
||||
});
|
||||
}
|
||||
|
||||
// Find root joint
|
||||
let rootJointIndex = 0;
|
||||
for (let i = 0; i < joints.length; i++) {
|
||||
if (joints[i].parentIndex === -1) {
|
||||
rootJointIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
joints,
|
||||
rootJointIndex
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate combined bounds for all meshes
|
||||
*/
|
||||
private calculateBounds(meshes: IMeshData[]): IBoundingBox {
|
||||
if (meshes.length === 0) {
|
||||
return { min: [0, 0, 0], max: [0, 0, 0] };
|
||||
}
|
||||
|
||||
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
||||
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
||||
|
||||
for (const mesh of meshes) {
|
||||
minX = Math.min(minX, mesh.bounds.min[0]);
|
||||
minY = Math.min(minY, mesh.bounds.min[1]);
|
||||
minZ = Math.min(minZ, mesh.bounds.min[2]);
|
||||
maxX = Math.max(maxX, mesh.bounds.max[0]);
|
||||
maxY = Math.max(maxY, mesh.bounds.max[1]);
|
||||
maxZ = Math.max(maxZ, mesh.bounds.max[2]);
|
||||
}
|
||||
|
||||
return {
|
||||
min: [minX, minY, minZ],
|
||||
max: [maxX, maxY, maxZ]
|
||||
};
|
||||
}
|
||||
}
|
||||
41
packages/engine/asset-system/src/loaders/JsonLoader.ts
Normal file
41
packages/engine/asset-system/src/loaders/JsonLoader.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
553
packages/engine/asset-system/src/loaders/OBJLoader.ts
Normal file
553
packages/engine/asset-system/src/loaders/OBJLoader.ts
Normal file
@@ -0,0 +1,553 @@
|
||||
/**
|
||||
* OBJ model loader implementation
|
||||
* OBJ 模型加载器实现
|
||||
*
|
||||
* Supports:
|
||||
* - Wavefront OBJ format (.obj)
|
||||
* - Vertices, normals, texture coordinates
|
||||
* - Triangular and quad faces (quads are triangulated)
|
||||
* - Multiple objects/groups
|
||||
* - MTL material references (materials loaded separately)
|
||||
*/
|
||||
|
||||
import { AssetType } from '../types/AssetTypes';
|
||||
import type { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
|
||||
import type {
|
||||
IAssetLoader,
|
||||
IAssetParseContext,
|
||||
IGLTFAsset,
|
||||
IMeshData,
|
||||
IGLTFMaterial,
|
||||
IGLTFNode,
|
||||
IBoundingBox
|
||||
} from '../interfaces/IAssetLoader';
|
||||
|
||||
/**
|
||||
* Parsed OBJ data structure
|
||||
* 解析后的 OBJ 数据结构
|
||||
*/
|
||||
interface OBJParseResult {
|
||||
positions: number[];
|
||||
normals: number[];
|
||||
uvs: number[];
|
||||
objects: OBJObject[];
|
||||
mtlLib?: string;
|
||||
}
|
||||
|
||||
interface OBJObject {
|
||||
name: string;
|
||||
material?: string;
|
||||
faces: OBJFace[];
|
||||
}
|
||||
|
||||
interface OBJFace {
|
||||
vertices: OBJVertex[];
|
||||
}
|
||||
|
||||
interface OBJVertex {
|
||||
positionIndex: number;
|
||||
uvIndex?: number;
|
||||
normalIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* OBJ model loader
|
||||
* OBJ 模型加载器
|
||||
*/
|
||||
export class OBJLoader implements IAssetLoader<IGLTFAsset> {
|
||||
readonly supportedType = AssetType.Model3D;
|
||||
readonly supportedExtensions = ['.obj'];
|
||||
readonly contentType: AssetContentType = 'text';
|
||||
|
||||
/**
|
||||
* Parse OBJ content
|
||||
* 解析 OBJ 内容
|
||||
*/
|
||||
async parse(content: IAssetContent, context: IAssetParseContext): Promise<IGLTFAsset> {
|
||||
const text = content.text;
|
||||
if (!text) {
|
||||
throw new Error('OBJ loader requires text content');
|
||||
}
|
||||
|
||||
// Parse OBJ text
|
||||
// 解析 OBJ 文本
|
||||
const objData = this.parseOBJ(text);
|
||||
|
||||
// Convert to meshes
|
||||
// 转换为网格
|
||||
const meshes = this.buildMeshes(objData);
|
||||
|
||||
// Create default materials
|
||||
// 创建默认材质
|
||||
const materials = this.buildMaterials(objData);
|
||||
|
||||
// Build nodes (one per object)
|
||||
// 构建节点(每个对象一个)
|
||||
const nodes: IGLTFNode[] = meshes.map((mesh, index) => ({
|
||||
name: mesh.name,
|
||||
meshIndex: index,
|
||||
children: [],
|
||||
transform: {
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1]
|
||||
}
|
||||
}));
|
||||
|
||||
// Calculate overall bounds
|
||||
// 计算总边界
|
||||
const bounds = this.calculateBounds(meshes);
|
||||
|
||||
// Get model name from file path
|
||||
// 从文件路径获取模型名称
|
||||
const pathParts = context.metadata.path.split(/[\\/]/);
|
||||
const fileName = pathParts[pathParts.length - 1];
|
||||
const name = fileName.replace(/\.obj$/i, '');
|
||||
|
||||
return {
|
||||
name,
|
||||
meshes,
|
||||
materials,
|
||||
textures: [],
|
||||
nodes,
|
||||
rootNodes: nodes.map((_, i) => i),
|
||||
bounds,
|
||||
sourcePath: context.metadata.path
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose OBJ asset
|
||||
* 释放 OBJ 资产
|
||||
*/
|
||||
dispose(asset: IGLTFAsset): void {
|
||||
for (const mesh of asset.meshes) {
|
||||
(mesh as { vertices: Float32Array | null }).vertices = null!;
|
||||
(mesh as { indices: Uint16Array | Uint32Array | null }).indices = null!;
|
||||
}
|
||||
asset.meshes.length = 0;
|
||||
}
|
||||
|
||||
// ===== Private Methods =====
|
||||
|
||||
/**
|
||||
* Parse OBJ text format
|
||||
* 解析 OBJ 文本格式
|
||||
*/
|
||||
private parseOBJ(text: string): OBJParseResult {
|
||||
const lines = text.split('\n');
|
||||
|
||||
const positions: number[] = [];
|
||||
const normals: number[] = [];
|
||||
const uvs: number[] = [];
|
||||
const objects: OBJObject[] = [];
|
||||
|
||||
let currentObject: OBJObject = { name: 'default', faces: [] };
|
||||
let mtlLib: string | undefined;
|
||||
|
||||
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
||||
const line = lines[lineNum].trim();
|
||||
|
||||
// Skip comments and empty lines
|
||||
// 跳过注释和空行
|
||||
if (line.length === 0 || line.startsWith('#')) continue;
|
||||
|
||||
const parts = line.split(/\s+/);
|
||||
const keyword = parts[0];
|
||||
|
||||
switch (keyword) {
|
||||
case 'v': // Vertex position
|
||||
positions.push(
|
||||
parseFloat(parts[1]) || 0,
|
||||
parseFloat(parts[2]) || 0,
|
||||
parseFloat(parts[3]) || 0
|
||||
);
|
||||
break;
|
||||
|
||||
case 'vn': // Vertex normal
|
||||
normals.push(
|
||||
parseFloat(parts[1]) || 0,
|
||||
parseFloat(parts[2]) || 0,
|
||||
parseFloat(parts[3]) || 0
|
||||
);
|
||||
break;
|
||||
|
||||
case 'vt': // Texture coordinate
|
||||
uvs.push(
|
||||
parseFloat(parts[1]) || 0,
|
||||
parseFloat(parts[2]) || 0
|
||||
);
|
||||
break;
|
||||
|
||||
case 'f': // Face
|
||||
const face = this.parseFace(parts.slice(1));
|
||||
if (face.vertices.length >= 3) {
|
||||
// Triangulate if more than 3 vertices (fan triangulation)
|
||||
// 如果超过 3 个顶点则三角化(扇形三角化)
|
||||
for (let i = 1; i < face.vertices.length - 1; i++) {
|
||||
currentObject.faces.push({
|
||||
vertices: [
|
||||
face.vertices[0],
|
||||
face.vertices[i],
|
||||
face.vertices[i + 1]
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'o': // Object name
|
||||
case 'g': // Group name
|
||||
if (currentObject.faces.length > 0) {
|
||||
objects.push(currentObject);
|
||||
}
|
||||
currentObject = {
|
||||
name: parts.slice(1).join(' ') || 'unnamed',
|
||||
faces: []
|
||||
};
|
||||
break;
|
||||
|
||||
case 'usemtl': // Material reference
|
||||
// If current object has faces with different material, split it
|
||||
// 如果当前对象有不同材质的面,则拆分
|
||||
if (currentObject.faces.length > 0 && currentObject.material) {
|
||||
objects.push(currentObject);
|
||||
currentObject = {
|
||||
name: `${currentObject.name}_${parts[1]}`,
|
||||
faces: [],
|
||||
material: parts[1]
|
||||
};
|
||||
} else {
|
||||
currentObject.material = parts[1];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'mtllib': // MTL library reference
|
||||
mtlLib = parts[1];
|
||||
break;
|
||||
|
||||
case 's': // Smoothing group (ignored)
|
||||
case 'l': // Line (ignored)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Push last object
|
||||
// 推送最后一个对象
|
||||
if (currentObject.faces.length > 0) {
|
||||
objects.push(currentObject);
|
||||
}
|
||||
|
||||
// If no objects were created, create one from default
|
||||
// 如果没有创建对象,从默认创建一个
|
||||
if (objects.length === 0 && currentObject.faces.length === 0) {
|
||||
throw new Error('OBJ file contains no geometry');
|
||||
}
|
||||
|
||||
return { positions, normals, uvs, objects, mtlLib };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a face definition
|
||||
* 解析面定义
|
||||
*
|
||||
* Format: v, v/vt, v/vt/vn, v//vn
|
||||
*/
|
||||
private parseFace(parts: string[]): OBJFace {
|
||||
const vertices: OBJVertex[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
const indices = part.split('/');
|
||||
const vertex: OBJVertex = {
|
||||
positionIndex: parseInt(indices[0], 10) - 1 // OBJ is 1-indexed
|
||||
};
|
||||
|
||||
if (indices.length > 1 && indices[1]) {
|
||||
vertex.uvIndex = parseInt(indices[1], 10) - 1;
|
||||
}
|
||||
|
||||
if (indices.length > 2 && indices[2]) {
|
||||
vertex.normalIndex = parseInt(indices[2], 10) - 1;
|
||||
}
|
||||
|
||||
vertices.push(vertex);
|
||||
}
|
||||
|
||||
return { vertices };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build mesh data from parsed OBJ
|
||||
* 从解析的 OBJ 构建网格数据
|
||||
*/
|
||||
private buildMeshes(objData: OBJParseResult): IMeshData[] {
|
||||
const meshes: IMeshData[] = [];
|
||||
|
||||
for (const obj of objData.objects) {
|
||||
const mesh = this.buildMesh(obj, objData);
|
||||
meshes.push(mesh);
|
||||
}
|
||||
|
||||
return meshes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single mesh from OBJ object
|
||||
* 从 OBJ 对象构建单个网格
|
||||
*/
|
||||
private buildMesh(obj: OBJObject, objData: OBJParseResult): IMeshData {
|
||||
// OBJ uses indexed vertices, but indices can reference different
|
||||
// position/uv/normal combinations, so we need to expand
|
||||
// OBJ 使用索引顶点,但索引可以引用不同的 position/uv/normal 组合,所以需要展开
|
||||
|
||||
const positions: number[] = [];
|
||||
const normals: number[] = [];
|
||||
const uvs: number[] = [];
|
||||
const indices: number[] = [];
|
||||
|
||||
// Map to track unique vertex combinations
|
||||
// 用于跟踪唯一顶点组合的映射
|
||||
const vertexMap = new Map<string, number>();
|
||||
let vertexIndex = 0;
|
||||
|
||||
for (const face of obj.faces) {
|
||||
const faceIndices: number[] = [];
|
||||
|
||||
for (const vertex of face.vertices) {
|
||||
// Create unique key for this vertex combination
|
||||
// 为此顶点组合创建唯一键
|
||||
const key = `${vertex.positionIndex}/${vertex.uvIndex ?? ''}/${vertex.normalIndex ?? ''}`;
|
||||
|
||||
let index = vertexMap.get(key);
|
||||
if (index === undefined) {
|
||||
// New unique vertex - add to arrays
|
||||
// 新的唯一顶点 - 添加到数组
|
||||
index = vertexIndex++;
|
||||
vertexMap.set(key, index);
|
||||
|
||||
// Position
|
||||
const pi = vertex.positionIndex * 3;
|
||||
positions.push(
|
||||
objData.positions[pi] ?? 0,
|
||||
objData.positions[pi + 1] ?? 0,
|
||||
objData.positions[pi + 2] ?? 0
|
||||
);
|
||||
|
||||
// UV
|
||||
if (vertex.uvIndex !== undefined) {
|
||||
const ui = vertex.uvIndex * 2;
|
||||
uvs.push(
|
||||
objData.uvs[ui] ?? 0,
|
||||
1 - (objData.uvs[ui + 1] ?? 0) // Flip V coordinate
|
||||
);
|
||||
} else {
|
||||
uvs.push(0, 0);
|
||||
}
|
||||
|
||||
// Normal
|
||||
if (vertex.normalIndex !== undefined) {
|
||||
const ni = vertex.normalIndex * 3;
|
||||
normals.push(
|
||||
objData.normals[ni] ?? 0,
|
||||
objData.normals[ni + 1] ?? 0,
|
||||
objData.normals[ni + 2] ?? 0
|
||||
);
|
||||
} else {
|
||||
normals.push(0, 1, 0); // Default up normal
|
||||
}
|
||||
}
|
||||
|
||||
faceIndices.push(index);
|
||||
}
|
||||
|
||||
// Add triangle indices
|
||||
// 添加三角形索引
|
||||
if (faceIndices.length === 3) {
|
||||
indices.push(faceIndices[0], faceIndices[1], faceIndices[2]);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate bounds
|
||||
// 计算边界
|
||||
const bounds = this.calculateMeshBounds(positions);
|
||||
|
||||
// Generate normals if not provided
|
||||
// 如果未提供法线则生成
|
||||
const hasValidNormals = objData.normals.length > 0;
|
||||
const finalNormals = hasValidNormals
|
||||
? new Float32Array(normals)
|
||||
: this.generateNormals(positions, indices);
|
||||
|
||||
return {
|
||||
name: obj.name,
|
||||
vertices: new Float32Array(positions),
|
||||
indices: new Uint32Array(indices),
|
||||
normals: finalNormals,
|
||||
uvs: new Float32Array(uvs),
|
||||
bounds,
|
||||
materialIndex: -1 // Material resolved by name
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate flat normals for mesh
|
||||
* 为网格生成平面法线
|
||||
*/
|
||||
private generateNormals(positions: number[], indices: number[]): Float32Array {
|
||||
const normals = new Float32Array(positions.length);
|
||||
|
||||
for (let i = 0; i < indices.length; i += 3) {
|
||||
const i0 = indices[i] * 3;
|
||||
const i1 = indices[i + 1] * 3;
|
||||
const i2 = indices[i + 2] * 3;
|
||||
|
||||
// Get triangle vertices
|
||||
const v0x = positions[i0], v0y = positions[i0 + 1], v0z = positions[i0 + 2];
|
||||
const v1x = positions[i1], v1y = positions[i1 + 1], v1z = positions[i1 + 2];
|
||||
const v2x = positions[i2], v2y = positions[i2 + 1], v2z = positions[i2 + 2];
|
||||
|
||||
// Calculate edge vectors
|
||||
const e1x = v1x - v0x, e1y = v1y - v0y, e1z = v1z - v0z;
|
||||
const e2x = v2x - v0x, e2y = v2y - v0y, e2z = v2z - v0z;
|
||||
|
||||
// Cross product
|
||||
const nx = e1y * e2z - e1z * e2y;
|
||||
const ny = e1z * e2x - e1x * e2z;
|
||||
const nz = e1x * e2y - e1y * e2x;
|
||||
|
||||
// Add to vertex normals (will be normalized later or kept as-is for flat shading)
|
||||
normals[i0] += nx; normals[i0 + 1] += ny; normals[i0 + 2] += nz;
|
||||
normals[i1] += nx; normals[i1 + 1] += ny; normals[i1 + 2] += nz;
|
||||
normals[i2] += nx; normals[i2 + 1] += ny; normals[i2 + 2] += nz;
|
||||
}
|
||||
|
||||
// Normalize
|
||||
for (let i = 0; i < normals.length; i += 3) {
|
||||
const len = Math.sqrt(normals[i] ** 2 + normals[i + 1] ** 2 + normals[i + 2] ** 2);
|
||||
if (len > 0) {
|
||||
normals[i] /= len;
|
||||
normals[i + 1] /= len;
|
||||
normals[i + 2] /= len;
|
||||
}
|
||||
}
|
||||
|
||||
return normals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build default materials
|
||||
* 构建默认材质
|
||||
*/
|
||||
private buildMaterials(objData: OBJParseResult): IGLTFMaterial[] {
|
||||
// Create one default material per unique material name
|
||||
// 为每个唯一的材质名称创建一个默认材质
|
||||
const materialNames = new Set<string>();
|
||||
for (const obj of objData.objects) {
|
||||
if (obj.material) {
|
||||
materialNames.add(obj.material);
|
||||
}
|
||||
}
|
||||
|
||||
const materials: IGLTFMaterial[] = [];
|
||||
|
||||
// Default material
|
||||
materials.push({
|
||||
name: 'Default',
|
||||
baseColorFactor: [0.8, 0.8, 0.8, 1],
|
||||
baseColorTextureIndex: -1,
|
||||
metallicFactor: 0,
|
||||
roughnessFactor: 0.5,
|
||||
metallicRoughnessTextureIndex: -1,
|
||||
normalTextureIndex: -1,
|
||||
normalScale: 1,
|
||||
occlusionTextureIndex: -1,
|
||||
occlusionStrength: 1,
|
||||
emissiveFactor: [0, 0, 0],
|
||||
emissiveTextureIndex: -1,
|
||||
alphaMode: 'OPAQUE',
|
||||
alphaCutoff: 0.5,
|
||||
doubleSided: false
|
||||
});
|
||||
|
||||
// Named materials (with placeholder values)
|
||||
for (const name of materialNames) {
|
||||
materials.push({
|
||||
name,
|
||||
baseColorFactor: [0.8, 0.8, 0.8, 1],
|
||||
baseColorTextureIndex: -1,
|
||||
metallicFactor: 0,
|
||||
roughnessFactor: 0.5,
|
||||
metallicRoughnessTextureIndex: -1,
|
||||
normalTextureIndex: -1,
|
||||
normalScale: 1,
|
||||
occlusionTextureIndex: -1,
|
||||
occlusionStrength: 1,
|
||||
emissiveFactor: [0, 0, 0],
|
||||
emissiveTextureIndex: -1,
|
||||
alphaMode: 'OPAQUE',
|
||||
alphaCutoff: 0.5,
|
||||
doubleSided: false
|
||||
});
|
||||
}
|
||||
|
||||
return materials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate mesh bounding box
|
||||
* 计算网格边界盒
|
||||
*/
|
||||
private calculateMeshBounds(positions: number[]): IBoundingBox {
|
||||
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
||||
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
||||
|
||||
for (let i = 0; i < positions.length; i += 3) {
|
||||
const x = positions[i];
|
||||
const y = positions[i + 1];
|
||||
const z = positions[i + 2];
|
||||
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
minZ = Math.min(minZ, z);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
maxZ = Math.max(maxZ, z);
|
||||
}
|
||||
|
||||
if (!isFinite(minX)) {
|
||||
return { min: [0, 0, 0], max: [0, 0, 0] };
|
||||
}
|
||||
|
||||
return {
|
||||
min: [minX, minY, minZ],
|
||||
max: [maxX, maxY, maxZ]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate combined bounds for all meshes
|
||||
* 计算所有网格的组合边界
|
||||
*/
|
||||
private calculateBounds(meshes: IMeshData[]): IBoundingBox {
|
||||
if (meshes.length === 0) {
|
||||
return { min: [0, 0, 0], max: [0, 0, 0] };
|
||||
}
|
||||
|
||||
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
||||
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
||||
|
||||
for (const mesh of meshes) {
|
||||
minX = Math.min(minX, mesh.bounds.min[0]);
|
||||
minY = Math.min(minY, mesh.bounds.min[1]);
|
||||
minZ = Math.min(minZ, mesh.bounds.min[2]);
|
||||
maxX = Math.max(maxX, mesh.bounds.max[0]);
|
||||
maxY = Math.max(maxY, mesh.bounds.max[1]);
|
||||
maxZ = Math.max(maxZ, mesh.bounds.max[2]);
|
||||
}
|
||||
|
||||
return {
|
||||
min: [minX, minY, minZ],
|
||||
max: [maxX, maxY, maxZ]
|
||||
};
|
||||
}
|
||||
}
|
||||
156
packages/engine/asset-system/src/loaders/PrefabLoader.ts
Normal file
156
packages/engine/asset-system/src/loaders/PrefabLoader.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
56
packages/engine/asset-system/src/loaders/TextLoader.ts
Normal file
56
packages/engine/asset-system/src/loaders/TextLoader.ts
Normal 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 = '';
|
||||
}
|
||||
}
|
||||
117
packages/engine/asset-system/src/loaders/TextureLoader.ts
Normal file
117
packages/engine/asset-system/src/loaders/TextureLoader.ts
Normal 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 = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
275
packages/engine/asset-system/src/runtime/RuntimeCatalog.ts
Normal file
275
packages/engine/asset-system/src/runtime/RuntimeCatalog.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Runtime Catalog for Asset Resolution
|
||||
* 资产解析的运行时目录
|
||||
*
|
||||
* Provides GUID-based asset lookup at runtime.
|
||||
* 提供运行时基于 GUID 的资产查找。
|
||||
*/
|
||||
|
||||
import { AssetGUID, AssetType } from '../types/AssetTypes';
|
||||
import {
|
||||
IRuntimeCatalog,
|
||||
IRuntimeAssetLocation,
|
||||
IRuntimeBundleInfo
|
||||
} from '../bundle/BundleFormat';
|
||||
|
||||
/**
|
||||
* Runtime Catalog Manager
|
||||
* 运行时目录管理器
|
||||
*
|
||||
* Loads and manages the asset catalog for runtime GUID resolution.
|
||||
*/
|
||||
export class RuntimeCatalog {
|
||||
private _catalog: IRuntimeCatalog | null = null;
|
||||
private _loadedBundles = new Map<string, ArrayBuffer>();
|
||||
private _loadingBundles = new Map<string, Promise<ArrayBuffer>>();
|
||||
private _baseUrl: string = './';
|
||||
|
||||
/**
|
||||
* Set base URL for loading catalog and bundles
|
||||
* 设置加载目录和包的基础 URL
|
||||
*/
|
||||
setBaseUrl(url: string): void {
|
||||
this._baseUrl = url.endsWith('/') ? url : `${url}/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load catalog from URL
|
||||
* 从 URL 加载目录
|
||||
*/
|
||||
async loadCatalog(catalogUrl?: string): Promise<void> {
|
||||
const url = catalogUrl || `${this._baseUrl}asset-catalog.json`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load catalog: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this._catalog = this._parseCatalog(data);
|
||||
|
||||
console.log(`[RuntimeCatalog] Loaded catalog with ${Object.keys(this._catalog.assets).length} assets`);
|
||||
} catch (error) {
|
||||
console.error('[RuntimeCatalog] Failed to load catalog:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize with pre-loaded catalog data
|
||||
* 使用预加载的目录数据初始化
|
||||
*/
|
||||
initWithData(catalogData: IRuntimeCatalog): void {
|
||||
this._catalog = catalogData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if catalog is loaded
|
||||
* 检查目录是否已加载
|
||||
*/
|
||||
isLoaded(): boolean {
|
||||
return this._catalog !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset location by GUID
|
||||
* 根据 GUID 获取资产位置
|
||||
*/
|
||||
getAssetLocation(guid: AssetGUID): IRuntimeAssetLocation | null {
|
||||
if (!this._catalog) {
|
||||
console.warn('[RuntimeCatalog] Catalog not loaded');
|
||||
return null;
|
||||
}
|
||||
|
||||
return this._catalog.assets[guid] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if asset exists in catalog
|
||||
* 检查资产是否存在于目录中
|
||||
*/
|
||||
hasAsset(guid: AssetGUID): boolean {
|
||||
return this._catalog?.assets[guid] !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all assets of a specific type
|
||||
* 获取特定类型的所有资产
|
||||
*/
|
||||
getAssetsByType(type: AssetType): AssetGUID[] {
|
||||
if (!this._catalog) return [];
|
||||
|
||||
return Object.entries(this._catalog.assets)
|
||||
.filter(([_, loc]) => loc.type === type)
|
||||
.map(([guid]) => guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bundle info
|
||||
* 获取包信息
|
||||
*/
|
||||
getBundleInfo(bundleName: string): IRuntimeBundleInfo | null {
|
||||
return this._catalog?.bundles[bundleName] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a bundle
|
||||
* 加载包
|
||||
*/
|
||||
async loadBundle(bundleName: string): Promise<ArrayBuffer> {
|
||||
// Return cached bundle
|
||||
const cached = this._loadedBundles.get(bundleName);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Return pending load
|
||||
const pending = this._loadingBundles.get(bundleName);
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
// Start new load
|
||||
const bundleInfo = this.getBundleInfo(bundleName);
|
||||
if (!bundleInfo) {
|
||||
throw new Error(`Bundle not found in catalog: ${bundleName}`);
|
||||
}
|
||||
|
||||
const loadPromise = this._fetchBundle(bundleInfo);
|
||||
this._loadingBundles.set(bundleName, loadPromise);
|
||||
|
||||
try {
|
||||
const data = await loadPromise;
|
||||
this._loadedBundles.set(bundleName, data);
|
||||
return data;
|
||||
} finally {
|
||||
this._loadingBundles.delete(bundleName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load asset data by GUID
|
||||
* 根据 GUID 加载资产数据
|
||||
*/
|
||||
async loadAssetData(guid: AssetGUID): Promise<ArrayBuffer> {
|
||||
const location = this.getAssetLocation(guid);
|
||||
if (!location) {
|
||||
throw new Error(`Asset not found in catalog: ${guid}`);
|
||||
}
|
||||
|
||||
// Load the bundle containing this asset
|
||||
const bundleData = await this.loadBundle(location.bundle);
|
||||
|
||||
// Extract asset data from bundle
|
||||
return bundleData.slice(location.offset, location.offset + location.size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload bundles marked for preloading
|
||||
* 预加载标记为预加载的包
|
||||
*/
|
||||
async preloadBundles(): Promise<void> {
|
||||
if (!this._catalog) return;
|
||||
|
||||
const preloadPromises: Promise<void>[] = [];
|
||||
|
||||
for (const [name, info] of Object.entries(this._catalog.bundles)) {
|
||||
if (info.preload) {
|
||||
preloadPromises.push(
|
||||
this.loadBundle(name).then(() => {
|
||||
console.log(`[RuntimeCatalog] Preloaded bundle: ${name}`);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(preloadPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload a bundle from memory
|
||||
* 从内存卸载包
|
||||
*/
|
||||
unloadBundle(bundleName: string): void {
|
||||
this._loadedBundles.delete(bundleName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all loaded bundles
|
||||
* 清除所有已加载的包
|
||||
*/
|
||||
clearBundles(): void {
|
||||
this._loadedBundles.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get catalog statistics
|
||||
* 获取目录统计信息
|
||||
*/
|
||||
getStatistics(): {
|
||||
totalAssets: number;
|
||||
totalBundles: number;
|
||||
loadedBundles: number;
|
||||
assetsByType: Record<string, number>;
|
||||
} {
|
||||
if (!this._catalog) {
|
||||
return {
|
||||
totalAssets: 0,
|
||||
totalBundles: 0,
|
||||
loadedBundles: 0,
|
||||
assetsByType: {}
|
||||
};
|
||||
}
|
||||
|
||||
const assetsByType: Record<string, number> = {};
|
||||
for (const loc of Object.values(this._catalog.assets)) {
|
||||
assetsByType[loc.type] = (assetsByType[loc.type] || 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
totalAssets: Object.keys(this._catalog.assets).length,
|
||||
totalBundles: Object.keys(this._catalog.bundles).length,
|
||||
loadedBundles: this._loadedBundles.size,
|
||||
assetsByType
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse catalog JSON to typed structure
|
||||
* 将目录 JSON 解析为类型化结构
|
||||
*/
|
||||
private _parseCatalog(data: unknown): IRuntimeCatalog {
|
||||
const raw = data as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
version: (raw.version as string) || '1.0',
|
||||
createdAt: (raw.createdAt as number) || Date.now(),
|
||||
bundles: (raw.bundles as Record<string, IRuntimeBundleInfo>) || {},
|
||||
assets: (raw.assets as Record<AssetGUID, IRuntimeAssetLocation>) || {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch bundle data
|
||||
* 获取包数据
|
||||
*/
|
||||
private async _fetchBundle(info: IRuntimeBundleInfo): Promise<ArrayBuffer> {
|
||||
const url = info.url.startsWith('http')
|
||||
? info.url
|
||||
: `${this._baseUrl}${info.url}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load bundle: ${url} (${response.status})`);
|
||||
}
|
||||
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global runtime catalog instance
|
||||
* 全局运行时目录实例
|
||||
*/
|
||||
export const runtimeCatalog = new RuntimeCatalog();
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Asset Metadata Service
|
||||
* 资产元数据服务
|
||||
*
|
||||
* Provides global access to asset metadata without requiring asset loading.
|
||||
* This service is independent of the texture loading path, allowing
|
||||
* render systems to query sprite info regardless of how textures are loaded.
|
||||
*
|
||||
* 提供对资产元数据的全局访问,无需加载资产。
|
||||
* 此服务独立于纹理加载路径,允许渲染系统查询 sprite 信息,
|
||||
* 无论纹理是如何加载的。
|
||||
*/
|
||||
|
||||
import { AssetDatabase, ITextureSpriteInfo } from '../core/AssetDatabase';
|
||||
import type { AssetGUID } from '../types/AssetTypes';
|
||||
import type { ITextureEngineBridge } from '../integration/EngineIntegration';
|
||||
|
||||
/**
|
||||
* Global asset database instance
|
||||
* 全局资产数据库实例
|
||||
*/
|
||||
let globalAssetDatabase: AssetDatabase | null = null;
|
||||
|
||||
/**
|
||||
* Global engine bridge instance
|
||||
* 全局引擎桥实例
|
||||
*
|
||||
* Used to query texture dimensions from Rust engine (single source of truth).
|
||||
* 用于从 Rust 引擎查询纹理尺寸(唯一事实来源)。
|
||||
*/
|
||||
let globalEngineBridge: ITextureEngineBridge | null = null;
|
||||
|
||||
/**
|
||||
* Set the global asset database
|
||||
* 设置全局资产数据库
|
||||
*
|
||||
* Should be called during engine initialization.
|
||||
* 应在引擎初始化期间调用。
|
||||
*
|
||||
* @param database - AssetDatabase instance | AssetDatabase 实例
|
||||
*/
|
||||
export function setGlobalAssetDatabase(database: AssetDatabase | null): void {
|
||||
globalAssetDatabase = database;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the global asset database
|
||||
* 获取全局资产数据库
|
||||
*
|
||||
* @returns AssetDatabase instance or null | AssetDatabase 实例或 null
|
||||
*/
|
||||
export function getGlobalAssetDatabase(): AssetDatabase | null {
|
||||
return globalAssetDatabase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the global engine bridge
|
||||
* 设置全局引擎桥
|
||||
*
|
||||
* The engine bridge is used to query texture dimensions directly from Rust engine.
|
||||
* This is the single source of truth for texture dimensions.
|
||||
* 引擎桥用于直接从 Rust 引擎查询纹理尺寸。
|
||||
* 这是纹理尺寸的唯一事实来源。
|
||||
*
|
||||
* @param bridge - ITextureEngineBridge instance | ITextureEngineBridge 实例
|
||||
*/
|
||||
export function setGlobalEngineBridge(bridge: ITextureEngineBridge | null): void {
|
||||
globalEngineBridge = bridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the global engine bridge
|
||||
* 获取全局引擎桥
|
||||
*
|
||||
* @returns ITextureEngineBridge instance or null | ITextureEngineBridge 实例或 null
|
||||
*/
|
||||
export function getGlobalEngineBridge(): ITextureEngineBridge | null {
|
||||
return globalEngineBridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get texture sprite info by GUID
|
||||
* 通过 GUID 获取纹理 Sprite 信息
|
||||
*
|
||||
* This is the primary API for render systems to query nine-patch/sprite info.
|
||||
* It combines data from:
|
||||
* - Asset metadata (sliceBorder, pivot) from AssetDatabase
|
||||
* - Texture dimensions (width, height) from Rust engine (single source of truth)
|
||||
*
|
||||
* 这是渲染系统查询九宫格/sprite 信息的主要 API。
|
||||
* 它合并来自:
|
||||
* - AssetDatabase 的资产元数据(sliceBorder, pivot)
|
||||
* - Rust 引擎的纹理尺寸(width, height)(唯一事实来源)
|
||||
*
|
||||
* @param guid - Texture asset GUID | 纹理资产 GUID
|
||||
* @returns Sprite info or undefined | Sprite 信息或 undefined
|
||||
*/
|
||||
export function getTextureSpriteInfo(guid: AssetGUID): ITextureSpriteInfo | undefined {
|
||||
// Get sprite settings from metadata
|
||||
// 从元数据获取 sprite 设置
|
||||
const metadataInfo = globalAssetDatabase?.getTextureSpriteInfo(guid);
|
||||
|
||||
// Get texture dimensions from Rust engine (single source of truth)
|
||||
// 从 Rust 引擎获取纹理尺寸(唯一事实来源)
|
||||
let dimensions: { width: number; height: number } | undefined;
|
||||
|
||||
if (globalEngineBridge?.getTextureInfoByPath && globalAssetDatabase) {
|
||||
// Get asset path from database
|
||||
// 从数据库获取资产路径
|
||||
const metadata = globalAssetDatabase.getMetadata(guid);
|
||||
if (metadata?.path) {
|
||||
const engineInfo = globalEngineBridge.getTextureInfoByPath(metadata.path);
|
||||
if (engineInfo) {
|
||||
dimensions = engineInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no metadata and no dimensions, return undefined
|
||||
// 如果没有元数据也没有尺寸,返回 undefined
|
||||
if (!metadataInfo && !dimensions) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Merge the two sources
|
||||
// 合并两个数据源
|
||||
// Prefer engine dimensions (runtime loaded), fallback to metadata dimensions (catalog stored)
|
||||
// 优先使用引擎尺寸(运行时加载),后备使用元数据尺寸(目录存储)
|
||||
return {
|
||||
sliceBorder: metadataInfo?.sliceBorder,
|
||||
pivot: metadataInfo?.pivot,
|
||||
width: dimensions?.width ?? metadataInfo?.width,
|
||||
height: dimensions?.height ?? metadataInfo?.height
|
||||
};
|
||||
}
|
||||
|
||||
// Re-export type for convenience
|
||||
// 为方便起见重新导出类型
|
||||
export type { ITextureSpriteInfo };
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 路径解析服务
|
||||
* Path Resolution Service
|
||||
*
|
||||
* 提供统一的路径解析接口,处理编辑器、Catalog、运行时三层路径转换。
|
||||
* Provides unified path resolution interface for editor, catalog, and runtime path conversion.
|
||||
*
|
||||
* 路径格式约定 | Path Format Convention:
|
||||
* - 编辑器路径 (Editor Path): 绝对路径,如 `C:\Project\assets\textures\bg.png`
|
||||
* - Catalog 路径 (Catalog Path): 相对于 assets 目录,不含 `assets/` 前缀,如 `textures/bg.png`
|
||||
* - 运行时 URL (Runtime URL): 完整 URL,如 `./assets/textures/bg.png` 或 `https://cdn.example.com/assets/textures/bg.png`
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { PathResolutionServiceToken, type IPathResolutionService } from '@esengine/asset-system';
|
||||
*
|
||||
* // 获取服务
|
||||
* const pathService = context.services.get(PathResolutionServiceToken);
|
||||
*
|
||||
* // Catalog 路径转运行时 URL
|
||||
* const url = pathService.catalogToRuntime('textures/bg.png');
|
||||
* // => './assets/textures/bg.png'
|
||||
*
|
||||
* // 编辑器路径转 Catalog 路径
|
||||
* const catalogPath = pathService.editorToCatalog('C:\\Project\\assets\\textures\\bg.png', 'C:\\Project');
|
||||
* // => 'textures/bg.png'
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { createServiceToken } from '@esengine/ecs-framework';
|
||||
|
||||
// ============================================================================
|
||||
// 接口定义 | Interface Definitions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 路径解析服务接口
|
||||
* Path resolution service interface
|
||||
*/
|
||||
export interface IPathResolutionService {
|
||||
/**
|
||||
* 将 Catalog 路径转换为运行时 URL
|
||||
* Convert catalog path to runtime URL
|
||||
*
|
||||
* @param catalogPath Catalog 路径(相对于 assets 目录,不含 assets/ 前缀)
|
||||
* @returns 运行时 URL
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 输入: 'textures/bg.png'
|
||||
* // 输出: './assets/textures/bg.png' (取决于 baseUrl 配置)
|
||||
* pathService.catalogToRuntime('textures/bg.png');
|
||||
* ```
|
||||
*/
|
||||
catalogToRuntime(catalogPath: string): string;
|
||||
|
||||
/**
|
||||
* 将编辑器绝对路径转换为 Catalog 路径
|
||||
* Convert editor absolute path to catalog path
|
||||
*
|
||||
* @param editorPath 编辑器绝对路径
|
||||
* @param projectRoot 项目根目录
|
||||
* @returns Catalog 路径(相对于 assets 目录,不含 assets/ 前缀)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 输入: 'C:\\Project\\assets\\textures\\bg.png', 'C:\\Project'
|
||||
* // 输出: 'textures/bg.png'
|
||||
* pathService.editorToCatalog('C:\\Project\\assets\\textures\\bg.png', 'C:\\Project');
|
||||
* ```
|
||||
*/
|
||||
editorToCatalog(editorPath: string, projectRoot: string): string;
|
||||
|
||||
/**
|
||||
* 设置运行时基础 URL
|
||||
* Set runtime base URL
|
||||
*
|
||||
* @param url 基础 URL(通常为 './assets' 或 CDN URL)
|
||||
*/
|
||||
setBaseUrl(url: string): void;
|
||||
|
||||
/**
|
||||
* 获取当前基础 URL
|
||||
* Get current base URL
|
||||
*/
|
||||
getBaseUrl(): string;
|
||||
|
||||
/**
|
||||
* 规范化路径(统一斜杠方向,移除重复斜杠)
|
||||
* Normalize path (unify slash direction, remove duplicate slashes)
|
||||
*
|
||||
* @param path 输入路径
|
||||
* @returns 规范化后的路径
|
||||
*/
|
||||
normalize(path: string): string;
|
||||
|
||||
/**
|
||||
* 检查路径是否为绝对 URL
|
||||
* Check if path is absolute URL
|
||||
*
|
||||
* @param path 输入路径
|
||||
* @returns 是否为绝对 URL
|
||||
*/
|
||||
isAbsoluteUrl(path: string): boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 服务令牌 | Service Token
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 路径解析服务令牌
|
||||
* Path resolution service token
|
||||
*/
|
||||
export const PathResolutionServiceToken = createServiceToken<IPathResolutionService>('pathResolutionService');
|
||||
|
||||
// ============================================================================
|
||||
// 默认实现 | Default Implementation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 路径解析服务默认实现
|
||||
* Default path resolution service implementation
|
||||
*/
|
||||
export class PathResolutionService implements IPathResolutionService {
|
||||
private _baseUrl: string = './assets';
|
||||
private _assetsDir: string = 'assets';
|
||||
|
||||
/**
|
||||
* 创建路径解析服务
|
||||
* Create path resolution service
|
||||
*
|
||||
* @param baseUrl 基础 URL(默认 './assets')
|
||||
*/
|
||||
constructor(baseUrl?: string) {
|
||||
if (baseUrl !== undefined) {
|
||||
this._baseUrl = baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Catalog 路径转换为运行时 URL
|
||||
* Convert catalog path to runtime URL
|
||||
*/
|
||||
catalogToRuntime(catalogPath: string): string {
|
||||
// 空路径直接返回
|
||||
if (!catalogPath) {
|
||||
return catalogPath;
|
||||
}
|
||||
|
||||
// 已经是绝对 URL 则直接返回
|
||||
if (this.isAbsoluteUrl(catalogPath)) {
|
||||
return catalogPath;
|
||||
}
|
||||
|
||||
// Data URL 直接返回
|
||||
if (catalogPath.startsWith('data:')) {
|
||||
return catalogPath;
|
||||
}
|
||||
|
||||
// 规范化路径
|
||||
let normalized = this.normalize(catalogPath);
|
||||
|
||||
// 移除开头的斜杠
|
||||
normalized = normalized.replace(/^\/+/, '');
|
||||
|
||||
// 如果路径以 'assets/' 开头,移除它(避免重复)
|
||||
// Catalog 路径不应包含 assets/ 前缀
|
||||
if (normalized.startsWith('assets/')) {
|
||||
normalized = normalized.substring(7);
|
||||
}
|
||||
|
||||
// 构建完整 URL
|
||||
const base = this._baseUrl.replace(/\/+$/, ''); // 移除尾部斜杠
|
||||
return `${base}/${normalized}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将编辑器绝对路径转换为 Catalog 路径
|
||||
* Convert editor absolute path to catalog path
|
||||
*/
|
||||
editorToCatalog(editorPath: string, projectRoot: string): string {
|
||||
// 规范化路径
|
||||
let normalizedPath = this.normalize(editorPath);
|
||||
let normalizedRoot = this.normalize(projectRoot);
|
||||
|
||||
// 确保根路径以斜杠结尾
|
||||
if (!normalizedRoot.endsWith('/')) {
|
||||
normalizedRoot += '/';
|
||||
}
|
||||
|
||||
// 移除项目根路径前缀
|
||||
if (normalizedPath.startsWith(normalizedRoot)) {
|
||||
normalizedPath = normalizedPath.substring(normalizedRoot.length);
|
||||
}
|
||||
|
||||
// 移除 assets/ 前缀(如果存在)
|
||||
const assetsPrefix = `${this._assetsDir}/`;
|
||||
if (normalizedPath.startsWith(assetsPrefix)) {
|
||||
normalizedPath = normalizedPath.substring(assetsPrefix.length);
|
||||
}
|
||||
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置运行时基础 URL
|
||||
* Set runtime base URL
|
||||
*/
|
||||
setBaseUrl(url: string): void {
|
||||
this._baseUrl = url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前基础 URL
|
||||
* Get current base URL
|
||||
*/
|
||||
getBaseUrl(): string {
|
||||
return this._baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化路径
|
||||
* Normalize path
|
||||
*/
|
||||
normalize(path: string): string {
|
||||
return path
|
||||
.replace(/\\/g, '/') // 反斜杠转正斜杠
|
||||
.replace(/\/+/g, '/'); // 移除重复斜杠
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查路径是否为绝对 URL
|
||||
* Check if path is absolute URL
|
||||
*/
|
||||
isAbsoluteUrl(path: string): boolean {
|
||||
return /^(https?:\/\/|file:\/\/|asset:\/\/|blob:)/.test(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* 场景资源管理器 - 集中式场景资源加载
|
||||
* SceneResourceManager - Centralized resource loading for scenes
|
||||
*
|
||||
* 扫描场景中所有组件,收集资源引用,批量加载资源,并将运行时 ID 分配回组件
|
||||
* Scans all components in a scene, collects resource references, batch-loads them, and assigns runtime IDs back to components
|
||||
*/
|
||||
|
||||
import type { Scene } from '@esengine/ecs-framework';
|
||||
import { isResourceComponent, type ResourceReference } from '../interfaces/IResourceComponent';
|
||||
|
||||
/**
|
||||
* 资源加载器接口
|
||||
* Resource loader interface
|
||||
*/
|
||||
export interface IResourceLoader {
|
||||
/**
|
||||
* 批量加载资源并返回路径到 ID 的映射
|
||||
* Load a batch of resources and return path-to-ID mapping
|
||||
* @param paths 资源路径数组 / Array of resource paths
|
||||
* @param type 资源类型 / Resource type
|
||||
* @returns 路径到运行时 ID 的映射 / Map of paths to runtime IDs
|
||||
*/
|
||||
loadResourcesBatch(paths: string[], type: ResourceReference['type']): Promise<Map<string, number>>;
|
||||
|
||||
/**
|
||||
* 卸载纹理资源(可选)
|
||||
* Unload texture resource (optional)
|
||||
*/
|
||||
unloadTexture?(textureId: number): void;
|
||||
|
||||
/**
|
||||
* 卸载音频资源(可选)
|
||||
* Unload audio resource (optional)
|
||||
*/
|
||||
unloadAudio?(audioId: number): void;
|
||||
|
||||
/**
|
||||
* 卸载数据资源(可选)
|
||||
* Unload data resource (optional)
|
||||
*/
|
||||
unloadData?(dataId: number): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源引用计数条目
|
||||
* Resource reference count entry
|
||||
*/
|
||||
interface ResourceRefCountEntry {
|
||||
/** 资源路径 / Resource path */
|
||||
path: string;
|
||||
/** 资源类型 / Resource type */
|
||||
type: ResourceReference['type'];
|
||||
/** 运行时 ID / Runtime ID */
|
||||
runtimeId: number;
|
||||
/** 使用此资源的场景名称集合 / Set of scene names using this resource */
|
||||
sceneNames: Set<string>;
|
||||
}
|
||||
|
||||
export class SceneResourceManager {
|
||||
private resourceLoader: IResourceLoader | null = null;
|
||||
|
||||
/**
|
||||
* 资源引用计数表
|
||||
* Resource reference count table
|
||||
*
|
||||
* Key: resource path, Value: reference count entry
|
||||
*/
|
||||
private _resourceRefCounts = new Map<string, ResourceRefCountEntry>();
|
||||
|
||||
/**
|
||||
* 场景到其使用的资源路径的映射
|
||||
* Map of scene name to resource paths used by that scene
|
||||
*/
|
||||
private _sceneResources = new Map<string, Set<string>>();
|
||||
|
||||
/**
|
||||
* 设置资源加载器实现
|
||||
* Set the resource loader implementation
|
||||
*
|
||||
* 应由引擎集成层调用
|
||||
* This should be called by the engine integration layer
|
||||
*
|
||||
* @param loader 资源加载器实例 / Resource loader instance
|
||||
*/
|
||||
setResourceLoader(loader: IResourceLoader): void {
|
||||
this.resourceLoader = loader;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载场景所需的所有资源
|
||||
* Load all resources required by a scene
|
||||
*
|
||||
* 流程 / Process:
|
||||
* 1. 扫描所有实体并从 IResourceComponent 实现中收集资源引用
|
||||
* Scan all entities and collect resource references from IResourceComponent implementations
|
||||
* 2. 按类型分组资源(纹理、音频等)
|
||||
* Group resources by type (texture, audio, etc.)
|
||||
* 3. 批量加载每种资源类型
|
||||
* Batch load each resource type
|
||||
* 4. 将运行时 ID 分配回组件
|
||||
* Assign runtime IDs back to components
|
||||
* 5. 更新引用计数
|
||||
* Update reference counts
|
||||
*
|
||||
* @param scene 要加载资源的场景 / The scene to load resources for
|
||||
* @returns 当所有资源加载完成时解析的 Promise / Promise that resolves when all resources are loaded
|
||||
*/
|
||||
async loadSceneResources(scene: Scene): Promise<void> {
|
||||
if (!this.resourceLoader) {
|
||||
console.warn('[SceneResourceManager] No resource loader set, skipping resource loading');
|
||||
return;
|
||||
}
|
||||
|
||||
const sceneName = scene.name;
|
||||
|
||||
// 从组件收集所有资源引用 / Collect all resource references from components
|
||||
const resourceRefs = this.collectResourceReferences(scene);
|
||||
|
||||
if (resourceRefs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 按资源类型分组 / Group by resource type
|
||||
const resourcesByType = new Map<ResourceReference['type'], Set<string>>();
|
||||
for (const ref of resourceRefs) {
|
||||
if (!resourcesByType.has(ref.type)) {
|
||||
resourcesByType.set(ref.type, new Set());
|
||||
}
|
||||
resourcesByType.get(ref.type)!.add(ref.path);
|
||||
}
|
||||
|
||||
// 批量加载每种资源类型 / Load each resource type in batch
|
||||
const allResourceIds = new Map<string, number>();
|
||||
|
||||
for (const [type, paths] of resourcesByType) {
|
||||
const pathsArray = Array.from(paths);
|
||||
|
||||
try {
|
||||
const resourceIds = await this.resourceLoader.loadResourcesBatch(pathsArray, type);
|
||||
|
||||
// 合并到总映射表 / Merge into combined map
|
||||
for (const [path, id] of resourceIds) {
|
||||
allResourceIds.set(path, id);
|
||||
|
||||
// 更新引用计数 / Update reference count
|
||||
this.addResourceReference(path, type, id, sceneName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[SceneResourceManager] Failed to load ${type} resources:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 将资源 ID 分配回组件 / Assign resource IDs back to components
|
||||
this.assignResourceIds(scene, allResourceIds);
|
||||
|
||||
// 记录场景使用的资源 / Record resources used by scene
|
||||
const scenePaths = new Set<string>();
|
||||
for (const ref of resourceRefs) {
|
||||
scenePaths.add(ref.path);
|
||||
}
|
||||
this._sceneResources.set(sceneName, scenePaths);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加资源引用
|
||||
* Add resource reference
|
||||
*/
|
||||
private addResourceReference(
|
||||
path: string,
|
||||
type: ResourceReference['type'],
|
||||
runtimeId: number,
|
||||
sceneName: string
|
||||
): void {
|
||||
let entry = this._resourceRefCounts.get(path);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
path,
|
||||
type,
|
||||
runtimeId,
|
||||
sceneNames: new Set()
|
||||
};
|
||||
this._resourceRefCounts.set(path, entry);
|
||||
}
|
||||
entry.sceneNames.add(sceneName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除资源引用
|
||||
* Remove resource reference
|
||||
*
|
||||
* @returns true 如果资源引用计数归零 / true if resource reference count reaches zero
|
||||
*/
|
||||
private removeResourceReference(path: string, sceneName: string): boolean {
|
||||
const entry = this._resourceRefCounts.get(path);
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
entry.sceneNames.delete(sceneName);
|
||||
|
||||
if (entry.sceneNames.size === 0) {
|
||||
this._resourceRefCounts.delete(path);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从场景实体收集所有资源引用
|
||||
* Collect all resource references from scene entities
|
||||
*/
|
||||
private collectResourceReferences(scene: Scene): ResourceReference[] {
|
||||
const refs: ResourceReference[] = [];
|
||||
|
||||
for (const entity of scene.entities.buffer) {
|
||||
for (const component of entity.components) {
|
||||
if (isResourceComponent(component)) {
|
||||
const componentRefs = component.getResourceReferences();
|
||||
refs.push(...componentRefs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将已加载的资源 ID 分配回组件
|
||||
* Assign loaded resource IDs back to components
|
||||
*
|
||||
* @param scene 场景 / Scene
|
||||
* @param pathToId 路径到 ID 的映射 / Path to ID mapping
|
||||
*/
|
||||
private assignResourceIds(scene: Scene, pathToId: Map<string, number>): void {
|
||||
for (const entity of scene.entities.buffer) {
|
||||
for (const component of entity.components) {
|
||||
if (isResourceComponent(component)) {
|
||||
component.setResourceIds(pathToId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载场景使用的所有资源
|
||||
* Unload all resources used by a scene
|
||||
*
|
||||
* 在场景销毁时调用,只会卸载不再被其他场景引用的资源
|
||||
* Called when a scene is being destroyed, only unloads resources not referenced by other scenes
|
||||
*
|
||||
* @param scene 要卸载资源的场景 / The scene to unload resources for
|
||||
*/
|
||||
async unloadSceneResources(scene: Scene): Promise<void> {
|
||||
const sceneName = scene.name;
|
||||
|
||||
// 获取场景使用的资源路径 / Get resource paths used by scene
|
||||
const scenePaths = this._sceneResources.get(sceneName);
|
||||
if (!scenePaths) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 要卸载的资源 / Resources to unload
|
||||
const toUnload: ResourceRefCountEntry[] = [];
|
||||
|
||||
// 移除引用并收集需要卸载的资源 / Remove references and collect resources to unload
|
||||
for (const path of scenePaths) {
|
||||
const entry = this._resourceRefCounts.get(path);
|
||||
if (entry) {
|
||||
const shouldUnload = this.removeResourceReference(path, sceneName);
|
||||
if (shouldUnload) {
|
||||
toUnload.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理场景资源记录 / Clean up scene resource record
|
||||
this._sceneResources.delete(sceneName);
|
||||
|
||||
// 卸载不再使用的资源 / Unload resources no longer in use
|
||||
if (this.resourceLoader && toUnload.length > 0) {
|
||||
for (const entry of toUnload) {
|
||||
this.unloadResource(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载单个资源
|
||||
* Unload a single resource
|
||||
*/
|
||||
private unloadResource(entry: ResourceRefCountEntry): void {
|
||||
if (!this.resourceLoader) return;
|
||||
|
||||
switch (entry.type) {
|
||||
case 'texture':
|
||||
if (this.resourceLoader.unloadTexture) {
|
||||
this.resourceLoader.unloadTexture(entry.runtimeId);
|
||||
}
|
||||
break;
|
||||
case 'audio':
|
||||
if (this.resourceLoader.unloadAudio) {
|
||||
this.resourceLoader.unloadAudio(entry.runtimeId);
|
||||
}
|
||||
break;
|
||||
case 'data':
|
||||
if (this.resourceLoader.unloadData) {
|
||||
this.resourceLoader.unloadData(entry.runtimeId);
|
||||
}
|
||||
break;
|
||||
case 'font':
|
||||
// 字体卸载暂未实现 / Font unloading not yet implemented
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源统计信息
|
||||
* Get resource statistics
|
||||
*/
|
||||
getStatistics(): {
|
||||
totalResources: number;
|
||||
trackedScenes: number;
|
||||
resourcesByType: Map<ResourceReference['type'], number>;
|
||||
} {
|
||||
const resourcesByType = new Map<ResourceReference['type'], number>();
|
||||
|
||||
for (const entry of this._resourceRefCounts.values()) {
|
||||
const count = resourcesByType.get(entry.type) || 0;
|
||||
resourcesByType.set(entry.type, count + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
totalResources: this._resourceRefCounts.size,
|
||||
trackedScenes: this._sceneResources.size,
|
||||
resourcesByType
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源的引用计数
|
||||
* Get reference count for a resource
|
||||
*/
|
||||
getResourceRefCount(path: string): number {
|
||||
const entry = this._resourceRefCounts.get(path);
|
||||
return entry ? entry.sceneNames.size : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有跟踪数据
|
||||
* Clear all tracking data
|
||||
*/
|
||||
clearAll(): void {
|
||||
this._resourceRefCounts.clear();
|
||||
this._sceneResources.clear();
|
||||
}
|
||||
}
|
||||
54
packages/engine/asset-system/src/tokens.ts
Normal file
54
packages/engine/asset-system/src/tokens.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Asset System 服务令牌
|
||||
* Asset System service tokens
|
||||
*
|
||||
* 定义 asset-system 模块导出的服务令牌和接口。
|
||||
* Defines service tokens and interfaces exported by asset-system module.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 消费方导入 Token | Consumer imports Token
|
||||
* import { AssetManagerToken, type IAssetManager } from '@esengine/asset-system';
|
||||
*
|
||||
* // 获取服务 | Get service
|
||||
* const assetManager = context.services.get(AssetManagerToken);
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { createServiceToken } from '@esengine/ecs-framework';
|
||||
import type { IAssetManager } from './interfaces/IAssetManager';
|
||||
import type { IPrefabService } from './interfaces/IPrefabAsset';
|
||||
import type { IPathResolutionService } from './services/PathResolutionService';
|
||||
|
||||
// 重新导出接口方便使用 | Re-export interface for convenience
|
||||
export type { IAssetManager } from './interfaces/IAssetManager';
|
||||
export type { IAssetLoadResult } from './types/AssetTypes';
|
||||
export type { IPrefabService, IPrefabAsset, IPrefabData, IPrefabMetadata } from './interfaces/IPrefabAsset';
|
||||
export type { IPathResolutionService } from './services/PathResolutionService';
|
||||
|
||||
/**
|
||||
* 资产管理器服务令牌
|
||||
* Asset manager service token
|
||||
*
|
||||
* 用于注册和获取资产管理器服务。
|
||||
* For registering and getting asset manager service.
|
||||
*/
|
||||
export const AssetManagerToken = createServiceToken<IAssetManager>('assetManager');
|
||||
|
||||
/**
|
||||
* 预制体服务令牌
|
||||
* Prefab service token
|
||||
*
|
||||
* 用于注册和获取预制体服务。
|
||||
* For registering and getting prefab service.
|
||||
*/
|
||||
export const PrefabServiceToken = createServiceToken<IPrefabService>('prefabService');
|
||||
|
||||
/**
|
||||
* 路径解析服务令牌
|
||||
* Path resolution service token
|
||||
*
|
||||
* 用于注册和获取路径解析服务。
|
||||
* For registering and getting path resolution service.
|
||||
*/
|
||||
export const PathResolutionServiceToken = createServiceToken<IPathResolutionService>('pathResolutionService');
|
||||
545
packages/engine/asset-system/src/types/AssetTypes.ts
Normal file
545
packages/engine/asset-system/src/types/AssetTypes.ts
Normal file
@@ -0,0 +1,545 @@
|
||||
/**
|
||||
* Core asset system types and enums
|
||||
* 核心资产系统类型和枚举
|
||||
*/
|
||||
|
||||
/**
|
||||
* Unique identifier for assets across the project
|
||||
* 项目中资产的唯一标识符
|
||||
*/
|
||||
export type AssetGUID = string;
|
||||
|
||||
/**
|
||||
* Runtime asset handle for efficient access
|
||||
* 运行时资产句柄,用于高效访问
|
||||
*/
|
||||
export type AssetHandle = number;
|
||||
|
||||
/**
|
||||
* Asset loading state
|
||||
* 资产加载状态
|
||||
*/
|
||||
export enum AssetState {
|
||||
/** 未加载 */
|
||||
Unloaded = 'unloaded',
|
||||
/** 加载中 */
|
||||
Loading = 'loading',
|
||||
/** 已加载 */
|
||||
Loaded = 'loaded',
|
||||
/** 加载失败 */
|
||||
Failed = 'failed',
|
||||
/** 释放中 */
|
||||
Disposing = 'disposing'
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset type - string based for extensibility
|
||||
* 资产类型 - 使用字符串以支持插件扩展
|
||||
*
|
||||
* Plugins can define their own asset types by using custom strings.
|
||||
* Built-in types are provided as constants below.
|
||||
* 插件可以通过使用自定义字符串定义自己的资产类型。
|
||||
* 内置类型作为常量提供如下。
|
||||
*/
|
||||
export type AssetType = string;
|
||||
|
||||
/**
|
||||
* Built-in asset types provided by asset-system
|
||||
* asset-system 提供的内置资产类型
|
||||
*/
|
||||
export const AssetType = {
|
||||
/** 纹理 */
|
||||
Texture: 'texture',
|
||||
/** 网格 */
|
||||
Mesh: 'mesh',
|
||||
/** 3D模型 (GLTF/GLB) | 3D Model */
|
||||
Model3D: 'model3d',
|
||||
/** 材质 */
|
||||
Material: 'material',
|
||||
/** 着色器 */
|
||||
Shader: 'shader',
|
||||
/** 音频 */
|
||||
Audio: 'audio',
|
||||
/** 字体 */
|
||||
Font: 'font',
|
||||
/** 预制体 */
|
||||
Prefab: 'prefab',
|
||||
/** 场景 */
|
||||
Scene: 'scene',
|
||||
/** 脚本 */
|
||||
Script: 'script',
|
||||
/** 动画片段 */
|
||||
AnimationClip: 'animation',
|
||||
/** JSON数据 */
|
||||
Json: 'json',
|
||||
/** 文本 */
|
||||
Text: 'text',
|
||||
/** 二进制 */
|
||||
Binary: 'binary',
|
||||
/** 自定义 */
|
||||
Custom: 'custom'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Platform variants for assets
|
||||
* 资产的平台变体
|
||||
*/
|
||||
export enum AssetPlatform {
|
||||
/** H5平台(浏览器) */
|
||||
H5 = 'h5',
|
||||
/** 微信小游戏 */
|
||||
WeChat = 'wechat',
|
||||
/** 试玩广告(Playable Ads) */
|
||||
Playable = 'playable',
|
||||
/** Android平台 */
|
||||
Android = 'android',
|
||||
/** iOS平台 */
|
||||
iOS = 'ios',
|
||||
/** 编辑器(Tauri桌面) */
|
||||
Editor = 'editor'
|
||||
}
|
||||
|
||||
/**
|
||||
* Quality levels for asset variants
|
||||
* 资产变体的质量级别
|
||||
*/
|
||||
export enum AssetQuality {
|
||||
/** 低质量 */
|
||||
Low = 'low',
|
||||
/** 中等质量 */
|
||||
Medium = 'medium',
|
||||
/** 高质量 */
|
||||
High = 'high',
|
||||
/** 超高质量 */
|
||||
Ultra = 'ultra'
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset metadata stored in the database
|
||||
* 存储在数据库中的资产元数据
|
||||
*/
|
||||
export interface IAssetMetadata {
|
||||
/** 全局唯一标识符 */
|
||||
guid: AssetGUID;
|
||||
/** 资产路径 */
|
||||
path: string;
|
||||
/** 资产类型 */
|
||||
type: AssetType;
|
||||
/** 资产名称 */
|
||||
name: string;
|
||||
/** 文件大小(字节) / File size in bytes */
|
||||
size: number;
|
||||
/** 内容哈希值 / Content hash for versioning */
|
||||
hash: string;
|
||||
/** 依赖的其他资产 / Dependencies on other assets */
|
||||
dependencies: AssetGUID[];
|
||||
/** 资产标签 / User-defined labels for categorization */
|
||||
labels: string[];
|
||||
/** 自定义标签 / Custom metadata tags */
|
||||
tags: Map<string, string>;
|
||||
/** 导入设置 / Import-time settings */
|
||||
importSettings?: Record<string, unknown>;
|
||||
/** 最后修改时间 / Unix timestamp of last modification */
|
||||
lastModified: number;
|
||||
/** 版本号 / Asset version number */
|
||||
version: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset variant descriptor
|
||||
* 资产变体描述符
|
||||
*/
|
||||
export interface IAssetVariant {
|
||||
/** 目标平台 */
|
||||
platform: AssetPlatform;
|
||||
/** 质量级别 */
|
||||
quality: AssetQuality;
|
||||
/** 本地化语言 / Language code for localized assets */
|
||||
locale?: string;
|
||||
/** 主题变体 / Theme identifier (e.g., 'dark', 'light') */
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset load options
|
||||
* 资产加载选项
|
||||
*/
|
||||
export interface IAssetLoadOptions {
|
||||
/** 加载优先级(0-100,越高越优先) / Priority level 0-100, higher loads first */
|
||||
priority?: number;
|
||||
/** 是否异步加载 / Use async loading */
|
||||
async?: boolean;
|
||||
/** 指定加载的变体 / Specific variant to load */
|
||||
variant?: IAssetVariant;
|
||||
/** 强制重新加载 / Force reload even if cached */
|
||||
forceReload?: boolean;
|
||||
/** 超时时间(毫秒) / Timeout in milliseconds */
|
||||
timeout?: number;
|
||||
/** 进度回调 / Progress callback (0-1) */
|
||||
onProgress?: (progress: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset bundle manifest
|
||||
* 资产包清单
|
||||
*/
|
||||
export interface IAssetBundleManifest {
|
||||
/** 包名称 */
|
||||
name: string;
|
||||
/** 版本号 */
|
||||
version: string;
|
||||
/** 内容哈希 / Content hash for integrity check */
|
||||
hash: string;
|
||||
/** 压缩类型 */
|
||||
compression?: 'none' | 'gzip' | 'brotli';
|
||||
/** 包含的资产列表 / Assets contained in this bundle */
|
||||
assets: AssetGUID[];
|
||||
/** 依赖的其他包 / Other bundles this depends on */
|
||||
dependencies: string[];
|
||||
/** 包大小(字节) / Bundle size in bytes */
|
||||
size: number;
|
||||
/** 创建时间戳 / Creation timestamp */
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset loading result
|
||||
* 资产加载结果
|
||||
*/
|
||||
export interface IAssetLoadResult<T = unknown> {
|
||||
/** 加载的资产实例 */
|
||||
asset: T;
|
||||
/** 资产句柄 */
|
||||
handle: AssetHandle;
|
||||
/** 资产元数据 */
|
||||
metadata: IAssetMetadata;
|
||||
/** 加载耗时(毫秒) / Load time in milliseconds */
|
||||
loadTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset loading error
|
||||
* 资产加载错误
|
||||
*/
|
||||
export class AssetLoadError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly guid: AssetGUID,
|
||||
public readonly type: AssetType,
|
||||
public readonly cause?: Error
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AssetLoadError';
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method for file not found error
|
||||
* 文件未找到错误的工厂方法
|
||||
*/
|
||||
static fileNotFound(guid: AssetGUID, path: string): AssetLoadError {
|
||||
return new AssetLoadError(`Asset file not found: ${path}`, guid, AssetType.Custom);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method for unsupported type error
|
||||
* 不支持的类型错误的工厂方法
|
||||
*/
|
||||
static unsupportedType(guid: AssetGUID, type: AssetType): AssetLoadError {
|
||||
return new AssetLoadError(`Unsupported asset type: ${type}`, guid, type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method for load timeout error
|
||||
* 加载超时错误的工厂方法
|
||||
*/
|
||||
static loadTimeout(guid: AssetGUID, type: AssetType, timeout: number): AssetLoadError {
|
||||
return new AssetLoadError(`Asset load timeout after ${timeout}ms`, guid, type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method for dependency failed error
|
||||
* 依赖加载失败错误的工厂方法
|
||||
*/
|
||||
static dependencyFailed(guid: AssetGUID, type: AssetType, depGuid: AssetGUID): AssetLoadError {
|
||||
return new AssetLoadError(`Dependency failed to load: ${depGuid}`, guid, type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset reference counting info
|
||||
* 资产引用计数信息
|
||||
*/
|
||||
export interface IAssetReferenceInfo {
|
||||
/** 资产GUID */
|
||||
guid: AssetGUID;
|
||||
/** 资产句柄 */
|
||||
handle: AssetHandle;
|
||||
/** 引用计数 */
|
||||
referenceCount: number;
|
||||
/** 最后访问时间 / Unix timestamp of last access */
|
||||
lastAccessTime: number;
|
||||
/** 当前状态 */
|
||||
state: AssetState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset import options
|
||||
* 资产导入选项
|
||||
*/
|
||||
export interface IAssetImportOptions {
|
||||
/** 资产类型 */
|
||||
type: AssetType;
|
||||
/** 生成Mipmap / Generate mipmaps for textures */
|
||||
generateMipmaps?: boolean;
|
||||
/** 纹理压缩格式 / Texture compression format */
|
||||
compression?: 'none' | 'dxt' | 'etc2' | 'astc';
|
||||
/** 最大纹理尺寸 / Maximum texture dimension */
|
||||
maxTextureSize?: number;
|
||||
/** 生成LOD / Generate LODs for meshes */
|
||||
generateLODs?: boolean;
|
||||
/** 优化网格 / Optimize mesh geometry */
|
||||
optimizeMesh?: boolean;
|
||||
/** 音频格式 / Audio encoding format */
|
||||
audioFormat?: 'mp3' | 'ogg' | 'wav';
|
||||
/** 自定义处理器 / Custom processor plugin name */
|
||||
customProcessor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset usage statistics
|
||||
* 资产使用统计
|
||||
*/
|
||||
export interface IAssetUsageStats {
|
||||
/** 资产GUID */
|
||||
guid: AssetGUID;
|
||||
/** 加载次数 */
|
||||
loadCount: number;
|
||||
/** 总加载时间(毫秒) / Total time spent loading in ms */
|
||||
totalLoadTime: number;
|
||||
/** 平均加载时间(毫秒) / Average load time in ms */
|
||||
averageLoadTime: number;
|
||||
/** 最后使用时间 / Unix timestamp of last use */
|
||||
lastUsedTime: number;
|
||||
/** 被引用的资产列表 / Assets that reference this one */
|
||||
referencedBy: AssetGUID[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset preload group
|
||||
* 资产预加载组
|
||||
*/
|
||||
export interface IAssetPreloadGroup {
|
||||
/** 组名称 */
|
||||
name: string;
|
||||
/** 包含的资产 */
|
||||
assets: AssetGUID[];
|
||||
/** 加载优先级 / Load priority 0-100 */
|
||||
priority: number;
|
||||
/** 是否必需 / Must be loaded before scene start */
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset loading progress info
|
||||
* 资产加载进度信息
|
||||
*/
|
||||
export interface IAssetLoadProgress {
|
||||
/** 当前加载的资产 */
|
||||
currentAsset: string;
|
||||
/** 已加载数量 */
|
||||
loadedCount: number;
|
||||
/** 总数量 */
|
||||
totalCount: number;
|
||||
/** 已加载字节数 */
|
||||
loadedBytes: number;
|
||||
/** 总字节数 */
|
||||
totalBytes: number;
|
||||
/** 进度百分比(0-1) / Progress value 0-1 */
|
||||
progress: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset loading strategy
|
||||
* 资产加载策略
|
||||
*
|
||||
* - 'file': Load assets directly via HTTP (development, simple builds)
|
||||
* - 'bundle': Load assets from binary bundles (optimized production builds)
|
||||
*
|
||||
* - 'file': 通过 HTTP 直接加载资产(开发模式、简单构建)
|
||||
* - 'bundle': 从二进制包加载资产(优化的生产构建)
|
||||
*/
|
||||
export type AssetLoadStrategy = 'file' | 'bundle';
|
||||
|
||||
/**
|
||||
* Asset catalog entry for runtime lookups
|
||||
* 运行时查找的资产目录条目
|
||||
*
|
||||
* This is a unified format supporting both file-based and bundle-based loading.
|
||||
* 这是一个统一格式,同时支持基于文件和基于包的加载。
|
||||
*/
|
||||
export interface IAssetCatalogEntry {
|
||||
/** 资产 GUID / Asset GUID */
|
||||
guid: AssetGUID;
|
||||
|
||||
/** 资产相对路径 / Asset relative path (e.g., 'assets/textures/player.png') */
|
||||
path: string;
|
||||
|
||||
/** 资产类型 / Asset type */
|
||||
type: AssetType;
|
||||
|
||||
/** 文件大小(字节) / File size in bytes */
|
||||
size: number;
|
||||
|
||||
/** 内容哈希(用于缓存校验) / Content hash for cache validation */
|
||||
hash: string;
|
||||
|
||||
// ===== Bundle mode fields (optional) =====
|
||||
// ===== Bundle 模式字段(可选)=====
|
||||
|
||||
/** 所在包名称(仅 bundle 模式) / Bundle name (bundle mode only) */
|
||||
bundle?: string;
|
||||
|
||||
/** 包内偏移(仅 bundle 模式) / Offset within bundle (bundle mode only) */
|
||||
offset?: number;
|
||||
|
||||
// ===== Optional metadata =====
|
||||
// ===== 可选元数据 =====
|
||||
|
||||
/** 可用变体 / Available variants (platform/quality specific) */
|
||||
variants?: IAssetVariant[];
|
||||
|
||||
/**
|
||||
* Import settings (e.g., sprite slicing for nine-patch)
|
||||
* 导入设置(如九宫格切片信息)
|
||||
*/
|
||||
importSettings?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset bundle info for runtime loading
|
||||
* 运行时加载的资产包信息
|
||||
*/
|
||||
export interface IAssetBundleInfo {
|
||||
/** 包 URL(相对于 catalog) / Bundle URL relative to catalog */
|
||||
url: string;
|
||||
|
||||
/** 包大小(字节) / Bundle size in bytes */
|
||||
size: number;
|
||||
|
||||
/** 内容哈希 / Content hash for integrity check */
|
||||
hash: string;
|
||||
|
||||
/** 是否预加载 / Whether to preload this bundle */
|
||||
preload?: boolean;
|
||||
|
||||
/** 压缩类型 / Compression type */
|
||||
compression?: 'none' | 'gzip' | 'brotli';
|
||||
|
||||
/** 依赖的其他包 / Dependencies on other bundles */
|
||||
dependencies?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime asset catalog
|
||||
* 运行时资产目录
|
||||
*
|
||||
* This is the canonical format for asset catalogs in ESEngine.
|
||||
* Both WebBuildPipeline and AssetPacker generate this format.
|
||||
* 这是 ESEngine 中资产目录的标准格式。
|
||||
* WebBuildPipeline 和 AssetPacker 都生成此格式。
|
||||
*
|
||||
* @example File mode (development/simple builds)
|
||||
* ```json
|
||||
* {
|
||||
* "version": "1.0.0",
|
||||
* "createdAt": 1702185600000,
|
||||
* "loadStrategy": "file",
|
||||
* "entries": {
|
||||
* "550e8400-e29b-41d4-a716-446655440000": {
|
||||
* "guid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
* "path": "assets/textures/player.png",
|
||||
* "type": "texture",
|
||||
* "size": 12345,
|
||||
* "hash": "abc123"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example Bundle mode (optimized production)
|
||||
* ```json
|
||||
* {
|
||||
* "version": "1.0.0",
|
||||
* "createdAt": 1702185600000,
|
||||
* "loadStrategy": "bundle",
|
||||
* "entries": {
|
||||
* "550e8400-e29b-41d4-a716-446655440000": {
|
||||
* "guid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
* "path": "assets/textures/player.png",
|
||||
* "type": "texture",
|
||||
* "size": 12345,
|
||||
* "hash": "abc123",
|
||||
* "bundle": "textures",
|
||||
* "offset": 1024
|
||||
* }
|
||||
* },
|
||||
* "bundles": {
|
||||
* "textures": {
|
||||
* "url": "bundles/textures.bundle",
|
||||
* "size": 1048576,
|
||||
* "hash": "def456",
|
||||
* "preload": true
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface IAssetCatalog {
|
||||
/** 目录版本号 / Catalog version */
|
||||
version: string;
|
||||
|
||||
/** 创建时间戳 / Creation timestamp (Unix ms) */
|
||||
createdAt: number;
|
||||
|
||||
/**
|
||||
* 加载策略 / Loading strategy
|
||||
* - 'file': 直接 HTTP 加载
|
||||
* - 'bundle': 从二进制包加载
|
||||
*/
|
||||
loadStrategy: AssetLoadStrategy;
|
||||
|
||||
/**
|
||||
* 资产条目(GUID 到条目的映射)
|
||||
* Asset entries (GUID to entry mapping)
|
||||
*
|
||||
* Uses Record for JSON serialization compatibility.
|
||||
* 使用 Record 以兼容 JSON 序列化。
|
||||
*/
|
||||
entries: Record<AssetGUID, IAssetCatalogEntry>;
|
||||
|
||||
/**
|
||||
* 包信息(仅 bundle 模式)
|
||||
* Bundle info (bundle mode only)
|
||||
*/
|
||||
bundles?: Record<string, IAssetBundleInfo>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset hot-reload event
|
||||
* 资产热重载事件
|
||||
*/
|
||||
export interface IAssetHotReloadEvent {
|
||||
/** 资产GUID */
|
||||
guid: AssetGUID;
|
||||
/** 资产路径 */
|
||||
path: string;
|
||||
/** 资产类型 */
|
||||
type: AssetType;
|
||||
/** 旧版本哈希 / Previous version hash */
|
||||
oldHash: string;
|
||||
/** 新版本哈希 / New version hash */
|
||||
newHash: string;
|
||||
/** 时间戳 */
|
||||
timestamp: number;
|
||||
}
|
||||
239
packages/engine/asset-system/src/utils/AssetCollector.ts
Normal file
239
packages/engine/asset-system/src/utils/AssetCollector.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 通用资产收集器
|
||||
* Generic Asset Collector
|
||||
*
|
||||
* 从序列化的场景数据中自动收集资产引用。
|
||||
* 支持基于字段名模式和 Property 元数据两种识别方式。
|
||||
*
|
||||
* Automatically collects asset references from serialized scene data.
|
||||
* Supports both field name pattern matching and Property metadata recognition.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 场景资产引用信息(用于构建时收集)
|
||||
* Scene asset reference info (for build-time collection)
|
||||
*/
|
||||
export interface SceneAssetRef {
|
||||
/** 资产 GUID | Asset GUID */
|
||||
guid: string;
|
||||
/** 来源组件类型 | Source component type */
|
||||
componentType: string;
|
||||
/** 来源字段名 | Source field name */
|
||||
fieldName: string;
|
||||
/** 实体名称(可选)| Entity name (optional) */
|
||||
entityName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 资产字段模式配置
|
||||
* Asset field pattern configuration
|
||||
*/
|
||||
export interface AssetFieldPattern {
|
||||
/** 字段名模式(正则表达式)| Field name pattern (regex) */
|
||||
pattern: RegExp;
|
||||
/** 字段类型(用于分类)| Field type (for categorization) */
|
||||
type?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认资产字段模式
|
||||
* Default asset field patterns
|
||||
*
|
||||
* 这些模式用于识别常见的资产引用字段
|
||||
* These patterns are used to identify common asset reference fields
|
||||
*/
|
||||
export const DEFAULT_ASSET_PATTERNS: AssetFieldPattern[] = [
|
||||
// GUID 类字段 | GUID-like fields
|
||||
{ pattern: /^.*[Gg]uid$/, type: 'guid' },
|
||||
{ pattern: /^.*[Aa]sset[Ii]d$/, type: 'guid' },
|
||||
{ pattern: /^.*[Aa]ssetGuid$/, type: 'guid' },
|
||||
|
||||
// 纹理/贴图字段 | Texture fields
|
||||
{ pattern: /^texture$/, type: 'texture' },
|
||||
{ pattern: /^.*[Tt]exture[Pp]ath$/, type: 'texture' },
|
||||
|
||||
// 音频字段 | Audio fields
|
||||
{ pattern: /^clip$/, type: 'audio' },
|
||||
{ pattern: /^.*[Aa]udio[Pp]ath$/, type: 'audio' },
|
||||
|
||||
// 通用路径字段 | Generic path fields
|
||||
{ pattern: /^.*[Pp]ath$/, type: 'path' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 检查值是否像 GUID
|
||||
* Check if value looks like a GUID
|
||||
*/
|
||||
function isGuidLike(value: unknown): value is string {
|
||||
if (typeof value !== 'string') return false;
|
||||
// GUID 格式: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
// 或者简单的包含连字符的长字符串
|
||||
return /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value) ||
|
||||
(value.includes('-') && value.length >= 30 && value.length <= 40);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从组件数据中收集资产引用
|
||||
* Collect asset references from component data
|
||||
*/
|
||||
function collectFromComponentData(
|
||||
componentType: string,
|
||||
data: Record<string, unknown>,
|
||||
patterns: AssetFieldPattern[],
|
||||
entityName?: string
|
||||
): SceneAssetRef[] {
|
||||
const references: SceneAssetRef[] = [];
|
||||
|
||||
for (const [fieldName, value] of Object.entries(data)) {
|
||||
// 检查是否匹配任何资产字段模式
|
||||
// Check if matches any asset field pattern
|
||||
const matchesPattern = patterns.some(p => p.pattern.test(fieldName));
|
||||
|
||||
if (matchesPattern) {
|
||||
// 处理单个值 | Handle single value
|
||||
if (isGuidLike(value)) {
|
||||
references.push({
|
||||
guid: value,
|
||||
componentType,
|
||||
fieldName,
|
||||
entityName
|
||||
});
|
||||
}
|
||||
// 处理数组 | Handle array
|
||||
else if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (isGuidLike(item)) {
|
||||
references.push({
|
||||
guid: item,
|
||||
componentType,
|
||||
fieldName,
|
||||
entityName
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 特殊处理已知的数组字段(如 particleAssets)
|
||||
// Special handling for known array fields (like particleAssets)
|
||||
if (fieldName === 'particleAssets' && Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (isGuidLike(item)) {
|
||||
references.push({
|
||||
guid: item,
|
||||
componentType,
|
||||
fieldName,
|
||||
entityName
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return references;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实体类型定义(支持嵌套 children)
|
||||
* Entity type definition (supports nested children)
|
||||
*/
|
||||
interface EntityData {
|
||||
name?: string;
|
||||
components?: Array<{ type: string; data?: Record<string, unknown> }>;
|
||||
children?: EntityData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归处理实体及其子实体
|
||||
* Recursively process entity and its children
|
||||
*/
|
||||
function collectFromEntity(
|
||||
entity: EntityData,
|
||||
patterns: AssetFieldPattern[],
|
||||
references: SceneAssetRef[]
|
||||
): void {
|
||||
const entityName = entity.name;
|
||||
|
||||
// 处理当前实体的组件 | Process current entity's components
|
||||
if (entity.components) {
|
||||
for (const component of entity.components) {
|
||||
if (!component.data) continue;
|
||||
|
||||
const componentRefs = collectFromComponentData(
|
||||
component.type,
|
||||
component.data,
|
||||
patterns,
|
||||
entityName
|
||||
);
|
||||
|
||||
references.push(...componentRefs);
|
||||
}
|
||||
}
|
||||
|
||||
// 递归处理子实体 | Recursively process children
|
||||
if (entity.children && Array.isArray(entity.children)) {
|
||||
for (const child of entity.children) {
|
||||
collectFromEntity(child, patterns, references);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从序列化的场景数据中收集所有资产引用
|
||||
* Collect all asset references from serialized scene data
|
||||
*
|
||||
* @param sceneData 序列化的场景数据(JSON 对象)| Serialized scene data (JSON object)
|
||||
* @param patterns 资产字段模式(可选,默认使用内置模式)| Asset field patterns (optional, defaults to built-in patterns)
|
||||
* @returns 资产引用列表 | List of asset references
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sceneData = JSON.parse(sceneJson);
|
||||
* const references = collectAssetReferences(sceneData);
|
||||
* for (const ref of references) {
|
||||
* console.log(`Found asset ${ref.guid} in ${ref.componentType}.${ref.fieldName}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function collectAssetReferences(
|
||||
sceneData: { entities?: EntityData[] },
|
||||
patterns: AssetFieldPattern[] = DEFAULT_ASSET_PATTERNS
|
||||
): SceneAssetRef[] {
|
||||
const references: SceneAssetRef[] = [];
|
||||
|
||||
if (!sceneData.entities) {
|
||||
return references;
|
||||
}
|
||||
|
||||
// 遍历顶层实体,递归处理嵌套的子实体
|
||||
// Iterate top-level entities, recursively process nested children
|
||||
for (const entity of sceneData.entities) {
|
||||
collectFromEntity(entity, patterns, references);
|
||||
}
|
||||
|
||||
return references;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从资产引用列表中提取唯一的 GUID 集合
|
||||
* Extract unique GUID set from asset references
|
||||
*/
|
||||
export function extractUniqueGuids(references: SceneAssetRef[]): Set<string> {
|
||||
return new Set(references.map(ref => ref.guid));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按组件类型分组资产引用
|
||||
* Group asset references by component type
|
||||
*/
|
||||
export function groupByComponentType(references: SceneAssetRef[]): Map<string, SceneAssetRef[]> {
|
||||
const groups = new Map<string, SceneAssetRef[]>();
|
||||
|
||||
for (const ref of references) {
|
||||
const existing = groups.get(ref.componentType) || [];
|
||||
existing.push(ref);
|
||||
groups.set(ref.componentType, existing);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
79
packages/engine/asset-system/src/utils/AssetUtils.ts
Normal file
79
packages/engine/asset-system/src/utils/AssetUtils.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Asset Utilities
|
||||
* 资产工具函数
|
||||
*
|
||||
* Provides common utilities for asset management:
|
||||
* - GUID validation and generation (re-exported from core)
|
||||
* - Content hashing
|
||||
* 提供资产管理的通用工具:
|
||||
* - GUID 验证和生成(从 core 重导出)
|
||||
* - 内容哈希
|
||||
*/
|
||||
|
||||
// Re-export GUID utilities from core (single source of truth)
|
||||
// 从 core 重导出 GUID 工具(单一来源)
|
||||
export { generateGUID, isValidGUID } from '@esengine/ecs-framework';
|
||||
|
||||
// ============================================================================
|
||||
// Hash Utilities
|
||||
// 哈希工具
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Compute SHA-256 hash of an ArrayBuffer
|
||||
* 计算 ArrayBuffer 的 SHA-256 哈希
|
||||
*
|
||||
* Returns first 16 hex characters of the hash.
|
||||
* 返回哈希的前 16 个十六进制字符。
|
||||
*
|
||||
* @param buffer - The buffer to hash
|
||||
* @returns Hash string (16 hex characters)
|
||||
*/
|
||||
export async function hashBuffer(buffer: ArrayBuffer): Promise<string> {
|
||||
// Use Web Crypto API if available
|
||||
// 如果可用则使用 Web Crypto API
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
|
||||
}
|
||||
|
||||
// Fallback: simple DJB2 hash
|
||||
// 回退:简单的 DJB2 哈希
|
||||
const view = new Uint8Array(buffer);
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < view.length; i++) {
|
||||
hash = ((hash << 5) + hash) ^ view[i];
|
||||
}
|
||||
return Math.abs(hash).toString(16).padStart(16, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute hash of a string
|
||||
* 计算字符串的哈希
|
||||
*
|
||||
* @param str - The string to hash
|
||||
* @returns Hash string (8 hex characters)
|
||||
*/
|
||||
export function hashString(str: string): string {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) + hash) ^ str.charCodeAt(i);
|
||||
}
|
||||
return Math.abs(hash).toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute content hash from file path and size
|
||||
* 从文件路径和大小计算内容哈希
|
||||
*
|
||||
* This is a lightweight hash for quick comparison, not cryptographically secure.
|
||||
* 这是一个用于快速比较的轻量级哈希,不具有加密安全性。
|
||||
*
|
||||
* @param path - File path
|
||||
* @param size - File size in bytes
|
||||
* @returns Hash string (8 hex characters)
|
||||
*/
|
||||
export function hashFileInfo(path: string, size: number): string {
|
||||
return hashString(`${path}:${size}`);
|
||||
}
|
||||
227
packages/engine/asset-system/src/utils/PathValidator.ts
Normal file
227
packages/engine/asset-system/src/utils/PathValidator.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Path Validator
|
||||
* 路径验证器
|
||||
*
|
||||
* Validates and sanitizes asset paths for security
|
||||
* 验证并清理资产路径以确保安全
|
||||
*/
|
||||
|
||||
/**
|
||||
* Path validation options.
|
||||
* 路径验证选项。
|
||||
*/
|
||||
export interface PathValidationOptions {
|
||||
/** Allow absolute paths (for editor environment). | 允许绝对路径(用于编辑器环境)。 */
|
||||
allowAbsolutePaths?: boolean;
|
||||
/** Allow URLs (http://, https://, asset://). | 允许 URL。 */
|
||||
allowUrls?: boolean;
|
||||
}
|
||||
|
||||
export class PathValidator {
|
||||
// Dangerous path patterns (without absolute path checks)
|
||||
private static readonly DANGEROUS_PATTERNS_STRICT = [
|
||||
/\.\.[/\\]/g, // Path traversal attempts (..)
|
||||
/^[/\\]/, // Absolute paths on Unix
|
||||
/^[a-zA-Z]:[/\\]/, // Absolute paths on Windows
|
||||
/\0/, // Null bytes
|
||||
/%00/, // URL encoded null bytes
|
||||
/\.\.%2[fF]/ // URL encoded path traversal
|
||||
];
|
||||
|
||||
// Dangerous path patterns (allowing absolute paths)
|
||||
private static readonly DANGEROUS_PATTERNS_RELAXED = [
|
||||
/\.\.[/\\]/g, // Path traversal attempts (..)
|
||||
/\0/, // Null bytes
|
||||
/%00/, // URL encoded null bytes
|
||||
/\.\.%2[fF]/ // URL encoded path traversal
|
||||
];
|
||||
|
||||
// Valid path characters for relative paths (alphanumeric, dash, underscore, dot, slash)
|
||||
private static readonly VALID_PATH_REGEX = /^[a-zA-Z0-9\-_./\\@]+$/;
|
||||
|
||||
// Valid path characters for absolute paths (includes colon for Windows drives)
|
||||
private static readonly VALID_ABSOLUTE_PATH_REGEX = /^[a-zA-Z0-9\-_./\\@:]+$/;
|
||||
|
||||
// URL pattern
|
||||
private static readonly URL_REGEX = /^(https?|asset|blob|data):\/\//;
|
||||
|
||||
// Maximum path length
|
||||
private static readonly MAX_PATH_LENGTH = 1024;
|
||||
|
||||
/** Global options for path validation. | 路径验证的全局选项。 */
|
||||
private static _globalOptions: PathValidationOptions = {
|
||||
allowAbsolutePaths: false,
|
||||
allowUrls: true
|
||||
};
|
||||
|
||||
/**
|
||||
* Set global validation options.
|
||||
* 设置全局验证选项。
|
||||
*/
|
||||
static setGlobalOptions(options: PathValidationOptions): void {
|
||||
this._globalOptions = { ...this._globalOptions, ...options };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current global options.
|
||||
* 获取当前全局选项。
|
||||
*/
|
||||
static getGlobalOptions(): PathValidationOptions {
|
||||
return { ...this._globalOptions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a path is safe
|
||||
* 验证路径是否安全
|
||||
*/
|
||||
static validate(path: string, options?: PathValidationOptions): { valid: boolean; reason?: string } {
|
||||
const opts = { ...this._globalOptions, ...options };
|
||||
|
||||
// Check for null/undefined/empty
|
||||
if (!path || typeof path !== 'string') {
|
||||
return { valid: false, reason: 'Path is empty or invalid' };
|
||||
}
|
||||
|
||||
// Check length
|
||||
if (path.length > this.MAX_PATH_LENGTH) {
|
||||
return { valid: false, reason: `Path exceeds maximum length of ${this.MAX_PATH_LENGTH} characters` };
|
||||
}
|
||||
|
||||
// Allow URLs if enabled
|
||||
if (opts.allowUrls && this.URL_REGEX.test(path)) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Choose patterns based on options
|
||||
const patterns = opts.allowAbsolutePaths
|
||||
? this.DANGEROUS_PATTERNS_RELAXED
|
||||
: this.DANGEROUS_PATTERNS_STRICT;
|
||||
|
||||
// Check for dangerous patterns
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(path)) {
|
||||
return { valid: false, reason: 'Path contains dangerous pattern' };
|
||||
}
|
||||
}
|
||||
|
||||
// Check for valid characters
|
||||
const validCharsRegex = opts.allowAbsolutePaths
|
||||
? this.VALID_ABSOLUTE_PATH_REGEX
|
||||
: this.VALID_PATH_REGEX;
|
||||
|
||||
if (!validCharsRegex.test(path)) {
|
||||
return { valid: false, reason: 'Path contains invalid characters' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a path
|
||||
* 清理路径
|
||||
*/
|
||||
static sanitize(path: string): string {
|
||||
if (!path || typeof path !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Remove dangerous patterns
|
||||
let sanitized = path;
|
||||
|
||||
// Remove path traversal (apply repeatedly until fully removed)
|
||||
let prev;
|
||||
do {
|
||||
prev = sanitized;
|
||||
sanitized = sanitized.replace(/\.\.[/\\]/g, '');
|
||||
} while (sanitized !== prev);
|
||||
|
||||
// Remove leading slashes
|
||||
sanitized = sanitized.replace(/^[/\\]+/, '');
|
||||
|
||||
// Remove null bytes
|
||||
sanitized = sanitized.replace(/\0/g, '');
|
||||
sanitized = sanitized.replace(/%00/g, '');
|
||||
|
||||
// Remove invalid Windows characters
|
||||
sanitized = sanitized.replace(/[<>:"|?*]/g, '_');
|
||||
|
||||
// Normalize slashes
|
||||
sanitized = sanitized.replace(/\\/g, '/');
|
||||
|
||||
// Remove double slashes
|
||||
sanitized = sanitized.replace(/\/+/g, '/');
|
||||
|
||||
// Trim whitespace
|
||||
sanitized = sanitized.trim();
|
||||
|
||||
// Truncate if too long
|
||||
if (sanitized.length > this.MAX_PATH_LENGTH) {
|
||||
sanitized = sanitized.substring(0, this.MAX_PATH_LENGTH);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path is trying to escape the base directory
|
||||
* 检查路径是否试图逃离基础目录
|
||||
*/
|
||||
static isPathTraversal(path: string): boolean {
|
||||
const normalized = path.replace(/\\/g, '/');
|
||||
return normalized.includes('../') || normalized.includes('..\\');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a path for consistent handling
|
||||
* 规范化路径以便一致处理
|
||||
*/
|
||||
static normalize(path: string): string {
|
||||
if (!path) return '';
|
||||
|
||||
// Sanitize first
|
||||
let normalized = this.sanitize(path);
|
||||
|
||||
// Convert backslashes to forward slashes
|
||||
normalized = normalized.replace(/\\/g, '/');
|
||||
|
||||
// Remove trailing slash (except for root)
|
||||
if (normalized.length > 1 && normalized.endsWith('/')) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join path segments safely
|
||||
* 安全地连接路径段
|
||||
*/
|
||||
static join(...segments: string[]): string {
|
||||
const validSegments = segments
|
||||
.filter((s) => s && typeof s === 'string')
|
||||
.map((s) => this.sanitize(s))
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
if (validSegments.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return this.normalize(validSegments.join('/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension safely
|
||||
* 安全地获取文件扩展名
|
||||
*/
|
||||
static getExtension(path: string): string {
|
||||
const sanitized = this.sanitize(path);
|
||||
const lastDot = sanitized.lastIndexOf('.');
|
||||
const lastSlash = sanitized.lastIndexOf('/');
|
||||
|
||||
if (lastDot > lastSlash && lastDot > 0) {
|
||||
return sanitized.substring(lastDot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
81
packages/engine/asset-system/src/utils/UVHelper.ts
Normal file
81
packages/engine/asset-system/src/utils/UVHelper.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* UV Coordinate Helper
|
||||
* UV 坐标辅助工具
|
||||
*
|
||||
* 引擎使用图像坐标系:
|
||||
* Engine uses image coordinate system:
|
||||
* - 原点 (0, 0) 在左上角 | Origin at top-left
|
||||
* - V 轴向下增长 | V-axis increases downward
|
||||
* - UV 格式:[u0, v0, u1, v1] 其中 v0 < v1
|
||||
*/
|
||||
export class UVHelper {
|
||||
/**
|
||||
* Calculate UV coordinates for a texture region
|
||||
* 计算纹理区域的 UV 坐标
|
||||
*/
|
||||
static calculateUV(
|
||||
imageRect: { x: number; y: number; width: number; height: number },
|
||||
textureSize: { width: number; height: number }
|
||||
): [number, number, number, number] {
|
||||
const { x, y, width, height } = imageRect;
|
||||
const { width: tw, height: th } = textureSize;
|
||||
|
||||
return [
|
||||
x / tw, // u0
|
||||
y / th, // v0
|
||||
(x + width) / tw, // u1
|
||||
(y + height) / th // v1
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate UV coordinates for a tile in a tileset
|
||||
* 计算 tileset 中某个 tile 的 UV 坐标
|
||||
*/
|
||||
static calculateTileUV(
|
||||
tileIndex: number,
|
||||
tilesetInfo: {
|
||||
columns: number;
|
||||
tileWidth: number;
|
||||
tileHeight: number;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
margin?: number;
|
||||
spacing?: number;
|
||||
}
|
||||
): [number, number, number, number] | null {
|
||||
if (tileIndex < 0) return null;
|
||||
|
||||
const {
|
||||
columns,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
margin = 0,
|
||||
spacing = 0
|
||||
} = tilesetInfo;
|
||||
|
||||
const col = tileIndex % columns;
|
||||
const row = Math.floor(tileIndex / columns);
|
||||
const x = margin + col * (tileWidth + spacing);
|
||||
const y = margin + row * (tileHeight + spacing);
|
||||
|
||||
return this.calculateUV(
|
||||
{ x, y, width: tileWidth, height: tileHeight },
|
||||
{ width: imageWidth, height: imageHeight }
|
||||
);
|
||||
}
|
||||
|
||||
static validateUV(uv: [number, number, number, number]): boolean {
|
||||
const [u0, v0, u1, v1] = uv;
|
||||
return u0 >= 0 && u0 <= 1 && u1 >= 0 && u1 <= 1 &&
|
||||
v0 >= 0 && v0 <= 1 && v1 >= 0 && v1 <= 1 &&
|
||||
u0 < u1 && v0 < v1;
|
||||
}
|
||||
|
||||
static debugPrint(uv: [number, number, number, number], label?: string): void {
|
||||
const prefix = label ? `[${label}] ` : '';
|
||||
console.log(`${prefix}UV: [${uv.map(n => n.toFixed(4)).join(', ')}]`);
|
||||
}
|
||||
}
|
||||
22
packages/engine/asset-system/tsconfig.build.json
Normal file
22
packages/engine/asset-system/tsconfig.build.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
37
packages/engine/asset-system/tsconfig.json
Normal file
37
packages/engine/asset-system/tsconfig.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": false,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"sourceMap": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"alwaysStrict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
7
packages/engine/asset-system/tsup.config.ts
Normal file
7
packages/engine/asset-system/tsup.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
import { runtimeOnlyPreset } from '../../tools/build-config/src/presets/plugin-tsup';
|
||||
|
||||
export default defineConfig({
|
||||
...runtimeOnlyPreset(),
|
||||
tsconfig: 'tsconfig.build.json'
|
||||
});
|
||||
Reference in New Issue
Block a user