소스 검색

Implemented layout and render time graph overlays

Julien Lebosquain 2 년 전
부모
커밋
5e13c5b59a
36개의 변경된 파일867개의 추가작업 그리고 320개의 파일을 삭제
  1. 3 2
      samples/GpuInterop/MainWindow.axaml.cs
  2. 14 0
      samples/RenderDemo/MainWindow.xaml
  3. 19 4
      samples/RenderDemo/MainWindow.xaml.cs
  4. 31 14
      samples/RenderDemo/ViewModels/MainWindowViewModel.cs
  5. 12 8
      src/Avalonia.Base/Layout/LayoutManager.cs
  6. 16 15
      src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs
  7. 66 0
      src/Avalonia.Base/Rendering/Composition/Server/DiagnosticTextRenderer.cs
  8. 22 36
      src/Avalonia.Base/Rendering/Composition/Server/FpsCounter.cs
  9. 176 0
      src/Avalonia.Base/Rendering/Composition/Server/FrameTimeGraph.cs
  10. 107 24
      src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs
  11. 2 9
      src/Avalonia.Base/Rendering/IRenderer.cs
  12. 11 0
      src/Avalonia.Base/Rendering/LayoutPassTiming.cs
  13. 35 0
      src/Avalonia.Base/Rendering/RendererDebugOverlays.cs
  14. 57 0
      src/Avalonia.Base/Rendering/RendererDiagnostics.cs
  15. 19 0
      src/Avalonia.Base/Utilities/StopwatchHelper.cs
  16. 2 2
      src/Avalonia.Base/composition-schema.xml
  17. 60 4
      src/Avalonia.Controls/TopLevel.cs
  18. 58 51
      src/Avalonia.Diagnostics/Diagnostics/ViewModels/MainViewModel.cs
  19. 2 2
      src/Avalonia.Diagnostics/Diagnostics/ViewModels/ViewModelBase.cs
  20. 19 5
      src/Avalonia.Diagnostics/Diagnostics/Views/MainView.xaml
  21. 3 11
      tests/Avalonia.Base.UnitTests/Input/MouseDeviceTests.cs
  22. 11 11
      tests/Avalonia.Base.UnitTests/Input/PointerOverTests.cs
  23. 4 4
      tests/Avalonia.Base.UnitTests/VisualTests.cs
  24. 0 50
      tests/Avalonia.Benchmarks/NullRenderer.cs
  25. 10 10
      tests/Avalonia.Controls.UnitTests/ButtonTests.cs
  26. 1 1
      tests/Avalonia.Controls.UnitTests/ContextMenuTests.cs
  27. 3 0
      tests/Avalonia.Controls.UnitTests/DesktopStyleApplicationLifetimeTests.cs
  28. 1 1
      tests/Avalonia.Controls.UnitTests/FlyoutTests.cs
  29. 1 1
      tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs
  30. 15 6
      tests/Avalonia.Controls.UnitTests/WindowBaseTests.cs
  31. 9 3
      tests/Avalonia.Controls.UnitTests/WindowTests.cs
  32. 1 42
      tests/Avalonia.LeakTests/ControlTests.cs
  33. 4 1
      tests/Avalonia.UnitTests/MockWindowingPlatform.cs
  34. 57 0
      tests/Avalonia.UnitTests/NullRenderer.cs
  35. 15 0
      tests/Avalonia.UnitTests/RendererMocks.cs
  36. 1 3
      tests/Avalonia.UnitTests/TestRoot.cs

+ 3 - 2
samples/GpuInterop/MainWindow.axaml.cs

@@ -1,6 +1,7 @@
 using Avalonia;
 using Avalonia.Controls;
 using Avalonia.Markup.Xaml;
