Repository: unoplatform/uno
Pull request #20298: feat(skia): collapse visual subtrees into one SKPicture when the subtree doesn't change for a bit
Author: login: ramezgerges
Labels: name: platform/wasm 🌐
description: Categorizes an issue or PR as relevant to the WebAssembly platform
name: platform/android 🤖
description: Categorizes an issue or PR as relevant to the Android platform
name: platform/macos 🍏
description: Categorizes an issue or PR as relevant to the macOS platform
name: platform/ios 🍎
description: Categorizes an issue or PR as relevant to the iOS platform
name: area/skia ✏️
description: Categorizes an issue or PR as relevant to Skia
name: area/automation
description: Categorizes an issue or PR as relevant to project automation
name: platform/wpf 🪟
description: Categorizes an issue or PR as relevant to WPF
name: platform/x11 🐧
description: Categorizes an issue or PR as relevant to X11
Created: 2025-05-09T17:10:32Z | Merged: 2025-05-23T00:35:15Z

GitHub Issue (If applicable): closes https://github.com/unoplatform/uno/issues/20282

<!-- Link to relevant GitHub issue if applicable. All PRs should be associated with an issue (GitHub issue or internal), unless the change is documentation related. -->

## PR Type

What kind of change does this PR introduce?
<!-- Please uncomment one or more that apply to this PR

- Bugfix
- Feature
- Code style update (formatting)
- Refactoring (no functional changes, no api changes)
- Build or CI related changes
- Documentation content changes
- Project automation
- Other... Please describe:

-->

## What is the current behavior?

<!-- Please describe the current behavior that you are modifying, or link to a relevant issue. -->


## What is the new behavior?

<!-- Please describe the new behavior after your modifications. -->

## PR Checklist

Please check if your PR fulfills the following requirements:

- [ ] Docs have been added/updated which fit [documentation template](https://github.com/unoplatform/uno/blob/master/doc/.feature-template.md) (for bug fixes / features)
- [ ] [Unit Tests and/or UI Tests](https://github.com/unoplatform/uno/blob/master/doc/articles/uno-development/working-with-the-samples-apps.md) for the changes have been added (for bug fixes / features) (if applicable)
- [ ] Validated PR `Screenshots Compare Test Run` results.
- [ ] Contains **NO** breaking changes
- [ ] Associated with an issue (GitHub or internal) and uses the [automatic close keywords](https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue).
- [ ] Commits must be following the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) specification.

<!-- If this PR contains a breaking change, please describe the impact and migration path for existing applications below.
     Please note that breaking changes are likely to be rejected -->

## Other information

<!-- Please provide any additional information if necessary -->

Internal Issue (If applicable):
<!-- Link to relevant internal issue if applicable. All PRs should be associated with an issue (GitHub issue or internal) -->



Diff:
diff --git a/src/SamplesApp/SamplesApp.Shared/App.xaml.cs b/src/SamplesApp/SamplesApp.Shared/App.xaml.cs
index a4f51a0f7721..863a202e749d 100644
--- a/src/SamplesApp/SamplesApp.Shared/App.xaml.cs
+++ b/src/SamplesApp/SamplesApp.Shared/App.xaml.cs
@@ -97,6 +97,8 @@ public App()
 			ConfigureFeatureFlags();
 			ParseCommandLineFeatureFlags();
 
+			DebugSettings.EnableFrameRateCounter = true;
+
 			AssertIssue1790ApplicationSettingsUsable();
 			AssertApplicationData();
 
diff --git a/src/SamplesApp/SamplesApp.UnitTests.Shared/Controls/UITests/Presentation/SampleChooserViewMode.Properties.cs b/src/SamplesApp/SamplesApp.UnitTests.Shared/Controls/UITests/Presentation/SampleChooserViewMode.Properties.cs
index d3dd75bd0006..d94e22f97ecd 100644
--- a/src/SamplesApp/SamplesApp.UnitTests.Shared/Controls/UITests/Presentation/SampleChooserViewMode.Properties.cs
+++ b/src/SamplesApp/SamplesApp.UnitTests.Shared/Controls/UITests/Presentation/SampleChooserViewMode.Properties.cs
@@ -516,6 +516,12 @@ public bool UseRtl
 			}
 		}
 
