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;
+ }
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs
index 2eca7e391f..cef5eb62de 100644
--- a/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs
+++ b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
-using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Diagnostics;
@@ -129,8 +128,16 @@ internal class CompositingRenderer : IRendererWithCompositor, IHitTester
///
public Visual? HitTestFirst(Point p, Visual root, Func? filter)
{
- // TODO: Optimize
- return HitTest(p, root, filter).FirstOrDefault();
+ using var _ = Diagnostic.PerformingHitTest();
+
+ if (root.CompositionVisual == null)
+ return null;
+
+ Func? f = filter is null
+ ? null :
+ v => v is not CompositionDrawListVisual dlv || filter(dlv.Visual);
+
+ return CompositionTarget.TryHitTestFirst(p, root.CompositionVisual, f, static v => v is CompositionDrawListVisual) is CompositionDrawListVisual dv ? dv.Visual : null;
}
///
diff --git a/src/Avalonia.Base/Rendering/Composition/CompositionHitTestAabbTree.cs b/src/Avalonia.Base/Rendering/Composition/CompositionHitTestAabbTree.cs
new file mode 100644
index 0000000000..f42c46a4a7
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/CompositionHitTestAabbTree.cs
@@ -0,0 +1,894 @@
+using System;
+using System.Buffers;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using Avalonia.Collections.Pooled;
+using Avalonia.Platform;
+
+namespace Avalonia.Rendering.Composition;
+
+internal sealed class CompositionHitTestAabbTree
+{
+ private const int Null = -1;
+ private const int OrderBucketSize = 32;
+ private const double FatBoundsPadding = 1;
+ private static readonly CandidateComparer s_candidateComparer = new();
+
+ private readonly CompositionVisualCollection _children;
+ private readonly Dictionary _entries = [];
+ private readonly List _buckets = [];
+ private readonly List _nodes = [];
+ private int _freeList = Null;
+
+ public CompositionHitTestAabbTree(CompositionVisualCollection children)
+ {
+ _children = children;
+
+ for (var i = 0; i < children.Count; i++)
+ Update(children[i], i);
+ }
+
+ public void Clear()
+ {
+ _entries.Clear();
+ _buckets.Clear();
+ _nodes.Clear();
+ _freeList = Null;
+ }
+
+ public void Update(CompositionVisual visual, int order)
+ {
+ ref var entry = ref CollectionsMarshal.GetValueRefOrAddDefault(_entries, visual, out var exists);
+ if (!exists)
+ {
+ var bucketIndex = GetBucketIndex(order);
+ entry = new Entry(order);
+ GetOrCreateBucket(bucketIndex);
+ }
+ else if (entry.Order != order)
+ {
+ UpdateOrderCore(visual, ref entry, order);
+ }
+
+ var state = GetBoundsState(visual, out var bounds, out var revision);
+ UpdateBounds(visual, ref entry, state, bounds, revision);
+ }
+
+ public void Remove(CompositionVisual visual)
+ {
+ if (!_entries.Remove(visual, out var entry))
+ return;
+
+ if (entry.Leaf != Null)
+ DestroyLeaf(entry.Leaf);
+ else if (entry.IsUnbounded)
+ RemoveUnbounded(visual, entry.Order);
+
+ RemoveBucketIfEmpty(GetBucketIndex(entry.Order));
+ }
+
+ public void UpdateOrder(CompositionVisual visual, int order)
+ {
+ ref var entry = ref CollectionsMarshal.GetValueRefOrAddDefault(_entries, visual, out var exists);
+ if (!exists)
+ {
+ entry = new Entry(order);
+ return;
+ }
+
+ if (entry.Order != order)
+ UpdateOrderCore(visual, ref entry, order);
+ }
+
+ private void UpdateOrderCore(CompositionVisual visual, ref Entry entry, int order)
+ {
+ var oldOrder = entry.Order;
+ var oldBucket = GetBucketIndex(oldOrder);
+ var newBucket = GetBucketIndex(order);
+ entry.Order = order;
+
+ if (entry.Leaf != Null)
+ {
+ MoveLeaf(entry.Leaf, order);
+ return;
+ }
+
+ if (entry.IsUnbounded)
+ {
+ MoveUnbounded(visual, oldOrder, order);
+ return;
+ }
+
+ if (oldBucket != newBucket)
+ RemoveBucketIfEmpty(oldBucket);
+ }
+
+ public void Query(Point point, PooledList results, ulong readbackRevision)
+ {
+ var candidates = ArrayPool.Shared.Rent(OrderBucketSize);
+ var stack = ArrayPool.Shared.Rent(16);
+ var candidateCount = 0;
+
+ try
+ {
+ for (var i = _buckets.Count - 1; i >= 0; i--)
+ {
+ UpdateBucket(i, readbackRevision);
+
+ var bucket = _buckets[i];
+ if (bucket.Root == Null && bucket.Unbounded is not { Count: > 0 })
+ continue;
+
+ candidateCount = 0;
+ var stackCount = 0;
+ QueryBucket(bucket, point, ref candidates, ref candidateCount, ref stack, ref stackCount);
+ candidates.AsSpan(0, candidateCount).Sort(s_candidateComparer);
+
+ for (var j = 0; j < candidateCount; j++)
+ results.Add(candidates[j].Visual);
+
+ candidates.AsSpan(0, candidateCount).Clear();
+ }
+ }
+ finally
+ {
+ candidates.AsSpan(0, candidateCount).Clear();
+ ArrayPool.Shared.Return(candidates);
+ ArrayPool.Shared.Return(stack);
+ }
+ }
+
+ public CompositionVisual? QueryFirst(CompositionTarget target, Point point, Func? filter, Func? resultFilter, ulong readbackRevision)
+ {
+ var candidates = ArrayPool.Shared.Rent(OrderBucketSize);
+ var stack = ArrayPool.Shared.Rent(16);
+ var candidateCount = 0;
+
+ try
+ {
+ for (var i = _buckets.Count - 1; i >= 0; i--)
+ {
+ UpdateBucket(i, readbackRevision);
+
+ var bucket = _buckets[i];
+ if (bucket.Root == Null && bucket.Unbounded is not { Count: > 0 })
+ continue;
+
+ candidateCount = 0;
+ var stackCount = 0;
+ QueryBucket(bucket, point, ref candidates, ref candidateCount, ref stack, ref stackCount);
+ candidates.AsSpan(0, candidateCount).Sort(s_candidateComparer);
+
+ for (var j = 0; j < candidateCount; j++)
+ {
+ var hit = target.HitTestFirstCore(candidates[j].Visual, point, filter, resultFilter);
+ if (hit != null)
+ return hit;
+ }
+
+ candidates.AsSpan(0, candidateCount).Clear();
+ }
+ }
+ finally
+ {
+ candidates.AsSpan(0, candidateCount).Clear();
+ ArrayPool.Shared.Return(candidates);
+ ArrayPool.Shared.Return(stack);
+ }
+
+ return null;
+ }
+
+ private void UpdateBucket(int bucketIndex, ulong readbackRevision)
+ {
+ ref var bucket = ref GetRef(_buckets, bucketIndex);
+ if (bucket.ReadbackRevision == readbackRevision)
+ return;
+
+ var end = Math.Min(_children.Count, (bucketIndex + 1) * OrderBucketSize);
+ for (var i = bucketIndex * OrderBucketSize; i < end; i++)
+ {
+ var visual = _children[i];
+ ref var entry = ref CollectionsMarshal.GetValueRefOrNullRef(_entries, visual);
+
+ var state = GetBoundsState(visual, out var bounds, out var revision);
+ if (entry.Revision != revision)
+ UpdateBounds(visual, ref entry, state, bounds, revision);
+ }
+
+ bucket.ReadbackRevision = readbackRevision;
+ }
+
+ private void UpdateBounds(CompositionVisual visual, ref Entry entry, BoundsState state, LtrbRect bounds, ulong revision)
+ {
+ entry.Revision = revision;
+
+ if (entry.Leaf != Null)
+ {
+ if (state == BoundsState.Bounded)
+ {
+ UpdateLeaf(entry.Leaf, bounds, entry.Order);
+ }
+ else
+ {
+ DestroyLeaf(entry.Leaf);
+ entry.Leaf = Null;
+ if (state == BoundsState.Unbounded)
+ AddUnbounded(visual, entry.Order, ref entry);
+ }
+
+ return;
+ }
+
+ if (entry.IsUnbounded)
+ {
+ if (state == BoundsState.Unbounded)
+ return;
+
+ RemoveUnbounded(visual, entry.Order);
+ entry.IsUnbounded = false;
+
+ if (state == BoundsState.Bounded)
+ entry.Leaf = CreateLeaf(visual, bounds, entry.Order);
+
+ return;
+ }
+
+ if (state == BoundsState.Bounded)
+ entry.Leaf = CreateLeaf(visual, bounds, entry.Order);
+ else if (state == BoundsState.Unbounded)
+ AddUnbounded(visual, entry.Order, ref entry);
+ }
+
+ private void QueryBucket(Bucket bucket, Point point, ref Candidate[] candidates, ref int candidateCount, ref int[] stack, ref int stackCount)
+ {
+ PushQueryNode(ref stack, ref stackCount, bucket.Root);
+
+ while (stackCount > 0)
+ {
+ var nodeIndex = stack[--stackCount];
+ var node = _nodes[nodeIndex];
+
+ if (!node.Bounds.Contains(point))
+ continue;
+
+ if (node.IsLeaf)
+ {
+ if (node.Visual != null)
+ AddCandidate(ref candidates, ref candidateCount, new Candidate(node.Visual, node.Order));
+ }
+ else
+ {
+ PushQueryNode(ref stack, ref stackCount, node.Child1);
+ PushQueryNode(ref stack, ref stackCount, node.Child2);
+ }
+ }
+
+ if (bucket.Unbounded != null)
+ foreach (var visual in bucket.Unbounded)
+ if (_entries.TryGetValue(visual, out var entry))
+ AddCandidate(ref candidates, ref candidateCount, new Candidate(visual, entry.Order));
+ }
+
+ private static void AddCandidate(ref Candidate[] candidates, ref int count, Candidate candidate)
+ {
+ if (count == candidates.Length)
+ Resize(ref candidates, count);
+
+ candidates[count++] = candidate;
+ }
+
+ private static void PushQueryNode(ref int[] stack, ref int count, int nodeIndex)
+ {
+ if (nodeIndex == Null)
+ return;
+
+ if (count == stack.Length)
+ Resize(ref stack, count);
+
+ stack[count++] = nodeIndex;
+ }
+
+ private static void Resize(ref T[] buffer, int count)
+ {
+ var resized = ArrayPool.Shared.Rent(buffer.Length * 2);
+ buffer.AsSpan(0, count).CopyTo(resized);
+ ArrayPool.Shared.Return(buffer, RuntimeHelpers.IsReferenceOrContainsReferences());
+ buffer = resized;
+ }
+
+ private static int GetBucketIndex(int order) => order / OrderBucketSize;
+
+ private ref Bucket GetOrCreateBucket(int bucketIndex)
+ {
+ while (_buckets.Count <= bucketIndex)
+ _buckets.Add(new Bucket(Null));
+
+ return ref GetRef(_buckets, bucketIndex);
+ }
+
+ private void RemoveBucketIfEmpty(int bucketIndex)
+ {
+ if (bucketIndex >= _buckets.Count)
+ return;
+
+ var lastRequiredBucketIndex = _children.Count == 0 ? Null : GetBucketIndex(_children.Count - 1);
+ bool removeTrailingBucket;
+ {
+ ref var bucket = ref GetRef(_buckets, bucketIndex);
+ if (bucket.Unbounded?.Count == 0)
+ bucket.Unbounded = null;
+
+ removeTrailingBucket = bucket.IsEmpty &&
+ bucketIndex == _buckets.Count - 1 &&
+ bucketIndex > lastRequiredBucketIndex;
+ }
+
+ if (!removeTrailingBucket)
+ return;
+
+ do
+ {
+ _buckets.RemoveAt(_buckets.Count - 1);
+ } while (_buckets.Count > 0 &&
+ _buckets.Count - 1 > lastRequiredBucketIndex &&
+ _buckets[^1].IsEmpty);
+ }
+
+ private static ref T GetRef(List items, int index) => ref CollectionsMarshal.AsSpan(items)[index];
+
+ private int CreateLeaf(CompositionVisual visual, LtrbRect bounds, int order)
+ {
+ var leaf = AllocateNode();
+ {
+ ref var node = ref GetRef(_nodes, leaf);
+ node.Bounds = Fatten(bounds);
+ node.Visual = visual;
+ node.Order = order;
+ node.Bucket = GetBucketIndex(order);
+ node.Height = 0;
+ }
+
+ InsertLeaf(leaf);
+ return leaf;
+ }
+
+ private void DestroyLeaf(int leaf)
+ {
+ RemoveLeaf(leaf);
+ FreeNode(leaf);
+ }
+
+ private void UpdateLeaf(int leaf, LtrbRect bounds, int order)
+ {
+ var bucket = GetBucketIndex(order);
+
+ {
+ ref var node = ref GetRef(_nodes, leaf);
+
+ // If the exact bounds still fit inside the fat bounds, the tree shape can stay unchanged.
+ if (node.Bucket == bucket && node.Bounds.Contains(bounds))
+ {
+ node.Order = order;
+ return;
+ }
+ }
+
+ RemoveLeaf(leaf);
+
+ {
+ ref var removedNode = ref GetRef(_nodes, leaf);
+ removedNode.Bounds = Fatten(bounds);
+ removedNode.Bucket = bucket;
+ removedNode.Order = order;
+ }
+
+ InsertLeaf(leaf);
+ }
+
+ private void MoveLeaf(int leaf, int order)
+ {
+ var bucket = GetBucketIndex(order);
+
+ {
+ ref var node = ref GetRef(_nodes, leaf);
+ if (node.Bucket == bucket)
+ {
+ node.Order = order;
+ return;
+ }
+ }
+
+ RemoveLeaf(leaf);
+
+ {
+ ref var removedNode = ref GetRef(_nodes, leaf);
+ removedNode.Order = order;
+ removedNode.Bucket = bucket;
+ }
+
+ InsertLeaf(leaf);
+ }
+
+ private int AllocateNode()
+ {
+ if (_freeList == Null)
+ {
+ _nodes.Add(new Node
+ {
+ Parent = Null,
+ Child1 = Null,
+ Child2 = Null,
+ Next = Null
+ });
+ return _nodes.Count - 1;
+ }
+
+ var index = _freeList;
+ ref var node = ref GetRef(_nodes, index);
+ _freeList = node.Next;
+ node.Parent = Null;
+ node.Child1 = Null;
+ node.Child2 = Null;
+ node.Next = Null;
+ node.Height = 0;
+ node.Visual = null;
+ node.Order = 0;
+ node.Bucket = 0;
+ return index;
+ }
+
+ private void FreeNode(int index)
+ {
+ ref var node = ref GetRef(_nodes, index);
+ node.Next = _freeList;
+ node.Parent = Null;
+ node.Child1 = Null;
+ node.Child2 = Null;
+ node.Height = -1;
+ node.Visual = null;
+ node.Order = 0;
+ node.Bucket = 0;
+ _freeList = index;
+ }
+
+ private void InsertLeaf(int leaf)
+ {
+ var bucketIndex = GetRef(_nodes, leaf).Bucket;
+ ref var bucket = ref GetOrCreateBucket(bucketIndex);
+
+ if (bucket.Root == Null)
+ {
+ bucket.Root = leaf;
+ ref var root = ref GetRef(_nodes, leaf);
+ root.Parent = Null;
+ return;
+ }
+
+ var leafBounds = GetRef(_nodes, leaf).Bounds;
+ var sibling = FindBestSibling(bucket.Root, leafBounds);
+ var oldParent = GetRef(_nodes, sibling).Parent;
+ var newParent = AllocateNode();
+
+ // Insert by replacing the chosen sibling with a new internal parent:
+ //
+ // Before: oldParent After: oldParent
+ // | |
+ // sibling newParent
+ // / \
+ // sibling leaf
+ ref var parentNode = ref GetRef(_nodes, newParent);
+ parentNode.Parent = oldParent;
+ parentNode.Bounds = leafBounds.Union(GetRef(_nodes, sibling).Bounds);
+ parentNode.Height = GetRef(_nodes, sibling).Height + 1;
+ parentNode.Child1 = sibling;
+ parentNode.Child2 = leaf;
+ parentNode.Visual = null;
+ parentNode.Bucket = bucketIndex;
+
+ ref var siblingNode = ref GetRef(_nodes, sibling);
+ siblingNode.Parent = newParent;
+
+ ref var leafNode = ref GetRef(_nodes, leaf);
+ leafNode.Parent = newParent;
+
+ if (oldParent == Null)
+ {
+ bucket.Root = newParent;
+ }
+ else
+ {
+ ref var oldParentNode = ref GetRef(_nodes, oldParent);
+ if (oldParentNode.Child1 == sibling)
+ oldParentNode.Child1 = newParent;
+ else
+ oldParentNode.Child2 = newParent;
+ }
+
+ FixAncestors(newParent);
+ }
+
+ private int FindBestSibling(int root, LtrbRect leafBounds)
+ {
+ var index = root;
+ while (!_nodes[index].IsLeaf)
+ {
+ var node = _nodes[index];
+ var child1 = node.Child1;
+ var child2 = node.Child2;
+ var area = Perimeter(node.Bounds);
+ var combinedArea = Perimeter(node.Bounds.Union(leafBounds));
+ var cost = 2 * combinedArea;
+ var inheritanceCost = 2 * (combinedArea - area);
+
+ var cost1 = GetInsertionCost(child1, leafBounds, inheritanceCost);
+ var cost2 = GetInsertionCost(child2, leafBounds, inheritanceCost);
+
+ // Stop descending when pairing with this internal node is already cheaper.
+ if (cost < cost1 && cost < cost2)
+ break;
+
+ index = cost1 < cost2 ? child1 : child2;
+ }
+
+ return index;
+ }
+
+ private double GetInsertionCost(int nodeIndex, LtrbRect leafBounds, double inheritanceCost)
+ {
+ var node = _nodes[nodeIndex];
+ var union = node.Bounds.Union(leafBounds);
+
+ if (node.IsLeaf)
+ return Perimeter(union) + inheritanceCost;
+
+ return Perimeter(union) - Perimeter(node.Bounds) + inheritanceCost;
+ }
+
+ private void RemoveLeaf(int leaf)
+ {
+ var bucketIndex = GetRef(_nodes, leaf).Bucket;
+ var removedRoot = false;
+ {
+ ref var bucket = ref GetRef(_buckets, bucketIndex);
+ if (leaf == bucket.Root)
+ {
+ bucket.Root = Null;
+ removedRoot = true;
+ }
+ }
+
+ if (removedRoot)
+ {
+ RemoveBucketIfEmpty(bucketIndex);
+ return;
+ }
+
+ var parent = GetRef(_nodes, leaf).Parent;
+ var parentNode = GetRef(_nodes, parent);
+ var grandParent = parentNode.Parent;
+ var sibling = parentNode.Child1 == leaf ? parentNode.Child2 : parentNode.Child1;
+
+ // Collapse the removed leaf's parent and promote the sibling.
+ if (grandParent != Null)
+ {
+ // Before: grandParent After: grandParent
+ // | |
+ // parent sibling
+ // / \
+ // leaf sibling
+ ref var grandParentNode = ref GetRef(_nodes, grandParent);
+ if (grandParentNode.Child1 == parent)
+ grandParentNode.Child1 = sibling;
+ else
+ grandParentNode.Child2 = sibling;
+
+ ref var siblingNode = ref GetRef(_nodes, sibling);
+ siblingNode.Parent = grandParent;
+
+ FreeNode(parent);
+ FixAncestors(grandParent);
+ }
+ else
+ {
+ // If the parent was the root, the sibling becomes the new root.
+ //
+ // Before: parent(root) After: sibling(root)
+ // / \
+ // leaf sibling
+ {
+ ref var bucket = ref GetRef(_buckets, bucketIndex);
+ bucket.Root = sibling;
+ }
+
+ ref var siblingNode = ref GetRef(_nodes, sibling);
+ siblingNode.Parent = Null;
+ FreeNode(parent);
+ }
+
+ {
+ ref var leafNode = ref GetRef(_nodes, leaf);
+ leafNode.Parent = Null;
+ }
+
+ RemoveBucketIfEmpty(bucketIndex);
+ }
+
+ private void FixAncestors(int index)
+ {
+ while (index != Null)
+ {
+ index = Balance(index);
+
+ ref var node = ref GetRef(_nodes, index);
+ var child1 = GetRef(_nodes, node.Child1);
+ var child2 = GetRef(_nodes, node.Child2);
+
+ // Ancestor bounds always cover both children after insert/remove/rotate.
+ node.Bounds = child1.Bounds.Union(child2.Bounds);
+ node.Height = 1 + Math.Max(child1.Height, child2.Height);
+
+ index = node.Parent;
+ }
+ }
+
+ private int Balance(int indexA)
+ {
+ var a = GetRef(_nodes, indexA);
+ if (a.IsLeaf || a.Height < 2)
+ return indexA;
+
+ var indexB = a.Child1;
+ var indexC = a.Child2;
+ var b = GetRef(_nodes, indexB);
+ var c = GetRef(_nodes, indexC);
+ var balance = c.Height - b.Height;
+
+ // The right subtree is heavier than the left. Rotate C up.
+ if (balance > 1)
+ return RotateCUp(indexA, indexB, indexC);
+
+ // The left subtree is heavier than the right. Rotate B up.
+ if (balance < -1)
+ return RotateBUp(indexA, indexB, indexC);
+
+ return indexA;
+ }
+
+ private int RotateCUp(int indexA, int indexB, int indexC)
+ {
+ // Rotate C above A:
+ //
+ // Before: A After, if F taller: C
+ // / \ / \
+ // B C A F
+ // / \ / \
+ // F G B G
+ //
+ // After, otherwise: C
+ // / \
+ // A G
+ // / \
+ // B F
+ ref var a = ref GetRef(_nodes, indexA);
+ ref var c = ref GetRef(_nodes, indexC);
+ var indexF = c.Child1;
+ var indexG = c.Child2;
+ ref var f = ref GetRef(_nodes, indexF);
+ ref var g = ref GetRef(_nodes, indexG);
+
+ c.Child1 = indexA;
+ c.Parent = a.Parent;
+ a.Parent = indexC;
+
+ // C takes A's old place in the parent chain.
+ ReplaceParentChild(indexA, indexC, c.Parent);
+
+ // Keep the taller C child with C, and move the other child under A.
+ if (f.Height > g.Height)
+ {
+ c.Child2 = indexF;
+ a.Child2 = indexG;
+ g.Parent = indexA;
+ a.Bounds = GetRef(_nodes, indexB).Bounds.Union(g.Bounds);
+ c.Bounds = a.Bounds.Union(f.Bounds);
+ a.Height = 1 + Math.Max(GetRef(_nodes, indexB).Height, g.Height);
+ c.Height = 1 + Math.Max(a.Height, f.Height);
+ }
+ else
+ {
+ c.Child2 = indexG;
+ a.Child2 = indexF;
+ f.Parent = indexA;
+ a.Bounds = GetRef(_nodes, indexB).Bounds.Union(f.Bounds);
+ c.Bounds = a.Bounds.Union(g.Bounds);
+ a.Height = 1 + Math.Max(GetRef(_nodes, indexB).Height, f.Height);
+ c.Height = 1 + Math.Max(a.Height, g.Height);
+ }
+
+ return indexC;
+ }
+
+ private int RotateBUp(int indexA, int indexB, int indexC)
+ {
+ // Rotate B above A:
+ //
+ // Before: A After, if D taller: B
+ // / \ / \
+ // B C A D
+ // / \ / \
+ // D E E C
+ //
+ // After, otherwise: B
+ // / \
+ // A E
+ // / \
+ // D C
+ ref var a = ref GetRef(_nodes, indexA);
+ ref var b = ref GetRef(_nodes, indexB);
+ var indexD = b.Child1;
+ var indexE = b.Child2;
+ ref var d = ref GetRef(_nodes, indexD);
+ ref var e = ref GetRef(_nodes, indexE);
+
+ b.Child1 = indexA;
+ b.Parent = a.Parent;
+ a.Parent = indexB;
+
+ // B takes A's old place in the parent chain.
+ ReplaceParentChild(indexA, indexB, b.Parent);
+
+ // Keep the taller B child with B, and move the other child under A.
+ if (d.Height > e.Height)
+ {
+ b.Child2 = indexD;
+ a.Child1 = indexE;
+ e.Parent = indexA;
+ a.Bounds = GetRef(_nodes, indexC).Bounds.Union(e.Bounds);
+ b.Bounds = a.Bounds.Union(d.Bounds);
+ a.Height = 1 + Math.Max(GetRef(_nodes, indexC).Height, e.Height);
+ b.Height = 1 + Math.Max(a.Height, d.Height);
+ }
+ else
+ {
+ b.Child2 = indexE;
+ a.Child1 = indexD;
+ d.Parent = indexA;
+ a.Bounds = GetRef(_nodes, indexC).Bounds.Union(d.Bounds);
+ b.Bounds = a.Bounds.Union(e.Bounds);
+ a.Height = 1 + Math.Max(GetRef(_nodes, indexC).Height, d.Height);
+ b.Height = 1 + Math.Max(a.Height, e.Height);
+ }
+
+ return indexB;
+ }
+
+ private void ReplaceParentChild(int oldChild, int newChild, int parent)
+ {
+ if (parent == Null)
+ {
+ ref var bucket = ref GetRef(_buckets, GetRef(_nodes, newChild).Bucket);
+ bucket.Root = newChild;
+ return;
+ }
+
+ ref var parentNode = ref GetRef(_nodes, parent);
+ if (parentNode.Child1 == oldChild)
+ parentNode.Child1 = newChild;
+ else
+ parentNode.Child2 = newChild;
+ }
+
+ private void AddUnbounded(CompositionVisual visual, int order, ref Entry entry)
+ {
+ var bucketIndex = GetBucketIndex(order);
+ ref var bucket = ref GetOrCreateBucket(bucketIndex);
+ (bucket.Unbounded ??= []).Add(visual);
+ entry.IsUnbounded = true;
+ }
+
+ private void MoveUnbounded(CompositionVisual visual, int oldOrder, int order)
+ {
+ var oldBucketIndex = GetBucketIndex(oldOrder);
+ var newBucketIndex = GetBucketIndex(order);
+
+ if (oldBucketIndex == newBucketIndex)
+ return;
+
+ RemoveUnbounded(visual, oldOrder);
+ ref var newBucket = ref GetOrCreateBucket(newBucketIndex);
+ (newBucket.Unbounded ??= []).Add(visual);
+ }
+
+ private void RemoveUnbounded(CompositionVisual visual, int order)
+ {
+ var bucketIndex = GetBucketIndex(order);
+ {
+ ref var bucket = ref GetRef(_buckets, bucketIndex);
+ bucket.Unbounded!.Remove(visual);
+ }
+
+ RemoveBucketIfEmpty(bucketIndex);
+ }
+
+ private static BoundsState GetBoundsState(CompositionVisual visual, out LtrbRect bounds, out ulong revision)
+ {
+ bounds = default;
+ revision = 0;
+
+ var readback = visual.TryGetValidReadback();
+ if (readback == null)
+ return BoundsState.Empty;
+ revision = readback.Revision;
+
+ if (visual.DisableSubTreeBoundsHitTestOptimization)
+ return BoundsState.Unbounded;
+
+ if (readback.TransformedSubtreeBounds is not { } subtreeBounds || subtreeBounds.IsZeroSize)
+ return BoundsState.Empty;
+
+ bounds = subtreeBounds;
+ return BoundsState.Bounded;
+ }
+
+ // Fatten the bounds by a small amount to avoid having to update the tree for every tiny movement.
+ private static LtrbRect Fatten(LtrbRect bounds) =>
+ new(bounds.Left - FatBoundsPadding,
+ bounds.Top - FatBoundsPadding,
+ bounds.Right + FatBoundsPadding,
+ bounds.Bottom + FatBoundsPadding);
+
+ private static double Perimeter(LtrbRect bounds) => 2 * (bounds.Width + bounds.Height);
+
+ private enum BoundsState
+ {
+ Empty,
+ Bounded,
+ Unbounded
+ }
+
+ private struct Node
+ {
+ public LtrbRect Bounds;
+ public CompositionVisual? Visual;
+ public int Parent;
+ public int Child1;
+ public int Child2;
+ public int Next;
+ public int Height;
+ public int Order;
+ public int Bucket;
+
+ public readonly bool IsLeaf => Child1 == Null;
+ }
+
+ private struct Bucket(int root)
+ {
+ public int Root = root;
+ public List? Unbounded = null;
+ public ulong ReadbackRevision;
+
+ public readonly bool IsEmpty => Root == Null && (Unbounded == null || Unbounded.Count == 0);
+ }
+
+ private struct Entry(int order)
+ {
+ public int Order = order;
+ public int Leaf = Null;
+ public ulong Revision;
+ public bool IsUnbounded;
+ }
+
+ private readonly struct Candidate(CompositionVisual visual, int order)
+ {
+ public CompositionVisual Visual { get; } = visual;
+ public int Order { get; } = order;
+ }
+
+ private sealed class CandidateComparer : IComparer
+ {
+ // Higher child order is topmost, sort descending.
+ public int Compare(Candidate left, Candidate right) => right.Order.CompareTo(left.Order);
+ }
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/CompositionTarget.cs b/src/Avalonia.Base/Rendering/Composition/CompositionTarget.cs
index 659d379517..8b418211b8 100644
--- a/src/Avalonia.Base/Rendering/Composition/CompositionTarget.cs
+++ b/src/Avalonia.Base/Rendering/Composition/CompositionTarget.cs
@@ -1,5 +1,4 @@
using System;
-using System.Collections.Generic;
using System.Numerics;
using Avalonia.Collections.Pooled;
using Avalonia.VisualTree;
@@ -11,6 +10,9 @@ namespace Avalonia.Rendering.Composition
///
internal partial class CompositionTarget
{
+ private readonly PooledList _hitTestChildCandidates = [];
+ private bool _hitTestChildCandidatesInUse;
+
partial void OnRootChanged()
{
if (Root != null)
@@ -30,12 +32,11 @@ namespace Avalonia.Rendering.Composition
public PooledList? TryHitTest(Point point, CompositionVisual? root, Func? filter)
{
Server.Compositor.Readback.NextRead();
+
root ??= Root;
if (root == null)
return null;
-
- var res = new PooledList();
-
+
// Need to convert transform the point using visual's readback since HitTestCore will use its inverse matrix
// NOTE: it can technically break hit-testing of the root visual itself if it has a non-identity transform,
// need to investigate that possibility later. We might want a separate mode for root hit-testing.
@@ -43,53 +44,159 @@ namespace Avalonia.Rendering.Composition
if (readback == null)
return null;
point = point.Transform(readback.Matrix);
-
+
+ var res = new PooledList();
HitTestCore(root, point, res, filter);
return res;
}
-
- void HitTestCore(CompositionVisual visual, Point parentPoint, PooledList result,
- Func? filter)
+
+ private PooledList RentHitTestChildCandidates(out bool releaseToField)
{
- if (visual.Visible == false)
+ if (!_hitTestChildCandidatesInUse)
+ {
+ _hitTestChildCandidatesInUse = true;
+ releaseToField = true;
+ _hitTestChildCandidates.Clear();
+ return _hitTestChildCandidates;
+ }
+
+ releaseToField = false;
+ return [];
+ }
+
+ private void ReleaseHitTestChildCandidates(PooledList candidates, bool releaseToField)
+ {
+ if (releaseToField)
+ {
+ candidates.Clear();
+ _hitTestChildCandidatesInUse = false;
+ }
+ else
+ {
+ candidates.Dispose();
+ }
+ }
+
+ private void HitTestCore(CompositionVisual visual, Point parentPoint, PooledList result, Func? filter)
+ {
+ if (!HitTestVisual(visual, parentPoint, filter, out var point))
return;
+
+ // Inspect children
+ if (visual is CompositionContainerVisual cv)
+ HitTestChildren(cv, point, result, filter);
+ // Hit-test the current node
+ if (visual.HitTest(point))
+ result.Add(visual);
+ }
+
+ private void HitTestChildren(CompositionContainerVisual visual, Point point, PooledList result, Func? filter)
+ {
+ if (visual.Children.Count >= CompositionContainerVisual.HitTestAabbTreeThreshold)
+ {
+ var candidates = RentHitTestChildCandidates(out var releaseToField);
+ try
+ {
+ if (visual.TryQueryHitTestChildren(point, candidates))
+ {
+ foreach (var child in candidates)
+ HitTestCore(child, point, result, filter);
+ return;
+ }
+ }
+ finally
+ {
+ ReleaseHitTestChildCandidates(candidates, releaseToField);
+ }
+ }
+
+ for (var c = visual.Children.Count - 1; c >= 0; c--)
+ HitTestCore(visual.Children[c], point, result, filter);
+ }
+
+ private static bool HitTestVisual(CompositionVisual visual, Point parentPoint, Func? filter, out Point point)
+ {
+ point = default;
+
+ if (visual.Visible == false)
+ return false;
+
if (filter != null && !filter(visual))
- return;
+ return false;
var readback = visual.TryGetValidReadback();
if(readback == null)
- return;
-
+ return false;
if (!visual.DisableSubTreeBoundsHitTestOptimization &&
(readback.TransformedSubtreeBounds == null ||
!readback.TransformedSubtreeBounds.Value.Contains(parentPoint)))
- return;
-
+ return false;
+
if(!readback.Matrix.TryInvert(out var invMatrix))
- return;
+ return false;
+
+ point = parentPoint.Transform(invMatrix);
- var point = parentPoint.Transform(invMatrix);
-
if (visual.ClipToBounds
&& (point.X < 0 || point.Y < 0 || point.X > visual.Size.X || point.Y > visual.Size.Y))
- return;
+ return false;
if (visual.Clip?.FillContains(point) == false)
- return;
-
- // Inspect children
+ return false;
+
+ return true;
+ }
+
+ public CompositionVisual? TryHitTestFirst(Point point, CompositionVisual? root, Func? filter, Func? resultFilter)
+ {
+ Server.Compositor.Readback.NextRead();
+
+ root ??= Root;
+ if (root == null)
+ return null;
+
+ // Need to convert transform the point using visual's readback since HitTestCore will use its inverse matrix
+ // NOTE: it can technically break hit-testing of the root visual itself if it has a non-identity transform,
+ // need to investigate that possibility later. We might want a separate mode for root hit-testing.
+ var readback = root.TryGetValidReadback();
+ if (readback == null)
+ return null;
+
+ return HitTestFirstCore(root, point.Transform(readback.Matrix), filter, resultFilter);
+ }
+
+ internal CompositionVisual? HitTestFirstCore(CompositionVisual visual, Point parentPoint, Func? filter, Func? resultFilter)
+ {
+ if (!HitTestVisual(visual, parentPoint, filter, out var point))
+ return null;
+
if (visual is CompositionContainerVisual cv)
- for (var c = cv.Children.Count - 1; c >= 0; c--)
+ {
+ var queriedIndexedChildren = false;
+ if (cv.Children.Count >= CompositionContainerVisual.HitTestAabbTreeThreshold)
{
- var ch = cv.Children[c];
- HitTestCore(ch, point, result, filter);
+ if (cv.TryQueryFirstHitTestChild(this, point, filter, resultFilter, out var hit))
+ {
+ queriedIndexedChildren = true;
+ if (hit != null)
+ return hit;
+ }
}
-
- // Hit-test the current node
- if (visual.HitTest(point))
- result.Add(visual);
+
+ if (!queriedIndexedChildren)
+ {
+ for (var c = cv.Children.Count - 1; c >= 0; c--)
+ {
+ var hit = HitTestFirstCore(cv.Children[c], point, filter, resultFilter);
+ if (hit != null)
+ return hit;
+ }
+ }
+ }
+
+ return visual.HitTest(point) && (resultFilter == null || resultFilter(visual)) ? visual : null;
}
///
diff --git a/src/Avalonia.Base/Rendering/Composition/ContainerVisual.cs b/src/Avalonia.Base/Rendering/Composition/ContainerVisual.cs
index f79828c9b4..ceaf18ee0c 100644
--- a/src/Avalonia.Base/Rendering/Composition/ContainerVisual.cs
+++ b/src/Avalonia.Base/Rendering/Composition/ContainerVisual.cs
@@ -1,3 +1,5 @@
+using System;
+using Avalonia.Collections.Pooled;
using Avalonia.Rendering.Composition.Server;
namespace Avalonia.Rendering.Composition
@@ -7,6 +9,9 @@ namespace Avalonia.Rendering.Composition
///
public partial class CompositionContainerVisual : CompositionVisual
{
+ internal const int HitTestAabbTreeThreshold = 32;
+ private CompositionHitTestAabbTree? _hitTestChildren;
+
public CompositionVisualCollection Children { get; private set; } = null!;
partial void InitializeDefaultsExtra()
@@ -20,5 +25,70 @@ namespace Avalonia.Rendering.Composition
ch.Root = Root;
base.OnRootChangedCore();
}
+
+ internal void AddHitTestChild(CompositionVisual child)
+ {
+ if (_hitTestChildren == null)
+ return;
+
+ var order = Children.IndexOf(child);
+ if (order >= 0)
+ _hitTestChildren.Update(child, order);
+
+ UpdateHitTestChildOrder();
+ }
+
+ internal void RemoveHitTestChild(CompositionVisual child)
+ {
+ if (_hitTestChildren == null)
+ return;
+
+ _hitTestChildren.Remove(child);
+
+ UpdateHitTestChildOrder();
+ }
+
+ internal void ClearHitTestChildren()
+ {
+ _hitTestChildren?.Clear();
+ }
+
+ internal bool TryQueryHitTestChildren(Point point, PooledList results)
+ {
+ if (Children.Count < HitTestAabbTreeThreshold)
+ {
+ _hitTestChildren = null;
+ return false;
+ }
+
+ _hitTestChildren ??= new CompositionHitTestAabbTree(Children);
+
+ _hitTestChildren.Query(point, results, Server.Compositor.Readback.ReadRevision);
+ return true;
+ }
+
+ internal bool TryQueryFirstHitTestChild(CompositionTarget target, Point point, Func? filter, Func? resultFilter, out CompositionVisual? hit)
+ {
+ if (Children.Count < HitTestAabbTreeThreshold)
+ {
+ _hitTestChildren = null;
+ hit = null;
+ return false;
+ }
+
+ _hitTestChildren ??= new CompositionHitTestAabbTree(Children);
+
+ hit = _hitTestChildren.QueryFirst(target, point, filter, resultFilter, Server.Compositor.Readback.ReadRevision);
+ return true;
+ }
+
+ private void UpdateHitTestChildOrder()
+ {
+ if (_hitTestChildren == null)
+ return;
+
+ for (var i = 0; i < Children.Count; i++)
+ _hitTestChildren.UpdateOrder(Children[i], i);
+ }
}
}
diff --git a/src/Avalonia.Base/Rendering/Composition/VisualCollection.cs b/src/Avalonia.Base/Rendering/Composition/VisualCollection.cs
index 5663b86af3..7ada536938 100644
--- a/src/Avalonia.Base/Rendering/Composition/VisualCollection.cs
+++ b/src/Avalonia.Base/Rendering/Composition/VisualCollection.cs
@@ -38,7 +38,11 @@ namespace Avalonia.Rendering.Composition
public void RemoveAll() => Clear();
- partial void OnAdded(CompositionVisual item) => item.Parent = _owner;
+ partial void OnAdded(CompositionVisual item)
+ {
+ item.Parent = _owner;
+ AddHitTestChild(item);
+ }
partial void OnBeforeReplace(CompositionVisual oldItem, CompositionVisual newItem)
{
@@ -55,7 +59,11 @@ namespace Avalonia.Rendering.Composition
}
}
- partial void OnRemoved(CompositionVisual item) => item.Parent = null;
+ partial void OnRemoved(CompositionVisual item)
+ {
+ item.Parent = null;
+ RemoveHitTestChild(item);
+ }
partial void OnBeforeClear()
{
@@ -63,11 +71,31 @@ namespace Avalonia.Rendering.Composition
i.Parent = null;
}
+ partial void OnClear() => ClearHitTestChildren();
+
partial void OnBeforeAdded(CompositionVisual item)
{
if (item.Parent != null)
throw new InvalidOperationException("Visual already has a parent");
item.Parent = _owner;
}
+
+ private void AddHitTestChild(CompositionVisual item)
+ {
+ if (_owner is CompositionContainerVisual container)
+ container.AddHitTestChild(item);
+ }
+
+ private void RemoveHitTestChild(CompositionVisual item)
+ {
+ if (_owner is CompositionContainerVisual container)
+ container.RemoveHitTestChild(item);
+ }
+
+ private void ClearHitTestChildren()
+ {
+ if (_owner is CompositionContainerVisual container)
+ container.ClearHitTestChildren();
+ }
}
}
diff --git a/tests/Avalonia.Base.UnitTests/Rendering/CompositorHitTestingTests.cs b/tests/Avalonia.Base.UnitTests/Rendering/CompositorHitTestingTests.cs
index 7cd02d2907..6fe481a517 100644
--- a/tests/Avalonia.Base.UnitTests/Rendering/CompositorHitTestingTests.cs
+++ b/tests/Avalonia.Base.UnitTests/Rendering/CompositorHitTestingTests.cs
@@ -8,6 +8,7 @@ using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Rendering;
+using Avalonia.Rendering.Composition;
using Avalonia.UnitTests;
using Avalonia.VisualTree;
using Moq;
@@ -494,6 +495,139 @@ public class CompositorHitTestingTests : CompositorTestsBase
}
}
+ [Fact]
+ public void HitTestFirst_Should_Skip_Element_Child_Composition_Visual()
+ {
+ using (var s = new CompositorTestServices(new Size(200, 200)))
+ {
+ var target = new Border
+ {
+ Width = 200,
+ Height = 200,
+ Background = Brushes.Red
+ };
+
+ s.TopLevel.Content = target;
+ s.RunJobs();
+
+ var childVisual = s.Compositor.CreateSolidColorVisual();
+ childVisual.Size = new Vector(200, 200);
+ childVisual.Color = Colors.Blue;
+ ElementComposition.SetElementChildVisual(target, childVisual);
+
+ s.AssertHitTestFirst(new Point(100, 100), null, target);
+ }
+ }
+
+ [Fact]
+ public void HitTest_Should_Find_Control_With_Many_Siblings()
+ {
+ using (var s = new CompositorTestServices(new Size(1000, 200)))
+ {
+ Border target = null!;
+ var canvas = new Canvas { Width = 1000, Height = 200 };
+
+ for (var i = 0; i < 70; i++)
+ {
+ var child = new Border { Width = 8, Height = 8, Background = Brushes.Red };
+ Canvas.SetLeft(child, i * 12);
+ canvas.Children.Add(child);
+
+ if (i == 0)
+ target = child;
+ }
+
+ s.TopLevel.Content = canvas;
+ s.AssertHitTestFirst(new Point(4, 4), null, target);
+ }
+ }
+
+ [Fact]
+ public void HitTest_Should_Return_Top_Controls_First_With_Many_Overlapping_Siblings()
+ {
+ using (var s = new CompositorTestServices(new Size(200, 200)))
+ {
+ Border top = null!;
+ var canvas = new Canvas { Width = 200, Height = 200 };
+
+ for (var i = 0; i < 70; i++)
+ {
+ var child = new Border { Width = 100, Height = 100, Background = Brushes.Red };
+ Canvas.SetLeft(child, 50);
+ Canvas.SetTop(child, 50);
+ canvas.Children.Add(child);
+
+ if (i == 69)
+ top = child;
+ }
+
+ s.TopLevel.Content = canvas;
+ s.AssertHitTestFirst(new Point(100, 100), null, top);
+ s.AssertHitTest(new Point(100, 100), null, canvas.Children.Cast().Reverse().ToArray());
+ }
+ }
+
+ [Fact]
+ public void HitTest_Should_Update_Many_Sibling_Index_When_Child_Moves()
+ {
+ using (var s = new CompositorTestServices(new Size(1000, 200)))
+ {
+ Border moving = null!;
+ var canvas = new Canvas { Width = 1000, Height = 200 };
+
+ for (var i = 0; i < 70; i++)
+ {
+ var child = new Border { Width = 8, Height = 8, Background = Brushes.Red };
+ Canvas.SetLeft(child, i * 12);
+ canvas.Children.Add(child);
+
+ if (i == 69)
+ moving = child;
+ }
+
+ s.TopLevel.Content = canvas;
+ s.AssertHitTestFirst(new Point(69 * 12 + 4, 4), null, moving);
+
+ Canvas.SetLeft(moving, 10);
+ Canvas.SetTop(moving, 100);
+ s.AssertHitTestFirst(new Point(14, 104), null, moving);
+ }
+ }
+
+ [Fact]
+ public void HitTest_Should_Update_Many_Sibling_Index_When_Child_Is_Added_And_Removed()
+ {
+ using (var s = new CompositorTestServices(new Size(200, 200)))
+ {
+ Border top = null!;
+ var canvas = new Canvas { Width = 200, Height = 200 };
+
+ for (var i = 0; i < 70; i++)
+ {
+ var child = new Border { Width = 100, Height = 100, Background = Brushes.Red };
+ Canvas.SetLeft(child, 50);
+ Canvas.SetTop(child, 50);
+ canvas.Children.Add(child);
+
+ if (i == 69)
+ top = child;
+ }
+
+ s.TopLevel.Content = canvas;
+ s.AssertHitTestFirst(new Point(100, 100), null, top);
+
+ var added = new Border { Width = 100, Height = 100, Background = Brushes.Blue };
+ Canvas.SetLeft(added, 50);
+ Canvas.SetTop(added, 50);
+ canvas.Children.Add(added);
+
+ s.AssertHitTestFirst(new Point(100, 100), null, added);
+
+ canvas.Children.Remove(added);
+ s.AssertHitTestFirst(new Point(100, 100), null, top);
+ }
+ }
+
private static IDisposable TestApplication()
{
return UnitTestApplication.Start(TestServices.MockPlatformRenderInterface);
diff --git a/tests/Avalonia.UnitTests/CompositorTestServices.cs b/tests/Avalonia.UnitTests/CompositorTestServices.cs
index 7fd5ae6ad8..14320ee4da 100644
--- a/tests/Avalonia.UnitTests/CompositorTestServices.cs
+++ b/tests/Avalonia.UnitTests/CompositorTestServices.cs
@@ -110,7 +110,7 @@ public class CompositorTestServices : IDisposable
public void AssertHitTestFirst(Point pt, Func? filter, Visual? expected)
{
RunJobs();
- var tested = Renderer.HitTest(pt, TopLevel, filter).First();
+ var tested = Renderer.HitTestFirst(pt, TopLevel, filter);
Assert.Equal(expected, tested);
}