* feat(ui): 动态图集系统与渲染调试增强 ## 核心功能 ### 动态图集系统 (Dynamic Atlas) - 新增 DynamicAtlasManager:运行时纹理打包,支持 MaxRects 算法 - 新增 DynamicAtlasService:自动纹理加载与图集管理 - 新增 BinPacker:高效矩形打包算法 - 支持动态/固定两种扩展策略 - 自动 UV 重映射,实现 UI 元素合批渲染 ### Frame Debugger 增强 - 新增合批分析面板,显示批次中断原因 - 新增 UI 元素层级信息(depth, worldOrderInLayer) - 新增实体高亮功能,点击可在场景中定位 - 新增动态图集可视化面板 - 改进渲染原语详情展示 ### 闪光效果 (Shiny Effect) - 新增 UIShinyEffectComponent:UI 闪光参数配置 - 新增 UIShinyEffectSystem:材质覆盖驱动的闪光动画 - 新增 ShinyEffectComponent/System(Sprite 版本) ## 引擎层改进 ### Rust 纹理管理扩展 - create_blank_texture:创建空白 GPU 纹理 - update_texture_region:局部纹理更新 - 支持动态图集的 GPU 端操作 ### 材质系统 - 新增 effects/ 目录:ShinyEffect 等效果实现 - 新增 interfaces/ 目录:IMaterial 等接口定义 - 新增 mixins/ 目录:可组合的材质功能 ### EngineBridge 扩展 - 新增 createBlankTexture/updateTextureRegion 方法 - 改进纹理加载回调机制 ## UI 渲染改进 - UIRenderCollector:支持合批调试信息 - 稳定排序:addIndex 保证渲染顺序一致性 - 九宫格渲染优化 - 材质覆盖支持 ## 其他改进 - 国际化:新增 Frame Debugger 相关翻译 - 编辑器:新增渲染调试入口 - 文档:新增架构设计文档目录 * refactor(ui): 引入新基础组件架构与渲染工具函数 Phase 1 重构 - 组件职责分离与代码复用: 新增基础组件层: - UIGraphicComponent: 所有可视 UI 元素的基类(颜色、透明度、raycast) - UIImageComponent: 纹理显示组件(支持简单、切片、平铺、填充模式) - UISelectableComponent: 可交互元素的基类(状态管理、颜色过渡) 新增渲染工具: - UIRenderUtils: 提取共享的坐标计算、边框渲染、阴影渲染等工具函数 - getUIRenderTransform: 统一的变换数据提取 - renderBorder/renderShadow: 复用的边框和阴影渲染逻辑 新增渲染系统: - UIGraphicRenderSystem: 处理新基础组件的统一渲染器 重构现有系统: - UIRectRenderSystem: 使用新工具函数,移除重复代码 - UIButtonRenderSystem: 使用新工具函数,移除重复代码 这些改动为后续统一渲染系统奠定基础。 * refactor(ui): UIProgressBarRenderSystem 使用渲染工具函数 - 使用 getUIRenderTransform 替代手动变换计算 - 使用 renderBorder 工具函数替代重复的边框渲染 - 使用 lerpColor 工具函数替代重复的颜色插值 - 简化方法签名,使用 UIRenderTransform 类型 - 移除约 135 行重复代码 * refactor(ui): Slider 和 ScrollView 渲染系统使用工具函数 - UISliderRenderSystem: 使用 getUIRenderTransform,简化方法签名 - UIScrollViewRenderSystem: 使用 getUIRenderTransform,简化方法签名 - 统一使用 UIRenderTransform 类型减少参数传递 - 消除重复的变换计算代码 * refactor(ui): 使用 UIWidgetMarker 消除硬编码组件依赖 - 新增 UIWidgetMarker 标记组件 - UIRectRenderSystem 改为检查标记而非硬编码4种组件类型 - 各 Widget 渲染系统自动添加标记组件 - 减少模块间耦合,提高可扩展性 * feat(ui): 实现 Canvas 隔离机制 - 新增 UICanvasComponent 定义 Canvas 渲染组 - UITransformComponent 添加 Canvas 相关字段:canvasEntityId, worldSortingLayer, pixelPerfect - UILayoutSystem 传播 Canvas 设置给子元素 - UIRenderUtils 使用 Canvas 继承的排序层 - 支持嵌套 Canvas 和不同渲染模式 * refactor(ui): 统一纹理管理工具函数 Phase 4: 纹理管理统一 新增: - UITextureUtils.ts: 统一的纹理描述符接口和验证函数 - UITextureDescriptor: 支持 GUID/textureId/path 多种纹理源 - isValidTextureGuid: GUID 验证 - getTextureKey: 获取用于合批的纹理键 - normalizeTextureDescriptor: 规范化各种输入格式 - utils/index.ts: 工具函数导出 修改: - UIGraphicRenderSystem: 使用新的纹理工具函数 - index.ts: 导出纹理工具类型和函数 * refactor(ui): 实现统一的脏标记机制 Phase 5: Dirty 标记机制 新增: - UIDirtyFlags.ts: 位标记枚举和追踪工具 - UIDirtyFlags: Visual/Layout/Transform/Material/Text 标记 - IDirtyTrackable: 脏追踪接口 - DirtyTracker: 辅助工具类 - 帧级别脏状态追踪 (markFrameDirty, isFrameDirty) 修改: - UIGraphicComponent: 实现 IDirtyTrackable - 属性 setter 自动设置脏标记 - 保留 setDirty/clearDirty 向后兼容 - UIImageComponent: 所有属性支持脏追踪 - textureGuid/imageType/fillAmount 等变化自动标记 - UIGraphicRenderSystem: 使用 clearDirtyFlags() 导出: - UIDirtyFlags, IDirtyTrackable, DirtyTracker - markFrameDirty, isFrameDirty, clearFrameDirty * refactor(ui): 移除过时的 dirty flag API 移除 UIGraphicComponent 中的兼容性 API: - 移除 _isDirty getter/setter - 移除 setDirty() 方法 - 移除 clearDirty() 方法 现在统一使用新的 dirty flag 系统: - isDirty() / hasDirtyFlag(flags) - markDirty(flags) / clearDirtyFlags() * fix(ui): 修复两个 TODO 功能 1. 滑块手柄命中测试 (UIInputSystem) - UISliderComponent 添加 getHandleBounds() 计算手柄边界 - UISliderComponent 添加 isPointInHandle() 精确命中测试 - UIInputSystem.handleSlider() 使用精确测试更新悬停状态 2. 径向填充渲染 (UIGraphicRenderSystem) - 实现 renderRadialFill() 方法 - 支持 radial90/radial180/radial360 三种模式 - 支持 fillOrigin (top/right/bottom/left) 和 fillClockwise - 使用多段矩形近似饼形填充效果 * feat(ui): 完善 UI 系统架构和九宫格渲染 * fix(ui): 修复文本渲染层级问题并清理调试代码 - 修复纹理就绪后调用 invalidateUIRenderCaches() 导致的无限循环 - 移除 UITextRenderSystem、UIButtonRenderSystem、UIRectRenderSystem 中的首帧调试输出 - 移除 UILayoutSystem 中的布局调试日志 - 清理所有 __UI_RENDER_DEBUG__ 条件日志 * refactor(ui): 优化渲染批处理和输入框组件 渲染系统: - 修复 RenderBatcher 保持渲染顺序 - 优化 Rust SpriteBatch 避免合并非连续精灵 - 增强 EngineRenderSystem 纹理就绪检测 输入框组件: - 增强 UIInputFieldComponent 功能 - 改进 UIInputSystem 输入处理 - 新增 TextMeasureService 文本测量服务 * fix(ui): 修复九宫格首帧渲染和InputField输入问题 - 修复九宫格首帧 size=0x0 问题: - Viewport.tsx: 预览模式读取图片尺寸存储到 importSettings - AssetDatabase: ISpriteSettings 添加 width/height 字段 - AssetMetadataService: getTextureSpriteInfo 使用元数据尺寸作为后备 - UIRectRenderSystem: 当 atlasEntry 不存在时使用 spriteInfo 尺寸 - WebBuildPipeline: 构建时包含 importSettings - AssetManager: 从 catalog 初始化时复制 importSettings - AssetTypes: IAssetCatalogEntry 添加 importSettings 字段 - 修复 InputField 无法输入问题: - UIRuntimeModule: manifest 添加 pluginExport: 'UIPlugin' - 确保预览模式正确加载 UI 插件并绑定 UIInputSystem - 添加调试日志用于排查纹理加载问题 * fix(sprite): 修复类型导出错误 MaterialPropertyOverride 和 MaterialOverrides 应从 @esengine/material-system 导出 * fix(ui-editor): 补充 AnchorPreset 拉伸预设的映射 添加 StretchTop, StretchMiddle, StretchBottom, StretchLeft, StretchCenter, StretchRight 的位置和锚点值映射
1701 lines
74 KiB
TypeScript
1701 lines
74 KiB
TypeScript
/**
|
||
* 渲染调试面板(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<RenderDebugPanelProps> = ({ visible, onClose, standalone = false }) => {
|
||
const [isPaused, setIsPaused] = useState(false);
|
||
const [snapshot, setSnapshot] = useState<RenderDebugSnapshot | null>(null);
|
||
const [events, setEvents] = useState<RenderEvent[]>([]);
|
||
const [selectedEvent, setSelectedEvent] = useState<RenderEvent | null>(null);
|
||
|
||
// 帧历史 | Frame history
|
||
const [frameHistory, setFrameHistory] = useState<RenderDebugSnapshot[]>([]);
|
||
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<HTMLCanvasElement>(null);
|
||
const windowRef = useRef<HTMLDivElement>(null);
|
||
|
||
// 高亮相关 | Highlight related
|
||
const previousSelectedIdsRef = useRef<number[] | null>(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<number, UIDebugInfo>();
|
||
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<string, string> = {
|
||
'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<RenderDebugSnapshot>('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 (
|
||
<div
|
||
ref={windowRef}
|
||
className={`render-debug-window ${isDragging ? 'dragging' : ''} ${standalone ? 'standalone' : ''}`}
|
||
style={windowStyle}
|
||
onMouseDown={standalone ? undefined : handleMouseDown}
|
||
>
|
||
{/* 头部(可拖动)| Header (draggable) */}
|
||
<div className="window-header">
|
||
<div className="window-title">
|
||
<Monitor size={16} />
|
||
<span>Frame Debugger</span>
|
||
{isPaused && (
|
||
<span className="paused-badge">PAUSED</span>
|
||
)}
|
||
</div>
|
||
<div className="window-controls">
|
||
{!standalone && (
|
||
<button className="window-btn" onClick={handlePopOut} title="Pop out to separate window">
|
||
<ExternalLink size={14} />
|
||
</button>
|
||
)}
|
||
<button className="window-btn" onClick={onClose} title="Close">
|
||
<X size={14} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 工具栏 | Toolbar */}
|
||
<div className="render-debug-toolbar">
|
||
<div className="toolbar-left">
|
||
<button
|
||
className={`toolbar-btn icon-only ${historyIndex < 0 && !isPaused ? 'recording' : ''}`}
|
||
onClick={() => {
|
||
if (historyIndex >= 0) {
|
||
goLive();
|
||
} else {
|
||
setIsPaused(!isPaused);
|
||
}
|
||
}}
|
||
title={historyIndex >= 0 ? 'Go Live' : (isPaused ? 'Start capturing' : 'Stop capturing')}
|
||
>
|
||
{historyIndex >= 0 ? <Radio size={14} /> : (isPaused ? <Play size={14} /> : <span className="record-dot" />)}
|
||
</button>
|
||
{historyIndex >= 0 && (
|
||
<span className="history-badge">HISTORY</span>
|
||
)}
|
||
<div className="toolbar-separator" />
|
||
<button
|
||
className="toolbar-btn icon-only"
|
||
onClick={() => goToFrame(0)}
|
||
disabled={frameHistory.length === 0}
|
||
title="First Frame"
|
||
>
|
||
<ChevronFirst size={14} />
|
||
</button>
|
||
<button
|
||
className="toolbar-btn icon-only"
|
||
onClick={() => goToFrame(historyIndex > 0 ? historyIndex - 1 : frameHistory.length - 1)}
|
||
disabled={frameHistory.length === 0}
|
||
title="Previous Frame"
|
||
>
|
||
<SkipBack size={14} />
|
||
</button>
|
||
<span className="frame-counter">
|
||
{historyIndex >= 0
|
||
? `${historyIndex + 1} / ${frameHistory.length}`
|
||
: `Frame ${snapshot?.frameNumber ?? 0}`}
|
||
</span>
|
||
<button
|
||
className="toolbar-btn icon-only"
|
||
onClick={() => goToFrame(historyIndex >= 0 ? historyIndex + 1 : 0)}
|
||
disabled={frameHistory.length === 0 || (historyIndex >= 0 && historyIndex >= frameHistory.length - 1)}
|
||
title="Next Frame"
|
||
>
|
||
<SkipForward size={14} />
|
||
</button>
|
||
<button
|
||
className="toolbar-btn icon-only"
|
||
onClick={() => goToFrame(frameHistory.length - 1)}
|
||
disabled={frameHistory.length === 0}
|
||
title="Last Frame"
|
||
>
|
||
<ChevronLast size={14} />
|
||
</button>
|
||
</div>
|
||
<div className="toolbar-right">
|
||
<button className="toolbar-btn icon-only" onClick={refreshData} title="Capture Frame">
|
||
<RefreshCw size={14} />
|
||
</button>
|
||
<button className="toolbar-btn icon-only" onClick={handleExport} title="Export JSON">
|
||
<Download size={14} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 时间线 | Timeline */}
|
||
{frameHistory.length > 0 && (
|
||
<div className="render-debug-timeline">
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={frameHistory.length - 1}
|
||
value={historyIndex >= 0 ? historyIndex : frameHistory.length - 1}
|
||
onChange={(e) => {
|
||
const idx = parseInt(e.target.value);
|
||
setIsPaused(true);
|
||
goToFrame(idx);
|
||
}}
|
||
className="timeline-slider"
|
||
/>
|
||
<div className="timeline-info">
|
||
<span>{frameHistory.length} frames captured</span>
|
||
{historyIndex >= 0 && snapshot && (
|
||
<span>Frame #{snapshot.frameNumber}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 主内容区 | Main content */}
|
||
<div className="render-debug-main">
|
||
{/* 左侧事件列表 | Left: Event list */}
|
||
<div className="render-debug-left">
|
||
<div className="event-list-header">
|
||
<span>Render Events</span>
|
||
<span className="event-count">{events.reduce((sum, e) => sum + (e.drawCalls || 0), 0)} draw calls</span>
|
||
</div>
|
||
<div className="event-list">
|
||
{events.length === 0 ? (
|
||
<div className="event-empty">
|
||
No render events captured.
|
||
<br />
|
||
Start preview mode to see events.
|
||
</div>
|
||
) : (
|
||
events.map(event => (
|
||
<EventItem
|
||
key={event.id}
|
||
event={event}
|
||
depth={0}
|
||
selected={selectedEvent?.id === event.id}
|
||
onSelect={handleEventSelect}
|
||
onToggle={toggleExpand}
|
||
/>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右侧内容 | Right: Content */}
|
||
<div className="render-debug-right">
|
||
{/* 预览区 | Preview */}
|
||
<div className="render-debug-preview">
|
||
<div className="preview-header">
|
||
<span>Output</span>
|
||
</div>
|
||
<div className="preview-canvas-container">
|
||
<canvas ref={canvasRef} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* 详情区 | Details */}
|
||
<div className="render-debug-details">
|
||
<div className="details-header">
|
||
<span>Details</span>
|
||
</div>
|
||
<div className="details-content">
|
||
{selectedEvent ? (
|
||
<EventDetails event={selectedEvent} />
|
||
) : (
|
||
<div className="details-empty">
|
||
Select a render event to see details
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 统计栏 | Stats bar */}
|
||
<div className="render-debug-stats">
|
||
<div className="stat-item">
|
||
<Monitor size={12} />
|
||
<span>Draw Calls: {events.reduce((sum, e) => sum + (e.drawCalls || 0), 0)}</span>
|
||
</div>
|
||
<div className="stat-item">
|
||
<Layers size={12} />
|
||
<span>Sprites: {snapshot?.sprites?.length ?? 0}</span>
|
||
</div>
|
||
<div className="stat-item">
|
||
<Sparkles size={12} />
|
||
<span>Particles: {snapshot?.particles?.reduce((sum, p) => sum + p.activeCount, 0) ?? 0}</span>
|
||
</div>
|
||
<div className="stat-item">
|
||
<Square size={12} />
|
||
<span>UI: {snapshot?.uiElements?.length ?? 0}</span>
|
||
</div>
|
||
<div className="stat-item">
|
||
<Image size={12} />
|
||
<span>Systems: {snapshot?.particles?.length ?? 0}</span>
|
||
</div>
|
||
{/* 动态图集统计 | Dynamic atlas stats */}
|
||
{snapshot?.atlasStats && (
|
||
<div
|
||
className={`stat-item clickable ${snapshot.atlasStats.enabled ? 'atlas-enabled' : 'atlas-disabled'}`}
|
||
title={
|
||
snapshot.atlasStats.enabled
|
||
? `Click to view atlas. ${snapshot.atlasStats.pageCount} pages, ${snapshot.atlasStats.textureCount} textures, ${(snapshot.atlasStats.averageOccupancy * 100).toFixed(0)}% occupancy`
|
||
: 'Dynamic Atlas: Disabled'
|
||
}
|
||
onClick={() => snapshot.atlasStats?.enabled && setShowAtlasPreview(true)}
|
||
>
|
||
<Grid3x3 size={12} />
|
||
<span>
|
||
Atlas: {snapshot.atlasStats.enabled
|
||
? `${snapshot.atlasStats.textureCount}/${snapshot.atlasStats.pageCount}p`
|
||
: 'Off'}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 调整大小手柄(独立模式下隐藏)| Resize handle (hidden in standalone mode) */}
|
||
{!standalone && <div className="resize-handle" onMouseDown={handleResizeMouseDown} />}
|
||
|
||
{/* 图集预览弹窗 | Atlas preview modal */}
|
||
{showAtlasPreview && snapshot?.atlasStats?.pages && (
|
||
<AtlasPreviewModal
|
||
atlasStats={snapshot.atlasStats}
|
||
selectedPage={selectedAtlasPage}
|
||
onSelectPage={setSelectedAtlasPage}
|
||
onClose={() => setShowAtlasPreview(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ========== 子组件 | Sub-components ==========
|
||
|
||
interface EventItemProps {
|
||
event: RenderEvent;
|
||
depth: number;
|
||
selected: boolean;
|
||
onSelect: (event: RenderEvent) => void;
|
||
onToggle: (event: RenderEvent) => void;
|
||
}
|
||
|
||
const EventItem: React.FC<EventItemProps> = ({ 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 <Image size={iconSize} className="event-icon sprite" />;
|
||
case 'particle': return <Sparkles size={iconSize} className="event-icon particle" />;
|
||
case 'ui': return <Square size={iconSize} className={`event-icon ui ${isBatchBreaker ? 'breaker' : ''}`} />;
|
||
case 'ui-batch': return <Layers size={iconSize} className="event-icon ui" />;
|
||
case 'batch': return <Layers size={iconSize} className="event-icon batch" />;
|
||
default: return <Monitor size={iconSize} className="event-icon" />;
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<div
|
||
className={`event-item ${selected ? 'selected' : ''} ${isBatchBreaker ? 'batch-breaker' : ''}`}
|
||
style={{ paddingLeft: 8 + depth * 16 }}
|
||
onClick={() => onSelect(event)}
|
||
>
|
||
{hasChildren ? (
|
||
<span className="expand-icon" onClick={(e) => { e.stopPropagation(); onToggle(event); }}>
|
||
{event.expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||
</span>
|
||
) : (
|
||
<span className="expand-icon placeholder" />
|
||
)}
|
||
{getTypeIcon()}
|
||
<span className={`event-name ${isBatchBreaker ? 'batch-breaker' : ''}`}>{event.name}</span>
|
||
{event.drawCalls !== undefined && event.drawCalls > 0 && (
|
||
<span className="event-draws">{event.drawCalls}</span>
|
||
)}
|
||
</div>
|
||
{hasChildren && event.expanded && event.children!.map(child => (
|
||
<EventItem
|
||
key={child.id}
|
||
event={child}
|
||
depth={depth + 1}
|
||
selected={selected && child.id === event.id}
|
||
onSelect={onSelect}
|
||
onToggle={onToggle}
|
||
/>
|
||
))}
|
||
</>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 纹理预览组件
|
||
* Texture Preview Component
|
||
*/
|
||
const TexturePreview: React.FC<{
|
||
textureUrl?: string;
|
||
texturePath?: string;
|
||
label?: string;
|
||
}> = ({ textureUrl, texturePath, label = 'Texture' }) => {
|
||
return (
|
||
<div className="texture-preview-row">
|
||
<span className="detail-label">{label}</span>
|
||
<div className="texture-preview-content">
|
||
{textureUrl ? (
|
||
<div className="texture-thumbnail-container">
|
||
<img src={textureUrl} alt="Texture" className="texture-thumbnail" />
|
||
<span className="texture-path">{texturePath || '-'}</span>
|
||
</div>
|
||
) : (
|
||
<span className="detail-value">{texturePath || '-'}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
interface EventDetailsProps {
|
||
event: RenderEvent;
|
||
}
|
||
|
||
const EventDetails: React.FC<EventDetailsProps> = ({ event }) => {
|
||
const data = event.data;
|
||
const canvasRef = useRef<HTMLCanvasElement>(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<number, string>();
|
||
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 (
|
||
<div className="details-grid">
|
||
<DetailRow label="Event" value={event.name} />
|
||
<DetailRow label="Type" value={event.type} />
|
||
<DetailRow label="Draw Calls" value={event.drawCalls?.toString() ?? '-'} />
|
||
<DetailRow label="Vertices" value={event.vertices?.toString() ?? '-'} />
|
||
|
||
{/* UI 批次信息 | UI batch info */}
|
||
{event.type === 'ui-batch' && batchInfo && (
|
||
<>
|
||
<div className="details-section">Batch Break Reason</div>
|
||
<DetailRow
|
||
label="Reason"
|
||
value={batchInfo.reason === 'first' ? 'First batch' : batchInfo.reason}
|
||
highlight={batchInfo.reason !== 'first'}
|
||
/>
|
||
<DetailRow label="Detail" value={batchInfo.detail} />
|
||
<div className="details-section">Batch Properties</div>
|
||
<DetailRow label="Batch Index" value={batchInfo.batchIndex.toString()} />
|
||
<DetailRow label="Primitives" value={batchInfo.primitiveCount.toString()} />
|
||
<DetailRow label="Sorting Layer" value={batchInfo.sortingLayer} />
|
||
<DetailRow label="Order" value={batchInfo.orderInLayer.toString()} />
|
||
<DetailRow
|
||
label="Texture"
|
||
value={batchInfo.textureKey.startsWith('atlas:')
|
||
? `🗂️ ${batchInfo.textureKey}`
|
||
: batchInfo.textureKey}
|
||
highlight={batchInfo.textureKey.startsWith('atlas:')}
|
||
/>
|
||
<DetailRow label="Material ID" value={batchInfo.materialId.toString()} />
|
||
{batchInfo.reason !== 'first' && (
|
||
<>
|
||
<div className="details-section">How to Fix</div>
|
||
<div className="batch-fix-tip">
|
||
{batchInfo.reason === 'sortingLayer' && (
|
||
<span>将这些元素放在同一个排序层中</span>
|
||
)}
|
||
{batchInfo.reason === 'texture' && (
|
||
<span>使用相同的纹理,或将纹理合并到图集中</span>
|
||
)}
|
||
{batchInfo.reason === 'material' && (
|
||
<span>使用相同的材质/着色器</span>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{data && (
|
||
<>
|
||
<div className="details-section">Properties</div>
|
||
|
||
{/* Sprite 数据 | Sprite data */}
|
||
{event.type === 'sprite' && data.entityName && (
|
||
<>
|
||
<DetailRow label="Entity" value={data.entityName} />
|
||
<DetailRow label="Position" value={`(${data.x?.toFixed(1)}, ${data.y?.toFixed(1)})`} />
|
||
<DetailRow label="Size" value={`${data.width?.toFixed(0)} x ${data.height?.toFixed(0)}`} />
|
||
<DetailRow label="Rotation" value={`${(data.rotation ?? 0).toFixed(1)}°`} />
|
||
<DetailRow label="UV" value={data.uv ? `[${data.uv.map((v: number) => v.toFixed(3)).join(', ')}]` : '-'} highlight />
|
||
<TexturePreview textureUrl={data.textureUrl} texturePath={data.texturePath} />
|
||
<DetailRow label="Sort Layer" value={data.sortingLayer || 'Default'} />
|
||
<DetailRow label="Order" value={data.orderInLayer?.toString() ?? '0'} />
|
||
<DetailRow label="Alpha" value={data.alpha?.toFixed(2) ?? '1.00'} />
|
||
<div className="details-section">Material</div>
|
||
<DetailRow label="Shader" value={data.shaderName ?? 'DefaultSprite'} highlight />
|
||
<DetailRow label="Shader ID" value={data.materialId?.toString() ?? '0'} />
|
||
{data.uniforms && Object.keys(data.uniforms).length > 0 && (
|
||
<>
|
||
<div className="details-section">Uniforms</div>
|
||
<UniformList uniforms={data.uniforms} />
|
||
</>
|
||
)}
|
||
<div className="details-section">Vertex Attributes</div>
|
||
<DetailRow label="aspectRatio" value={data.aspectRatio?.toFixed(4) ?? '1.0000'} highlight />
|
||
</>
|
||
)}
|
||
|
||
{/* 粒子系统数据 | Particle system data */}
|
||
{event.type === 'particle' && data.activeCount !== undefined && (
|
||
<>
|
||
{data.entityName && <DetailRow label="Entity" value={data.entityName} />}
|
||
<DetailRow label="Active" value={`${data.activeCount} / ${data.maxParticles}`} />
|
||
<DetailRow label="Playing" value={data.isPlaying ? 'Yes' : 'No'} />
|
||
<TexturePreview textureUrl={data.textureUrl} texturePath={data.texturePath} />
|
||
{data.textureSheetAnimation && (
|
||
<>
|
||
<div className="details-section">Texture Sheet</div>
|
||
<DetailRow label="Tiles" value={`${data.textureSheetAnimation.tilesX} x ${data.textureSheetAnimation.tilesY}`} />
|
||
<DetailRow label="Frames" value={data.textureSheetAnimation.totalFrames?.toString() ?? '-'} />
|
||
{data.sampleParticles?.length > 0 && (
|
||
<DetailRow
|
||
label="Active Frames"
|
||
value={Array.from(new Set<number>(data.sampleParticles.map((p: any) => p.frame))).sort((a, b) => a - b).join(', ')}
|
||
highlight
|
||
/>
|
||
)}
|
||
{/* TextureSheet 网格预览 | TextureSheet grid preview */}
|
||
<div className="texture-sheet-preview">
|
||
<canvas ref={canvasRef} style={{ width: '100%', height: '120px' }} />
|
||
</div>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* 单个粒子数据 | Single particle data */}
|
||
{event.type === 'particle' && data.frame !== undefined && data.activeCount === undefined && (
|
||
<>
|
||
{data.systemName && <DetailRow label="System" value={data.systemName} />}
|
||
<DetailRow label="Frame" value={data.frame.toString()} highlight />
|
||
<DetailRow label="UV" value={data.uv ? `[${data.uv.map((v: number) => v.toFixed(3)).join(', ')}]` : '-'} />
|
||
<DetailRow label="Position" value={`(${data.x?.toFixed(1)}, ${data.y?.toFixed(1)})`} />
|
||
<DetailRow label="Size" value={data.size?.toFixed(1) ?? '-'} />
|
||
<DetailRow label="Age/Life" value={`${data.age?.toFixed(2)}s / ${data.lifetime?.toFixed(2)}s`} />
|
||
<DetailRow label="Alpha" value={data.alpha?.toFixed(2) ?? '1.00'} />
|
||
</>
|
||
)}
|
||
|
||
{/* UI 元素数据 | UI element data */}
|
||
{event.type === 'ui' && data.entityName && (
|
||
<>
|
||
{/* 如果是打断合批的元素,显示警告 | Show warning if this element breaks batching */}
|
||
{data.isBatchBreaker && (
|
||
<>
|
||
<div className="details-section batch-breaker-warning">⚡ Batch Breaker</div>
|
||
<div className="batch-fix-tip">
|
||
此元素导致了新的 Draw Call。
|
||
{data.breakReason === 'sortingLayer' && ' 原因:排序层与前一个元素不同。'}
|
||
{data.breakReason === 'orderInLayer' && ' 原因:层内顺序与前一个元素不同。'}
|
||
{data.breakReason === 'texture' && ' 原因:纹理与前一个元素不同。'}
|
||
{data.breakReason === 'material' && ' 原因:材质/着色器与前一个元素不同。'}
|
||
</div>
|
||
</>
|
||
)}
|
||
<DetailRow label="Entity" value={data.entityName} />
|
||
<DetailRow label="Type" value={data.type} highlight />
|
||
<DetailRow label="Position" value={`(${data.x?.toFixed(0)}, ${data.y?.toFixed(0)})`} />
|
||
<DetailRow label="World Pos" value={`(${data.worldX?.toFixed(0)}, ${data.worldY?.toFixed(0)})`} />
|
||
<DetailRow label="Size" value={`${data.width?.toFixed(0)} x ${data.height?.toFixed(0)}`} />
|
||
<DetailRow label="Rotation" value={`${((data.rotation ?? 0) * 180 / Math.PI).toFixed(1)}°`} />
|
||
<DetailRow label="Visible" value={data.visible ? 'Yes' : 'No'} />
|
||
<DetailRow label="Alpha" value={data.alpha?.toFixed(2) ?? '1.00'} />
|
||
<div className="details-section">Sorting</div>
|
||
<DetailRow label="Sort Layer" value={data.sortingLayer || 'UI'} highlight={data.isBatchBreaker && data.breakReason === 'sortingLayer'} />
|
||
<DetailRow label="Order" value={data.orderInLayer?.toString() ?? '0'} />
|
||
<DetailRow label="Depth" value={data.depth?.toString() ?? '0'} />
|
||
<DetailRow label="World Order" value={data.worldOrderInLayer?.toString() ?? '0'} highlight />
|
||
{data.backgroundColor && (
|
||
<DetailRow label="Background" value={data.backgroundColor} />
|
||
)}
|
||
{data.textureGuid && (
|
||
<TexturePreview textureUrl={data.textureUrl} texturePath={data.textureGuid} />
|
||
)}
|
||
{!data.textureGuid && data.isBatchBreaker && data.breakReason === 'texture' && (
|
||
<DetailRow label="Texture" value="(none / solid)" highlight />
|
||
)}
|
||
{data.text && (
|
||
<>
|
||
<div className="details-section">Text</div>
|
||
<DetailRow label="Content" value={data.text.length > 30 ? data.text.slice(0, 30) + '...' : data.text} />
|
||
{data.fontSize && <DetailRow label="Font Size" value={data.fontSize.toString()} />}
|
||
</>
|
||
)}
|
||
<div className="details-section">Material</div>
|
||
<DetailRow label="Shader" value={data.shaderName ?? 'DefaultSprite'} highlight={data.isBatchBreaker && data.breakReason === 'material'} />
|
||
<DetailRow label="Shader ID" value={data.materialId?.toString() ?? '0'} highlight={data.isBatchBreaker && data.breakReason === 'material'} />
|
||
{data.uniforms && Object.keys(data.uniforms).length > 0 && (
|
||
<>
|
||
<div className="details-section">Uniforms</div>
|
||
<UniformList uniforms={data.uniforms} />
|
||
</>
|
||
)}
|
||
<div className="details-section">Vertex Attributes</div>
|
||
<DetailRow label="aspectRatio" value={data.aspectRatio?.toFixed(4) ?? '1.0000'} highlight />
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const DetailRow: React.FC<{ label: string; value: string; highlight?: boolean }> = ({ label, value, highlight }) => (
|
||
<div className={`detail-row ${highlight ? 'highlight' : ''}`}>
|
||
<span className="detail-label">{label}</span>
|
||
<span className="detail-value">{value}</span>
|
||
</div>
|
||
);
|
||
|
||
/**
|
||
* 格式化 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<string, UniformDebugValue> }> = ({ uniforms }) => {
|
||
const entries = Object.entries(uniforms);
|
||
if (entries.length === 0) {
|
||
return <DetailRow label="Uniforms" value="(none)" />;
|
||
}
|
||
return (
|
||
<>
|
||
{entries.map(([name, uniform]) => (
|
||
<DetailRow
|
||
key={name}
|
||
label={name.replace(/^u_/, '')}
|
||
value={`${formatUniformValue(uniform)} (${uniform.type})`}
|
||
/>
|
||
))}
|
||
</>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 图集预览弹窗组件
|
||
* Atlas Preview Modal Component
|
||
*/
|
||
interface AtlasPreviewModalProps {
|
||
atlasStats: AtlasStats;
|
||
selectedPage: number;
|
||
onSelectPage: (page: number) => void;
|
||
onClose: () => void;
|
||
}
|
||
|
||
const AtlasPreviewModal: React.FC<AtlasPreviewModalProps> = ({
|
||
atlasStats,
|
||
selectedPage,
|
||
onSelectPage,
|
||
onClose
|
||
}) => {
|
||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||
const [hoveredEntry, setHoveredEntry] = useState<AtlasEntryDebugInfo | null>(null);
|
||
const [loadedImages, setLoadedImages] = useState<Map<string, HTMLImageElement>>(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<string, HTMLImageElement>();
|
||
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<HTMLCanvasElement>) => {
|
||
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<HTMLCanvasElement>) => {
|
||
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<HTMLCanvasElement>) => {
|
||
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 (
|
||
<div className="atlas-preview-modal" onClick={onClose}>
|
||
<div className="atlas-preview-content" onClick={e => e.stopPropagation()}>
|
||
<div className="atlas-preview-header">
|
||
<span>Dynamic Atlas Preview</span>
|
||
<button className="window-btn" onClick={onClose}>
|
||
<X size={14} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* 页面选择器 | Page selector */}
|
||
{atlasStats.pages.length > 1 && (
|
||
<div className="atlas-page-tabs">
|
||
{atlasStats.pages.map((page, idx) => (
|
||
<button
|
||
key={idx}
|
||
className={`atlas-page-tab ${selectedPage === idx ? 'active' : ''}`}
|
||
onClick={() => onSelectPage(idx)}
|
||
>
|
||
Page {idx} ({(page.occupancy * 100).toFixed(0)}%)
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* 图集可视化 | Atlas visualization */}
|
||
<div className="atlas-preview-canvas-container">
|
||
<canvas
|
||
ref={canvasRef}
|
||
onMouseMove={handleMouseMove}
|
||
onMouseDown={handleMouseDown}
|
||
onMouseUp={handleMouseUp}
|
||
onMouseLeave={() => { setHoveredEntry(null); setIsPanning(false); }}
|
||
onWheel={handleWheel}
|
||
onDoubleClick={handleDoubleClick}
|
||
style={{ cursor: isPanning ? 'grabbing' : 'grab' }}
|
||
/>
|
||
</div>
|
||
|
||
{/* 悬停信息 | Hover info */}
|
||
<div className="atlas-preview-info">
|
||
{hoveredEntry ? (
|
||
<>
|
||
<div className="atlas-entry-info">
|
||
<span className="label">GUID:</span>
|
||
<span className="value">{hoveredEntry.guid.slice(0, 8)}...</span>
|
||
</div>
|
||
<div className="atlas-entry-info">
|
||
<span className="label">Position:</span>
|
||
<span className="value">({hoveredEntry.x}, {hoveredEntry.y})</span>
|
||
</div>
|
||
<div className="atlas-entry-info">
|
||
<span className="label">Size:</span>
|
||
<span className="value">{hoveredEntry.width} x {hoveredEntry.height}</span>
|
||
</div>
|
||
<div className="atlas-entry-info">
|
||
<span className="label">UV:</span>
|
||
<span className="value">[{hoveredEntry.uv.map(v => v.toFixed(3)).join(', ')}]</span>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<span className="hint">Scroll to zoom, drag to pan, double-click to reset</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* 统计信息 | Statistics */}
|
||
<div className="atlas-preview-stats">
|
||
<span>Total: {atlasStats.textureCount} textures in {atlasStats.pageCount} page(s)</span>
|
||
<span>Avg Occupancy: {(atlasStats.averageOccupancy * 100).toFixed(1)}%</span>
|
||
{atlasStats.loadingCount > 0 && <span>Loading: {atlasStats.loadingCount}</span>}
|
||
{atlasStats.failedCount > 0 && <span className="error">Failed: {atlasStats.failedCount}</span>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default RenderDebugPanel;
|