+		public bool ShowFpsIndicator
+		{
+			get => Application.Current.DebugSettings.EnableFrameRateCounter;
+			set => Application.Current.DebugSettings.EnableFrameRateCounter = value;
+		}
+
 #if HAS_UNO
 		public bool SimulateTouch
 		{
diff --git a/src/SamplesApp/SamplesApp.UnitTests.Shared/Controls/UITests/Presentation/SampleChooserViewModel.cs b/src/SamplesApp/SamplesApp.UnitTests.Shared/Controls/UITests/Presentation/SampleChooserViewModel.cs
index ac54618255f4..5d641f35c773 100644
--- a/src/SamplesApp/SamplesApp.UnitTests.Shared/Controls/UITests/Presentation/SampleChooserViewModel.cs
+++ b/src/SamplesApp/SamplesApp.UnitTests.Shared/Controls/UITests/Presentation/SampleChooserViewModel.cs
@@ -150,7 +150,7 @@ public SampleChooserViewModel(SampleChooserControl owner)
 		/// <returns></returns>
 		private void ShowNewSection(CancellationToken ct, Section section)
 		{
-			Console.WriteLine($"Section changed: {section}");
+			Console.WriteLine($"Section changed: {section} ({GetMemoryStats()})");
 
 			switch (section)
 			{
@@ -175,6 +175,12 @@ private void ShowNewSection(CancellationToken ct, Section section)
 			}
 		}
 
+		private string GetMemoryStats()
+		{
+			var totalMemory = GC.GetTotalMemory(false);
+			return $"GC Heap: {totalMemory / 1024.0 / 1024.0:0.00} MB";
+		}
+
 		/// <summary>
 		/// Goes back one page.
 		/// </summary>
diff --git a/src/SamplesApp/SamplesApp.UnitTests.Shared/Controls/UITests/Views/Controls/SampleChooserControl.xaml b/src/SamplesApp/SamplesApp.UnitTests.Shared/Controls/UITests/Views/Controls/SampleChooserControl.xaml
index b363ae9a2adc..3877bab83df3 100644
--- a/src/SamplesApp/SamplesApp.UnitTests.Shared/Controls/UITests/Views/Controls/SampleChooserControl.xaml
+++ b/src/SamplesApp/SamplesApp.UnitTests.Shared/Controls/UITests/Views/Controls/SampleChooserControl.xaml
@@ -571,6 +571,11 @@ BorderBrush="{StaticResource Color05Brush}">
 										<FontIcon FontFamily="{ThemeResource SymbolThemeFontFamily}" Glyph="&#xE815;" />
 									</not_win:ToggleMenuFlyoutItem.Icon>
 								</not_win:ToggleMenuFlyoutItem>
+								<ToggleMenuFlyoutItem Text="Show Fps Indicator" IsChecked="{Binding ShowFpsIndicator, Mode=TwoWay}">
+									<ToggleMenuFlyoutItem.Icon>
+										<FontIcon FontFamily="{ThemeResource SymbolThemeFontFamily}" Glyph="&#xE815;" />
+									</ToggleMenuFlyoutItem.Icon>
+								</ToggleMenuFlyoutItem>
 								<MenuFlyoutItem Text="Log view dump" Command="{Binding LogViewDumpCommand}" Visibility="{Binding IsDebug, Converter={StaticResource TrueToVisible}}">
 									<MenuFlyoutItem.Icon>
 										<FontIcon FontFamily="{ThemeResource SymbolThemeFontFamily}" Glyph="&#xEBD2;" />
diff --git a/src/SamplesApp/UITests.Shared/UITests.Shared.projitems b/src/SamplesApp/UITests.Shared/UITests.Shared.projitems
index 4b5c46a0fb16..55e06403fed7 100644
--- a/src/SamplesApp/UITests.Shared/UITests.Shared.projitems
+++ b/src/SamplesApp/UITests.Shared/UITests.Shared.projitems
@@ -1214,6 +1214,10 @@
       <SubType>Designer</SubType>
       <Generator>MSBuild:Compile</Generator>
     </Page>
+    <Page Include="$(MSBuildThisFileDirectory)Windows_UI_Xaml\Performance\Performance_1000ButtonsContinuousRendering.xaml">
+      <SubType>Designer</SubType>
+      <Generator>MSBuild:Compile</Generator>
+    </Page>
     <Page Include="$(MSBuildThisFileDirectory)Windows_UI_Xaml\Properties\DataContextProperty.xaml">
       <SubType>Designer</SubType>
       <Generator>MSBuild:Compile</Generator>
@@ -6488,6 +6492,9 @@
     <Compile Include="$(MSBuildThisFileDirectory)Windows_UI_Xaml\Performance\Performance_Dopes.xaml.cs">
       <DependentUpon>Performance_Dopes.xaml</DependentUpon>
     </Compile>
+    <Compile Include="$(MSBuildThisFileDirectory)Windows_UI_Xaml\Performance\Performance_1000ButtonsContinuousRendering.xaml.cs">
+      <DependentUpon>Performance_1000ButtonsContinuousRendering.xaml</DependentUpon>
+    </Compile>
     <Compile Include="$(MSBuildThisFileDirectory)Windows_UI_Xaml\StyleTests\Style_Inline.xaml.cs">
       <DependentUpon>Style_Inline.xaml</DependentUpon>
     </Compile>
diff --git a/src/SamplesApp/UITests.Shared/Windows_UI_Xaml/Performance/Performance_1000ButtonsContinuousRendering.xaml b/src/SamplesApp/UITests.Shared/Windows_UI_Xaml/Performance/Performance_1000ButtonsContinuousRendering.xaml
new file mode 100644
index 000000000000..4dc9eaec9fea
--- /dev/null
+++ b/src/SamplesApp/UITests.Shared/Windows_UI_Xaml/Performance/Performance_1000ButtonsContinuousRendering.xaml
@@ -0,0 +1,37 @@
+﻿<Page
+    x:Class="UITests.Windows_UI_Xaml.Performance.Performance_1000ButtonsContinuousRendering"
+    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+    xmlns:local="using:UITests.Windows_UI_Xaml.Performance"
+    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+    xmlns:not_win="http://uno.ui/not_win"
+    mc:Ignorable="d not_win"
+    Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
+	<Page.Resources>
+		<SolidColorBrush x:Key="ColorAnimation_Background_Brush"
+		                 Color="Blue" />
+		<Storyboard x:Name="colorStoryboard">
+			<ColorAnimation Duration="0:00:05"
+			                RepeatBehavior="Forever"
+			                Storyboard.TargetName="TargetBorder"
+			                Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)"
+			                To="Red" />
+		</Storyboard>
+	</Page.Resources>
+
+	<StackPanel>
+		<StackPanel Orientation="Horizontal">
+			<TextBlock Text="A border that has a ColorAnimation to keep rendering active: " />
+			<Border x:Name="TargetBorder" Width="10" Height="10" Background="{StaticResource ColorAnimation_Background_Brush}" />
+		</StackPanel>
+		<StackPanel Orientation="Horizontal">
+			<TextBlock Text="Number of Buttons: " />
+			<NumberBox Minimum="1" ValueChanged="NumberBoxValueChanged" Value="100" />
+		</StackPanel>
+		<TextBlock x:Name="tb" Text="Number of visuals in WrapPanel: N/A" />
+		<Viewbox Width="500" Height="300">
+			<not_win:WrapPanel Width="500" x:Name="wp" Orientation="Horizontal" />
+		</Viewbox>
+	</StackPanel>
+</Page>
diff --git a/src/SamplesApp/UITests.Shared/Windows_UI_Xaml/Performance/Performance_1000ButtonsContinuousRendering.xaml.cs b/src/SamplesApp/UITests.Shared/Windows_UI_Xaml/Performance/Performance_1000ButtonsContinuousRendering.xaml.cs
new file mode 100644
index 000000000000..6786eacdfc43
--- /dev/null
+++ b/src/SamplesApp/UITests.Shared/Windows_UI_Xaml/Performance/Performance_1000ButtonsContinuousRendering.xaml.cs
@@ -0,0 +1,52 @@
+﻿using System;
+using System.Threading.Tasks;
+using Microsoft.UI.Composition;
+using Uno.UI.Samples.Controls;
+using Microsoft.UI.Xaml.Controls;
+
+// The Blank Page item template is documented at https://go.microsoft.com/fwlink/?LinkId=234238
+
+namespace UITests.Windows_UI_Xaml.Performance
+{
+	/// <summary>
+	/// An empty page that can be used on its own or navigated to within a Frame.
+	/// </summary>
+	[Sample("Performance")]
+	public sealed partial class Performance_1000ButtonsContinuousRendering : Page
+	{
+		public Performance_1000ButtonsContinuousRendering()
+		{
+			this.InitializeComponent();
+
+			Loaded += (s, e) =>
+			{
+				colorStoryboard.Begin();
+#if __SKIA__
+				NumberBoxValueChanged(this, new NumberBoxValueChangedEventArgs(0, 100));
+#endif
+			};
+
+			Unloaded += (s, e) =>
+			{
+				colorStoryboard.Stop();
+			};
+		}
+
+		private async void NumberBoxValueChanged(object sender, NumberBoxValueChangedEventArgs e)
+		{
+#if __SKIA__
+			wp.Children.Clear();
+			var val = (int)Math.Round(Math.Max(0, e.NewValue));
+			for (var i = 0; i < val; i++)
+			{
+				wp.Children.Add(new Button { Content = i.ToString() });
+			}
+
+			await Task.Delay(TimeSpan.FromSeconds(1));
+			tb.Text = $"Number of visuals in WrapPanel: {wp.Visual.GetSubTreeVisualCount()}";
+#else
+			await Task.CompletedTask;
+#endif
+		}
+	}
+}
diff --git a/src/SamplesApp/UITests.Shared/Windows_UI_Xaml_Controls/ImageTests/SvgImageSource_Icons.xaml.cs b/src/SamplesApp/UITests.Shared/Windows_UI_Xaml_Controls/ImageTests/SvgImageSource_Icons.xaml.cs
index d7e7039d239d..b7cdf290a79b 100644
--- a/src/SamplesApp/UITests.Shared/Windows_UI_Xaml_Controls/ImageTests/SvgImageSource_Icons.xaml.cs
+++ b/src/SamplesApp/UITests.Shared/Windows_UI_Xaml_Controls/ImageTests/SvgImageSource_Icons.xaml.cs
@@ -17,7 +17,7 @@
 namespace UITests.Windows_UI_Xaml_Controls.ImageTests;
 
 [Sample("Image"
-#if __SKIA
+#if __SKIA__
 	, IgnoreInSnapshotTests = true
 #endif
 	)]
diff --git a/src/Uno.UI.Composition/Composition/Compositor.skia.cs b/src/Uno.UI.Composition/Composition/Compositor.skia.cs
index 8b8bd8bf9fab..19133f26385d 100644
--- a/src/Uno.UI.Composition/Composition/Compositor.skia.cs
+++ b/src/Uno.UI.Composition/Composition/Compositor.skia.cs
@@ -1,4 +1,5 @@
-﻿#nullable enable
+﻿// #define PRINT_FRAME_TIMES
+#nullable enable
 
 using System;
 using System.Collections.Generic;
