From d64e463a71927d95615dfb6dbddf4f19df268c13 Mon Sep 17 00:00:00 2001 From: yhh <359807859@qq.com> Date: Tue, 16 Dec 2025 11:28:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor-app):=20=E6=B7=BB=E5=8A=A0=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E8=B0=83=E8=AF=95=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 RenderDebugService 和调试面板 UI - App/ContentBrowser 添加调试日志 - TitleBar/Viewport 优化 - DialogManager 改进 --- packages/editor-app/src/App.tsx | 211 +++- .../src/app/managers/DialogManager.ts | 22 +- .../src/components/ContentBrowser.tsx | 3 + .../editor-app/src/components/TitleBar.tsx | 5 +- .../editor-app/src/components/Viewport.tsx | 163 ++- .../src/components/debug/RenderDebugPanel.css | 633 ++++++++++ .../src/components/debug/RenderDebugPanel.tsx | 1059 +++++++++++++++++ .../editor-app/src/components/debug/index.ts | 7 + .../src/services/RenderDebugService.ts | 591 +++++++++ 9 files changed, 2684 insertions(+), 10 deletions(-) create mode 100644 packages/editor-app/src/components/debug/RenderDebugPanel.css create mode 100644 packages/editor-app/src/components/debug/RenderDebugPanel.tsx create mode 100644 packages/editor-app/src/components/debug/index.ts create mode 100644 packages/editor-app/src/services/RenderDebugService.ts diff --git a/packages/editor-app/src/App.tsx b/packages/editor-app/src/App.tsx index 263a4e28..f1a45593 100644 --- a/packages/editor-app/src/App.tsx +++ b/packages/editor-app/src/App.tsx @@ -40,11 +40,15 @@ import { Inspector } from './components/inspectors/Inspector'; import { AssetBrowser } from './components/AssetBrowser'; import { Viewport } from './components/Viewport'; import { AdvancedProfilerWindow } from './components/AdvancedProfilerWindow'; +import { RenderDebugPanel } from './components/debug/RenderDebugPanel'; +import { emit, emitTo, listen } from '@tauri-apps/api/event'; +import { renderDebugService } from './services/RenderDebugService'; import { PortManager } from './components/PortManager'; import { SettingsWindow } from './components/SettingsWindow'; import { AboutDialog } from './components/AboutDialog'; import { ErrorDialog } from './components/ErrorDialog'; import { ConfirmDialog } from './components/ConfirmDialog'; +import { ExternalModificationDialog } from './components/ExternalModificationDialog'; import { PluginGeneratorWindow } from './components/PluginGeneratorWindow'; import { BuildSettingsWindow } from './components/BuildSettingsWindow'; import { ForumPanel } from './components/forum'; @@ -63,6 +67,7 @@ import { useLocale } from './hooks/useLocale'; import { useStoreSubscriptions } from './hooks/useStoreSubscriptions'; import { en, zh, es } from './locales'; import type { Locale } from '@esengine/editor-core'; +import { UserCodeService } from '@esengine/editor-core'; import { Loader2 } from 'lucide-react'; import './styles/App.css'; @@ -84,12 +89,24 @@ Core.services.registerInstance(ICompilerRegistry, compilerRegistryInstance); const logger = createLogger('App'); +// 检查是否为独立窗口模式 | Check if standalone window mode +const isFrameDebuggerMode = new URLSearchParams(window.location.search).get('mode') === 'frame-debugger'; + function App() { const initRef = useRef(false); const layoutContainerRef = useRef(null); const [pluginLoader] = useState(() => new PluginLoader()); const { showToast, hideToast } = useToast(); + // 如果是独立调试窗口模式,只渲染调试面板 | If standalone debugger mode, only render debug panel + if (isFrameDebuggerMode) { + return ( +
+ window.close()} standalone /> +
+ ); + } + // ===== 本地初始化状态(只用于初始化阶段)| Local init state (only for initialization) ===== const [initialized, setInitialized] = useState(false); @@ -170,10 +187,40 @@ function App() { showAbout, setShowAbout, showPluginGenerator, setShowPluginGenerator, showBuildSettings, setShowBuildSettings, + showRenderDebug, setShowRenderDebug, errorDialog, setErrorDialog, - confirmDialog, setConfirmDialog + confirmDialog, setConfirmDialog, + externalModificationDialog, setExternalModificationDialog } = useDialogStore(); + // 全局监听独立调试窗口的数据请求 | Global listener for standalone debug window requests + useEffect(() => { + let broadcastInterval: ReturnType | null = null; + + const unlistenPromise = listen('render-debug-request-data', () => { + // 开始定时广播数据 | Start broadcasting data periodically + if (!broadcastInterval) { + const broadcast = () => { + renderDebugService.setEnabled(true); + const snap = renderDebugService.collectSnapshot(); + if (snap) { + // 使用 emitTo 发送到独立窗口 | Use emitTo to send to standalone window + emitTo('frame-debugger', 'render-debug-snapshot', snap).catch(() => {}); + } + }; + broadcast(); // 立即广播一次 | Broadcast immediately + broadcastInterval = setInterval(broadcast, 500); + } + }); + + return () => { + unlistenPromise.then(unlisten => unlisten()); + if (broadcastInterval) { + clearInterval(broadcastInterval); + } + }; + }, []); + useEffect(() => { // 禁用默认右键菜单 const handleContextMenu = (e: MouseEvent) => { @@ -483,6 +530,113 @@ function App() { }; }, [initialized]); + // Handle external scene file changes + // 处理外部场景文件变更 + useEffect(() => { + if (!initialized || !messageHubRef.current || !sceneManagerRef.current) return; + const hub = messageHubRef.current; + const sm = sceneManagerRef.current; + + const unsubscribe = hub.subscribe('scene:external-change', (data: { + path: string; + sceneName: string; + }) => { + logger.info('Scene externally modified:', data.path); + + // Show confirmation dialog to reload the scene + // 显示确认对话框以重新加载场景 + setConfirmDialog({ + title: t('scene.externalChange.title'), + message: t('scene.externalChange.message', { name: data.sceneName }), + confirmText: t('scene.externalChange.reload'), + cancelText: t('scene.externalChange.ignore'), + onConfirm: async () => { + setConfirmDialog(null); + try { + await sm.openScene(data.path); + showToast(t('scene.reloadedSuccess', { name: data.sceneName }), 'success'); + } catch (error) { + console.error('Failed to reload scene:', error); + showToast(t('scene.reloadFailed'), 'error'); + } + }, + onCancel: () => { + // User chose to ignore, do nothing + // 用户选择忽略,不做任何操作 + } + }); + }); + + return () => unsubscribe?.(); + }, [initialized, t, showToast]); + + // Handle external modification when saving scene + // 处理保存场景时的外部修改检测 + useEffect(() => { + if (!initialized || !messageHubRef.current || !sceneManagerRef.current) return; + const hub = messageHubRef.current; + const sm = sceneManagerRef.current; + + const unsubscribe = hub.subscribe('scene:externalModification', (data: { + path: string; + sceneName: string; + }) => { + logger.info('Scene file externally modified during save:', data.path); + + // Show external modification dialog with three options + // 显示外部修改对话框,提供三个选项 + setExternalModificationDialog({ + sceneName: data.sceneName, + onReload: async () => { + setExternalModificationDialog(null); + try { + await sm.reloadScene(); + showToast(t('scene.reloadedSuccess', { name: data.sceneName }), 'success'); + } catch (error) { + console.error('Failed to reload scene:', error); + showToast(t('scene.reloadFailed'), 'error'); + } + }, + onOverwrite: async () => { + setExternalModificationDialog(null); + try { + await sm.saveScene(true); // Force save, overwriting external changes + showToast(t('scene.savedSuccess', { name: data.sceneName }), 'success'); + } catch (error) { + console.error('Failed to save scene:', error); + showToast(t('scene.saveFailed'), 'error'); + } + } + }); + }); + + return () => unsubscribe?.(); + }, [initialized, t, showToast, setExternalModificationDialog]); + + // Handle user code compilation results + // 处理用户代码编译结果 + useEffect(() => { + if (!initialized || !messageHubRef.current) return; + const hub = messageHubRef.current; + + const unsubscribe = hub.subscribe('usercode:compilation-result', (data: { + success: boolean; + exports: string[]; + errors: string[]; + }) => { + if (data.success) { + if (data.exports.length > 0) { + showToast(t('usercode.compileSuccess', { count: data.exports.length }), 'success'); + } + } else { + const errorMsg = data.errors[0] ?? t('usercode.compileError'); + showToast(errorMsg, 'error'); + } + }); + + return () => unsubscribe?.(); + }, [initialized, t, showToast]); + const handleOpenRecentProject = async (projectPath: string) => { try { setIsLoading(true, t('loading.step1')); @@ -523,7 +677,6 @@ function App() { const sceneFiles = await TauriAPI.scanDirectory(`${projectPath}/scenes`, '*.ecs'); const sceneNames = sceneFiles.map(f => `scenes/${f.split(/[\\/]/).pop()}`); setAvailableScenes(sceneNames); - console.log('[App] Found scenes:', sceneNames); } catch (e) { console.warn('[App] Failed to scan scenes:', e); } @@ -545,12 +698,8 @@ function App() { // Load project plugin config and activate plugins (after engine init, before module system init) if (pluginManagerRef.current) { const pluginSettings = projectService.getPluginSettings(); - console.log('[App] Plugin settings from project:', pluginSettings); if (pluginSettings && pluginSettings.enabledPlugins.length > 0) { - console.log('[App] Loading plugin config:', pluginSettings.enabledPlugins); await pluginManagerRef.current.loadConfig({ enabledPlugins: pluginSettings.enabledPlugins }); - } else { - console.log('[App] No plugin settings found in project config'); } } @@ -566,6 +715,13 @@ function App() { setIsLoading(true, t('loading.step3')); + // Wait for user code to be compiled and registered before loading scenes + // 等待用户代码编译和注册完成后再加载场景 + const userCodeService = Core.services.tryResolve(UserCodeService); + if (userCodeService) { + await userCodeService.waitForReady(); + } + const sceneManagerService = Core.services.resolve(SceneManagerService); if (sceneManagerService) { await sceneManagerService.newScene(); @@ -696,6 +852,13 @@ function App() { } try { + // Wait for user code to be ready before loading scene + // 在加载场景前等待用户代码就绪 + const userCodeService = Core.services.tryResolve(UserCodeService); + if (userCodeService) { + await userCodeService.waitForReady(); + } + await sceneManager.openScene(); const sceneState = sceneManager.getSceneState(); setStatus(t('scene.openedSuccess', { name: sceneState.sceneName })); @@ -706,13 +869,25 @@ function App() { }; const handleOpenSceneByPath = useCallback(async (scenePath: string) => { + console.log('[App] handleOpenSceneByPath called:', scenePath); if (!sceneManager) { console.error('SceneManagerService not available'); return; } try { + // Wait for user code to be ready before loading scene + // 在加载场景前等待用户代码就绪 + const userCodeService = Core.services.tryResolve(UserCodeService); + if (userCodeService) { + console.log('[App] Waiting for user code service...'); + await userCodeService.waitForReady(); + console.log('[App] User code service ready'); + } + + console.log('[App] Calling sceneManager.openScene...'); await sceneManager.openScene(scenePath); + console.log('[App] Scene opened successfully'); const sceneState = sceneManager.getSceneState(); setStatus(t('scene.openedSuccess', { name: sceneState.sceneName })); } catch (error) { @@ -1087,6 +1262,14 @@ function App() { }} /> )} + {externalModificationDialog && ( + setExternalModificationDialog(null)} + /> + )} ); } @@ -1121,6 +1304,7 @@ function App() { onCreatePlugin={handleCreatePlugin} onReloadPlugins={handleReloadPlugins} onOpenBuildSettings={() => setShowBuildSettings(true)} + onOpenRenderDebug={() => setShowRenderDebug(true)} /> )} + {/* 渲染调试面板 | Render Debug Panel */} + setShowRenderDebug(false)} + /> + {errorDialog && ( )} + + {externalModificationDialog && ( + setExternalModificationDialog(null)} + /> + )} ); } diff --git a/packages/editor-app/src/app/managers/DialogManager.ts b/packages/editor-app/src/app/managers/DialogManager.ts index e07bc2d7..78f09aab 100644 --- a/packages/editor-app/src/app/managers/DialogManager.ts +++ b/packages/editor-app/src/app/managers/DialogManager.ts @@ -6,6 +6,16 @@ interface ErrorDialogData { message: string; } +/** + * 外部修改对话框数据 + * External modification dialog data + */ +export interface ExternalModificationDialogData { + sceneName: string; + onReload: () => void; + onOverwrite: () => void; +} + interface DialogState { showProfiler: boolean; showAdvancedProfiler: boolean; @@ -14,8 +24,10 @@ interface DialogState { showAbout: boolean; showPluginGenerator: boolean; showBuildSettings: boolean; + showRenderDebug: boolean; errorDialog: ErrorDialogData | null; confirmDialog: ConfirmDialogData | null; + externalModificationDialog: ExternalModificationDialogData | null; setShowProfiler: (show: boolean) => void; setShowAdvancedProfiler: (show: boolean) => void; @@ -24,8 +36,10 @@ interface DialogState { setShowAbout: (show: boolean) => void; setShowPluginGenerator: (show: boolean) => void; setShowBuildSettings: (show: boolean) => void; + setShowRenderDebug: (show: boolean) => void; setErrorDialog: (data: ErrorDialogData | null) => void; setConfirmDialog: (data: ConfirmDialogData | null) => void; + setExternalModificationDialog: (data: ExternalModificationDialogData | null) => void; closeAllDialogs: () => void; } @@ -37,8 +51,10 @@ export const useDialogStore = create((set) => ({ showAbout: false, showPluginGenerator: false, showBuildSettings: false, + showRenderDebug: false, errorDialog: null, confirmDialog: null, + externalModificationDialog: null, setShowProfiler: (show) => set({ showProfiler: show }), setShowAdvancedProfiler: (show) => set({ showAdvancedProfiler: show }), @@ -47,8 +63,10 @@ export const useDialogStore = create((set) => ({ setShowAbout: (show) => set({ showAbout: show }), setShowPluginGenerator: (show) => set({ showPluginGenerator: show }), setShowBuildSettings: (show) => set({ showBuildSettings: show }), + setShowRenderDebug: (show) => set({ showRenderDebug: show }), setErrorDialog: (data) => set({ errorDialog: data }), setConfirmDialog: (data) => set({ confirmDialog: data }), + setExternalModificationDialog: (data) => set({ externalModificationDialog: data }), closeAllDialogs: () => set({ showProfiler: false, @@ -58,7 +76,9 @@ export const useDialogStore = create((set) => ({ showAbout: false, showPluginGenerator: false, showBuildSettings: false, + showRenderDebug: false, errorDialog: null, - confirmDialog: null + confirmDialog: null, + externalModificationDialog: null }) })); diff --git a/packages/editor-app/src/components/ContentBrowser.tsx b/packages/editor-app/src/components/ContentBrowser.tsx index 982219f8..a1aabfcb 100644 --- a/packages/editor-app/src/components/ContentBrowser.tsx +++ b/packages/editor-app/src/components/ContentBrowser.tsx @@ -1026,13 +1026,16 @@ export class ${className} { // Handle asset double click const handleAssetDoubleClick = useCallback(async (asset: AssetItem) => { + console.log('[ContentBrowser] Double click:', asset.name, 'type:', asset.type, 'ext:', asset.extension); if (asset.type === 'folder') { setCurrentPath(asset.path); loadAssets(asset.path); setExpandedFolders(prev => new Set([...prev, asset.path])); } else { const ext = asset.extension?.toLowerCase(); + console.log('[ContentBrowser] File ext:', ext, 'onOpenScene:', !!onOpenScene); if (ext === 'ecs' && onOpenScene) { + console.log('[ContentBrowser] Opening scene:', asset.path); onOpenScene(asset.path); return; } diff --git a/packages/editor-app/src/components/TitleBar.tsx b/packages/editor-app/src/components/TitleBar.tsx index 40d22c7d..3c0fc33f 100644 --- a/packages/editor-app/src/components/TitleBar.tsx +++ b/packages/editor-app/src/components/TitleBar.tsx @@ -38,6 +38,7 @@ interface TitleBarProps { onCreatePlugin?: () => void; onReloadPlugins?: () => void; onOpenBuildSettings?: () => void; + onOpenRenderDebug?: () => void; } export function TitleBar({ @@ -61,7 +62,8 @@ export function TitleBar({ onOpenAbout, onCreatePlugin, onReloadPlugins, - onOpenBuildSettings + onOpenBuildSettings, + onOpenRenderDebug }: TitleBarProps) { const { t } = useLocale(); const [openMenu, setOpenMenu] = useState(null); @@ -197,6 +199,7 @@ export function TitleBar({ { label: t('menu.tools.reloadPlugins'), shortcut: 'Ctrl+R', onClick: onReloadPlugins }, { separator: true }, { label: t('menu.tools.portManager'), onClick: onOpenPortManager }, + { label: t('menu.tools.renderDebug'), onClick: onOpenRenderDebug }, { separator: true }, { label: t('menu.tools.settings'), onClick: onOpenSettings } ], diff --git a/packages/editor-app/src/components/Viewport.tsx b/packages/editor-app/src/components/Viewport.tsx index aa605c44..17e2517e 100644 --- a/packages/editor-app/src/components/Viewport.tsx +++ b/packages/editor-app/src/components/Viewport.tsx @@ -8,9 +8,9 @@ import '../styles/Viewport.css'; import { useEngine } from '../hooks/useEngine'; import { useLocale } from '../hooks/useLocale'; import { EngineService } from '../services/EngineService'; -import { Core, Entity, SceneSerializer, PrefabSerializer, ComponentRegistry } from '@esengine/ecs-framework'; +import { Core, Entity, SceneSerializer, PrefabSerializer, GlobalComponentRegistry } from '@esengine/ecs-framework'; import type { PrefabData, ComponentType } from '@esengine/ecs-framework'; -import { MessageHub, ProjectService, AssetRegistryService, EntityStoreService, CommandManager, SceneManagerService } from '@esengine/editor-core'; +import { MessageHub, ProjectService, AssetRegistryService, EntityStoreService, CommandManager, SceneManagerService, UserCodeService, UserCodeTarget } from '@esengine/editor-core'; import { InstantiatePrefabCommand } from '../application/commands/prefab/InstantiatePrefabCommand'; import { TransformCommand, type TransformState, type TransformOperationType } from '../application/commands'; import { TransformComponent } from '@esengine/engine-core'; @@ -21,6 +21,8 @@ import { open } from '@tauri-apps/plugin-shell'; import { RuntimeResolver } from '../services/RuntimeResolver'; import { QRCodeDialog } from './QRCodeDialog'; import { collectAssetReferences } from '@esengine/asset-system'; +import { RuntimeSceneManager, type IRuntimeSceneManager } from '@esengine/runtime-core'; +import { ParticleSystemComponent } from '@esengine/particle'; import type { ModuleManifest } from '../services/RuntimeResolver'; @@ -264,6 +266,9 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport const editorCameraRef = useRef({ x: 0, y: 0, zoom: 1 }); const playStateRef = useRef('stopped'); + // Runtime scene manager for play mode scene switching | Play 模式场景切换管理器 + const runtimeSceneManagerRef = useRef(null); + // Live transform display state | 实时变换显示状态 const [liveTransform, setLiveTransform] = useState<{ type: 'move' | 'rotate' | 'scale'; @@ -811,7 +816,22 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport return; } // Save scene snapshot before playing + // saveSceneSnapshot clears all textures, so we need to reset particle textureIds after + // saveSceneSnapshot 会清除所有纹理,所以之后需要重置粒子的 textureId EngineService.getInstance().saveSceneSnapshot(); + + // Reset particle component textureIds after snapshot (textures were cleared) + // 快照后重置粒子组件的 textureId(纹理已被清除) + const scene = Core.scene; + if (scene) { + for (const entity of scene.entities.buffer) { + const particleComponent = entity.getComponent(ParticleSystemComponent); + if (particleComponent) { + particleComponent.textureId = 0; + } + } + } + // Save editor camera state editorCameraRef.current = { x: camera2DOffset.x, y: camera2DOffset.y, zoom: camera2DZoom }; setPlayState('playing'); @@ -820,6 +840,132 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport EngineService.getInstance().setEditorMode(false); // Switch to player camera syncPlayerCamera(); + + // Register RuntimeSceneManager for scene switching in play mode + // 注册 RuntimeSceneManager 以支持 Play 模式下的场景切换 + const projectService = Core.services.tryResolve(ProjectService); + const projectPath = projectService?.getCurrentProject()?.path; + if (projectPath) { + // Create scene loader function that reads scene files using Tauri API + // 创建使用 Tauri API 读取场景文件的场景加载器函数 + const editorSceneLoader = async (scenePath: string): Promise => { + try { + // Normalize path: handle both relative and absolute paths + // 标准化路径:处理相对路径和绝对路径 + let fullPath = scenePath; + if (!scenePath.includes(':') && !scenePath.startsWith('/')) { + // Relative path - construct full path + // 相对路径 - 构建完整路径 + const normalizedPath = scenePath.replace(/^\.\//, '').replace(/\//g, '\\'); + fullPath = `${projectPath}\\${normalizedPath}`; + } else { + // Absolute path - normalize separators for Windows + // 绝对路径 - 为 Windows 规范化分隔符 + fullPath = scenePath.replace(/\//g, '\\'); + } + + // Read scene file content + // 读取场景文件内容 + const sceneJson = await TauriAPI.readFileContent(fullPath); + + // Validate scene data + // 验证场景数据 + const validation = SceneSerializer.validate(sceneJson); + if (!validation.valid) { + throw new Error(`Invalid scene: ${validation.errors?.join(', ')}`); + } + + // Save current scene snapshot (so we can go back) + // 保存当前场景快照(以便返回) + EngineService.getInstance().saveSceneSnapshot(); + + // Load new scene by deserializing into current scene + // 通过反序列化加载新场景到当前场景 + const scene = Core.scene; + if (scene) { + scene.deserialize(sceneJson, { strategy: 'replace' }); + + // Reset particle component textureIds after scene switch + // 场景切换后重置粒子组件的 textureId + // This ensures ParticleUpdateSystem will reload textures + // 这确保 ParticleUpdateSystem 会重新加载纹理 + for (const entity of scene.entities.buffer) { + const particleComponent = entity.getComponent(ParticleSystemComponent); + if (particleComponent) { + particleComponent.textureId = 0; + } + } + + // Re-register user code components and systems after scene switch + // 场景切换后重新注册用户代码组件和系统 + const userCodeService = Core.services.tryResolve(UserCodeService); + if (userCodeService) { + const runtimeModule = userCodeService.getModule(UserCodeTarget.Runtime); + if (runtimeModule) { + // Re-register components (ensures GlobalComponentRegistry has correct references) + // 重新注册组件(确保 GlobalComponentRegistry 有正确的引用) + userCodeService.registerComponents(runtimeModule, GlobalComponentRegistry); + + // Re-register systems (recreates systems with correct component references) + // 重新注册系统(使用正确的组件引用重建系统) + userCodeService.registerSystems(runtimeModule, scene); + } + } + + // Load scene resources (textures, etc.) + // 加载场景资源(纹理等) + await EngineService.getInstance().loadSceneResources(); + + // Sync entity store + // 同步实体存储 + const entityStore = Core.services.tryResolve(EntityStoreService); + entityStore?.syncFromScene(); + } + + console.log(`[Viewport] Scene loaded in play mode: ${scenePath}`); + } catch (error) { + console.error(`[Viewport] Failed to load scene: ${scenePath}`, error); + throw error; + } + }; + + // Create and register RuntimeSceneManager + // 创建并注册 RuntimeSceneManager + const sceneManager = new RuntimeSceneManager( + editorSceneLoader, + `${projectPath}\\scenes` + ); + runtimeSceneManagerRef.current = sceneManager; + + // Register to Core.services with the global key + // 使用全局 key 注册到 Core.services + const GlobalSceneManagerKey = Symbol.for('@esengine/service:runtimeSceneManager'); + if (!Core.services.isRegistered(GlobalSceneManagerKey)) { + Core.services.registerInstance(GlobalSceneManagerKey, sceneManager); + } + + console.log('[Viewport] RuntimeSceneManager registered for play mode'); + } + + // Register user code components and systems before starting engine + // 在启动引擎前注册用户代码组件和系统 + const userCodeService = Core.services.tryResolve(UserCodeService); + if (userCodeService) { + const runtimeModule = userCodeService.getModule(UserCodeTarget.Runtime); + if (runtimeModule) { + // Register components first (ensures GlobalComponentRegistry has correct references) + // 先注册组件(确保 GlobalComponentRegistry 有正确的引用) + userCodeService.registerComponents(runtimeModule, GlobalComponentRegistry); + + // Then register systems (uses registered component references) + // 然后注册系统(使用已注册的组件引用) + const scene = Core.scene; + if (scene) { + userCodeService.registerSystems(runtimeModule, scene); + } + } + } + engine.start(); } else if (playState === 'paused') { setPlayState('playing'); @@ -837,6 +983,19 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport const handleStop = async () => { setPlayState('stopped'); engine.stop(); + + // Unregister RuntimeSceneManager + // 注销 RuntimeSceneManager + if (runtimeSceneManagerRef.current) { + const GlobalSceneManagerKey = Symbol.for('@esengine/service:runtimeSceneManager'); + if (Core.services.isRegistered(GlobalSceneManagerKey)) { + Core.services.unregister(GlobalSceneManagerKey); + } + runtimeSceneManagerRef.current.dispose(); + runtimeSceneManagerRef.current = null; + console.log('[Viewport] RuntimeSceneManager unregistered'); + } + // Restore scene snapshot await EngineService.getInstance().restoreSceneSnapshot(); // Restore editor camera state diff --git a/packages/editor-app/src/components/debug/RenderDebugPanel.css b/packages/editor-app/src/components/debug/RenderDebugPanel.css new file mode 100644 index 00000000..ff23ada2 --- /dev/null +++ b/packages/editor-app/src/components/debug/RenderDebugPanel.css @@ -0,0 +1,633 @@ +/** + * 渲染调试面板样式 (浮动窗口) + * Render Debug Panel Styles (Floating Window) + */ + +/* ==================== Floating Window ==================== */ +.render-debug-window { + position: fixed; + display: flex; + flex-direction: column; + background: #1e1e1e; + border: 1px solid #3c3c3c; + border-radius: 6px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + z-index: 1000; + overflow: hidden; + font-family: 'Segoe UI', system-ui, sans-serif; + font-size: 11px; + color: #ccc; +} + +.render-debug-window.dragging { + cursor: move; + user-select: none; +} + +/* 独立窗口模式 | Standalone mode */ +.render-debug-window.standalone { + position: relative; + border: none; + border-radius: 0; + box-shadow: none; +} + +.render-debug-window.standalone .window-header { + cursor: default; +} + +/* ==================== Window Header ==================== */ +.render-debug-window .window-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: #2d2d2d; + border-bottom: 1px solid #1a1a1a; + cursor: move; + flex-shrink: 0; +} + +.render-debug-window .window-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + color: #e0e0e0; +} + +.render-debug-window .window-title svg { + color: #4a9eff; +} + +.render-debug-window .paused-badge { + padding: 2px 6px; + background: #f59e0b; + color: #000; + font-size: 9px; + font-weight: 700; + border-radius: 3px; + letter-spacing: 0.5px; +} + +.render-debug-window .window-controls { + display: flex; + gap: 4px; +} + +.render-debug-window .window-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: transparent; + border: none; + border-radius: 4px; + color: #888; + cursor: pointer; + transition: all 0.15s; +} + +.render-debug-window .window-btn:hover { + background: #3a3a3a; + color: #fff; +} + +/* ==================== Toolbar ==================== */ +.render-debug-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px; + background: #262626; + border-bottom: 1px solid #1a1a1a; + flex-shrink: 0; +} + +.render-debug-toolbar .toolbar-left, +.render-debug-toolbar .toolbar-right { + display: flex; + align-items: center; + gap: 6px; +} + +.render-debug-toolbar .toolbar-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: #3a3a3a; + border: 1px solid #4a4a4a; + border-radius: 3px; + color: #ccc; + font-size: 10px; + cursor: pointer; + transition: all 0.15s; +} + +.render-debug-toolbar .toolbar-btn:hover { + background: #4a4a4a; + color: #fff; +} + +.render-debug-toolbar .toolbar-btn.active { + background: #4a9eff; + border-color: #4a9eff; + color: #fff; +} + +.render-debug-toolbar .toolbar-btn.icon-only { + padding: 4px 6px; +} + +.render-debug-toolbar .toolbar-btn.recording { + background: rgba(239, 68, 68, 0.2); + border-color: #ef4444; +} + +.render-debug-toolbar .toolbar-btn .record-dot { + display: inline-block; + width: 10px; + height: 10px; + background: #ef4444; + border-radius: 50%; + animation: pulse 1s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.render-debug-toolbar .history-badge { + padding: 2px 6px; + background: #8b5cf6; + color: #fff; + font-size: 9px; + font-weight: 700; + border-radius: 3px; + letter-spacing: 0.5px; + margin-left: 4px; +} + +.render-debug-toolbar .toolbar-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.render-debug-toolbar .toolbar-btn:disabled:hover { + background: #3a3a3a; + color: #ccc; +} + +/* ==================== Timeline ==================== */ +.render-debug-timeline { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 12px; + background: #222; + border-bottom: 1px solid #1a1a1a; + flex-shrink: 0; +} + +.render-debug-timeline .timeline-slider { + width: 100%; + height: 6px; + -webkit-appearance: none; + appearance: none; + background: #333; + border-radius: 3px; + cursor: pointer; +} + +.render-debug-timeline .timeline-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + background: #4a9eff; + border-radius: 50%; + cursor: grab; + transition: transform 0.1s; +} + +.render-debug-timeline .timeline-slider::-webkit-slider-thumb:hover { + transform: scale(1.2); +} + +.render-debug-timeline .timeline-slider::-webkit-slider-thumb:active { + cursor: grabbing; + transform: scale(1.1); +} + +.render-debug-timeline .timeline-slider::-moz-range-thumb { + width: 14px; + height: 14px; + background: #4a9eff; + border: none; + border-radius: 50%; + cursor: grab; +} + +.render-debug-timeline .timeline-info { + display: flex; + justify-content: space-between; + font-size: 9px; + color: #666; +} + +.render-debug-toolbar .toolbar-separator { + width: 1px; + height: 16px; + background: #3a3a3a; +} + +.render-debug-toolbar .frame-counter { + font-family: 'Consolas', monospace; + font-size: 10px; + color: #888; + padding: 0 6px; +} + +/* ==================== Main Layout ==================== */ +.render-debug-main { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +/* ==================== Left Panel (Event List) ==================== */ +.render-debug-left { + width: 260px; + min-width: 180px; + display: flex; + flex-direction: column; + background: #222; + border-right: 1px solid #1a1a1a; + flex-shrink: 0; +} + +.event-list-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + background: #262626; + border-bottom: 1px solid #1a1a1a; + font-size: 10px; + font-weight: 600; + color: #888; +} + +.event-list-header .event-count { + font-weight: 400; + color: #666; +} + +.event-list { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} + +.event-list::-webkit-scrollbar { + width: 5px; +} + +.event-list::-webkit-scrollbar-track { + background: #1a1a1a; +} + +.event-list::-webkit-scrollbar-thumb { + background: #3a3a3a; + border-radius: 2px; +} + +.event-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: #555; + font-size: 10px; + text-align: center; + padding: 16px; + line-height: 1.5; +} + +/* Event Items */ +.event-item { + display: flex; + align-items: center; + padding: 3px 6px; + cursor: pointer; + user-select: none; + font-size: 10px; + color: #bbb; + border-bottom: 1px solid #1a1a1a; + gap: 3px; +} + +.event-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +.event-item.selected { + background: rgba(74, 158, 255, 0.2); + border-left: 2px solid #4a9eff; + padding-left: 4px; +} + +.event-item .expand-icon { + display: flex; + align-items: center; + justify-content: center; + width: 12px; + height: 12px; + color: #666; + flex-shrink: 0; +} + +.event-item .expand-icon:not(.placeholder):hover { + color: #ccc; +} + +.event-item .expand-icon.placeholder { + visibility: hidden; +} + +.event-item .event-icon { + color: #666; + flex-shrink: 0; + margin-right: 3px; +} + +.event-item .event-icon.sprite { + color: #4fc3f7; +} + +.event-item .event-icon.particle { + color: #ffb74d; +} + +.event-item .event-icon.ui { + color: #81c784; +} + +.event-item .event-icon.batch { + color: #81c784; +} + +.event-item .event-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.event-item .event-draws { + font-family: 'Consolas', monospace; + font-size: 9px; + color: #666; + padding: 1px 3px; + background: #1a1a1a; + border-radius: 2px; + flex-shrink: 0; +} + +/* ==================== Right Panel ==================== */ +.render-debug-right { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; +} + +/* Preview Section */ +.render-debug-preview { + height: 40%; + min-height: 120px; + display: flex; + flex-direction: column; + border-bottom: 1px solid #1a1a1a; +} + +.preview-header { + padding: 6px 10px; + background: #262626; + border-bottom: 1px solid #1a1a1a; + font-size: 10px; + font-weight: 600; + color: #888; +} + +.preview-canvas-container { + flex: 1; + background: #1a1a1a; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.preview-canvas-container canvas { + width: 100%; + height: 100%; +} + +/* Details Section */ +.render-debug-details { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.details-header { + padding: 6px 10px; + background: #262626; + border-bottom: 1px solid #1a1a1a; + font-size: 10px; + font-weight: 600; + color: #888; +} + +.details-content { + flex: 1; + overflow-y: auto; + padding: 10px; + background: #1e1e1e; +} + +.details-content::-webkit-scrollbar { + width: 5px; +} + +.details-content::-webkit-scrollbar-track { + background: #1a1a1a; +} + +.details-content::-webkit-scrollbar-thumb { + background: #3a3a3a; + border-radius: 2px; +} + +.details-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #555; + font-size: 10px; +} + +/* Details Grid */ +.details-grid { + display: flex; + flex-direction: column; + gap: 1px; +} + +.details-section { + font-size: 9px; + font-weight: 600; + color: #666; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 8px 0 3px 0; + margin-top: 6px; + border-top: 1px solid #333; +} + +.details-section:first-child { + margin-top: 0; + border-top: none; + padding-top: 0; +} + +.detail-row { + display: flex; + align-items: flex-start; + padding: 3px 0; + font-size: 10px; +} + +.detail-row .detail-label { + width: 100px; + color: #888; + flex-shrink: 0; +} + +.detail-row .detail-value { + flex: 1; + color: #ccc; + font-family: 'Consolas', monospace; + word-break: break-all; +} + +.detail-row.highlight .detail-value { + color: #4fc3f7; + font-weight: 600; +} + +/* ==================== Stats Bar ==================== */ +.render-debug-stats { + display: flex; + align-items: center; + gap: 16px; + padding: 6px 12px; + background: #262626; + border-top: 1px solid #1a1a1a; + flex-shrink: 0; +} + +.render-debug-stats .stat-item { + display: flex; + align-items: center; + gap: 4px; + font-size: 10px; + color: #888; +} + +.render-debug-stats .stat-item svg { + color: #4a9eff; +} + +/* ==================== Resize Handle ==================== */ +.resize-handle { + position: absolute; + right: 0; + bottom: 0; + width: 16px; + height: 16px; + cursor: se-resize; + background: linear-gradient(135deg, transparent 50%, #3a3a3a 50%); + border-radius: 0 0 6px 0; +} + +.resize-handle:hover { + background: linear-gradient(135deg, transparent 50%, #4a9eff 50%); +} + +/* ==================== TextureSheet Preview ==================== */ +.texture-sheet-preview { + margin-top: 8px; + border-radius: 4px; + overflow: hidden; + background: #1a1a1a; + border: 1px solid #333; +} + +.texture-sheet-preview canvas { + display: block; + width: 100%; + height: auto; +} + +/* ==================== Texture Preview ==================== */ +.texture-preview-row { + display: flex; + align-items: flex-start; + padding: 3px 0; + font-size: 10px; +} + +.texture-preview-row .detail-label { + width: 100px; + color: #888; + flex-shrink: 0; +} + +.texture-preview-content { + flex: 1; + min-width: 0; +} + +.texture-thumbnail-container { + display: flex; + flex-direction: column; + gap: 4px; +} + +.texture-thumbnail { + max-width: 100%; + max-height: 80px; + object-fit: contain; + border-radius: 3px; + border: 1px solid #333; + background: repeating-conic-gradient(#2a2a2a 0% 25%, #1a1a1a 0% 50%) 50% / 8px 8px; +} + +.texture-path { + font-family: 'Consolas', monospace; + font-size: 9px; + color: #666; + word-break: break-all; + line-height: 1.3; +} diff --git a/packages/editor-app/src/components/debug/RenderDebugPanel.tsx b/packages/editor-app/src/components/debug/RenderDebugPanel.tsx new file mode 100644 index 00000000..e10b37bb --- /dev/null +++ b/packages/editor-app/src/components/debug/RenderDebugPanel.tsx @@ -0,0 +1,1059 @@ +/** + * 渲染调试面板(Frame Debugger 风格) + * Render Debug Panel (Frame Debugger Style) + * + * 用于诊断渲染问题的可视化调试工具 + * Visual debugging tool for diagnosing rendering issues + */ + +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { + X, + ExternalLink, + Monitor, + Play, + Pause, + SkipForward, + SkipBack, + ChevronRight, + ChevronDown, + ChevronFirst, + ChevronLast, + Layers, + Image, + Sparkles, + RefreshCw, + Download, + Radio, + Square, + Type +} from 'lucide-react'; +import { WebviewWindow, getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; +import { emit, emitTo, listen, type UnlistenFn } from '@tauri-apps/api/event'; +import { renderDebugService, type RenderDebugSnapshot, type SpriteDebugInfo, type ParticleDebugInfo, type UIDebugInfo } from '../../services/RenderDebugService'; +import './RenderDebugPanel.css'; + +/** + * 渲染事件类型 + * Render event type + */ +type RenderEventType = 'clear' | 'sprite' | 'particle' | 'ui' | 'batch' | 'draw'; + +/** + * 渲染事件 + * Render event + */ +interface RenderEvent { + id: number; + type: RenderEventType; + name: string; + children?: RenderEvent[]; + expanded?: boolean; + data?: SpriteDebugInfo | ParticleDebugInfo | UIDebugInfo | any; + drawCalls?: number; + vertices?: number; +} + +interface RenderDebugPanelProps { + visible: boolean; + onClose: () => void; + /** 独立窗口模式(填满整个窗口)| Standalone mode (fill entire window) */ + standalone?: boolean; +} + +// 最大历史帧数 | Max history frames +const MAX_HISTORY_FRAMES = 120; + +export const RenderDebugPanel: React.FC = ({ visible, onClose, standalone = false }) => { + const [isPaused, setIsPaused] = useState(false); + const [snapshot, setSnapshot] = useState(null); + const [events, setEvents] = useState([]); + const [selectedEvent, setSelectedEvent] = useState(null); + + // 帧历史 | Frame history + const [frameHistory, setFrameHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); // -1 表示实时模式 | -1 means live mode + + // 窗口拖动状态 | Window drag state + const [position, setPosition] = useState({ x: 100, y: 60 }); + const [size, setSize] = useState({ width: 900, height: 600 }); + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); + + const canvasRef = useRef(null); + const windowRef = useRef(null); + + // 弹出为独立窗口 | Pop out to separate window + const handlePopOut = useCallback(async () => { + try { + // 检查窗口是否已存在 | Check if window already exists + const existingWindow = await WebviewWindow.getByLabel('frame-debugger'); + if (existingWindow) { + // 聚焦到现有窗口 | Focus existing window + await existingWindow.setFocus(); + onClose(); + return; + } + + const webview = new WebviewWindow('frame-debugger', { + url: window.location.href.split('?')[0] + '?mode=frame-debugger', + title: 'Frame Debugger', + width: 1000, + height: 700, + minWidth: 600, + minHeight: 400, + center: false, + x: 100, + y: 100, + resizable: true, + decorations: true, + alwaysOnTop: false, + focus: true + }); + + webview.once('tauri://created', () => { + console.log('[FrameDebugger] Separate window created'); + onClose(); // 关闭内嵌面板 | Close embedded panel + }); + + webview.once('tauri://error', (e) => { + console.error('[FrameDebugger] Failed to create window:', e); + }); + } catch (err) { + console.error('[FrameDebugger] Error creating window:', err); + } + }, [onClose]); + + // 从快照构建事件树 | Build event tree from snapshot + const buildEventsFromSnapshot = useCallback((snap: RenderDebugSnapshot): RenderEvent[] => { + const newEvents: RenderEvent[] = []; + let eventId = 0; + + newEvents.push({ + id: eventId++, + type: 'clear', + name: 'Clear (color)', + drawCalls: 1, + vertices: 0 + }); + + if (snap.sprites.length > 0) { + const spriteChildren: RenderEvent[] = snap.sprites.map((sprite) => ({ + id: eventId++, + type: 'sprite' as RenderEventType, + name: `Draw Sprite: ${sprite.entityName}`, + data: sprite, + drawCalls: 1, + vertices: 4 + })); + + newEvents.push({ + id: eventId++, + type: 'batch', + name: `SpriteBatch (${snap.sprites.length} sprites)`, + children: spriteChildren, + expanded: true, + drawCalls: snap.sprites.length, + vertices: snap.sprites.length * 4 + }); + } + + snap.particles.forEach(ps => { + const particleChildren: RenderEvent[] = ps.sampleParticles.map((p, idx) => ({ + id: eventId++, + type: 'particle' as RenderEventType, + name: `Particle ${idx}: frame=${p.frame}`, + data: { ...p, systemName: ps.systemName }, + drawCalls: 0, + vertices: 4 + })); + + newEvents.push({ + id: eventId++, + type: 'particle', + name: `ParticleSystem: ${ps.entityName} (${ps.activeCount} active)`, + children: particleChildren, + expanded: false, + data: ps, + drawCalls: 1, + vertices: ps.activeCount * 4 + }); + }); + + // UI 元素 | UI elements + if (snap.uiElements && snap.uiElements.length > 0) { + const uiChildren: RenderEvent[] = snap.uiElements.map((ui) => ({ + id: eventId++, + type: 'ui' as RenderEventType, + name: `UI ${ui.type}: ${ui.entityName}`, + data: ui, + drawCalls: 1, + vertices: 4 + })); + + newEvents.push({ + id: eventId++, + type: 'batch', + name: `UIBatch (${snap.uiElements.length} elements)`, + children: uiChildren, + expanded: true, + drawCalls: snap.uiElements.length, + vertices: snap.uiElements.length * 4 + }); + } + + newEvents.push({ + id: eventId++, + type: 'draw', + name: 'BlitFinalToBackBuffer', + drawCalls: 1, + vertices: 3 + }); + + return newEvents; + }, []); + + // 添加快照到历史 | Add snapshot to history + const addToHistory = useCallback((snap: RenderDebugSnapshot) => { + setFrameHistory(prev => { + const newHistory = [...prev, snap]; + if (newHistory.length > MAX_HISTORY_FRAMES) { + newHistory.shift(); + } + return newHistory; + }); + }, []); + + // 跳转到指定帧 | Go to specific frame + const goToFrame = useCallback((index: number) => { + if (index < 0 || index >= frameHistory.length) return; + + setHistoryIndex(index); + const snap = frameHistory[index]; + if (snap) { + setSnapshot(snap); + setEvents(buildEventsFromSnapshot(snap)); + setSelectedEvent(null); + } + }, [frameHistory, buildEventsFromSnapshot]); + + // 返回实时模式 | Return to live mode + const goLive = useCallback(() => { + setHistoryIndex(-1); + setIsPaused(false); + }, []); + + // 刷新数据 | Refresh data + const refreshData = useCallback(() => { + // 独立窗口模式下不直接收集,等待主窗口广播 | In standalone mode, wait for broadcast from main window + if (standalone) return; + // 如果在历史回放模式,不刷新 | Don't refresh if in history playback mode + if (historyIndex >= 0) return; + + renderDebugService.setEnabled(true); + const snap = renderDebugService.collectSnapshot(); + + if (snap) { + setSnapshot(snap); + addToHistory(snap); + setEvents(buildEventsFromSnapshot(snap)); + + // 广播给独立窗口 | Broadcast to standalone windows + emit('render-debug-snapshot', snap).catch(() => {}); + } + }, [standalone, historyIndex, addToHistory, buildEventsFromSnapshot]); + + // 处理接收到的快照数据 | Process received snapshot data + const processSnapshot = useCallback((snap: RenderDebugSnapshot) => { + // 如果在历史回放模式,不处理新数据 | Don't process new data if in history playback mode + if (historyIndex >= 0) return; + + setSnapshot(snap); + addToHistory(snap); + setEvents(buildEventsFromSnapshot(snap)); + }, [historyIndex, addToHistory, buildEventsFromSnapshot]); + + // 独立窗口模式:监听主窗口广播 | Standalone mode: listen to main window broadcast + useEffect(() => { + if (!standalone || !visible) return; + + console.log('[FrameDebugger-Standalone] Setting up listener for render-debug-snapshot'); + + let unlisten: UnlistenFn | null = null; + + listen('render-debug-snapshot', (event) => { + console.log('[FrameDebugger-Standalone] Received snapshot:', event.payload?.frameNumber); + if (!isPaused) { + processSnapshot(event.payload); + } + }).then(fn => { + unlisten = fn; + console.log('[FrameDebugger-Standalone] Listener registered successfully'); + }); + + // 通知主窗口开始收集 | Notify main window to start collecting + console.log('[FrameDebugger-Standalone] Sending render-debug-request-data to main window...'); + emitTo('main', 'render-debug-request-data', {}).then(() => { + console.log('[FrameDebugger-Standalone] Request sent to main window successfully'); + }).catch((err) => { + console.error('[FrameDebugger-Standalone] Failed to send request:', err); + }); + + return () => { + unlisten?.(); + }; + }, [standalone, visible, isPaused, processSnapshot]); + + // 自动刷新(仅主窗口模式且面板可见)| Auto refresh (main window mode only, when panel visible) + useEffect(() => { + if (visible && !isPaused && !standalone) { + refreshData(); + const interval = setInterval(refreshData, 500); + return () => clearInterval(interval); + } + }, [visible, isPaused, standalone, refreshData]); + + // 拖动处理 | Drag handling + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest('.window-header')) { + setIsDragging(true); + setDragOffset({ + x: e.clientX - position.x, + y: e.clientY - position.y + }); + } + }, [position]); + + const handleResizeMouseDown = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + setIsResizing(true); + setDragOffset({ + x: e.clientX, + y: e.clientY + }); + }, []); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (isDragging) { + setPosition({ + x: Math.max(0, e.clientX - dragOffset.x), + y: Math.max(0, e.clientY - dragOffset.y) + }); + } else if (isResizing) { + const dx = e.clientX - dragOffset.x; + const dy = e.clientY - dragOffset.y; + setSize(prev => ({ + width: Math.max(400, prev.width + dx), + height: Math.max(300, prev.height + dy) + })); + setDragOffset({ x: e.clientX, y: e.clientY }); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + setIsResizing(false); + }; + + if (isDragging || isResizing) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + } + }, [isDragging, isResizing, dragOffset]); + + // 绘制预览 | Draw preview + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * window.devicePixelRatio; + canvas.height = rect.height * window.devicePixelRatio; + ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + + // 背景 | Background + ctx.fillStyle = '#1a1a1a'; + ctx.fillRect(0, 0, rect.width, rect.height); + + if (!selectedEvent) { + ctx.fillStyle = '#666'; + ctx.font = '12px system-ui'; + ctx.textAlign = 'center'; + ctx.fillText('Select a render event to preview', rect.width / 2, rect.height / 2); + return; + } + + const data = selectedEvent.data; + const margin = 20; + const viewWidth = rect.width - margin * 2; + const viewHeight = rect.height - margin * 2; + + // ParticleSystem:显示粒子空间分布 | ParticleSystem: show particle spatial distribution + if (selectedEvent.type === 'particle' && data?.sampleParticles?.length > 0) { + const particles = data.sampleParticles; + + // 计算边界 | Calculate bounds + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + particles.forEach((p: any) => { + minX = Math.min(minX, p.x); + maxX = Math.max(maxX, p.x); + minY = Math.min(minY, p.y); + maxY = Math.max(maxY, p.y); + }); + + // 添加边距 | Add padding + const padding = 50; + const rangeX = Math.max(maxX - minX, 100) + padding * 2; + const rangeY = Math.max(maxY - minY, 100) + padding * 2; + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + + const scale = Math.min(viewWidth / rangeX, viewHeight / rangeY); + + // 绘制坐标轴 | Draw axes + ctx.strokeStyle = '#333'; + ctx.lineWidth = 1; + const originX = margin + viewWidth / 2 - centerX * scale; + const originY = margin + viewHeight / 2 + centerY * scale; + + // X 轴 | X axis + ctx.beginPath(); + ctx.moveTo(margin, originY); + ctx.lineTo(margin + viewWidth, originY); + ctx.stroke(); + // Y 轴 | Y axis + ctx.beginPath(); + ctx.moveTo(originX, margin); + ctx.lineTo(originX, margin + viewHeight); + ctx.stroke(); + + // 绘制粒子 | Draw particles + const frameColors = ['#4a9eff', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'] as const; + particles.forEach((p: any, idx: number) => { + const px = margin + viewWidth / 2 + (p.x - centerX) * scale; + const py = margin + viewHeight / 2 - (p.y - centerY) * scale; + const size = Math.max(4, Math.min(20, (p.size ?? 10) * scale * 0.1)); + const color = frameColors[idx % frameColors.length] ?? '#4a9eff'; + const alpha = p.alpha ?? 1; + + ctx.globalAlpha = alpha; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(px, py, size, 0, Math.PI * 2); + ctx.fill(); + + // 标注帧号 | Label frame number + ctx.globalAlpha = 1; + ctx.fillStyle = '#fff'; + ctx.font = '9px Consolas'; + ctx.textAlign = 'center'; + ctx.fillText(`f${p.frame}`, px, py - size - 3); + }); + + ctx.globalAlpha = 1; + + // 显示信息 | Show info + ctx.fillStyle = '#666'; + ctx.font = '10px system-ui'; + ctx.textAlign = 'left'; + ctx.fillText(`${particles.length} particles sampled`, margin, rect.height - 6); + + } else if (data?.uv) { + // Sprite 或单个粒子:显示 UV 区域 | Sprite or single particle: show UV region + const uv = data.uv; + const previewSize = Math.min(viewWidth, viewHeight); + const offsetX = (rect.width - previewSize) / 2; + const offsetY = (rect.height - previewSize) / 2; + + // 绘制纹理边框 | Draw texture border + ctx.strokeStyle = '#333'; + ctx.lineWidth = 1; + ctx.strokeRect(offsetX, offsetY, previewSize, previewSize); + + // 如果是粒子帧,显示 TextureSheet 网格 | If particle frame, show TextureSheet grid + const tilesX = data._animTilesX ?? (data.systemName ? 1 : 1); + const tilesY = data._animTilesY ?? 1; + + if (tilesX > 1 || tilesY > 1) { + const cellWidth = previewSize / tilesX; + const cellHeight = previewSize / tilesY; + + // 绘制网格 | Draw grid + ctx.strokeStyle = '#2a2a2a'; + for (let i = 0; i <= tilesX; i++) { + ctx.beginPath(); + ctx.moveTo(offsetX + i * cellWidth, offsetY); + ctx.lineTo(offsetX + i * cellWidth, offsetY + previewSize); + ctx.stroke(); + } + for (let j = 0; j <= tilesY; j++) { + ctx.beginPath(); + ctx.moveTo(offsetX, offsetY + j * cellHeight); + ctx.lineTo(offsetX + previewSize, offsetY + j * cellHeight); + ctx.stroke(); + } + } + + // 高亮 UV 区域 | Highlight UV region + const x = offsetX + uv[0] * previewSize; + const y = offsetY + uv[1] * previewSize; + const w = (uv[2] - uv[0]) * previewSize; + const h = (uv[3] - uv[1]) * previewSize; + + ctx.fillStyle = 'rgba(74, 158, 255, 0.3)'; + ctx.fillRect(x, y, w, h); + ctx.strokeStyle = '#4a9eff'; + ctx.lineWidth = 2; + ctx.strokeRect(x, y, w, h); + + // 显示 UV 坐标 | Show UV coordinates + ctx.fillStyle = '#4a9eff'; + ctx.font = '10px Consolas, monospace'; + ctx.textAlign = 'left'; + ctx.fillText(`UV: [${uv.map((v: number) => v.toFixed(3)).join(', ')}]`, offsetX, offsetY + previewSize + 14); + + if (data.frame !== undefined) { + ctx.fillText(`Frame: ${data.frame}`, offsetX, offsetY + previewSize + 26); + } + } else { + // 其他事件类型 | Other event types + ctx.fillStyle = '#555'; + ctx.font = '11px system-ui'; + ctx.textAlign = 'center'; + ctx.fillText(selectedEvent.name, rect.width / 2, rect.height / 2 - 10); + ctx.fillStyle = '#444'; + ctx.font = '10px system-ui'; + ctx.fillText('No visual data available', rect.width / 2, rect.height / 2 + 10); + } + }, [selectedEvent]); + + // 切换展开/折叠 | Toggle expand/collapse + const toggleExpand = (event: RenderEvent) => { + setEvents(prev => prev.map(e => { + if (e.id === event.id) { + return { ...e, expanded: !e.expanded }; + } + return e; + })); + }; + + // 导出数据 | Export data + const handleExport = () => { + const json = renderDebugService.exportAsJSON(); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `render-debug-${Date.now()}.json`; + a.click(); + URL.revokeObjectURL(url); + }; + + if (!visible) return null; + + // 独立窗口模式的样式 | Standalone mode styles + const windowStyle = standalone + ? { left: 0, top: 0, width: '100%', height: '100%', borderRadius: 0 } + : { left: position.x, top: position.y, width: size.width, height: size.height }; + + return ( +
+ {/* 头部(可拖动)| Header (draggable) */} +
+
+ + Frame Debugger + {isPaused && ( + PAUSED + )} +
+
+ {!standalone && ( + + )} + +
+
+ + {/* 工具栏 | Toolbar */} +
+
+ + {historyIndex >= 0 && ( + HISTORY + )} +
+ + + + {historyIndex >= 0 + ? `${historyIndex + 1} / ${frameHistory.length}` + : `Frame ${snapshot?.frameNumber ?? 0}`} + + + +
+
+ + +
+
+ + {/* 时间线 | Timeline */} + {frameHistory.length > 0 && ( +
+ = 0 ? historyIndex : frameHistory.length - 1} + onChange={(e) => { + const idx = parseInt(e.target.value); + setIsPaused(true); + goToFrame(idx); + }} + className="timeline-slider" + /> +
+ {frameHistory.length} frames captured + {historyIndex >= 0 && snapshot && ( + Frame #{snapshot.frameNumber} + )} +
+
+ )} + + {/* 主内容区 | Main content */} +
+ {/* 左侧事件列表 | Left: Event list */} +
+
+ Render Events + {events.reduce((sum, e) => sum + (e.drawCalls || 0), 0)} draw calls +
+
+ {events.length === 0 ? ( +
+ No render events captured. +
+ Start preview mode to see events. +
+ ) : ( + events.map(event => ( + + )) + )} +
+
+ + {/* 右侧内容 | Right: Content */} +
+ {/* 预览区 | Preview */} +
+
+ Output +
+
+ +
+
+ + {/* 详情区 | Details */} +
+
+ Details +
+
+ {selectedEvent ? ( + + ) : ( +
+ Select a render event to see details +
+ )} +
+
+
+
+ + {/* 统计栏 | Stats bar */} +
+
+ + Draw Calls: {events.reduce((sum, e) => sum + (e.drawCalls || 0), 0)} +
+
+ + Sprites: {snapshot?.sprites?.length ?? 0} +
+
+ + Particles: {snapshot?.particles?.reduce((sum, p) => sum + p.activeCount, 0) ?? 0} +
+
+ + UI: {snapshot?.uiElements?.length ?? 0} +
+
+ + Systems: {snapshot?.particles?.length ?? 0} +
+
+ + {/* 调整大小手柄(独立模式下隐藏)| Resize handle (hidden in standalone mode) */} + {!standalone &&
} +
+ ); +}; + +// ========== 子组件 | Sub-components ========== + +interface EventItemProps { + event: RenderEvent; + depth: number; + selected: boolean; + onSelect: (event: RenderEvent) => void; + onToggle: (event: RenderEvent) => void; +} + +const EventItem: React.FC = ({ event, depth, selected, onSelect, onToggle }) => { + const hasChildren = event.children && event.children.length > 0; + const iconSize = 12; + + const getTypeIcon = () => { + switch (event.type) { + case 'sprite': return ; + case 'particle': return ; + case 'ui': return ; + case 'batch': return ; + default: return ; + } + }; + + return ( + <> +
onSelect(event)} + > + {hasChildren ? ( + { e.stopPropagation(); onToggle(event); }}> + {event.expanded ? : } + + ) : ( + + )} + {getTypeIcon()} + {event.name} + {event.drawCalls !== undefined && ( + {event.drawCalls} + )} +
+ {hasChildren && event.expanded && event.children!.map(child => ( + + ))} + + ); +}; + +/** + * 纹理预览组件 + * Texture Preview Component + */ +const TexturePreview: React.FC<{ + textureUrl?: string; + texturePath?: string; + label?: string; +}> = ({ textureUrl, texturePath, label = 'Texture' }) => { + return ( +
+ {label} +
+ {textureUrl ? ( +
+ Texture + {texturePath || '-'} +
+ ) : ( + {texturePath || '-'} + )} +
+
+ ); +}; + +interface EventDetailsProps { + event: RenderEvent; +} + +const EventDetails: React.FC = ({ event }) => { + const data = event.data; + const canvasRef = useRef(null); + + // 绘制 TextureSheet 网格 | Draw TextureSheet grid + useEffect(() => { + if (event.type !== 'particle' || !data?.textureSheetAnimation) return; + + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio; + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + ctx.scale(dpr, dpr); + + const tsAnim = data.textureSheetAnimation; + const tilesX = tsAnim.tilesX; + const tilesY = tsAnim.tilesY; + const totalFrames = tsAnim.totalFrames; + + const size = Math.min(rect.width, rect.height); + const offsetX = (rect.width - size) / 2; + const offsetY = (rect.height - size) / 2; + const cellWidth = size / tilesX; + const cellHeight = size / tilesY; + + // 背景 | Background + ctx.fillStyle = '#1a1a1a'; + ctx.fillRect(0, 0, rect.width, rect.height); + + // 绘制网格 | Draw grid + ctx.strokeStyle = '#3a3a3a'; + ctx.lineWidth = 1; + for (let i = 0; i <= tilesX; i++) { + ctx.beginPath(); + ctx.moveTo(offsetX + i * cellWidth, offsetY); + ctx.lineTo(offsetX + i * cellWidth, offsetY + size); + ctx.stroke(); + } + for (let j = 0; j <= tilesY; j++) { + ctx.beginPath(); + ctx.moveTo(offsetX, offsetY + j * cellHeight); + ctx.lineTo(offsetX + size, offsetY + j * cellHeight); + ctx.stroke(); + } + + // 绘制帧编号 | Draw frame numbers + ctx.fillStyle = '#555'; + ctx.font = `${Math.max(8, Math.min(12, cellWidth / 3))}px Consolas`; + ctx.textAlign = 'center'; + for (let frame = 0; frame < totalFrames; frame++) { + const col = frame % tilesX; + const row = Math.floor(frame / tilesX); + ctx.fillText(frame.toString(), offsetX + col * cellWidth + cellWidth / 2, offsetY + row * cellHeight + cellHeight / 2 + 4); + } + + // 高亮活跃帧 | Highlight active frames + const sampleParticles = data.sampleParticles ?? []; + const frameColors = ['#4a9eff', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'] as const; + const usedFrames = new Map(); + sampleParticles.forEach((p: any, idx: number) => { + if (!usedFrames.has(p.frame)) { + usedFrames.set(p.frame, frameColors[idx % frameColors.length] ?? '#4a9eff'); + } + }); + + usedFrames.forEach((color, frame) => { + const col = frame % tilesX; + const row = Math.floor(frame / tilesX); + const x = offsetX + col * cellWidth; + const y = offsetY + row * cellHeight; + + ctx.fillStyle = `${color}40`; + ctx.fillRect(x + 1, y + 1, cellWidth - 2, cellHeight - 2); + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.strokeRect(x + 1, y + 1, cellWidth - 2, cellHeight - 2); + }); + }, [event, data]); + + return ( +
+ + + + + + {data && ( + <> +
Properties
+ + {/* Sprite 数据 | Sprite data */} + {event.type === 'sprite' && data.entityName && ( + <> + + + + + v.toFixed(3)).join(', ')}]` : '-'} highlight /> + + + + + + )} + + {/* 粒子系统数据 | Particle system data */} + {event.type === 'particle' && data.activeCount !== undefined && ( + <> + {data.entityName && } + + + + {data.textureSheetAnimation && ( + <> +
Texture Sheet
+ + + {data.sampleParticles?.length > 0 && ( + (data.sampleParticles.map((p: any) => p.frame))).sort((a, b) => a - b).join(', ')} + highlight + /> + )} + {/* TextureSheet 网格预览 | TextureSheet grid preview */} +
+ +
+ + )} + + )} + + {/* 单个粒子数据 | Single particle data */} + {event.type === 'particle' && data.frame !== undefined && data.activeCount === undefined && ( + <> + {data.systemName && } + + v.toFixed(3)).join(', ')}]` : '-'} /> + + + + + + )} + + {/* UI 元素数据 | UI element data */} + {event.type === 'ui' && data.entityName && ( + <> + + + + + + + + + + + {data.backgroundColor && ( + + )} + {data.textureGuid && ( + + )} + {data.text && ( + <> +
Text
+ 30 ? data.text.slice(0, 30) + '...' : data.text} /> + {data.fontSize && } + + )} + + )} + + )} +
+ ); +}; + +const DetailRow: React.FC<{ label: string; value: string; highlight?: boolean }> = ({ label, value, highlight }) => ( +
+ {label} + {value} +
+); + +export default RenderDebugPanel; diff --git a/packages/editor-app/src/components/debug/index.ts b/packages/editor-app/src/components/debug/index.ts new file mode 100644 index 00000000..308fa792 --- /dev/null +++ b/packages/editor-app/src/components/debug/index.ts @@ -0,0 +1,7 @@ +/** + * 调试组件导出 + * Debug components export + */ + +export { RenderDebugPanel } from './RenderDebugPanel'; +export type { default as RenderDebugPanelProps } from './RenderDebugPanel'; diff --git a/packages/editor-app/src/services/RenderDebugService.ts b/packages/editor-app/src/services/RenderDebugService.ts new file mode 100644 index 00000000..04a95f30 --- /dev/null +++ b/packages/editor-app/src/services/RenderDebugService.ts @@ -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(); + private _texturePending = new Set(); + + /** + * 解析纹理 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 { + 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 = { + '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('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; +}