Files
2026-07-13 13:13:17 +08:00

464 lines
20 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#nullable enable
using System.Diagnostics;
using System.Globalization;
using ImGuiNET;
using T3.Core.DataTypes.Vector;
using T3.Core.Stats;
using T3.Core.Utils;
using T3.Editor.Gui.Styling;
using T3.Editor.Gui.UiHelpers;
using T3.Editor.Gui.Windows.Layouts;
namespace T3.Editor.Gui.Windows.Analyze;
internal static class T3Metrics
{
public static void UiRenderingStarted()
{
_watchImgRenderTime.Restart();
_watchImgRenderTime.Start();
}
public static void UiRenderingCompleted()
{
_watchImgRenderTime.Stop();
_uiRenderDurationMs = (float)((double)_watchImgRenderTime.ElapsedTicks / Stopwatch.Frequency * 1000.0);
PerformanceMetrics.RecordUiRender(_uiRenderDurationMs);
}
public static void DrawRenderPerformanceGraph()
{
float barHeight = 4;
var offsetFromAppMenu = new Vector2(AppMenuBar.AppBarSpacingX,
(int)((ImGui.GetFrameHeight() - barHeight) * 0.5f));
var screenPosition = ImGui.GetCursorScreenPos() + offsetFromAppMenu;
float barWidth = 100;
float paddedBarWidth = barWidth + 30;
ImGui.SameLine(0, offsetFromAppMenu.X);
if (ImGui.InvisibleButton("performanceGraph", new Vector2(barWidth, ImGui.GetFrameHeight())))
{
WindowManager.ToggleInstanceVisibility<PerformanceWindow>();
}
// Glance tooltip — only when the full Performance window isn't already visible,
// so you don't get a redundant overlay on top of the window.
if (ImGui.IsItemHovered() && !WindowManager.IsAnyInstanceVisible<PerformanceWindow>())
{
CustomComponents.BeginTooltip(450 * T3Ui.UiScaleFactor);
{
ImGui.Dummy(new Vector2(250 * T3Ui.UiScaleFactor, 1));
DrawDetailedView();
ImGui.Spacing();
ImGui.PushFont(Fonts.FontSmall);
ImGui.PushStyleColor(ImGuiCol.Text, UiColors.TextMuted.Rgba);
ImGui.TextUnformatted("Click to open Performance window");
ImGui.PopStyleColor();
ImGui.PopFont();
}
CustomComponents.EndTooltip();
}
float normalFramerateLevelAt = 0.5f;
float frameTimingScaleFactor = barWidth / normalFramerateLevelAt / ExpectedFramerate;
_uiSmoothedRenderDurationMs = MathUtils.Lerp(_uiSmoothedRenderDurationMs, _uiRenderDurationMs, 0.05f);
_peakUiRenderDurationMs = _peakUiRenderDurationMs > _uiRenderDurationMs
? MathUtils.Lerp(_peakUiRenderDurationMs, _uiRenderDurationMs, 0.05f)
: _uiRenderDurationMs;
var deltaTimeMs = ImGui.GetIO().DeltaTime * 1000;
PerformanceMetrics.RecordFrame(deltaTimeMs);
_peakDeltaTimeMs = _peakDeltaTimeMs > deltaTimeMs
? MathUtils.Lerp(_peakDeltaTimeMs, deltaTimeMs, 0.05f)
: deltaTimeMs;
var drawList = ImGui.GetWindowDrawList();
// Draw Ui Render Duration
var uiTimeWidth = (float)Math.Ceiling(_uiRenderDurationMs * frameTimingScaleFactor).Clamp(0, paddedBarWidth);
drawList.AddRectFilled(screenPosition, screenPosition + new Vector2(uiTimeWidth, barHeight), ColorForUiBar);
// Draw Frame Render Duration
var deltaTimeWidth = (deltaTimeMs * frameTimingScaleFactor - uiTimeWidth).Clamp(0, paddedBarWidth);
var renderBarPos = screenPosition + new Vector2(uiTimeWidth, 0);
drawList.AddRectFilled(renderBarPos, renderBarPos + new Vector2(deltaTimeWidth, barHeight), ColorForFramerateBar);
// Draw Peak UI Duration
var peakUiTimePos = screenPosition + new Vector2((int)(_peakUiRenderDurationMs * frameTimingScaleFactor).Clamp(0, paddedBarWidth), 0);
drawList.AddRectFilled(peakUiTimePos, peakUiTimePos + new Vector2(2, barHeight), ColorForUiBar);
// Draw Peak Render Duration
var peakDeltaTimePos = screenPosition + new Vector2((int)(_peakDeltaTimeMs * frameTimingScaleFactor).Clamp(0, paddedBarWidth), 0);
drawList.AddRectFilled(peakDeltaTimePos, peakDeltaTimePos + new Vector2(2, barHeight), ColorForFramerateBar);
// Draw 60fps mark
var normalFramerateMarkerPos = screenPosition + new Vector2(ExpectedFrameDurationMs * frameTimingScaleFactor, 0);
drawList.AddRectFilled(normalFramerateMarkerPos + new Vector2(0, -1), normalFramerateMarkerPos + new Vector2(1, barHeight + 1), ColorForUiBar);
}
/// <summary>
/// Body of the Performance window: three labelled metric graphs, render summary, and per-frame render stats.
/// </summary>
internal static void DrawDetailedView()
{
DrawCopyCsvButton();
DrawLabeledGraph(PerformanceMetrics.FrameDuration, "Frame", "ms", 32f, FormatMs);
ImGui.Separator();
DrawLabeledGraph(PerformanceMetrics.UiRenderDuration, "Draw", "ms", 32f, FormatMs);
ImGui.Separator();
DrawLabeledGraph(PerformanceMetrics.GcAllocationsKb, "Mem-Alloc", "", 10_000f, FormatKb);
ImGui.TextUnformatted($"Render: {_peakDeltaTimeMs:0.0}ms");
ImGui.Spacing();
ImGui.PushFont(Fonts.FontSmall);
foreach (var (key, number) in RenderStatsCollector.ResultsForLastFrame)
{
var formattedNumber = number switch
{
> 1000000 => $"{number / 1000000.0:0.0}M",
> 1000 => $"{number / 1000.0:0.0}K",
_ => number.ToString()
};
ImGui.Text($"{formattedNumber} {key}");
}
ImGui.PopFont();
}
/// <summary>
/// Small icon button at the top of the detailed view that copies the current window's samples
/// to the clipboard as CSV. Intended for quickly sharing performance snapshots (e.g. pasting
/// into a bug report or chat).
/// </summary>
private static void DrawCopyCsvButton()
{
var contentWidth = ImGui.GetContentRegionAvail().X;
var btnSize = ImGui.GetFrameHeight();
ImGui.SetCursorPosX(ImGui.GetCursorPosX() + contentWidth - btnSize);
if (CustomComponents.TransparentIconButton(Icon.CopyToClipboard, new Vector2(btnSize)))
{
ImGui.SetClipboardText(BuildCsvExport());
}
CustomComponents.TooltipForLastItem("Copy current performance window to clipboard as CSV.");
}
/// <summary>Builds a CSV snapshot of the current performance window across all three metrics.</summary>
private static string BuildCsvExport()
{
var frameScratch = new float[PerformanceMetrics.WindowSize];
var drawScratch = new float[PerformanceMetrics.WindowSize];
var allocScratch = new float[PerformanceMetrics.WindowSize];
var frames = PerformanceMetrics.FrameDuration.AsOrderedSpan(frameScratch);
var draws = PerformanceMetrics.UiRenderDuration.AsOrderedSpan(drawScratch);
var allocs = PerformanceMetrics.GcAllocationsKb.AsOrderedSpan(allocScratch);
var count = Math.Min(Math.Min(frames.Length, draws.Length), allocs.Length);
var sb = new System.Text.StringBuilder(count * 32 + 256);
sb.Append("# TiXL Performance Export ").AppendLine(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
sb.Append("# Window: ").Append(PerformanceMetrics.WindowSize).Append(" samples Total frames: ").AppendLine(PerformanceMetrics.TotalFrameCount.ToString());
sb.AppendLine("# Columns: frame_index, frame_ms, draw_ms, alloc_kB");
sb.AppendLine("frame_index,frame_ms,draw_ms,alloc_kB");
for (var i = 0; i < count; i++)
{
sb.Append(i).Append(',')
.Append(frames[i].ToString("0.##", CultureInfo.InvariantCulture)).Append(',')
.Append(draws[i].ToString("0.##", CultureInfo.InvariantCulture)).Append(',')
.AppendLine(allocs[i].ToString("0.#", CultureInfo.InvariantCulture));
}
return sb.ToString();
}
/// <summary>
/// Renders a labelled header line plus a histogram + plot-line graph for the given metric.
/// Header has three columns: label (left) | ~average (centre) | max (right, muted).
/// The plot line auto-scales its Y-axis to the current window's max; <paramref name="domainMax"/>
/// is only used as the rightmost histogram axis tick (the fixed domain upper bound).
/// </summary>
private static void DrawLabeledGraph(RollingMetric metric, string label, string unit, float domainMax, Func<float, string> axisFormat)
{
if (metric.Count < 1)
return;
var contentWidth = ImGui.GetContentRegionAvail().X;
var startPos = ImGui.GetCursorScreenPos();
// Three-column header.
var centerText = $"~{axisFormat(metric.Average)}{unit}";
var rightText = $"{axisFormat(metric.Max)}{unit} max";
ImGui.TextUnformatted(label);
var centerSize = ImGui.CalcTextSize(centerText);
ImGui.SameLine(0, 0);
ImGui.SetCursorScreenPos(new Vector2(startPos.X + (contentWidth - centerSize.X) * 0.5f, startPos.Y));
ImGui.TextUnformatted(centerText);
var rightSize = ImGui.CalcTextSize(rightText);
ImGui.SameLine(0, 0);
ImGui.SetCursorScreenPos(new Vector2(startPos.X + contentWidth - rightSize.X, startPos.Y));
ImGui.PushStyleColor(ImGuiCol.Text, UiColors.TextMuted.Rgba);
ImGui.TextUnformatted(rightText);
ImGui.PopStyleColor();
// Graph area.
var size = new Vector2(contentWidth, 60 * T3Ui.UiScaleFactor);
var cursor = ImGui.GetCursorScreenPos();
ImGui.Dummy(size);
ImGui.Dummy(new Vector2(1, 6 * T3Ui.UiScaleFactor));
var rect = new ImRect(cursor, cursor + size);
var slots = metric.Slots;
// Axis ticks describe the histogram's fixed x-domain — same value also drives the plot Y-scale
// so the graph stays readable (a jumping auto-scaled max is harder to parse than a fixed one).
var axisLeft = axisFormat(slots[0].ValueRangeMin);
var axisMid = axisFormat(slots[slots.Length / 2].ValueRangeMin);
var axisRight = axisFormat(domainMax);
var drawList = ImGui.GetWindowDrawList();
var hoveredBucket = MetricGraphView.DrawGraph(drawList, rect, metric, _floatGraphBuffer,
BarColor, FlashColor, LineColor,
PerformanceMetrics.Now, domainMax,
axisLeft, axisMid, axisRight);
if (hoveredBucket >= 0)
DrawBucketTooltip(label, unit, axisFormat, domainMax, metric, hoveredBucket);
}
/// <summary>
/// Tooltip shown while hovering a histogram bucket. Shows count + share + estimated periodicity
/// for the current window and all-time. Holding <c>Shift</c> switches to cumulative
/// ("this bucket and everything higher") — useful for "how often do we see at least X".
/// </summary>
private static void DrawBucketTooltip(string label, string unit, Func<float, string> axisFormat,
float domainMax, RollingMetric metric, int bucketIndex)
{
var slots = metric.Slots;
var cumulative = ImGui.GetIO().KeyShift;
int countRecent;
long countTotal;
if (cumulative)
{
countRecent = 0;
countTotal = 0;
for (var i = bucketIndex; i < slots.Length; i++)
{
countRecent += slots[i].CountRecent;
countTotal += slots[i].CountTotal;
}
}
else
{
countRecent = slots[bucketIndex].CountRecent;
countTotal = slots[bucketIndex].CountTotal;
}
string rangeLabel;
if (cumulative)
{
rangeLabel = $"≥ {axisFormat(slots[bucketIndex].ValueRangeMin)}{unit}";
}
else
{
var upperEdge = bucketIndex + 1 < slots.Length
? slots[bucketIndex + 1].ValueRangeMin
: domainMax;
rangeLabel = $"{axisFormat(slots[bucketIndex].ValueRangeMin)} .. {axisFormat(upperEdge)}{unit}";
}
ImGui.BeginTooltip();
{
ImGui.PushFont(Fonts.FontBold);
ImGui.TextUnformatted(label);
ImGui.PopFont();
ImGui.PushStyleColor(ImGuiCol.Text, UiColors.TextMuted.Rgba);
ImGui.TextUnformatted($"({rangeLabel})");
ImGui.PopStyleColor();
ImGui.Separator();
// Recent window.
ImGui.TextUnformatted($"Last {PerformanceMetrics.WindowSize} Frames");
var pctRecent = countRecent / (float)PerformanceMetrics.WindowSize * 100f;
ImGui.TextUnformatted($" {countRecent}× {pctRecent:0.#}%");
// Periodicity from inner span (first..last visible occurrence) — stays steady as ticks
// scroll in and out of the window, unlike WindowSize/count which jumps in steps.
if (TryComputeInnerSpanPeriod(metric, bucketIndex, cumulative, domainMax,
out var periodFramesF, out var periodSeconds))
{
ImGui.PushStyleColor(ImGuiCol.Text, UiColors.TextMuted.Rgba);
ImGui.TextUnformatted($" ~ every {periodFramesF:0.#}F / {periodSeconds:0.00}s");
ImGui.PopStyleColor();
}
// Per-frame occurrence strip: shows *when* in the window each hit happened.
DrawOccurrenceBar(metric, bucketIndex, cumulative, domainMax);
ImGui.Spacing();
// All-time.
var total = PerformanceMetrics.TotalFrameCount;
ImGui.TextUnformatted($"Total ({total:N0} Frames)");
if (total > 0)
{
var pctTotal = countTotal / (float)total * 100f;
ImGui.TextUnformatted($" {countTotal}× {pctTotal:0.##}%");
}
ImGui.Spacing();
ImGui.PushFont(Fonts.FontSmall);
ImGui.PushStyleColor(ImGuiCol.Text, UiColors.TextMuted.Rgba);
ImGui.TextUnformatted(cumulative ? "(release Shift for single bucket)" : "(hold Shift for cumulative)");
ImGui.PopStyleColor();
ImGui.PopFont();
}
ImGui.EndTooltip();
}
/// <summary>
/// Estimates the typical period between hits in the hovered bucket(s) using the *inner span* —
/// distance between the first and last visible occurrence — instead of <c>WindowSize / count</c>.
/// This is stable as occurrences scroll in and out at the buffer edges; the naive formula jumps
/// in coarse steps and visibly flickers for short periods.
///
/// Returns false when fewer than two occurrences are present (no meaningful period available).
/// </summary>
private static bool TryComputeInnerSpanPeriod(RollingMetric metric, int bucketIndex, bool cumulative,
float domainMax, out float periodFrames, out float periodSeconds)
{
var slots = metric.Slots;
var lowerEdge = slots[bucketIndex].ValueRangeMin;
float upperEdge;
if (cumulative)
{
upperEdge = float.PositiveInfinity;
}
else
{
upperEdge = bucketIndex + 1 < slots.Length
? slots[bucketIndex + 1].ValueRangeMin
: domainMax;
}
var samples = metric.AsOrderedSpan(_floatGraphBuffer);
var firstIdx = -1;
var lastIdx = -1;
var matchCount = 0;
for (var i = 0; i < samples.Length; i++)
{
var v = samples[i];
if (v < lowerEdge || v >= upperEdge)
continue;
if (firstIdx < 0)
firstIdx = i;
lastIdx = i;
matchCount++;
}
if (matchCount < 2)
{
periodFrames = 0f;
periodSeconds = 0f;
return false;
}
periodFrames = (lastIdx - firstIdx) / (float)(matchCount - 1);
var avgFrameMs = PerformanceMetrics.FrameDuration.Average;
periodSeconds = periodFrames * avgFrameMs / 1000f;
return true;
}
/// <summary>
/// Draws a compact horizontal strip inside the tooltip where every tick marks a frame in the
/// current window whose sample fell into the hovered bucket (or any higher bucket when cumulative).
/// Makes the temporal pattern of allocations (periodic / clustered / one-off) visible at a glance.
/// Scans the value buffer directly — matching against bucket value-range bounds — so the
/// hot Metrics API stays lean.
/// </summary>
private static void DrawOccurrenceBar(RollingMetric metric, int bucketIndex, bool cumulative, float domainMax)
{
var barWidth = 220f * T3Ui.UiScaleFactor;
var barHeight = 10f * T3Ui.UiScaleFactor;
var pos = ImGui.GetCursorScreenPos();
ImGui.Dummy(new Vector2(barWidth, barHeight));
var drawList = ImGui.GetWindowDrawList();
drawList.AddRectFilled(pos, pos + new Vector2(barWidth, barHeight), UiColors.BackgroundFull.Fade(0.8f));
var samples = metric.AsOrderedSpan(_floatGraphBuffer);
var count = samples.Length;
if (count < 2)
return;
var slots = metric.Slots;
var lowerEdge = slots[bucketIndex].ValueRangeMin;
float upperEdge;
if (cumulative)
{
upperEdge = float.PositiveInfinity;
}
else
{
upperEdge = bucketIndex + 1 < slots.Length
? slots[bucketIndex + 1].ValueRangeMin
: domainMax;
}
var step = barWidth / (count - 1);
uint tickColor = UiColors.ForegroundFull;
var yTop = pos.Y + 1f;
var yBottom = pos.Y + barHeight - 1f;
for (var i = 0; i < count; i++)
{
var v = samples[i];
if (v < lowerEdge || v >= upperEdge)
continue;
var x = pos.X + i * step;
drawList.AddLine(new Vector2(x, yTop), new Vector2(x, yBottom), tickColor, 1f);
}
}
private static string FormatMs(float ms) => $"{ms:0.#}";
/// <summary>Formats a kilobyte count using K / MB / GB suffixes (e.g. 100 → "100K", 1500 → "1.5MB").</summary>
private static string FormatKb(float kb)
{
if (kb < 1000f)
return $"{kb:0}K";
if (kb < 1_000_000f)
return $"{kb / 1000f:0.#}MB";
return $"{kb / 1_000_000f:0.#}GB";
}
private static uint ColorForUiBar => UiColors.ForegroundFull.Fade(0.4f);
private static uint ColorForFramerateBar => UiColors.ForegroundFull.Fade(0.1f);
private static Color BarColor => UiColors.ForegroundFull.Fade(0.1f);
private static Color FlashColor => UiColors.ForegroundFull.Fade(0.5f);
private static Color LineColor => UiColors.ForegroundFull.Fade(0.5f);
private const float ExpectedFramerate = 60;
private const float ExpectedFrameDurationMs = 1 / ExpectedFramerate * 1000;
private static float _peakUiRenderDurationMs;
private static float _peakDeltaTimeMs;
private static float _uiRenderDurationMs;
private static float _uiSmoothedRenderDurationMs;
private static readonly Stopwatch _watchImgRenderTime = new();
// Scratch buffer for plot-line copy-out (and reused by the tooltip's occurrence strip).
// Sized to match PerformanceMetrics.WindowSize.
private static readonly float[] _floatGraphBuffer = new float[PerformanceMetrics.WindowSize];
}