diff --git a/src/Uno.UI.Composition/Composition/ContainerVisual.skia.cs b/src/Uno.UI.Composition/Composition/ContainerVisual.skia.cs
index 2b0a72135d39..c6d9d0582673 100644
--- a/src/Uno.UI.Composition/Composition/ContainerVisual.skia.cs
+++ b/src/Uno.UI.Composition/Composition/ContainerVisual.skia.cs
@@ -14,6 +14,7 @@ public partial class ContainerVisual : Visual
 {
 	private List<Visual>? _childrenInRenderOrder;
 	private bool _hasCustomRenderOrder;
+	private int? _subtreeVisualCount;
 
 	private (Rect rect, bool isAncestorClip)? _layoutClip;
 
@@ -25,6 +26,13 @@ partial void InitializePartial()
 		{
 			IsChildrenRenderOrderDirty = true;
 
+			var parent = this;
+			while (parent is not null && parent._subtreeVisualCount is not null)
+			{
+				parent._subtreeVisualCount = null;
+				parent = parent.Parent;
+			}
+
 			if (e.Action is NotifyCollectionChangedAction.Remove or NotifyCollectionChangedAction.Reset
 				&& e.OldItems is not null)
 			{
@@ -161,4 +169,20 @@ internal override bool SetMatrixDirty()
 
 		return false;
 	}
+
+	internal override int GetSubTreeVisualCount()
+	{
+		if (_subtreeVisualCount is { } count)
+		{
+			return count;
+		}
+		var acc = 0;
+		foreach (var visual in Children.InnerList)
+		{
+			acc += visual.GetSubTreeVisualCount();
+		}
+		_subtreeVisualCount ??= Children.Count + acc;
+
+		return _subtreeVisualCount.Value;
+	}
 }
diff --git a/src/Uno.UI.Composition/Composition/SingleFrameProvider.skia.cs b/src/Uno.UI.Composition/Composition/SingleFrameProvider.skia.cs
index c79aa8c022c0..5b4f81e39bac 100644
--- a/src/Uno.UI.Composition/Composition/SingleFrameProvider.skia.cs
+++ b/src/Uno.UI.Composition/Composition/SingleFrameProvider.skia.cs
@@ -36,17 +36,7 @@ public void Dispose()
 				_disposed = true;
 				_image.Dispose();
 				GC.RemoveMemoryPressure(_bytes);
-				GC.SuppressFinalize(this);
-			}
-			else
-			{
-				this.LogError()?.Error("Detected a double dispose.");
 			}
 		}
 	}
-
-	~SingleFrameProvider()
-	{
-		Dispose();
-	}
 }
diff --git a/src/Uno.UI.Composition/Composition/Visual.skia.cs b/src/Uno.UI.Composition/Composition/Visual.skia.cs
index a3c30ba8c15c..b0ceb0ef93a0 100644
--- a/src/Uno.UI.Composition/Composition/Visual.skia.cs
+++ b/src/Uno.UI.Composition/Composition/Visual.skia.cs
@@ -12,7 +12,6 @@
 using Uno.Disposables;
 using Uno.Extensions;
 using Uno.Helpers;
-using Uno.UI.Composition;
 using Uno.UI.Composition.Composition;
 
 namespace Microsoft.UI.Composition;
@@ -25,6 +24,14 @@ public partial class Visual : global::Microsoft.UI.Composition.CompositionObject
 	private static readonly IPrivateSessionFactory _factory = new PaintingSession.SessionFactory();
 	private static readonly List<Visual> s_emptyList = new List<Visual>();
 
+	internal static bool EnablePictureCollapsingOptimization { get; set; } = true;
+	internal static int PictureCollapsingOptimizationFrameThreshold { get; set; } = 50;
+	internal static int PictureCollapsingOptimizationVisualCountThreshold { get; set; } = 100;
+
+	private bool _enablePictureCollapsingOptimization;
+	private int _pictureCollapsingOptimizationFrameThreshold;
+	private int _pictureCollapsingOptimizationVisualCountThreshold;
+
 	// Since painting (and recording) is done on the UI thread, we need a single SKPictureRecorder per UI thread.
 	// If we move to a UI-thread-per-window model, then we need multiple recorders.
 	[ThreadStatic]
@@ -35,8 +42,10 @@ public partial class Visual : global::Microsoft.UI.Composition.CompositionObject
 	private int _zIndex;
 	private (Matrix4x4 matrix, bool isLocalMatrixIdentity) _totalMatrix = (Matrix4x4.Identity, true);
 	private SKPicture? _picture;
+	private SKPicture? _childrenPicture;
+	private int _framesSinceSubtreeNotChanged;
 
-	private VisualFlags _flags = VisualFlags.MatrixDirty | VisualFlags.PaintDirty;
+	private VisualFlags _flags = VisualFlags.MatrixDirty | VisualFlags.PaintDirty | VisualFlags.ChildrenSKPictureInvalid;
 
 	internal bool IsNativeHostVisual => (_flags & VisualFlags.IsNativeHostVisualSet) != 0 ? (_flags & VisualFlags.IsNativeHostVisual) != 0 : (_flags & VisualFlags.IsNativeHostVisualInherited) != 0;
 
@@ -79,6 +88,13 @@ private void SetAsNativeHostVisual(bool? isNativeHostVisual, bool inherited)
 		}
 	}
 
+	partial void InitializePartial()
+	{
+		_enablePictureCollapsingOptimization = EnablePictureCollapsingOptimization;
+		_pictureCollapsingOptimizationFrameThreshold = PictureCollapsingOptimizationFrameThreshold;
+		_pictureCollapsingOptimizationVisualCountThreshold = PictureCollapsingOptimizationVisualCountThreshold;
+	}
+
 	/// <summary>
 	/// Identifies whether a Visual can paint things. For example, ContainerVisuals don't
 	/// paint on their own (even though they might contain other Visuals that do).
@@ -96,6 +112,7 @@ internal virtual bool SetMatrixDirty()
 	{
 		var matrixDirty = (_flags & VisualFlags.MatrixDirty) != 0;
 		_flags |= VisualFlags.MatrixDirty;
+		InvalidateParentChildrenPicture();
 		return !matrixDirty;
 	}
 
@@ -190,6 +207,19 @@ internal void InvalidatePaint()
 		_picture?.Dispose();
 		_picture = null;
 		_flags |= VisualFlags.PaintDirty;
+		InvalidateParentChildrenPicture();
+	}
+
+	private void InvalidateParentChildrenPicture()
+	{
+		var parent = this.Parent;
+		while (parent is not null && (parent._flags & VisualFlags.ChildrenSKPictureInvalid) == 0)
+		{
+			parent._childrenPicture?.Dispose();
+			parent._childrenPicture = null;
+			parent._flags |= VisualFlags.ChildrenSKPictureInvalid;
+			parent = parent.Parent;
+		}
 	}
 
 	public CompositionClip? Clip
@@ -285,7 +315,7 @@ internal void RenderRootVisual(SKCanvas canvas, Vector2? offsetOverride)
 	/// Position a sub visual on the canvas and draw its content.
 	/// </summary>
 	/// <param name="parentSession">The drawing session of the <see cref="Parent"/> visual.</param>
-	private void Render(in PaintingSession parentSession)
+	private void Render(in PaintingSession parentSession, bool applyChildOptimization = true)
 	{
 #if TRACE_COMPOSITION
 		var indent = int.TryParse(Comment?.Split(new char[] { '-' }, 2, StringSplitOptions.TrimEntries).FirstOrDefault(), out var depth)
@@ -299,6 +329,16 @@ private void Render(in PaintingSession parentSession)
 			return;
 		}
 
+		if ((_flags & VisualFlags.ChildrenSKPictureInvalid) == 0)
+		{
+			_framesSinceSubtreeNotChanged++;
+		}
+		else
+		{
+			_framesSinceSubtreeNotChanged = 0;
+			_flags &= ~VisualFlags.ChildrenSKPictureInvalid;
+		}
+
 		CreateLocalSession(in parentSession, out var session);
 
 		using (session)