+using Avalonia.Rendering;
 
 namespace GpuInterop
 {
@@ -8,9 +9,9 @@ namespace GpuInterop
     {
         public MainWindow()
         {
-            this.InitializeComponent();
+            InitializeComponent();
             this.AttachDevTools();
-            this.Renderer.DrawFps = true;
+            Renderer.Diagnostics.DebugOverlays = RendererDebugOverlays.Fps;
         }
 
         private void InitializeComponent()

+ 14 - 0
samples/RenderDemo/MainWindow.xaml

@@ -26,6 +26,20 @@
                         IsHitTestVisible="False" />
             </MenuItem.Icon>
           </MenuItem>
+          <MenuItem Command="{Binding ToggleDrawLayoutTimeGraph}" Header="Draw layout time graph">
+            <MenuItem.Icon>
+              <CheckBox BorderThickness="0"
+                        IsChecked="{Binding DrawLayoutTimeGraph}"
+                        IsHitTestVisible="False" />
+            </MenuItem.Icon>
+          </MenuItem>
+          <MenuItem Command="{Binding ToggleDrawRenderTimeGraph}" Header="Draw render time graph">
+            <MenuItem.Icon>
+              <CheckBox BorderThickness="0"
+                        IsChecked="{Binding DrawRenderTimeGraph}"
+                        IsHitTestVisible="False" />
+            </MenuItem.Icon>
+          </MenuItem>
         </MenuItem>
         <MenuItem Header="Tests">
           <MenuItem Command="{Binding ResizeWindow}" Header="Resize window" />

+ 19 - 4
samples/RenderDemo/MainWindow.xaml.cs

@@ -1,7 +1,9 @@
 using System;
+using System.Linq.Expressions;
 using Avalonia;
 using Avalonia.Controls;
 using Avalonia.Markup.Xaml;
+using Avalonia.Rendering;
 using RenderDemo.ViewModels;
 using MiniMvvm;
 
@@ -11,13 +13,26 @@ namespace RenderDemo
     {
         public MainWindow()
         {
-            this.InitializeComponent();
+            InitializeComponent();
             this.AttachDevTools();
 
             var vm = new MainWindowViewModel();
-            vm.WhenAnyValue(x => x.DrawDirtyRects).Subscribe(x => Renderer.DrawDirtyRects = x);
-            vm.WhenAnyValue(x => x.DrawFps).Subscribe(x => Renderer.DrawFps = x);
-            this.DataContext = vm;
+
+            void BindOverlay(Expression<Func<MainWindowViewModel, bool>> expr, RendererDebugOverlays overlay)
+                => vm.WhenAnyValue(expr).Subscribe(x =>
+                {
+                    var diagnostics = Renderer.Diagnostics;
+                    diagnostics.DebugOverlays = x ?
+                        diagnostics.DebugOverlays | overlay :
+                        diagnostics.DebugOverlays & ~overlay;
+                });
+
+            BindOverlay(x => x.DrawDirtyRects, RendererDebugOverlays.DirtyRects);
+            BindOverlay(x => x.DrawFps, RendererDebugOverlays.Fps);
+            BindOverlay(x => x.DrawLayoutTimeGraph, RendererDebugOverlays.LayoutTimeGraph);
+            BindOverlay(x => x.DrawRenderTimeGraph, RendererDebugOverlays.RenderTimeGraph);
+
+            DataContext = vm;
         }
 
         private void InitializeComponent()

+ 31 - 14
samples/RenderDemo/ViewModels/MainWindowViewModel.cs

@@ -1,49 +1,66 @@
-using System.Reactive;
-using System.Threading.Tasks;
+using System.Threading.Tasks;
 using MiniMvvm;
 
 namespace RenderDemo.ViewModels
 {
     public class MainWindowViewModel : ViewModelBase
     {
-        private bool drawDirtyRects = false;
-        private bool drawFps = true;
-        private double width = 800;
-        private double height = 600;
+        private bool _drawDirtyRects;
+        private bool _drawFps = true;
+        private bool _drawLayoutTimeGraph;
+        private bool _drawRenderTimeGraph;
+        private double _width = 800;
+        private double _height = 600;
 
         public MainWindowViewModel()
         {
             ToggleDrawDirtyRects = MiniCommand.Create(() => DrawDirtyRects = !DrawDirtyRects);
             ToggleDrawFps = MiniCommand.Create(() => DrawFps = !DrawFps);
+            ToggleDrawLayoutTimeGraph = MiniCommand.Create(() => DrawLayoutTimeGraph = !DrawLayoutTimeGraph);
+            ToggleDrawRenderTimeGraph = MiniCommand.Create(() => DrawRenderTimeGraph = !DrawRenderTimeGraph);
             ResizeWindow = MiniCommand.CreateFromTask(ResizeWindowAsync);
         }
 
         public bool DrawDirtyRects
         {
-            get => drawDirtyRects;
-            set => this.RaiseAndSetIfChanged(ref drawDirtyRects, value);
+            get => _drawDirtyRects;
+            set => RaiseAndSetIfChanged(ref _drawDirtyRects, value);
         }
 
         public bool DrawFps
         {
-            get => drawFps;
-            set => this.RaiseAndSetIfChanged(ref drawFps, value);
+            get => _drawFps;
+            set => RaiseAndSetIfChanged(ref _drawFps, value);
+        }
+
+        public bool DrawLayoutTimeGraph
+        {
+            get => _drawLayoutTimeGraph;
+            set => RaiseAndSetIfChanged(ref _drawLayoutTimeGraph, value);
+        }
+
+        public bool DrawRenderTimeGraph
+        {
+            get => _drawRenderTimeGraph;
+            set => RaiseAndSetIfChanged(ref _drawRenderTimeGraph, value);
         }
 
         public double Width
         {
-            get => width;
-            set => this.RaiseAndSetIfChanged(ref width, value);
+            get => _width;
+            set => RaiseAndSetIfChanged(ref _width, value);
         }
 
         public double Height
         {
-            get => height;
-            set => this.RaiseAndSetIfChanged(ref height, value);
+            get => _height;
+            set => RaiseAndSetIfChanged(ref _height, value);
         }
 
         public MiniCommand ToggleDrawDirtyRects { get; }
         public MiniCommand ToggleDrawFps { get; }
+        public MiniCommand ToggleDrawLayoutTimeGraph { get; }
+        public MiniCommand ToggleDrawRenderTimeGraph { get; }
         public MiniCommand ResizeWindow { get; }
 
         private async Task ResizeWindowAsync()

+ 12 - 8
src/Avalonia.Base/Layout/LayoutManager.cs

@@ -3,8 +3,9 @@ using System.Buffers;
 using System.Collections.Generic;
 using System.Diagnostics;
 using Avalonia.Logging;
+using Avalonia.Rendering;
 using Avalonia.Threading;
-using Avalonia.VisualTree;
+using Avalonia.Utilities;
 
 #nullable enable
 
@@ -24,6 +25,7 @@ namespace Avalonia.Layout
         private bool _disposed;
         private bool _queued;
         private bool _running;
+        private int _totalPassCount;
 
         public LayoutManager(ILayoutRoot owner)
         {
@@ -33,6 +35,8 @@ namespace Avalonia.Layout
 
         public virtual event EventHandler? LayoutUpdated;
 
+        internal Action<LayoutPassTiming>? LayoutPassTimed { get; set; }
+
         /// <inheritdoc/>
         public virtual void InvalidateMeasure(Layoutable control)
         {
@@ -116,10 +120,9 @@ namespace Avalonia.Layout
 
             if (!_running)
             {
-                Stopwatch? stopwatch = null;
-
                 const LogEventLevel timingLogLevel = LogEventLevel.Information;
-                bool captureTiming = Logger.IsEnabled(timingLogLevel, LogArea.Layout);
+                var captureTiming = LayoutPassTimed is not null || Logger.IsEnabled(timingLogLevel, LogArea.Layout);
+                var startingTimestamp = 0L;
 
                 if (captureTiming)
                 {
@@ -129,8 +132,7 @@ namespace Avalonia.Layout
                         _toMeasure.Count,
                         _toArrange.Count);
 
-                    stopwatch = new Stopwatch();
-                    stopwatch.Start();
+                    startingTimestamp = Stopwatch.GetTimestamp();
                 }
 
                 _toMeasure.BeginLoop(MaxPasses);
@@ -139,6 +141,7 @@ namespace Avalonia.Layout
                 try
                 {
                     _running = true;
+                    ++_totalPassCount;
 
                     for (var pass = 0; pass < MaxPasses; ++pass)
                     {
@@ -160,9 +163,10 @@ namespace Avalonia.Layout
 
                 if (captureTiming)
                 {
-                    stopwatch!.Stop();
+                    var elapsed = StopwatchHelper.GetElapsedTime(startingTimestamp);
+                    LayoutPassTimed?.Invoke(new LayoutPassTiming(_totalPassCount, elapsed));
 
-                    Logger.TryGet(timingLogLevel, LogArea.Layout)?.Log(this, "Layout pass finished in {Time}", stopwatch.Elapsed);
+                    Logger.TryGet(timingLogLevel, LogArea.Layout)?.Log(this, "Layout pass finished in {Time}", elapsed);
                 }
             }
 

+ 16 - 15
src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs

@@ -1,15 +1,12 @@
 using System;
 using System.Collections.Generic;
+using System.ComponentModel;
 using System.Linq;
 using System.Numerics;
-using System.Runtime.InteropServices;
 using System.Threading.Tasks;
 using Avalonia.Collections;
 using Avalonia.Collections.Pooled;
 using Avalonia.Media;
-using Avalonia.Rendering.Composition.Drawing;
-using Avalonia.Rendering.Composition.Server;
-using Avalonia.Threading;
 using Avalonia.VisualTree;
 
 // Special license applies <see href="https://raw.githubusercontent.com/AvaloniaUI/Avalonia/master/src/Avalonia.Base/Rendering/Composition/License.md">License.md</see>
@@ -38,6 +35,9 @@ public class CompositingRenderer : IRendererWithCompositor
     /// </summary>
     public bool RenderOnlyOnRenderThread { get; set; } = true;
 
+    /// <inheritdoc/>
+    public RendererDiagnostics Diagnostics { get; }
+
     public CompositingRenderer(IRenderRoot root, Compositor compositor, Func<IEnumerable<object>> surfaces)
     {
         _root = root;
@@ -46,20 +46,21 @@ public class CompositingRenderer : IRendererWithCompositor
         CompositionTarget = compositor.CreateCompositionTarget(surfaces);
         CompositionTarget.Root = ((Visual)root).AttachToCompositor(compositor);
         _update = Update;
+        Diagnostics = new RendererDiagnostics();
+        Diagnostics.PropertyChanged += OnDiagnosticsPropertyChanged;
     }
 
-    /// <inheritdoc/>
-    public bool DrawFps
+    private void OnDiagnosticsPropertyChanged(object? sender, PropertyChangedEventArgs e)
     {
-        get => CompositionTarget.DrawFps;
-        set => CompositionTarget.DrawFps = value;
-    }
-    
-    /// <inheritdoc/>
-    public bool DrawDirtyRects
-    {
-        get => CompositionTarget.DrawDirtyRects;
-        set => CompositionTarget.DrawDirtyRects = value;
+        switch (e.PropertyName)
+        {
+            case nameof(RendererDiagnostics.DebugOverlays):
+                CompositionTarget.DebugOverlays = Diagnostics.DebugOverlays;
+                break;
+            case nameof(RendererDiagnostics.LastLayoutPassTiming):
+                CompositionTarget.LastLayoutPassTiming = Diagnostics.LastLayoutPassTiming;
+                break;
+        }
     }
 
     /// <inheritdoc/>

+ 66 - 0
src/Avalonia.Base/Rendering/Composition/Server/DiagnosticTextRenderer.cs

@@ -0,0 +1,66 @@
+using System;
+using Avalonia.Media;
+using Avalonia.Platform;
+
+namespace Avalonia.Rendering.Composition.Server
+{
+    /// <summary>
+    /// A class used to render diagnostic strings (only!), with caching of ASCII glyph runs.
+    /// </summary>
+    internal sealed class DiagnosticTextRenderer
+    {
+        private const char FirstChar = (char)32;
+        private const char LastChar = (char)126;
+
+        private readonly GlyphRun[] _runs = new GlyphRun[LastChar - FirstChar + 1];
+
+        public double MaxHeight { get; }
+
+        public DiagnosticTextRenderer(IGlyphTypeface typeface, double fontRenderingEmSize)
+        {
+            var chars = new char[LastChar - FirstChar + 1];
+            for (var c = FirstChar; c <= LastChar; c++)
+            {
+                var index = c - FirstChar;
+                chars[index] = c;
+                var glyph = typeface.GetGlyph(c);
+                var run = new GlyphRun(typeface, fontRenderingEmSize, chars.AsMemory(index, 1), new[] { glyph });
+                _runs[index] = run;
+                MaxHeight = Math.Max(run.Size.Height, MaxHeight);
+            }
+        }
+
+        public Size MeasureAsciiText(ReadOnlySpan<char> text)
+        {
+            var width = 0.0;
+            var height = 0.0;
+
+            foreach (var c in text)
+            {
+                var effectiveChar = c is >= FirstChar and <= LastChar ? c : ' ';
+                var run = _runs[effectiveChar - FirstChar];
+                width += run.Size.Width;
+                height = Math.Max(height, run.Size.Height);
+            }
+
+            return new Size(width, height);
+        }
+
+        public void DrawAsciiText(IDrawingContextImpl context, ReadOnlySpan<char> text, IBrush foreground)
+        {
+            var offset = 0.0;
+            var originalTransform = context.Transform;
+
+            foreach (var c in text)
+            {
+                var effectiveChar = c is >= FirstChar and <= LastChar ? c : ' ';
+                var run = _runs[effectiveChar - FirstChar];
+                context.Transform = originalTransform * Matrix.CreateTranslation(offset, 0.0);
+                context.DrawGlyphRun(foreground, run.PlatformImpl);
+                offset += run.Size.Width;
+            }
+
+            context.Transform = originalTransform;
+        }
+    }
+}

+ 22 - 36
src/Avalonia.Base/Rendering/Composition/Server/FpsCounter.cs

@@ -1,11 +1,8 @@
 using System;
 using System.Diagnostics;
 using System.Globalization;
-using System.Linq;
 using Avalonia.Media;
-using Avalonia.Media.TextFormatting;
 using Avalonia.Platform;
-using Avalonia.Utilities;
 
 // Special license applies <see href="https://raw.githubusercontent.com/AvaloniaUI/Avalonia/master/src/Avalonia.Base/Rendering/Composition/License.md">License.md</see>
 
@@ -17,26 +14,18 @@ namespace Avalonia.Rendering.Composition.Server;
 internal class FpsCounter
 {
     private readonly Stopwatch _stopwatch = Stopwatch.StartNew();
+    private readonly DiagnosticTextRenderer _textRenderer;
+
     private int _framesThisSecond;
     private int _totalFrames;
     private int _fps;
     private TimeSpan _lastFpsUpdate;
-    const int FirstChar = 32;
-    const int LastChar = 126;
-    // ASCII chars
-    private GlyphRun[] _runs = new GlyphRun[LastChar - FirstChar + 1];
-    
-    public FpsCounter(IGlyphTypeface typeface)
-    {
-        for (var c = FirstChar; c <= LastChar; c++)
-        {
-            var s = new string((char)c, 1);
-            var glyph = typeface.GetGlyph((uint)(s[0]));
-            _runs[c - FirstChar] = new GlyphRun(typeface, 18, s.AsMemory(), new ushort[] { glyph });
-        }
-    }
 
-    public void FpsTick() => _framesThisSecond++;
+    public FpsCounter(DiagnosticTextRenderer textRenderer)
+        => _textRenderer = textRenderer;
+
+    public void FpsTick()
+        => _framesThisSecond++;
 
     public void RenderFps(IDrawingContextImpl context, string aux)
     {
@@ -53,27 +42,24 @@ internal class FpsCounter
             _lastFpsUpdate = now;
         }
 
-        var fpsLine = FormattableString.Invariant($"Frame #{_totalFrames:00000000} FPS: {_fps:000} ") + aux;
-        double width = 0;
-        double height = 0;
-        foreach (var ch in fpsLine)
-        {
-            var run = _runs[ch - FirstChar];
-            width +=  run.Size.Width;
-            height = Math.Max(height, run.Size.Height);
-        }
+#if NET6_0_OR_GREATER
+        var fpsLine = string.Create(CultureInfo.InvariantCulture, $"Frame #{_totalFrames:00000000} FPS: {_fps:000} {aux}");
+#else
+        var fpsLine = FormattableString.Invariant($"Frame #{_totalFrames:00000000} FPS: {_fps:000} {aux}");
+#endif
 
-        var rect = new Rect(0, 0, width + 3, height + 3);
+        var size = _textRenderer.MeasureAsciiText(fpsLine.AsSpan());
+        var rect = new Rect(0.0, 0.0, size.Width + 3.0, size.Height + 3.0);
 
         context.DrawRectangle(Brushes.Black, null, rect);
 
-        double offset = 0;
-        foreach (var ch in fpsLine)
-        {
-            var run = _runs[ch - FirstChar];
-            context.Transform = Matrix.CreateTranslation(offset, 0);
-            context.DrawGlyphRun(Brushes.White, run.PlatformImpl);
-            offset += run.Size.Width;
-        }
+        _textRenderer.DrawAsciiText(context, fpsLine.AsSpan(), Brushes.White);
+    }
+
+    public void Reset()
+    {
+        _framesThisSecond = 0;
+        _totalFrames = 0;
+        _fps = 0;
     }
 }

+ 176 - 0
src/Avalonia.Base/Rendering/Composition/Server/FrameTimeGraph.cs

@@ -0,0 +1,176 @@
+using System;
+using System.Diagnostics;
+using System.Globalization;
+using System.Runtime.CompilerServices;
+using Avalonia.Media;
+using Avalonia.Media.Immutable;
+using Avalonia.Platform;
+
+namespace Avalonia.Rendering.Composition.Server;
+
+/// <summary>
+/// Represents a simple time graph for diagnostics purpose, used to show layout and render times.
+/// </summary>
+internal sealed class FrameTimeGraph
+{
+    private const double HeaderPadding = 2.0;
+
+    private readonly IPlatformRenderInterface _renderInterface;
+    private readonly ImmutableSolidColorBrush _borderBrush;
+    private readonly ImmutablePen _graphPen;
+    private readonly double[] _frameValues;
+    private readonly Size _size;
+    private readonly Size _headerSize;
+    private readonly Size _graphSize;
+    private readonly double _defaultMaxY;
+    private readonly string _title;
+    private readonly DiagnosticTextRenderer _textRenderer;
+
+    private int _startFrameIndex;
+    private int _frameCount;
+
+    public Size Size
+        => _size;
+
+    public FrameTimeGraph(int maxFrames, Size size, double defaultMaxY, string title,
+        DiagnosticTextRenderer textRenderer)
+    {
+        Debug.Assert(maxFrames >= 1);
+        Debug.Assert(size.Width > 0.0);
+        Debug.Assert(size.Height > 0.0);
+
+        _renderInterface = AvaloniaLocator.Current.GetRequiredService<IPlatformRenderInterface>();
+        _borderBrush = new ImmutableSolidColorBrush(0x80808080);
+        _graphPen = new ImmutablePen(Brushes.Blue);
+        _frameValues = new double[maxFrames];
+        _size = size;
+        _headerSize = new Size(size.Width, textRenderer.MaxHeight + HeaderPadding * 2.0);
+        _graphSize = new Size(size.Width, size.Height - _headerSize.Height);
+        _defaultMaxY = defaultMaxY;
+        _title = title;
+        _textRenderer = textRenderer;
+    }
+
+    public void AddFrameValue(double value)
+    {
+        if (_frameCount < _frameValues.Length)
+        {
+            _frameValues[_startFrameIndex + _frameCount] = value;
+            ++_frameCount;
+        }
+        else
+        {
+            // overwrite oldest value
+            _frameValues[_startFrameIndex] = value;
+            if (++_startFrameIndex == _frameValues.Length)
+            {
+                _startFrameIndex = 0;
+            }
+        }
+    }
+
+    public void Reset()
+    {
+        _startFrameIndex = 0;
+        _frameCount = 0;
+    }
+
+    public void Render(IDrawingContextImpl context)
+    {
+        var originalTransform = context.Transform;
+        context.PushClip(new Rect(_size));
+
+        context.DrawRectangle(_borderBrush, null, new RoundedRect(new Rect(_size)));
+        context.DrawRectangle(_borderBrush, null, new RoundedRect(new Rect(_headerSize)));
+
+        context.Transform = originalTransform * Matrix.CreateTranslation(HeaderPadding, HeaderPadding);
+        _textRenderer.DrawAsciiText(context, _title.AsSpan(), Brushes.Black);
+
+        if (_frameCount > 0)
+        {
+            var (min, avg, max) = GetYValues();
+
+            DrawLabelledValue(context, "Min", min, originalTransform, _headerSize.Width * 0.19);
+            DrawLabelledValue(context, "Avg", avg, originalTransform, _headerSize.Width * 0.46);
+            DrawLabelledValue(context, "Max", max, originalTransform, _headerSize.Width * 0.73);
+
+            context.Transform = originalTransform * Matrix.CreateTranslation(0.0, _headerSize.Height);
+            context.DrawGeometry(null, _graphPen, BuildGraphGeometry(Math.Max(max, _defaultMaxY)));
+        }
+
+        context.Transform = originalTransform;
+        context.PopClip();
+    }
+
+    private void DrawLabelledValue(IDrawingContextImpl context, string label, double value, in Matrix originalTransform,
+        double left)
+    {
+        context.Transform = originalTransform * Matrix.CreateTranslation(left + HeaderPadding, HeaderPadding);
+
+        var brush = value <= _defaultMaxY ? Brushes.Black : Brushes.Red;
+
+#if NET6_0_OR_GREATER
+        Span<char> buffer = stackalloc char[24];
+        buffer.TryWrite(CultureInfo.InvariantCulture, $"{label}: {value,5:F2}ms", out var charsWritten);
+        _textRenderer.DrawAsciiText(context, buffer.Slice(0, charsWritten), brush);
+#else
+        var text = FormattableString.Invariant($"{label}: {value,5:F2}ms");
+        _textRenderer.DrawAsciiText(context, text.AsSpan(), brush);
+#endif
+    }
+
+    private IStreamGeometryImpl BuildGraphGeometry(double maxY)
+    {
+        Debug.Assert(_frameCount > 0);
+
+        var graphGeometry = _renderInterface.CreateStreamGeometry();
+        using var geometryContext = graphGeometry.Open();
+
+        var xRatio = _graphSize.Width / _frameValues.Length;
+        var yRatio = _graphSize.Height / maxY;
+
+        geometryContext.BeginFigure(new Point(0.0, _graphSize.Height - GetFrameValue(0) * yRatio), false);
+
+        for (var i = 1; i < _frameCount; ++i)
+        {
+            var x = Math.Round(i * xRatio);
+            var y = _graphSize.Height - GetFrameValue(i) * yRatio;
+            geometryContext.LineTo(new Point(x, y));
+        }
+
+        geometryContext.EndFigure(false);
+        return graphGeometry;
+    }
+
+    private (double Min, double Average, double Max) GetYValues()
+    {
+        Debug.Assert(_frameCount > 0);
+
+        var min = double.MaxValue;
+        var max = double.MinValue;
+        var total = 0.0;
+
+        for (var i = 0; i < _frameCount; ++i)
+        {
+            var y = GetFrameValue(i);
+
+            total += y;
+
+            if (y < min)
+            {
+                min = y;
+            }
+
+            if (y > max)
+            {
+                max = y;
+            }
+        }
+
+        return (min, total / _frameCount, max);
+    }
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    private double GetFrameValue(int frameOffset)
+        => _frameValues[(_startFrameIndex + frameOffset) % _frameValues.Length];
+}

+ 107 - 24
src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs

@@ -1,6 +1,6 @@
 using System;
 using System.Collections.Generic;
-using System.Numerics;
+using System.Diagnostics;
 using System.Threading;
 using Avalonia.Media;
 using Avalonia.Media.Imaging;
@@ -22,10 +22,11 @@ namespace Avalonia.Rendering.Composition.Server
         private readonly ServerCompositor _compositor;
         private readonly Func<IEnumerable<object>> _surfaces;
         private static long s_nextId = 1;
-        public long Id { get; }
-        public ulong Revision { get; private set; }
         private IRenderTarget? _renderTarget;
-        private FpsCounter _fpsCounter = new FpsCounter(Typeface.Default.GlyphTypeface);
+        private DiagnosticTextRenderer? _diagnosticTextRenderer;
+        private FpsCounter? _fpsCounter;
+        private FrameTimeGraph? _renderTimeGraph;
+        private FrameTimeGraph? _layoutTimeGraph;
         private Rect _dirtyRect;
         private Random _random = new();
         private Size _layerSize;
@@ -35,10 +36,24 @@ namespace Avalonia.Rendering.Composition.Server
         private HashSet<ServerCompositionVisual> _attachedVisuals = new();
         private Queue<ServerCompositionVisual> _adornerUpdateQueue = new();
 
+        public long Id { get; }
+        public ulong Revision { get; private set; }
         public ICompositionTargetDebugEvents? DebugEvents { get; set; }
         public ReadbackIndices Readback { get; } = new();
         public int RenderedVisuals { get; set; }
 
+        private DiagnosticTextRenderer DiagnosticTextRenderer
+            => _diagnosticTextRenderer ??= new DiagnosticTextRenderer(Typeface.Default.GlyphTypeface, 12.0);
+
+        private FpsCounter FpsCounter
+            => _fpsCounter ??= new FpsCounter(DiagnosticTextRenderer);
+
+        private FrameTimeGraph LayoutTimeGraph
+            => _layoutTimeGraph ??= CreateTimeGraph("Layout");
+
+        private FrameTimeGraph RenderTimeGraph
+            => _renderTimeGraph ??= CreateTimeGraph("Render");
+
         public ServerCompositionTarget(ServerCompositor compositor, Func<IEnumerable<object>> surfaces) :
             base(compositor)
         {
@@ -47,6 +62,9 @@ namespace Avalonia.Rendering.Composition.Server
             Id = Interlocked.Increment(ref s_nextId);
         }
 
+        private FrameTimeGraph CreateTimeGraph(string title)
+            => new(360, new Size(360.0, 64.0), 1000.0 / 60.0, title, DiagnosticTextRenderer);
+
         partial void OnIsEnabledChanged()
         {
             if (IsEnabled)
@@ -62,7 +80,33 @@ namespace Avalonia.Rendering.Composition.Server
                     v.Deactivate();
             }
         }
-        
+
+        partial void OnDebugOverlaysChanged()
+        {
+            if ((DebugOverlays & RendererDebugOverlays.Fps) == 0)
+            {
+                _fpsCounter?.Reset();
+            }
+
+            if ((DebugOverlays & RendererDebugOverlays.LayoutTimeGraph) == 0)
+            {
+                _layoutTimeGraph?.Reset();
+            }
+
+            if ((DebugOverlays & RendererDebugOverlays.RenderTimeGraph) == 0)
+            {
+                _renderTimeGraph?.Reset();
+            }
+        }
+
+        partial void OnLastLayoutPassTimingChanged()
+        {
+            if ((DebugOverlays & RendererDebugOverlays.LayoutTimeGraph) != 0)
+            {
+                LayoutTimeGraph.AddFrameValue(LastLayoutPassTiming.Elapsed.TotalMilliseconds);
+            }
+        }
+
         partial void DeserializeChangesExtra(BatchStreamReader c)
         {
             _redrawRequested = true;
@@ -92,7 +136,10 @@ namespace Avalonia.Rendering.Composition.Server
                 return;
 
             Revision++;
-            
+
+            var captureTiming = (DebugOverlays & RendererDebugOverlays.RenderTimeGraph) != 0;
+            var startingTimestamp = captureTiming ? Stopwatch.GetTimestamp() : 0L;
+
             // Update happens in a separate phase to extend dirty rect if needed
             Root.Update(this);
 
@@ -137,33 +184,69 @@ namespace Avalonia.Rendering.Composition.Server
                     targetContext.DrawBitmap(RefCountable.CreateUnownedNotClonable(_layer), 1,
                         new Rect(_layerSize),
                         new Rect(Size), BitmapInterpolationMode.LowQuality);
-                
-                
-                if (DrawDirtyRects)
-                {
-                    targetContext.DrawRectangle(new ImmutableSolidColorBrush(
-                            new Color(30, (byte)_random.Next(255), (byte)_random.Next(255),
-                                (byte)_random.Next(255)))
-                        , null, _dirtyRect);
-                }
 
-                if (DrawFps)
+                if (DebugOverlays != RendererDebugOverlays.None)
                 {
-                    var nativeMem = ByteSizeHelper.ToString((ulong)(
-                        (Compositor.BatchMemoryPool.CurrentUsage + Compositor.BatchMemoryPool.CurrentPool)  *
-                                                    Compositor.BatchMemoryPool.BufferSize), false);
-                    var managedMem = ByteSizeHelper.ToString((ulong)(
-                        (Compositor.BatchObjectPool.CurrentUsage + Compositor.BatchObjectPool.CurrentPool) *
-                                                                     Compositor.BatchObjectPool.ArraySize *
-                                                                     IntPtr.Size), false);
-                    _fpsCounter.RenderFps(targetContext, FormattableString.Invariant($"M:{managedMem} / N:{nativeMem} R:{RenderedVisuals:0000}"));
+                    if (captureTiming)
+                    {
+                        var elapsed = StopwatchHelper.GetElapsedTime(startingTimestamp);
+                        RenderTimeGraph.AddFrameValue(elapsed.TotalMilliseconds);
+                    }
+
+                    DrawOverlays(targetContext, layerSize);
                 }
+
                 RenderedVisuals = 0;
 
                 _dirtyRect = default;
             }
         }
 
+        private void DrawOverlays(IDrawingContextImpl targetContext, Size layerSize)
+        {
+            if ((DebugOverlays & RendererDebugOverlays.DirtyRects) != 0)
+            {
+                targetContext.DrawRectangle(
+                    new ImmutableSolidColorBrush(
+                        new Color(30, (byte)_random.Next(255), (byte)_random.Next(255), (byte)_random.Next(255))),
+                    null,
+                    _dirtyRect);
+            }
+
+            if ((DebugOverlays & RendererDebugOverlays.Fps) != 0)
+            {
+                var nativeMem = ByteSizeHelper.ToString((ulong) (
+                    (Compositor.BatchMemoryPool.CurrentUsage + Compositor.BatchMemoryPool.CurrentPool) *
+                    Compositor.BatchMemoryPool.BufferSize), false);
+                var managedMem = ByteSizeHelper.ToString((ulong) (
+                    (Compositor.BatchObjectPool.CurrentUsage + Compositor.BatchObjectPool.CurrentPool) *
+                    Compositor.BatchObjectPool.ArraySize *
+                    IntPtr.Size), false);
+                FpsCounter.RenderFps(targetContext,
+                    FormattableString.Invariant($"M:{managedMem} / N:{nativeMem} R:{RenderedVisuals:0000}"));
+            }
+
+            var top = 0.0;
+
+            void DrawTimeGraph(FrameTimeGraph graph)
+            {
+                top += 8.0;
+                targetContext.Transform = Matrix.CreateTranslation(layerSize.Width - graph.Size.Width - 8.0, top);
+                graph.Render(targetContext);
+                top += graph.Size.Height;
+            }
+
+            if ((DebugOverlays & RendererDebugOverlays.LayoutTimeGraph) != 0)
+            {
+                DrawTimeGraph(LayoutTimeGraph);
+            }
+
+            if ((DebugOverlays & RendererDebugOverlays.RenderTimeGraph) != 0)
+            {
+                DrawTimeGraph(RenderTimeGraph);
+            }
+        }
+
         public Rect SnapToDevicePixels(Rect rect) => SnapToDevicePixels(rect, Scaling);
         
         private static Rect SnapToDevicePixels(Rect rect, double scale)

+ 2 - 9
src/Avalonia.Base/Rendering/IRenderer.cs

@@ -1,5 +1,4 @@
 using System;
-using Avalonia.VisualTree;
 using System.Collections.Generic;
 using System.Threading.Tasks;
 using Avalonia.Rendering.Composition;
@@ -12,15 +11,9 @@ namespace Avalonia.Rendering
     public interface IRenderer : IDisposable
     {
         /// <summary>
-        /// Gets or sets a value indicating whether the renderer should draw an FPS counter.
+        /// Gets a value indicating whether the renderer should draw specific diagnostics.
         /// </summary>
-        bool DrawFps { get; set; }
-
-        /// <summary>
-        /// Gets or sets a value indicating whether the renderer should draw a visual representation
-        /// of its dirty rectangles.
-        /// </summary>
-        bool DrawDirtyRects { get; set; }
+        RendererDiagnostics Diagnostics { get; }
 
         /// <summary>
         /// Raised when a portion of the scene has been invalidated.

+ 11 - 0
src/Avalonia.Base/Rendering/LayoutPassTiming.cs

@@ -0,0 +1,11 @@
+using System;
+
+namespace Avalonia.Rendering
+{
+    /// <summary>
+    /// Represents a single layout pass timing.
+    /// </summary>
+    /// <param name="PassCounter">The number of the layout pass.</param>
+    /// <param name="Elapsed">The elapsed time during the layout pass.</param>
+    public readonly record struct LayoutPassTiming(int PassCounter, TimeSpan Elapsed);
+}

+ 35 - 0
src/Avalonia.Base/Rendering/RendererDebugOverlays.cs

@@ -0,0 +1,35 @@
+using System;
+
+namespace Avalonia.Rendering;
+
+/// <summary>
+/// Represents the various types of overlays that can be drawn by a renderer.
+/// </summary>
+[Flags]
+public enum RendererDebugOverlays
+{
+    /// <summary>
+    /// Do not draw any overlay.
+    /// </summary>
+    None = 0,
+
+    /// <summary>
+    /// Draw a FPS counter.
+    /// </summary>
+    Fps = 1 << 0,
+
+    /// <summary>
+    /// Draw invalidated rectangles each frame.
+    /// </summary>
+    DirtyRects = 1 << 1,
+
+    /// <summary>
+    /// Draw a graph of past layout times.
+    /// </summary>
+    LayoutTimeGraph = 1 << 2,
+
+    /// <summary>
+    /// Draw a graph of past render times.
+    /// </summary>
+    RenderTimeGraph = 1 << 3
+}

+ 57 - 0
src/Avalonia.Base/Rendering/RendererDiagnostics.cs

@@ -0,0 +1,57 @@
+using System.ComponentModel;
+
+namespace Avalonia.Rendering
+{
+    /// <summary>
+    /// Manages configurable diagnostics that can be displayed by a renderer.
+    /// </summary>
+    public class RendererDiagnostics : INotifyPropertyChanged
+    {
+        private RendererDebugOverlays _debugOverlays;
+        private LayoutPassTiming _lastLayoutPassTiming;
+        private PropertyChangedEventArgs? _debugOverlaysChangedEventArgs;
+        private PropertyChangedEventArgs? _lastLayoutPassTimingChangedEventArgs;
+
+        /// <summary>
+        /// Gets or sets which debug overlays are displayed by the renderer.
+        /// </summary>
+        public RendererDebugOverlays DebugOverlays
+        {
+            get => _debugOverlays;
+            set
+            {
+                if (_debugOverlays != value)
+                {
+                    _debugOverlays = value;
+                    OnPropertyChanged(_debugOverlaysChangedEventArgs ??= new(nameof(DebugOverlays)));
+                }
+            }
+        }
+
+        /// <summary>
+        /// Gets or sets the last layout pass timing that the renderer may display.
+        /// </summary>
+        public LayoutPassTiming LastLayoutPassTiming
+        {
+            get => _lastLayoutPassTiming;
+            set
+            {
+                if (!_lastLayoutPassTiming.Equals(value))
+                {
+                    _lastLayoutPassTiming = value;
+                    OnPropertyChanged(_lastLayoutPassTimingChangedEventArgs ??= new(nameof(LastLayoutPassTiming)));
+                }
+            }
+        }
+
+        /// <inheritdoc />
+        public event PropertyChangedEventHandler? PropertyChanged;
+
+        /// <summary>
+        /// Called when a property changes on the object.
+        /// </summary>
+        /// <param name="args">The property change details.</param>
+        protected virtual void OnPropertyChanged(PropertyChangedEventArgs args)
+            => PropertyChanged?.Invoke(this, args);
+    }
+}

+ 19 - 0
src/Avalonia.Base/Utilities/StopwatchHelper.cs

@@ -0,0 +1,19 @@
+using System;
+using System.Diagnostics;
+
+namespace Avalonia.Utilities;
+
+/// <summary>
+/// Allows using <see cref="Stopwatch"/> as timestamps without allocating.
+/// </summary>
+/// <remarks>Equivalent to Stopwatch.GetElapsedTime in .NET 7.</remarks>
+internal static class StopwatchHelper
+{
+    private static readonly double s_timestampToTicks = TimeSpan.TicksPerSecond / (double)Stopwatch.Frequency;
+
+    public static TimeSpan GetElapsedTime(long startingTimestamp)
+        => GetElapsedTime(startingTimestamp, Stopwatch.GetTimestamp());
+
+    public static TimeSpan GetElapsedTime(long startingTimestamp, long endingTimestamp)
+        => new((long)((endingTimestamp - startingTimestamp) * s_timestampToTicks));
+}

+ 2 - 2
src/Avalonia.Base/composition-schema.xml

@@ -39,8 +39,8 @@
     <Object Name="CompositionTarget" CustomServerCtor="true">
         <Property Name="Root" Type="CompositionVisual?"/>
         <Property Name="IsEnabled" Type="bool"/>
-        <Property Name="DrawDirtyRects" Type="bool"/>
-        <Property Name="DrawFps" Type="bool"/>
+        <Property Name="DebugOverlays" Type="RendererDebugOverlays"/>
+        <Property Name="LastLayoutPassTiming" Type="LayoutPassTiming"/>
         <Property Name="Scaling" Type="double"/>
         <Property Name="Size" Type="Size" />
     </Object>

+ 60 - 4
src/Avalonia.Controls/TopLevel.cs

@@ -1,7 +1,7 @@
 using System;
+using System.ComponentModel;
 using Avalonia.Reactive;
 using Avalonia.Controls.Metadata;
-using Avalonia.Controls.Notifications;
 using Avalonia.Controls.Platform;
 using Avalonia.Controls.Primitives;
 using Avalonia.Input;
@@ -17,7 +17,6 @@ using Avalonia.Platform.Storage;
 using Avalonia.Rendering;
 using Avalonia.Styling;
 using Avalonia.Utilities;
-using Avalonia.VisualTree;
 using Avalonia.Input.Platform;
 using System.Linq;
 
@@ -106,6 +105,7 @@ namespace Avalonia.Controls
         private Border? _transparencyFallbackBorder;
         private TargetWeakEventSubscriber<TopLevel, ResourcesChangedEventArgs>? _resourcesChangesSubscriber;
         private IStorageProvider? _storageProvider;
+        private LayoutDiagnosticBridge? _layoutDiagnosticBridge;
         
         /// <summary>
         /// Initializes static members of the <see cref="TopLevel"/> class.
@@ -194,7 +194,7 @@ namespace Avalonia.Controls
 
             ClientSize = impl.ClientSize;
             FrameSize = impl.FrameSize;
-            
+
             this.GetObservable(PointerOverElementProperty)
                 .Select(
                     x => (x as InputElement)?.GetObservable(CursorProperty) ?? Observable.Empty<Cursor>())
@@ -328,8 +328,17 @@ namespace Avalonia.Controls
         {
             get
             {
-                if (_layoutManager == null)
+                if (_layoutManager is null)
+                {
                     _layoutManager = CreateLayoutManager();
+
+                    if (_layoutManager is LayoutManager typedLayoutManager && Renderer is not null)
+                    {
+                        _layoutDiagnosticBridge = new LayoutDiagnosticBridge(Renderer.Diagnostics, typedLayoutManager);
+                        _layoutDiagnosticBridge.SetupBridge();
+                    }
+                }
+
                 return _layoutManager;
             }
         }
@@ -435,6 +444,9 @@ namespace Avalonia.Controls
             Renderer?.Dispose();
             Renderer = null!;
 
+            _layoutDiagnosticBridge?.Dispose();
+            _layoutDiagnosticBridge = null;
+
             _pointerOverPreProcessor?.OnCompleted();
             _pointerOverPreProcessorSubscription?.Dispose();
             _backGestureSubscription?.Dispose();
@@ -617,5 +629,49 @@ namespace Avalonia.Controls
         }
 
         ITextInputMethodImpl? ITextInputMethodRoot.InputMethod => PlatformImpl?.TryGetFeature<ITextInputMethodImpl>();
+
+        /// <summary>
+        /// Provides layout pass timing from the layout manager to the renderer, for diagnostics purposes.
+        /// </summary>
+        private sealed class LayoutDiagnosticBridge : IDisposable
+        {
+            private readonly RendererDiagnostics _diagnostics;
+            private readonly LayoutManager _layoutManager;
+            private bool _isHandling;
+
+            public LayoutDiagnosticBridge(RendererDiagnostics diagnostics, LayoutManager layoutManager)
+            {
+                _diagnostics = diagnostics;
+                _layoutManager = layoutManager;
+
+                diagnostics.PropertyChanged += OnDiagnosticsPropertyChanged;
+            }
+
+            public void SetupBridge()
+            {
+                var needsHandling = (_diagnostics.DebugOverlays & RendererDebugOverlays.LayoutTimeGraph) != 0;
+                if (needsHandling != _isHandling)
+                {
+                    _isHandling = needsHandling;
+                    _layoutManager.LayoutPassTimed = needsHandling
+                        ? timing => _diagnostics.LastLayoutPassTiming = timing
+                        : null;
+                }
+            }
+
+            private void OnDiagnosticsPropertyChanged(object? sender, PropertyChangedEventArgs e)
+            {
+                if (e.PropertyName == nameof(RendererDiagnostics.DebugOverlays))
+                {
+                    SetupBridge();
+                }
+            }
+
+            public void Dispose()
+            {
+                _diagnostics.PropertyChanged -= OnDiagnosticsPropertyChanged;
+                _layoutManager.LayoutPassTimed = null;
+            }
+        }
     }
 }

+ 58 - 51
src/Avalonia.Diagnostics/Diagnostics/ViewModels/MainViewModel.cs

@@ -1,11 +1,13 @@
 using System;
 using System.ComponentModel;
+using System.Runtime.CompilerServices;
 using Avalonia.Controls;
 using Avalonia.Diagnostics.Models;
 using Avalonia.Input;
 using Avalonia.Metadata;
 using Avalonia.Threading;
 using Avalonia.Reactive;
+using Avalonia.Rendering;
 
 namespace Avalonia.Diagnostics.ViewModels
 {
@@ -21,8 +23,6 @@ namespace Avalonia.Diagnostics.ViewModels
         private string? _focusedControl;
         private IInputElement? _pointerOverElement;
         private bool _shouldVisualizeMarginPadding = true;
-        private bool _shouldVisualizeDirtyRects;
-        private bool _showFpsOverlay;
         private bool _freezePopups;
         private string? _pointerOverElementName;
         private IInputRoot? _pointerOverRoot;
@@ -75,69 +75,76 @@ namespace Avalonia.Diagnostics.ViewModels
             set => RaiseAndSetIfChanged(ref _shouldVisualizeMarginPadding, value);
         }
 
-        public bool ShouldVisualizeDirtyRects
+        public void ToggleVisualizeMarginPadding()
+            => ShouldVisualizeMarginPadding = !ShouldVisualizeMarginPadding;
+
+        private IRenderer? TryGetRenderer()
+            => _root switch
+            {
+                TopLevel topLevel => topLevel.Renderer,
+                Controls.Application app => app.RendererRoot,
+                _ => null
+            };
+
+        private bool GetDebugOverlay(RendererDebugOverlays overlay)
+            => ((TryGetRenderer()?.Diagnostics.DebugOverlays ?? RendererDebugOverlays.None) & overlay) != 0;
+
+        private void SetDebugOverlay(RendererDebugOverlays overlay, bool enable,
+            [CallerMemberName] string? propertyName = null)
         {
-            get => _shouldVisualizeDirtyRects;
-            set
+            if (TryGetRenderer() is not { } renderer)
             {
-                var changed = true;
-                if (_root is TopLevel topLevel && topLevel.Renderer is { })
-                {
-                    topLevel.Renderer.DrawDirtyRects = value;
-                }
-                else if (_root is Controls.Application app && app.RendererRoot is { })
-                {
-                    app.RendererRoot.DrawDirtyRects = value;
-                }
-                else
-                {
-                    changed = false;
-                }
-                if (changed)
-                {
-                    RaiseAndSetIfChanged(ref _shouldVisualizeDirtyRects, value);
-                }
+                return;
             }
-        }
 
-        public void ToggleVisualizeDirtyRects()
-        {
-            ShouldVisualizeDirtyRects = !ShouldVisualizeDirtyRects;
+            var oldValue = renderer.Diagnostics.DebugOverlays;
+            var newValue = enable ? oldValue | overlay : oldValue & ~overlay;
+
+            if (oldValue == newValue)
+            {
+                return;
+            }
+
+            renderer.Diagnostics.DebugOverlays = newValue;
+            RaisePropertyChanged(propertyName);
         }
 
-        public void ToggleVisualizeMarginPadding()
+        public bool ShowDirtyRectsOverlay
         {
-            ShouldVisualizeMarginPadding = !ShouldVisualizeMarginPadding;
+            get => GetDebugOverlay(RendererDebugOverlays.DirtyRects);
+            set => SetDebugOverlay(RendererDebugOverlays.DirtyRects, value);
         }
 
+        public void ToggleDirtyRectsOverlay()
+            => ShowDirtyRectsOverlay = !ShowDirtyRectsOverlay;
+
         public bool ShowFpsOverlay
         {
-            get => _showFpsOverlay;
-            set
-            {
-                var changed = true;
-                if (_root is TopLevel topLevel && topLevel.Renderer is { })
-                {
-                    topLevel.Renderer.DrawFps = value;
-                }
-                else if (_root is Controls.Application app && app.RendererRoot is { })
-                {
-                    app.RendererRoot.DrawFps = value;
-                }
-                else
-                {
-                    changed = false;
-                }
-                if(changed)
-                    RaiseAndSetIfChanged(ref _showFpsOverlay, value);
-            }
+            get => GetDebugOverlay(RendererDebugOverlays.Fps);
+            set => SetDebugOverlay(RendererDebugOverlays.Fps, value);
         }
 
         public void ToggleFpsOverlay()
+            => ShowFpsOverlay = !ShowFpsOverlay;
+
+        public bool ShowLayoutTimeGraphOverlay
         {
-            ShowFpsOverlay = !ShowFpsOverlay;
+            get => GetDebugOverlay(RendererDebugOverlays.LayoutTimeGraph);
+            set => SetDebugOverlay(RendererDebugOverlays.LayoutTimeGraph, value);
         }
 
+        public void ToggleLayoutTimeGraphOverlay()
+            => ShowLayoutTimeGraphOverlay = !ShowLayoutTimeGraphOverlay;
+
+        public bool ShowRenderTimeGraphOverlay
+        {
+            get => GetDebugOverlay(RendererDebugOverlays.RenderTimeGraph);
+            set => SetDebugOverlay(RendererDebugOverlays.RenderTimeGraph, value);
+        }
+
+        public void ToggleRenderTimeGraphOverlay()
+            => ShowRenderTimeGraphOverlay = !ShowRenderTimeGraphOverlay;
+
         public ConsoleViewModel Console { get; }
 
         public ViewModelBase? Content
@@ -254,10 +261,10 @@ namespace Avalonia.Diagnostics.ViewModels
             _pointerOverSubscription.Dispose();
             _logicalTree.Dispose();
             _visualTree.Dispose();
-            if (_root is TopLevel top)
+
+            if (TryGetRenderer() is { } renderer)
             {
-                top.Renderer.DrawDirtyRects = false;
-                top.Renderer.DrawFps = false;
+                renderer.Diagnostics.DebugOverlays = RendererDebugOverlays.None;
             }
         }
 

+ 2 - 2
src/Avalonia.Diagnostics/Diagnostics/ViewModels/ViewModelBase.cs

@@ -20,7 +20,7 @@ namespace Avalonia.Diagnostics.ViewModels
         {
         }
 
-        protected bool RaiseAndSetIfChanged<T>([NotNullIfNotNull("value")] ref T field, T value, [CallerMemberName] string propertyName = null!)
+        protected bool RaiseAndSetIfChanged<T>([NotNullIfNotNull("value")] ref T field, T value, [CallerMemberName] string? propertyName = null)
         {
             if (!EqualityComparer<T>.Default.Equals(field, value))
             {
@@ -32,7 +32,7 @@ namespace Avalonia.Diagnostics.ViewModels
             return false;
         }
 
-        protected void RaisePropertyChanged([CallerMemberName] string propertyName = null!)
+        protected void RaisePropertyChanged([CallerMemberName] string? propertyName = null)
         {
             var e = new PropertyChangedEventArgs(propertyName);
             OnPropertyChanged(e);

+ 19 - 5
src/Avalonia.Diagnostics/Diagnostics/Views/MainView.xaml

@@ -65,28 +65,42 @@
           </MenuItem>          
         </MenuItem>
       </MenuItem>
-      <MenuItem Header="_Options">
-        <MenuItem Header="Visualize margin/padding" Command="{Binding ToggleVisualizeMarginPadding}">
+      <MenuItem Header="_Overlays">
+        <MenuItem Header="Margin/padding" Command="{Binding ToggleVisualizeMarginPadding}">
           <MenuItem.Icon>
             <CheckBox BorderThickness="0"
                       IsChecked="{Binding ShouldVisualizeMarginPadding}"
                       IsEnabled="False" />
           </MenuItem.Icon>
         </MenuItem>
-        <MenuItem Header="Visualize dirty rects" Command="{Binding ToggleVisualizeDirtyRects}">
+        <MenuItem Header="Dirty rects" Command="{Binding ToggleDirtyRectsOverlay}">
           <MenuItem.Icon>
             <CheckBox BorderThickness="0"
-                      IsChecked="{Binding ShouldVisualizeDirtyRects}"
+                      IsChecked="{Binding ShowDirtyRectsOverlay}"
                       IsEnabled="False" />
           </MenuItem.Icon>
         </MenuItem>
-        <MenuItem Header="Show fps overlay" Command="{Binding ToggleFpsOverlay}">
+        <MenuItem Header="FPS" Command="{Binding ToggleFpsOverlay}">
           <MenuItem.Icon>
             <CheckBox BorderThickness="0"
                       IsChecked="{Binding ShowFpsOverlay}"
                       IsEnabled="False" />
           </MenuItem.Icon>
         </MenuItem>
+        <MenuItem Header="Layout time graph" Command="{Binding ToggleLayoutTimeGraphOverlay}">
+          <MenuItem.Icon>
+            <CheckBox BorderThickness="0"
+                      IsChecked="{Binding ShowLayoutTimeGraphOverlay}"
+                      IsEnabled="False" />
+          </MenuItem.Icon>
+        </MenuItem>
+        <MenuItem Header="Render time graph" Command="{Binding ToggleRenderTimeGraphOverlay}">
+          <MenuItem.Icon>
+            <CheckBox BorderThickness="0"
+                      IsChecked="{Binding ShowRenderTimeGraphOverlay}"
+                      IsEnabled="False" />
+          </MenuItem.Icon>
+        </MenuItem>
       </MenuItem>
     </Menu>
 

+ 3 - 11
tests/Avalonia.Base.UnitTests/Input/MouseDeviceTests.cs

@@ -1,15 +1,7 @@
-using System;
-using System.Collections.Generic;
-using Avalonia.Controls;
-using Avalonia.Controls.Presenters;
-using Avalonia.Controls.Templates;
+using Avalonia.Controls;
 using Avalonia.Input;
-using Avalonia.Input.Raw;
 using Avalonia.Media;
-using Avalonia.Platform;
-using Avalonia.Rendering;
 using Avalonia.UnitTests;
-using Moq;
 using Xunit;
 
 namespace Avalonia.Base.UnitTests.Input
@@ -21,7 +13,7 @@ namespace Avalonia.Base.UnitTests.Input
         {
             using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager()));
 
-            var renderer = new Mock<IRenderer>();
+            var renderer = RendererMocks.CreateRenderer();
             var device = new MouseDevice();
             var impl = CreateTopLevelImplMock(renderer.Object);
 
@@ -59,7 +51,7 @@ namespace Avalonia.Base.UnitTests.Input
         {
             using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager()));
 
-            var renderer = new Mock<IRenderer>();
+            var renderer = RendererMocks.CreateRenderer();
             var device = new MouseDevice();
             var impl = CreateTopLevelImplMock(renderer.Object);
 

+ 11 - 11
tests/Avalonia.Base.UnitTests/Input/PointerOverTests.cs

@@ -22,7 +22,7 @@ namespace Avalonia.Base.UnitTests.Input
         {
             using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager()));
 
-            var renderer = new Mock<IRenderer>();
+            var renderer = RendererMocks.CreateRenderer();
             var device = CreatePointerDeviceMock().Object;
             var impl = CreateTopLevelImplMock(renderer.Object);
 
@@ -50,7 +50,7 @@ namespace Avalonia.Base.UnitTests.Input
         {
             using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager()));
 
