Skip to content

Improve hit testing performance by using R-tree - #21306

Closed
hez2010 wants to merge 7 commits into
AvaloniaUI:masterfrom
hez2010:rtree
Closed

Improve hit testing performance by using R-tree#21306
hez2010 wants to merge 7 commits into
AvaloniaUI:masterfrom
hez2010:rtree

Conversation

@hez2010

@hez2010 hez2010 commented May 4, 2026

Copy link
Copy Markdown
Contributor

What does the pull request do?

Added an R-tree for composition hit testing. Instead of recursively walking every composition visual for each hit test, CompositionTarget now queries spatial candidates from an R-tree, restores topmost ordering, and then runs the existing exact transform/clip/filter/custom-hit-test checks.

Also added a benchmark which uses a linear test as the reference baseline. Benchmark result:

Method VisualCount Mean Error StdDev Ratio RatioSD Gen0 Allocated Alloc Ratio
HitTestFirst_Linear 1024 11,516.9 ns 84.57 ns 79.11 ns 1.00 0.01 - 56 B 1.00
HitTestFirst_RTree 1024 191.7 ns 0.70 ns 0.62 ns 0.02 0.00 0.0014 24 B 0.43
HitTestFirst_Linear 4096 61,120.7 ns 1,019.11 ns 1,213.17 ns 1.000 0.03 - 56 B 1.00
HitTestFirst_RTree 4096 203.9 ns 0.84 ns 0.79 ns 0.003 0.00 0.0014 24 B 0.43
HitTestFirst_Linear 16384 290,090.0 ns 5,764.47 ns 9,143.06 ns 1.001 0.04 - 56 B 1.00
HitTestFirst_RTree 16384 202.6 ns 1.11 ns 0.99 ns 0.001 0.00 0.0014 24 B 0.43

The hit test performance improved by ~1430x with 16384 visuals, and reduced the allocation by 57%

What is the current behavior?

Composition hit testing walks the composition visual tree recursively. HitTestFirst also materializes the full hit list and then returns the first result, so pointer hit testing can scale linearly with the number of composition visuals.

What is the updated/expected behavior with this PR?

Hit testing first queries an R-tree of transformed render-data AABBs to reduce the candidate set, then verifies candidates with the same exact checks used by the previous implementation.

HitTestFirst now uses a direct first-hit path instead of collecting every matching visual first.