@@ -318,10 +358,7 @@ private void Render(in PaintingSession parentSession)
 			{
 				PaintStep(this, session);
 				PostPaintingClipStep(this, canvas);
-				foreach (var child in GetChildrenInRenderOrder())
-				{
-					child.Render(in session);
-				}
+				RenderChildrenStep(this, session, applyChildOptimization);
 			}
 			else
 			{
@@ -334,10 +371,7 @@ private void Render(in PaintingSession parentSession)
 				{
 					PaintStep(this, childSession);
 					PostPaintingClipStep(this, recordingCanvas);
-					foreach (var child in GetChildrenInRenderOrder())
-					{
-						child.Render(in childSession);
-					}
+					RenderChildrenStep(this, childSession, applyChildOptimization);
 				}
 				var childrenPicture = recorder.EndRecording();
 				canvas.DrawPicture(childrenPicture, ShadowState.ShadowOnlyPaint);
@@ -387,6 +421,41 @@ static void PostPaintingClipStep(Visual visual, SKCanvas canvas)
 				canvas.ClipPath(postClip, antialias: true);
 			}
 		}
+
+		static void RenderChildrenStep(Visual visual, PaintingSession session, bool applyChildOptimization)
+		{
+			if (visual._childrenPicture is not null)
+			{
+				session.Canvas.DrawPicture(visual._childrenPicture);
+			}
+			else if (!visual._enablePictureCollapsingOptimization
+					 || visual._framesSinceSubtreeNotChanged < visual._pictureCollapsingOptimizationFrameThreshold
+					 || !applyChildOptimization
+					 || visual.GetSubTreeVisualCount() < visual._pictureCollapsingOptimizationVisualCountThreshold)
+			{
+				foreach (var child in visual.GetChildrenInRenderOrder())
+				{
+					child.Render(in session, applyChildOptimization);
+				}
+			}
+			else
+			{
+				var recorder = new SKPictureRecorder();
+				var recordingCanvas = recorder.BeginRecording(new SKRect(-999999, -999999, 999999, 999999));
+				// child.Render will reapply the total transform matrix, so we need to invert ours.
+				Matrix4x4.Invert(visual.TotalMatrix, out var rootTransform);
+				_factory.CreateInstance(visual, recordingCanvas, ref rootTransform, session.Opacity, out var childSession);
+				using (childSession)
+				{
+					foreach (var child in visual.GetChildrenInRenderOrder())
+					{
+						child.Render(in childSession, applyChildOptimization: false);
+					}
+				}
+				visual._childrenPicture = recorder.EndRecording();
+				session.Canvas.DrawPicture(visual._childrenPicture);
+			}
+		}
 	}
 
 	internal void GetNativeViewPath(SKPath clipFromParent, SKPath outPath)
@@ -473,6 +542,8 @@ internal virtual bool GetPrePaintingClipping(SKPath dst)
 	/// <remarks>You should NOT mutate the list returned by this method.</remarks>
 	internal List<Visual> GetChildrenInRenderOrderTestingOnly() => GetChildrenInRenderOrder();
 
+	internal virtual int GetSubTreeVisualCount() => 1;
+
 	/// <summary>
 	/// Creates a new <see cref="PaintingSession"/> set up with the local coordinates and opacity.
 	/// </summary>
@@ -518,5 +589,6 @@ internal enum VisualFlags : byte
 		IsNativeHostVisualInherited = 4,
 		MatrixDirty = 8,
 		PaintDirty = 16,
+		ChildrenSKPictureInvalid = 32, // some child in the subtree of this visual is dirty.
 	}
 }
diff --git a/src/Uno.UI.Runtime.Skia.Android/Microsoft/UI/Xaml/UnoSKCanvasView.cs b/src/Uno.UI.Runtime.Skia.Android/Microsoft/UI/Xaml/UnoSKCanvasView.cs
index 7826670968a9..1fed0397ebe7 100644
--- a/src/Uno.UI.Runtime.Skia.Android/Microsoft/UI/Xaml/UnoSKCanvasView.cs
+++ b/src/Uno.UI.Runtime.Skia.Android/Microsoft/UI/Xaml/UnoSKCanvasView.cs
@@ -1,6 +1,4 @@
-﻿// #define FPS_DISPLAY
-
-using System;
+﻿using System;
 using System.Threading;
 using Android.Content;
 using Android.Graphics;