-            var renderer = new Mock<IRenderer>();
+            var renderer = RendererMocks.CreateRenderer();
             var device = CreatePointerDeviceMock().Object;
             var impl = CreateTopLevelImplMock(renderer.Object);
 
@@ -93,7 +93,7 @@ namespace Avalonia.Base.UnitTests.Input
         {
             using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager()));
 
-            var renderer = new Mock<IRenderer>();
+            var renderer = RendererMocks.CreateRenderer();
             var device = CreatePointerDeviceMock(pointerType: PointerType.Touch).Object;
             var impl = CreateTopLevelImplMock(renderer.Object);
 
@@ -119,7 +119,7 @@ namespace Avalonia.Base.UnitTests.Input
         {
             using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager()));
 
-            var renderer = new Mock<IRenderer>();
+            var renderer = RendererMocks.CreateRenderer();
             var pointer = new Mock<IPointer>();
             var device = CreatePointerDeviceMock(pointer.Object).Object;
             var impl = CreateTopLevelImplMock(renderer.Object);
@@ -155,7 +155,7 @@ namespace Avalonia.Base.UnitTests.Input
         {
             using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager()));
 
-            var renderer = new Mock<IRenderer>();
+            var renderer = RendererMocks.CreateRenderer();
             var device = CreatePointerDeviceMock().Object;
             var impl = CreateTopLevelImplMock(renderer.Object);
 