How was the solution implemented (if it's not obvious)?

The R-tree is used to reduce candidates: it does not decide the final hit result by itself. The candidate order is restored to existing topmost-first order before exact hit testing. Exact verification still performs existing checks like transforms, visibility, clipping and etc.

ICustomHitTest visuals are treated conservatively as unbounded candidates, since their hit region can extend outside normal render bounds.

Checklist

Breaking changes

No

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.1.999-cibuild0065200-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@kekekeks kekekeks left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are using render-thread-computed transforms for hit-testing. This means that they are affected by render thread animations. Which means that assuming that we can track hit-test tree validity from the UI thread is incorrect (we can, however, assume that readback revision bump indicates tree invalidation)

Another thing here is that the tree seems to be rebuilt entirely on every frame. This significantly affects the benchmark that needs to account tree rebuild amortization and subsequent allocation pressure.

@kekekeks

kekekeks commented May 4, 2026

Copy link
Copy Markdown
Member

What kind of visual structure are you using for benchmark? Somewhat balanced "natural" UI tree or 16K visuals attached directly to the tree root?

@hez2010

hez2010 commented May 4, 2026

Copy link
Copy Markdown
Contributor Author

We are using render-thread-computed transforms for hit-testing. This means that they are affected by render thread animations.

Will look into this.

Another thing here is that the tree seems to be rebuilt entirely on every frame.

I don't think the tree is rebuilt on every frame. It only gets rebuilt when the hit test index becomes dirty (see InvalidateHitTestIndex).

What kind of visual structure are you using for benchmark? Somewhat balanced "natural" UI tree or 16K visuals attached directly to the tree root?

I'm using the benchmark I included in this PR (tests/Avalonia.Benchmarks/Rendering/CompositionHitTesting.cs), which sets up a canvas and puts a lot of rectangles on it to form a grid, then hit tests against a point at the top level.

@kekekeks

kekekeks commented May 4, 2026

Copy link
Copy Markdown
Member

I suppose we don't really need an RTree for the entire composition target. Since visuals already include information about their subtree bounds we can opportunistically enable such indexing for direct children of visuals with >100 nodes. Would this make sense?

@hez2010

hez2010 commented May 4, 2026

Copy link
Copy Markdown
Contributor Author

Since visuals already include information about their subtree bounds we can opportunistically enable such indexing for direct children of visuals with >100 nodes.

Sounds like a BVH-like approach on top of AABB. So yeah, I think it should be fine as well. I will give it a try.

@hez2010

hez2010 commented May 4, 2026

Copy link
Copy Markdown
Contributor Author

Since visuals already include information about their subtree bounds we can opportunistically enable such indexing for direct children of visuals with >100 nodes.

Sounds like a BVH-like approach on top of AABB. So yeah, I think it should be fine as well. I will give it a try.

Can the subtree visuals be out-of-bound to their parent visual? If yes I think we will still need to build a separate index based on their transformed bounds.

@kekekeks

kekekeks commented May 4, 2026

Copy link
Copy Markdown
Member

We generally try to avoid effects that trigger a full tree walk on one visual change. This was a source of major perf problems with deep visual trees in 11.x.

i. e. consider a scene with animated progressbar at the corner. Since it technically invalidates composition target for every frame, it will trigger a full tree rebuild.

@kekekeks

kekekeks commented May 4, 2026

Copy link
Copy Markdown
Member

I think we also propagate readback revision bump to all parents (need to recheck this, but I think it should happen automatically since those are marked as having dirty subtree bounds), so it could potentially be useful for per-visual hittest tree cache invalidation.

@kekekeks

kekekeks commented May 4, 2026

Copy link
Copy Markdown
Member

Can the subtree visuals be out-of-bound to their parent visual?

Own bounds and subtree bounds are things we are tracking separately. Subtree bounds are guaranteed to contain all of child visuals. Existing hit-test code should already rely on this for filtering out nodes.

Note that bounds reported to the UI thread might be in visual's parent coordinate space (need to recheck, don't remember)

@hez2010

hez2010 commented May 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the background! I'm turning this PR into draft for now and will be back after investigating into some hierarchy-based approach (such as per-visual index cache).

@hez2010
hez2010 marked this pull request as draft May 4, 2026 09:30
@hez2010

hez2010 commented May 4, 2026

Copy link
Copy Markdown
Contributor Author

@kekekeks

Quickly prototyped a dynamic AABB tree for hit testing (per visual AABB tree) and benchmarked it against static visuals and animated visuals respectively. See bc3c5ba

It still has room for optimization, but I ran the benchmark anyway.

Static visual benchmarks:

public class CompositionHitTesting
{
    private const int CellSize = 8;
    private const int CellStride = 12;
    private const int TreeDepth = 4;

    private CompositorTestServices? _services;
    private Point _hitPoint;
    private Border? _expectedHit;

    [Params(1, 2, 4, 8, 16, 32, 64, 1024, 4096, 16384)]
    public int VisualCount { get; set; }

    [GlobalSetup]
    public void Setup()
    {
        var canvas = BuildGrid(VisualCount, out var size, out _expectedHit);

        _services = new CompositorTestServices(size);
        _services.TopLevel.Content = canvas;
        _services.RunJobs();

        _hitPoint = new Point(CellSize / 2d, CellSize / 2d);

        if (!ReferenceEquals(HitTestFirst(), _expectedHit))
            throw new InvalidOperationException("Hit test returned an unexpected visual.");
    }

