Files
esengine/packages/editor-app/src/components/debug/RenderDebugPanel.tsx
YHH 536c4c5593 refactor(ui): UI 系统架构重构 (#309)
* 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 的位置和锚点值映射
2025-12-19 15:33:36 +08:00

1701 lines
74 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 渲染调试面板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;