@@ -201,7 +201,7 @@ namespace Avalonia.Base.UnitTests.Input
         {
             using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager()));
 
-            var renderer = new Mock<IRenderer>();
+            var renderer = RendererMocks.CreateRenderer();
             var deviceMock = CreatePointerDeviceMock();
             var impl = CreateTopLevelImplMock(renderer.Object);
             var result = new List<(object?, string)>();
@@ -256,7 +256,7 @@ namespace Avalonia.Base.UnitTests.Input
         {
             using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager()));
 
-            var renderer = new Mock<IRenderer>();
+            var renderer = RendererMocks.CreateRenderer();
             var deviceMock = CreatePointerDeviceMock();
             var impl = CreateTopLevelImplMock(renderer.Object);
             var result = new List<(object?, string)>();
@@ -307,7 +307,7 @@ namespace Avalonia.Base.UnitTests.Input
             using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager()));
 
             var expectedPosition = new Point(15, 15);
-            var renderer = new Mock<IRenderer>();
+            var renderer = RendererMocks.CreateRenderer();
             var deviceMock = CreatePointerDeviceMock();
             var impl = CreateTopLevelImplMock(renderer.Object);
             var result = new List<(object?, string, Point)>();
@@ -351,7 +351,7 @@ namespace Avalonia.Base.UnitTests.Input
         {
             using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager()));
 
