feat(profiler): 实现高级性能分析器 (#248)
* feat(profiler): 实现高级性能分析器 * test(core): 添加 ProfilerSDK 和 AdvancedProfilerCollector 测试覆盖 * test(core): 添加 ProfilerSDK 和 AdvancedProfilerCollector 测试覆盖
This commit is contained in:
@@ -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)} />
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
787
packages/editor-app/src/components/AdvancedProfiler.tsx
Normal file
787
packages/editor-app/src/components/AdvancedProfiler.tsx
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
705
packages/editor-app/src/styles/AdvancedProfiler.css
Normal file
705
packages/editor-app/src/styles/AdvancedProfiler.css
Normal 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;
|
||||
}
|
||||
@@ -918,7 +918,7 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ==================== Transform Component (UE5 Style) ==================== */
|
||||
/* ==================== Transform Component ==================== */
|
||||
.transform-section {
|
||||
background: #262626;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
|
||||
Reference in New Issue
Block a user