Enhance Profiler with Label Metrics and Rolling Windows

- Introduced LabelMetrics and RollingLabelMetrics structs to capture detailed profiling data.
- Implemented a RollingWindow class to maintain a rolling average of metrics.
- Updated Frame class to collect and store label metrics, including memory allocation and garbage collection statistics.
- Enhanced ScopeNode class to support self-time calculations and child duration tracking.
- Improved the Profiler class to manage frame budgets and rolling metrics.
- Refactored the ProfilerVisualizer to support new timeline rendering features, including zoom and pan functionality.
- Added tooltip support for detailed node information in the flame graph.
- Enhanced BoundedQueue to be thread-safe with proper locking mechanisms for concurrent access.
This commit is contained in:
max
2026-08-04 17:19:00 +02:00
parent 5eaf3547dc
commit 2d4139fb2c
4 changed files with 1021 additions and 234 deletions
+104 -60
View File
@@ -1,6 +1,7 @@
using ImGuiNET;
using MoonTools.ECS;
using Nerfed.Runtime;
using System.Numerics;
namespace Nerfed.Editor.Systems
{
@@ -12,7 +13,9 @@ namespace Nerfed.Editor.Systems
private int selectedFrame = 0;
private int previousSelectedFrame = -1;
private IOrderedEnumerable<KeyValuePair<string, (double ms, uint calls)>> orderedCombinedData = null;
private IOrderedEnumerable<KeyValuePair<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms)>> orderedCombinedData = null;
private readonly ProfilerVisualizer.TimelineState timelineState = new ProfilerVisualizer.TimelineState();
private readonly List<Profiler.Frame> frameSnapshot = new List<Profiler.Frame>(256);
public EditorProfilerWindow(World world) : base(world)
{
@@ -25,49 +28,88 @@ namespace Nerfed.Editor.Systems
return;
}
if (Profiler.CopyFramesTo(frameSnapshot) <= 0)
{
return;
}
selectedFrame = Math.Clamp(selectedFrame, 0, frameSnapshot.Count - 1);
timelineState.SelectedFrameIndex = Math.Clamp(timelineState.SelectedFrameIndex, -1, frameSnapshot.Count - 1);
timelineState.VisibleFrameCount = Math.Clamp(timelineState.VisibleFrameCount, 1, frameSnapshot.Count);
ImGui.Begin("Profiler");
ImGui.BeginChild("Toolbar", new System.Numerics.Vector2(0, 0), ImGuiChildFlags.AutoResizeY);
ImGui.BeginChild("Toolbar", new Vector2(0, 0), ImGuiChildFlags.AutoResizeY);
if (ImGui.RadioButton("Recording", Profiler.IsRecording))
{
Profiler.SetActive(!Profiler.IsRecording);
}
ImGui.SameLine();
ImGui.Text("Follow");
ImGui.SameLine();
ImGui.Checkbox("##follow-timeline", ref timelineState.FollowLatest);
ImGui.SameLine();
int visibleFrameCount = timelineState.VisibleFrameCount;
ImGui.SetNextItemWidth(130f);
if (ImGui.SliderInt("Window", ref visibleFrameCount, 1, frameSnapshot.Count))
{
timelineState.VisibleFrameCount = visibleFrameCount;
timelineState.FollowLatest = false;
}
ImGui.SameLine();
if (ImGui.Button("Reset Zoom"))
{
timelineState.Zoom = 1f;
timelineState.PanTicks = 0d;
timelineState.FollowLatest = true;
}
if (Profiler.IsRecording)
{
// Select last frame when recording to see latest frame data.
selectedFrame = Profiler.Frames.Count - 1;
}
if (ImGui.SliderInt(string.Empty, ref selectedFrame, 0, Profiler.Frames.Count - 1))
{
// Stop recording when browsing frames.
Profiler.SetActive(false);
selectedFrame = frameSnapshot.Count - 1;
}
Profiler.Frame frame = Profiler.Frames.ElementAt(selectedFrame);
int sliderFrame = selectedFrame;
if (ImGui.SliderInt("Frame", ref sliderFrame, 0, frameSnapshot.Count - 1))
{
selectedFrame = sliderFrame;
timelineState.SelectedFrameIndex = selectedFrame;
timelineState.FollowLatest = false;
}
Profiler.Frame frame = frameSnapshot[selectedFrame];
double ms = frame.ElapsedMilliseconds();
double s = 1000;
ImGui.Text($"Frame: {frame.FrameCount} ({ms:0.000} ms | {(s / ms):0} fps)");
ImGui.Text($"Budget: {frame.BudgetMilliseconds:0.00} ms ({(frame.OverBudget ? "over" : "within")})");
ImGui.Text($"Alloc: {frame.AllocatedBytesDelta / 1024d:0.0} KB | GC: G0 {frame.Gen0CollectionsDelta}, G1 {frame.Gen1CollectionsDelta}, G2 {frame.Gen2CollectionsDelta}");
ImGui.EndChild();
if (!Profiler.IsRecording) {
if (previousSelectedFrame != selectedFrame)
{
previousSelectedFrame = selectedFrame;
orderedCombinedData = CalculateCombinedData(frame);
}
DrawFlameGraph(frame);
DrawHierachy(frame);
ImGui.SameLine();
DrawCombined(orderedCombinedData);
ProfilerVisualizer.TimelineRenderResult timelineResult = DrawFlameGraph(frameSnapshot, timelineState);
if (timelineResult.SelectionChanged)
{
selectedFrame = timelineResult.SelectedFrameIndex;
}
selectedFrame = Math.Clamp(selectedFrame, 0, frameSnapshot.Count - 1);
frame = frameSnapshot[selectedFrame];
if (previousSelectedFrame != selectedFrame)
{
previousSelectedFrame = selectedFrame;
orderedCombinedData = CalculateCombinedData(frame);
}
DrawHierachy(frame);
ImGui.SameLine();
DrawCombined(orderedCombinedData);
ImGui.End();
}
@@ -78,13 +120,14 @@ namespace Nerfed.Editor.Systems
return;
}
ImGui.BeginChild("Hierachy", new System.Numerics.Vector2(150, 0), ImGuiChildFlags.ResizeX);
ImGui.BeginChild("Hierachy", new Vector2(150, 0), ImGuiChildFlags.ResizeX);
if (ImGui.BeginTable("ProfilerData", 3, tableFlags, new System.Numerics.Vector2(0, 0)))
if (ImGui.BeginTable("ProfilerData", 4, tableFlags, new Vector2(0, 0)))
{
ImGui.TableSetupColumn("name", ImGuiTableColumnFlags.WidthStretch, 0.8f, 0);
ImGui.TableSetupColumn("thread", ImGuiTableColumnFlags.WidthStretch, 0.2f, 1);
ImGui.TableSetupColumn("ms", ImGuiTableColumnFlags.WidthStretch, 0.2f, 1);
ImGui.TableSetupColumn("name", ImGuiTableColumnFlags.WidthStretch, 0.6f, 0);
ImGui.TableSetupColumn("thread", ImGuiTableColumnFlags.WidthStretch, 0.15f, 1);
ImGui.TableSetupColumn("ms", ImGuiTableColumnFlags.WidthStretch, 0.15f, 1);
ImGui.TableSetupColumn("self", ImGuiTableColumnFlags.WidthStretch, 0.15f, 2);
ImGui.TableSetupScrollFreeze(0, 1); // Make row always visible
ImGui.TableHeadersRow();
@@ -119,6 +162,8 @@ namespace Nerfed.Editor.Systems
ImGui.Text($"{node.ManagedThreadId}");
ImGui.TableNextColumn();
ImGui.Text($"{node.ElapsedMilliseconds():0.000}");
ImGui.TableNextColumn();
ImGui.Text($"{node.SelfMilliseconds():0.000}");
if (isOpen)
{
@@ -130,24 +175,27 @@ namespace Nerfed.Editor.Systems
}
}
private static void DrawCombined(in IOrderedEnumerable<KeyValuePair<string, (double ms, uint calls)>> orderedCombinedData)
private static void DrawCombined(in IOrderedEnumerable<KeyValuePair<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms)>> orderedCombinedData)
{
if(orderedCombinedData == null)
{
return;
}
ImGui.BeginChild("Combined", new System.Numerics.Vector2(0, 0));
ImGui.BeginChild("Combined", new Vector2(0, 0));
if (ImGui.BeginTable("ProfilerCombinedData", 3, tableFlags, new System.Numerics.Vector2(0, 0)))
if (ImGui.BeginTable("ProfilerCombinedData", 6, tableFlags, new Vector2(0, 0)))
{
ImGui.TableSetupColumn("name", ImGuiTableColumnFlags.WidthStretch, 0.6f, 0);
ImGui.TableSetupColumn("ms", ImGuiTableColumnFlags.WidthStretch, 0.2f, 1);
ImGui.TableSetupColumn("calls", ImGuiTableColumnFlags.WidthStretch, 0.2f, 2);
ImGui.TableSetupColumn("name", ImGuiTableColumnFlags.WidthStretch, 0.45f, 0);
ImGui.TableSetupColumn("ms", ImGuiTableColumnFlags.WidthStretch, 0.14f, 1);
ImGui.TableSetupColumn("self", ImGuiTableColumnFlags.WidthStretch, 0.14f, 2);
ImGui.TableSetupColumn("calls", ImGuiTableColumnFlags.WidthStretch, 0.10f, 3);
ImGui.TableSetupColumn("avg", ImGuiTableColumnFlags.WidthStretch, 0.10f, 4);
ImGui.TableSetupColumn("p95", ImGuiTableColumnFlags.WidthStretch, 0.10f, 5);
ImGui.TableSetupScrollFreeze(0, 1); // Make row always visible
ImGui.TableHeadersRow();
foreach (KeyValuePair<string, (double ms, uint calls)> combinedData in orderedCombinedData)
foreach (KeyValuePair<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms)> combinedData in orderedCombinedData)
{
ImGui.TableNextRow();
ImGui.TableNextColumn();
@@ -155,7 +203,13 @@ namespace Nerfed.Editor.Systems
ImGui.TableNextColumn();
ImGui.Text($"{combinedData.Value.ms:0.000}");
ImGui.TableNextColumn();
ImGui.Text($"{combinedData.Value.selfMs:0.000}");
ImGui.TableNextColumn();
ImGui.Text($"{combinedData.Value.calls}");
ImGui.TableNextColumn();
ImGui.Text($"{combinedData.Value.avgMs:0.000}");
ImGui.TableNextColumn();
ImGui.Text($"{combinedData.Value.p95Ms:0.000}");
}
ImGui.EndTable();
@@ -164,41 +218,31 @@ namespace Nerfed.Editor.Systems
ImGui.EndChild();
}
private static IOrderedEnumerable<KeyValuePair<string, (double ms, uint calls)>> CalculateCombinedData(Profiler.Frame frame)
private static IOrderedEnumerable<KeyValuePair<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms)>> CalculateCombinedData(Profiler.Frame frame)
{
Dictionary<string, (double ms, uint calls)> combinedRecordData = new Dictionary<string, (double ms, uint calls)>(128);
foreach (Profiler.ScopeNode node in frame.RootNodes)
IReadOnlyDictionary<string, Profiler.RollingLabelMetrics> rollingData = Profiler.GetRollingLabelMetricsSnapshot();
Dictionary<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms)> combinedRecordData = new Dictionary<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms)>(128);
foreach (KeyValuePair<string, Profiler.LabelMetrics> metric in frame.LabelMetrics)
{
CalculateCombinedData(node, in combinedRecordData);
Profiler.RollingLabelMetrics rolling = default;
if (rollingData.TryGetValue(metric.Key, out Profiler.RollingLabelMetrics found))
{
rolling = found;
}
combinedRecordData[metric.Key] = (metric.Value.InclusiveMs, metric.Value.SelfMs, metric.Value.Calls, rolling.AverageMs, rolling.P95Ms);
}
return combinedRecordData.OrderByDescending(x => x.Value.ms);
}
private static void CalculateCombinedData(Profiler.ScopeNode node, in Dictionary<string, (double ms, uint calls)> combinedRecordData)
private static ProfilerVisualizer.TimelineRenderResult DrawFlameGraph(IReadOnlyList<Profiler.Frame> frames, ProfilerVisualizer.TimelineState timelineState)
{
if (combinedRecordData.TryGetValue(node.Label, out (double ms, uint calls) combined))
if (frames == null || frames.Count == 0)
{
combinedRecordData[node.Label] = (combined.ms + node.ElapsedMilliseconds(), combined.calls + 1);
}
else
{
combinedRecordData.Add(node.Label, (node.ElapsedMilliseconds(), 1));
return default;
}
for (int i = 0; i < node.Children.Count; i++)
{
CalculateCombinedData(node.Children[i], combinedRecordData);
}
}
private static void DrawFlameGraph(Profiler.Frame frame)
{
if (frame == null)
{
return;
}
ProfilerVisualizer.RenderFlameGraph(frame);
return ProfilerVisualizer.RenderTimeline(frames, timelineState);
}
}
}
+334 -33
View File
@@ -18,18 +18,145 @@ public struct ProfilerScope : IDisposable
public static class Profiler
{
public readonly struct LabelMetrics
{
public LabelMetrics(double inclusiveMs, double selfMs, uint calls, double minInclusiveMs, double maxInclusiveMs)
{
InclusiveMs = inclusiveMs;
SelfMs = selfMs;
Calls = calls;
MinInclusiveMs = minInclusiveMs;
MaxInclusiveMs = maxInclusiveMs;
}
public double InclusiveMs { get; }
public double SelfMs { get; }
public uint Calls { get; }
public double MinInclusiveMs { get; }
public double MaxInclusiveMs { get; }
}
public readonly struct RollingLabelMetrics
{
public RollingLabelMetrics(double averageMs, double minMs, double maxMs, double p95Ms, int samples)
{
AverageMs = averageMs;
MinMs = minMs;
MaxMs = maxMs;
P95Ms = p95Ms;
Samples = samples;
}
public double AverageMs { get; }
public double MinMs { get; }
public double MaxMs { get; }
public double P95Ms { get; }
public int Samples { get; }
}
private sealed class RollingWindow
{
private readonly double[] values;
private int index;
private int count;
public RollingWindow(int capacity)
{
values = new double[Math.Max(8, capacity)];
}
public void Add(double value)
{
values[index] = value;
index = (index + 1) % values.Length;
if (count < values.Length)
{
count++;
}
}
public RollingLabelMetrics Snapshot()
{
if (count == 0)
{
return default;
}
double sum = 0;
double min = double.MaxValue;
double max = double.MinValue;
double[] sorted = new double[count];
int start = (index - count + values.Length) % values.Length;
for (int i = 0; i < count; i++)
{
double value = values[(start + i) % values.Length];
sorted[i] = value;
sum += value;
min = Math.Min(min, value);
max = Math.Max(max, value);
}
Array.Sort(sorted);
int percentileIndex = (int)Math.Ceiling((count - 1) * 0.95d);
double p95 = sorted[Math.Clamp(percentileIndex, 0, count - 1)];
return new RollingLabelMetrics(sum / count, min, max, p95, count);
}
}
public class Frame(uint frameCount)
{
public uint FrameCount { get; } = frameCount;
public long StartTime { get; } = Stopwatch.GetTimestamp();
public long EndTime { get; private set; }
// Use a concurrent list to collect all thread root nodes per frame.
public ConcurrentBag<ScopeNode> RootNodes = new ConcurrentBag<ScopeNode>();
private readonly List<ScopeNode> rootNodes = new List<ScopeNode>(8);
private readonly object rootNodesLock = new object();
private readonly Dictionary<string, LabelMetrics> labelMetrics = new Dictionary<string, LabelMetrics>(128, StringComparer.Ordinal);
internal void End()
public IReadOnlyList<ScopeNode> RootNodes => rootNodes;
public IReadOnlyDictionary<string, LabelMetrics> LabelMetrics => labelMetrics;
public long AllocatedBytesStart { get; } = GC.GetTotalAllocatedBytes(false);
public long AllocatedBytesEnd { get; private set; }
public long AllocatedBytesDelta { get; private set; }
public int Gen0CollectionsStart { get; } = GC.CollectionCount(0);
public int Gen1CollectionsStart { get; } = GC.CollectionCount(1);
public int Gen2CollectionsStart { get; } = GC.CollectionCount(2);
public int Gen0CollectionsEnd { get; private set; }
public int Gen1CollectionsEnd { get; private set; }
public int Gen2CollectionsEnd { get; private set; }
public int Gen0CollectionsDelta { get; private set; }
public int Gen1CollectionsDelta { get; private set; }
public int Gen2CollectionsDelta { get; private set; }
public bool OverBudget { get; private set; }
public double BudgetMilliseconds { get; private set; }
internal void AddRootNode(ScopeNode rootNode)
{
lock (rootNodesLock)
{
rootNodes.Add(rootNode);
}
}
internal void End(double budgetMilliseconds)
{
EndTime = Stopwatch.GetTimestamp();
BudgetMilliseconds = budgetMilliseconds;
OverBudget = budgetMilliseconds > 0 && ElapsedMilliseconds() > budgetMilliseconds;
AllocatedBytesEnd = GC.GetTotalAllocatedBytes(false);
AllocatedBytesDelta = AllocatedBytesEnd - AllocatedBytesStart;
Gen0CollectionsEnd = GC.CollectionCount(0);
Gen1CollectionsEnd = GC.CollectionCount(1);
Gen2CollectionsEnd = GC.CollectionCount(2);
Gen0CollectionsDelta = Gen0CollectionsEnd - Gen0CollectionsStart;
Gen1CollectionsDelta = Gen1CollectionsEnd - Gen1CollectionsStart;
Gen2CollectionsDelta = Gen2CollectionsEnd - Gen2CollectionsStart;
BuildLabelMetrics();
}
public double ElapsedMilliseconds()
@@ -37,53 +164,155 @@ public static class Profiler
long elapsedTicks = EndTime - StartTime;
return ((double)(elapsedTicks * 1000)) / Stopwatch.Frequency;
}
private void BuildLabelMetrics()
{
labelMetrics.Clear();
lock (rootNodesLock)
{
for (int i = 0; i < rootNodes.Count; i++)
{
AccumulateLabelMetrics(rootNodes[i]);
}
}
}
private void AccumulateLabelMetrics(ScopeNode node)
{
double inclusiveMs = node.ElapsedMilliseconds();
double selfMs = node.SelfMilliseconds();
if (labelMetrics.TryGetValue(node.Label, out LabelMetrics current))
{
labelMetrics[node.Label] = new LabelMetrics(
current.InclusiveMs + inclusiveMs,
current.SelfMs + selfMs,
current.Calls + 1,
Math.Min(current.MinInclusiveMs, inclusiveMs),
Math.Max(current.MaxInclusiveMs, inclusiveMs));
}
else
{
labelMetrics[node.Label] = new LabelMetrics(inclusiveMs, selfMs, 1, inclusiveMs, inclusiveMs);
}
for (int i = 0; i < node.Children.Count; i++)
{
AccumulateLabelMetrics(node.Children[i]);
}
}
}
public class ScopeNode(string label)
public class ScopeNode
{
public string Label { get; } = label;
public long StartTime { get; private set; } = Stopwatch.GetTimestamp(); // Start time in ticks
public string Label { get; private set; } = string.Empty;
public long StartTime { get; private set; }
public long EndTime { get; private set; }
public int ManagedThreadId { get; } = Environment.CurrentManagedThreadId;
public int ManagedThreadId { get; private set; }
public List<ScopeNode> Children { get; } = new List<ScopeNode>();
internal ScopeNode Parent { get; private set; }
internal long ChildrenDurationTicks { get; private set; }
internal void Reset(string label, int managedThreadId, ScopeNode parent)
{
Label = label;
ManagedThreadId = managedThreadId;
Parent = parent;
StartTime = Stopwatch.GetTimestamp();
EndTime = 0;
ChildrenDurationTicks = 0;
Children.Clear();
}
internal void End()
{
EndTime = Stopwatch.GetTimestamp(); // End time in ticks
if (EndTime != 0)
{
return;
}
EndTime = Stopwatch.GetTimestamp();
if (Parent != null)
{
Parent.ChildrenDurationTicks += Math.Max(0, EndTime - StartTime);
}
}
public double ElapsedMilliseconds()
{
return ((double)(EndTime - StartTime)) * 1000 / Stopwatch.Frequency; // Convert ticks to ms
return ((double)(Math.Max(0, EndTime - StartTime))) * 1000 / Stopwatch.Frequency;
}
public double SelfMilliseconds()
{
long elapsedTicks = Math.Max(0, EndTime - StartTime);
long selfTicks = Math.Max(0, elapsedTicks - ChildrenDurationTicks);
return ((double)selfTicks) * 1000 / Stopwatch.Frequency;
}
// Add a child node (used for nested scopes)
internal ScopeNode AddChild(string label)
{
ScopeNode child = new ScopeNode(label);
ScopeNode child = RentNode(label, ManagedThreadId, this);
Children.Add(child);
return child;
}
}
private const int maxFrames = 128;
private const int rollingWindowSize = 240;
public static bool IsRecording { get; private set; } = true;
public static double FrameBudgetMilliseconds { get; set; } = 16.667;
// Store only the last x amount of frames in memory.
public static readonly BoundedQueue<Frame> Frames = new(maxFrames);
// Use ThreadLocal to store a stack of ScopeNodes per thread and enable tracking of thread-local values.
private static readonly ThreadLocal<Stack<ScopeNode>> threadLocalScopes = new ThreadLocal<Stack<ScopeNode>>(() => new Stack<ScopeNode>(), true);
private static readonly ConcurrentDictionary<int, string> threadRootLabelCache = new ConcurrentDictionary<int, string>();
private static readonly ConcurrentBag<ScopeNode> nodePool = new ConcurrentBag<ScopeNode>();
private static readonly Dictionary<string, RollingWindow> rollingWindows = new Dictionary<string, RollingWindow>(256, StringComparer.Ordinal);
private static readonly object rollingWindowsLock = new object();
private static Frame currentFrame = null;
private static uint frameCount = 0;
public static void SetActive(bool isRecording)
{
if (IsRecording && !isRecording)
{
FinalizeCurrentFrame();
}
IsRecording = isRecording;
}
public static int CopyFramesTo(List<Frame> destination)
{
destination.Clear();
foreach (Frame frame in Frames)
{
destination.Add(frame);
}
return destination.Count;
}
public static IReadOnlyDictionary<string, RollingLabelMetrics> GetRollingLabelMetricsSnapshot()
{
lock (rollingWindowsLock)
{
Dictionary<string, RollingLabelMetrics> snapshot = new Dictionary<string, RollingLabelMetrics>(rollingWindows.Count, StringComparer.Ordinal);
foreach (KeyValuePair<string, RollingWindow> pair in rollingWindows)
{
snapshot[pair.Key] = pair.Value.Snapshot();
}
return snapshot;
}
}
[Conditional("PROFILING")]
public static void BeginFrame()
{
@@ -92,6 +321,11 @@ public static class Profiler
return;
}
if (currentFrame != null)
{
FinalizeCurrentFrame();
}
currentFrame = new Frame(frameCount);
}
@@ -103,40 +337,31 @@ public static class Profiler
return;
}
foreach (Stack<ScopeNode> scopes in threadLocalScopes.Values)
{
if (scopes.Count > 0)
{
// Pop the left over root nodes.
ScopeNode currentScope = scopes.Pop();
currentScope.End();
}
// Clean up the thread-local stack to ensure it's empty for the next frame.
scopes.Clear();
}
currentFrame.End();
Frames.Enqueue(currentFrame);
frameCount++;
FinalizeCurrentFrame();
}
[Conditional("PROFILING")]
public static void BeginSample(string label)
{
if (!IsRecording)
if (!IsRecording || currentFrame == null)
{
return;
}
Stack<ScopeNode> scopes = threadLocalScopes.Value; // Get the stack for the current thread
Frame frame = currentFrame;
if (frame == null)
{
return;
}
if (scopes.Count == 0)
{
// First scope for this thread (new root for this thread)
ScopeNode rootScopeNode = new ScopeNode($"Thread-{Environment.CurrentManagedThreadId}");
int threadId = Environment.CurrentManagedThreadId;
ScopeNode rootScopeNode = RentNode(GetThreadRootLabel(threadId), threadId, null);
scopes.Push(rootScopeNode);
currentFrame.RootNodes.Add(rootScopeNode); // Add root node to the frame list
frame.AddRootNode(rootScopeNode);
}
// Create a new child under the current top of the stack
@@ -148,19 +373,95 @@ public static class Profiler
[Conditional("PROFILING")]
public static void EndSample()
{
if (!IsRecording)
if (!IsRecording || currentFrame == null)
{
return;
}
Stack<ScopeNode> scopes = threadLocalScopes.Value;
if (scopes.Count > 0)
if (scopes.Count > 1)
{
// Only pop if this is not the root node.
//ScopeNode currentScope = scopes.Count > 1 ? scopes.Pop() : scopes.Peek();
ScopeNode currentScope = scopes.Pop();
currentScope.End();
}
}
private static string GetThreadRootLabel(int threadId)
{
return threadRootLabelCache.GetOrAdd(threadId, static id => $"Thread-{id}");
}
private static ScopeNode RentNode(string label, int managedThreadId, ScopeNode parent)
{
if (!nodePool.TryTake(out ScopeNode node))
{
node = new ScopeNode();
}
node.Reset(label, managedThreadId, parent);
return node;
}
private static void ReturnNodeTree(ScopeNode node)
{
for (int i = 0; i < node.Children.Count; i++)
{
ReturnNodeTree(node.Children[i]);
}
node.Reset(string.Empty, 0, null);
nodePool.Add(node);
}
private static void FinalizeCurrentFrame()
{
Frame frame = currentFrame;
if (frame == null)
{
return;
}
foreach (Stack<ScopeNode> scopes in threadLocalScopes.Values)
{
while (scopes.Count > 0)
{
ScopeNode currentScope = scopes.Pop();
currentScope.End();
}
scopes.Clear();
}
frame.End(FrameBudgetMilliseconds);
if (Frames.Enqueue(frame, out Frame evictedFrame))
{
for (int i = 0; i < evictedFrame.RootNodes.Count; i++)
{
ReturnNodeTree(evictedFrame.RootNodes[i]);
}
}
UpdateRollingWindows(frame);
frameCount++;
currentFrame = null;
}
private static void UpdateRollingWindows(Frame frame)
{
lock (rollingWindowsLock)
{
foreach (KeyValuePair<string, LabelMetrics> pair in frame.LabelMetrics)
{
if (!rollingWindows.TryGetValue(pair.Key, out RollingWindow window))
{
window = new RollingWindow(rollingWindowSize);
rollingWindows.Add(pair.Key, window);
}
window.Add(pair.Value.InclusiveMs);
}
}
}
}
+508 -123
View File
@@ -5,152 +5,537 @@ namespace Nerfed.Runtime;
public static class ProfilerVisualizer
{
private const float barHeight = 20f;
private const float barPadding = 2f;
// Render the flame graph across multiple threads
public static void RenderFlameGraph(Profiler.Frame frame)
public sealed class TimelineState
{
if (frame == null) return;
if (frame.RootNodes == null) return;
// Calculate the total timeline duration (max end time across all nodes)
double totalDuration = frame.EndTime - frame.StartTime;
double startTime = frame.StartTime;
// Precompute the maximum depth for each thread's call stack
Dictionary<int, int> threadMaxDepths = new Dictionary<int, int>();
foreach (IGrouping<int, Profiler.ScopeNode> threadGroup in frame.RootNodes.GroupBy(node => node.ManagedThreadId))
{
int maxDepth = 0;
foreach (Profiler.ScopeNode rootNode in threadGroup)
{
maxDepth = Math.Max(maxDepth, GetMaxDepth(rootNode, 0));
}
threadMaxDepths[threadGroup.Key] = maxDepth;
}
// Start a child window to support scrolling
ImGui.BeginChild("FlameGraph", new Vector2(0, 64), ImGuiChildFlags.Border | ImGuiChildFlags.ResizeY, ImGuiWindowFlags.HorizontalScrollbar | ImGuiWindowFlags.AlwaysVerticalScrollbar);
ImDrawListPtr drawList = ImGui.GetWindowDrawList();
Vector2 windowPos = ImGui.GetCursorScreenPos();
// Sort nodes by ThreadID, ensuring main thread (Thread ID 1) is on top
IOrderedEnumerable<IGrouping<int, Profiler.ScopeNode>> threadGroups = frame.RootNodes.GroupBy(node => node.ManagedThreadId).OrderBy(g => g.Key);
// Initial Y position for drawing
float baseY = windowPos.Y;
bool alternate = false;
float contentWidth = ImGui.GetContentRegionAvail().X;
// Draw each thread's flame graph row by row
foreach (IGrouping<int, Profiler.ScopeNode> threadGroup in threadGroups)
{
int threadId = threadGroup.Key;
// Compute the base Y position for this thread
float threadBaseY = baseY;
// Calculate the maximum height for this thread's flame graph
float threadHeight = (threadMaxDepths[threadId] + 1) * (barHeight + barPadding);
// Draw the alternating background for each thread row
uint backgroundColor = ImGui.ColorConvertFloat4ToU32(alternate ? new Vector4(0.2f, 0.2f, 0.2f, 1f) : new Vector4(0.1f, 0.1f, 0.1f, 1f));
drawList.AddRectFilled(new Vector2(windowPos.X, threadBaseY), new Vector2(windowPos.X + contentWidth, threadBaseY + threadHeight), backgroundColor);
alternate = !alternate;
// Draw each root node in the group (one per thread)
foreach (Profiler.ScopeNode rootNode in threadGroup)
{
RenderNode(drawList, rootNode, startTime, totalDuration, windowPos.X, threadBaseY, 0, contentWidth, false);
}
// Move to the next thread's row (max depth * height per level)
baseY += (threadMaxDepths[threadId] + 1) * (barHeight + barPadding);
}
// Ensure that ImGui knows the size of the content.
ImGui.Dummy(new Vector2(contentWidth, baseY));
ImGui.EndChild();
public int SelectedFrameIndex = -1;
public int WindowStartIndex = 0;
public int VisibleFrameCount = 64;
public bool FollowLatest = true;
public float Zoom = 1f;
public double PanTicks = 0;
}
private static void RenderNode(ImDrawListPtr drawList, Profiler.ScopeNode node, double startTime, double totalDuration, float startX, float baseY, int depth, float contentWidth, bool alternate)
public readonly struct TimelineRenderResult
{
if (node == null) return;
double nodeStartTime = node.StartTime - startTime;
double nodeEndTime = node.EndTime - startTime;
double nodeDuration = nodeEndTime - nodeStartTime;
// Calculate the position and width of the bar based on time
float xPos = (float)(startX + (nodeStartTime / totalDuration) * contentWidth);
float width = (float)((nodeDuration / totalDuration) * contentWidth);
// Calculate the Y position based on depth
float yPos = baseY + (depth * (barHeight + barPadding)) + (barPadding * 0.5f);
// Define the rectangle bounds for the node
Vector2 min = new Vector2(xPos, yPos);
Vector2 max = new Vector2(xPos + width, yPos + barHeight);
// Define color.
Vector4 barColor = alternate ? new Vector4(0.4f, 0.6f, 0.9f, 1f) : new Vector4(0.4f, 0.5f, 0.8f, 1f);
Vector4 textColor = new Vector4(1f, 1f, 1f, 1f);
if (depth != 0)
public TimelineRenderResult(int selectedFrameIndex, bool selectionChanged, bool userNavigated)
{
// Draw the bar for the node (colored based on thread depth)
drawList.AddRectFilled(min, max, ImGui.ColorConvertFloat4ToU32(barColor));
SelectedFrameIndex = selectedFrameIndex;
SelectionChanged = selectionChanged;
UserNavigated = userNavigated;
}
// Draw the label if it fits inside the bar
string label = $"{node.Label} ({node.ElapsedMilliseconds():0.000} ms)";
if (width > ImGui.CalcTextSize(label).X)
public int SelectedFrameIndex { get; }
public bool SelectionChanged { get; }
public bool UserNavigated { get; }
}
private readonly struct HoverEntry
{
public HoverEntry(Profiler.ScopeNode node, Profiler.Frame frame, int frameIndex, int depth, double timelineStartTicks)
{
Node = node;
Frame = frame;
FrameIndex = frameIndex;
Depth = depth;
DurationMs = TicksToMilliseconds(node.EndTime - node.StartTime);
SelfMs = node.SelfMilliseconds();
StartInFrameMs = TicksToMilliseconds(node.StartTime - frame.StartTime);
EndInFrameMs = TicksToMilliseconds(node.EndTime - frame.StartTime);
StartInTimelineMs = TicksToMilliseconds(node.StartTime - timelineStartTicks);
EndInTimelineMs = TicksToMilliseconds(node.EndTime - timelineStartTicks);
}
public Profiler.ScopeNode Node { get; }
public Profiler.Frame Frame { get; }
public int FrameIndex { get; }
public int Depth { get; }
public double DurationMs { get; }
public double SelfMs { get; }
public double StartInFrameMs { get; }
public double EndInFrameMs { get; }
public double StartInTimelineMs { get; }
public double EndInTimelineMs { get; }
}
private const float BarHeight = 18f;
private const float BarPadding = 2f;
private const float ThreadGap = 8f;
private const float HeaderHeight = 28f;
private const float TimelineHeight = 220f;
private const float MinTextWidth = 36f;
private static readonly double TickToMs = 1000d / System.Diagnostics.Stopwatch.Frequency;
// Backwards-compatible entry point used by existing call sites.
public static void RenderFlameGraph(Profiler.Frame frame)
{
if (frame == null)
{
return;
}
List<Profiler.Frame> frames = new List<Profiler.Frame>(1)
{
frame
};
TimelineState state = new TimelineState
{
VisibleFrameCount = 1,
SelectedFrameIndex = 0,
FollowLatest = true
};
RenderTimeline(frames, state);
}
public static TimelineRenderResult RenderTimeline(IReadOnlyList<Profiler.Frame> frames, TimelineState state)
{
if (frames == null || frames.Count == 0 || state == null)
{
return default;
}
bool selectionChanged = false;
bool userNavigated = false;
int frameCount = frames.Count;
state.VisibleFrameCount = Math.Clamp(state.VisibleFrameCount, 1, frameCount);
state.Zoom = Math.Clamp(state.Zoom, 1f, 128f);
int maxStartIndex = Math.Max(0, frameCount - state.VisibleFrameCount);
if (state.FollowLatest)
{
state.WindowStartIndex = maxStartIndex;
}
else
{
state.WindowStartIndex = Math.Clamp(state.WindowStartIndex, 0, maxStartIndex);
}
int visibleStartIndex = state.WindowStartIndex;
int visibleEndIndex = visibleStartIndex + state.VisibleFrameCount - 1;
if (state.SelectedFrameIndex < 0)
{
state.SelectedFrameIndex = visibleEndIndex;
selectionChanged = true;
}
state.SelectedFrameIndex = Math.Clamp(state.SelectedFrameIndex, visibleStartIndex, visibleEndIndex);
Profiler.Frame firstFrame = frames[visibleStartIndex];
Profiler.Frame lastFrame = frames[visibleEndIndex];
double timelineStartTicks = firstFrame.StartTime;
double timelineEndTicks = Math.Max(lastFrame.EndTime, firstFrame.StartTime + 1);
double timelineDurationTicks = Math.Max(1d, timelineEndTicks - timelineStartTicks);
double visibleDurationTicks = Math.Max(1d, timelineDurationTicks / state.Zoom);
double maxPanTicks = Math.Max(0d, timelineDurationTicks - visibleDurationTicks);
if (state.FollowLatest)
{
state.PanTicks = maxPanTicks;
}
else
{
state.PanTicks = Math.Clamp(state.PanTicks, 0d, maxPanTicks);
}
double visibleStartTicks = timelineStartTicks + state.PanTicks;
double visibleEndTicks = visibleStartTicks + visibleDurationTicks;
Dictionary<int, int> threadDepths = BuildThreadDepths(frames, visibleStartIndex, visibleEndIndex);
List<int> threadOrder = threadDepths.Keys.OrderBy(x => x).ToList();
Dictionary<int, float> threadBaseY = new Dictionary<int, float>(threadOrder.Count);
float yCursor = HeaderHeight;
for (int i = 0; i < threadOrder.Count; i++)
{
int threadId = threadOrder[i];
threadBaseY[threadId] = yCursor;
yCursor += ((threadDepths[threadId] + 1) * (BarHeight + BarPadding)) + ThreadGap;
}
float contentHeight = Math.Max(TimelineHeight, yCursor + 6f);
ImGui.BeginChild("ProfilerTimeline", new Vector2(0, TimelineHeight), ImGuiChildFlags.Border | ImGuiChildFlags.ResizeY, ImGuiWindowFlags.AlwaysVerticalScrollbar);
ImDrawListPtr drawList = ImGui.GetWindowDrawList();
Vector2 origin = ImGui.GetCursorScreenPos();
Vector2 viewSize = ImGui.GetContentRegionAvail();
float canvasWidth = Math.Max(1f, viewSize.X);
uint frameBgColor = ImGui.GetColorU32(ImGuiCol.FrameBg);
uint frameBgHoveredColor = ImGui.GetColorU32(ImGuiCol.FrameBgHovered);
uint headerColor = ImGui.GetColorU32(ImGuiCol.Header);
uint headerHoveredColor = ImGui.GetColorU32(ImGuiCol.HeaderHovered);
uint textColor = ImGui.GetColorU32(ImGuiCol.Text);
uint borderColor = ImGui.GetColorU32(ImGuiCol.Border);
float clipMinX = origin.X;
float clipMaxX = origin.X + canvasWidth;
float clipMinY = origin.Y;
float clipMaxY = origin.Y + Math.Max(1f, ImGui.GetWindowHeight());
DrawTimelineHeader(drawList, origin, canvasWidth, timelineStartTicks, visibleStartTicks, visibleDurationTicks, textColor, borderColor);
HoverEntry? hovered = null;
for (int frameIndex = visibleStartIndex; frameIndex <= visibleEndIndex; frameIndex++)
{
Profiler.Frame frame = frames[frameIndex];
float frameStartX = ToScreenX(frame.StartTime, visibleStartTicks, visibleDurationTicks, origin.X, canvasWidth);
float frameEndX = ToScreenX(frame.EndTime, visibleStartTicks, visibleDurationTicks, origin.X, canvasWidth);
if (frameEndX < clipMinX || frameStartX > clipMaxX)
{
drawList.AddText(new Vector2(xPos + barPadding, yPos + barPadding), ImGui.ColorConvertFloat4ToU32(textColor), label);
continue;
}
// Add tooltip on hover
if (ImGui.IsMouseHoveringRect(min, max))
bool isSelectedFrame = frameIndex == state.SelectedFrameIndex;
uint frameShadeColor = (frameIndex & 1) == 0 ? frameBgColor : frameBgHoveredColor;
if (isSelectedFrame)
{
// Show tooltip when hovering over the node
ImGui.BeginTooltip();
ImGui.Text($"{node.Label}");
ImGui.Text($"{node.ElapsedMilliseconds():0.000} ms");
ImGui.Text($"{node.ManagedThreadId}");
ImGui.EndTooltip();
frameShadeColor = LerpColor(headerColor, headerHoveredColor, 0.45f);
}
drawList.AddRectFilled(new Vector2(frameStartX, origin.Y + HeaderHeight), new Vector2(frameEndX, origin.Y + contentHeight), SetAlpha(frameShadeColor, isSelectedFrame ? 0.20f : 0.08f));
drawList.AddLine(new Vector2(frameStartX, origin.Y + HeaderHeight), new Vector2(frameStartX, origin.Y + contentHeight), SetAlpha(borderColor, 0.55f), 1f);
foreach (Profiler.ScopeNode root in frame.RootNodes)
{
if (!threadBaseY.TryGetValue(root.ManagedThreadId, out float baseY))
{
continue;
}
DrawThreadLabel(drawList, origin.X, origin.Y + baseY, root.ManagedThreadId, textColor);
for (int i = 0; i < root.Children.Count; i++)
{
RenderNode(
drawList,
root.Children[i],
frame,
frameIndex,
baseY,
0,
visibleStartTicks,
visibleDurationTicks,
origin.X,
origin.Y,
canvasWidth,
clipMinX,
clipMaxX,
clipMaxY,
ref hovered,
textColor,
headerColor,
headerHoveredColor,
frameIndex == state.SelectedFrameIndex);
}
}
drawList.AddLine(new Vector2(frameEndX, origin.Y + HeaderHeight), new Vector2(frameEndX, origin.Y + contentHeight), SetAlpha(borderColor, 0.30f), 1f);
}
ImGui.Dummy(new Vector2(canvasWidth, contentHeight));
bool windowHovered = ImGui.IsWindowHovered(ImGuiHoveredFlags.ChildWindows);
if (windowHovered)
{
HandleZoomAndPan(state, timelineStartTicks, timelineDurationTicks, visibleDurationTicks, origin.X, canvasWidth, ref userNavigated);
}
if (windowHovered && ImGui.IsMouseClicked(ImGuiMouseButton.Left))
{
int clickedFrame = FindFrameIndexByMouseX(frames, visibleStartIndex, visibleEndIndex, visibleStartTicks, visibleDurationTicks, origin.X, canvasWidth, ImGui.GetMousePos().X);
if (clickedFrame >= visibleStartIndex && clickedFrame <= visibleEndIndex && clickedFrame != state.SelectedFrameIndex)
{
state.SelectedFrameIndex = clickedFrame;
selectionChanged = true;
state.FollowLatest = false;
userNavigated = true;
}
}
if (hovered.HasValue)
{
if (ImGui.IsMouseClicked(ImGuiMouseButton.Left) && hovered.Value.FrameIndex != state.SelectedFrameIndex)
{
state.SelectedFrameIndex = hovered.Value.FrameIndex;
selectionChanged = true;
state.FollowLatest = false;
userNavigated = true;
}
DrawTooltip(hovered.Value);
}
ImGui.EndChild();
return new TimelineRenderResult(state.SelectedFrameIndex, selectionChanged, userNavigated);
}
private static void HandleZoomAndPan(TimelineState state, double timelineStartTicks, double timelineDurationTicks, double visibleDurationTicks, float originX, float canvasWidth, ref bool userNavigated)
{
ImGuiIOPtr io = ImGui.GetIO();
if (Math.Abs(io.MouseWheel) < float.Epsilon)
{
return;
}
bool isZoom = io.KeyCtrl;
if (isZoom)
{
float previousZoom = state.Zoom;
double visibleStartTicksBefore = timelineStartTicks + state.PanTicks;
double mouseT = Math.Clamp((ImGui.GetMousePos().X - originX) / Math.Max(1f, canvasWidth), 0f, 1f);
double pivotTick = visibleStartTicksBefore + (visibleDurationTicks * mouseT);
state.Zoom = Math.Clamp(state.Zoom * MathF.Pow(1.12f, io.MouseWheel), 1f, 128f);
if (Math.Abs(previousZoom - state.Zoom) > float.Epsilon)
{
double newVisibleDurationTicks = Math.Max(1d, timelineDurationTicks / state.Zoom);
double newVisibleStartTicks = pivotTick - (newVisibleDurationTicks * mouseT);
state.PanTicks = Math.Clamp(newVisibleStartTicks - timelineStartTicks, 0d, Math.Max(0d, timelineDurationTicks - newVisibleDurationTicks));
state.FollowLatest = false;
userNavigated = true;
}
}
else
{
// Aka root node.
string label = $"{node.Label}";
drawList.AddText(new Vector2(startX + barPadding, yPos + barPadding), ImGui.ColorConvertFloat4ToU32(textColor), label);
}
// Draw each child node under this node
foreach (Profiler.ScopeNode child in node.Children)
{
alternate = !alternate;
RenderNode(drawList, child, startTime, totalDuration, startX, baseY, depth + 1, contentWidth, alternate);
state.PanTicks -= io.MouseWheel * (visibleDurationTicks * 0.10d);
state.FollowLatest = false;
userNavigated = true;
}
}
// Recursive function to calculate the maximum depth of the node tree
private static int GetMaxDepth(Profiler.ScopeNode node, int currentDepth)
private static int FindFrameIndexByMouseX(IReadOnlyList<Profiler.Frame> frames, int visibleStartIndex, int visibleEndIndex, double visibleStartTicks, double visibleDurationTicks, float originX, float width, float mouseX)
{
if (node.Children == null || node.Children.Count == 0)
double t = Math.Clamp((mouseX - originX) / Math.Max(1f, width), 0f, 1f);
double timelineTicks = visibleStartTicks + (visibleDurationTicks * t);
for (int i = visibleStartIndex; i <= visibleEndIndex; i++)
{
return currentDepth;
Profiler.Frame frame = frames[i];
if (timelineTicks >= frame.StartTime && timelineTicks <= frame.EndTime)
{
return i;
}
}
int maxDepth = currentDepth;
foreach (Profiler.ScopeNode child in node.Children)
return -1;
}
private static void DrawTimelineHeader(ImDrawListPtr drawList, Vector2 origin, float width, double timelineStartTicks, double visibleStartTicks, double visibleDurationTicks, uint textColor, uint borderColor)
{
drawList.AddLine(new Vector2(origin.X, origin.Y + HeaderHeight), new Vector2(origin.X + width, origin.Y + HeaderHeight), SetAlpha(borderColor, 0.65f), 1f);
int tickCount = Math.Clamp((int)(width / 130f), 4, 20);
for (int i = 0; i <= tickCount; i++)
{
maxDepth = Math.Max(maxDepth, GetMaxDepth(child, currentDepth + 1));
float t = i / (float)tickCount;
float x = origin.X + (t * width);
drawList.AddLine(new Vector2(x, origin.Y + HeaderHeight - 8f), new Vector2(x, origin.Y + HeaderHeight), SetAlpha(borderColor, 0.8f), 1f);
double ms = TicksToMilliseconds((visibleStartTicks - timelineStartTicks) + (visibleDurationTicks * t));
drawList.AddText(new Vector2(x + 2f, origin.Y + 4f), textColor, $"+{ms:0.0} ms");
}
}
private static void DrawThreadLabel(ImDrawListPtr drawList, float x, float y, int threadId, uint textColor)
{
drawList.AddText(new Vector2(x + 4f, y + 2f), SetAlpha(textColor, 0.85f), $"T{threadId}");
}
private static void DrawTooltip(HoverEntry hover)
{
ImGui.BeginTooltip();
ImGui.Text($"{hover.Node.Label}");
ImGui.Separator();
ImGui.Text($"Frame: {hover.Frame.FrameCount} (idx {hover.FrameIndex})");
ImGui.Text($"Thread: {hover.Node.ManagedThreadId}");
ImGui.Text($"Depth: {hover.Depth}");
ImGui.Text($"Duration: {hover.DurationMs:0.000} ms");
ImGui.Text($"Self: {hover.SelfMs:0.000} ms");
ImGui.Text($"Frame Start: {hover.StartInFrameMs:0.000} ms");
ImGui.Text($"Frame End: {hover.EndInFrameMs:0.000} ms");
ImGui.Text($"Timeline Start: {hover.StartInTimelineMs:0.000} ms");
ImGui.Text($"Timeline End: {hover.EndInTimelineMs:0.000} ms");
ImGui.Text($"Children: {hover.Node.Children.Count}");
ImGui.EndTooltip();
}
private static void RenderNode(
ImDrawListPtr drawList,
Profiler.ScopeNode node,
Profiler.Frame frame,
int frameIndex,
float baseY,
int depth,
double visibleStartTicks,
double visibleDurationTicks,
float originX,
float originY,
float width,
float clipMinX,
float clipMaxX,
float clipMaxY,
ref HoverEntry? hovered,
uint textColor,
uint headerColor,
uint headerHoveredColor,
bool selectedFrame)
{
long nodeEndTime = Math.Max(node.EndTime, node.StartTime + 1);
if (nodeEndTime < visibleStartTicks || node.StartTime > visibleStartTicks + visibleDurationTicks)
{
return;
}
float y = originY + baseY + (depth * (BarHeight + BarPadding));
if (y > clipMaxY)
{
return;
}
float minX = ToScreenX(node.StartTime, visibleStartTicks, visibleDurationTicks, originX, width);
float maxX = ToScreenX(nodeEndTime, visibleStartTicks, visibleDurationTicks, originX, width);
if (maxX < clipMinX || minX > clipMaxX)
{
return;
}
float barWidth = Math.Max(1f, maxX - minX);
Vector2 min = new Vector2(minX, y + (BarPadding * 0.5f));
Vector2 max = new Vector2(minX + barWidth, y + (BarPadding * 0.5f) + BarHeight);
uint barColor = BuildBarColor(node.Label, depth, selectedFrame);
uint borderColor = LerpColor(headerColor, headerHoveredColor, 0.45f);
drawList.AddRectFilled(min, max, barColor, 3f);
drawList.AddRect(min, max, SetAlpha(borderColor, 0.55f), 3f, ImDrawFlags.None, 1f);
Vector2 mousePos = ImGui.GetMousePos();
bool isHovered = mousePos.X >= min.X && mousePos.X <= max.X && mousePos.Y >= min.Y && mousePos.Y <= max.Y;
if (isHovered)
{
hovered = new HoverEntry(node, frame, frameIndex, depth, visibleStartTicks);
drawList.AddRect(min, max, headerHoveredColor, 3f, ImDrawFlags.None, 1.5f);
}
if (barWidth > MinTextWidth)
{
string label = node.Label;
float textWidth = ImGui.CalcTextSize(label).X;
if (textWidth + 8f <= barWidth)
{
drawList.AddText(new Vector2(min.X + 4f, min.Y + 2f), textColor, label);
}
}
for (int i = 0; i < node.Children.Count; i++)
{
RenderNode(
drawList,
node.Children[i],
frame,
frameIndex,
baseY,
depth + 1,
visibleStartTicks,
visibleDurationTicks,
originX,
originY,
width,
clipMinX,
clipMaxX,
clipMaxY,
ref hovered,
textColor,
headerColor,
headerHoveredColor,
selectedFrame);
}
}
private static Dictionary<int, int> BuildThreadDepths(IReadOnlyList<Profiler.Frame> frames, int startIndex, int endIndex)
{
Dictionary<int, int> threadMaxDepths = new Dictionary<int, int>(8);
for (int frameIndex = startIndex; frameIndex <= endIndex; frameIndex++)
{
foreach (Profiler.ScopeNode root in frames[frameIndex].RootNodes)
{
int maxDepth = 0;
for (int i = 0; i < root.Children.Count; i++)
{
maxDepth = Math.Max(maxDepth, GetMaxDepth(root.Children[i], 0));
}
if (threadMaxDepths.TryGetValue(root.ManagedThreadId, out int currentMax))
{
if (maxDepth > currentMax)
{
threadMaxDepths[root.ManagedThreadId] = maxDepth;
}
}
else
{
threadMaxDepths[root.ManagedThreadId] = maxDepth;
}
}
}
return threadMaxDepths;
}
private static int GetMaxDepth(Profiler.ScopeNode node, int depth)
{
if (node.Children.Count == 0)
{
return depth;
}
int maxDepth = depth;
for (int i = 0; i < node.Children.Count; i++)
{
maxDepth = Math.Max(maxDepth, GetMaxDepth(node.Children[i], depth + 1));
}
return maxDepth;
}
private static uint BuildBarColor(string label, int depth, bool selectedFrame)
{
int hash = label.GetHashCode();
float hue = ((hash & 1023) / 1023f + (depth * 0.031f)) % 1f;
ImGui.ColorConvertHSVtoRGB(hue, 0.52f, selectedFrame ? 0.82f : 0.68f, out float r, out float g, out float b);
Vector4 frameBg = ImGui.ColorConvertU32ToFloat4(ImGui.GetColorU32(ImGuiCol.FrameBg));
Vector4 accent = new Vector4(r, g, b, 1f);
Vector4 mixed = Vector4.Lerp(frameBg, accent, 0.72f);
return ImGui.ColorConvertFloat4ToU32(mixed);
}
private static float ToScreenX(double ticks, double visibleStartTicks, double visibleDurationTicks, float startX, float width)
{
double normalized = (ticks - visibleStartTicks) / visibleDurationTicks;
return startX + (float)(normalized * width);
}
private static double TicksToMilliseconds(double ticks)
{
return ticks * TickToMs;
}
private static uint SetAlpha(uint color, float alpha)
{
Vector4 c = ImGui.ColorConvertU32ToFloat4(color);
c.W *= alpha;
return ImGui.ColorConvertFloat4ToU32(c);
}
private static uint LerpColor(uint a, uint b, float t)
{
Vector4 av = ImGui.ColorConvertU32ToFloat4(a);
Vector4 bv = ImGui.ColorConvertU32ToFloat4(b);
return ImGui.ColorConvertFloat4ToU32(Vector4.Lerp(av, bv, Math.Clamp(t, 0f, 1f)));
}
}
+75 -18
View File
@@ -5,6 +5,7 @@ namespace Nerfed.Runtime;
public class BoundedQueue<T> : IEnumerable<T>, ICollection, IReadOnlyCollection<T>
{
private readonly Queue<T> queue = null;
private readonly object syncLock = new object();
private readonly int maxSize = 10;
private T lastAddedElement;
@@ -16,58 +17,114 @@ public class BoundedQueue<T> : IEnumerable<T>, ICollection, IReadOnlyCollection<
public void Enqueue(T item)
{
queue.Enqueue(item);
if (queue.Count > maxSize)
{
queue.Dequeue(); // Remove the oldest element
}
Enqueue(item, out _);
}
lastAddedElement = item;
public bool Enqueue(T item, out T evictedItem)
{
lock (syncLock)
{
queue.Enqueue(item);
if (queue.Count > maxSize)
{
evictedItem = queue.Dequeue();
lastAddedElement = item;
return true;
}
evictedItem = default;
lastAddedElement = item;
return false;
}
}
public T Dequeue()
{
return queue.Dequeue();
lock (syncLock)
{
return queue.Dequeue();
}
}
public T Peek()
{
return queue.Peek();
lock (syncLock)
{
return queue.Peek();
}
}
public T LastAddedElement()
{
return lastAddedElement;
lock (syncLock)
{
return lastAddedElement;
}
}
public void Clear()
{
queue.Clear();
lock (syncLock)
{
queue.Clear();
}
}
public bool Contains(T item)
{
return queue.Contains(item);
lock (syncLock)
{
return queue.Contains(item);
}
}
public IEnumerator<T> GetEnumerator()
{
return queue.GetEnumerator();
T[] snapshot;
lock (syncLock)
{
snapshot = queue.ToArray();
}
return ((IEnumerable<T>)snapshot).GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return queue.GetEnumerator();
return GetEnumerator();
}
public void CopyTo(Array array, int index)
{
((ICollection)queue).CopyTo(array, index);
lock (syncLock)
{
((ICollection)queue).CopyTo(array, index);
}
}
public int Count
{
get
{
lock (syncLock)
{
return queue.Count;
}
}
}
public int Count => queue.Count;
public int Capacity => maxSize;
public bool IsSynchronized => ((ICollection)queue).IsSynchronized;
public object SyncRoot => ((ICollection)queue).SyncRoot;
int IReadOnlyCollection<T>.Count => queue.Count;
public bool IsSynchronized => true;
public object SyncRoot => syncLock;
int IReadOnlyCollection<T>.Count
{
get
{
lock (syncLock)
{
return queue.Count;
}
}
}
}