-            var renderer = new Mock<IRenderer>();
+            var renderer = RendererMocks.CreateRenderer();
             var deviceMock = CreatePointerDeviceMock();
             var impl = CreateTopLevelImplMock(renderer.Object);
 
@@ -405,7 +405,7 @@ namespace Avalonia.Base.UnitTests.Input
         {
             using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager()));
 
-            var renderer = new Mock<IRenderer>();
+            var renderer = RendererMocks.CreateRenderer();
             var deviceMock = CreatePointerDeviceMock();
             var impl = CreateTopLevelImplMock(renderer.Object);
 
@@ -442,7 +442,7 @@ namespace Avalonia.Base.UnitTests.Input
         {
             using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager()));
 
-            var renderer = new Mock<IRenderer>();
+            var renderer = RendererMocks.CreateRenderer();
             var deviceMock = CreatePointerDeviceMock();
             var impl = CreateTopLevelImplMock(renderer.Object);
 

+ 4 - 4
tests/Avalonia.Base.UnitTests/VisualTests.cs

@@ -150,7 +150,7 @@ namespace Avalonia.Base.UnitTests
         [Fact]
         public void Attaching_To_Visual_Tree_Should_Invalidate_Visual()
         {
-            var renderer = new Mock<IRenderer>();
+            var renderer = RendererMocks.CreateRenderer();
             var child = new Decorator();
             var root = new TestRoot
             {
@@ -165,7 +165,7 @@ namespace Avalonia.Base.UnitTests
         [Fact]
         public void Detaching_From_Visual_Tree_Should_Invalidate_Visual()
         {
-            var renderer = new Mock<IRenderer>();
+            var renderer = RendererMocks.CreateRenderer();
             var child = new Decorator();
             var root = new TestRoot
             {
@@ -307,7 +307,7 @@ namespace Avalonia.Base.UnitTests
         public void Changing_ZIndex_Should_InvalidateVisual()
         {
             Canvas canvas1;
-            var renderer = new Mock<IRenderer>();
+            var renderer = RendererMocks.CreateRenderer();
             var root = new TestRoot
             {
                 Child = new StackPanel
@@ -331,7 +331,7 @@ namespace Avalonia.Base.UnitTests
         {
             Canvas canvas1;
             StackPanel stackPanel;
-            var renderer = new Mock<IRenderer>();
+            var renderer = RendererMocks.CreateRenderer();
             var root = new TestRoot
             {
                 Child = stackPanel = new StackPanel

+ 0 - 50
tests/Avalonia.Benchmarks/NullRenderer.cs

@@ -1,50 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Threading.Tasks;
-using Avalonia.Rendering;
-using Avalonia.VisualTree;
-
-namespace Avalonia.Benchmarks
-{
-    internal class NullRenderer : IRenderer
-    {
-        public bool DrawFps { get; set; }
-        public bool DrawDirtyRects { get; set; }
-#pragma warning disable CS0067
-        public event EventHandler<SceneInvalidatedEventArgs> SceneInvalidated;
-#pragma warning restore CS0067
-        public void AddDirty(Visual visual)
-        {
-        }
-
-        public void Dispose()
-        {
-        }
-
-        public IEnumerable<Visual> HitTest(Point p, Visual root, Func<Visual, bool> filter) => null;
-
-        public Visual HitTestFirst(Point p, Visual root, Func<Visual, bool> filter) => null;
-
-        public void Paint(Rect rect)
-        {
-        }
-
-        public void RecalculateChildren(Visual visual)
-        {
-        }
-
-        public void Resized(Size size)
-        {
-        }
-
-        public void Start()
-        {
-        }
-
-        public void Stop()
-        {
-        }
-
-        public ValueTask<object> TryGetRenderInterfaceFeature(Type featureType) => new(0);
-    }
-}

+ 10 - 10
tests/Avalonia.Controls.UnitTests/ButtonTests.cs

@@ -134,16 +134,16 @@ namespace Avalonia.Controls.UnitTests
         [Fact]
         public void Button_Raises_Click()
         {
-            var renderer = Mock.Of<IRenderer>();
+            var renderer = RendererMocks.CreateRenderer();
             var pt = new Point(50, 50);
-            Mock.Get(renderer).Setup(r => r.HitTest(It.IsAny<Point>(), It.IsAny<Visual>(), It.IsAny<Func<Visual, bool>>()))
+            renderer.Setup(r => r.HitTest(It.IsAny<Point>(), It.IsAny<Visual>(), It.IsAny<Func<Visual, bool>>()))
                 .Returns<Point, Visual, Func<Visual, bool>>((p, r, f) =>
                     r.Bounds.Contains(p) ? new Visual[] { r } : new Visual[0]);
 
             var target = new TestButton()
             {
                 Bounds = new Rect(0, 0, 100, 100),
-                Renderer = renderer
+                Renderer = renderer.Object
             };
 
             bool clicked = false;
@@ -166,16 +166,16 @@ namespace Avalonia.Controls.UnitTests
         [Fact]
         public void Button_Does_Not_Raise_Click_When_PointerReleased_Outside()
         {
-            var renderer = Mock.Of<IRenderer>();
-            
-            Mock.Get(renderer).Setup(r => r.HitTest(It.IsAny<Point>(), It.IsAny<Visual>(), It.IsAny<Func<Visual, bool>>()))
+            var renderer = RendererMocks.CreateRenderer();
+
+            renderer.Setup(r => r.HitTest(It.IsAny<Point>(), It.IsAny<Visual>(), It.IsAny<Func<Visual, bool>>()))
                 .Returns<Point, Visual, Func<Visual, bool>>((p, r, f) =>
                     r.Bounds.Contains(p) ? new Visual[] { r } : new Visual[0]);
 
             var target = new TestButton()
             {
                 Bounds = new Rect(0, 0, 100, 100),
-                Renderer = renderer
+                Renderer = renderer.Object
             };
 
             bool clicked = false;
@@ -199,9 +199,9 @@ namespace Avalonia.Controls.UnitTests
         [Fact]
         public void Button_With_RenderTransform_Raises_Click()
         {
-            var renderer = Mock.Of<IRenderer>();
+            var renderer = RendererMocks.CreateRenderer();
             var pt = new Point(150, 50);
-            Mock.Get(renderer).Setup(r => r.HitTest(It.IsAny<Point>(), It.IsAny<Visual>(), It.IsAny<Func<Visual, bool>>()))
+            renderer.Setup(r => r.HitTest(It.IsAny<Point>(), It.IsAny<Visual>(), It.IsAny<Func<Visual, bool>>()))
                 .Returns<Point, Visual, Func<Visual, bool>>((p, r, f) =>
                     r.Bounds.Contains(p.Transform(r.RenderTransform.Value.Invert())) ?
                     new Visual[] { r } : new Visual[0]);
@@ -210,7 +210,7 @@ namespace Avalonia.Controls.UnitTests
             {
                 Bounds = new Rect(0, 0, 100, 100),
                 RenderTransform = new TranslateTransform { X = 100, Y = 0 },
-                Renderer = renderer
+                Renderer = renderer.Object
             };
 
             //actual bounds of button should  be 100,0,100,100 x -> translated 100 pixels

+ 1 - 1
tests/Avalonia.Controls.UnitTests/ContextMenuTests.cs

@@ -595,7 +595,7 @@ namespace Avalonia.Controls.UnitTests
 
         private static Window PreparedWindow(object content = null)
         {
-            var renderer = new Mock<IRenderer>();
+            var renderer = RendererMocks.CreateRenderer();
             var platform = AvaloniaLocator.Current.GetRequiredService<IWindowingPlatform>();
             var windowImpl = Mock.Get(platform.CreateWindow());
             windowImpl.Setup(x => x.CreateRenderer(It.IsAny<IRenderRoot>())).Returns(renderer.Object);

+ 3 - 0
tests/Avalonia.Controls.UnitTests/DesktopStyleApplicationLifetimeTests.cs

@@ -2,6 +2,7 @@ using System;
 using System.Collections.Generic;
 using Avalonia.Controls.ApplicationLifetimes;
 using Avalonia.Platform;
+using Avalonia.Rendering;
 using Avalonia.UnitTests;
 using Moq;
 using Xunit;
@@ -189,6 +190,8 @@ namespace Avalonia.Controls.UnitTests
         public void Impl_Closing_Should_Remove_Window_From_OpenWindows()
         {
             var windowImpl = new Mock<IWindowImpl>();
+            windowImpl.Setup(x => x.CreateRenderer(It.IsAny<IRenderRoot>()))
+                .Returns(() => RendererMocks.CreateRenderer().Object);
             windowImpl.SetupProperty(x => x.Closed);
             windowImpl.Setup(x => x.DesktopScaling).Returns(1);
             windowImpl.Setup(x => x.RenderScaling).Returns(1);

+ 1 - 1
tests/Avalonia.Controls.UnitTests/FlyoutTests.cs

@@ -569,7 +569,7 @@ namespace Avalonia.Controls.UnitTests
 
         private static Window PreparedWindow(object content = null)
         {
-            var renderer = new Mock<IRenderer>();
+            var renderer = RendererMocks.CreateRenderer();
             var platform = AvaloniaLocator.Current.GetRequiredService<IWindowingPlatform>();
             var windowImpl = Mock.Get(platform.CreateWindow());
             windowImpl.Setup(x => x.CreateRenderer(It.IsAny<IRenderRoot>())).Returns(renderer.Object);

+ 1 - 1
tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs

@@ -563,7 +563,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
         {
             using (CreateServices())
             {
-                var renderer = new Mock<IRenderer>();
+                var renderer = RendererMocks.CreateRenderer();
                 var platform = AvaloniaLocator.Current.GetRequiredService<IWindowingPlatform>();
                 var windowImpl = Mock.Get(platform.CreateWindow());
                 windowImpl.Setup(x => x.CreateRenderer(It.IsAny<IRenderRoot>())).Returns(renderer.Object);

+ 15 - 6
tests/Avalonia.Controls.UnitTests/WindowBaseTests.cs

@@ -110,6 +110,8 @@ namespace Avalonia.Controls.UnitTests
         public void IsVisible_Should_Be_False_Atfer_Impl_Signals_Close()
         {
             var windowImpl = new Mock<IPopupImpl>();
+            windowImpl.Setup(x => x.CreateRenderer(It.IsAny<IRenderRoot>()))
+                .Returns(() => RendererMocks.CreateRenderer().Object);
             windowImpl.Setup(x => x.DesktopScaling).Returns(1);
             windowImpl.Setup(x => x.RenderScaling).Returns(1);
             windowImpl.SetupProperty(x => x.Closed);
@@ -129,6 +131,8 @@ namespace Avalonia.Controls.UnitTests
         public void Setting_IsVisible_True_Shows_Window()
         {
             var windowImpl = new Mock<IPopupImpl>();
+            windowImpl.Setup(x => x.CreateRenderer(It.IsAny<IRenderRoot>()))
+                .Returns(() => RendererMocks.CreateRenderer().Object);
             windowImpl.Setup(x => x.DesktopScaling).Returns(1);
             windowImpl.Setup(x => x.RenderScaling).Returns(1);
 
@@ -145,6 +149,8 @@ namespace Avalonia.Controls.UnitTests
         public void Setting_IsVisible_False_Hides_Window()
         {
             var windowImpl = new Mock<IPopupImpl>();
+            windowImpl.Setup(x => x.CreateRenderer(It.IsAny<IRenderRoot>()))
+                .Returns(() => RendererMocks.CreateRenderer().Object);
             windowImpl.Setup(x => x.DesktopScaling).Returns(1);
             windowImpl.Setup(x => x.RenderScaling).Returns(1);
 
@@ -163,7 +169,7 @@ namespace Avalonia.Controls.UnitTests
         {
             using (UnitTestApplication.Start(TestServices.StyledWindow))
             {
-                var renderer = new Mock<IRenderer>();
+                var renderer = RendererMocks.CreateRenderer();
                 var target = new TestWindowBase(renderer.Object);
 
                 target.Show();
@@ -194,7 +200,7 @@ namespace Avalonia.Controls.UnitTests
 
             using (UnitTestApplication.Start(TestServices.StyledWindow))
             {
-                var renderer = new Mock<IRenderer>();
+                var renderer = RendererMocks.CreateRenderer();
                 var target = new TestWindowBase(renderer.Object);
 
                 target.Show();
@@ -209,7 +215,7 @@ namespace Avalonia.Controls.UnitTests
         {
             using (UnitTestApplication.Start(TestServices.StyledWindow))
             {
-                var renderer = new Mock<IRenderer>();
+                var renderer = RendererMocks.CreateRenderer();
                 var windowImpl = new Mock<IPopupImpl>();
                 windowImpl.Setup(x => x.DesktopScaling).Returns(1);
                 windowImpl.Setup(x => x.RenderScaling).Returns(1);
@@ -240,12 +246,15 @@ namespace Avalonia.Controls.UnitTests
             public bool IsClosed { get; private set; }
 
             public TestWindowBase(IRenderer renderer = null)
-                : base(Mock.Of<IWindowBaseImpl>(x => 
-                    x.RenderScaling == 1 &&
-                    x.CreateRenderer(It.IsAny<IRenderRoot>()) == renderer))
+                : base(CreateWindowsBaseImplMock(renderer ?? RendererMocks.CreateRenderer().Object))
             {
             }
 
+            private static IWindowBaseImpl CreateWindowsBaseImplMock(IRenderer renderer)
+                => Mock.Of<IWindowBaseImpl>(x =>
+                    x.RenderScaling == 1 &&
+                    x.CreateRenderer(It.IsAny<IRenderRoot>()) == renderer);
+
             public TestWindowBase(IWindowBaseImpl impl)
                 : base(impl)
             {

+ 9 - 3
tests/Avalonia.Controls.UnitTests/WindowTests.cs

@@ -98,6 +98,8 @@ namespace Avalonia.Controls.UnitTests
         public void IsVisible_Should_Be_False_After_Impl_Signals_Close()
         {
             var windowImpl = new Mock<IWindowImpl>();
+            windowImpl.Setup(x => x.CreateRenderer(It.IsAny<IRenderRoot>()))
+                .Returns(() => RendererMocks.CreateRenderer().Object);
             windowImpl.SetupProperty(x => x.Closed);
             windowImpl.Setup(x => x.DesktopScaling).Returns(1);
             windowImpl.Setup(x => x.RenderScaling).Returns(1);
@@ -269,7 +271,7 @@ namespace Avalonia.Controls.UnitTests
         {
             using (UnitTestApplication.Start(TestServices.StyledWindow))
             {
-                var renderer = new Mock<IRenderer>();
+                var renderer = RendererMocks.CreateRenderer();
                 var target = new Window(CreateImpl(renderer));
 
                 target.Show();
@@ -284,7 +286,7 @@ namespace Avalonia.Controls.UnitTests
             using (UnitTestApplication.Start(TestServices.StyledWindow))
             {
                 var parent = new Window();
-                var renderer = new Mock<IRenderer>();
+                var renderer = RendererMocks.CreateRenderer();
                 var target = new Window(CreateImpl(renderer));
 
                 parent.Show();
@@ -317,7 +319,7 @@ namespace Avalonia.Controls.UnitTests
         {
             using (UnitTestApplication.Start(TestServices.StyledWindow))
             {
-                var renderer = new Mock<IRenderer>();
+                var renderer = RendererMocks.CreateRenderer();
                 var target = new Window(CreateImpl(renderer));
 
                 target.Show();
@@ -334,6 +336,8 @@ namespace Avalonia.Controls.UnitTests
             {
                 var parent = new Window();
                 var windowImpl = new Mock<IWindowImpl>();
+                windowImpl.Setup(x => x.CreateRenderer(It.IsAny<IRenderRoot>()))
+                    .Returns(() => RendererMocks.CreateRenderer().Object);
                 windowImpl.SetupProperty(x => x.Closed);
                 windowImpl.Setup(x => x.DesktopScaling).Returns(1);
                 windowImpl.Setup(x => x.RenderScaling).Returns(1);
@@ -375,6 +379,8 @@ namespace Avalonia.Controls.UnitTests
             {
                 var parent = new Window();
                 var windowImpl = new Mock<IWindowImpl>();
+                windowImpl.Setup(x => x.CreateRenderer(It.IsAny<IRenderRoot>()))
+                    .Returns(() => RendererMocks.CreateRenderer().Object);
                 windowImpl.SetupProperty(x => x.Closed);
                 windowImpl.Setup(x => x.DesktopScaling).Returns(1);
                 windowImpl.Setup(x => x.RenderScaling).Returns(1);

+ 1 - 42
tests/Avalonia.LeakTests/ControlTests.cs

@@ -462,7 +462,7 @@ namespace Avalonia.LeakTests
         {
             using (Start())
             {
-                var renderer = new Mock<IRenderer>();
+                var renderer = RendererMocks.CreateRenderer();
                 renderer.Setup(x => x.Dispose());
                 var impl = new Mock<IWindowImpl>();
                 impl.Setup(r => r.TryGetFeature(It.IsAny<Type>())).Returns(null);
@@ -1029,46 +1029,5 @@ namespace Avalonia.LeakTests
             public IEnumerable<Node> Children { get; set; }
         }
 
-        private class NullRenderer : IRenderer
-        {
-            public bool DrawFps { get; set; }
-            public bool DrawDirtyRects { get; set; }
-#pragma warning disable CS0067
-            public event EventHandler<SceneInvalidatedEventArgs> SceneInvalidated;
-#pragma warning restore CS0067
-            public void AddDirty(Visual visual)
-            {
-            }
-
-            public void Dispose()
-            {
-            }
-
-            public IEnumerable<Visual> HitTest(Point p, Visual root, Func<Visual, bool> filter) => null;
-
-            public Visual HitTestFirst(Point p, Visual root, Func<Visual, bool> filter) => null;
-
-            public void Paint(Rect rect)
-            {
-            }
-
-            public void RecalculateChildren(Visual visual)
-            {
-            }
-
-            public void Resized(Size size)
-            {
-            }
-
-            public void Start()
-            {
-            }
-
-            public void Stop()
-            {
-            }
-
-            public ValueTask<object> TryGetRenderInterfaceFeature(Type featureType) => new(null);
-        }
     }
 }

+ 4 - 1
tests/Avalonia.UnitTests/MockWindowingPlatform.cs

@@ -1,6 +1,5 @@
 using System;
 using Avalonia.Controls.Primitives.PopupPositioning;
-using Avalonia.Input;
 using Moq;
 using Avalonia.Platform;
 using Avalonia.Rendering;
@@ -28,6 +27,8 @@ namespace Avalonia.UnitTests
             var clientSize = new Size(initialWidth,  initialHeight);
 
             windowImpl.SetupAllProperties();
+            windowImpl.Setup(x => x.CreateRenderer(It.IsAny<IRenderRoot>()))
+                .Returns(() => RendererMocks.CreateRenderer().Object);
             windowImpl.Setup(x => x.ClientSize).Returns(() => clientSize);
             windowImpl.Setup(x => x.MaxAutoSizeHint).Returns(s_screenSize);
             windowImpl.Setup(x => x.DesktopScaling).Returns(1);
@@ -92,6 +93,8 @@ namespace Avalonia.UnitTests
             var positioner = new ManagedPopupPositioner(positionerHelper);
 
             popupImpl.SetupAllProperties();
+            popupImpl.Setup(x => x.CreateRenderer(It.IsAny<IRenderRoot>()))
+                .Returns(() => RendererMocks.CreateRenderer().Object);
             popupImpl.Setup(x => x.ClientSize).Returns(() => clientSize);
             popupImpl.Setup(x => x.MaxAutoSizeHint).Returns(s_screenSize);
             popupImpl.Setup(x => x.RenderScaling).Returns(1);

+ 57 - 0
tests/Avalonia.UnitTests/NullRenderer.cs

@@ -0,0 +1,57 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia.Rendering;
+
+namespace Avalonia.UnitTests;
+
+public sealed class NullRenderer : IRenderer
+{
+    public RendererDiagnostics Diagnostics { get; } = new();
+
+    public event EventHandler<SceneInvalidatedEventArgs>? SceneInvalidated;
+
+    public NullRenderer()
+    {
+    }
+
+    public void AddDirty(Visual visual)
+    {
+    }
+
+    public void Dispose()
+    {
+    }
+
+    public IEnumerable<Visual> HitTest(Point p, Visual root, Func<Visual, bool> filter)
+        => Enumerable.Empty<Visual>();
+
+    public Visual? HitTestFirst(Point p, Visual root, Func<Visual, bool> filter)
+        => null;
+
+    public void Paint(Rect rect)
+    {
+    }
+
+    public void RecalculateChildren(Visual visual)
+    {
+    }
+
+    public void Resized(Size size)
+    {
+    }
+
+    public void Start()
+    {
+    }
+
+    public void Stop()
+    {
+    }
+
+    public ValueTask<object?> TryGetRenderInterfaceFeature(Type featureType)
+        => new((object?) null);
+}

+ 15 - 0
tests/Avalonia.UnitTests/RendererMocks.cs

@@ -0,0 +1,15 @@
+using Avalonia.Rendering;
+using Moq;
+
+namespace Avalonia.UnitTests
+{
+    public static class RendererMocks
+    {
+        public static Mock<IRenderer> CreateRenderer()
+        {
+            var renderer = new Mock<IRenderer>();
+            renderer.SetupGet(r => r.Diagnostics).Returns(new RendererDiagnostics());
+            return renderer;
+        }
+    }
+}

+ 1 - 3
tests/Avalonia.UnitTests/TestRoot.cs

@@ -1,9 +1,7 @@
-using System;
 using Avalonia.Controls;
 using Avalonia.Input;
 using Avalonia.Layout;
 using Avalonia.LogicalTree;
-using Avalonia.Media;
 using Avalonia.Platform;
 using Avalonia.Rendering;
 using Avalonia.Styling;
@@ -18,7 +16,7 @@ namespace Avalonia.UnitTests
 
         public TestRoot()
         {
-            Renderer = Mock.Of<IRenderer>();
+            Renderer = RendererMocks.CreateRenderer().Object;
             LayoutManager = new LayoutManager(this);
             IsVisible = true;
             KeyboardNavigation.SetTabNavigation(this, KeyboardNavigationMode.Cycle);