feat(profiler): 实现高级性能分析器 (#248)

* feat(profiler): 实现高级性能分析器

* test(core): 添加 ProfilerSDK 和 AdvancedProfilerCollector 测试覆盖

* test(core): 添加 ProfilerSDK 和 AdvancedProfilerCollector 测试覆盖
This commit is contained in:
YHH
2025-11-30 00:22:47 +08:00
committed by GitHub
parent 359886c72f
commit 374e08a79e
35 changed files with 4168 additions and 9096 deletions

View File

@@ -37,6 +37,7 @@ import { AssetBrowser } from './components/AssetBrowser';
import { ConsolePanel } from './components/ConsolePanel';
import { Viewport } from './components/Viewport';
import { ProfilerWindow } from './components/ProfilerWindow';
import { AdvancedProfilerWindow } from './components/AdvancedProfilerWindow';
import { PortManager } from './components/PortManager';
import { SettingsWindow } from './components/SettingsWindow';
import { AboutDialog } from './components/AboutDialog';
@@ -114,6 +115,7 @@ function App() {
const {
showProfiler, setShowProfiler,
showAdvancedProfiler, setShowAdvancedProfiler,
showPortManager, setShowPortManager,
showSettings, setShowSettings,
showAbout, setShowAbout,
@@ -266,6 +268,8 @@ function App() {
if (windowId === 'profiler') {
setShowProfiler(true);
} else if (windowId === 'advancedProfiler') {
setShowAdvancedProfiler(true);
} else if (windowId === 'pluginManager') {
// 插件管理现在整合到设置窗口中
setSettingsInitialCategory('plugins');
@@ -947,6 +951,10 @@ function App() {
<ProfilerWindow onClose={() => setShowProfiler(false)} />
)}
{showAdvancedProfiler && (
<AdvancedProfilerWindow onClose={() => setShowAdvancedProfiler(false)} />
)}
{showPortManager && (
<PortManager onClose={() => setShowPortManager(false)} />
)}

View File

@@ -8,6 +8,7 @@ interface ErrorDialogData {
interface DialogState {
showProfiler: boolean;
showAdvancedProfiler: boolean;
showPortManager: boolean;
showSettings: boolean;
showAbout: boolean;
@@ -16,6 +17,7 @@ interface DialogState {
confirmDialog: ConfirmDialogData | null;
setShowProfiler: (show: boolean) => void;
setShowAdvancedProfiler: (show: boolean) => void;
setShowPortManager: (show: boolean) => void;
setShowSettings: (show: boolean) => void;
setShowAbout: (show: boolean) => void;
@@ -27,6 +29,7 @@ interface DialogState {
export const useDialogStore = create<DialogState>((set) => ({
showProfiler: false,
showAdvancedProfiler: false,
showPortManager: false,
showSettings: false,
showAbout: false,
@@ -35,6 +38,7 @@ export const useDialogStore = create<DialogState>((set) => ({
confirmDialog: null,
setShowProfiler: (show) => set({ showProfiler: show }),
setShowAdvancedProfiler: (show) => set({ showAdvancedProfiler: show }),
setShowPortManager: (show) => set({ showPortManager: show }),
setShowSettings: (show) => set({ showSettings: show }),
setShowAbout: (show) => set({ showAbout: show }),
@@ -44,6 +48,7 @@ export const useDialogStore = create<DialogState>((set) => ({
closeAllDialogs: () => set({
showProfiler: false,
showAdvancedProfiler: false,
showPortManager: false,
showSettings: false,
showAbout: false,

View File

@@ -0,0 +1,787 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import {
Activity, Pause, Play, RefreshCw, Search, ChevronDown, ChevronUp,
ChevronRight, ArrowRight, Cpu, BarChart3, Settings
} from 'lucide-react';
import '../styles/AdvancedProfiler.css';
/**
* 高级性能数据接口(与 Core 的 IAdvancedProfilerData 对应)
*/
interface AdvancedProfilerData {
currentFrame: {
frameNumber: number;
frameTime: number;
fps: number;
memory: {
usedHeapSize: number;
totalHeapSize: number;
heapSizeLimit: number;
utilizationPercent: number;
gcCount: number;
};
};
frameTimeHistory: Array<{
frameNumber: number;
time: number;
duration: number;
}>;
categoryStats: Array<{
category: string;
totalTime: number;
percentOfFrame: number;
sampleCount: number;
expanded?: boolean;
items: Array<{
name: string;
inclusiveTime: number;
exclusiveTime: number;
callCount: number;
percentOfCategory: number;
percentOfFrame: number;
}>;
}>;
hotspots: Array<{
name: string;
category: string;
inclusiveTime: number;
inclusiveTimePercent: number;
exclusiveTime: number;
exclusiveTimePercent: number;
callCount: number;
avgCallTime: number;
}>;
callGraph: {
currentFunction: string | null;
callers: Array<{
name: string;
callCount: number;
totalTime: number;
percentOfCurrent: number;
}>;
callees: Array<{
name: string;
callCount: number;
totalTime: number;
percentOfCurrent: number;
}>;
};
longTasks: Array<{
startTime: number;
duration: number;
attribution: string[];
}>;
memoryTrend: Array<{
time: number;
usedMB: number;
totalMB: number;
gcCount: number;
}>;
summary: {
totalFrames: number;
averageFrameTime: number;
minFrameTime: number;
maxFrameTime: number;
p95FrameTime: number;
p99FrameTime: number;
currentMemoryMB: number;
peakMemoryMB: number;
gcCount: number;
longTaskCount: number;
};
}
interface ProfilerServiceInterface {
subscribeAdvanced: (listener: (data: { advancedProfiler?: AdvancedProfilerData; performance?: unknown; systems?: unknown }) => void) => () => void;
isConnected: () => boolean;
requestAdvancedProfilerData?: () => void;
setProfilerSelectedFunction?: (name: string | null) => void;
}
interface AdvancedProfilerProps {
profilerService: ProfilerServiceInterface | null;
}
type SortColumn = 'name' | 'incTime' | 'incPercent' | 'excTime' | 'excPercent' | 'calls' | 'avgTime' | 'framePercent';
type SortDirection = 'asc' | 'desc';
const CATEGORY_COLORS: Record<string, string> = {
'ECS': '#3b82f6',
'Rendering': '#8b5cf6',
'Physics': '#f59e0b',
'Audio': '#ec4899',
'Network': '#14b8a6',
'Script': '#84cc16',
'Memory': '#ef4444',
'Animation': '#f97316',
'AI': '#6366f1',
'Input': '#06b6d4',
'Loading': '#a855f7',
'Custom': '#64748b'
};
export function AdvancedProfiler({ profilerService }: AdvancedProfilerProps) {
const [data, setData] = useState<AdvancedProfilerData | null>(null);
const [isPaused, setIsPaused] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [selectedFunction, setSelectedFunction] = useState<string | null>(null);
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(['ECS']));
const [sortColumn, setSortColumn] = useState<SortColumn>('incTime');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const [viewMode, setViewMode] = useState<'hierarchical' | 'flat'>('hierarchical');
const canvasRef = useRef<HTMLCanvasElement>(null);
const frameHistoryRef = useRef<Array<{ time: number; duration: number }>>([]);
const lastDataRef = useRef<AdvancedProfilerData | null>(null);
// 订阅数据更新
useEffect(() => {
if (!profilerService) return;
const unsubscribe = profilerService.subscribeAdvanced((rawData: { advancedProfiler?: AdvancedProfilerData; performance?: unknown; systems?: unknown }) => {
if (isPaused) return;
// 解析高级性能数据
if (rawData.advancedProfiler) {
setData(rawData.advancedProfiler);
lastDataRef.current = rawData.advancedProfiler;
} else if (rawData.performance) {
// 从传统数据构建
const advancedData = buildFromLegacyData(rawData);
setData(advancedData);
lastDataRef.current = advancedData;
}
});
return unsubscribe;
}, [profilerService, isPaused]);
// 当选中函数变化时,通知服务端
useEffect(() => {
if (profilerService?.setProfilerSelectedFunction) {
profilerService.setProfilerSelectedFunction(selectedFunction);
}
}, [selectedFunction, profilerService]);
// 绘制帧时间图表
useEffect(() => {
if (!canvasRef.current || !data) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// 更新帧历史
if (data.currentFrame.frameTime > 0) {
frameHistoryRef.current.push({
time: Date.now(),
duration: data.currentFrame.frameTime
});
if (frameHistoryRef.current.length > 300) {
frameHistoryRef.current.shift();
}
}
drawFrameTimeGraph(ctx, canvas, frameHistoryRef.current);
}, [data]);
const drawFrameTimeGraph = useCallback((
ctx: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
history: Array<{ time: number; duration: number }>
) => {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const width = rect.width;
const height = rect.height;
// 清空画布
ctx.fillStyle = '#1e1e1e';
ctx.fillRect(0, 0, width, height);
if (history.length < 2) return;
// 计算最大值
const maxTime = Math.max(...history.map((h) => h.duration), 33.33);
const targetLine = 16.67; // 60 FPS
// 绘制网格线
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.setLineDash([2, 2]);
// 16.67ms 线 (60 FPS)
const targetY = height - (targetLine / maxTime) * height;
ctx.beginPath();
ctx.moveTo(0, targetY);
ctx.lineTo(width, targetY);
ctx.stroke();
// 33.33ms 线 (30 FPS)
const halfY = height - (33.33 / maxTime) * height;
ctx.beginPath();
ctx.moveTo(0, halfY);
ctx.lineTo(width, halfY);
ctx.stroke();
ctx.setLineDash([]);
// 绘制帧时间曲线
const stepX = width / (history.length - 1);
ctx.beginPath();
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 1.5;
history.forEach((frame, i) => {
const x = i * stepX;
const y = height - (frame.duration / maxTime) * height;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
// 如果超过阈值,改变颜色
if (frame.duration > 33.33) {
ctx.stroke();
ctx.beginPath();
ctx.strokeStyle = '#ef4444';
ctx.moveTo(x, y);
} else if (frame.duration > 16.67) {
ctx.stroke();
ctx.beginPath();
ctx.strokeStyle = '#fbbf24';
ctx.moveTo(x, y);
}
});
ctx.stroke();
// 绘制填充区域
ctx.beginPath();
ctx.fillStyle = 'rgba(74, 222, 128, 0.1)';
ctx.moveTo(0, height);
history.forEach((frame, i) => {
const x = i * stepX;
const y = height - (frame.duration / maxTime) * height;
ctx.lineTo(x, y);
});
ctx.lineTo(width, height);
ctx.closePath();
ctx.fill();
}, []);
const handleSort = (column: SortColumn) => {
if (sortColumn === column) {
setSortDirection((d) => d === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(column);
setSortDirection('desc');
}
};
const toggleCategory = (category: string) => {
setExpandedCategories((prev) => {
const next = new Set(prev);
if (next.has(category)) {
next.delete(category);
} else {
next.add(category);
}
return next;
});
};
const handleReset = () => {
frameHistoryRef.current = [];
setData(null);
};
const getFrameTimeClass = (frameTime: number): string => {
if (frameTime > 33.33) return 'critical';
if (frameTime > 16.67) return 'warning';
return '';
};
const formatTime = (ms: number): string => {
if (ms < 0.01) return '< 0.01';
return ms.toFixed(2);
};
const formatPercent = (percent: number): string => {
return percent.toFixed(1) + '%';
};
// 排序数据
const getSortedHotspots = () => {
if (!data) return [];
const filtered = data.hotspots.filter(h =>
searchTerm === '' || h.name.toLowerCase().includes(searchTerm.toLowerCase())
);
return [...filtered].sort((a, b) => {
let comparison = 0;
switch (sortColumn) {
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'incTime':
comparison = a.inclusiveTime - b.inclusiveTime;
break;
case 'incPercent':
comparison = a.inclusiveTimePercent - b.inclusiveTimePercent;
break;
case 'excTime':
comparison = a.exclusiveTime - b.exclusiveTime;
break;
case 'excPercent':
comparison = a.exclusiveTimePercent - b.exclusiveTimePercent;
break;
case 'calls':
comparison = a.callCount - b.callCount;
break;
case 'avgTime':
comparison = a.avgCallTime - b.avgCallTime;
break;
case 'framePercent':
comparison = a.inclusiveTimePercent - b.inclusiveTimePercent;
break;
}
return sortDirection === 'asc' ? comparison : -comparison;
});
};
const renderSortIcon = (column: SortColumn) => {
if (sortColumn !== column) return null;
return sortDirection === 'asc' ? <ChevronUp size={10} /> : <ChevronDown size={10} />;
};
if (!profilerService) {
return (
<div className="advanced-profiler">
<div className="profiler-empty-state">
<Cpu size={48} />
<div className="profiler-empty-state-title">Profiler Service Unavailable</div>
<div className="profiler-empty-state-hint">
Connect to a running game to start profiling
</div>
</div>
</div>
);
}
return (
<div className="advanced-profiler">
{/* Top Toolbar */}
<div className="profiler-top-bar">
<div className="profiler-thread-selector">
<button className="profiler-thread-btn active">Main Thread</button>
</div>
<div className="profiler-frame-time">
<span className="profiler-frame-time-label">Frame:</span>
<span className={`profiler-frame-time-value ${getFrameTimeClass(data?.currentFrame.frameTime || 0)}`}>
{formatTime(data?.currentFrame.frameTime || 0)} ms
</span>
<span className="profiler-frame-time-label">FPS:</span>
<span className="profiler-frame-time-value">
{data?.currentFrame.fps || 0}
</span>
</div>
<div className="profiler-controls">
<button
className={`profiler-control-btn ${isPaused ? '' : 'active'}`}
onClick={() => setIsPaused(!isPaused)}
title={isPaused ? 'Resume' : 'Pause'}
>
{isPaused ? <Play size={14} /> : <Pause size={14} />}
</button>
<button
className="profiler-control-btn"
onClick={handleReset}
title="Reset"
>
<RefreshCw size={14} />
</button>
<button className="profiler-control-btn" title="Settings">
<Settings size={14} />
</button>
</div>
</div>
<div className="profiler-main">
{/* Left Panel - Stats Groups */}
<div className="profiler-left-panel">
<div className="profiler-search-box">
<Search size={14} />
<input
type="text"
placeholder="Search stats..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
<div className="profiler-group-controls">
<select className="profiler-group-select" defaultValue="category">
<option value="category">Group by Category</option>
<option value="name">Group by Name</option>
</select>
</div>
<div className="profiler-type-filters">
<button className="profiler-type-filter hier active">Hier</button>
<button className="profiler-type-filter float">Float</button>
<button className="profiler-type-filter int">Int</button>
<button className="profiler-type-filter mem">Mem</button>
</div>
<div className="profiler-groups-list">
{data?.categoryStats.map(cat => (
<div key={cat.category}>
<div
className={`profiler-group-item ${expandedCategories.has(cat.category) ? 'selected' : ''}`}
onClick={() => toggleCategory(cat.category)}
>
<input
type="checkbox"
className="profiler-group-checkbox"
checked={expandedCategories.has(cat.category)}
onChange={() => {}}
/>
<span
className="category-dot"
style={{ background: CATEGORY_COLORS[cat.category] || '#666' }}
/>
<span className="profiler-group-name">{cat.category}</span>
<span className="profiler-group-count">({cat.sampleCount})</span>
</div>
</div>
))}
</div>
</div>
{/* Right Content */}
<div className="profiler-content">
{/* Graph View */}
<div className="profiler-graph-section">
<div className="profiler-graph-header">
<BarChart3 size={14} />
<span className="profiler-graph-title">Graph View</span>
<div className="profiler-graph-stats">
<div className="profiler-graph-stat">
<span className="profiler-graph-stat-label">Avg:</span>
<span className="profiler-graph-stat-value">
{formatTime(data?.summary.averageFrameTime || 0)} ms
</span>
</div>
<div className="profiler-graph-stat">
<span className="profiler-graph-stat-label">Min:</span>
<span className="profiler-graph-stat-value">
{formatTime(data?.summary.minFrameTime || 0)} ms
</span>
</div>
<div className="profiler-graph-stat">
<span className="profiler-graph-stat-label">Max:</span>
<span className="profiler-graph-stat-value">
{formatTime(data?.summary.maxFrameTime || 0)} ms
</span>
</div>
</div>
</div>
<div className="profiler-graph-canvas">
<canvas ref={canvasRef} />
<div className="profiler-graph-overlay">
<div className="profiler-graph-line" style={{ top: '50%' }}>
<span className="profiler-graph-line-label">16.67ms</span>
</div>
</div>
</div>
</div>
{/* Call Graph */}
<div className="profiler-callgraph-section">
<div className="profiler-callgraph-header">
<Activity size={14} />
<span className="profiler-graph-title">Call Graph</span>
<div className="profiler-callgraph-controls">
<select className="profiler-callgraph-type-select">
<option value="oneframe">One Frame</option>
<option value="average">Average</option>
<option value="maximum">Maximum</option>
</select>
<div className="profiler-callgraph-view-mode">
<button
className={`profiler-callgraph-view-btn ${viewMode === 'hierarchical' ? 'active' : ''}`}
onClick={() => setViewMode('hierarchical')}
>
Hierarchical
</button>
<button
className={`profiler-callgraph-view-btn ${viewMode === 'flat' ? 'active' : ''}`}
onClick={() => setViewMode('flat')}
>
Flat
</button>
</div>
</div>
</div>
<div className="profiler-callgraph-content">
<div className="profiler-callgraph-column">
<div className="profiler-callgraph-column-header">
<ArrowRight size={10} />
Calling Functions
</div>
<div className="profiler-callgraph-list">
{data?.callGraph.callers.map((caller, i) => (
<div
key={i}
className="profiler-callgraph-item"
onClick={() => setSelectedFunction(caller.name)}
>
<span className="profiler-callgraph-item-name">{caller.name}</span>
<span className="profiler-callgraph-item-percent">
{formatPercent(caller.percentOfCurrent)}
</span>
<span className="profiler-callgraph-item-time">
{formatTime(caller.totalTime)} ms
</span>
</div>
))}
</div>
</div>
<div className="profiler-callgraph-column">
<div className="profiler-callgraph-column-header">
Current Function
</div>
<div className="profiler-callgraph-list">
{selectedFunction ? (
<div className="profiler-callgraph-item current">
<span className="profiler-callgraph-item-name">{selectedFunction}</span>
</div>
) : (
<div className="profiler-callgraph-item">
<span className="profiler-callgraph-item-name" style={{ color: '#666' }}>
Select a function from the table
</span>
</div>
)}
</div>
</div>
<div className="profiler-callgraph-column">
<div className="profiler-callgraph-column-header">
Called Functions
<ArrowRight size={10} />
</div>
<div className="profiler-callgraph-list">
{data?.callGraph.callees.map((callee, i) => (
<div
key={i}
className="profiler-callgraph-item"
onClick={() => setSelectedFunction(callee.name)}
>
<span className="profiler-callgraph-item-name">{callee.name}</span>
<span className="profiler-callgraph-item-percent">
{formatPercent(callee.percentOfCurrent)}
</span>
<span className="profiler-callgraph-item-time">
{formatTime(callee.totalTime)} ms
</span>
</div>
))}
</div>
</div>
</div>
</div>
{/* Data Table */}
<div className="profiler-table-section">
<div className="profiler-table-header">
<div
className={`profiler-table-header-cell col-name ${sortColumn === 'name' ? 'sorted' : ''}`}
onClick={() => handleSort('name')}
>
Event Name {renderSortIcon('name')}
</div>
<div
className={`profiler-table-header-cell col-inc-time ${sortColumn === 'incTime' ? 'sorted' : ''}`}
onClick={() => handleSort('incTime')}
>
Inc Time (ms) {renderSortIcon('incTime')}
</div>
<div
className={`profiler-table-header-cell col-inc-percent ${sortColumn === 'incPercent' ? 'sorted' : ''}`}
onClick={() => handleSort('incPercent')}
>
Inc % {renderSortIcon('incPercent')}
</div>
<div
className={`profiler-table-header-cell col-exc-time ${sortColumn === 'excTime' ? 'sorted' : ''}`}
onClick={() => handleSort('excTime')}
>
Exc Time (ms) {renderSortIcon('excTime')}
</div>
<div
className={`profiler-table-header-cell col-exc-percent ${sortColumn === 'excPercent' ? 'sorted' : ''}`}
onClick={() => handleSort('excPercent')}
>
Exc % {renderSortIcon('excPercent')}
</div>
<div
className={`profiler-table-header-cell col-calls ${sortColumn === 'calls' ? 'sorted' : ''}`}
onClick={() => handleSort('calls')}
>
Calls {renderSortIcon('calls')}
</div>
<div
className={`profiler-table-header-cell col-avg-calls ${sortColumn === 'avgTime' ? 'sorted' : ''}`}
onClick={() => handleSort('avgTime')}
>
Avg (ms) {renderSortIcon('avgTime')}
</div>
<div
className={`profiler-table-header-cell col-frame-percent ${sortColumn === 'framePercent' ? 'sorted' : ''}`}
onClick={() => handleSort('framePercent')}
>
% of Frame {renderSortIcon('framePercent')}
</div>
</div>
<div className="profiler-table-body">
{getSortedHotspots().map((item, index) => (
<div
key={item.name + index}
className={`profiler-table-row ${selectedFunction === item.name ? 'selected' : ''}`}
onClick={() => setSelectedFunction(item.name)}
>
<div className="profiler-table-cell col-name name">
<ChevronRight size={12} className="expand-icon" />
<span
className="category-dot"
style={{ background: CATEGORY_COLORS[item.category] || '#666' }}
/>
{item.name}
</div>
<div className="profiler-table-cell col-inc-time numeric">
{formatTime(item.inclusiveTime)}
</div>
<div className="profiler-table-cell col-inc-percent percent">
<div className="bar-container">
<div
className={`bar ${item.inclusiveTimePercent > 50 ? 'critical' : item.inclusiveTimePercent > 25 ? 'warning' : ''}`}
style={{ width: `${Math.min(item.inclusiveTimePercent, 100)}%` }}
/>
<span>{formatPercent(item.inclusiveTimePercent)}</span>
</div>
</div>
<div className="profiler-table-cell col-exc-time numeric">
{formatTime(item.exclusiveTime)}
</div>
<div className="profiler-table-cell col-exc-percent percent">
{formatPercent(item.exclusiveTimePercent)}
</div>
<div className="profiler-table-cell col-calls numeric">
{item.callCount}
</div>
<div className="profiler-table-cell col-avg-calls numeric">
{formatTime(item.avgCallTime)}
</div>
<div className="profiler-table-cell col-frame-percent percent">
{formatPercent(item.inclusiveTimePercent)}
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}
/**
* 从传统数据构建高级性能数据
*/
function buildFromLegacyData(rawData: any): AdvancedProfilerData {
const performance = rawData.performance || {};
const systems = rawData.systems?.systemsInfo || [];
const frameTime = performance.frameTime || 0;
const fps = frameTime > 0 ? Math.round(1000 / frameTime) : 0;
// 构建 hotspots
const hotspots = systems.map((sys: any) => ({
name: sys.name || sys.type || 'Unknown',
category: 'ECS',
inclusiveTime: sys.executionTime || 0,
inclusiveTimePercent: frameTime > 0 ? (sys.executionTime / frameTime) * 100 : 0,
exclusiveTime: sys.executionTime || 0,
exclusiveTimePercent: frameTime > 0 ? (sys.executionTime / frameTime) * 100 : 0,
callCount: 1,
avgCallTime: sys.executionTime || 0
}));
// 构建 categoryStats
const totalECSTime = hotspots.reduce((sum: number, h: any) => sum + h.inclusiveTime, 0);
const categoryStats = [{
category: 'ECS',
totalTime: totalECSTime,
percentOfFrame: frameTime > 0 ? (totalECSTime / frameTime) * 100 : 0,
sampleCount: hotspots.length,
items: hotspots.map((h: any) => ({
name: h.name,
inclusiveTime: h.inclusiveTime,
exclusiveTime: h.exclusiveTime,
callCount: h.callCount,
percentOfCategory: totalECSTime > 0 ? (h.inclusiveTime / totalECSTime) * 100 : 0,
percentOfFrame: h.inclusiveTimePercent
}))
}];
return {
currentFrame: {
frameNumber: 0,
frameTime,
fps,
memory: {
usedHeapSize: (performance.memoryUsage || 0) * 1024 * 1024,
totalHeapSize: 0,
heapSizeLimit: 0,
utilizationPercent: 0,
gcCount: 0
}
},
frameTimeHistory: performance.frameTimeHistory?.map((t: number, i: number) => ({
frameNumber: i,
time: Date.now() - (performance.frameTimeHistory.length - i) * 16,
duration: t
})) || [],
categoryStats,
hotspots,
callGraph: {
currentFunction: null,
callers: [],
callees: []
},
longTasks: [],
memoryTrend: [],
summary: {
totalFrames: 0,
averageFrameTime: performance.averageFrameTime || frameTime,
minFrameTime: performance.minFrameTime || frameTime,
maxFrameTime: performance.maxFrameTime || frameTime,
p95FrameTime: frameTime,
p99FrameTime: frameTime,
currentMemoryMB: performance.memoryUsage || 0,
peakMemoryMB: performance.memoryUsage || 0,
gcCount: 0,
longTaskCount: 0
}
};
}

View File

@@ -0,0 +1,67 @@
import { useState, useEffect } from 'react';
import { X, BarChart3 } from 'lucide-react';
import { ProfilerService } from '../services/ProfilerService';
import { AdvancedProfiler } from './AdvancedProfiler';
import '../styles/ProfilerWindow.css';
interface AdvancedProfilerWindowProps {
onClose: () => void;
}
interface WindowWithProfiler extends Window {
__PROFILER_SERVICE__?: ProfilerService;
}
export function AdvancedProfilerWindow({ onClose }: AdvancedProfilerWindowProps) {
const [profilerService, setProfilerService] = useState<ProfilerService | null>(null);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
const service = (window as WindowWithProfiler).__PROFILER_SERVICE__;
if (service) {
setProfilerService(service);
}
}, []);
useEffect(() => {
if (!profilerService) return;
const checkStatus = () => {
setIsConnected(profilerService.isConnected());
};
checkStatus();
const interval = setInterval(checkStatus, 1000);
return () => clearInterval(interval);
}, [profilerService]);
return (
<div className="profiler-window-overlay" onClick={onClose}>
<div
className="profiler-window advanced-profiler-window"
onClick={(e) => e.stopPropagation()}
style={{ width: '90vw', height: '85vh', maxWidth: '1600px' }}
>
<div className="profiler-window-header">
<div className="profiler-window-title">
<BarChart3 size={20} />
<h2>Advanced Performance Profiler</h2>
{!isConnected && (
<span className="paused-indicator" style={{ background: '#ef4444' }}>
DISCONNECTED
</span>
)}
</div>
<button className="profiler-window-close" onClick={onClose} title="Close">
<X size={20} />
</button>
</div>
<div className="profiler-window-content" style={{ padding: 0 }}>
<AdvancedProfiler profilerService={profilerService} />
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Activity, Cpu, Layers, Package, Wifi, WifiOff, Maximize2, Pause, Play } from 'lucide-react';
import { Activity, Cpu, Layers, Package, Wifi, WifiOff, Maximize2, Pause, Play, BarChart3 } from 'lucide-react';
import { ProfilerService, ProfilerData } from '../services/ProfilerService';
import { SettingsService } from '../services/SettingsService';
import { Core } from '@esengine/ecs-framework';
@@ -77,6 +77,13 @@ export function ProfilerDockPanel() {
}
};
const handleOpenAdvancedProfiler = () => {
const messageHub = Core.services.resolve(MessageHub);
if (messageHub) {
messageHub.publish('ui:openWindow', { windowId: 'advancedProfiler' });
}
};
const handleTogglePause = () => {
setIsPaused(!isPaused);
};
@@ -95,6 +102,13 @@ export function ProfilerDockPanel() {
>
{isPaused ? <Play size={14} /> : <Pause size={14} />}
</button>
<button
className="profiler-dock-details-btn"
onClick={handleOpenAdvancedProfiler}
title="Open advanced profiler"
>
<BarChart3 size={14} />
</button>
<button
className="profiler-dock-details-btn"
onClick={handleOpenDetails}

View File

@@ -122,6 +122,14 @@ class ProfilerEditorModule implements IEditorModuleLoader {
execute: () => {
this.messageHub?.publish('ui:openWindow', { windowId: 'profiler' });
}
},
{
id: 'window.advancedProfiler',
label: 'Advanced Profiler',
parentId: 'window',
execute: () => {
this.messageHub?.publish('ui:openWindow', { windowId: 'advancedProfiler' });
}
}
];
}

View File

@@ -56,15 +56,28 @@ export interface ProfilerData {
type ProfilerDataListener = (data: ProfilerData) => void;
/**
* 高级性能数据结构(用于高级性能分析器)
*/
export interface AdvancedProfilerDataPayload {
advancedProfiler?: any;
performance?: any;
systems?: any;
}
type AdvancedProfilerDataListener = (data: AdvancedProfilerDataPayload) => void;
export class ProfilerService {
private ws: WebSocket | null = null;
private isServerRunning = false;
private wsPort: string;
private listeners: Set<ProfilerDataListener> = new Set();
private advancedListeners: Set<AdvancedProfilerDataListener> = new Set();
private currentData: ProfilerData | null = null;
private lastRawData: AdvancedProfilerDataPayload | null = null;
private checkServerInterval: NodeJS.Timeout | null = null;
private reconnectTimeout: NodeJS.Timeout | null = null;
private clientIdMap: Map<string, string> = new Map(); // 客户端地址 -> 客户端ID映射
private clientIdMap: Map<string, string> = new Map();
private autoStart: boolean;
constructor() {
@@ -122,6 +135,61 @@ export class ProfilerService {
};
}
/**
* 订阅高级性能数据(用于 AdvancedProfiler 组件)
*/
public subscribeAdvanced(listener: AdvancedProfilerDataListener): () => void {
this.advancedListeners.add(listener);
// 如果已有数据,立即发送给新订阅者
if (this.lastRawData) {
listener(this.lastRawData);
}
return () => {
this.advancedListeners.delete(listener);
};
}
/**
* 请求高级性能分析数据
*/
public requestAdvancedProfilerData(): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return;
}
try {
const request = {
type: 'get_advanced_profiler_data',
requestId: `advanced_profiler_${Date.now()}`
};
this.ws.send(JSON.stringify(request));
} catch (error) {
console.error('[ProfilerService] Failed to request advanced profiler data:', error);
}
}
/**
* 设置性能分析器选中的函数(用于调用关系视图)
*/
public setProfilerSelectedFunction(functionName: string | null): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return;
}
try {
const request = {
type: 'set_profiler_selected_function',
requestId: `set_function_${Date.now()}`,
functionName
};
this.ws.send(JSON.stringify(request));
} catch (error) {
console.error('[ProfilerService] Failed to set selected function:', error);
}
}
public isConnected(): boolean {
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
}
@@ -237,6 +305,8 @@ export class ProfilerService {
this.handleRawEntityListResponse(message.data);
} else if (message.type === 'get_entity_details_response' && message.data) {
this.handleEntityDetailsResponse(message.data);
} else if (message.type === 'get_advanced_profiler_data_response' && message.data) {
this.handleAdvancedProfilerData(message.data);
} else if (message.type === 'log' && message.data) {
this.handleRemoteLog(message.data);
}
@@ -310,10 +380,31 @@ export class ProfilerService {
this.notifyListeners(this.currentData);
// 通知高级监听器原始数据
this.lastRawData = {
performance: debugData.performance,
systems: {
systemsInfo: systems.map(sys => ({
name: sys.name,
executionTime: sys.executionTime,
entityCount: sys.entityCount,
averageTime: sys.averageTime
}))
}
};
this.notifyAdvancedListeners(this.lastRawData);
// 请求完整的实体列表
this.requestRawEntityList();
}
private handleAdvancedProfilerData(data: any): void {
this.lastRawData = {
advancedProfiler: data
};
this.notifyAdvancedListeners(this.lastRawData);
}
private requestRawEntityList(): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return;
@@ -437,6 +528,16 @@ export class ProfilerService {
});
}
private notifyAdvancedListeners(data: AdvancedProfilerDataPayload): void {
this.advancedListeners.forEach((listener) => {
try {
listener(data);
} catch (error) {
console.error('[ProfilerService] Error in advanced listener:', error);
}
});
}
private disconnect(): void {
const hadConnection = this.ws !== null;
@@ -465,6 +566,8 @@ export class ProfilerService {
}
this.listeners.clear();
this.advancedListeners.clear();
this.currentData = null;
this.lastRawData = null;
}
}

View File

@@ -0,0 +1,705 @@
/* ==================== Advanced Profiler ==================== */
.advanced-profiler {
display: flex;
flex-direction: column;
height: 100%;
background-color: #1a1a1a;
color: #ccc;
font-size: 11px;
overflow: hidden;
}
/* ==================== Top Toolbar ==================== */
.profiler-top-bar {
display: flex;
align-items: center;
height: 28px;
padding: 0 8px;
background: #2d2d2d;
border-bottom: 1px solid #1a1a1a;
gap: 12px;
flex-shrink: 0;
}
.profiler-thread-selector {
display: flex;
align-items: center;
gap: 6px;
}
.profiler-thread-btn {
padding: 2px 8px;
background: #3a3a3a;
border: 1px solid #4a4a4a;
border-radius: 3px;
color: #ccc;
font-size: 11px;
cursor: pointer;
}
.profiler-thread-btn.active {
background: #4a9eff;
border-color: #4a9eff;
color: #fff;
}
.profiler-frame-time {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
font-family: 'Consolas', monospace;
}
.profiler-frame-time-label {
color: #888;
}
.profiler-frame-time-value {
color: #4ade80;
font-weight: 600;
}
.profiler-frame-time-value.warning {
color: #fbbf24;
}
.profiler-frame-time-value.critical {
color: #ef4444;
}
.profiler-controls {
display: flex;
align-items: center;
gap: 4px;
}
.profiler-control-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: transparent;
border: none;
color: #888;
cursor: pointer;
border-radius: 3px;
}
.profiler-control-btn:hover {
background: #3a3a3a;
color: #ccc;
}
.profiler-control-btn.active {
color: #4a9eff;
}
/* ==================== Main Layout ==================== */
.profiler-main {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* ==================== Left Panel (Stats Groups) ==================== */
.profiler-left-panel {
width: 200px;
min-width: 150px;
max-width: 300px;
display: flex;
flex-direction: column;
background: #222;
border-right: 1px solid #1a1a1a;
flex-shrink: 0;
}
.profiler-search-box {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-bottom: 1px solid #1a1a1a;
}
.profiler-search-box input {
flex: 1;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 3px;
padding: 4px 8px;
color: #ccc;
font-size: 11px;
}
.profiler-search-box input:focus {
outline: none;
border-color: #4a9eff;
}
.profiler-search-box svg {
color: #666;
flex-shrink: 0;
}
.profiler-group-controls {
display: flex;
gap: 4px;
padding: 6px 8px;
border-bottom: 1px solid #1a1a1a;
}
.profiler-group-select {
flex: 1;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 3px;
padding: 2px 4px;
color: #ccc;
font-size: 10px;
}
.profiler-type-filters {
display: flex;
gap: 2px;
padding: 4px 8px;
border-bottom: 1px solid #1a1a1a;
flex-wrap: wrap;
}
.profiler-type-filter {
padding: 2px 6px;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 3px;
color: #888;
font-size: 10px;
cursor: pointer;
}
.profiler-type-filter:hover {
background: #3a3a3a;
color: #ccc;
}
.profiler-type-filter.active {
background: #4a9eff;
border-color: #4a9eff;
color: #fff;
}
.profiler-type-filter.hier { background: #3b82f6; border-color: #3b82f6; color: #fff; }
.profiler-type-filter.float { background: #22c55e; border-color: #22c55e; color: #fff; }
.profiler-type-filter.int { background: #f59e0b; border-color: #f59e0b; color: #000; }
.profiler-type-filter.mem { background: #ef4444; border-color: #ef4444; color: #fff; }
.profiler-groups-list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.profiler-groups-list::-webkit-scrollbar {
width: 6px;
}
.profiler-groups-list::-webkit-scrollbar-track {
background: #1a1a1a;
}
.profiler-groups-list::-webkit-scrollbar-thumb {
background: #3a3a3a;
border-radius: 3px;
}
.profiler-group-item {
display: flex;
align-items: center;
padding: 3px 8px;
cursor: pointer;
user-select: none;
}
.profiler-group-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.profiler-group-item.selected {
background: rgba(74, 158, 255, 0.2);
}
.profiler-group-checkbox {
width: 12px;
height: 12px;
margin-right: 6px;
accent-color: #4a9eff;
}
.profiler-group-name {
flex: 1;
font-size: 11px;
color: #ccc;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profiler-group-count {
font-size: 10px;
color: #666;
margin-left: 4px;
}
/* ==================== Right Content Area ==================== */
.profiler-content {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
/* ==================== Graph View ==================== */
.profiler-graph-section {
height: 120px;
min-height: 80px;
max-height: 200px;
border-bottom: 1px solid #1a1a1a;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.profiler-graph-header {
display: flex;
align-items: center;
padding: 4px 8px;
background: #262626;
border-bottom: 1px solid #1a1a1a;
gap: 8px;
}
.profiler-graph-title {
font-size: 11px;
font-weight: 600;
color: #888;
}
.profiler-graph-stats {
display: flex;
gap: 12px;
margin-left: auto;
font-family: 'Consolas', monospace;
font-size: 10px;
}
.profiler-graph-stat {
display: flex;
gap: 4px;
}
.profiler-graph-stat-label {
color: #666;
}
.profiler-graph-stat-value {
color: #ccc;
}
.profiler-graph-canvas {
flex: 1;
background: #1e1e1e;
position: relative;
overflow: hidden;
}
.profiler-graph-canvas canvas {
width: 100%;
height: 100%;
}
.profiler-graph-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
}
.profiler-graph-line {
position: absolute;
left: 0;
right: 0;
height: 1px;
border-top: 1px dashed #333;
}
.profiler-graph-line-label {
position: absolute;
right: 4px;
transform: translateY(-50%);
font-size: 9px;
color: #555;
background: #1e1e1e;
padding: 0 2px;
}
/* ==================== Call Graph Section ==================== */
.profiler-callgraph-section {
height: 140px;
min-height: 100px;
border-bottom: 1px solid #1a1a1a;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.profiler-callgraph-header {
display: flex;
align-items: center;
padding: 4px 8px;
background: #262626;
border-bottom: 1px solid #1a1a1a;
gap: 8px;
}
.profiler-callgraph-controls {
display: flex;
gap: 8px;
align-items: center;
}
.profiler-callgraph-type-select {
padding: 2px 6px;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 3px;
color: #ccc;
font-size: 10px;
}
.profiler-callgraph-view-mode {
display: flex;
gap: 2px;
}
.profiler-callgraph-view-btn {
padding: 2px 6px;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 3px;
color: #888;
font-size: 10px;
cursor: pointer;
}
.profiler-callgraph-view-btn.active {
background: #3b82f6;
border-color: #3b82f6;
color: #fff;
}
.profiler-callgraph-content {
flex: 1;
display: flex;
overflow: hidden;
}
.profiler-callgraph-column {
flex: 1;
display: flex;
flex-direction: column;
border-right: 1px solid #1a1a1a;
overflow: hidden;
}
.profiler-callgraph-column:last-child {
border-right: none;
}
.profiler-callgraph-column-header {
padding: 4px 8px;
background: #222;
font-size: 10px;
font-weight: 600;
color: #888;
text-align: center;
border-bottom: 1px solid #1a1a1a;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.profiler-callgraph-column-header svg {
color: #666;
}
.profiler-callgraph-list {
flex: 1;
overflow-y: auto;
padding: 2px 0;
}
.profiler-callgraph-item {
display: flex;
align-items: center;
padding: 3px 8px;
cursor: pointer;
gap: 6px;
}
.profiler-callgraph-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.profiler-callgraph-item.current {
background: rgba(74, 158, 255, 0.2);
}
.profiler-callgraph-item-name {
flex: 1;
font-size: 11px;
color: #ccc;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profiler-callgraph-item-percent {
font-size: 10px;
color: #888;
font-family: 'Consolas', monospace;
}
.profiler-callgraph-item-time {
font-size: 10px;
color: #4ade80;
font-family: 'Consolas', monospace;
}
/* ==================== Data Table Section ==================== */
.profiler-table-section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 150px;
overflow: hidden;
}
.profiler-table-header {
display: flex;
background: #2d2d2d;
border-bottom: 1px solid #1a1a1a;
font-size: 10px;
font-weight: 600;
color: #888;
flex-shrink: 0;
}
.profiler-table-header-cell {
padding: 6px 8px;
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 4px;
border-right: 1px solid #1a1a1a;
}
.profiler-table-header-cell:hover {
background: #3a3a3a;
color: #ccc;
}
.profiler-table-header-cell:last-child {
border-right: none;
}
.profiler-table-header-cell.sorted {
color: #4a9eff;
}
.profiler-table-header-cell.sorted svg {
color: #4a9eff;
}
.profiler-table-body {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.profiler-table-body::-webkit-scrollbar {
width: 8px;
}
.profiler-table-body::-webkit-scrollbar-track {
background: #1a1a1a;
}
.profiler-table-body::-webkit-scrollbar-thumb {
background: #3a3a3a;
border-radius: 4px;
}
.profiler-table-row {
display: flex;
border-bottom: 1px solid #222;
cursor: pointer;
}
.profiler-table-row:hover {
background: rgba(255, 255, 255, 0.03);
}
.profiler-table-row.selected {
background: rgba(74, 158, 255, 0.15);
}
.profiler-table-row.expanded {
background: rgba(74, 158, 255, 0.1);
}
.profiler-table-cell {
padding: 4px 8px;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-right: 1px solid #222;
}
.profiler-table-cell:last-child {
border-right: none;
}
.profiler-table-cell.name {
display: flex;
align-items: center;
gap: 4px;
}
.profiler-table-cell.name .expand-icon {
color: #666;
flex-shrink: 0;
cursor: pointer;
}
.profiler-table-cell.name .category-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.profiler-table-cell.numeric {
text-align: right;
font-family: 'Consolas', monospace;
color: #ccc;
}
.profiler-table-cell.percent {
text-align: right;
font-family: 'Consolas', monospace;
}
.profiler-table-cell .bar-container {
display: flex;
align-items: center;
gap: 4px;
}
.profiler-table-cell .bar {
height: 10px;
background: #4a9eff;
border-radius: 2px;
min-width: 2px;
}
.profiler-table-cell .bar.warning {
background: #fbbf24;
}
.profiler-table-cell .bar.critical {
background: #ef4444;
}
/* Column widths */
.col-name { flex: 2; min-width: 150px; }
.col-inc-time { width: 80px; flex-shrink: 0; }
.col-inc-percent { width: 80px; flex-shrink: 0; }
.col-exc-time { width: 80px; flex-shrink: 0; }
.col-exc-percent { width: 80px; flex-shrink: 0; }
.col-calls { width: 60px; flex-shrink: 0; }
.col-avg-calls { width: 70px; flex-shrink: 0; }
.col-thread-percent { width: 80px; flex-shrink: 0; }
.col-frame-percent { width: 80px; flex-shrink: 0; }
/* Category colors */
.category-ecs { background-color: #3b82f6; }
.category-rendering { background-color: #8b5cf6; }
.category-physics { background-color: #f59e0b; }
.category-audio { background-color: #ec4899; }
.category-network { background-color: #14b8a6; }
.category-script { background-color: #84cc16; }
.category-memory { background-color: #ef4444; }
.category-animation { background-color: #f97316; }
.category-ai { background-color: #6366f1; }
.category-input { background-color: #06b6d4; }
.category-loading { background-color: #a855f7; }
.category-custom { background-color: #64748b; }
/* ==================== Empty State ==================== */
.profiler-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
text-align: center;
padding: 32px;
}
.profiler-empty-state svg {
margin-bottom: 16px;
opacity: 0.5;
}
.profiler-empty-state-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
}
.profiler-empty-state-hint {
font-size: 12px;
color: #555;
}
/* ==================== Resize Handle ==================== */
.profiler-resize-handle {
width: 4px;
cursor: col-resize;
background: transparent;
transition: background 0.15s;
}
.profiler-resize-handle:hover {
background: #4a9eff;
}
.profiler-resize-handle-h {
height: 4px;
cursor: row-resize;
background: transparent;
transition: background 0.15s;
}
.profiler-resize-handle-h:hover {
background: #4a9eff;
}

View File

@@ -918,7 +918,7 @@
font-size: 12px;
}
/* ==================== Transform Component (UE5 Style) ==================== */
/* ==================== Transform Component ==================== */
.transform-section {
background: #262626;
border-bottom: 1px solid #1a1a1a;