    [GlobalCleanup]
    public void Cleanup()
    {
        _services?.Dispose();
        _services = null;
        _expectedHit = null;
    }

    [Benchmark]
    public Visual? HitTestFirst()
    {
        return _services!.Renderer.HitTestFirst(_hitPoint, _services.TopLevel, null);
    }

    internal static Canvas BuildGrid(int visualCount, out Size size, out Border? firstChild)
    {
        var columns = (int)Math.Ceiling(Math.Sqrt(visualCount));
        var rows = (visualCount + columns - 1) / columns;
        size = new Size(columns * CellStride, rows * CellStride);
        firstChild = null;

        var root = new Canvas
        {
            Width = size.Width,
            Height = size.Height
        };

        var leafHost = root;
        for (var depth = 0; depth < TreeDepth; depth++)
        {
            var nested = new Canvas
            {
                Width = size.Width,
                Height = size.Height
            };
            leafHost.Children.Add(nested);
            leafHost = nested;
        }

        for (var i = 0; i < visualCount; i++)
        {
            var child = new Border
            {
                Width = CellSize,
                Height = CellSize,
                Background = Brushes.Red
            };

            Canvas.SetLeft(child, i % columns * CellStride);
            Canvas.SetTop(child, i / columns * CellStride);
            leafHost.Children.Add(child);

            if (i == 0)
                firstChild = child;
        }

        return root;
    }
}
Method Job VisualCount Mean Error StdDev Gen0 Allocated
HitTestFirst AabbTree 1 486.0 ns 2.98 ns 2.93 ns 0.0229 360 B
HitTestFirst Linear 1 187.0 ns 1.05 ns 0.98 ns 0.0014 24 B
HitTestFirst AabbTree 2 476.9 ns 3.23 ns 3.02 ns 0.0229 360 B
HitTestFirst Linear 2 204.1 ns 0.81 ns 0.72 ns 0.0014 24 B
HitTestFirst AabbTree 4 474.6 ns 3.61 ns 3.38 ns 0.0229 360 B
HitTestFirst Linear 4 235.3 ns 1.01 ns 0.94 ns 0.0014 24 B
HitTestFirst AabbTree 8 479.6 ns 2.75 ns 2.43 ns 0.0229 360 B
HitTestFirst Linear 8 285.3 ns 1.26 ns 1.18 ns 0.0014 24 B
HitTestFirst AabbTree 16 483.0 ns 1.31 ns 1.16 ns 0.0229 360 B
HitTestFirst Linear 16 434.5 ns 2.59 ns 2.42 ns 0.0014 24 B
HitTestFirst AabbTree 32 484.6 ns 3.37 ns 3.16 ns 0.0229 360 B
HitTestFirst Linear 32 651.6 ns 2.42 ns 2.14 ns 0.0010 24 B
HitTestFirst AabbTree 64 488.6 ns 2.01 ns 1.88 ns 0.0229 360 B
HitTestFirst Linear 64 1,121.8 ns 3.39 ns 3.17 ns - 24 B
HitTestFirst AabbTree 1024 523.5 ns 2.57 ns 2.40 ns 0.0229 360 B
HitTestFirst Linear 1024 14,676.2 ns 40.48 ns 35.88 ns - 24 B
HitTestFirst AabbTree 4096 525.9 ns 3.01 ns 2.81 ns 0.0229 360 B
HitTestFirst Linear 4096 69,479.5 ns 1,201.35 ns 1,123.75 ns - 24 B
HitTestFirst AabbTree 16384 546.2 ns 1.93 ns 1.71 ns 0.0229 360 B
HitTestFirst Linear 16384 354,096.2 ns 6,229.39 ns 9,130.97 ns - 24 B

Animated visual benchmarks:

public class CompositionHitTestingAnimated
{
    private const int CellSize = 8;
    private const int CellStride = 12;
    private const int TreeDepth = 4;