@@ -25,12 +23,7 @@ namespace Uno.UI.Runtime.Skia.Android;
 
 internal sealed class UnoSKCanvasView : GLSurfaceView
 {
-#if FPS_DISPLAY
-	private long _counter;
-	private DateTime _time = DateTime.UtcNow;
-	private string _fpsText = "0";
-#endif
-
+	private readonly SkiaRenderHelper.FpsHelper _fpsHelper = new();
 	private SKPicture? _picture;
 
 	internal UnoExploreByTouchHelper ExploreByTouchHelper { get; }
@@ -91,6 +84,7 @@ internal void InvalidateRender()
 		{
 			canvas.Clear(SKColors.Transparent);
 			var scale = DisplayInformation.GetForCurrentViewSafe()!.RawPixelsPerViewPixel;
+			_fpsHelper.Scale = (float)scale;
 			canvas.Scale((float)scale);
 			var negativePath = SkiaRenderHelper.RenderRootVisualAndReturnNegativePath((int)window.Bounds.Width,
 				(int)window.Bounds.Height, root.Visual, canvas);
@@ -100,24 +94,6 @@ internal void InvalidateRender()
 				nativeLayerHost.Invalidate();
 			}
 
-#if FPS_DISPLAY
-			// This naively calculates the difference in time every 100 frames, so to get
-			// a usable number, open a sample with a continuously-running animation.
-			_counter++;
-			if (_counter % 100 == 0)
-			{
-				var newTime = DateTime.UtcNow;
-				_fpsText = $"{100 / (newTime - _time).TotalSeconds}";
-				_time = newTime;
-			}
-			canvas.DrawText(
-				_fpsText,
-				(float)(window.Bounds.Width / 2),
-				(float)(window.Bounds.Height / 2),
-				new SKFont(SKTypeface.Default, size: 20F),
-				new SKPaint { Color = SKColors.Red});
-#endif
-
 			var picture = recorder.EndRecording();
 
 			Interlocked.Exchange(ref _picture, picture);
@@ -220,6 +196,8 @@ private class InternalRenderer(UnoSKCanvasView surfaceView) : Java.Lang.Object,
 
 		void IRenderer.OnDrawFrame(IGL10? gl)
 		{
+			using var _ = surfaceView._fpsHelper.BeginFrame();
+
 			var currentPicture = Volatile.Read(ref surfaceView._picture);
 
 			GLES20.GlClear(GLES20.GlColorBufferBit | GLES20.GlDepthBufferBit | GLES20.GlStencilBufferBit);
@@ -285,6 +263,7 @@ void IRenderer.OnDrawFrame(IGL10? gl)
 			{
 				// start drawing
 				canvas.DrawPicture(currentPicture);
+				surfaceView._fpsHelper.DrawFps(canvas);
 			}
 
 			// flush the SkiaSharp contents to GL
diff --git a/src/Uno.UI.Runtime.Skia.AppleUIKit/UI/Xaml/UnoSKMetalView.cs b/src/Uno.UI.Runtime.Skia.AppleUIKit/UI/Xaml/UnoSKMetalView.cs
index b1547cbb6b01..9de833c645d9 100644
--- a/src/Uno.UI.Runtime.Skia.AppleUIKit/UI/Xaml/UnoSKMetalView.cs
+++ b/src/Uno.UI.Runtime.Skia.AppleUIKit/UI/Xaml/UnoSKMetalView.cs
@@ -14,12 +14,14 @@
 using UIKit;
 using Uno.Foundation.Logging;
 using Uno.UI.Dispatching;
+using Uno.UI.Helpers;
 
 namespace Uno.UI.Runtime.Skia.AppleUIKit
 {
 	[Register(nameof(UnoSKMetalView))]
 	internal sealed class UnoSKMetalView : MTKView, IMTKViewDelegate
 	{
+		private readonly SkiaRenderHelper.FpsHelper _fpsHelper = new();
 		private readonly GRContext? _context;
 		private readonly IMTLCommandQueue? _queue;
 		private readonly Action _onFrameDrawn;
@@ -124,6 +126,7 @@ public void QueueRender()
 			using (new SKAutoCanvasRestore(canvas, true))
 			{
 				_owner!.OnPaintSurfaceInner(canvas);
+				_fpsHelper.Scale = (float?)AppManager.XamlRootMap.GetRootForHost(_owner)?.RasterizationScale;
 
 				var picture = recorder.EndRecording();
 
@@ -147,6 +150,7 @@ void IMTKViewDelegate.DrawableSizeWillChange(MTKView view, CGSize size)
 
 		void IMTKViewDelegate.Draw(MTKView view)
 		{
+			using var _ = _fpsHelper.BeginFrame();
 			_onFrameDrawn();
 
 #if REPORT_FPS
@@ -184,6 +188,7 @@ void IMTKViewDelegate.Draw(MTKView view)
 					if (currentPicture is { } picture)
 					{
 						canvas.DrawPicture(picture);
+						_fpsHelper.DrawFps(canvas);
 					}
 				}
 
diff --git a/src/Uno.UI.Runtime.Skia.MacOS/MacOSWindowHost.cs b/src/Uno.UI.Runtime.Skia.MacOS/MacOSWindowHost.cs
index c40164aab8aa..dd63068c2c39 100644
--- a/src/Uno.UI.Runtime.Skia.MacOS/MacOSWindowHost.cs
+++ b/src/Uno.UI.Runtime.Skia.MacOS/MacOSWindowHost.cs
@@ -26,6 +26,7 @@ namespace Uno.UI.Runtime.Skia.MacOS;
 
 internal class MacOSWindowHost : IXamlRootHost, IUnoKeyboardInputSource, IUnoCorePointerInputSource
 {
+	private readonly SkiaRenderHelper.FpsHelper _fpsHelper = new();
 	private readonly MacOSWindowNative _nativeWindow;
 	private readonly Window _winUIWindow;
 	private readonly XamlRoot _xamlRoot;
@@ -82,6 +83,7 @@ private void UpdateWindowSize(double nativeWidth, double nativeHeight)
 
 	private void Draw(double nativeWidth, double nativeHeight, SKSurface surface)
 	{
+		using var _ = _fpsHelper.BeginFrame();
 		using var canvas = surface.Canvas;
 		using (new SKAutoCanvasRestore(canvas, true))
 		{
@@ -92,6 +94,7 @@ private void Draw(double nativeWidth, double nativeHeight, SKSurface surface)
 				int width = (int)nativeWidth;
 				int height = (int)nativeHeight;
 				var path = SkiaRenderHelper.RenderRootVisualAndReturnNegativePath(width, height, rootVisual, surface.Canvas);
+				_fpsHelper.DrawFps(canvas);
 				var clip = path.IsEmpty ? null : path.ToSvgPathData();
 				if (clip != _lastSvgClipPath)
 				{
diff --git a/src/Uno.UI.Runtime.Skia.WebAssembly.Browser/BrowserRenderer.cs b/src/Uno.UI.Runtime.Skia.WebAssembly.Browser/BrowserRenderer.cs
index 5accd80f1f51..a0d7ed4fdc1e 100644
--- a/src/Uno.UI.Runtime.Skia.WebAssembly.Browser/BrowserRenderer.cs
+++ b/src/Uno.UI.Runtime.Skia.WebAssembly.Browser/BrowserRenderer.cs
@@ -13,6 +13,7 @@ namespace Uno.UI.Runtime.Skia;
 
 internal partial class BrowserRenderer
 {
+	private readonly SkiaRenderHelper.FpsHelper _fpsHelper = new();
 	private readonly IXamlRootHost _host;
 	private int _renderCount;
 	private DisplayInformation? _displayInformation;
@@ -63,6 +64,7 @@ private void Initialize()
 
 	private void RenderFrame()
 	{
+		using var _ = _fpsHelper.BeginFrame();
 		if (!_jsInfo.IsValid)
 		{
 			Initialize();
@@ -93,7 +95,7 @@ private void RenderFrame()
 		var newCanvasSize = new SKSizeI((int)(Microsoft.UI.Xaml.Window.CurrentSafe!.Bounds.Width), (int)(Microsoft.UI.Xaml.Window.CurrentSafe!.Bounds.Height));
 
 		// manage the drawing surface
-		if (_surface == null || _renderTarget == null || _lastSize != newCanvasSize || !_renderTarget.IsValid)
+		if (_surface == null || _canvas == null || _renderTarget == null || _lastSize != newCanvasSize || !_renderTarget.IsValid)
 		{
 			if (this.Log().IsEnabled(LogLevel.Trace))
 			{
@@ -131,7 +133,8 @@ private void RenderFrame()
 			_surface.Canvas.Scale((float)scale);
 			if (_host.RootElement?.Visual is { } rootVisual)
 			{
-				var negativePath = SkiaRenderHelper.RenderRootVisualAndReturnNegativePath(_renderTarget.Width, _renderTarget.Height, rootVisual, _surface.Canvas);
+				var negativePath = SkiaRenderHelper.RenderRootVisualAndReturnNegativePath(_renderTarget.Width, _renderTarget.Height, rootVisual, _canvas);
+				_fpsHelper.DrawFps(_canvas);
 				// Unlike other skia platforms, on skia/wasm we need to undo the scaling  adjustment that happens inside
 				// RenderRootVisualAndReturnNegativePath since the numbers we get from native are already scaled, so we
 				// don't need to do our own scaling in RenderRootVisualAndReturnNegativePath.
diff --git a/src/Uno.UI.Runtime.Skia.Win32/Win32WindowWrapper.Rendering.cs b/src/Uno.UI.Runtime.Skia.Win32/Win32WindowWrapper.Rendering.cs
index 22b8bebebfb2..3503e144828b 100644
--- a/src/Uno.UI.Runtime.Skia.Win32/Win32WindowWrapper.Rendering.cs
+++ b/src/Uno.UI.Runtime.Skia.Win32/Win32WindowWrapper.Rendering.cs
@@ -15,6 +15,7 @@ internal partial class Win32WindowWrapper
 
 	private Size? _lastSize;
 	private SKSurface? _surface;
+	private readonly SkiaRenderHelper.FpsHelper _fpsHelper = new();
 
 	public event EventHandler<SKPath>? RenderingNegativePathReevaluated; // not necessarily changed
 
@@ -25,6 +26,8 @@ private void Paint()
 			return;
 		}
 
+		using var _ = _fpsHelper.BeginFrame();
+
 		this.LogTrace()?.Trace($"Render {this._renderCount++}");
 
 		_renderer.StartPaint();
@@ -64,6 +67,7 @@ private void Paint()
 			{
 				rootVisual.Compositor.IsSoftwareRenderer = _renderer.IsSoftware();
 				var path = SkiaRenderHelper.RenderRootVisualAndReturnNegativePath(clientRect.Width, clientRect.Height, rootVisual, _surface.Canvas);
+				_fpsHelper.DrawFps(canvas);
 				RenderingNegativePathReevaluated?.Invoke(this, path);
 			}
 			finally
diff --git a/src/Uno.UI.Runtime.Skia.Wpf/Rendering/OpenGLWpfRenderer.cs b/src/Uno.UI.Runtime.Skia.Wpf/Rendering/OpenGLWpfRenderer.cs
index 983bb69d3ca6..f5677814c3fe 100644
--- a/src/Uno.UI.Runtime.Skia.Wpf/Rendering/OpenGLWpfRenderer.cs
+++ b/src/Uno.UI.Runtime.Skia.Wpf/Rendering/OpenGLWpfRenderer.cs
@@ -20,6 +20,7 @@ internal partial class OpenGLWpfRenderer : IWpfRenderer
 	private const SKColorType colorType = SKColorType.Rgba8888;
 	private const GRSurfaceOrigin surfaceOrigin = GRSurfaceOrigin.TopLeft;
 
+	private readonly SkiaRenderHelper.FpsHelper _fpsHelper = new();
 	private readonly WpfControl _hostControl;
 	private readonly IWpfXamlRootHost _host;
 	private WinUI.XamlRoot? _xamlRoot;
@@ -165,6 +166,8 @@ public void Render(DrawingContext drawingContext)
 			return;
 		}
 
+		using var _ = _fpsHelper.BeginFrame();
+
 		int width, height;
 
 		_xamlRoot ??= WpfManager.XamlRootMap.GetRootForHost((IWpfXamlRootHost)_hostControl) ?? throw new InvalidOperationException("XamlRoot must not be null when renderer is initialized");
@@ -230,6 +233,7 @@ public void Render(DrawingContext drawingContext)
 			if (_host.RootElement?.Visual is { } rootVisual)
 			{
 				var negativePath = SkiaRenderHelper.RenderRootVisualAndReturnNegativePath(width, height, rootVisual, _surface.Canvas);
+				_fpsHelper.DrawFps(canvas);
 
 				if (_host.NativeOverlayLayer is { } nativeLayer)
 				{
diff --git a/src/Uno.UI.Runtime.Skia.Wpf/Rendering/SoftwareWpfRenderer.cs b/src/Uno.UI.Runtime.Skia.Wpf/Rendering/SoftwareWpfRenderer.cs
index 9b2548e1196c..abae9063aa1a 100644
--- a/src/Uno.UI.Runtime.Skia.Wpf/Rendering/SoftwareWpfRenderer.cs
+++ b/src/Uno.UI.Runtime.Skia.Wpf/Rendering/SoftwareWpfRenderer.cs
@@ -17,6 +17,7 @@ namespace Uno.UI.Runtime.Skia.Wpf.Rendering;
 
 internal class SoftwareWpfRenderer : IWpfRenderer
 {
+	private readonly SkiaRenderHelper.FpsHelper _fpsHelper = new();
 	private readonly WpfControl _hostControl;
 	private readonly IWpfXamlRootHost _host;
 	private WriteableBitmap? _bitmap;
@@ -47,6 +48,7 @@ public void Render(DrawingContext drawingContext)
 			return;
 		}
 
+		using var _ = _fpsHelper.BeginFrame();
 
 		int width, height;
 
@@ -91,6 +93,7 @@ public void Render(DrawingContext drawingContext)
 					rootVisual.Compositor.IsSoftwareRenderer = true;
 
 					var negativePath = SkiaRenderHelper.RenderRootVisualAndReturnNegativePath(width, height, rootVisual, surface.Canvas);
+					_fpsHelper.DrawFps(canvas);
 
 					if (_host.NativeOverlayLayer is { } nativeLayer)
 					{
diff --git a/src/Uno.UI.Runtime.Skia.X11/X11OpenGLRenderer.cs b/src/Uno.UI.Runtime.Skia.X11/X11OpenGLRenderer.cs
index 2c9db65b7f7b..9065f0d50dce 100644
--- a/src/Uno.UI.Runtime.Skia.X11/X11OpenGLRenderer.cs
+++ b/src/Uno.UI.Runtime.Skia.X11/X11OpenGLRenderer.cs
@@ -17,6 +17,7 @@ internal class X11OpenGLRenderer : IX11Renderer, IDisposable
 		private readonly GRContext _grContext;
 		private readonly X11Window _x11Window;
 		private readonly IXamlRootHost _host;
+		private readonly SkiaRenderHelper.FpsHelper _fpsHelper = new();
 
 		private int _renderCount;
 		private Size _lastSize;
@@ -38,6 +39,8 @@ public X11OpenGLRenderer(IXamlRootHost host, X11Window x11window)
 
 		void IX11Renderer.Render()
 		{
+			using var fpsDisposable = _fpsHelper.BeginFrame();
+
 			var display = _x11Window.Display;
 			var window = _x11Window.Window;
 			using var lockDiposable = X11Helper.XLock(display);
@@ -116,6 +119,7 @@ void IX11Renderer.Render()
 				if (_host.RootElement?.Visual is { } rootVisual)
 				{
 					var path = SkiaRenderHelper.RenderRootVisualAndReturnPath(width, height, rootVisual, _surface.Canvas);
+					_fpsHelper.DrawFps(canvas);
 					_airspaceHelper.XShapeClip(path);
 				}
 			}
diff --git a/src/Uno.UI.Runtime.Skia.X11/X11SoftwareRenderer.cs b/src/Uno.UI.Runtime.Skia.X11/X11SoftwareRenderer.cs
index 52b3d522db65..c3cb77a17e1c 100644
--- a/src/Uno.UI.Runtime.Skia.X11/X11SoftwareRenderer.cs
+++ b/src/Uno.UI.Runtime.Skia.X11/X11SoftwareRenderer.cs
@@ -10,6 +10,7 @@ internal class X11SoftwareRenderer(IXamlRootHost host, X11Window x11window) : IX
 	{
 		private const int BitmapPad = 32;
 
+		private readonly SkiaRenderHelper.FpsHelper _fpsHelper = new();
 		private SKBitmap? _bitmap;
 		private SKSurface? _surface;
 		private X11AirspaceRenderHelper? _airspaceHelper;
@@ -22,6 +23,7 @@ internal class X11SoftwareRenderer(IXamlRootHost host, X11Window x11window) : IX
 
 		void IX11Renderer.Render()
 		{
+			using var fpsDisposable = _fpsHelper.BeginFrame();
 			using var lockDiposable = X11Helper.XLock(x11window.Display);
 
 			if (host is X11XamlRootHost { Closed.IsCompleted: true })
@@ -79,6 +81,7 @@ void IX11Renderer.Render()
 				if (host.RootElement?.Visual is { } rootVisual)
 				{
 					var path = SkiaRenderHelper.RenderRootVisualAndReturnPath(width, height, rootVisual, _surface.Canvas);
+					_fpsHelper.DrawFps(canvas);
 					_airspaceHelper.XShapeClip(path);
 				}
 
diff --git a/src/Uno.UI/FeatureConfiguration.cs b/src/Uno.UI/FeatureConfiguration.cs
index a08d8777dcb4..262d07bd134f 100644
--- a/src/Uno.UI/FeatureConfiguration.cs
+++ b/src/Uno.UI/FeatureConfiguration.cs
@@ -8,6 +8,7 @@
 using Uno.Foundation.Logging;
 using Uno.UI.Xaml.Controls;
 using System.Runtime.InteropServices;
+using Microsoft.UI.Composition;
 
 namespace Uno.UI
 {
@@ -873,6 +874,53 @@ public static class Rendering
 			/// Determines if OpenGL rendering should be enabled on the Android target when using the skia renderer.
 			/// </summary>
 			public static bool UseOpenGLOnSkiaAndroid { get; set; } = true;
+
+			/// <summary>
+			/// Enables certain optimizations that skip rendering some subtrees
+			/// of the visual tree that do not change often and instead caches their
+			/// rendering output. This optimization is only for skia targets.
+			/// </summary>
+			public static bool EnableVisualSubtreeSkippingOptimization
+			{
+#if __SKIA__
+				get => Visual.EnablePictureCollapsingOptimization;
+				set => Visual.EnablePictureCollapsingOptimization = value;
+#else
+				get => false;
+				set { }
+#endif
+			}
+
+			/// <summary>
+			/// When <see cref="EnableVisualSubtreeSkippingOptimization"/> is enabled, determines the number
+			/// of frames that a visual subtree needs to remain unchanged through, after which the subtree
+			/// is considered for the subtree skipping optimization.
+			/// </summary>
+			public static int VisualSubtreeSkippingOptimizationCleanFramesThreshold
+			{
+#if __SKIA__
+				get => Visual.PictureCollapsingOptimizationFrameThreshold;
+				set => Visual.PictureCollapsingOptimizationFrameThreshold = value;
+#else
+				get => 0;
+				set { }
+#endif
+			}
+
+			/// <summary>
+			/// When <see cref="EnableVisualSubtreeSkippingOptimization"/> is enabled, determines the minimum
+			/// size of visual subtrees considered for the subtree skipping optimization.
+			/// </summary>
+			public static int VisualSubtreeSkippingOptimizationVisualCountThreshold
+			{
+#if __SKIA__
+				get => Visual.PictureCollapsingOptimizationVisualCountThreshold;
+				set => Visual.PictureCollapsingOptimizationVisualCountThreshold = value;
+#else
+				get => 0;
+				set { }
+#endif
+			}
 		}
 
 		public static class DependencyProperty
diff --git a/src/Uno.UI/Helpers/SkiaRenderHelper.skia.cs b/src/Uno.UI/Helpers/SkiaRenderHelper.skia.cs
index d66fd62d1ba8..56f1f22b39b7 100644
--- a/src/Uno.UI/Helpers/SkiaRenderHelper.skia.cs
+++ b/src/Uno.UI/Helpers/SkiaRenderHelper.skia.cs
@@ -1,6 +1,10 @@
 #nullable enable
 
+using System;
+using System.Diagnostics;
+using System.Threading;
 using Microsoft.UI.Composition;
+using Microsoft.UI.Xaml;
 using Microsoft.UI.Xaml.Controls;
 using SkiaSharp;
 
@@ -41,4 +45,83 @@ public static SKPath RenderRootVisualAndReturnPath(int width, int height, Contai
 		outPath.Op(RenderRootVisualAndReturnNegativePath(width, height, rootVisual, canvas), SKPathOp.Difference, outPath);
 		return outPath;
 	}
+
+	public class FpsHelper
+	{
+		public readonly record struct FrameDisposable(FpsHelper @this) : IDisposable
+		{
+			public void Dispose() => @this.EndFrame();
+		}
+
+		private static readonly SKPaint _blackPaint = new() { Color = SKColors.Black };
+		private static readonly SKPaint _redPaint = new() { Color = SKColors.Red };
+		private static readonly SKFont _font = new() { Size = 20, Embolden = true };
+
+		private readonly TimeSpan[] _frameTimes;
+		private readonly Timer _fpsTimer;
+		private int _frameTimesHead;
+		private int _framesRenderedInLastSecond;
+		private long _currentFrameBeginTimestamp;
+
+		public FpsHelper(int numberOfFramesToCalculateFrameTime = 10)
+		{
+			_frameTimes = new TimeSpan[numberOfFramesToCalculateFrameTime];
+			_fpsTimer = new Timer(static state => (state as FpsHelper)?.TimerTick(), this, TimeSpan.Zero, TimeSpan.FromSeconds(1));
+		}
+
+		public double Fps { get; private set; }
+		public double FrameTime { get; private set; }
+
+		public float? Scale { private get; set; }
+
+		public FrameDisposable BeginFrame()
+		{
+			_currentFrameBeginTimestamp = Stopwatch.GetTimestamp();
+			return new FrameDisposable(this);
+		}
+
+		private void EndFrame()
+		{
+			_frameTimes[_frameTimesHead] = Stopwatch.GetElapsedTime(_currentFrameBeginTimestamp);
+			_frameTimesHead = (_frameTimesHead + 1) % _frameTimes.Length;
+			var acc = TimeSpan.Zero;
+			foreach (var t in _frameTimes)
+			{
+				acc += t;
+			}
+			FrameTime = acc.TotalMilliseconds / _frameTimes.Length;
+
+			_framesRenderedInLastSecond++;
+		}
+
+		public void DrawFps(SKCanvas canvas)
+		{
+			if (!Application.Current.DebugSettings.EnableFrameRateCounter)
+			{
+				return;
+			}
+
+			var text = $"{Fps:F1}   {FrameTime:F1}";
+			_font.MeasureText(text, out var rect);
+			if (Scale is { } scale)
+			{
+				canvas.Save();
+				canvas.Scale(scale, scale);
+			}
+			canvas.DrawRect(new SKRect(0, 0, rect.Width, rect.Height), _blackPaint);
+			canvas.DrawText(text, 0, -rect.Top, _font, _redPaint);
+			if (Scale is not null)
+			{
+				canvas.Restore();
+			}
+		}
+
+		private void TimerTick()
+		{
+			Fps = _framesRenderedInLastSecond;
+			_framesRenderedInLastSecond = 0;
+		}
+
+		public void Dispose() => _fpsTimer.Dispose();
+	}
 }
diff --git a/src/Uno.UI/UI/Xaml/DebugSettings.cs b/src/Uno.UI/UI/Xaml/DebugSettings.cs
index c86af2af70d5..409164dcab22 100644
--- a/src/Uno.UI/UI/Xaml/DebugSettings.cs
+++ b/src/Uno.UI/UI/Xaml/DebugSettings.cs
@@ -9,7 +9,9 @@ public sealed partial class DebugSettings
 		public LayoutCycleTracingLevel LayoutCycleTracingLevel { get; set; }
 #endif
 
+#if !UNO_REFERENCE_API
 		[Uno.NotImplemented]
+#endif
 		public bool EnableFrameRateCounter { get; set; }
 
 		[Uno.NotImplemented]
diff --git a/src/Uno.UI/UI/Xaml/Media/Imaging/RenderTargetBitmap.skia.cs b/src/Uno.UI/UI/Xaml/Media/Imaging/RenderTargetBitmap.skia.cs
index 93da7807405b..27ada73d15f0 100644
--- a/src/Uno.UI/UI/Xaml/Media/Imaging/RenderTargetBitmap.skia.cs
+++ b/src/Uno.UI/UI/Xaml/Media/Imaging/RenderTargetBitmap.skia.cs
@@ -6,7 +6,6 @@
 using Microsoft.UI.Composition;
 using Uno.UI.Xaml.Media;
 using SkiaSharp;
-using Uno.UI.Composition;
 
 namespace Microsoft.UI.Xaml.Media.Imaging
 {


Reviews:
user: login: ajpinedam
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T03:38:05Z
user: login: jeromelaban
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-12T12:15:42Z
user: login: Youssef1313
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-12T12:45:58Z
user: login: ramezgerges
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T13:51:11Z
user: login: Youssef1313
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-12T18:38:52Z

Review comments:
diff_hunk: @@ -161,4 +169,10 @@ internal override bool SetMatrixDirty()
 
 		return false;
 	}
+
+	internal override int GetSubTreeVisualCount()
+	{
+		_subtreeVisualCount ??= Children.Count + Children.InnerList.Aggregate(0, (acc, visual) => acc + visual.GetSubTreeVisualCount());
path: src/Uno.UI.Composition/Composition/ContainerVisual.skia.cs
user: login: jeromelaban
body: We should avoid LINQ in this context, this is pretty expensive.
created_at: 2025-05-12T12:15:42Z
author_association: MEMBER
diff_hunk: @@ -25,6 +26,13 @@ partial void InitializePartial()
 		{
 			IsChildrenRenderOrderDirty = true;
 
+			var parent = this;
+			while (parent is not null && parent._subtreeVisualCount is not null)
+			{
+				parent._subtreeVisualCount = null;
path: src/Uno.UI.Composition/Composition/ContainerVisual.skia.cs
user: login: Youssef1313
body: Wouldn't it be more performant if you adjust this based on the given NotifyCollectionChangedAction?

- Remove: subtract one
- Add: add one
- Replace/Move: do nothing
- Reset: find the difference between the number of elements before/after the collection change for the *current* visual, and propagate the difference up the tree, without having to re-calculate everything.
created_at: 2025-05-12T12:45:58Z
author_association: MEMBER
diff_hunk: @@ -25,6 +26,13 @@ partial void InitializePartial()
 		{
 			IsChildrenRenderOrderDirty = true;
 
+			var parent = this;
+			while (parent is not null && parent._subtreeVisualCount is not null)
+			{
+				parent._subtreeVisualCount = null;
path: src/Uno.UI.Composition/Composition/ContainerVisual.skia.cs
user: login: ramezgerges
body: That's fair but the average number of children of a visual is likely around 2. Looping through 3 children and reading a cached value is hardly more expensive and it leaves the code a lot cleaner and easier to debug when we make invalidation bugs. We can revisit this if it shows up while profiling.
created_at: 2025-05-12T13:51:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -161,4 +169,16 @@ internal override bool SetMatrixDirty()
 
 		return false;
 	}
+
+	internal override int GetSubTreeVisualCount()
+	{
+		var acc = 0;
+		foreach (var visual in Children.InnerList)
+		{
+			acc += visual.GetSubTreeVisualCount();
+		}
+		_subtreeVisualCount ??= Children.Count + acc;
+
+		return _subtreeVisualCount.Value;
path: src/Uno.UI.Composition/Composition/ContainerVisual.skia.cs
user: login: Youssef1313
body: ⚠️ Here you will loop to calculate `acc` even if the value is already cached?
created_at: 2025-05-12T18:38:52Z
author_association: MEMBER

PR comments:
user: login: unodevops
created_at: 2025-05-09T17:27:53Z
body: 🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-20298/docs/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-09T17:30:15Z
body: 🤖 Your WebAssembly Sample App 2 stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-20298/wasm-skia-net9/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-09T19:22:45Z
body: 🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-20298/wasm-skia-net9/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-09T19:30:33Z
body: 🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-20298/docs/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-09T23:25:23Z
body: ⚠️⚠️ The build [165135](https://dev.azure.com/uno-platform//Uno%20Platform/_build/results?buildId=165135) has failed on Uno.UI - CI.
author_association: CONTRIBUTOR
user: login: ajpinedam
created_at: 2025-05-12T05:22:34Z
body: /azp run
author_association: CONTRIBUTOR
user: login: azure-pipelines[bot]
created_at: 2025-05-12T05:22:57Z
body: <samp>
Azure Pipelines successfully started running 2 pipeline(s).<br>

</samp>
author_association: NONE
user: login: unodevops
created_at: 2025-05-12T05:41:18Z
body: 🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-20298/docs/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-12T05:44:51Z
body: 🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-20298/wasm-skia-net9/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-12T07:07:11Z
body: ⚠️⚠️ The build [165220](https://dev.azure.com/uno-platform//Uno%20Platform/_build/results?buildId=165220) has failed on Uno.UI - CI.
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-12T14:09:30Z
body: 🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-20298/wasm-skia-net9/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-12T14:10:57Z
body: 🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-20298/docs/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-12T18:33:17Z
body: ⚠️⚠️ The build [165246](https://dev.azure.com/uno-platform//Uno%20Platform/_build/results?buildId=165246) has failed on Uno.UI - CI.
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-12T18:43:49Z
body: 🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-20298/docs/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-12T19:03:47Z
body: 🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-20298/wasm-skia-net9/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-12T20:02:22Z
body: 🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-20298/docs/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-12T20:03:59Z
body: 🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-20298/wasm-skia-net9/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-12T21:53:40Z
body: 🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-20298/wasm-skia-net9/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-12T21:54:13Z
body: 🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-20298/docs/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-12T22:58:07Z
body: 🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-20298/wasm-skia-net9/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-12T23:26:18Z
body: 🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-20298/docs/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-13T16:08:33Z
body: 🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-20298/wasm-skia-net9/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-13T16:20:13Z
body: ⚠️⚠️ The build [165425](https://dev.azure.com/uno-platform///_build/results?buildId=165425) has failed on Uno.UI - docs.
author_association: CONTRIBUTOR
user: login: jeromelaban
created_at: 2025-05-14T19:40:10Z
body: /azp run
author_association: MEMBER
user: login: azure-pipelines[bot]
created_at: 2025-05-14T19:40:37Z
body: <samp>
Azure Pipelines successfully started running 2 pipeline(s).<br>

</samp>
author_association: NONE
user: login: unodevops
created_at: 2025-05-14T20:55:40Z
body: 🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-20298/docs/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-14T21:30:13Z
body: 🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-20298/wasm-skia-net9/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-15T14:24:32Z
body: 🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-20298/docs/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-15T14:26:16Z
body: 🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-20298/wasm-skia-net9/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-15T17:45:54Z
body: 🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-20298/docs/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-15T17:47:47Z
body: 🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-20298/wasm-skia-net9/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-15T20:55:53Z
body: ⚠️⚠️ The build [165828](https://dev.azure.com/uno-platform//Uno%20Platform/_build/results?buildId=165828) has failed on Uno.UI - CI.
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-16T15:58:53Z
body: 🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-20298/docs/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-16T16:03:35Z
body: 🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-20298/wasm-skia-net9/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-16T20:45:03Z
body: ⚠️⚠️ The build [165940](https://dev.azure.com/uno-platform//Uno%20Platform/_build/results?buildId=165940) has failed on Uno.UI - CI.
author_association: CONTRIBUTOR
user: login: ramezgerges
created_at: 2025-05-20T16:23:59Z
body: The differences are massive on skia/wasm where going to JS and back is expensive. In an artificial scenario with N buttons in a WrapPanel, the numbers before and after differ by a full order of magnitude in some cases:

After (fps and frame time):
N = 10 -> 50-52fps ~7.5ms
N = 14 (right before threshold for opt) -> 57fps ~5.5ms
N = 15 -> roughly the same as above
20 -> roughly the same as above
100 -> 30-35fps ~6.5ms
200 -> ~31fps 8.5ms (was 14fps 50ms for the first second or so before the opt hit)
500 -> ~19fps ~14ms (was ~14fps ~110ms for the first few seconds before the opt hit)
1000 -> ~19fps ~24ms (was ~4fps ~215ms for the first few seconds before the opt hit)

Before:
10 -> ~7ms
14 -> ~8.5ms
500 -> ~110ms
1000 -> ~200ms
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-20T18:44:28Z
body: 🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-20298/docs/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-20T19:13:26Z
body: 🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-20298/wasm-skia-net9/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-20T23:57:09Z
body: ⚠️⚠️ The build [166210](https://dev.azure.com/uno-platform//Uno%20Platform/_build/results?buildId=166210) has failed on Uno.UI - CI.
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-21T12:55:35Z
body: 🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-20298/wasm-skia-net9/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-21T12:57:29Z
body: 🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-20298/docs/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-21T18:32:04Z
body: ⚠️⚠️ The build [166300](https://dev.azure.com/uno-platform//Uno%20Platform/_build/results?buildId=166300) has failed on Uno.UI - CI.
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-21T23:13:45Z
body: 🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-20298/wasm-skia-net9/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-21T23:20:37Z
body: 🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-20298/docs/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-22T03:58:37Z
body: ⚠️⚠️ The build [166392](https://dev.azure.com/uno-platform//Uno%20Platform/_build/results?buildId=166392) has failed on Uno.UI - CI.
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-22T14:47:13Z
body: 🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-20298/wasm-skia-net9/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-22T15:08:09Z
body: 🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-20298/docs/index.html
author_association: CONTRIBUTOR
user: login: unodevops
created_at: 2025-05-22T22:07:55Z
body: ⚠️⚠️ The build [166474](https://dev.azure.com/uno-platform//Uno%20Platform/_build/results?buildId=166474) has failed on Uno.UI - CI.
author_association: CONTRIBUTOR
user: login: jeromelaban
created_at: 2025-05-23T16:07:14Z
body: @mergifyio backport release/stable/6.0 
author_association: MEMBER
user: login: mergify[bot]
created_at: 2025-05-23T16:07:40Z
body: > backport release/stable/6.0

#### ✅ Backports have been created

<details>

* [#20538 feat(skia): collapse visual subtrees into one SKPicture when the subtree doesn't change for a bit (backport #20298)](https://github.com/unoplatform/uno/pull/20538) has been created for branch `release/stable/6.0`

</details>


<!---
DO NOT EDIT
-*- Mergify Payload -*-
{"command": "backport release/stable/6.0", "conclusion": "success", "action_is_running": true}
-*- Mergify Payload End -*-
-->
author_association: CONTRIBUTOR