Gondwana Game Engine Development (master) - 2.6.1
Gondwana is a cross-platform 2.5D game and rendering engine written in C#/.NET 8. It provides fine-grained control over rendering, timing, and scene composition, with built-in support for parallax, z-ordering, pixel overhang, collision detection, and particle effects. Gondwana targets desktop and web platforms using SkiaSharp for graphics and pluggable audio backends for desktop and browser targets.
Loading...
Searching...
No Matches
ProfilerWidget.cs
1using System.Diagnostics;
2using System.Drawing;
3using System.Numerics;
4using System.Text;
13using SkiaSharp;
14
15namespace Gondwana.Widgets.Hud;
16
32
36[Flags]
38{
42 None = 0,
43
47 Scene = 1 << 0,
48
52 View = 1 << 1,
53
57 Backbuffer = 1 << 2,
58
63
67 Msaa = 1 << 4,
68
73}
74
80{
88 TelemetrySnapshot? snapshot,
89 RenderSurfaceHostBase renderSurfaceHost,
90 View view)
91 {
92 Snapshot = snapshot;
93 RenderSurfaceHost = renderSurfaceHost ?? throw new ArgumentNullException(nameof(renderSurfaceHost));
94 View = view ?? throw new ArgumentNullException(nameof(view));
95 }
96
101
106
110 public View View { get; }
111}
112
121public sealed class ProfilerWidget : ContainerWidget
122{
123 private static readonly TimeSpan MinimumRefreshInterval = TimeSpan.FromMilliseconds(10);
124 private static readonly TimeSpan MaximumRefreshInterval = TimeSpan.FromMinutes(1);
125 private const int MetricSelectorHeight = 30;
126 private const int MetricSelectorWidth = 92;
127 private const int MetricSelectorMargin = 4;
128
129 private readonly Dictionary<string, bool> _sourceVisibility = new(StringComparer.OrdinalIgnoreCase);
130 private readonly Dictionary<string, bool> _measurementVisibility = new(StringComparer.Ordinal);
131 private readonly Dictionary<string, bool> _sourceMeasurementVisibility = new(StringComparer.Ordinal);
132 private readonly Dictionary<string, string> _measurementDescriptions = new(StringComparer.Ordinal);
133 private readonly Dictionary<int, string> _renderedMetricLines = [];
134 private readonly TooltipWidget _measurementTooltip;
135 private Size _size;
136 private TimeSpan _refreshInterval = TimeSpan.FromMilliseconds(250);
137 private long _lastRefreshTimestamp;
138 private IDisposable? _collectionRequest;
139 private bool _disposed;
140 private string _headerText = "Gondwana Runtime Profiler";
141 private ProfilerMeasurementVisibilityMode _measurementVisibilityMode = ProfilerMeasurementVisibilityMode.All;
142 private bool _measurementTooltipsEnabled = true;
143 private bool _showMetricSelector = true;
144 private TelemetrySnapshot? _latestSnapshot;
145 private ButtonWidget? _metricSelectorButton;
146 private ListBoxWidget? _metricSelectorList;
147 private readonly List<MetricSelectorRow> _metricSelectorRows = [];
148 private string? _metricSelectorSignature;
149 private bool _metricSelectorOpen;
150 private int _profilerZOrder;
151 private bool _showHeader = true;
152 private bool _showSnapshotMetadata = true;
153 private bool _showSourceHeaders = true;
154 private bool _showUnavailableMeasurements;
155
164 RenderSurfaceHostBase renderSurfaceHost,
165 View view,
166 Rectangle bounds,
167 string? nickname = null)
168 : base(
169 renderSurfaceHost,
171 ValidateBounds(bounds).Location,
172 nickname)
173 {
174 ArgumentNullException.ThrowIfNull(view);
175
176 _size = bounds.Size;
177 Rectangle displayBounds = GetDisplayBounds(bounds, showSelector: true);
178
179 Display = new LabelWidget(
180 renderSurfaceHost,
181 view,
182 displayBounds,
183 nickname: $"{Nickname}.display")
184 {
185 VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
186 VerticalScrollEndPaddingPx = 8f
187 };
188
189 Display
190 .SetFont(SKTypeface.Default, 15f, minSize: 9f)
191 .SetColors(SKColors.White, new SKColor(0, 0, 0, 102))
192 .SetAlignment(SKTextAlign.Left, TextBlock.VerticalAlign.Top)
193 .SetPadding(12f, 10f)
194 .EnableWrapping(false);
195
196 Add(
197 Display,
198 new Vector2(
199 displayBounds.X - bounds.X,
200 displayBounds.Y - bounds.Y));
201
202 _measurementTooltip = new TooltipWidget(
203 renderSurfaceHost,
204 view,
205 new Size(420, 78),
206 $"{Nickname}.tooltip");
207
208 IsInputEnabled = true;
211 CanReceiveFocus = false;
212
213 PointerMove += OnProfilerPointerMove;
214 PointerLeave += OnProfilerPointerLeave;
215 Display.PointerMove += OnProfilerPointerMove;
216 Display.PointerLeave += OnProfilerPointerLeave;
217 Display.MouseWheel += OnProfilerMouseWheel;
218
219 Display.SetText("Gondwana Runtime Profiler\nInactive. Call Show() to collect telemetry.");
220 SetIsVisible(false);
221 }
222
226 public LabelWidget Display { get; }
227
231 public Rectangle Bounds
232 {
233 get
234 {
235 Vector2 position = GetPosition();
236 return new Rectangle(
237 (int)MathF.Round(position.X),
238 (int)MathF.Round(position.Y),
239 _size.Width,
240 _size.Height);
241 }
242 }
243
247 public Size Size
248 {
249 get => _size;
250 set
251 {
252 ValidateSize(value);
253 _size = value;
254 ApplyLayout();
255 }
256 }
257
265 public TimeSpan RefreshInterval
266 {
267 get => _refreshInterval;
268 set
269 {
270 if (value < MinimumRefreshInterval || value > MaximumRefreshInterval)
271 {
272 throw new ArgumentOutOfRangeException(
273 nameof(value),
274 value,
275 "Profiler widget refresh interval must be between 10 ms and one minute.");
276 }
277
278 _refreshInterval = value;
279 }
280 }
281
286 {
287 get => _measurementVisibilityMode;
288 set
289 {
290 if (!Enum.IsDefined(value))
291 throw new ArgumentOutOfRangeException(nameof(value));
292
293 if (_measurementVisibilityMode == value)
294 return;
295
296 _measurementVisibilityMode = value;
297 }
298 }
299
303 public string HeaderText
304 {
305 get => _headerText;
306 set
307 {
308 ArgumentException.ThrowIfNullOrWhiteSpace(value);
309 _headerText = value;
310 }
311 }
312
316 public bool ShowHeader
317 {
318 get => _showHeader;
319 set => _showHeader = value;
320 }
321
326 {
327 get => _showSnapshotMetadata;
328 set => _showSnapshotMetadata = value;
329 }
330
335 {
336 get => _showSourceHeaders;
337 set => _showSourceHeaders = value;
338 }
339
345 {
346 get => _showUnavailableMeasurements;
347 set
348 {
349 if (_showUnavailableMeasurements == value)
350 return;
351
352 _showUnavailableMeasurements = value;
353 }
354 }
355
360 {
361 get => _measurementTooltipsEnabled;
362 set
363 {
364 if (_measurementTooltipsEnabled == value)
365 return;
366
367 _measurementTooltipsEnabled = value;
368
369 if (!value)
370 _measurementTooltip.HideTooltip();
371 }
372 }
373
383 {
384 get => _showMetricSelector;
385 set
386 {
387 if (_showMetricSelector == value)
388 return;
389
390 _showMetricSelector = value;
391 _metricSelectorSignature = null;
392
393 if (!value)
394 DisposeMetricSelector();
395
396 ApplyLayout();
397
398 if (value && _latestSnapshot is not null)
399 EnsureMetricSelector(_latestSnapshot);
400 }
401 }
402
406 public TooltipWidget MeasurementTooltip => _measurementTooltip;
407
416
425 public Func<ProfilerWidgetExtensionContext, IEnumerable<string>?>? AdditionalLinesProvider { get; set; }
426
430 public bool IsCollectionActive => _collectionRequest is not null;
431
438 public ProfilerWidget SetSourceVisible(string sourceName, bool visible)
439 {
440 ArgumentException.ThrowIfNullOrWhiteSpace(sourceName);
441 _sourceVisibility[sourceName] = visible;
442 return this;
443 }
444
451 public ProfilerWidget SetMeasurementVisible(string metricKey, bool visible)
452 {
453 ValidateMetricKey(metricKey);
454 _measurementVisibility[metricKey] = visible;
455 return this;
456 }
457
466 string sourceName,
467 string metricKey,
468 bool visible)
469 {
470 ArgumentException.ThrowIfNullOrWhiteSpace(sourceName);
471 ValidateMetricKey(metricKey);
472 _sourceMeasurementVisibility[CreateSourceMetricKey(sourceName, metricKey)] = visible;
473 return this;
474 }
475
481 {
482 _sourceVisibility.Clear();
483 _measurementVisibility.Clear();
484 _sourceMeasurementVisibility.Clear();
485 return this;
486 }
487
494 public ProfilerWidget SetMeasurementDescription(string metricKey, string? description)
495 {
496 ValidateMetricKey(metricKey);
497
498 if (string.IsNullOrWhiteSpace(description))
499 _measurementDescriptions.Remove(metricKey);
500 else
501 _measurementDescriptions[metricKey] = description.Trim();
502
503 return this;
504 }
505
512 public bool IsMeasurementVisible(string sourceName, string metricKey)
513 {
514 ArgumentException.ThrowIfNullOrWhiteSpace(sourceName);
515 ValidateMetricKey(metricKey);
516 return ResolveMeasurementVisibility(sourceName, metricKey, TelemetryAvailability.Available);
517 }
518
525 public ProfilerWidget SetColors(SKColor foreground, SKColor background)
526 {
527 Display.SetColors(foreground, background);
528 return this;
529 }
530
538 public ProfilerWidget SetFont(SKTypeface typeface, float size, float? minSize = null)
539 {
540 Display.SetFont(typeface, size, minSize);
541 return this;
542 }
543
550 {
551 _profilerZOrder = zOrder;
552 Display.SetLabelZOrder(zOrder);
553 _metricSelectorButton?.SetButtonZOrder(zOrder + 100);
554 _metricSelectorList?.SetListBoxZOrder(zOrder + 200);
555 _measurementTooltip.SetTooltipZOrder(zOrder + 1000);
556 return this;
557 }
558
564 {
565 if (_disposed)
566 return this;
567
569 _latestSnapshot = snapshot;
570
571 string text = FormatSnapshot(snapshot);
572 CaptureRenderedMetricLines(text);
573 Display.SetText(text);
574
575 if (snapshot is not null)
576 EnsureMetricSelector(snapshot);
577
578 return this;
579 }
580
582 protected override void ProcessShown()
583 {
584 base.ProcessShown();
585
586 if (!_metricSelectorOpen)
587 _metricSelectorList?.Hide();
588
589 if (_collectionRequest is not null)
590 return;
591
592 _collectionRequest = Engine.Instance.Profiler.Start();
593 _lastRefreshTimestamp = 0;
594 Engine.Instance.AfterBackgroundTasksExecute += OnAfterBackgroundTasksExecute;
595 Refresh();
596 }
597
599 protected override void ProcessHidden()
600 {
601 _measurementTooltip.HideTooltip();
602 _metricSelectorOpen = false;
603 _metricSelectorList?.Hide();
604 StopCollection();
605 base.ProcessHidden();
606 }
607
609 public override void Dispose()
610 {
611 if (_disposed)
612 return;
613
614 _disposed = true;
615
616 PointerMove -= OnProfilerPointerMove;
617 PointerLeave -= OnProfilerPointerLeave;
618 Display.PointerMove -= OnProfilerPointerMove;
619 Display.PointerLeave -= OnProfilerPointerLeave;
620 Display.MouseWheel -= OnProfilerMouseWheel;
621
622 _measurementTooltip.HideTooltip();
623 _measurementTooltip.Dispose();
624 DisposeMetricSelector();
625 StopCollection();
626 base.Dispose();
627 }
628
629 private void OnAfterBackgroundTasksExecute()
630 {
631 if (_collectionRequest is null)
632 return;
633
634 long now = Stopwatch.GetTimestamp();
635 if (_lastRefreshTimestamp != 0 &&
636 Stopwatch.GetElapsedTime(_lastRefreshTimestamp, now) < RefreshInterval)
637 {
638 return;
639 }
640
641 _lastRefreshTimestamp = now;
642 Refresh();
643 }
644
645 private void StopCollection()
646 {
647 Engine.Instance.AfterBackgroundTasksExecute -= OnAfterBackgroundTasksExecute;
648 _collectionRequest?.Dispose();
649 _collectionRequest = null;
650 _lastRefreshTimestamp = 0;
651 }
652
653 private string FormatSnapshot(TelemetrySnapshot? snapshot)
654 {
655 var text = new StringBuilder();
656
657 if (ShowHeader)
658 text.AppendLine(HeaderText);
659
660 View? view = View;
661 if (view is not null)
662 {
663 var extensionContext = new ProfilerWidgetExtensionContext(
664 snapshot,
666 view);
667
668 AppendAdditionalLines(text, extensionContext);
669 AppendRuntimeContext(text, extensionContext);
670 }
671
672 if (snapshot is null)
673 {
674 text.Append("Waiting for first completed telemetry window...");
675 return text.ToString().TrimEnd();
676 }
677
679 {
680 text.Append("Generation ")
681 .Append(snapshot.Generation)
682 .Append(" | window ")
683 .Append((snapshot.ElapsedSeconds * 1000d).ToString("0.0"))
684 .Append(" ms");
685
686 if (snapshot.Truncated)
687 text.Append(" | detail truncated");
688
689 text.AppendLine();
690 }
691
692 bool wroteSource = false;
693 foreach (TelemetrySourceSnapshot source in snapshot.Sources)
694 {
695 if (!IsSourceVisible(source.Name))
696 continue;
697
698 var metrics = source.Metrics
699 .Where(pair => ResolveMeasurementVisibility(
700 source.Name,
701 pair.Key,
702 pair.Value.Availability))
703 .OrderBy(pair => pair.Key, StringComparer.Ordinal)
704 .ToArray();
705
706 if (metrics.Length == 0)
707 continue;
708
709 if (wroteSource)
710 text.AppendLine();
711
713 {
714 text.Append(source.Name)
715 .Append(" [")
716 .Append(source.Backend)
717 .AppendLine("]");
718 }
719
720 foreach (KeyValuePair<string, TelemetrySummary> pair in metrics)
721 {
722 text.Append(" ")
723 .Append(pair.Key)
724 .Append(": ")
725 .AppendLine(FormatSummary(pair.Value, snapshot.ElapsedSeconds));
726 }
727
728 wroteSource = true;
729 }
730
731 if (!wroteSource)
732 text.Append("No measurements selected.");
733
734 return text.ToString().TrimEnd();
735 }
736
737 private void AppendAdditionalLines(
738 StringBuilder text,
739 ProfilerWidgetExtensionContext context)
740 {
741 IEnumerable<string>? lines = AdditionalLinesProvider?.Invoke(context);
742 if (lines is null)
743 return;
744
745 foreach (string line in lines)
746 text.AppendLine(line ?? string.Empty);
747 }
748
749 private void AppendRuntimeContext(
750 StringBuilder text,
751 ProfilerWidgetExtensionContext context)
752 {
753 if ((ContextInfo & ProfilerContextInfo.Scene) != 0)
754 {
755 var scene = context.RenderSurfaceHost.Scene;
756 long gridCells = scene.SceneLayers.Sum(
757 static layer => (long)layer.GridColumnCount * layer.GridRowCount);
758
759 text.Append("Animating tiles: ")
760 .AppendLine(Tile.TilesAnimating.Count.ToString("N0"));
761 text.Append("Layers / grid cells: ")
762 .Append(scene.SceneLayers.Count)
763 .Append(" / ")
764 .AppendLine(gridCells.ToString("N0"));
765 }
766
767 Rectangle viewport = context.View.Viewport.TargetRectPx;
768 if ((ContextInfo & ProfilerContextInfo.View) != 0)
769 {
770 PointF camera = context.View.Camera.PositionPx;
771 text.Append("Camera: ")
772 .Append(camera.X.ToString("0.0"))
773 .Append(", ")
774 .Append(camera.Y.ToString("0.0"))
775 .Append(" px; zoom: ")
776 .Append(context.View.Viewport.Zoom.ToString("0.000"))
777 .AppendLine("x");
778 }
779
780 bool includeViewport = (ContextInfo & ProfilerContextInfo.View) != 0;
781 bool includeBackbuffer = (ContextInfo & ProfilerContextInfo.Backbuffer) != 0;
782 if (includeViewport && includeBackbuffer)
783 {
784 var backbuffer = context.RenderSurfaceHost.Backbuffer;
785 text.Append("Viewport / backbuffer: ")
786 .Append(viewport.Width)
787 .Append('x')
788 .Append(viewport.Height)
789 .Append(" / ")
790 .Append(backbuffer.Width)
791 .Append('x')
792 .AppendLine(backbuffer.Height.ToString());
793 }
794 else if (includeViewport)
795 {
796 text.Append("Viewport: ")
797 .Append(viewport.Width)
798 .Append('x')
799 .AppendLine(viewport.Height.ToString());
800 }
801 else if (includeBackbuffer)
802 {
803 var backbuffer = context.RenderSurfaceHost.Backbuffer;
804 text.Append("Backbuffer: ")
805 .Append(backbuffer.Width)
806 .Append('x')
807 .AppendLine(backbuffer.Height.ToString());
808 }
809
810 if ((ContextInfo & ProfilerContextInfo.EngineConfiguration) != 0)
811 {
812 text.Append("Target FPS / VSync: ")
813 .Append(Engine.Instance.Configuration.TargetFPS)
814 .Append(" / ")
815 .AppendLine(Engine.Instance.Configuration.VSync.ToString());
816 }
817
818 if ((ContextInfo & ProfilerContextInfo.Msaa) != 0 &&
819 context.RenderSurfaceHost.Backbuffer is GpuBackbuffer gpu)
820 {
821 text.Append("MSAA requested / actual / max: ")
822 .Append(gpu.MsaaSampleCount)
823 .Append(" / ")
824 .Append(gpu.ActualMsaaSampleCount)
825 .Append(" / ")
826 .AppendLine(gpu.MaxSupportedMsaaSampleCount.ToString());
827 }
828 }
829
830 private void ApplyLayout()
831 {
832 Rectangle bounds = Bounds;
833 Rectangle displayBounds = GetDisplayBounds(bounds, ShowMetricSelector);
834
836 Display,
837 new Vector2(
838 displayBounds.X - bounds.X,
839 displayBounds.Y - bounds.Y));
840 Display.Size = displayBounds.Size;
841
842 if (_metricSelectorButton is not null || _metricSelectorList is not null)
843 {
844 DisposeMetricSelector();
845 _metricSelectorSignature = null;
846
847 if (ShowMetricSelector && _latestSnapshot is not null)
848 EnsureMetricSelector(_latestSnapshot);
849 }
850 }
851
852 private static Rectangle GetDisplayBounds(Rectangle bounds, bool showSelector)
853 {
854 int toolbarHeight = showSelector
855 ? Math.Min(MetricSelectorHeight, Math.Max(0, bounds.Height - 1))
856 : 0;
857
858 return new Rectangle(
859 bounds.Left,
860 bounds.Top + toolbarHeight,
861 bounds.Width,
862 Math.Max(1, bounds.Height - toolbarHeight));
863 }
864
865 private static Rectangle GetMetricSelectorButtonBounds(Rectangle bounds)
866 {
867 int width = Math.Max(
868 1,
869 Math.Min(
870 MetricSelectorWidth,
871 Math.Max(1, bounds.Width - MetricSelectorMargin * 2)));
872 int height = Math.Max(
873 1,
874 Math.Min(
875 MetricSelectorHeight - MetricSelectorMargin * 2,
876 Math.Max(1, bounds.Height)));
877
878 return new Rectangle(
879 bounds.Left + MetricSelectorMargin,
880 bounds.Top + MetricSelectorMargin,
881 width,
882 height);
883 }
884
885 private static Rectangle GetMetricSelectorListBounds(Rectangle bounds)
886 {
887 int width = Math.Max(24, Math.Min(460, Math.Max(24, bounds.Width - MetricSelectorMargin * 2)));
888 int availableHeight = Math.Max(
889 28,
890 bounds.Height - MetricSelectorHeight - MetricSelectorMargin);
891 int height = Math.Max(28, Math.Min(420, availableHeight));
892
893 return new Rectangle(
894 bounds.Left + MetricSelectorMargin,
895 bounds.Top + MetricSelectorHeight,
896 width,
897 height);
898 }
899
900 private void EnsureMetricSelector(TelemetrySnapshot snapshot)
901 {
902 View? view = View;
903 if (!ShowMetricSelector || view is null)
904 return;
905
906 string signature = BuildMetricSelectorSignature(snapshot);
907 if (_metricSelectorButton is null ||
908 _metricSelectorList is null ||
909 !string.Equals(_metricSelectorSignature, signature, StringComparison.Ordinal))
910 {
911 bool wasOpen = _metricSelectorOpen;
912 DisposeMetricSelector();
913 _metricSelectorOpen = wasOpen;
914
915 Rectangle bounds = Bounds;
916 Rectangle buttonBounds = GetMetricSelectorButtonBounds(bounds);
917 Rectangle listBounds = GetMetricSelectorListBounds(bounds);
918
919 _metricSelectorButton = new ButtonWidget(
921 view,
922 buttonBounds,
923 "Metrics...",
924 $"{Nickname}.metric-selector.button")
925 .SetButtonZOrder(_profilerZOrder + 100);
926
927 _metricSelectorButton.Clicked += ToggleMetricSelector;
928
929 _metricSelectorList = new ListBoxWidget(
931 view,
932 listBounds,
933 nickname: $"{Nickname}.metric-selector.list")
934 {
935 ItemHeight = 22,
936 VerticalScrollBarVisibility = ScrollBarVisibility.Auto
937 };
938
939 _metricSelectorList
940 .SetListBoxZOrder(_profilerZOrder + 200)
941 .SetSelectionColor(Color.FromArgb(255, 70, 70, 84));
942
943 _metricSelectorList.SelectionCommitted += OnMetricSelectorSelectionCommitted;
944
945 Add(
946 _metricSelectorButton,
947 new Vector2(
948 buttonBounds.X - bounds.X,
949 buttonBounds.Y - bounds.Y));
950 Add(
951 _metricSelectorList,
952 new Vector2(
953 listBounds.X - bounds.X,
954 listBounds.Y - bounds.Y));
955
956 _metricSelectorSignature = signature;
957
958 if (!_metricSelectorOpen)
959 _metricSelectorList.Hide();
960 }
961
962 RefreshMetricSelectorRows(snapshot);
963 }
964
965 private string BuildMetricSelectorSignature(TelemetrySnapshot snapshot)
966 {
967 var signature = new StringBuilder()
968 .Append(Bounds.Width)
969 .Append('x')
970 .Append(Bounds.Height);
971
972 foreach (var sourceGroup in snapshot.Sources
973 .GroupBy(source => source.Name, StringComparer.OrdinalIgnoreCase)
974 .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase))
975 {
976 signature.Append('|').Append(sourceGroup.Key);
977
978 foreach (string backend in sourceGroup
979 .Select(source => source.Backend)
980 .Distinct(StringComparer.Ordinal)
981 .OrderBy(value => value, StringComparer.Ordinal))
982 {
983 signature.Append('[').Append(backend).Append(']');
984 }
985
986 foreach (string metricKey in sourceGroup
987 .SelectMany(source => source.Metrics.Keys)
988 .Distinct(StringComparer.Ordinal)
989 .OrderBy(value => value, StringComparer.Ordinal))
990 {
991 signature.Append(';').Append(metricKey);
992 }
993 }
994
995 return signature.ToString();
996 }
997
998 private void RefreshMetricSelectorRows(TelemetrySnapshot snapshot)
999 {
1000 if (_metricSelectorList is null)
1001 return;
1002
1003 _metricSelectorRows.Clear();
1004
1005 AddSelectorRow(
1007 "Hover definitions",
1009 AddSelectorRow(
1011 "Show unavailable",
1013 _metricSelectorRows.Add(new MetricSelectorRow("Show all measurements", () =>
1014 {
1017 }));
1018 _metricSelectorRows.Add(new MetricSelectorRow("Hide all measurements", () =>
1019 {
1022 }));
1023 _metricSelectorRows.Add(new MetricSelectorRow("------------------------------", null));
1024
1025 foreach (var sourceGroup in snapshot.Sources
1026 .GroupBy(source => source.Name, StringComparer.OrdinalIgnoreCase)
1027 .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase))
1028 {
1029 string sourceName = sourceGroup.Key;
1030 string backend = string.Join(
1031 "/",
1032 sourceGroup
1033 .Select(source => source.Backend)
1034 .Distinct(StringComparer.Ordinal)
1035 .OrderBy(value => value, StringComparer.Ordinal));
1036
1037 bool sourceVisible = IsSourceVisible(sourceName);
1038 AddSelectorRow(
1039 sourceVisible,
1040 $"{sourceName} [{backend}]",
1041 () => SetSourceVisible(sourceName, !IsSourceVisible(sourceName)));
1042
1043 foreach (string metricKey in sourceGroup
1044 .SelectMany(source => source.Metrics.Keys)
1045 .Distinct(StringComparer.Ordinal)
1046 .OrderBy(value => value, StringComparer.Ordinal))
1047 {
1048 TelemetryAvailability availability = sourceGroup
1049 .Where(source => source.Metrics.ContainsKey(metricKey))
1050 .Select(source => source.Metrics[metricKey].Availability)
1051 .DefaultIfEmpty(TelemetryAvailability.Unsupported)
1052 .Max();
1053 bool metricVisible = ResolveMeasurementVisibility(
1054 sourceName,
1055 metricKey,
1056 availability);
1057 string capturedMetricKey = metricKey;
1058 string capturedSourceName = sourceName;
1059 TelemetryAvailability capturedAvailability = availability;
1060 string availabilitySuffix = availability == TelemetryAvailability.Available
1061 ? string.Empty
1062 : $" [{FormatAvailability(availability)}]";
1063
1064 AddSelectorRow(
1065 metricVisible,
1066 $" {metricKey}{availabilitySuffix}",
1068 capturedSourceName,
1069 capturedMetricKey,
1070 !ResolveMeasurementVisibility(
1071 capturedSourceName,
1072 capturedMetricKey,
1073 capturedAvailability)));
1074 }
1075 }
1076
1077 int topIndex = _metricSelectorList.TopIndex;
1078 _metricSelectorList.SetItems(_metricSelectorRows.Select(row => row.Text));
1079
1080 int maximumTopIndex = Math.Max(
1081 0,
1082 _metricSelectorRows.Count - _metricSelectorList.VisibleItemCount);
1083 _metricSelectorList.TopIndex = Math.Min(topIndex, maximumTopIndex);
1084 }
1085
1086 private void AddSelectorRow(bool isChecked, string text, Action action)
1087 {
1088 _metricSelectorRows.Add(
1089 new MetricSelectorRow(
1090 $"[{(isChecked ? 'x' : ' ')}] {text}",
1091 action));
1092 }
1093
1094 private void ToggleMetricSelector()
1095 {
1096 if (_metricSelectorList is null)
1097 return;
1098
1099 _metricSelectorOpen = !_metricSelectorOpen;
1100 if (_metricSelectorOpen)
1101 {
1102 _metricSelectorList.Show();
1103 _metricSelectorList.Activate();
1104 }
1105 else
1106 {
1107 _metricSelectorList.Hide();
1108 }
1109 }
1110
1111 private void OnMetricSelectorSelectionCommitted(int index)
1112 {
1113 if (index < 0 || index >= _metricSelectorRows.Count)
1114 return;
1115
1116 MetricSelectorRow row = _metricSelectorRows[index];
1117 if (row.Action is null)
1118 return;
1119
1120 row.Action();
1121 Refresh();
1122 }
1123
1124 private void DisposeMetricSelector()
1125 {
1126 if (_metricSelectorButton is not null)
1127 {
1128 _metricSelectorButton.Clicked -= ToggleMetricSelector;
1129 RemoveChild(_metricSelectorButton, dispose: true);
1130 _metricSelectorButton = null;
1131 }
1132
1133 if (_metricSelectorList is not null)
1134 {
1135 _metricSelectorList.SelectionCommitted -= OnMetricSelectorSelectionCommitted;
1136 RemoveChild(_metricSelectorList, dispose: true);
1137 _metricSelectorList = null;
1138 }
1139
1140 _metricSelectorRows.Clear();
1141 _metricSelectorSignature = null;
1142 }
1143
1144 private void CaptureRenderedMetricLines(string text)
1145 {
1146 _renderedMetricLines.Clear();
1147
1148 string[] lines = text
1149 .Replace("\r\n", "\n", StringComparison.Ordinal)
1150 .Split('\n');
1151
1152 for (int index = 0; index < lines.Length; index++)
1153 {
1154 string line = lines[index];
1155 if (!line.StartsWith(" ", StringComparison.Ordinal))
1156 continue;
1157
1158 int colon = line.IndexOf(':', 2);
1159 if (colon <= 2)
1160 continue;
1161
1162 string metricKey = line[2..colon].Trim();
1163 if (!string.IsNullOrWhiteSpace(metricKey))
1164 _renderedMetricLines[index] = metricKey;
1165 }
1166 }
1167
1168 private void OnProfilerPointerMove(WidgetPointerEventArgs args)
1169 {
1171 {
1172 _measurementTooltip.HideTooltip();
1173 return;
1174 }
1175
1176 Rectangle displayBounds = Display.Bounds;
1177 Point pointer = Point.Round(args.ScreenPositionPx);
1178 if (!displayBounds.Contains(pointer))
1179 {
1180 _measurementTooltip.HideTooltip();
1181 return;
1182 }
1183
1184 float lineHeight = Display.TextBlock.LineHeight;
1185 if (lineHeight <= 0f)
1186 {
1187 _measurementTooltip.HideTooltip();
1188 return;
1189 }
1190
1191 float localY =
1192 args.ScreenPositionPx.Y -
1193 displayBounds.Top -
1194 Display.TextBlock.VerticalPadding +
1195 Display.VerticalScrollOffsetPx;
1196
1197 if (localY < 0f)
1198 {
1199 _measurementTooltip.HideTooltip();
1200 return;
1201 }
1202
1203 int lineIndex = (int)MathF.Floor(localY / lineHeight);
1204 if (!_renderedMetricLines.TryGetValue(lineIndex, out string? metricKey))
1205 {
1206 _measurementTooltip.HideTooltip();
1207 return;
1208 }
1209
1210 string? description = GetMeasurementDescription(metricKey);
1211 if (string.IsNullOrWhiteSpace(description))
1212 {
1213 _measurementTooltip.HideTooltip();
1214 return;
1215 }
1216
1217 _measurementTooltip.ShowTooltip(
1218 $"{metricKey}\n{description}",
1219 pointer);
1220 }
1221
1222 private void OnProfilerPointerLeave(WidgetPointerEventArgs args)
1223 {
1224 _ = args;
1225 _measurementTooltip.HideTooltip();
1226 }
1227
1228 private void OnProfilerMouseWheel(WidgetMouseWheelEventArgs args)
1229 {
1230 _ = args;
1231 _measurementTooltip.HideTooltip();
1232 }
1233
1234 private string? GetMeasurementDescription(string metricKey)
1235 {
1236 if (_measurementDescriptions.TryGetValue(metricKey, out string? overrideDescription))
1237 return overrideDescription;
1238
1239 string normalizedKey = NormalizeLayerMetricKey(metricKey);
1240 string? description = normalizedKey switch
1241 {
1242 "cycle.cpu.ms" =>
1243 "CPU time for one complete Engine cycle. The sample rate is the Engine cycle rate (CPS).",
1244 "background.cpu.ms" =>
1245 "CPU time spent in background/simulation work for one Engine cycle.",
1246 "foreground.cpu.ms" =>
1247 "CPU time spent in foreground/render-preparation work. The sample rate is foreground frame production.",
1248 "build.cpu.ms" =>
1249 "Engine-thread time spent building the render frame or render snapshot.",
1250 "query.cpu.ms" =>
1251 "Time spent querying and culling visible drawables. Sorting is reported separately.",
1252 "sort.cpu.ms" =>
1253 "Time spent sorting queried drawables into render order.",
1254 "record.cpu.ms" =>
1255 "Time spent recording render commands after query/sort.",
1256 "overlay.cpu.ms" =>
1257 "Time spent recording view/direct-drawing overlay work.",
1258 "visible.drawables" =>
1259 "Visible drawable instances observed during a render build, including repeated instances across views.",
1260 "visible.tiles" =>
1261 "Visible tile draw instances observed during a render build.",
1262 "atlas.batches" =>
1263 "Atlas-backed tile batches generated for a render build.",
1264 "atlas.tiles" =>
1265 "Tiles emitted through atlas-backed batches for a render build.",
1266 "replay.cpu.ms" =>
1267 "GPU presentation-thread time spent replaying a completed render snapshot.",
1268 "picture.cpu.ms" =>
1269 "Time spent replaying the recorded Skia picture into the GPU backbuffer.",
1270 "backbuffer.flush.cpu.ms" =>
1271 "Time spent flushing GPU backbuffer drawing after replay.",
1272 "snapshot.cpu.ms" =>
1273 "Time spent snapshotting the current GPU backbuffer for presentation/inspection.",
1274 "snapshot.age.ms" =>
1275 "Age of the consumed render snapshot when GPU replay begins. Higher values indicate producer/consumer lag.",
1276 "mailbox.published.lifetime" =>
1277 "Lifetime count of render snapshots published to the producer/consumer mailbox.",
1278 "mailbox.dropped.lifetime" =>
1279 "Lifetime count of published snapshots superseded before they could be consumed.",
1280 "mailbox.slots" =>
1281 "Current occupied snapshot-mailbox slots.",
1282 "snapshot.commands.approximate" =>
1283 "Approximate command count retained by the latest render snapshot.",
1284 "gate.wait.cpu.ms" =>
1285 "Time spent waiting to enter the shared live render-state gate. Current desktop snapshot replay does not enter this broad gate, so the desktop GPU source reports NotApplicable.",
1286 "gate.held.cpu.ms" =>
1287 "Time spent while holding the shared live render-state gate. Current desktop snapshot replay does not enter this broad gate, so the desktop GPU source reports NotApplicable.",
1288 "presentation.count" =>
1289 "Completed presentation callbacks. Its sample rate is the actual GPU presentation FPS for this render surface.",
1290 "presentation.cpu.ms" =>
1291 "Total CPU duration of one GPU presentation callback.",
1292 "render.snapshot.cpu.ms" =>
1293 "Combined render/snapshot CPU work measured by the GPU presentation path.",
1294 "blit.cpu.ms" =>
1295 "Platform presentation time spent blitting the completed image.",
1296 "flush.cpu.ms" =>
1297 "Platform presentation time spent performing the final graphics flush.",
1298 "layers.omitted" =>
1299 "Number of SceneLayers omitted from detailed per-layer telemetry because the retained layer-detail limit was reached.",
1300 "layer.query.cpu.ms" =>
1301 "Per-layer time spent querying/culling visible content; sorting is excluded.",
1302 "layer.record.cpu.ms" =>
1303 "Per-layer time spent recording render commands.",
1304 "layer.drawables" =>
1305 "Visible drawable instances contributed by this SceneLayer across views.",
1306 "layer.tiles" =>
1307 "Visible tile draw instances contributed by this SceneLayer.",
1308 "layer.transformed.tiles" =>
1309 "Current count of this layer's visible tiles requiring transform work.",
1310 "layer.tile.width.px" =>
1311 "Current native tile width for this SceneLayer.",
1312 "layer.tile.height.px" =>
1313 "Current native tile height for this SceneLayer.",
1314 "layer.z" =>
1315 "Current SceneLayer Z-order.",
1316 _ => null
1317 };
1318
1319 if (description is null)
1320 return null;
1321
1322 if (_latestSnapshot is null)
1323 return description;
1324
1325 TelemetrySummary? summary = _latestSnapshot.Sources
1326 .SelectMany(source => source.Metrics)
1327 .Where(pair => string.Equals(
1328 NormalizeLayerMetricKey(pair.Key),
1329 normalizedKey,
1330 StringComparison.Ordinal))
1331 .Select(pair => pair.Value)
1332 .FirstOrDefault();
1333
1334 return summary?.Kind == TelemetryMetricKind.Sample
1335 ? description +
1336 "\n\navg/min/max are values observed during the current telemetry window. " +
1337 "samples is how many times this metric was recorded; samples/s is that count " +
1338 "divided by the window duration, not the metric value per second."
1339 : description;
1340 }
1341
1342 private static string NormalizeLayerMetricKey(string metricKey)
1343 {
1344 if (!metricKey.StartsWith("layer.", StringComparison.Ordinal))
1345 return metricKey;
1346
1347 int firstSeparator = metricKey.IndexOf('.', "layer.".Length);
1348 if (firstSeparator < 0 || firstSeparator + 1 >= metricKey.Length)
1349 return metricKey;
1350
1351 return "layer." + metricKey[(firstSeparator + 1)..];
1352 }
1353
1354 private sealed record MetricSelectorRow(string Text, Action? Action);
1355
1356 private bool IsSourceVisible(string sourceName)
1357 {
1358 return !_sourceVisibility.TryGetValue(sourceName, out bool visible) || visible;
1359 }
1360
1361 private bool ResolveMeasurementVisibility(
1362 string sourceName,
1363 string metricKey,
1364 TelemetryAvailability availability)
1365 {
1366 if (!IsSourceVisible(sourceName))
1367 return false;
1368
1369 bool explicitlySelected = false;
1370 bool selected;
1371
1372 if (_sourceMeasurementVisibility.TryGetValue(
1373 CreateSourceMetricKey(sourceName, metricKey),
1374 out bool sourceMetricVisible))
1375 {
1376 selected = sourceMetricVisible;
1377 explicitlySelected = sourceMetricVisible;
1378 }
1379 else if (_measurementVisibility.TryGetValue(metricKey, out bool metricVisible))
1380 {
1381 selected = metricVisible;
1382 explicitlySelected = metricVisible;
1383 }
1384 else
1385 {
1387 }
1388
1389 if (!selected)
1390 return false;
1391
1392 return availability == TelemetryAvailability.Available ||
1394 explicitlySelected;
1395 }
1396
1397 private static string FormatSummary(TelemetrySummary summary, double elapsedSeconds)
1398 {
1399 if (summary.Availability != TelemetryAvailability.Available)
1400 return summary.Availability.ToString();
1401
1402 return summary.Kind switch
1403 {
1404 TelemetryMetricKind.Gauge =>
1405 $"last {FormatNumber(summary.Last)}",
1406 TelemetryMetricKind.LifetimeCounter =>
1407 summary.WindowDelta is { } delta
1408 ? $"last {FormatNumber(summary.Last)}, delta {FormatNumber(delta)}"
1409 : $"last {FormatNumber(summary.Last)}, delta n/a",
1410 _ =>
1411 $"avg {FormatNumber(summary.Mean)}, min {FormatNumber(summary.Minimum)}, " +
1412 $"max {FormatNumber(summary.Maximum)}, samples {summary.Count:N0} " +
1413 $"({FormatRate(summary.Count, elapsedSeconds)} samples/s)"
1414 };
1415 }
1416
1417 private static string FormatRate(long count, double elapsedSeconds)
1418 {
1419 return elapsedSeconds > 0d
1420 ? (count / elapsedSeconds).ToString("0.0")
1421 : "n/a";
1422 }
1423
1424 private static string FormatNumber(double? value)
1425 {
1426 return value?.ToString("0.###") ?? "n/a";
1427 }
1428
1429 private static string FormatAvailability(TelemetryAvailability availability)
1430 {
1431 return availability switch
1432 {
1433 TelemetryAvailability.Unsupported => "unsupported",
1434 TelemetryAvailability.NotApplicable => "not applicable",
1435 TelemetryAvailability.NotYetSampled => "not yet sampled",
1436 _ => "available"
1437 };
1438 }
1439
1440 private static string CreateSourceMetricKey(string sourceName, string metricKey)
1441 {
1442 return $"{sourceName.ToUpperInvariant()}\u001f{metricKey}";
1443 }
1444
1445 private static void ValidateMetricKey(string metricKey)
1446 {
1447 ArgumentException.ThrowIfNullOrWhiteSpace(metricKey);
1448 }
1449
1450 private static Rectangle ValidateBounds(Rectangle bounds)
1451 {
1452 ValidateSize(bounds.Size);
1453 return bounds;
1454 }
1455
1456 private static void ValidateSize(Size size)
1457 {
1458 if (size.Width <= 0 || size.Height <= 0)
1459 throw new ArgumentOutOfRangeException(nameof(size), size, "Profiler widget size must be positive.");
1460 }
1461}
TelemetrySnapshot? GetLatestSnapshot()
Returns the latest completed bucket, or null before one completes.
IDisposable Start()
Starts or joins collection. Disposing the last request stops collection.
bool Visible
Gets or sets the visibility of all descendants.
RenderSurfaceHostBase RenderSurfaceHost
Gets the render surface host shared by the composite and its children.
DirectComposite SetLocalOffset(IDirectCompositeChild child, Vector2 newLocalOffsetPx)
Changes one child's local offset and reapplies its position.
DirectComposite SetIsVisible(bool visible)
Applies one visibility value to every descendant.
virtual DirectComposite Clear()
Removes every child without disposing them.
View? View
Gets the view shared by all children in view mode.
Vector2 GetPosition()
Gets the current composite anchor.
A retained-mode text drawable with wrapping, multi-line ( ) support, horizontal/vertical alignment,...
Definition TextBlock.cs:44
VerticalAlign
Vertical alignment options for multi-line text within the control's bounds.
Definition TextBlock.cs:49
Represents the core game engine singleton responsible for managing the main update loop,...
Definition Engine.cs:36
static Engine Instance
Gets the singleton instance of the Engine.
Definition Engine.cs:49
Diagnostics.RuntimeProfiler Profiler
Opt-in runtime CPU measurements and bounded history, independent of CPS notifications.
Definition Engine.cs:228
Represents a base class for hosting a render surface, providing functionality for managing rendering ...
Represents a rendered view of a scene, combining a camera position with a viewport configuration to c...
Definition View.cs:14
override DirectComposite Clear()
Removes every child without disposing them.The current composite.
bool RemoveChild(WidgetBase widget, bool dispose=false)
Removes a child widget from the container.
override DirectComposite Add(IDirectCompositeChild child, bool keepCurrentOffset=true, Vector2? explicitLocalOffsetPx=null)
Adds a composite-compatible direct drawable as a child.The current composite.
ContainerWidget(RenderSurfaceHostBase renderSurfaceHost, DirectDrawingMode mode, PointF anchor=default, string? nickname=null)
Initializes a new instance of the ContainerWidget class.
Represents a basic text button composed from a rectangle and text block.
Displays retained-mode text as a reusable Gondwana widget.
Provides a selectable, vertically scrolling list of text items.
ProfilerWidgetExtensionContext(TelemetrySnapshot? snapshot, RenderSurfaceHostBase renderSurfaceHost, View view)
Initializes a new profiler-widget extension context.
RenderSurfaceHostBase RenderSurfaceHost
Gets the render surface host displayed by the widget.
TelemetrySnapshot? Snapshot
Gets the latest detached telemetry snapshot, or null before the first window completes.
View View
Gets the view displayed by the widget.
ProfilerWidget(RenderSurfaceHostBase renderSurfaceHost, View view, Rectangle bounds, string? nickname=null)
Creates a view-level runtime-profiler widget.
ProfilerWidget SetProfilerZOrder(int zOrder)
Sets the base Z-order used by the profiler label and scrollbar.
bool MeasurementTooltipsEnabled
Gets or sets whether hovering a rendered measurement shows its definition.
bool IsCollectionActive
Gets whether this widget currently owns an active profiler collection request.
bool IsMeasurementVisible(string sourceName, string metricKey)
Returns whether a metric would be displayed under the current source/metric visibility rules.
ProfilerMeasurementVisibilityMode MeasurementVisibilityMode
Gets or sets the default measurement visibility policy.
Rectangle Bounds
Gets the current widget bounds in view pixels.
bool ShowSourceHeaders
Gets or sets whether source name/backend headings are displayed.
Func< ProfilerWidgetExtensionContext, IEnumerable< string >?>? AdditionalLinesProvider
Gets or sets an optional callback that supplies application-specific diagnostic lines.
bool ShowSnapshotMetadata
Gets or sets whether generation, elapsed-window, and truncation information is displayed.
bool ShowMetricSelector
Gets or sets whether the built-in Metrics selector control is displayed.
override void Dispose()
Disposes the composite and every child it owns.
LabelWidget Display
Gets the label used to render profiler text and optional scrolling.
bool ShowHeader
Gets or sets whether HeaderText is displayed.
TooltipWidget MeasurementTooltip
Gets the tooltip used for measurement definitions.
ProfilerWidget SetFont(SKTypeface typeface, float size, float? minSize=null)
Sets the profiler typeface and text size.
TimeSpan RefreshInterval
Gets or sets how frequently the widget reads and formats the latest completed snapshot.
ProfilerContextInfo ContextInfo
Gets or sets the supplemental runtime context rendered before profiler measurements.
ProfilerWidget SetColors(SKColor foreground, SKColor background)
Sets profiler text and background colors.
ProfilerWidget SetMeasurementDescription(string metricKey, string? description)
Defines or replaces hover help for a measurement key.
override void ProcessShown()
Performs processing when the widget is shown before notification callbacks are invoked.
override void ProcessHidden()
Performs processing when the widget is hidden before notification callbacks are invoked.
string HeaderText
Gets or sets the heading displayed when ShowHeader is enabled.
bool ShowUnavailableMeasurements
Gets or sets whether unsupported, not-applicable, and not-yet-sampled measurements are shown when the...
ProfilerWidget SetMeasurementVisible(string metricKey, bool visible)
Sets a visibility override for a metric key across all profiler sources.
ProfilerWidget ClearVisibilityOverrides()
Removes all explicit source and measurement visibility overrides.
Size Size
Gets or sets the widget size while preserving its current position.
ProfilerWidget SetMeasurementVisible(string sourceName, string metricKey, bool visible)
Sets a visibility override for one metric on one named profiler source.
ProfilerWidget SetSourceVisible(string sourceName, bool visible)
Sets whether all measurements from a named profiler source are displayed.
ProfilerWidget Refresh()
Immediately reads and formats the latest completed profiler snapshot.
Displays a lightweight view-level text tooltip near a screen position.
bool CanReceiveFocus
Gets or sets a value indicating whether the widget can receive keyboard focus.
bool IsPointerInputEnabled
Gets or sets a value indicating whether the widget can receive pointer input.
Action< WidgetPointerEventArgs >? PointerMove
Occurs when a pointer moves while routed to the widget.
Definition WidgetBase.cs:56
Action< WidgetPointerEventArgs >? PointerLeave
Occurs when a pointer leaves the widget bounds.
Definition WidgetBase.cs:46
bool IsKeyboardInputEnabled
Gets or sets a value indicating whether the widget can receive keyboard input.
bool IsInputEnabled
Gets or sets a value indicating whether the widget can participate in input routing.
PointF ScreenPositionPx
Gets the pointer position in screen pixels.
record TelemetrySummary(TelemetryAvailability Availability, long Count, double Sum, double? Minimum, double? Maximum, double? Last)
Detached statistics for observations in one window; durations use milliseconds.
record TelemetrySourceSnapshot(long Id, string Name, string Backend, IReadOnlyDictionary< string, TelemetrySummary > Metrics)
Detached source identity, backend label, and measurements. No runtime objects are retained.
TelemetryAvailability
Whether an observation is meaningful for its source and window.
record TelemetrySnapshot(long Generation, double StartedSeconds, double EndedSeconds, IReadOnlyList< TelemetrySourceSnapshot > Sources, bool Truncated)
Detached completed window. Sources can be observed asynchronously within its interval.
@ Rectangle
Particles are spawned uniformly within a rectangular area. This is the default distribution.
DirectDrawingMode
Specifies where direct drawing operations are applied within the rendering pipeline.
ScrollBarVisibility
Specifies when a scrollable widget displays its vertical scrollbar.
ProfilerMeasurementVisibilityMode
Controls the default visibility policy for runtime-profiler measurements.
@ Selected
Hides measurements unless they are explicitly shown.
@ All
Shows every measurement unless it is explicitly hidden.
ProfilerContextInfo
Selects optional non-profiler runtime context rendered alongside telemetry measurements.
@ Msaa
Displays requested, actual, and maximum MSAA values when the backbuffer is GPU-backed.
@ EngineConfiguration
Displays target FPS and VSync configuration.
@ View
Displays camera position, viewport zoom, and viewport dimensions.
@ None
Displays no supplemental runtime context.
@ Scene
Displays scene layer, grid-cell, and active tile-animation counts.
@ Backbuffer
Displays logical backbuffer dimensions.