    private CompositorTestServices? _services;
    private CompositionVisual? _animatedVisual;
    private Border? _expectedHit;
    private Point _hitPoint;

    [Params(1, 2, 4, 8, 16, 32, 64, 1024, 4096, 16384)]
    public int VisualCount { get; set; }

    [GlobalSetup]
    public void Setup()
    {
        var canvas = BuildDeepAnimatedGrid(VisualCount, out var size, out _expectedHit);

        _services = new CompositorTestServices(size);
        _services.TopLevel.Content = canvas;
        _services.RunJobs();

        _animatedVisual = _expectedHit!.CompositionVisual;
        StartOffsetAnimation();
        _services.RunJobs();
        UpdateHitPoint();

        if (!ReferenceEquals(HitTestAnimatedChild(), _expectedHit))
            throw new InvalidOperationException("Hit test returned an unexpected visual.");
    }

    [GlobalCleanup]
    public void Cleanup()
    {
        _services?.Dispose();
        _services = null;
        _animatedVisual = null;
        _expectedHit = null;
    }

    [Benchmark]
    public Visual? HitTestAnimatedChild()
    {
        _services!.RunJobs();
        UpdateHitPoint();
        return _services.Renderer.HitTestFirst(_hitPoint, _services.TopLevel, null);
    }

    private void StartOffsetAnimation()
    {
        var animation = _animatedVisual!.Compositor.CreateVector3KeyFrameAnimation();
        animation.Target = "Offset";
        animation.InsertKeyFrame(0f, new Vector3(CellStride, CellStride, 0), new LinearEasing());
        animation.InsertKeyFrame(1f, new Vector3(CellStride * 3, CellStride * 3, 0), new LinearEasing());
        animation.Duration = TimeSpan.FromSeconds(1);
        animation.Direction = PlaybackDirection.Alternate;
        animation.IterationBehavior = AnimationIterationBehavior.Forever;
        _animatedVisual.StartAnimation("Offset", animation);
    }

    private void UpdateHitPoint()
    {
        var server = _animatedVisual!.Server;
        var bounds = server.GetReadback(server.Compositor.Readback.LastCompletedWrite)!.TransformedSubtreeBounds!.Value;
        _hitPoint = new Point((bounds.Left + bounds.Right) / 2, (bounds.Top + bounds.Bottom) / 2);
    }

