From 1fb702169e697700181e0d6001a2ca044d7d38e8 Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Sat, 6 Dec 2025 14:47:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(asset-system):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=8A=A0=E8=BD=BD=E5=92=8C=E5=9C=BA=E6=99=AF?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E7=AE=A1=E7=90=86=20(#289)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 AudioLoader 支持音频资源加载 (mp3/wav/ogg/m4a/flac/aac) - EngineIntegration 添加音频资源加载/卸载支持 - EngineIntegration 添加数据(JSON)资源加载/卸载支持 - SceneResourceManager 实现完整的引用计数机制 - SceneResourceManager 实现场景资源卸载,仅卸载无引用的资源 - 添加资源统计和引用计数查询接口 --- packages/asset-system/src/index.ts | 1 + .../src/integration/EngineIntegration.ts | 309 +++++++++++++++++- .../src/loaders/AssetLoaderFactory.ts | 4 + .../asset-system/src/loaders/AudioLoader.ts | 102 ++++++ .../src/services/SceneResourceManager.ts | 217 +++++++++++- 5 files changed, 618 insertions(+), 15 deletions(-) create mode 100644 packages/asset-system/src/loaders/AudioLoader.ts diff --git a/packages/asset-system/src/index.ts b/packages/asset-system/src/index.ts index f1642e23..e6fac8d4 100644 --- a/packages/asset-system/src/index.ts +++ b/packages/asset-system/src/index.ts @@ -40,6 +40,7 @@ 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'; // Integration export { EngineIntegration } from './integration/EngineIntegration'; diff --git a/packages/asset-system/src/integration/EngineIntegration.ts b/packages/asset-system/src/integration/EngineIntegration.ts index d653176d..c521008f 100644 --- a/packages/asset-system/src/integration/EngineIntegration.ts +++ b/packages/asset-system/src/integration/EngineIntegration.ts @@ -5,7 +5,7 @@ import { AssetManager } from '../core/AssetManager'; import { AssetGUID } from '../types/AssetTypes'; -import { ITextureAsset } from '../interfaces/IAssetLoader'; +import { ITextureAsset, IAudioAsset, IJsonAsset } from '../interfaces/IAssetLoader'; import { globalPathResolver } from '../core/AssetPathResolver'; /** @@ -38,6 +38,26 @@ export interface IEngineBridge { getTextureInfo(id: number): { 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; +} + /** * Asset system engine integration * 资产系统引擎集成 @@ -48,6 +68,18 @@ export class EngineIntegration { private _textureIdMap = new Map(); private _pathToTextureId = new Map(); + // Audio resource mappings | 音频资源映射 + private _audioIdMap = new Map(); + private _pathToAudioId = new Map(); + private _audioAssets = new Map(); + private static _nextAudioId = 1; + + // Data resource mappings | 数据资源映射 + private _dataIdMap = new Map(); + private _pathToDataId = new Map(); + private _dataAssets = new Map(); + private static _nextDataId = 1; + constructor(assetManager: AssetManager, engineBridge?: IEngineBridge) { this._assetManager = assetManager; this._engineBridge = engineBridge; @@ -170,14 +202,241 @@ export class EngineIntegration { * @returns 路径到运行时 ID 的映射 / Map of paths to runtime IDs */ async loadResourcesBatch(paths: string[], type: 'texture' | 'audio' | 'font' | 'data'): Promise> { - // 目前只支持纹理 / Currently only supports textures - if (type === 'texture') { - return this.loadTexturesBatch(paths); + 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 { + // 检查缓存 / Check cache + const existingId = this._pathToAudioId.get(audioPath); + if (existingId) { + return existingId; } - // 其他资源类型暂未实现 / Other resource types not yet implemented - console.warn(`[EngineIntegration] Resource type '${type}' not yet supported`); - return new Map(); + // 通过资产系统加载 / Load through asset system + const result = await this._assetManager.loadAssetByPath(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> { + const results = new Map(); + + // 收集需要加载的音频 / 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 { + // 检查缓存 / Check cache + const existingId = this._pathToDataId.get(dataPath); + if (existingId) { + return existingId; + } + + // 通过资产系统加载 / Load through asset system + const result = await this._assetManager.loadAssetByPath(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> { + const results = new Map(); + + // 收集需要加载的数据 / 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(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; + } + } + } } /** @@ -233,15 +492,49 @@ export class EngineIntegration { this._pathToTextureId.clear(); } + /** + * 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 + loadedTextures: this._pathToTextureId.size, + loadedAudio: this._audioAssets.size, + loadedData: this._dataAssets.size }; } } diff --git a/packages/asset-system/src/loaders/AssetLoaderFactory.ts b/packages/asset-system/src/loaders/AssetLoaderFactory.ts index a2be2092..30549135 100644 --- a/packages/asset-system/src/loaders/AssetLoaderFactory.ts +++ b/packages/asset-system/src/loaders/AssetLoaderFactory.ts @@ -9,6 +9,7 @@ import { TextureLoader } from './TextureLoader'; import { JsonLoader } from './JsonLoader'; import { TextLoader } from './TextLoader'; import { BinaryLoader } from './BinaryLoader'; +import { AudioLoader } from './AudioLoader'; /** * Asset loader factory @@ -38,6 +39,9 @@ export class AssetLoaderFactory implements IAssetLoaderFactory { // 二进制加载器 / Binary loader this._loaders.set(AssetType.Binary, new BinaryLoader()); + + // 音频加载器 / Audio loader + this._loaders.set(AssetType.Audio, new AudioLoader()); } /** diff --git a/packages/asset-system/src/loaders/AudioLoader.ts b/packages/asset-system/src/loaders/AudioLoader.ts new file mode 100644 index 00000000..f65ec2f5 --- /dev/null +++ b/packages/asset-system/src/loaders/AudioLoader.ts @@ -0,0 +1,102 @@ +/** + * 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 { + 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) { + AudioLoader._audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); + } + return AudioLoader._audioContext; + } + + /** + * Parse audio from content. + * 从内容解析音频。 + */ + async parse(content: IAssetContent, _context: IAssetParseContext): Promise { + 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 { + const ctx = AudioLoader.getAudioContext(); + if (ctx.state === 'suspended') { + await ctx.resume(); + } + } + + /** + * Get the shared AudioContext instance + * 获取共享的 AudioContext 实例 + */ + static get audioContext(): AudioContext { + return AudioLoader.getAudioContext(); + } +} diff --git a/packages/asset-system/src/services/SceneResourceManager.ts b/packages/asset-system/src/services/SceneResourceManager.ts index 63b9918b..1547437c 100644 --- a/packages/asset-system/src/services/SceneResourceManager.ts +++ b/packages/asset-system/src/services/SceneResourceManager.ts @@ -22,11 +22,58 @@ export interface IResourceLoader { * @returns 路径到运行时 ID 的映射 / Map of paths to runtime IDs */ loadResourcesBatch(paths: string[], type: ResourceReference['type']): Promise>; + + /** + * 卸载纹理资源(可选) + * 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; } export class SceneResourceManager { private resourceLoader: IResourceLoader | null = null; + /** + * 资源引用计数表 + * Resource reference count table + * + * Key: resource path, Value: reference count entry + */ + private _resourceRefCounts = new Map(); + + /** + * 场景到其使用的资源路径的映射 + * Map of scene name to resource paths used by that scene + */ + private _sceneResources = new Map>(); + /** * 设置资源加载器实现 * Set the resource loader implementation @@ -53,6 +100,8 @@ export class SceneResourceManager { * 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 @@ -63,6 +112,8 @@ export class SceneResourceManager { return; } + const sceneName = scene.name; + // 从组件收集所有资源引用 / Collect all resource references from components const resourceRefs = this.collectResourceReferences(scene); @@ -91,6 +142,9 @@ export class SceneResourceManager { // 合并到总映射表 / 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); @@ -99,6 +153,58 @@ export class SceneResourceManager { // 将资源 ID 分配回组件 / Assign resource IDs back to components this.assignResourceIds(scene, allResourceIds); + + // 记录场景使用的资源 / Record resources used by scene + const scenePaths = new Set(); + 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; } /** @@ -141,15 +247,112 @@ export class SceneResourceManager { * 卸载场景使用的所有资源 * Unload all resources used by a scene * - * 在场景销毁时调用 - * Called when a scene is being destroyed + * 在场景销毁时调用,只会卸载不再被其他场景引用的资源 + * 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 { - // TODO: 实现资源卸载 / Implement resource unloading - // 需要跟踪资源引用计数,仅在不再使用时卸载 - // Need to track resource reference counts and only unload when no longer used - console.log('[SceneResourceManager] Scene resource unloading not yet implemented'); + async unloadSceneResources(scene: Scene): Promise { + 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; + } { + const resourcesByType = new Map(); + + 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(); } }