From 603b14a4392602cf7622b65502a7eca3682272f8 Mon Sep 17 00:00:00 2001 From: Steve Date: Tue, 19 May 2026 10:05:22 +0000 Subject: [PATCH] Improve composition hit testing performance with per-visual AABBs (#21310) * Prototype an AABB tree for hit testing * Some optimizations * Revert runtime knobs and add tests * Avoid rebuilding the whole tree * Revert an invalid optimization * Add a hit test page * Clean up * Format code * Nit * Use weak CompositionVisual reference in server-side * Bucket composition hit-test AABB tree by child order * Use readback revisions for AABB hit-test updates --- samples/RenderDemo/MainWindow.xaml | 3 + samples/RenderDemo/Pages/HitTestingPage.cs | 401 ++++++++ .../Composition/CompositingRenderer.cs | 13 +- .../Composition/CompositionHitTestAabbTree.cs | 894 ++++++++++++++++++ .../Composition/CompositionTarget.cs | 163 +++- .../Rendering/Composition/ContainerVisual.cs | 70 ++ .../Rendering/Composition/VisualCollection.cs | 32 +- .../Rendering/CompositorHitTestingTests.cs | 134 +++ .../CompositorTestServices.cs | 2 +- 9 files changed, 1678 insertions(+), 34 deletions(-) create mode 100644 samples/RenderDemo/Pages/HitTestingPage.cs create mode 100644 src/Avalonia.Base/Rendering/Composition/CompositionHitTestAabbTree.cs 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); }