diff --git a/samples/RenderDemo/MainWindow.xaml b/samples/RenderDemo/MainWindow.xaml
index e1dbd20b07..e3afd0c241 100644
--- a/samples/RenderDemo/MainWindow.xaml
+++ b/samples/RenderDemo/MainWindow.xaml
@@ -64,6 +64,9 @@
+
+
+
diff --git a/samples/RenderDemo/Pages/HitTestingPage.cs b/samples/RenderDemo/Pages/HitTestingPage.cs
new file mode 100644
index 0000000000..f7b2e454b2
--- /dev/null
+++ b/samples/RenderDemo/Pages/HitTestingPage.cs
@@ -0,0 +1,401 @@
+using System;
+using System.Diagnostics;
+using System.Numerics;
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media;
+using Avalonia.Rendering.Composition;
+using Avalonia.Rendering.Composition.Animations;
+using Avalonia.VisualTree;
+
+namespace RenderDemo.Pages
+{
+ public class HitTestingPage : UserControl
+ {
+ private const int GroupColumns = 8;
+ private const int GroupRows = 5;
+ private const int CellsPerGroupSide = 10;
+ private const int CellStride = 10;
+ private const int CellSize = 8;
+ private const int AnimationTravel = 64;
+
+ private readonly Canvas _scene;
+ private readonly TextBlock _stats;
+ private readonly Cell[] _cells = new Cell[GroupColumns * GroupRows * CellsPerGroupSide * CellsPerGroupSide];
+ private readonly Stopwatch _stopwatch = Stopwatch.StartNew();
+ private Compositor? _compositor;
+ private int _hitTestsPerFrame = 256;
+ private int _updateCount;
+ private int _hitTestCount;
+ private int _hitCount;
+ private int _lastSecondUpdateCount;
+ private int _lastSecondHitTestCount;
+ private int _lastSecondHitCount;
+ private TimeSpan _lastSecondTime;
+ private double _lastSecondUpdatesPerSecond;
+ private double _lastSecondHitTestsPerSecond;
+ private double _lastSecondHitsPerSecond;
+ private int _clickCount;
+ private bool _isAttached;
+ private bool _updateQueued;
+ private bool _animationsStarted;
+ private Cell? _lastHitCell;
+ private Cell? _lastClickedCell;
+
+ public HitTestingPage()
+ {
+ _scene = new Canvas
+ {
+ Width = GroupColumns * CellsPerGroupSide * CellStride,
+ Height = GroupRows * CellsPerGroupSide * CellStride,
+ Background = Brushes.Transparent
+ };
+
+ _stats = new TextBlock
+ {
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top,
+ Margin = new Thickness(12),
+ Padding = new Thickness(8, 4),
+ Background = new SolidColorBrush(Color.FromArgb(220, 255, 255, 255)),
+ Foreground = Brushes.Black,
+ IsHitTestVisible = false
+ };
+
+ var numberOfHitTests = new NumericUpDown
+ {
+ Minimum = 0,
+ Value = _hitTestsPerFrame,
+ Width = 200,
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left,
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
+ };
+
+ numberOfHitTests.ValueChanged += (s, e) =>
+ {
+ if (numberOfHitTests.Value.HasValue)
+ {
+ _hitTestsPerFrame = (int)numberOfHitTests.Value.Value;
+ }
+ };
+
+ var param = new StackPanel
+ {
+ Orientation = Avalonia.Layout.Orientation.Horizontal,
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left,
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Bottom,
+ Margin = new Thickness(12),
+ Spacing = 8,
+ Children =
+ {
+ new TextBlock { Text = "Hit tests per update:" },
+ numberOfHitTests
+ }
+ };
+
+ var root = new Grid
+ {
+ ClipToBounds = true,
+ RowDefinitions =
+ {
+ new RowDefinition(GridLength.Auto),
+ new RowDefinition(GridLength.Auto),
+ new RowDefinition(GridLength.Star),
+ },
+ };
+
+ Grid.SetRow(param, 0);
+ root.Children.Add(param);
+ Grid.SetRow(_stats, 1);
+ root.Children.Add(_stats);
+ Grid.SetRow(_scene, 2);
+ root.Children.Add(_scene);
+
+ Content = root;
+ BuildScene();
+ ResetState();
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ ResetState();
+ _compositor = ElementComposition.GetElementVisual(this)?.Compositor;
+ _isAttached = true;
+ RequestNextUpdate();
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ _isAttached = false;
+ _updateQueued = false;
+ _compositor = null;
+ ResetState();
+ base.OnDetachedFromVisualTree(e);
+ }
+
+ private void BuildScene()
+ {
+ var index = 0;
+ var groupSize = CellsPerGroupSide * CellStride;
+
+ for (var groupY = 0; groupY < GroupRows; groupY++)
+ {
+ for (var groupX = 0; groupX < GroupColumns; groupX++)
+ {
+ var group = new Canvas
+ {
+ Width = groupSize,
+ Height = groupSize,
+ Background = Brushes.Transparent
+ };
+ Canvas.SetLeft(group, groupX * groupSize);
+ Canvas.SetTop(group, groupY * groupSize);
+ _scene.Children.Add(group);
+
+ for (var y = 0; y < CellsPerGroupSide; y++)
+ {
+ for (var x = 0; x < CellsPerGroupSide; x++)
+ {
+ var cell = new Cell(index)
+ {
+ Width = CellSize,
+ Height = CellSize,
+ Background = CreateBrush(index),
+ RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative)
+ };
+ cell.PointerPressed += OnCellPointerPressed;
+
+ Canvas.SetLeft(cell, x * CellStride);
+ Canvas.SetTop(cell, y * CellStride);
+ group.Children.Add(cell);
+ _cells[index++] = cell;
+ }
+ }
+ }
+ }
+ }
+
+ private void OnCompositionUpdate()
+ {
+ _updateQueued = false;
+
+ if (!_isAttached)
+ return;
+
+ if (!_animationsStarted)
+ StartAnimations();
+
+ RunHitTests();
+
+ _updateCount++;
+ if (_stopwatch.Elapsed - _lastSecondTime >= TimeSpan.FromSeconds(1))
+ UpdateStats();
+
+ RequestNextUpdate();
+ }
+
+ private void RequestNextUpdate()
+ {
+ if (_updateQueued || _compositor == null)
+ return;
+
+ _updateQueued = true;
+ _compositor.RequestCompositionUpdate(OnCompositionUpdate);
+ }
+
+ private void StartAnimations()
+ {
+ var started = 0;
+ var easing = new SineEaseInOut();
+
+ for (var i = 0; i < _cells.Length; i++)
+ {
+ if (i % 5 != 0)
+ continue;
+
+ var visual = ElementComposition.GetElementVisual(_cells[i]);
+ if (visual == null)
+ continue;
+
+ var translation = visual.Compositor.CreateVector3KeyFrameAnimation();
+ translation.Target = "Translation";
+ translation.Duration = TimeSpan.FromMilliseconds(900 + (i % 700));
+ translation.Direction = PlaybackDirection.Alternate;
+ translation.IterationBehavior = AnimationIterationBehavior.Forever;
+ translation.InsertKeyFrame(0f, new Vector3(0, 0, 0), easing);
+ translation.InsertKeyFrame(1f, GetAnimationOffset(i), easing);
+ visual.StartAnimation("Translation", translation);
+
+ started++;
+ }
+
+ _animationsStarted = started > 0;
+ }
+
+ private void StopAnimations()
+ {
+ for (var i = 0; i < _cells.Length; i++)
+ {
+ var visual = ElementComposition.GetElementVisual(_cells[i]);
+ if (visual == null)
+ continue;
+
+ visual.StopAnimation("Translation");
+ visual.Translation = default;
+ }
+
+ _animationsStarted = false;
+ }
+
+ private void RunHitTests()
+ {
+ var width = Math.Max(1, _scene.Bounds.Width);
+ var height = Math.Max(1, _scene.Bounds.Height);
+ var baseIndex = _updateCount * 37;
+
+ for (var i = 0; i < _hitTestsPerFrame; i++)
+ {
+ _hitTestCount++;
+ var sample = baseIndex + (i * 97);
+ var point = new Point(sample * 17 % width, sample * 29 % height);
+ var hit = _scene.GetVisualAt(point);
+
+ if (hit is Cell cell)
+ {
+ SetLastHitCell(cell);
+ _hitCount++;
+ }
+ }
+ }
+
+ private static Vector3 GetAnimationOffset(int index)
+ {
+ var x = index % 4 switch
+ {
+ 0 => -AnimationTravel,
+ 1 => AnimationTravel,
+ 2 => -AnimationTravel / 2,
+ _ => AnimationTravel / 2
+ };
+ var y = index / 4 % 4 switch
+ {
+ 0 => -AnimationTravel,
+ 1 => AnimationTravel,
+ 2 => AnimationTravel / 2,
+ _ => -AnimationTravel / 2
+ };
+
+ return new Vector3(x, y, 0);
+ }
+
+ private void ResetState()
+ {
+ StopAnimations();
+
+ _lastClickedCell?.ClearHighlight();
+ _lastHitCell = null;
+ _lastClickedCell = null;
+
+ _updateCount = 0;
+ _hitTestCount = 0;
+ _hitCount = 0;
+ _lastSecondUpdateCount = 0;
+ _lastSecondHitTestCount = 0;
+ _lastSecondHitCount = 0;
+ _lastSecondTime = default;
+ _lastSecondUpdatesPerSecond = 0;
+ _lastSecondHitTestsPerSecond = 0;
+ _lastSecondHitsPerSecond = 0;
+ _clickCount = 0;
+ _stopwatch.Restart();
+ UpdateStats();
+ }
+
+ private void UpdateStats()
+ {
+ var elapsed = _stopwatch.Elapsed;
+ var seconds = Math.Max(0.001, (elapsed - _lastSecondTime).TotalSeconds);
+ _lastSecondUpdatesPerSecond = (_updateCount - _lastSecondUpdateCount) / seconds;
+ _lastSecondHitTestsPerSecond = (_hitTestCount - _lastSecondHitTestCount) / seconds;
+ _lastSecondHitsPerSecond = (_hitCount - _lastSecondHitCount) / seconds;
+ _lastSecondUpdateCount = _updateCount;
+ _lastSecondHitTestCount = _hitTestCount;
+ _lastSecondHitCount = _hitCount;
+ _lastSecondTime = elapsed;
+
+ _stats.Text =
+ $"Visuals: {_cells.Length} ({_cells.Length / 5} animated), " +
+ $"Hit tests/frame: {_hitTestsPerFrame}, " +
+ $"Composition updates/s: {_lastSecondUpdatesPerSecond:F1}, Hit tests/s: {_lastSecondHitTestsPerSecond:F0}, " +
+ $"Hits/s: {_lastSecondHitsPerSecond:F0}, Misses/s: {_lastSecondHitTestsPerSecond - _lastSecondHitsPerSecond:F0}, " +
+ $"Clicks: {_clickCount}";
+ }
+
+ private void OnCellPointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (sender is not Cell cell)
+ return;
+
+ SetLastClickedCell(cell);
+ _clickCount++;
+ e.Handled = true;
+ }
+
+ private void SetLastHitCell(Cell cell)
+ {
+ if (ReferenceEquals(_lastHitCell, cell))
+ return;
+
+ _lastHitCell = cell;
+ }
+
+ private void SetLastClickedCell(Cell cell)
+ {
+ if (!ReferenceEquals(_lastClickedCell, cell) && _lastClickedCell != null)
+ {
+ _lastClickedCell.IsLatestClick = false;
+ _lastClickedCell.UpdateHighlight();
+ }
+
+ _lastClickedCell = cell;
+ cell.IsLatestClick = true;
+ cell.UpdateHighlight();
+ }
+
+ private static IBrush CreateBrush(int index)
+ {
+ var r = (byte)(80 + (index * 47 % 160));
+ var g = (byte)(80 + (index * 91 % 160));
+ var b = (byte)(80 + (index * 137 % 160));
+ return new SolidColorBrush(Color.FromRgb(r, g, b));
+ }
+
+ private sealed class Cell : Border
+ {
+ public Cell(int index)
+ {
+ Index = index;
+ BorderThickness = new Thickness(1);
+ }
+
+ public int Index { get; }
+ public bool IsLatestClick { get; set; }
+
+ public void ClearHighlight()
+ {
+ IsLatestClick = false;
+ UpdateHighlight();
+ }
+
+ public void UpdateHighlight()
+ {
+ BorderBrush = IsLatestClick ? Brushes.White : Brushes.Transparent;
+ ZIndex = IsLatestClick ? 1 : 0;
+ }
+ }
+ }
+}