    private static Canvas BuildDeepAnimatedGrid(int visualCount, out Size size, out Border target)
    {
        var branchCount = Math.Min(8, Math.Max(1, visualCount / 64));
        var leavesPerBranch = (visualCount + branchCount - 1) / branchCount;
        var columns = (int)Math.Ceiling(Math.Sqrt(leavesPerBranch + 1));
        var rows = (leavesPerBranch + columns - 1) / columns;
        var branchSize = new Size(columns * CellStride + CellStride * 4, rows * CellStride + CellStride * 4);

        size = new Size(branchSize.Width * branchCount, branchSize.Height);
        target = null!;

        var root = new Canvas
        {
            Width = size.Width,
            Height = size.Height
        };

        var remaining = visualCount;
        for (var branch = 0; branch < branchCount; branch++)
        {
            var branchRoot = new Canvas
            {
                Width = branchSize.Width,
                Height = branchSize.Height
            };
            Canvas.SetLeft(branchRoot, branch * branchSize.Width);
            root.Children.Add(branchRoot);

            var leafHost = branchRoot;
            for (var depth = 0; depth < TreeDepth; depth++)
            {
                var nested = new Canvas
                {
                    Width = branchSize.Width,
                    Height = branchSize.Height
                };
                leafHost.Children.Add(nested);
                leafHost = nested;
            }

            var count = Math.Min(leavesPerBranch, remaining);
            remaining -= count;

            if (branch == 0)
                count--;

            for (var i = 0; i < count; i++)
            {
                var child = new Border
                {
                    Width = CellSize,
                    Height = CellSize,
                    Background = Brushes.Red
                };

                Canvas.SetLeft(child, (i % columns) * CellStride);
                Canvas.SetTop(child, (i / columns) * CellStride);
                leafHost.Children.Add(child);
            }

            if (branch == 0)
            {
                target = new Border
                {
                    Width = CellSize,
                    Height = CellSize,
                    Background = Brushes.Blue
                };

                Canvas.SetLeft(target, CellStride);
                Canvas.SetTop(target, CellStride);
                leafHost.Children.Add(target);
            }
        }

        return root;
    }
}
Method Job VisualCount Mean Error StdDev Gen0 Allocated
HitTestAnimatedChild AabbTree 1 2.178 us 0.0125 us 0.0104 us 0.0458 776 B
HitTestAnimatedChild Linear 1 1.541 us 0.0197 us 0.0256 us 0.0210 344 B
HitTestAnimatedChild AabbTree 2 2.269 us 0.0103 us 0.0086 us 0.0458 776 B
HitTestAnimatedChild Linear 2 1.466 us 0.0067 us 0.0056 us 0.0210 344 B
HitTestAnimatedChild AabbTree 4 2.247 us 0.0357 us 0.0334 us 0.0458 776 B
HitTestAnimatedChild Linear 4 1.524 us 0.0062 us 0.0051 us 0.0210 344 B
HitTestAnimatedChild AabbTree 8 2.456 us 0.0148 us 0.0123 us 0.0458 776 B
HitTestAnimatedChild Linear 8 1.714 us 0.0107 us 0.0089 us 0.0210 344 B
HitTestAnimatedChild AabbTree 16 2.793 us 0.0277 us 0.0245 us 0.0496 811 B
HitTestAnimatedChild Linear 16 2.082 us 0.0127 us 0.0106 us 0.0191 344 B
HitTestAnimatedChild AabbTree 32 3.497 us 0.0267 us 0.0237 us 0.0496 829 B
HitTestAnimatedChild Linear 32 2.657 us 0.0122 us 0.0102 us 0.0191 344 B
HitTestAnimatedChild AabbTree 64 4.942 us 0.0299 us 0.0265 us 0.0458 831 B
HitTestAnimatedChild Linear 64 3.968 us 0.0172 us 0.0144 us 0.0153 344 B
HitTestAnimatedChild AabbTree 1024 8.610 us 0.0915 us 0.0856 us 0.0458 829 B
HitTestAnimatedChild Linear 1024 7.227 us 0.0378 us 0.0335 us 0.0153 344 B
HitTestAnimatedChild AabbTree 4096 23.718 us 0.2997 us 0.2803 us 0.0305 829 B
HitTestAnimatedChild Linear 4096 22.574 us 0.1343 us 0.1256 us - 344 B
HitTestAnimatedChild AabbTree 16384 82.064 us 0.4120 us 0.3854 us - 833 B
HitTestAnimatedChild Linear 16384 85.043 us 0.5420 us 0.4526 us - 344 B

It seems that it may be better to set the threshold to 32?

@hez2010

hez2010 commented May 4, 2026

Copy link
Copy Markdown
Contributor Author

Will open a new PR with new numbers.

@hez2010 hez2010 closed this May 4, 2026
@kekekeks

kekekeks commented May 4, 2026

Copy link
Copy Markdown
Member

It seems that it may be better to set the threshold to 32?

I suspect that it will add allocation / memory footprint for little gains.

Also, the animated child benchmark is actually more informative: it indicates when building the tree and hit-testing starts to take roughly the same time as just hit-testing.

@hez2010

hez2010 commented May 5, 2026

Copy link
Copy Markdown
Contributor Author

It seems that it may be better to set the threshold to 32?

I suspect that it will add allocation / memory footprint for little gains.

Also, the animated child benchmark is actually more informative: it indicates when building the tree and hit-testing starts to take roughly the same time as just hit-testing.

I opened a new PR with updated numbers after some optimizations, and included an actual test page. See #21310.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants