diff --git a/Nerfed.Editor/Systems/EditorProfilerWindow.cs b/Nerfed.Editor/Systems/EditorProfilerWindow.cs index c0a8896..486e01c 100644 --- a/Nerfed.Editor/Systems/EditorProfilerWindow.cs +++ b/Nerfed.Editor/Systems/EditorProfilerWindow.cs @@ -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> orderedCombinedData = null; + private IOrderedEnumerable> orderedCombinedData = null; + private readonly ProfilerVisualizer.TimelineState timelineState = new ProfilerVisualizer.TimelineState(); + private readonly List frameSnapshot = new List(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> orderedCombinedData) + private static void DrawCombined(in IOrderedEnumerable> 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 combinedData in orderedCombinedData) + foreach (KeyValuePair 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> CalculateCombinedData(Profiler.Frame frame) + private static IOrderedEnumerable> CalculateCombinedData(Profiler.Frame frame) { - Dictionary combinedRecordData = new Dictionary(128); - foreach (Profiler.ScopeNode node in frame.RootNodes) + IReadOnlyDictionary rollingData = Profiler.GetRollingLabelMetricsSnapshot(); + Dictionary combinedRecordData = new Dictionary(128); + foreach (KeyValuePair 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 combinedRecordData) + private static ProfilerVisualizer.TimelineRenderResult DrawFlameGraph(IReadOnlyList 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); } } } diff --git a/Nerfed.Runtime/Profiler.cs b/Nerfed.Runtime/Profiler.cs index d968b3f..f5d79f5 100644 --- a/Nerfed.Runtime/Profiler.cs +++ b/Nerfed.Runtime/Profiler.cs @@ -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 RootNodes = new ConcurrentBag(); + private readonly List rootNodes = new List(8); + private readonly object rootNodesLock = new object(); + private readonly Dictionary labelMetrics = new Dictionary(128, StringComparer.Ordinal); - internal void End() + public IReadOnlyList RootNodes => rootNodes; + + public IReadOnlyDictionary 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 Children { get; } = new List(); + 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 Frames = new(maxFrames); // Use ThreadLocal to store a stack of ScopeNodes per thread and enable tracking of thread-local values. private static readonly ThreadLocal> threadLocalScopes = new ThreadLocal>(() => new Stack(), true); + private static readonly ConcurrentDictionary threadRootLabelCache = new ConcurrentDictionary(); + private static readonly ConcurrentBag nodePool = new ConcurrentBag(); + private static readonly Dictionary rollingWindows = new Dictionary(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 destination) + { + destination.Clear(); + foreach (Frame frame in Frames) + { + destination.Add(frame); + } + + return destination.Count; + } + + public static IReadOnlyDictionary GetRollingLabelMetricsSnapshot() + { + lock (rollingWindowsLock) + { + Dictionary snapshot = new Dictionary(rollingWindows.Count, StringComparer.Ordinal); + foreach (KeyValuePair 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 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 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 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 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 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); + } + } + } } \ No newline at end of file diff --git a/Nerfed.Runtime/ProfilerVisualizer.cs b/Nerfed.Runtime/ProfilerVisualizer.cs index 116be63..69e7710 100644 --- a/Nerfed.Runtime/ProfilerVisualizer.cs +++ b/Nerfed.Runtime/ProfilerVisualizer.cs @@ -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 threadMaxDepths = new Dictionary(); - foreach (IGrouping 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> 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 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 frames = new List(1) + { + frame + }; + + TimelineState state = new TimelineState + { + VisibleFrameCount = 1, + SelectedFrameIndex = 0, + FollowLatest = true + }; + + RenderTimeline(frames, state); + } + + public static TimelineRenderResult RenderTimeline(IReadOnlyList 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 threadDepths = BuildThreadDepths(frames, visibleStartIndex, visibleEndIndex); + List threadOrder = threadDepths.Keys.OrderBy(x => x).ToList(); + + Dictionary threadBaseY = new Dictionary(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 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 BuildThreadDepths(IReadOnlyList frames, int startIndex, int endIndex) + { + Dictionary threadMaxDepths = new Dictionary(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))); + } } \ No newline at end of file diff --git a/Nerfed.Runtime/Util/BoundedQueue.cs b/Nerfed.Runtime/Util/BoundedQueue.cs index e354dae..889542a 100644 --- a/Nerfed.Runtime/Util/BoundedQueue.cs +++ b/Nerfed.Runtime/Util/BoundedQueue.cs @@ -5,6 +5,7 @@ namespace Nerfed.Runtime; public class BoundedQueue : IEnumerable, ICollection, IReadOnlyCollection { private readonly Queue queue = null; + private readonly object syncLock = new object(); private readonly int maxSize = 10; private T lastAddedElement; @@ -16,58 +17,114 @@ public class BoundedQueue : IEnumerable, 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 GetEnumerator() { - return queue.GetEnumerator(); + T[] snapshot; + lock (syncLock) + { + snapshot = queue.ToArray(); + } + + return ((IEnumerable)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.Count => queue.Count; + public bool IsSynchronized => true; + public object SyncRoot => syncLock; + + int IReadOnlyCollection.Count + { + get + { + lock (syncLock) + { + return queue.Count; + } + } + } } \ No newline at end of file