Skip to content

Commit 1bd7860

Browse files
authored
CompositionBrush corrections (#21873)
* Add failing tests for composition gradient brush change tracking Three defects in the unreleased CompositionBrush surface: - Replacing GradientStops after the first commit never reaches the server: the hand-written property registers nothing for serialization, so the change only ships once an unrelated tracked property dirties the brush. - SpreadMethod has the same gap. - A mutable Media.GradientStop crosses the batch by reference and the render thread then reads a UI-thread object at replay time. - A CompositionBrush resolves its server object for any compositor, so a foreign compositor ends up sharing a resource across render loops. * Track composition gradient brush changes and respect compositor affinity - GradientStops and SpreadMethod now register for serialization when assigned, so changes made after the first commit reach the server without waiting for an unrelated tracked property to dirty the brush. In-place list mutation stays untracked and is documented as requiring re-assignment. - Non-composition gradient stops are snapshotted to ImmutableGradientStop at serialization time instead of crossing the batch by reference, since the render thread reads the server list at replay time. - Resolving a CompositionBrush for a different compositor now throws instead of silently wiring one server resource into two render loops. Transient contexts without a compositor keep receiving the client brush unchanged.
1 parent 06cf8ef commit 1bd7860

3 files changed

Lines changed: 149 additions & 3 deletions

File tree

src/Avalonia.Base/Rendering/Composition/Brushes/CompositionBrush.cs

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Collections.Generic;
22
using Avalonia.Media;
3+
using Avalonia.Media.Immutable;
34
using Avalonia.Rendering.Composition;
45
using Avalonia.Rendering.Composition.Server;
56
using Avalonia.Rendering.Composition.Transport;
@@ -42,10 +43,47 @@ partial class CompositionConicGradientBrush : IConicGradientBrush
4243

4344
public abstract partial class CompositionGradientBrush : CompositionBrush, IGradientBrush
4445
{
46+
private List<IGradientStop> _gradientStops = [];
47+
private GradientSpreadMethod _spreadMethod;
48+
4549
internal new ServerCompositionGradientBrush Server { get; }
46-
public List<IGradientStop> GradientStops { get; set; } = [];
50+
51+
/// <summary>
52+
/// The gradient stops. Mutations of the list itself are not tracked - assign
53+
/// the property to ship in-place edits made after a commit. Stops created via
54+
/// <see cref="Compositor.CreateGradientStop(double, Media.Color)"/> stay live
55+
/// on the server and animate individually; other stops are snapshotted at
56+
/// serialization time.
57+
/// </summary>
58+
public List<IGradientStop> GradientStops
59+
{
60+
get => _gradientStops;
61+
set
62+
{
63+
if (ReferenceEquals(_gradientStops, value))
64+
return;
65+
_gradientStops = value;
66+
RegisterForSerialization();
67+
}
68+
}
69+
4770
IReadOnlyList<IGradientStop> IGradientBrush.GradientStops => GradientStops;
48-
public GradientSpreadMethod SpreadMethod { get; set; }
71+
72+
/// <summary>
73+
/// How the gradient repeats outside the stop range.
74+
/// </summary>
75+
public GradientSpreadMethod SpreadMethod
76+
{
77+
get => _spreadMethod;
78+
set
79+
{
80+
if (_spreadMethod == value)
81+
return;
82+
_spreadMethod = value;
83+
RegisterForSerialization();
84+
}
85+
}
86+
4987
partial void OnRootChanged();
5088
partial void OnRootChanging();
5189

@@ -63,7 +101,10 @@ private protected override void SerializeChangesCore(BatchStreamWriter writer)
63101
if (stop is CompositionGradientStop comp)
64102
writer.WriteObject(comp.Server);
65103
else
66-
writer.WriteObject(stop);
104+
// A mutable UI-thread stop must not cross to the render thread
105+
// by reference; ship its current values instead.
106+
writer.WriteObject(stop as ImmutableGradientStop
107+
?? new ImmutableGradientStop(stop.Offset, stop.Color));
67108
}
68109
}
69110
}

src/Avalonia.Base/Rendering/Composition/Drawing/ServerResourceHelperExtensions.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,14 @@ static class ServerResourceHelperExtensions
2020
if (brush is ICompositionRenderResource<IBrush> resource)
2121
return resource.GetForCompositor(compositor);
2222
if (brush is CompositionBrush compositionBrush)
23+
{
24+
// The server object belongs to its own compositor's render loop;
25+
// handing it to another compositor would share one resource across
26+
// two render threads.
27+
if (compositionBrush.Compositor != compositor)
28+
ThrowForeignCompositor(compositionBrush);
2329
return compositionBrush.Server;
30+
}
2431
ThrowNotCompatible(brush);
2532
return null;
2633
}
@@ -42,6 +49,10 @@ static class ServerResourceHelperExtensions
4249
[MethodImpl(MethodImplOptions.NoInlining), DoesNotReturn]
4350
static void ThrowNotCompatible(object o) =>
4451
throw new InvalidOperationException(o.GetType() + " is not compatible with composition");
52+
53+
[MethodImpl(MethodImplOptions.NoInlining), DoesNotReturn]
54+
static void ThrowForeignCompositor(CompositionObject o) =>
55+
throw new InvalidOperationException(o.GetType() + " belongs to a different compositor");
4556

4657
public static ITransform? GetServer(this ITransform? transform, Compositor? compositor)
4758
{
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using Avalonia.Media;
4+
using Avalonia.Media.Immutable;
5+
using Avalonia.Rendering;
6+
using Avalonia.Rendering.Composition;
7+
using Avalonia.Rendering.Composition.Drawing;
8+
using Avalonia.Threading;
9+
using Avalonia.UnitTests;
10+
using Xunit;
11+
12+
namespace Avalonia.Base.UnitTests.Composition;
13+
14+
public class CompositionBrushTests : ScopedTestBase
15+
{
16+
[Fact]
17+
public void Replacing_The_Gradient_Stop_List_After_A_Commit_Should_Reach_The_Server()
18+
{
19+
using var services = new CompositorTestServices();
20+
var compositor = services.Compositor;
21+
22+
var brush = compositor.CreateLinearGradientBrush();
23+
brush.GradientStops.Add(compositor.CreateGradientStop(0, Colors.Red));
24+
services.RunJobs();
25+
26+
Assert.Single(brush.Server.GradientStops);
27+
28+
brush.GradientStops = new List<IGradientStop>
29+
{
30+
compositor.CreateGradientStop(0, Colors.Red),
31+
compositor.CreateGradientStop(1, Colors.Blue),
32+
};
33+
services.RunJobs();
34+
35+
Assert.Equal(2, brush.Server.GradientStops.Count);
36+
}
37+
38+
[Fact]
39+
public void Changing_SpreadMethod_After_A_Commit_Should_Reach_The_Server()
40+
{
41+
using var services = new CompositorTestServices();
42+
var compositor = services.Compositor;
43+
44+
var brush = compositor.CreateLinearGradientBrush();
45+
brush.GradientStops.Add(compositor.CreateGradientStop(0, Colors.Red));
46+
services.RunJobs();
47+
48+
Assert.Equal(GradientSpreadMethod.Pad, brush.Server.SpreadMethod);
49+
50+
brush.SpreadMethod = GradientSpreadMethod.Repeat;
51+
services.RunJobs();
52+
53+
Assert.Equal(GradientSpreadMethod.Repeat, brush.Server.SpreadMethod);
54+
}
55+
56+
[Fact]
57+
public void Mutable_Gradient_Stops_Should_Be_Snapshotted_For_The_Server()
58+
{
59+
using var services = new CompositorTestServices();
60+
var compositor = services.Compositor;
61+
62+
var mutableStop = new GradientStop(Colors.Red, 0);
63+
var brush = compositor.CreateLinearGradientBrush();
64+
brush.GradientStops.Add(mutableStop);
65+
services.RunJobs();
66+
67+
// The render thread reads the server list at replay time, so a mutable
68+
// UI-thread stop must not cross the batch by reference.
69+
var serverStop = Assert.Single(brush.Server.GradientStops);
70+
Assert.NotSame(mutableStop, serverStop);
71+
Assert.Equal(Colors.Red, serverStop.Color);
72+
Assert.Equal(0, serverStop.Offset);
73+
}
74+
75+
[Fact]
76+
public void Using_A_Composition_Brush_With_A_Foreign_Compositor_Should_Throw()
77+
{
78+
using var services = new CompositorTestServices();
79+
80+
var brush = services.Compositor.CreateSolidColorBrush(Colors.Red);
81+
82+
var foreign = new Compositor(RenderLoop.FromTimer(services.Timer), null,
83+
true, new DispatcherCompositorScheduler(), true, Dispatcher.UIThread);
84+
85+
// A composition brush's server object belongs to its own compositor's
86+
// render loop; handing it to another compositor's stream would let two
87+
// render threads race over one resource.
88+
Assert.Throws<InvalidOperationException>(() => brush.GetServer(foreign));
89+
90+
// A transient context without a compositor keeps the client brush and
91+
// draws its static values, so no affinity applies there.
92+
Assert.Same(brush, brush.GetServer(null));
93+
}
94+
}

0 commit comments

Comments
 (0)