390 lines
15 KiB
C#
390 lines
15 KiB
C#
using System;
|
|||
|
|
using System.Collections.Generic;
|
||
|
|
using System.IO;
|
||
|
|
using System.Text;
|
||
|
|
using Unity.Profiling;
|
||
|
|
using UnityEditor;
|
||
|
|
using UnityEditor.Profiling;
|
||
|
|
using UnityEditorInternal;
|
||
|
|
using UnityEngine;
|
||
|
|
using UnityEngine.Profiling;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Profiler .data 文件导出器
|
||
|
|
/// 使用 ProfilerDriver.GetHierarchyFrameDataView API 提取函数级 GC Alloc / CPU 耗时 / 调用次数
|
||
|
|
/// 菜单: Tools > Profiler Data Exporter
|
||
|
|
/// </summary>
|
||
|
|
public class ProfilerDataExporter : EditorWindow
|
||
|
|
{
|
||
|
|
[MenuItem("Tools/Profiler Data Exporter")]
|
||
|
|
public static void ShowWindow()
|
||
|
|
{
|
||
|
|
GetWindow<ProfilerDataExporter>("Profiler Exporter");
|
||
|
|
}
|
||
|
|
|
||
|
|
private string dataFilePath = "";
|
||
|
|
private string outputDir = "";
|
||
|
|
private bool includeAllThreads = false;
|
||
|
|
|
||
|
|
void OnEnable()
|
||
|
|
{
|
||
|
|
if (string.IsNullOrEmpty(outputDir))
|
||
|
|
outputDir = Environment.GetFolderPath(Environment.SpecialFolder.Desktop);
|
||
|
|
}
|
||
|
|
|
||
|
|
void OnGUI()
|
||
|
|
{
|
||
|
|
GUILayout.Label("Profiler Data Exporter", EditorStyles.boldLabel);
|
||
|
|
GUILayout.Label("用 ProfilerDriver API 提取函数级 GC/CPU 数据", EditorStyles.miniLabel);
|
||
|
|
|
||
|
|
GUILayout.Space(10);
|
||
|
|
|
||
|
|
// 数据文件选择
|
||
|
|
GUILayout.Label("1. 选择 .data 文件:");
|
||
|
|
GUILayout.BeginHorizontal();
|
||
|
|
dataFilePath = EditorGUILayout.TextField(dataFilePath);
|
||
|
|
if (GUILayout.Button("浏览", GUILayout.Width(60)))
|
||
|
|
{
|
||
|
|
string picked = EditorUtility.OpenFilePanel("选择 Profiler .data 文件", "", "data");
|
||
|
|
if (!string.IsNullOrEmpty(picked))
|
||
|
|
dataFilePath = picked;
|
||
|
|
}
|
||
|
|
GUILayout.EndHorizontal();
|
||
|
|
|
||
|
|
GUILayout.Space(5);
|
||
|
|
|
||
|
|
// 输出目录
|
||
|
|
GUILayout.Label("2. 输出目录:");
|
||
|
|
GUILayout.BeginHorizontal();
|
||
|
|
outputDir = EditorGUILayout.TextField(outputDir);
|
||
|
|
if (GUILayout.Button("浏览", GUILayout.Width(60)))
|
||
|
|
{
|
||
|
|
string picked = EditorUtility.OpenFolderPanel("选择输出目录", outputDir, "");
|
||
|
|
if (!string.IsNullOrEmpty(picked))
|
||
|
|
outputDir = picked;
|
||
|
|
}
|
||
|
|
GUILayout.EndHorizontal();
|
||
|
|
|
||
|
|
GUILayout.Space(5);
|
||
|
|
includeAllThreads = EditorGUILayout.Toggle("包含所有线程(否则仅主线程)", includeAllThreads);
|
||
|
|
|
||
|
|
GUILayout.Space(15);
|
||
|
|
|
||
|
|
if (GUILayout.Button("导出函数级数据 (GC + CPU)", GUILayout.Height(35)))
|
||
|
|
{
|
||
|
|
ExportFunctionLevelData();
|
||
|
|
}
|
||
|
|
|
||
|
|
GUILayout.Space(5);
|
||
|
|
|
||
|
|
if (GUILayout.Button("导出逐帧摘要 (帧时间 + GC)", GUILayout.Height(35)))
|
||
|
|
{
|
||
|
|
ExportPerFrameSummary();
|
||
|
|
}
|
||
|
|
|
||
|
|
GUILayout.Space(5);
|
||
|
|
|
||
|
|
if (GUILayout.Button("一键全量导出 (两个CSV)", GUILayout.Height(35)))
|
||
|
|
{
|
||
|
|
ExportFunctionLevelData();
|
||
|
|
ExportPerFrameSummary();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ================================================================
|
||
|
|
// 加载 .data 并等待帧就绪(LoadProfile 是异步的,首次调用 firstFrameIndex 可能为 -1)
|
||
|
|
// 返回 false 表示加载失败
|
||
|
|
// ================================================================
|
||
|
|
bool LoadAndValidate(out int firstFrame, out int lastFrame)
|
||
|
|
{
|
||
|
|
firstFrame = -1;
|
||
|
|
lastFrame = -1;
|
||
|
|
|
||
|
|
ProfilerDriver.LoadProfile(dataFilePath, false);
|
||
|
|
|
||
|
|
// LoadProfile 异步,轮询等待帧索引就绪(最多 10 秒)
|
||
|
|
DateTime deadline = DateTime.Now.AddSeconds(10);
|
||
|
|
int retry = 0;
|
||
|
|
while (DateTime.Now < deadline)
|
||
|
|
{
|
||
|
|
firstFrame = ProfilerDriver.firstFrameIndex;
|
||
|
|
lastFrame = ProfilerDriver.lastFrameIndex;
|
||
|
|
if (firstFrame >= 0 && lastFrame >= 0 && lastFrame >= firstFrame)
|
||
|
|
return true;
|
||
|
|
|
||
|
|
retry++;
|
||
|
|
if (EditorUtility.DisplayCancelableProgressBar("加载 Profiler 数据",
|
||
|
|
$"等待帧数据就绪... ({retry})", 0f))
|
||
|
|
{
|
||
|
|
EditorUtility.ClearProgressBar();
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 让编辑器处理一帧,使 LoadProfile 继续推进
|
||
|
|
System.Threading.Thread.Sleep(50);
|
||
|
|
}
|
||
|
|
|
||
|
|
EditorUtility.ClearProgressBar();
|
||
|
|
Debug.LogError($"[ProfilerExport] 加载超时 firstFrame={firstFrame} lastFrame={lastFrame}");
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ================================================================
|
||
|
|
// 函数级导出:每个函数的 GC Alloc / Self Time / Total Time / Calls
|
||
|
|
// ================================================================
|
||
|
|
void ExportFunctionLevelData()
|
||
|
|
{
|
||
|
|
if (!ValidateFile()) return;
|
||
|
|
|
||
|
|
string baseName = Path.GetFileNameWithoutExtension(dataFilePath);
|
||
|
|
string csvPath = Path.Combine(outputDir, baseName + "_functions.csv");
|
||
|
|
|
||
|
|
if (!LoadAndValidate(out int firstFrame, out int lastFrame))
|
||
|
|
{
|
||
|
|
EditorUtility.DisplayDialog("错误", "数据文件加载失败或没有帧数据", "OK");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 聚合:函数名 -> 统计
|
||
|
|
var stats = new Dictionary<string, FuncStats>();
|
||
|
|
|
||
|
|
int totalFrames = lastFrame - firstFrame + 1;
|
||
|
|
int processed = 0;
|
||
|
|
int threadCount = includeAllThreads ? 16 : 1; // 最多扫16个线程
|
||
|
|
|
||
|
|
for (int frame = firstFrame; frame <= lastFrame; frame++)
|
||
|
|
{
|
||
|
|
if (EditorUtility.DisplayCancelableProgressBar("导出函数级数据",
|
||
|
|
$"帧 {frame} ({processed}/{totalFrames})",
|
||
|
|
(float)processed / totalFrames))
|
||
|
|
break;
|
||
|
|
|
||
|
|
for (int thread = 0; thread < threadCount; thread++)
|
||
|
|
{
|
||
|
|
using (var frameData = ProfilerDriver.GetHierarchyFrameDataView(
|
||
|
|
frame, thread,
|
||
|
|
HierarchyFrameDataView.ViewModes.MergeSamplesWithTheSameName,
|
||
|
|
HierarchyFrameDataView.columnGcMemory, false))
|
||
|
|
{
|
||
|
|
if (!frameData.valid) break; // 线程不存在,跳出
|
||
|
|
|
||
|
|
int rootId = frameData.GetRootItemID();
|
||
|
|
ProcessItemRecursive(frameData, rootId, stats);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
processed++;
|
||
|
|
}
|
||
|
|
|
||
|
|
EditorUtility.ClearProgressBar();
|
||
|
|
|
||
|
|
// 按 GC Alloc 降序排列
|
||
|
|
var sorted = new List<FuncStats>(stats.Values);
|
||
|
|
sorted.Sort((a, b) => b.totalGcAlloc.CompareTo(a.totalGcAlloc));
|
||
|
|
|
||
|
|
using (var writer = new StreamWriter(csvPath, false, Encoding.UTF8))
|
||
|
|
{
|
||
|
|
writer.WriteLine("FunctionName,TotalGcAllocBytes,AvgGcPerCall,TotalCalls,SelfTimeMs,TotalTimeMs,FrameCount");
|
||
|
|
foreach (var s in sorted)
|
||
|
|
{
|
||
|
|
long avgGc = s.totalCalls > 0 ? s.totalGcAlloc / s.totalCalls : 0;
|
||
|
|
writer.WriteLine($"{EscapeCsv(s.name)},{s.totalGcAlloc},{avgGc},{s.totalCalls},{s.selfTimeMs:F4},{s.totalTimeMs:F4},{s.frameCount}");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 打印 Top 20 GC 分配函数
|
||
|
|
Debug.Log($"[ProfilerExport] 函数级数据已导出: {csvPath}");
|
||
|
|
Debug.Log($"[ProfilerExport] 共 {sorted.Count} 个函数,处理 {processed}/{totalFrames} 帧");
|
||
|
|
Debug.Log("[ProfilerExport] ====== GC Alloc Top 20 ======");
|
||
|
|
int topN = Math.Min(20, sorted.Count);
|
||
|
|
for (int i = 0; i < topN; i++)
|
||
|
|
{
|
||
|
|
var s = sorted[i];
|
||
|
|
Debug.Log($" {i + 1}. {s.name} | GC={s.totalGcAlloc:N0}B | Calls={s.totalCalls} | Self={s.selfTimeMs:F2}ms");
|
||
|
|
}
|
||
|
|
|
||
|
|
EditorUtility.DisplayDialog("完成",
|
||
|
|
$"导出 {sorted.Count} 个函数到:\n{csvPath}\n\nTop 20 GC 函数已打印到 Console", "OK");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ================================================================
|
||
|
|
// 逐帧摘要:每帧的总时间、GC、DrawCall 等
|
||
|
|
// ================================================================
|
||
|
|
void ExportPerFrameSummary()
|
||
|
|
{
|
||
|
|
if (!ValidateFile()) return;
|
||
|
|
|
||
|
|
string baseName = Path.GetFileNameWithoutExtension(dataFilePath);
|
||
|
|
string csvPath = Path.Combine(outputDir, baseName + "_frames.csv");
|
||
|
|
|
||
|
|
if (!LoadAndValidate(out int firstFrame, out int lastFrame))
|
||
|
|
{
|
||
|
|
EditorUtility.DisplayDialog("错误", "数据文件加载失败或没有帧数据", "OK");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
int totalFrames = lastFrame - firstFrame + 1;
|
||
|
|
int processed = 0;
|
||
|
|
|
||
|
|
using (var writer = new StreamWriter(csvPath, false, Encoding.UTF8))
|
||
|
|
{
|
||
|
|
writer.WriteLine("FrameIndex,FrameTimeMs,Fps,TotalGcAllocBytes,DrawCalls,Batches,SetPassCalls,Triangles,Vertices");
|
||
|
|
|
||
|
|
for (int frame = firstFrame; frame <= lastFrame; frame++)
|
||
|
|
{
|
||
|
|
if (EditorUtility.DisplayCancelableProgressBar("导出逐帧摘要",
|
||
|
|
$"帧 {frame} ({processed}/{totalFrames})",
|
||
|
|
(float)processed / totalFrames))
|
||
|
|
break;
|
||
|
|
|
||
|
|
float frameTimeMs = 0f;
|
||
|
|
long frameGc = 0;
|
||
|
|
int drawCalls = 0, batches = 0, setPass = 0, triangles = 0, vertices = 0;
|
||
|
|
|
||
|
|
using (var frameData = ProfilerDriver.GetHierarchyFrameDataView(
|
||
|
|
frame, 0,
|
||
|
|
HierarchyFrameDataView.ViewModes.MergeSamplesWithTheSameName,
|
||
|
|
HierarchyFrameDataView.columnGcMemory, false))
|
||
|
|
{
|
||
|
|
if (frameData.valid)
|
||
|
|
{
|
||
|
|
frameTimeMs = frameData.frameTimeMs;
|
||
|
|
// 遍历根节点找 GC Alloc 总量和渲染统计
|
||
|
|
int rootId = frameData.GetRootItemID();
|
||
|
|
var children = new List<int>();
|
||
|
|
frameData.GetItemChildren(rootId, children);
|
||
|
|
foreach (int childId in children)
|
||
|
|
{
|
||
|
|
string name = frameData.GetItemName(childId);
|
||
|
|
float gc = frameData.GetItemColumnDataAsFloat(childId, HierarchyFrameDataView.columnGcMemory);
|
||
|
|
frameGc += (long)gc;
|
||
|
|
|
||
|
|
// 渲染相关
|
||
|
|
if (name == "Render")
|
||
|
|
{
|
||
|
|
var renderChildren = new List<int>();
|
||
|
|
frameData.GetItemChildren(childId, renderChildren);
|
||
|
|
foreach (int rid in renderChildren)
|
||
|
|
{
|
||
|
|
string rname = frameData.GetItemName(rid);
|
||
|
|
float val = frameData.GetItemColumnDataAsFloat(rid, HierarchyFrameDataView.columnTotalTime);
|
||
|
|
// 渲染统计需要用 ProfilerRecorder 获取
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 用 ProfilerRecorder 获取渲染数据
|
||
|
|
using (var rec = ProfilerRecorder.StartNew(ProfilerCategory.Render, "Draw Calls Count", 1))
|
||
|
|
drawCalls = (int)rec.LastValue;
|
||
|
|
using (var rec = ProfilerRecorder.StartNew(ProfilerCategory.Render, "Batches Count", 1))
|
||
|
|
batches = (int)rec.LastValue;
|
||
|
|
using (var rec = ProfilerRecorder.StartNew(ProfilerCategory.Render, "SetPass Calls Count", 1))
|
||
|
|
setPass = (int)rec.LastValue;
|
||
|
|
using (var rec = ProfilerRecorder.StartNew(ProfilerCategory.Render, "Triangles Count", 1))
|
||
|
|
triangles = (int)rec.LastValue;
|
||
|
|
using (var rec = ProfilerRecorder.StartNew(ProfilerCategory.Render, "Vertices Count", 1))
|
||
|
|
vertices = (int)rec.LastValue;
|
||
|
|
|
||
|
|
float fps = frameTimeMs > 0 ? 1000f / frameTimeMs : 0;
|
||
|
|
writer.WriteLine($"{frame},{frameTimeMs:F2},{fps:F1},{frameGc},{drawCalls},{batches},{setPass},{triangles},{vertices}");
|
||
|
|
|
||
|
|
processed++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
EditorUtility.ClearProgressBar();
|
||
|
|
Debug.Log($"[ProfilerExport] 逐帧摘要已导出: {csvPath}");
|
||
|
|
EditorUtility.DisplayDialog("完成", $"逐帧摘要已导出到:\n{csvPath}", "OK");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ================================================================
|
||
|
|
// 递归遍历 hierarchy,累加每个函数的统计
|
||
|
|
// 每层递归用独立 List,避免 GetItemChildren 修改正在枚举的集合
|
||
|
|
// ================================================================
|
||
|
|
void ProcessItemRecursive(HierarchyFrameDataView frameData, int itemId,
|
||
|
|
Dictionary<string, FuncStats> stats)
|
||
|
|
{
|
||
|
|
ProcessItem(frameData, itemId, stats);
|
||
|
|
|
||
|
|
// 递归子节点:每层新建 list,避免递归间共享集合被修改导致枚举异常
|
||
|
|
var children = new List<int>();
|
||
|
|
frameData.GetItemChildren(itemId, children);
|
||
|
|
foreach (int childId in children)
|
||
|
|
{
|
||
|
|
ProcessItemRecursive(frameData, childId, stats);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
void ProcessItem(HierarchyFrameDataView frameData, int itemId, Dictionary<string, FuncStats> stats)
|
||
|
|
{
|
||
|
|
string name = frameData.GetItemName(itemId);
|
||
|
|
if (string.IsNullOrEmpty(name)) return;
|
||
|
|
|
||
|
|
float totalTime = frameData.GetItemColumnDataAsFloat(itemId, HierarchyFrameDataView.columnTotalTime);
|
||
|
|
float selfTime = frameData.GetItemColumnDataAsFloat(itemId, HierarchyFrameDataView.columnSelfTime);
|
||
|
|
float gcAlloc = frameData.GetItemColumnDataAsFloat(itemId, HierarchyFrameDataView.columnGcMemory);
|
||
|
|
int calls = itemId >= 0 ? 1 : 0; // calls 列可能不直接可用,用出现次数代替
|
||
|
|
|
||
|
|
// 尝试获取 calls 列(如果该列存在)
|
||
|
|
try
|
||
|
|
{
|
||
|
|
// 某些 Unity 版本支持 GetItemColumnDataAsInt
|
||
|
|
var method = typeof(HierarchyFrameDataView).GetMethod("GetItemColumnDataAsInt",
|
||
|
|
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
|
||
|
|
if (method != null)
|
||
|
|
{
|
||
|
|
calls = (int)method.Invoke(frameData, new object[] { itemId, HierarchyFrameDataView.columnCalls });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch { /* 忽略,用默认值 */ }
|
||
|
|
|
||
|
|
if (!stats.TryGetValue(name, out var s))
|
||
|
|
{
|
||
|
|
s = new FuncStats { name = name };
|
||
|
|
stats[name] = s;
|
||
|
|
}
|
||
|
|
s.totalTimeMs += totalTime;
|
||
|
|
s.selfTimeMs += selfTime;
|
||
|
|
s.totalGcAlloc += (long)gcAlloc;
|
||
|
|
s.totalCalls += calls;
|
||
|
|
s.frameCount++;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ================================================================
|
||
|
|
// 工具方法
|
||
|
|
// ================================================================
|
||
|
|
bool ValidateFile()
|
||
|
|
{
|
||
|
|
if (string.IsNullOrEmpty(dataFilePath) || !File.Exists(dataFilePath))
|
||
|
|
{
|
||
|
|
EditorUtility.DisplayDialog("错误", "请选择有效的 .data 文件", "OK");
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (string.IsNullOrEmpty(outputDir) || !Directory.Exists(outputDir))
|
||
|
|
{
|
||
|
|
EditorUtility.DisplayDialog("错误", "请选择有效的输出目录", "OK");
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
string EscapeCsv(string field)
|
||
|
|
{
|
||
|
|
if (field.Contains(",") || field.Contains("\"") || field.Contains("\n"))
|
||
|
|
return "\"" + field.Replace("\"", "\"\"") + "\"";
|
||
|
|
return field;
|
||
|
|
}
|
||
|
|
|
||
|
|
class FuncStats
|
||
|
|
{
|
||
|
|
public string name;
|
||
|
|
public float totalTimeMs;
|
||
|
|
public float selfTimeMs;
|
||
|
|
public long totalGcAlloc;
|
||
|
|
public int totalCalls;
|
||
|
|
public int frameCount;
|
||
|
|
}
|
||
|
|
}
|