/** * 渲染调试面板(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, Grid3x3 } 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, type UniformDebugValue, type AtlasStats, type AtlasPageDebugInfo, type AtlasEntryDebugInfo } from '../../services/RenderDebugService'; import type { BatchDebugInfo } from '@esengine/ui'; import { EngineService } from '../../services/EngineService'; import './RenderDebugPanel.css'; /** * 渲染事件类型 * Render event type */ type RenderEventType = 'clear' | 'sprite' | 'particle' | 'ui' | 'batch' | 'draw' | 'ui-batch'; /** * 渲染事件 * Render event */ interface RenderEvent { id: number; type: RenderEventType; name: string; children?: RenderEvent[]; expanded?: boolean; data?: SpriteDebugInfo | ParticleDebugInfo | UIDebugInfo | any; drawCalls?: number; vertices?: number; /** 合批调试信息 | Batch debug info */ batchInfo?: BatchDebugInfo; } 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 // 图集预览状态 | Atlas preview state const [showAtlasPreview, setShowAtlasPreview] = useState(false); const [selectedAtlasPage, setSelectedAtlasPage] = useState(0); // 窗口拖动状态 | 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); // 高亮相关 | Highlight related const previousSelectedIdsRef = useRef(null); const engineService = useRef(EngineService.getInstance()); // 处理事件选中并高亮实体 | Handle event selection and highlight entity const handleEventSelect = useCallback((event: RenderEvent | null) => { setSelectedEvent(event); // 获取实体 ID | Get entity ID const entityId = event?.data?.entityId; if (entityId !== undefined) { // 保存原始选中状态(只保存一次)| Save original selection (only once) if (previousSelectedIdsRef.current === null) { previousSelectedIdsRef.current = engineService.current.getSelectedEntityIds?.() || []; } // 高亮选中的实体 | Highlight selected entity engineService.current.setSelectedEntityIds([entityId]); } else if (previousSelectedIdsRef.current !== null) { // 恢复原始选中状态 | Restore original selection engineService.current.setSelectedEntityIds(previousSelectedIdsRef.current); previousSelectedIdsRef.current = null; } }, []); // 面板关闭时恢复原始选中状态 | Restore original selection when panel closes useEffect(() => { if (!visible && previousSelectedIdsRef.current !== null) { engineService.current.setSelectedEntityIds(previousSelectedIdsRef.current); previousSelectedIdsRef.current = null; } }, [visible]); // 弹出为独立窗口 | 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 batches and elements // 使用 entityIds 进行精确的批次-元素匹配 | Use entityIds for precise batch-element matching if (snap.uiBatches && snap.uiBatches.length > 0) { const uiChildren: RenderEvent[] = []; // 构建 entityId -> UI 元素的映射 | Build entityId -> UI element map const uiElementMap = new Map(); snap.uiElements?.forEach(ui => { if (ui.entityId !== undefined) { uiElementMap.set(ui.entityId, ui); } }); // 为每个批次创建事件,包含其子元素 | Create events for each batch with its child elements snap.uiBatches.forEach((batch) => { const reasonLabels: Record = { 'first': '', 'sortingLayer': '⚠️ Layer', 'texture': '⚠️ Texture', 'material': '⚠️ Material' }; const reasonLabel = reasonLabels[batch.reason] || ''; const batchName = batch.reason === 'first' ? `DC ${batch.batchIndex}: ${batch.primitiveCount} prims` : `DC ${batch.batchIndex} ${reasonLabel}: ${batch.primitiveCount} prims`; // 从 entityIds 获取此批次的 UI 元素 | Get UI elements for this batch from entityIds const batchElements: RenderEvent[] = []; const entityIds = batch.entityIds ?? []; const firstEntityId = batch.firstEntityId; entityIds.forEach((entityId) => { const ui = uiElementMap.get(entityId); if (ui) { // 使用 firstEntityId 精确标记打断批次的元素 | Use firstEntityId to precisely mark batch breaker const isBreaker = entityId === firstEntityId && batch.reason !== 'first'; batchElements.push({ id: eventId++, type: 'ui' as RenderEventType, name: isBreaker ? `⚡ ${ui.type}: ${ui.entityName}` : `${ui.type}: ${ui.entityName}`, data: { ...ui, isBatchBreaker: isBreaker, breakReason: isBreaker ? batch.reason : undefined, batchIndex: batch.batchIndex }, drawCalls: 0, vertices: 4 }); } }); uiChildren.push({ id: eventId++, type: 'ui-batch' as RenderEventType, name: batchName, batchInfo: batch, children: batchElements.length > 0 ? batchElements : undefined, expanded: batchElements.length > 0 && batchElements.length <= 10, drawCalls: 1, vertices: batch.primitiveCount * 4 }); }); const totalPrimitives = snap.uiBatches.reduce((sum, b) => sum + b.primitiveCount, 0); const dcCount = snap.uiBatches.length; newEvents.push({ id: eventId++, type: 'batch', name: `UI Render (${dcCount} DC, ${snap.uiElements?.length ?? 0} elements)`, children: uiChildren, expanded: true, drawCalls: dcCount, vertices: totalPrimitives * 4 }); } else if (snap.uiElements && snap.uiElements.length > 0) { // 回退:没有批次信息时按元素显示 | Fallback: show by element when no batch info 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)); handleEventSelect(null); } }, [frameHistory, buildEventsFromSnapshot, handleEventSelect]); // 返回实时模式 | 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 || data?.textureUrl) { // Sprite 或 UI 元素:显示纹理和 UV 区域 | Sprite or UI element: show texture and UV region const uv = data.uv ?? [0, 0, 1, 1]; const previewSize = Math.min(viewWidth, viewHeight) - 30; // 留出底部文字空间 const offsetX = (rect.width - previewSize) / 2; const offsetY = margin; // 绘制棋盘格背景(透明度指示)| Draw checkerboard background (transparency indicator) const checkerSize = 8; for (let cx = 0; cx < previewSize; cx += checkerSize) { for (let cy = 0; cy < previewSize; cy += checkerSize) { const isLight = ((cx / checkerSize) + (cy / checkerSize)) % 2 === 0; ctx.fillStyle = isLight ? '#2a2a2a' : '#1f1f1f'; ctx.fillRect(offsetX + cx, offsetY + cy, checkerSize, checkerSize); } } // 如果有纹理 URL,加载并绘制纹理 | If texture URL exists, load and draw texture if (data.textureUrl) { const img = document.createElement('img'); img.onload = () => { // 重新获取 context(异步回调中需要)| Re-get context (needed in async callback) const ctx2 = canvas.getContext('2d'); if (!ctx2) return; ctx2.scale(window.devicePixelRatio, window.devicePixelRatio); // 绘制纹理 | Draw texture ctx2.drawImage(img, offsetX, offsetY, previewSize, previewSize); // 高亮 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; ctx2.fillStyle = 'rgba(74, 158, 255, 0.2)'; ctx2.fillRect(x, y, w, h); ctx2.strokeStyle = '#4a9eff'; ctx2.lineWidth = 2; ctx2.strokeRect(x, y, w, h); // 绘制边框 | Draw border ctx2.strokeStyle = '#444'; ctx2.lineWidth = 1; ctx2.strokeRect(offsetX, offsetY, previewSize, previewSize); // 显示信息 | Show info ctx2.fillStyle = '#4a9eff'; ctx2.font = '10px Consolas, monospace'; ctx2.textAlign = 'left'; const infoY = offsetY + previewSize + 14; ctx2.fillText(`UV: [${uv.map((v: number) => v.toFixed(3)).join(', ')}]`, offsetX, infoY); if (data.aspectRatio !== undefined) { ctx2.fillStyle = '#10b981'; ctx2.fillText(`aspectRatio: ${data.aspectRatio.toFixed(4)}`, offsetX + 180, infoY); } if (data.color) { ctx2.fillStyle = '#f59e0b'; ctx2.fillText(`color: ${data.color}`, offsetX, infoY + 12); } }; img.src = data.textureUrl; } else { // 没有纹理时绘制占位符 | Draw placeholder when no texture ctx.strokeStyle = '#333'; ctx.lineWidth = 1; ctx.strokeRect(offsetX, offsetY, previewSize, previewSize); // 如果是粒子帧,显示 TextureSheet 网格 | If particle frame, show TextureSheet grid const tilesX = data._animTilesX ?? 1; const tilesY = data._animTilesY ?? 1; if (tilesX > 1 || tilesY > 1) { const cellWidth = previewSize / tilesX; const cellHeight = previewSize / tilesY; 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); // 显示信息 | Show info ctx.fillStyle = '#4a9eff'; ctx.font = '10px Consolas, monospace'; ctx.textAlign = 'left'; const infoY = offsetY + previewSize + 14; ctx.fillText(`UV: [${uv.map((v: number) => v.toFixed(3)).join(', ')}]`, offsetX, infoY); if (data.aspectRatio !== undefined) { ctx.fillStyle = '#10b981'; ctx.fillText(`aspectRatio: ${data.aspectRatio.toFixed(4)}`, offsetX + 180, infoY); } if (data.frame !== undefined) { ctx.fillText(`Frame: ${data.frame}`, offsetX, infoY + 12); } } } 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}
{/* 动态图集统计 | Dynamic atlas stats */} {snapshot?.atlasStats && (
snapshot.atlasStats?.enabled && setShowAtlasPreview(true)} > Atlas: {snapshot.atlasStats.enabled ? `${snapshot.atlasStats.textureCount}/${snapshot.atlasStats.pageCount}p` : 'Off'}
)}
{/* 调整大小手柄(独立模式下隐藏)| Resize handle (hidden in standalone mode) */} {!standalone &&
} {/* 图集预览弹窗 | Atlas preview modal */} {showAtlasPreview && snapshot?.atlasStats?.pages && ( setShowAtlasPreview(false)} /> )}
); }; // ========== 子组件 | 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 isBatchBreaker = event.data?.isBatchBreaker === true; const getTypeIcon = () => { switch (event.type) { case 'sprite': return ; case 'particle': return ; case 'ui': return ; case 'ui-batch': return ; case 'batch': return ; default: return ; } }; return ( <>
onSelect(event)} > {hasChildren ? ( { e.stopPropagation(); onToggle(event); }}> {event.expanded ? : } ) : ( )} {getTypeIcon()} {event.name} {event.drawCalls !== undefined && event.drawCalls > 0 && ( {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]); const batchInfo = event.batchInfo; return (
{/* UI 批次信息 | UI batch info */} {event.type === 'ui-batch' && batchInfo && ( <>
Batch Break Reason
Batch Properties
{batchInfo.reason !== 'first' && ( <>
How to Fix
{batchInfo.reason === 'sortingLayer' && ( 将这些元素放在同一个排序层中 )} {batchInfo.reason === 'texture' && ( 使用相同的纹理,或将纹理合并到图集中 )} {batchInfo.reason === 'material' && ( 使用相同的材质/着色器 )}
)} )} {data && ( <>
Properties
{/* Sprite 数据 | Sprite data */} {event.type === 'sprite' && data.entityName && ( <> v.toFixed(3)).join(', ')}]` : '-'} highlight />
Material
{data.uniforms && Object.keys(data.uniforms).length > 0 && ( <>
Uniforms
)}
Vertex Attributes
)} {/* 粒子系统数据 | 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 && ( <> {/* 如果是打断合批的元素,显示警告 | Show warning if this element breaks batching */} {data.isBatchBreaker && ( <>
⚡ Batch Breaker
此元素导致了新的 Draw Call。 {data.breakReason === 'sortingLayer' && ' 原因:排序层与前一个元素不同。'} {data.breakReason === 'orderInLayer' && ' 原因:层内顺序与前一个元素不同。'} {data.breakReason === 'texture' && ' 原因:纹理与前一个元素不同。'} {data.breakReason === 'material' && ' 原因:材质/着色器与前一个元素不同。'}
)}
Sorting
{data.backgroundColor && ( )} {data.textureGuid && ( )} {!data.textureGuid && data.isBatchBreaker && data.breakReason === 'texture' && ( )} {data.text && ( <>
Text
30 ? data.text.slice(0, 30) + '...' : data.text} /> {data.fontSize && } )}
Material
{data.uniforms && Object.keys(data.uniforms).length > 0 && ( <>
Uniforms
)}
Vertex Attributes
)} )}
); }; const DetailRow: React.FC<{ label: string; value: string; highlight?: boolean }> = ({ label, value, highlight }) => (
{label} {value}
); /** * 格式化 uniform 值 * Format uniform value */ function formatUniformValue(uniform: UniformDebugValue): string { const { type, value } = uniform; if (typeof value === 'number') { return type === 'int' ? value.toString() : value.toFixed(4); } if (Array.isArray(value)) { return value.map(v => v.toFixed(3)).join(', '); } return String(value); } /** * Uniform 列表组件 * Uniform list component */ const UniformList: React.FC<{ uniforms: Record }> = ({ uniforms }) => { const entries = Object.entries(uniforms); if (entries.length === 0) { return ; } return ( <> {entries.map(([name, uniform]) => ( ))} ); }; /** * 图集预览弹窗组件 * Atlas Preview Modal Component */ interface AtlasPreviewModalProps { atlasStats: AtlasStats; selectedPage: number; onSelectPage: (page: number) => void; onClose: () => void; } const AtlasPreviewModal: React.FC = ({ atlasStats, selectedPage, onSelectPage, onClose }) => { const canvasRef = useRef(null); const [hoveredEntry, setHoveredEntry] = useState(null); const [loadedImages, setLoadedImages] = useState>(new Map()); // 缩放和平移状态 | Zoom and pan state const [zoom, setZoom] = useState(1); const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); const [isPanning, setIsPanning] = useState(false); const [lastMousePos, setLastMousePos] = useState({ x: 0, y: 0 }); const currentPage = atlasStats.pages[selectedPage]; // 重置视图当页面切换时 | Reset view when page changes useEffect(() => { setZoom(1); setPanOffset({ x: 0, y: 0 }); }, [selectedPage]); // 预加载所有纹理图像 | Preload all texture images useEffect(() => { if (!currentPage) return; const newImages = new Map(); let loadCount = 0; const totalCount = currentPage.entries.filter(e => e.dataUrl).length; currentPage.entries.forEach(entry => { if (entry.dataUrl) { const img = document.createElement('img'); img.onload = () => { newImages.set(entry.guid, img); loadCount++; if (loadCount === totalCount) { setLoadedImages(new Map(newImages)); } }; img.onerror = () => { loadCount++; if (loadCount === totalCount) { setLoadedImages(new Map(newImages)); } }; img.src = entry.dataUrl; } }); // 如果没有图像需要加载,立即设置空 Map if (totalCount === 0) { setLoadedImages(new Map()); } }, [currentPage]); // 绘制图集预览 | Draw atlas preview useEffect(() => { const canvas = canvasRef.current; if (!canvas || !currentPage) 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 pageSize = currentPage.width; // 基础缩放:让图集适应画布 | Base scale: fit atlas to canvas const baseScale = Math.min(rect.width, rect.height) / pageSize * 0.9; // 应用用户缩放 | Apply user zoom const scale = baseScale * zoom; // 计算中心偏移 + 用户平移 | Calculate center offset + user pan const offsetX = (rect.width - pageSize * scale) / 2 + panOffset.x; const offsetY = (rect.height - pageSize * scale) / 2 + panOffset.y; // 背景 | Background ctx.fillStyle = '#1a1a1a'; ctx.fillRect(0, 0, rect.width, rect.height); // 棋盘格背景(在图集区域内)| Checkerboard background (inside atlas area) ctx.save(); ctx.beginPath(); ctx.rect(offsetX, offsetY, pageSize * scale, pageSize * scale); ctx.clip(); const checkerSize = Math.max(8, 16 * zoom); for (let cx = 0; cx < pageSize * scale; cx += checkerSize) { for (let cy = 0; cy < pageSize * scale; cy += checkerSize) { const isLight = (Math.floor(cx / checkerSize) + Math.floor(cy / checkerSize)) % 2 === 0; ctx.fillStyle = isLight ? '#2a2a2a' : '#222'; ctx.fillRect(offsetX + cx, offsetY + cy, checkerSize, checkerSize); } } ctx.restore(); // 绘制图集边框 | Draw atlas border ctx.strokeStyle = '#444'; ctx.lineWidth = 1; ctx.strokeRect(offsetX, offsetY, pageSize * scale, pageSize * scale); // 绘制每个纹理区域 | Draw each texture region const colors = ['#4a9eff', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16']; currentPage.entries.forEach((entry, idx) => { const x = offsetX + entry.x * scale; const y = offsetY + entry.y * scale; const w = entry.width * scale; const h = entry.height * scale; const color = colors[idx % colors.length] ?? '#4a9eff'; const isHovered = hoveredEntry?.guid === entry.guid; // 尝试绘制图像 | Try to draw image const img = loadedImages.get(entry.guid); if (img) { ctx.drawImage(img, x, y, w, h); } else { // 没有图像时显示占位背景 | Show placeholder when no image ctx.fillStyle = `${color}40`; ctx.fillRect(x, y, w, h); } // 边框 | Border ctx.strokeStyle = isHovered ? '#fff' : (img ? '#333' : color); ctx.lineWidth = isHovered ? 2 : 1; ctx.strokeRect(x, y, w, h); // 高亮时显示尺寸标签 | Show size label when hovered if (isHovered || (!img && w > 30 && h > 20)) { // 半透明背景 | Semi-transparent background ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; const labelText = `${entry.width}x${entry.height}`; ctx.font = `${Math.max(10, 10 * zoom)}px Consolas`; const textWidth = ctx.measureText(labelText).width; ctx.fillRect(x + w / 2 - textWidth / 2 - 4, y + h / 2 - 8, textWidth + 8, 16); ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; ctx.fillText(labelText, x + w / 2, y + h / 2 + 4); } }); // 绘制信息 | Draw info ctx.fillStyle = '#666'; ctx.font = '11px system-ui'; ctx.textAlign = 'left'; ctx.fillText(`${currentPage.width}x${currentPage.height} | ${(currentPage.occupancy * 100).toFixed(1)}% | Zoom: ${(zoom * 100).toFixed(0)}%`, 8, rect.height - 8); }, [currentPage, hoveredEntry, loadedImages, zoom, panOffset]); // 鼠标悬停检测和拖动 | Mouse hover detection and dragging const handleMouseMove = useCallback((e: React.MouseEvent) => { const canvas = canvasRef.current; if (!canvas || !currentPage) return; // 处理拖动平移 | Handle pan dragging if (isPanning) { const dx = e.clientX - lastMousePos.x; const dy = e.clientY - lastMousePos.y; setPanOffset(prev => ({ x: prev.x + dx, y: prev.y + dy })); setLastMousePos({ x: e.clientX, y: e.clientY }); return; } const rect = canvas.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const pageSize = currentPage.width; const baseScale = Math.min(rect.width, rect.height) / pageSize * 0.9; const scale = baseScale * zoom; const offsetX = (rect.width - pageSize * scale) / 2 + panOffset.x; const offsetY = (rect.height - pageSize * scale) / 2 + panOffset.y; // 检查是否悬停在某个条目上 | Check if hovering over an entry let found: AtlasEntryDebugInfo | null = null; for (const entry of currentPage.entries) { const x = offsetX + entry.x * scale; const y = offsetY + entry.y * scale; const w = entry.width * scale; const h = entry.height * scale; if (mouseX >= x && mouseX <= x + w && mouseY >= y && mouseY <= y + h) { found = entry; break; } } setHoveredEntry(found); }, [currentPage, isPanning, lastMousePos, zoom, panOffset]); // 滚轮缩放 | Wheel zoom const handleWheel = useCallback((e: React.WheelEvent) => { e.preventDefault(); const delta = e.deltaY > 0 ? 0.9 : 1.1; setZoom(prev => Math.max(0.5, Math.min(10, prev * delta))); }, []); // 开始拖动 | Start dragging const handleMouseDown = useCallback((e: React.MouseEvent) => { if (e.button === 0 || e.button === 1) { // 左键或中键 | Left or middle button setIsPanning(true); setLastMousePos({ x: e.clientX, y: e.clientY }); } }, []); // 结束拖动 | End dragging const handleMouseUp = useCallback(() => { setIsPanning(false); }, []); // 双击重置视图 | Double click to reset view const handleDoubleClick = useCallback(() => { setZoom(1); setPanOffset({ x: 0, y: 0 }); }, []); return (
e.stopPropagation()}>
Dynamic Atlas Preview
{/* 页面选择器 | Page selector */} {atlasStats.pages.length > 1 && (
{atlasStats.pages.map((page, idx) => ( ))}
)} {/* 图集可视化 | Atlas visualization */}
{ setHoveredEntry(null); setIsPanning(false); }} onWheel={handleWheel} onDoubleClick={handleDoubleClick} style={{ cursor: isPanning ? 'grabbing' : 'grab' }} />
{/* 悬停信息 | Hover info */}
{hoveredEntry ? ( <>
GUID: {hoveredEntry.guid.slice(0, 8)}...
Position: ({hoveredEntry.x}, {hoveredEntry.y})
Size: {hoveredEntry.width} x {hoveredEntry.height}
UV: [{hoveredEntry.uv.map(v => v.toFixed(3)).join(', ')}]
) : ( Scroll to zoom, drag to pan, double-click to reset )}
{/* 统计信息 | Statistics */}
Total: {atlasStats.textureCount} textures in {atlasStats.pageCount} page(s) Avg Occupancy: {(atlasStats.averageOccupancy * 100).toFixed(1)}% {atlasStats.loadingCount > 0 && Loading: {atlasStats.loadingCount}} {atlasStats.failedCount > 0 && Failed: {atlasStats.failedCount}}
); }; export default RenderDebugPanel;