feat: 纹理路径稳定 ID 与架构改进 (#305)
* feat(asset-system): 实现路径稳定 ID 生成器 使用 FNV-1a hash 算法为纹理生成稳定的运行时 ID: - 新增 _pathIdCache 静态缓存,跨 Play/Stop 循环保持稳定 - 新增 getStableIdForPath() 方法,相同路径永远返回相同 ID - 修改 loadTextureForComponent/loadTextureByGuid 使用稳定 ID - clearTextureMappings() 不再清除 _pathIdCache 这解决了 Play/Stop 后纹理 ID 失效的根本问题。 * fix(runtime-core): 移除 Play/Stop 循环中的 clearTextureMappings 调用 使用路径稳定 ID 后,不再需要在快照保存/恢复时清除纹理缓存: - saveSceneSnapshot() 移除 clearTextureMappings() 调用 - restoreSceneSnapshot() 移除 clearTextureMappings() 调用 - 组件保存的 textureId 在 Play/Stop 后仍然有效 * fix(editor-core): 修复场景切换时的资源泄漏 在 openScene() 加载新场景前先卸载旧场景资源: - 调用 sceneResourceManager.unloadSceneResources() 释放旧资源 - 使用引用计数机制,仅卸载不再被引用的资源 - 路径稳定 ID 缓存不受影响,保持 ID 稳定性 * fix(runtime-core): 修复 PluginManager 组件注册类型错误 将 ComponentRegistry 类改为 GlobalComponentRegistry 实例: - registerComponents() 期望 IComponentRegistry 接口实例 - GlobalComponentRegistry 是 ComponentRegistry 的全局实例 * refactor(core): 提取 IComponentRegistry 接口 将组件注册表抽象为接口,支持场景级组件注册: - 新增 IComponentRegistry 接口定义 - Scene 持有独立的 componentRegistry 实例 - 支持从 GlobalComponentRegistry 克隆 - 各系统支持传入自定义注册表 * refactor(engine-core): 改进插件服务注册机制 - 更新 IComponentRegistry 类型引用 - 优化 PluginServiceRegistry 服务管理 * refactor(modules): 适配新的组件注册接口 更新各模块 RuntimeModule 使用 IComponentRegistry 接口: - audio, behavior-tree, camera - sprite, tilemap, world-streaming * fix(physics-rapier2d): 修复物理插件组件注册 - PhysicsEditorPlugin 添加 runtimeModule 引用 - 适配 IComponentRegistry 接口 - 修复物理组件在场景加载时未注册的问题 * feat(editor-core): 添加 UserCodeService 就绪信号机制 - 新增 waitForReady()/signalReady() API - 支持等待用户脚本编译完成 - 解决场景加载时组件未注册的时序问题 * fix(editor-app): 在编译完成后调用 signalReady() 确保用户脚本编译完成后发出就绪信号: - 编译成功后调用 userCodeService.signalReady() - 编译失败也要发出信号,避免阻塞场景加载 * feat(editor-core): 改进编辑器核心服务 - EntityStoreService 添加调试日志 - AssetRegistryService 优化资产注册 - PluginManager 改进插件管理 - IFileAPI 添加 getFileMtime 接口 * feat(engine): 改进 Rust 纹理管理器 - 支持任意 ID 的纹理加载(非递增) - 添加纹理状态追踪 API - 优化纹理缓存清理机制 - 更新 TypeScript 绑定 * feat(ui): 添加场景切换和文本闪烁组件 新增组件: - SceneLoadTriggerComponent: 场景切换触发器 - TextBlinkComponent: 文本闪烁效果 新增系统: - SceneLoadTriggerSystem: 处理场景切换逻辑 - TextBlinkSystem: 处理文本闪烁动画 其他改进: - UIRuntimeModule 适配新组件注册接口 - UI 渲染系统优化 * feat(editor-app): 添加外部文件修改检测 - 新增 ExternalModificationDialog 组件 - TauriFileAPI 支持 getFileMtime - 场景文件被外部修改时提示用户 * feat(editor-app): 添加渲染调试面板 - 新增 RenderDebugService 和调试面板 UI - App/ContentBrowser 添加调试日志 - TitleBar/Viewport 优化 - DialogManager 改进 * refactor(editor-app): 编辑器服务和组件优化 - EngineService 改进引擎集成 - EditorEngineSync 同步优化 - AssetFileInspector 改进 - VectorFieldEditors 优化 - InstantiatePrefabCommand 改进 * feat(i18n): 更新国际化翻译 - 添加新功能相关翻译 - 更新中文、英文、西班牙文 * feat(tauri): 添加文件修改时间查询命令 - 新增 get_file_mtime 命令 - 支持检测文件外部修改 * refactor(particle): 粒子系统改进 - 适配新的组件注册接口 - ParticleSystem 优化 - 添加单元测试 * refactor(platform): 平台适配层优化 - BrowserRuntime 改进 - 新增 RuntimeSceneManager 服务 - 导出优化 * refactor(asset-system-editor): 资产元数据改进 - AssetMetaFile 优化 - 导出调整 * fix(asset-system): 移除未使用的 TextureLoader 导入 * fix(tests): 更新测试以使用 GlobalComponentRegistry 实例 修复多个测试文件以适配 ComponentRegistry 从静态类变为实例类的变更: - ComponentStorage.test.ts: 使用 GlobalComponentRegistry.reset() - EntitySerializer.test.ts: 使用 GlobalComponentRegistry 实例 - IncrementalSerialization.test.ts: 使用 GlobalComponentRegistry 实例 - SceneSerializer.test.ts: 使用 GlobalComponentRegistry 实例 - ComponentRegistry.extended.test.ts: 使用 GlobalComponentRegistry,同时注册到 scene.componentRegistry - SystemTypes.test.ts: 在 Scene 创建前注册组件 - QuerySystem.test.ts: mockScene 添加 componentRegistry
This commit is contained in:
@@ -278,12 +278,20 @@ export class EditorEngineSync {
|
||||
* Update sprite in engine entity.
|
||||
* 更新引擎实体的精灵。
|
||||
*
|
||||
* Note: Texture loading is now handled automatically by EngineRenderSystem.
|
||||
* 注意:纹理加载现在由EngineRenderSystem自动处理。
|
||||
* Preloads textures when textureGuid changes to ensure they're available for rendering.
|
||||
* 当 textureGuid 变更时预加载纹理以确保渲染时可用。
|
||||
*/
|
||||
private updateSprite(entity: Entity, sprite: SpriteComponent, property: string, value: any): void {
|
||||
// No manual texture loading needed - EngineRenderSystem handles it
|
||||
// 不需要手动加载纹理 - EngineRenderSystem会处理
|
||||
// When textureGuid changes, trigger texture preload
|
||||
// 当 textureGuid 变更时,触发纹理预加载
|
||||
if (property === 'textureGuid' && value) {
|
||||
const bridge = this.engineService.getBridge();
|
||||
if (bridge) {
|
||||
// Preload the texture so it's ready for the next render frame
|
||||
// 预加载纹理以便下一渲染帧时可用
|
||||
bridge.getOrLoadTextureByPath(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Core, Scene, Entity, SceneSerializer, ProfilerSDK, createLogger, Plugin
|
||||
import { CameraConfig, EngineBridgeToken, RenderSystemToken, EngineIntegrationToken } from '@esengine/ecs-engine-bindgen';
|
||||
import { TransformComponent, TransformTypeToken, CanvasElementToken } from '@esengine/engine-core';
|
||||
import { SpriteComponent, SpriteAnimatorComponent, SpriteAnimatorSystemToken } from '@esengine/sprite';
|
||||
import { ParticleSystemComponent } from '@esengine/particle';
|
||||
import { invalidateUIRenderCaches, UIRenderProviderToken, UIInputSystemToken } from '@esengine/ui';
|
||||
import * as esEngine from '@esengine/engine';
|
||||
import {
|
||||
@@ -462,6 +463,43 @@ export class EngineService {
|
||||
if (this._runtime?.bridge) {
|
||||
this._engineIntegration = new EngineIntegration(this._assetManager, this._runtime.bridge);
|
||||
|
||||
// 为 EngineIntegration 设置使用 Tauri URL 转换的 PathResolver
|
||||
// Set PathResolver for EngineIntegration that uses Tauri URL conversion
|
||||
this._engineIntegration.setPathResolver({
|
||||
catalogToRuntime: (catalogPath: string): string => {
|
||||
// 空路径直接返回
|
||||
if (!catalogPath) return catalogPath;
|
||||
|
||||
// 已经是 URL 则直接返回
|
||||
if (catalogPath.startsWith('http://') ||
|
||||
catalogPath.startsWith('https://') ||
|
||||
catalogPath.startsWith('data:') ||
|
||||
catalogPath.startsWith('asset://')) {
|
||||
return catalogPath;
|
||||
}
|
||||
|
||||
// 使用 pathTransformerFn 转换路径为 Tauri URL
|
||||
// 路径应该是相对于项目目录的,如 'assets/sparkle_yellow.png'
|
||||
let fullPath = catalogPath;
|
||||
// 如果路径不以 'assets/' 开头,添加前缀
|
||||
if (!catalogPath.startsWith('assets/') && !catalogPath.startsWith('assets\\')) {
|
||||
fullPath = `assets/${catalogPath}`;
|
||||
}
|
||||
return pathTransformerFn(fullPath);
|
||||
},
|
||||
editorToCatalog: (editorPath: string, projectRoot: string): string => {
|
||||
return editorPath; // 不需要在此上下文中使用
|
||||
},
|
||||
setBaseUrl: () => {},
|
||||
getBaseUrl: () => '',
|
||||
normalize: (path: string) => path.replace(/\\/g, '/'),
|
||||
isAbsoluteUrl: (path: string) =>
|
||||
path.startsWith('http://') ||
|
||||
path.startsWith('https://') ||
|
||||
path.startsWith('data:') ||
|
||||
path.startsWith('asset://')
|
||||
});
|
||||
|
||||
this._sceneResourceManager = new SceneResourceManager();
|
||||
this._sceneResourceManager.setResourceLoader(this._engineIntegration);
|
||||
|
||||
@@ -712,10 +750,15 @@ export class EngineService {
|
||||
return convertFileSrc(absolutePath);
|
||||
}
|
||||
return relativePath;
|
||||
} else {
|
||||
// GUID not found in registry - this could be a timing issue where asset
|
||||
// was just added but not yet registered. Log for debugging.
|
||||
// GUID 在注册表中未找到 - 可能是资源刚添加但尚未注册的时序问题
|
||||
console.warn(`[AssetPathResolver] GUID not found in registry: ${guidOrPath}. Asset may not be registered yet.`);
|
||||
}
|
||||
}
|
||||
// GUID not found, return original value
|
||||
// 未找到 GUID,返回原值
|
||||
// GUID not found, return original value (will result in white block)
|
||||
// 未找到 GUID,返回原值(会显示白块)
|
||||
return guidOrPath;
|
||||
}
|
||||
|
||||
@@ -1029,6 +1072,19 @@ export class EngineService {
|
||||
// 清除 UI 渲染缓存
|
||||
invalidateUIRenderCaches();
|
||||
|
||||
// Reset particle component textureIds before loading resources
|
||||
// 在加载资源前重置粒子组件的 textureId
|
||||
// This ensures ParticleUpdateSystem will reload textures
|
||||
// 这确保 ParticleUpdateSystem 会重新加载纹理
|
||||
if (this._runtime.scene) {
|
||||
for (const entity of this._runtime.scene.entities.buffer) {
|
||||
const particleComponent = entity.getComponent(ParticleSystemComponent);
|
||||
if (particleComponent) {
|
||||
particleComponent.textureId = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载场景资源
|
||||
if (this._sceneResourceManager && this._runtime.scene) {
|
||||
await this._sceneResourceManager.loadSceneResources(this._runtime.scene);
|
||||
@@ -1057,6 +1113,21 @@ export class EngineService {
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load scene resources (textures, audio, etc.)
|
||||
* 加载场景资源(纹理、音频等)
|
||||
*
|
||||
* Used by runtime scene switching in play mode.
|
||||
* 用于 Play 模式下的运行时场景切换。
|
||||
*/
|
||||
async loadSceneResources(): Promise<void> {
|
||||
const scene = this._runtime?.scene;
|
||||
if (!this._sceneResourceManager || !scene) {
|
||||
return;
|
||||
}
|
||||
await this._sceneResourceManager.loadSceneResources(scene);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a snapshot exists.
|
||||
*/
|
||||
|
||||
591
packages/editor-app/src/services/RenderDebugService.ts
Normal file
591
packages/editor-app/src/services/RenderDebugService.ts
Normal file
@@ -0,0 +1,591 @@
|
||||
/**
|
||||
* 渲染调试服务
|
||||
* Render Debug Service
|
||||
*
|
||||
* 从引擎收集渲染调试数据
|
||||
* Collects render debug data from the engine
|
||||
*/
|
||||
|
||||
import { Core, Entity } from '@esengine/ecs-framework';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { SpriteComponent } from '@esengine/sprite';
|
||||
import { ParticleSystemComponent } from '@esengine/particle';
|
||||
import { UITransformComponent, UIRenderComponent, UITextComponent } from '@esengine/ui';
|
||||
import { AssetRegistryService, ProjectService } from '@esengine/editor-core';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
/**
|
||||
* 纹理调试信息
|
||||
* Texture debug info
|
||||
*/
|
||||
export interface TextureDebugInfo {
|
||||
id: number;
|
||||
path: string;
|
||||
width: number;
|
||||
height: number;
|
||||
state: 'loading' | 'ready' | 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprite 调试信息
|
||||
* Sprite debug info
|
||||
*/
|
||||
export interface SpriteDebugInfo {
|
||||
entityId: number;
|
||||
entityName: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
textureId: number;
|
||||
texturePath: string;
|
||||
/** 预解析的纹理 URL(可直接用于 img src)| Pre-resolved texture URL (can be used directly in img src) */
|
||||
textureUrl?: string;
|
||||
uv: [number, number, number, number];
|
||||
color: string;
|
||||
alpha: number;
|
||||
sortingLayer: string;
|
||||
orderInLayer: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 粒子调试信息
|
||||
* Particle debug info
|
||||
*/
|
||||
export interface ParticleDebugInfo {
|
||||
entityId: number;
|
||||
entityName: string;
|
||||
systemName: string;
|
||||
isPlaying: boolean;
|
||||
activeCount: number;
|
||||
maxParticles: number;
|
||||
textureId: number;
|
||||
texturePath: string;
|
||||
/** 预解析的纹理 URL(可直接用于 img src)| Pre-resolved texture URL (can be used directly in img src) */
|
||||
textureUrl?: string;
|
||||
textureSheetAnimation: {
|
||||
enabled: boolean;
|
||||
tilesX: number;
|
||||
tilesY: number;
|
||||
totalFrames: number;
|
||||
} | null;
|
||||
sampleParticles: Array<{
|
||||
index: number;
|
||||
x: number;
|
||||
y: number;
|
||||
frame: number;
|
||||
uv: [number, number, number, number];
|
||||
age: number;
|
||||
lifetime: number;
|
||||
size: number;
|
||||
color: string;
|
||||
alpha: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI 元素调试信息
|
||||
* UI element debug info
|
||||
*/
|
||||
export interface UIDebugInfo {
|
||||
entityId: number;
|
||||
entityName: string;
|
||||
type: 'rect' | 'image' | 'text' | 'ninepatch' | 'circle' | 'rounded-rect' | 'unknown';
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
worldX: number;
|
||||
worldY: number;
|
||||
rotation: number;
|
||||
visible: boolean;
|
||||
alpha: number;
|
||||
sortingLayer: string;
|
||||
orderInLayer: number;
|
||||
textureGuid?: string;
|
||||
textureUrl?: string;
|
||||
backgroundColor?: string;
|
||||
text?: string;
|
||||
fontSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染调试快照
|
||||
* Render debug snapshot
|
||||
*/
|
||||
export interface RenderDebugSnapshot {
|
||||
timestamp: number;
|
||||
frameNumber: number;
|
||||
textures: TextureDebugInfo[];
|
||||
sprites: SpriteDebugInfo[];
|
||||
particles: ParticleDebugInfo[];
|
||||
uiElements: UIDebugInfo[];
|
||||
stats: {
|
||||
totalSprites: number;
|
||||
totalParticles: number;
|
||||
totalUIElements: number;
|
||||
totalTextures: number;
|
||||
drawCalls: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染调试服务
|
||||
* Render Debug Service
|
||||
*/
|
||||
export class RenderDebugService {
|
||||
private static _instance: RenderDebugService | null = null;
|
||||
private _frameNumber: number = 0;
|
||||
private _enabled: boolean = false;
|
||||
private _snapshots: RenderDebugSnapshot[] = [];
|
||||
private _maxSnapshots: number = 60;
|
||||
|
||||
// 引擎引用 | Engine reference
|
||||
private _engineBridge: any = null;
|
||||
|
||||
static getInstance(): RenderDebugService {
|
||||
if (!RenderDebugService._instance) {
|
||||
RenderDebugService._instance = new RenderDebugService();
|
||||
}
|
||||
return RenderDebugService._instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置引擎桥接
|
||||
* Set engine bridge
|
||||
*/
|
||||
setEngineBridge(bridge: any): void {
|
||||
this._engineBridge = bridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用/禁用调试
|
||||
* Enable/disable debugging
|
||||
*/
|
||||
setEnabled(enabled: boolean): void {
|
||||
this._enabled = enabled;
|
||||
if (!enabled) {
|
||||
this._snapshots = [];
|
||||
}
|
||||
}
|
||||
|
||||
get enabled(): boolean {
|
||||
return this._enabled;
|
||||
}
|
||||
|
||||
// 纹理 base64 缓存 | Texture base64 cache
|
||||
private _textureCache = new Map<string, string>();
|
||||
private _texturePending = new Set<string>();
|
||||
|
||||
/**
|
||||
* 解析纹理 GUID 为 base64 data URL(从缓存获取)
|
||||
* Resolve texture GUID to base64 data URL (from cache)
|
||||
*/
|
||||
private _resolveTextureUrl(textureGuid: string | null | undefined): string | undefined {
|
||||
if (!textureGuid) return undefined;
|
||||
|
||||
// 从缓存获取 | Get from cache
|
||||
if (this._textureCache.has(textureGuid)) {
|
||||
console.log('[RenderDebugService] Texture from cache:', textureGuid);
|
||||
return this._textureCache.get(textureGuid);
|
||||
}
|
||||
|
||||
// 如果正在加载中,返回 undefined | If loading, return undefined
|
||||
if (this._texturePending.has(textureGuid)) {
|
||||
console.log('[RenderDebugService] Texture loading:', textureGuid);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 异步加载纹理 | Load texture asynchronously
|
||||
console.log('[RenderDebugService] Starting texture load:', textureGuid);
|
||||
this._loadTextureToCache(textureGuid);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步加载纹理到缓存
|
||||
* Load texture to cache asynchronously
|
||||
*/
|
||||
private async _loadTextureToCache(textureGuid: string): Promise<void> {
|
||||
if (this._textureCache.has(textureGuid) || this._texturePending.has(textureGuid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._texturePending.add(textureGuid);
|
||||
|
||||
try {
|
||||
const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null;
|
||||
const projectService = Core.services.tryResolve(ProjectService) as { getCurrentProject: () => { path: string } | null } | null;
|
||||
|
||||
let resolvedPath: string | null = null;
|
||||
|
||||
// 检查是否是 GUID 格式 | Check if GUID format
|
||||
const isGuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(textureGuid);
|
||||
|
||||
if (isGuid && assetRegistry) {
|
||||
resolvedPath = assetRegistry.getPathByGuid(textureGuid) || null;
|
||||
} else {
|
||||
resolvedPath = textureGuid;
|
||||
}
|
||||
|
||||
if (!resolvedPath) {
|
||||
this._texturePending.delete(textureGuid);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否是图片 | Check if image
|
||||
const ext = resolvedPath.toLowerCase().split('.').pop() || '';
|
||||
const imageExts: Record<string, string> = {
|
||||
'png': 'image/png',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'gif': 'image/gif',
|
||||
'webp': 'image/webp',
|
||||
'bmp': 'image/bmp'
|
||||
};
|
||||
|
||||
const mimeType = imageExts[ext];
|
||||
if (!mimeType) {
|
||||
this._texturePending.delete(textureGuid);
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建完整路径 | Build full path
|
||||
const projectPath = projectService?.getCurrentProject()?.path;
|
||||
const fullPath = resolvedPath.startsWith('/') || resolvedPath.includes(':')
|
||||
? resolvedPath
|
||||
: projectPath
|
||||
? `${projectPath}/${resolvedPath}`
|
||||
: resolvedPath;
|
||||
|
||||
// 通过 Tauri command 读取文件并转为 base64 | Read file via Tauri command and convert to base64
|
||||
console.log('[RenderDebugService] Loading texture:', fullPath);
|
||||
const base64 = await invoke<string>('read_file_as_base64', { filePath: fullPath });
|
||||
const dataUrl = `data:${mimeType};base64,${base64}`;
|
||||
|
||||
console.log('[RenderDebugService] Texture loaded, base64 length:', base64.length);
|
||||
this._textureCache.set(textureGuid, dataUrl);
|
||||
} catch (err) {
|
||||
console.error('[RenderDebugService] Failed to load texture:', textureGuid, err);
|
||||
} finally {
|
||||
this._texturePending.delete(textureGuid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集当前帧的调试数据
|
||||
* Collect debug data for current frame
|
||||
*/
|
||||
collectSnapshot(): RenderDebugSnapshot | null {
|
||||
if (!this._enabled) return null;
|
||||
|
||||
const scene = Core.scene;
|
||||
if (!scene) return null;
|
||||
|
||||
this._frameNumber++;
|
||||
|
||||
const snapshot: RenderDebugSnapshot = {
|
||||
timestamp: Date.now(),
|
||||
frameNumber: this._frameNumber,
|
||||
textures: this._collectTextures(),
|
||||
sprites: this._collectSprites(scene.entities.buffer),
|
||||
particles: this._collectParticles(scene.entities.buffer),
|
||||
uiElements: this._collectUI(scene.entities.buffer),
|
||||
stats: {
|
||||
totalSprites: 0,
|
||||
totalParticles: 0,
|
||||
totalUIElements: 0,
|
||||
totalTextures: 0,
|
||||
drawCalls: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// 计算统计 | Calculate stats
|
||||
snapshot.stats.totalSprites = snapshot.sprites.length;
|
||||
snapshot.stats.totalParticles = snapshot.particles.reduce((sum, p) => sum + p.activeCount, 0);
|
||||
snapshot.stats.totalUIElements = snapshot.uiElements.length;
|
||||
snapshot.stats.totalTextures = snapshot.textures.length;
|
||||
|
||||
// 保存快照 | Save snapshot
|
||||
this._snapshots.push(snapshot);
|
||||
if (this._snapshots.length > this._maxSnapshots) {
|
||||
this._snapshots.shift();
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最新快照
|
||||
* Get latest snapshot
|
||||
*/
|
||||
getLatestSnapshot(): RenderDebugSnapshot | null {
|
||||
return this._snapshots.length > 0 ? this._snapshots[this._snapshots.length - 1] ?? null : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有快照
|
||||
* Get all snapshots
|
||||
*/
|
||||
getSnapshots(): RenderDebugSnapshot[] {
|
||||
return [...this._snapshots];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除快照
|
||||
* Clear snapshots
|
||||
*/
|
||||
clearSnapshots(): void {
|
||||
this._snapshots = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集纹理信息
|
||||
* Collect texture info
|
||||
*/
|
||||
private _collectTextures(): TextureDebugInfo[] {
|
||||
const textures: TextureDebugInfo[] = [];
|
||||
|
||||
// TODO: 从 EngineBridge 获取纹理管理器数据
|
||||
// TODO: Get texture manager data from EngineBridge
|
||||
if (this._engineBridge) {
|
||||
// const textureManager = this._engineBridge.getTextureManager();
|
||||
// for (const [id, tex] of textureManager.entries()) {
|
||||
// textures.push({ ... });
|
||||
// }
|
||||
}
|
||||
|
||||
return textures;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集 Sprite 信息
|
||||
* Collect sprite info
|
||||
*/
|
||||
private _collectSprites(entities: readonly Entity[]): SpriteDebugInfo[] {
|
||||
const sprites: SpriteDebugInfo[] = [];
|
||||
|
||||
for (const entity of entities) {
|
||||
const sprite = entity.getComponent(SpriteComponent);
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
|
||||
if (!sprite || !transform) continue;
|
||||
|
||||
const pos = transform.worldPosition ?? transform.position;
|
||||
const rot = typeof transform.rotation === 'number'
|
||||
? transform.rotation
|
||||
: transform.rotation.z;
|
||||
|
||||
const textureGuid = sprite.textureGuid ?? '';
|
||||
sprites.push({
|
||||
entityId: entity.id,
|
||||
entityName: entity.name,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
width: sprite.width,
|
||||
height: sprite.height,
|
||||
rotation: rot,
|
||||
textureId: (sprite as any).textureId ?? 0,
|
||||
texturePath: textureGuid,
|
||||
textureUrl: this._resolveTextureUrl(textureGuid),
|
||||
uv: [...sprite.uv] as [number, number, number, number],
|
||||
color: sprite.color,
|
||||
alpha: sprite.alpha,
|
||||
sortingLayer: sprite.sortingLayer,
|
||||
orderInLayer: sprite.orderInLayer,
|
||||
});
|
||||
}
|
||||
|
||||
return sprites;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集粒子系统信息
|
||||
* Collect particle system info
|
||||
*/
|
||||
private _collectParticles(entities: readonly Entity[]): ParticleDebugInfo[] {
|
||||
const particleSystems: ParticleDebugInfo[] = [];
|
||||
|
||||
for (const entity of entities) {
|
||||
const ps = entity.getComponent(ParticleSystemComponent);
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
|
||||
if (!ps) continue;
|
||||
|
||||
const pool = ps.pool;
|
||||
|
||||
// 通过 getModule 获取 TextureSheetAnimation 模块 | Get TextureSheetAnimation module via getModule
|
||||
const textureSheetAnim = ps.getModule?.('TextureSheetAnimation') as any;
|
||||
|
||||
// 收集所有活跃粒子 | Collect all active particles
|
||||
const sampleParticles: ParticleDebugInfo['sampleParticles'] = [];
|
||||
if (pool) {
|
||||
let count = 0;
|
||||
pool.forEachActive((p: any) => {
|
||||
const tilesX = p._animTilesX ?? 1;
|
||||
const tilesY = p._animTilesY ?? 1;
|
||||
const frame = p._animFrame ?? 0;
|
||||
const col = frame % tilesX;
|
||||
const row = Math.floor(frame / tilesX);
|
||||
const uWidth = 1 / tilesX;
|
||||
const vHeight = 1 / tilesY;
|
||||
|
||||
sampleParticles.push({
|
||||
index: count,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
frame,
|
||||
uv: [
|
||||
col * uWidth,
|
||||
row * vHeight,
|
||||
(col + 1) * uWidth,
|
||||
(row + 1) * vHeight,
|
||||
],
|
||||
age: p.age,
|
||||
lifetime: p.lifetime,
|
||||
size: p.size ?? p.startSize ?? 1,
|
||||
color: p.color ?? '#ffffff',
|
||||
alpha: p.alpha ?? 1,
|
||||
});
|
||||
count++;
|
||||
});
|
||||
}
|
||||
|
||||
// 获取模块的 tilesX/tilesY | Get tilesX/tilesY from module
|
||||
const tilesX = textureSheetAnim?.tilesX ?? 1;
|
||||
const tilesY = textureSheetAnim?.tilesY ?? 1;
|
||||
const totalFrames = textureSheetAnim?.actualTotalFrames ?? (tilesX * tilesY);
|
||||
|
||||
const textureGuid = ps.textureGuid ?? '';
|
||||
particleSystems.push({
|
||||
entityId: entity.id,
|
||||
entityName: entity.name,
|
||||
systemName: `ParticleSystem_${entity.id}`,
|
||||
isPlaying: ps.isPlaying,
|
||||
activeCount: pool?.activeCount ?? 0,
|
||||
maxParticles: ps.maxParticles,
|
||||
textureId: ps.textureId ?? 0,
|
||||
texturePath: textureGuid,
|
||||
textureUrl: this._resolveTextureUrl(textureGuid),
|
||||
textureSheetAnimation: textureSheetAnim?.enabled ? {
|
||||
enabled: true,
|
||||
tilesX,
|
||||
tilesY,
|
||||
totalFrames,
|
||||
} : null,
|
||||
sampleParticles,
|
||||
});
|
||||
}
|
||||
|
||||
return particleSystems;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集 UI 元素信息
|
||||
* Collect UI element info
|
||||
*/
|
||||
private _collectUI(entities: readonly Entity[]): UIDebugInfo[] {
|
||||
const uiElements: UIDebugInfo[] = [];
|
||||
|
||||
for (const entity of entities) {
|
||||
const uiTransform = entity.getComponent(UITransformComponent);
|
||||
|
||||
if (!uiTransform) continue;
|
||||
|
||||
const uiRender = entity.getComponent(UIRenderComponent);
|
||||
const uiText = entity.getComponent(UITextComponent);
|
||||
|
||||
// 确定类型 | Determine type
|
||||
let type: UIDebugInfo['type'] = 'unknown';
|
||||
if (uiText) {
|
||||
type = 'text';
|
||||
} else if (uiRender) {
|
||||
switch (uiRender.type) {
|
||||
case 'rect': type = 'rect'; break;
|
||||
case 'image': type = 'image'; break;
|
||||
case 'ninepatch': type = 'ninepatch'; break;
|
||||
case 'circle': type = 'circle'; break;
|
||||
case 'rounded-rect': type = 'rounded-rect'; break;
|
||||
default: type = 'rect';
|
||||
}
|
||||
}
|
||||
|
||||
// 获取纹理 GUID | Get texture GUID
|
||||
const textureGuid = uiRender?.textureGuid?.toString() ?? '';
|
||||
|
||||
// 转换颜色为十六进制字符串 | Convert color to hex string
|
||||
const backgroundColor = uiRender?.backgroundColor !== undefined
|
||||
? `#${uiRender.backgroundColor.toString(16).padStart(6, '0')}`
|
||||
: undefined;
|
||||
|
||||
uiElements.push({
|
||||
entityId: entity.id,
|
||||
entityName: entity.name,
|
||||
type,
|
||||
x: uiTransform.x,
|
||||
y: uiTransform.y,
|
||||
width: uiTransform.width,
|
||||
height: uiTransform.height,
|
||||
worldX: uiTransform.worldX,
|
||||
worldY: uiTransform.worldY,
|
||||
rotation: uiTransform.rotation,
|
||||
visible: uiTransform.visible && uiTransform.worldVisible,
|
||||
alpha: uiTransform.worldAlpha,
|
||||
sortingLayer: uiTransform.sortingLayer,
|
||||
orderInLayer: uiTransform.orderInLayer,
|
||||
textureGuid: textureGuid || undefined,
|
||||
textureUrl: textureGuid ? this._resolveTextureUrl(textureGuid) : undefined,
|
||||
backgroundColor,
|
||||
text: uiText?.text,
|
||||
fontSize: uiText?.fontSize,
|
||||
});
|
||||
}
|
||||
|
||||
return uiElements;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出调试数据为 JSON
|
||||
* Export debug data as JSON
|
||||
*/
|
||||
exportAsJSON(): string {
|
||||
return JSON.stringify({
|
||||
exportTime: new Date().toISOString(),
|
||||
snapshots: this._snapshots,
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印当前粒子 UV 到控制台
|
||||
* Print current particle UVs to console
|
||||
*/
|
||||
logParticleUVs(): void {
|
||||
const snapshot = this.collectSnapshot();
|
||||
if (!snapshot) {
|
||||
console.log('[RenderDebugService] No scene available');
|
||||
return;
|
||||
}
|
||||
|
||||
console.group('[RenderDebugService] Particle UV Debug');
|
||||
for (const ps of snapshot.particles) {
|
||||
console.group(`${ps.entityName} (${ps.activeCount} active)`);
|
||||
if (ps.textureSheetAnimation) {
|
||||
console.log(`TextureSheetAnimation: ${ps.textureSheetAnimation.tilesX}x${ps.textureSheetAnimation.tilesY}`);
|
||||
}
|
||||
for (const p of ps.sampleParticles) {
|
||||
console.log(` Particle ${p.index}: frame=${p.frame}, UV=[${p.uv.map(v => v.toFixed(3)).join(', ')}]`);
|
||||
}
|
||||
console.groupEnd();
|
||||
}
|
||||
console.groupEnd();
|
||||
}
|
||||
}
|
||||
|
||||
// 全局实例 | Global instance
|
||||
export const renderDebugService = RenderDebugService.getInstance();
|
||||
|
||||
// 导出到全局以便控制台使用 | Export to global for console usage
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).renderDebugService = renderDebugService;
|
||||
}
|
||||
Reference in New Issue
Block a user