From ee767d5de19b6efffc5fc7d079867932166009d8 Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Fri, 3 Jul 2026 11:11:22 +0200 Subject: [PATCH] Composition-aware geometries and drawing change detection (#21682) * Composition-aware geometries * Propagate resource changes in DrawingBrush * Change detection in Drawing * Make IRenderDataGeometry a private API --- src/Avalonia.Base/CombinedGeometry.cs | 21 ++++ src/Avalonia.Base/Media/Drawing.cs | 58 ++++++++- src/Avalonia.Base/Media/DrawingBrush.cs | 84 ++++++++++--- src/Avalonia.Base/Media/DrawingContext.cs | 15 ++- src/Avalonia.Base/Media/DrawingGroup.cs | 38 ++++++ src/Avalonia.Base/Media/DrawingImage.cs | 14 ++- src/Avalonia.Base/Media/Geometry.cs | 31 ++++- src/Avalonia.Base/Media/GeometryCollection.cs | 7 ++ src/Avalonia.Base/Platform/IGeometryImpl.cs | 5 +- .../Brushes/ServerSimpleContentBrush.cs | 18 ++- .../Drawing/IRenderDataGeometry.cs | 20 +++ .../Drawing/RenderDataDrawingContext.cs | 15 ++- .../Drawing/RenderDataStream.Visit.cs | 7 +- .../Composition/Drawing/RenderDataStream.cs | 4 +- .../Drawing/ServerResourceHelperExtensions.cs | 15 ++- .../Server/ServerCompositionSimpleGeometry.cs | 5 + src/Avalonia.Base/composition-schema.xml | 3 + .../Media/DrawingGroupTests.cs | 44 +++++++ .../Media/GeometryRenderResourceTests.cs | 84 +++++++++++++ .../Rendering/DrawingBrushPropagationTests.cs | 56 +++++++++ .../Rendering/DrawingImagePropagationTests.cs | 35 ++++++ .../SceneGraph/DrawOperationTests.cs | 9 +- .../RenderDataStreamHitTestTests.cs | 2 + .../SceneGraph/RenderDataStreamTests.cs | 5 +- .../Media/DrawingContentTests.cs | 114 ++++++++++++++++++ ...Reflects_Replaced_Inner_Brush.expected.png | Bin 0 -> 329 bytes ...Reflects_Replaced_Inner_Brush.expected.png | Bin 0 -> 329 bytes 27 files changed, 671 insertions(+), 38 deletions(-) create mode 100644 src/Avalonia.Base/Rendering/Composition/Drawing/IRenderDataGeometry.cs create mode 100644 src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionSimpleGeometry.cs create mode 100644 tests/Avalonia.Base.UnitTests/Media/GeometryRenderResourceTests.cs create mode 100644 tests/Avalonia.Base.UnitTests/Rendering/DrawingBrushPropagationTests.cs create mode 100644 tests/Avalonia.Base.UnitTests/Rendering/DrawingImagePropagationTests.cs create mode 100644 tests/Avalonia.RenderTests/Media/DrawingContentTests.cs create mode 100644 tests/TestFiles/Skia/Media/DrawingContent/DrawingBrush_Reflects_Replaced_Inner_Brush.expected.png create mode 100644 tests/TestFiles/Skia/Media/DrawingContent/DrawingImage_Reflects_Replaced_Inner_Brush.expected.png diff --git a/src/Avalonia.Base/CombinedGeometry.cs b/src/Avalonia.Base/CombinedGeometry.cs index a47e756c88..d7297b3749 100644 --- a/src/Avalonia.Base/CombinedGeometry.cs +++ b/src/Avalonia.Base/CombinedGeometry.cs @@ -55,6 +55,11 @@ namespace Avalonia.Media public static readonly StyledProperty GeometryCombineModeProperty = AvaloniaProperty.Register(nameof(GeometryCombineMode)); + static CombinedGeometry() + { + AffectsGeometry(Geometry1Property, Geometry2Property, GeometryCombineModeProperty); + } + /// /// Initializes a new instance of the class. /// @@ -145,6 +150,22 @@ namespace Avalonia.Media return new CombinedGeometry(GeometryCombineMode, Geometry1, Geometry2, Transform); } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == Geometry1Property || change.Property == Geometry2Property) + { + var (oldValue, newValue) = change.GetOldAndNewValue(); + + oldValue?.Changed -= ChildGeometryChanged; + newValue?.Changed += ChildGeometryChanged; + } + } + + private void ChildGeometryChanged(object? sender, EventArgs e) + => InvalidateGeometry(); + private protected sealed override IGeometryImpl? CreateDefiningGeometry() { var g1 = Geometry1; diff --git a/src/Avalonia.Base/Media/Drawing.cs b/src/Avalonia.Base/Media/Drawing.cs index 584e5978fc..f754eb5bfa 100644 --- a/src/Avalonia.Base/Media/Drawing.cs +++ b/src/Avalonia.Base/Media/Drawing.cs @@ -1,4 +1,6 @@ -namespace Avalonia.Media +using System; + +namespace Avalonia.Media { /// /// Abstract class that describes a 2-D drawing. @@ -7,9 +9,16 @@ { internal Drawing() { - } - + + /// + /// Raised when the drawing changes in a way that requires its consumer to re-record it: a referenced resource + /// is replaced (e.g. a new brush, pen, geometry or child), or a value that is baked into the recorded drawing + /// commands changes (e.g. a transform, opacity or effect). Mutations _within_ a referenced compositor-aware + /// resource (a brush's color, a geometry's shape) are not signaled here: the compositor propagates those on its own. + /// + internal event EventHandler? Invalidated; + /// /// Draws this drawing to the given . /// @@ -22,5 +31,48 @@ /// Gets the drawing's bounding rectangle. /// public abstract Rect GetBounds(); + + private protected void RaiseInvalidated() + => Invalidated?.Invoke(this, EventArgs.Empty); + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + // Every property of a drawing is baked into the recorded drawing commands or selects which resource is used, + // so any change requires a re-record. Keep subscriptions to nested values whose _own_ changes are also baked + // (and thus can't be propagated by the compositor) in sync. + UpdateValueSubscription(change.OldValue, subscribe: false); + UpdateValueSubscription(change.NewValue, subscribe: true); + + RaiseInvalidated(); + } + + private void UpdateValueSubscription(object? value, bool subscribe) + { + switch (value) + { + // A transform's matrix is baked into the recorded commands, so its value changes (not just its + // replacement) require a re-record. + case Transform transform: + if (subscribe) + transform.Changed += ValueInvalidated; + else + transform.Changed -= ValueInvalidated; + break; + + // Effects are baked as immutable, and nested image sources (e.g. a DrawingImage) are inlined, + // so their visual changes require a re-record too. + case IAffectsRender affectsRender: + if (subscribe) + affectsRender.Invalidated += ValueInvalidated; + else + affectsRender.Invalidated -= ValueInvalidated; + break; + } + + void ValueInvalidated(object? sender, EventArgs e) + => RaiseInvalidated(); + } } } diff --git a/src/Avalonia.Base/Media/DrawingBrush.cs b/src/Avalonia.Base/Media/DrawingBrush.cs index c4e5dc8d13..c1e0900ae5 100644 --- a/src/Avalonia.Base/Media/DrawingBrush.cs +++ b/src/Avalonia.Base/Media/DrawingBrush.cs @@ -1,6 +1,4 @@ using System; -using Avalonia.Media.Immutable; -using Avalonia.Rendering; using Avalonia.Rendering.Composition; using Avalonia.Rendering.Composition.Drawing; using Avalonia.Rendering.Composition.Server; @@ -19,7 +17,7 @@ namespace Avalonia.Media /// public static readonly StyledProperty DrawingProperty = AvaloniaProperty.Register(nameof(Drawing)); - + /// /// Initializes a new instance of the class. /// @@ -49,7 +47,7 @@ namespace Avalonia.Media { if (Drawing == null) return null; - + using var recorder = new RenderDataDrawingContext(null); Drawing?.Draw(recorder); return recorder.GetImmediateSceneBrushContent(this, null, true); @@ -58,38 +56,84 @@ namespace Avalonia.Media internal override Func Factory => static c => new ServerCompositionSimpleContentBrush(c.Server); - private InlineDictionary _renderDataDictionary; - - private protected override void OnReferencedFromCompositor(Compositor c) + private sealed class RenderDataItem(CompositionRenderData data) : IDisposable { - _renderDataDictionary.Add(c, CreateServerContent(c)); - base.OnReferencedFromCompositor(c); + public CompositionRenderData Data { get; } = data; + public bool IsDirty; + public void Dispose() => Data.Dispose(); } + private InlineDictionary _renderDataDictionary; + protected override void OnUnreferencedFromCompositor(Compositor c) { if (_renderDataDictionary.TryGetAndRemoveValue(c, out var content)) content?.Dispose(); base.OnUnreferencedFromCompositor(c); } - + private protected override void SerializeChanges(Compositor c, BatchStreamWriter writer) { base.SerializeChanges(c, writer); - if (_renderDataDictionary.TryGetValue(c, out var content) && content != null) - writer.WriteObject(new CompositionRenderDataSceneBrushContent.Properties(content.Server, null, true)); - else - writer.WriteObject(null); + + CompositionRenderDataSceneBrushContent.Properties? content = null; + if (IsOnCompositor(c)) // Should always be true here, but just in case do this check + { + _renderDataDictionary.TryGetValue(c, out var data); + if (data is null || data.IsDirty) + { + var created = CreateServerContent(c); + // Dispose the old render list _after_ creating a new one to avoid unnecessary detach/attach + // sequence for referenced resources + data?.Dispose(); + _renderDataDictionary[c] = data = created; + } + + if (data is not null) + content = new(data.Data.Server, null, true); + } + + writer.WriteObject(content); } - - CompositionRenderData? CreateServerContent(Compositor c) + + private void InvalidateContent() { - if (Drawing == null) + foreach (var item in _renderDataDictionary) + item.Value?.IsDirty = true; + + RegisterForSerialization(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + // Replacing the drawing, or a structural change inside it (e.g. a replaced brush, pen or geometry) changes + // the recorded content; mark it dirty so it's re-recorded. Mutations _within_ a referenced compositor-aware + // resource (a brush's color, a geometry's shape) are propagated by the server without re-recording. + if (change.Property == DrawingProperty) + { + var (oldValue, newValue) = change.GetOldAndNewValue(); + + oldValue?.Invalidated -= DrawingInvalidated; + newValue?.Invalidated += DrawingInvalidated; + + InvalidateContent(); + } + + base.OnPropertyChanged(change); + + void DrawingInvalidated(object? sender, EventArgs e) + => InvalidateContent(); + } + + private RenderDataItem? CreateServerContent(Compositor c) + { + if (Drawing is not { } drawing) return null; - + using var recorder = new RenderDataDrawingContext(c); - Drawing?.Draw(recorder); - return recorder.GetRenderResults(); + drawing.Draw(recorder); + var renderData = recorder.GetRenderResults(); + return renderData is null ? null : new RenderDataItem(renderData); } } } diff --git a/src/Avalonia.Base/Media/DrawingContext.cs b/src/Avalonia.Base/Media/DrawingContext.cs index 8003fb7c32..d3b43f1b8b 100644 --- a/src/Avalonia.Base/Media/DrawingContext.cs +++ b/src/Avalonia.Base/Media/DrawingContext.cs @@ -90,8 +90,19 @@ namespace Avalonia.Media /// The geometry. public void DrawGeometry(IBrush? brush, IPen? pen, Geometry geometry) { - if ((brush != null || PenIsVisible(pen)) && geometry.PlatformImpl != null) - DrawGeometryCore(brush, pen, geometry.PlatformImpl); + if (brush != null || PenIsVisible(pen)) + DrawGeometryCore(brush, pen, geometry); + } + + /// + /// Draws a mutable geometry. Implementations that support compositor-aware resources can keep a reference to + /// the so that subsequent changes to it are propagated without re-recording. + /// The default implementation simply forwards the geometry's current . + /// + protected virtual void DrawGeometryCore(IBrush? brush, IPen? pen, Geometry geometry) + { + if (geometry.PlatformImpl is { } geometryImpl) + DrawGeometryCore(brush, pen, geometryImpl); } /// diff --git a/src/Avalonia.Base/Media/DrawingGroup.cs b/src/Avalonia.Base/Media/DrawingGroup.cs index b2181d74fb..1b9157751b 100644 --- a/src/Avalonia.Base/Media/DrawingGroup.cs +++ b/src/Avalonia.Base/Media/DrawingGroup.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.Specialized; using Avalonia.Metadata; using Avalonia.Platform; using Avalonia.Rendering.SceneGraph; @@ -35,6 +36,9 @@ namespace Avalonia.Media private DrawingCollection _children = new DrawingCollection(); + public DrawingGroup() + => _children.CollectionChanged += ChildrenCollectionChanged; + public double Opacity { get => GetValue(OpacityProperty); @@ -81,12 +85,46 @@ namespace Avalonia.Media get => _children; set { + if (ReferenceEquals(_children, value)) + return; + + _children.CollectionChanged -= ChildrenCollectionChanged; + foreach (var child in _children) + child.Invalidated -= ChildInvalidated; + SetAndRaise(ChildrenProperty, ref _children, value); + + _children.CollectionChanged += ChildrenCollectionChanged; + foreach (var child in _children) + child.Invalidated += ChildInvalidated; } } public DrawingContext Open() => new DrawingGroupDrawingContext(this); + private void ChildrenCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.Action == NotifyCollectionChangedAction.Reset) + throw new NotSupportedException(); + + if (e.OldItems is not null) + { + foreach (Drawing child in e.OldItems) + child.Invalidated -= ChildInvalidated; + } + + if (e.NewItems is not null) + { + foreach (Drawing child in e.NewItems) + child.Invalidated += ChildInvalidated; + } + + RaiseInvalidated(); + } + + private void ChildInvalidated(object? sender, EventArgs e) + => RaiseInvalidated(); + internal override void DrawCore(DrawingContext context) { // Compute local bounds from children only when EffectBounds is not explicitly set. diff --git a/src/Avalonia.Base/Media/DrawingImage.cs b/src/Avalonia.Base/Media/DrawingImage.cs index 7949eaa351..d34f1d5379 100644 --- a/src/Avalonia.Base/Media/DrawingImage.cs +++ b/src/Avalonia.Base/Media/DrawingImage.cs @@ -98,10 +98,22 @@ namespace Avalonia.Media { base.OnPropertyChanged(change); - if (change.Property == DrawingProperty || change.Property == ViewboxProperty) + if (change.Property == DrawingProperty) + { + var (oldValue, newValue) = change.GetOldAndNewValue(); + + oldValue?.Invalidated -= DrawingInvalidated; + newValue?.Invalidated += DrawingInvalidated; + + RaiseInvalidated(EventArgs.Empty); + } + else if (change.Property == ViewboxProperty) { RaiseInvalidated(EventArgs.Empty); } + + void DrawingInvalidated(object? sender, EventArgs e) + => RaiseInvalidated(EventArgs.Empty); } /// diff --git a/src/Avalonia.Base/Media/Geometry.cs b/src/Avalonia.Base/Media/Geometry.cs index 87b7c147d7..8082c2e71a 100644 --- a/src/Avalonia.Base/Media/Geometry.cs +++ b/src/Avalonia.Base/Media/Geometry.cs @@ -4,6 +4,10 @@ using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Globalization; using Avalonia.Reactive; +using Avalonia.Rendering.Composition; +using Avalonia.Rendering.Composition.Drawing; +using Avalonia.Rendering.Composition.Server; +using Avalonia.Rendering.Composition.Transport; namespace Avalonia.Media @@ -12,7 +16,10 @@ namespace Avalonia.Media /// Defines a geometric shape. /// [TypeConverter(typeof(GeometryTypeConverter))] - public abstract class Geometry : AvaloniaObject + public abstract class Geometry : + AvaloniaObject, + ICompositionRenderResource, + ICompositorSerializable { /// /// Defines the property. @@ -168,6 +175,7 @@ namespace Avalonia.Media _isDirty = true; _platformImpl = null; + RegisterForSerialization(); Changed?.Invoke(this, EventArgs.Empty); } @@ -209,6 +217,7 @@ namespace Avalonia.Media _platformImpl = _platformImpl.WithTransform(transform.Value); } + RegisterForSerialization(); Changed?.Invoke(this, EventArgs.Empty); } @@ -297,6 +306,26 @@ namespace Avalonia.Media segmentGeometry = new PlatformGeometry(segment); return true; } + + private CompositorResourceHolder _resource; + + private protected void RegisterForSerialization() + => _resource.RegisterForInvalidationOnAllCompositors(this); + + ServerCompositionSimpleGeometry ICompositionRenderResource.GetForCompositor(Compositor c) + => _resource.GetForCompositor(c); + + void ICompositionRenderResource.AddRefOnCompositor(Compositor c) + => _resource.CreateOrAddRef(c, this, out _, static c => new ServerCompositionSimpleGeometry(c.Server)); + + void ICompositionRenderResource.ReleaseOnCompositor(Compositor c) + => _resource.Release(c); + + SimpleServerObject? ICompositorSerializable.TryGetServer(Compositor c) + => _resource.TryGetForCompositor(c); + + void ICompositorSerializable.SerializeChanges(Compositor c, BatchStreamWriter writer) + => ServerCompositionSimpleGeometry.SerializeAllChanges(writer, PlatformImpl); } public class GeometryTypeConverter : TypeConverter diff --git a/src/Avalonia.Base/Media/GeometryCollection.cs b/src/Avalonia.Base/Media/GeometryCollection.cs index 2afa191dcf..3f2732943b 100644 --- a/src/Avalonia.Base/Media/GeometryCollection.cs +++ b/src/Avalonia.Base/Media/GeometryCollection.cs @@ -15,10 +15,12 @@ namespace Avalonia.Media this.ForEachItem( x => { + x.Changed += ChildChanged; Parent?.Invalidate(); }, x => { + x.Changed -= ChildChanged; Parent?.Invalidate(); }, () => throw new NotSupportedException()); @@ -31,15 +33,20 @@ namespace Avalonia.Media this.ForEachItem( x => { + x.Changed += ChildChanged; Parent?.Invalidate(); }, x => { + x.Changed -= ChildChanged; Parent?.Invalidate(); }, () => throw new NotSupportedException()); } public GeometryGroup? Parent { get; set; } + + private void ChildChanged(object? sender, EventArgs e) + => Parent?.Invalidate(); } } diff --git a/src/Avalonia.Base/Platform/IGeometryImpl.cs b/src/Avalonia.Base/Platform/IGeometryImpl.cs index 0d1e7b972c..0e74f1e4c5 100644 --- a/src/Avalonia.Base/Platform/IGeometryImpl.cs +++ b/src/Avalonia.Base/Platform/IGeometryImpl.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using Avalonia.Media; using Avalonia.Metadata; +using Avalonia.Rendering.Composition.Drawing; namespace Avalonia.Platform { @@ -8,8 +9,10 @@ namespace Avalonia.Platform /// Defines the platform-specific interface for a . /// [Unstable] - public interface IGeometryImpl + public interface IGeometryImpl : IRenderDataGeometry { + IGeometryImpl IRenderDataGeometry.GeometryImpl => this; + /// /// Gets the geometry's bounding rectangle. /// diff --git a/src/Avalonia.Base/Rendering/Composition/Brushes/ServerSimpleContentBrush.cs b/src/Avalonia.Base/Rendering/Composition/Brushes/ServerSimpleContentBrush.cs index 673804f7bd..2ff9f66787 100644 --- a/src/Avalonia.Base/Rendering/Composition/Brushes/ServerSimpleContentBrush.cs +++ b/src/Avalonia.Base/Rendering/Composition/Brushes/ServerSimpleContentBrush.cs @@ -8,7 +8,7 @@ namespace Avalonia.Rendering.Composition.Server; internal sealed class ServerCompositionSimpleContentBrush : ServerCompositionSimpleTileBrush, ITileBrush, ISceneBrush { private CompositionRenderDataSceneBrushContent.Properties? _content; - + internal ServerCompositionSimpleContentBrush(ServerCompositor compositor) : base(compositor) { @@ -22,6 +22,20 @@ internal sealed class ServerCompositionSimpleContentBrush : ServerCompositionSim protected override void DeserializeChangesCore(BatchStreamReader reader, TimeSpan committedAt) { base.DeserializeChangesCore(reader, committedAt); - _content = reader.ReadObject(); + var content = reader.ReadObject(); + + if (!ReferenceEquals(_content?.RenderData, content?.RenderData)) + { + _content?.RenderData.RemoveObserver(this); + content?.RenderData.AddObserver(this); + } + + _content = content; + } + + public override void Dispose() + { + _content?.RenderData.RemoveObserver(this); + base.Dispose(); } } diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/IRenderDataGeometry.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/IRenderDataGeometry.cs new file mode 100644 index 0000000000..a7c7a6543e --- /dev/null +++ b/src/Avalonia.Base/Rendering/Composition/Drawing/IRenderDataGeometry.cs @@ -0,0 +1,20 @@ +using Avalonia.Metadata; +using Avalonia.Platform; + +namespace Avalonia.Rendering.Composition.Drawing; + +/// +/// Common abstraction for a geometry to render. +/// It is implemented by both the immutable platform (which returns itself) +/// and the compositor-aware server resource +/// (which returns its current, mutable backing geometry). This lets the render data stay strongly typed +/// while still resolving to the concrete that platform drawing contexts require. +/// +[PrivateApi] +public interface IRenderDataGeometry +{ + /// + /// The underlying immutable platform geometry, or null when there's nothing to draw. + /// + IGeometryImpl? GeometryImpl { get; } +} diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs index 2472d69f08..abe210e3c5 100644 --- a/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs +++ b/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs @@ -103,6 +103,18 @@ internal class RenderDataDrawingContext : DrawingContext Stream.DrawGeometry(brush.GetServer(_compositor), pen.GetServer(_compositor), pen, geometry); } + protected override void DrawGeometryCore(IBrush? brush, IPen? pen, Geometry geometry) + { + if (brush is null && pen is null) + return; + + AddResource(brush); + AddResource(pen); + AddResource(geometry); + + Stream.DrawGeometry(brush.GetServer(_compositor), pen.GetServer(_compositor), pen, geometry.GetServer(_compositor)); + } + protected override void DrawRectangleCore(IBrush? brush, IPen? pen, RoundedRect rrect, BoxShadows boxShadows = default) { if (rrect.IsEmpty()) @@ -164,8 +176,9 @@ internal class RenderDataDrawingContext : DrawingContext return; } + AddResource(clip); var before = Stream.OpcodeLength; - Stream.PushGeometryClip(clip.PlatformImpl); + Stream.PushGeometryClip(clip.GetServer(_compositor)); PushedScope(before); } diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataStream.Visit.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataStream.Visit.cs index 370a79403c..7bb848495a 100644 --- a/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataStream.Visit.cs +++ b/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataStream.Visit.cs @@ -69,7 +69,7 @@ internal partial class RenderDataStream (IBrush?)_resources[p.ServerBrush], (IPen?)_resources[p.ServerPen], (IPen?)_resources[p.ClientPen], - (IGeometryImpl?)_resources[p.Geometry]); + GetGeometryImpl(_resources[p.Geometry])); break; } case RenderDataOpcode.DrawGlyphRun: @@ -103,7 +103,7 @@ internal partial class RenderDataStream case RenderDataOpcode.PushGeometryClip: { var p = reader.ReadPayload(); - scopes[depth++] = visitor.OnPushGeometryClip((IGeometryImpl?)_resources[p.Geometry]); + scopes[depth++] = visitor.OnPushGeometryClip(GetGeometryImpl(_resources[p.Geometry])); break; } case RenderDataOpcode.PushOpacity: @@ -174,4 +174,7 @@ internal partial class RenderDataStream rest[i] = reader.Read(); return new BoxShadows(first, rest); } + + private static IGeometryImpl? GetGeometryImpl(object? resource) + => ((IRenderDataGeometry?)resource)?.GeometryImpl; } diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataStream.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataStream.cs index 910043ffea..ddfeae4251 100644 --- a/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataStream.cs +++ b/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataStream.cs @@ -93,7 +93,7 @@ internal partial class RenderDataStream : IDisposable }); } - public void DrawGeometry(IBrush? serverBrush, IPen? serverPen, IPen? clientPen, IGeometryImpl? geometry) + public void DrawGeometry(IBrush? serverBrush, IPen? serverPen, IPen? clientPen, IRenderDataGeometry? geometry) { _writer.WritePayload(new DrawGeometryPayload { @@ -138,7 +138,7 @@ internal partial class RenderDataStream : IDisposable EnterScope(); } - public void PushGeometryClip(IGeometryImpl? geometry) + public void PushGeometryClip(IRenderDataGeometry? geometry) { _writer.WritePayload(new PushGeometryClipPayload { Geometry = _resources.Intern(geometry) }); EnterScope(); diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/ServerResourceHelperExtensions.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/ServerResourceHelperExtensions.cs index 2a6971e8ea..6fb646cd23 100644 --- a/src/Avalonia.Base/Rendering/Composition/Drawing/ServerResourceHelperExtensions.cs +++ b/src/Avalonia.Base/Rendering/Composition/Drawing/ServerResourceHelperExtensions.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using Avalonia.Media; using Avalonia.Media.Immutable; +using Avalonia.Rendering.Composition.Server; namespace Avalonia.Rendering.Composition.Drawing; @@ -52,4 +53,16 @@ static class ServerResourceHelperExtensions resource.GetForCompositor(compositor); return new ImmutableTransform(transform.Value); } -} \ No newline at end of file + + public static IRenderDataGeometry? GetServer(this Geometry? geometry, Compositor? compositor) + { + if (geometry == null) + return null; + if (compositor == null) + return geometry.PlatformImpl; + if (geometry is ICompositionRenderResource resource) + return resource.GetForCompositor(compositor); + ThrowNotCompatible(geometry); + return null; + } +} diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionSimpleGeometry.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionSimpleGeometry.cs new file mode 100644 index 0000000000..7b7ca2e7b5 --- /dev/null +++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionSimpleGeometry.cs @@ -0,0 +1,5 @@ +using Avalonia.Rendering.Composition.Drawing; + +namespace Avalonia.Rendering.Composition.Server; + +internal partial class ServerCompositionSimpleGeometry : IRenderDataGeometry; diff --git a/src/Avalonia.Base/composition-schema.xml b/src/Avalonia.Base/composition-schema.xml index 442d0c9197..460e402572 100644 --- a/src/Avalonia.Base/composition-schema.xml +++ b/src/Avalonia.Base/composition-schema.xml @@ -82,6 +82,9 @@ + + + diff --git a/tests/Avalonia.Base.UnitTests/Media/DrawingGroupTests.cs b/tests/Avalonia.Base.UnitTests/Media/DrawingGroupTests.cs index ccabf7303f..4fbce0c2ac 100644 --- a/tests/Avalonia.Base.UnitTests/Media/DrawingGroupTests.cs +++ b/tests/Avalonia.Base.UnitTests/Media/DrawingGroupTests.cs @@ -110,6 +110,50 @@ public class DrawingGroupTests } } + [Fact] + public void Invalidated_Is_Raised_When_Child_Is_Added_Or_Removed() + { + var group = new DrawingGroup(); + var child = new GeometryDrawing(); + var count = 0; + group.Invalidated += (_, _) => count++; + + group.Children.Add(child); + Assert.Equal(1, count); + + group.Children.Remove(child); + Assert.Equal(2, count); + } + + [Fact] + public void Invalidated_Is_Raised_When_Child_Changes() + { + var child = new GeometryDrawing(); + var group = new DrawingGroup(); + group.Children.Add(child); + + var count = 0; + group.Invalidated += (_, _) => count++; + + child.Brush = Brushes.Red; + Assert.True(count > 0); + } + + [Fact] + public void Removed_Child_Is_No_Longer_Tracked() + { + var child = new GeometryDrawing(); + var group = new DrawingGroup(); + group.Children.Add(child); + group.Children.Remove(child); + + var count = 0; + group.Invalidated += (_, _) => count++; + + child.Brush = Brushes.Red; + Assert.Equal(0, count); + } + private class MockDrawingContext : DrawingContext { public IEffect? Effect { get; private set; } diff --git a/tests/Avalonia.Base.UnitTests/Media/GeometryRenderResourceTests.cs b/tests/Avalonia.Base.UnitTests/Media/GeometryRenderResourceTests.cs new file mode 100644 index 0000000000..767f545689 --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Media/GeometryRenderResourceTests.cs @@ -0,0 +1,84 @@ +using Avalonia.Media; +using Xunit; + +namespace Avalonia.Base.UnitTests.Media; + +public class GeometryRenderResourceTests +{ + [Fact] + public void Changing_Geometry_Property_Raises_Invalidated() + { + var target = new EllipseGeometry(new Rect(0, 0, 10, 10)); + + RenderResourceTestHelper.AssertResourceInvalidation( + target, + () => target.Rect = new Rect(0, 0, 20, 20)); + } + + [Fact] + public void Changing_Transform_Raises_Invalidated() + { + var target = new EllipseGeometry(new Rect(0, 0, 10, 10)); + + RenderResourceTestHelper.AssertResourceInvalidation( + target, + () => target.Transform = new TranslateTransform(5, 5)); + } + + [Fact] + public void Changing_Transform_Value_Raises_Invalidated() + { + var transform = new TranslateTransform(5, 5); + var target = new EllipseGeometry(new Rect(0, 0, 10, 10)) { Transform = transform }; + + RenderResourceTestHelper.AssertResourceInvalidation( + target, + () => transform.X = 10); + } + + [Fact] + public void Adding_Child_To_GeometryGroup_Raises_Invalidated() + { + var target = new GeometryGroup(); + + RenderResourceTestHelper.AssertResourceInvalidation( + target, + () => target.Children.Add(new EllipseGeometry(new Rect(0, 0, 10, 10)))); + } + + [Fact] + public void Changing_Child_Of_GeometryGroup_Raises_Invalidated() + { + var child = new EllipseGeometry(new Rect(0, 0, 10, 10)); + var target = new GeometryGroup(); + target.Children.Add(child); + + RenderResourceTestHelper.AssertResourceInvalidation( + target, + () => child.Rect = new Rect(0, 0, 20, 20)); + } + + [Fact] + public void Changing_Geometry1_Of_CombinedGeometry_Raises_Invalidated() + { + var geometry1 = new EllipseGeometry(new Rect(0, 0, 10, 10)); + var geometry2 = new RectangleGeometry(new Rect(5, 5, 10, 10)); + var target = new CombinedGeometry(GeometryCombineMode.Union, geometry1, geometry2); + + RenderResourceTestHelper.AssertResourceInvalidation( + target, + () => geometry1.Rect = new Rect(0, 0, 20, 20)); + } + + [Fact] + public void Changing_CombineMode_Of_CombinedGeometry_Raises_Invalidated() + { + var geometry1 = new EllipseGeometry(new Rect(0, 0, 10, 10)); + var geometry2 = new RectangleGeometry(new Rect(5, 5, 10, 10)); + var target = new CombinedGeometry(GeometryCombineMode.Union, geometry1, geometry2); + + RenderResourceTestHelper.AssertResourceInvalidation( + target, + () => target.GeometryCombineMode = GeometryCombineMode.Intersect); + } +} diff --git a/tests/Avalonia.Base.UnitTests/Rendering/DrawingBrushPropagationTests.cs b/tests/Avalonia.Base.UnitTests/Rendering/DrawingBrushPropagationTests.cs new file mode 100644 index 0000000000..7a3b11f0f4 --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Rendering/DrawingBrushPropagationTests.cs @@ -0,0 +1,56 @@ +using Avalonia.Controls; +using Avalonia.Media; +using Xunit; + +namespace Avalonia.Base.UnitTests.Rendering; + +public class DrawingBrushPropagationTests : CompositorTestsBase +{ + private static Border CreateBorder(DrawingBrush brush) => new() + { + Background = brush, + Width = 20, + Height = 10, + [Canvas.LeftProperty] = 30, + [Canvas.TopProperty] = 50 + }; + + [Fact] + public void Mutating_Geometry_Inside_DrawingBrush_Invalidates_Consumer() + { + using var services = new CompositorCanvas(); + + var geometry = new RectangleGeometry(new Rect(0, 0, 20, 10)); + var brush = new DrawingBrush(new GeometryDrawing { Brush = Brushes.Red, Geometry = geometry }); + services.Canvas.Children.Add(CreateBorder(brush)); + services.RunJobs(); + services.Events.Rects.Clear(); + + geometry.Rect = new Rect(0, 0, 30, 15); + + services.AssertRects(new Rect(30, 50, 20, 10)); + } + + [Fact] + public void Replacing_Drawing_Invalidates_Consumer() + { + using var services = new CompositorCanvas(); + + var brush = new DrawingBrush(new GeometryDrawing + { + Brush = Brushes.Red, + Geometry = new RectangleGeometry(new Rect(0, 0, 20, 10)) + }); + services.Canvas.Children.Add(CreateBorder(brush)); + services.RunJobs(); + services.Events.Rects.Clear(); + + brush.Drawing = new GeometryDrawing + { + Brush = Brushes.Blue, + Geometry = new RectangleGeometry(new Rect(0, 0, 20, 10)) + }; + + services.AssertRects(new Rect(30, 50, 20, 10)); + } +} diff --git a/tests/Avalonia.Base.UnitTests/Rendering/DrawingImagePropagationTests.cs b/tests/Avalonia.Base.UnitTests/Rendering/DrawingImagePropagationTests.cs new file mode 100644 index 0000000000..18fff7a86a --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Rendering/DrawingImagePropagationTests.cs @@ -0,0 +1,35 @@ +using Avalonia.Controls; +using Avalonia.Media; +using Xunit; + +namespace Avalonia.Base.UnitTests.Rendering; + +public class DrawingImagePropagationTests : CompositorTestsBase +{ + private static Image CreateImage(DrawingImage image) => new() + { + Source = image, + Width = 20, + Height = 10, + [Canvas.LeftProperty] = 30, + [Canvas.TopProperty] = 50 + }; + + [Fact] + public void Mutating_Geometry_Inside_DrawingImage_Invalidates_Consumer() + { + using var services = new CompositorCanvas(); + + var geometry = new RectangleGeometry(new Rect(0, 0, 20, 10)); + var image = new DrawingImage(new GeometryDrawing { Brush = Brushes.Red, Geometry = geometry }); + services.Canvas.Children.Add(CreateImage(image)); + services.RunJobs(); + services.Events.Rects.Clear(); + + geometry.Rect = new Rect(0, 0, 30, 15); + + services.AssertRects( + new Rect(30, 50, 20, 10), + new Rect(30, 50, 30, 15)); + } +} diff --git a/tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/DrawOperationTests.cs b/tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/DrawOperationTests.cs index 7ee04c32ad..a0ff9f2134 100644 --- a/tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/DrawOperationTests.cs +++ b/tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/DrawOperationTests.cs @@ -120,7 +120,12 @@ namespace Avalonia.Base.UnitTests.Rendering.SceneGraph { var ctx = new TestContext(_services); using (ctx.Context.PushTransform(new Matrix())) - ctx.Context.DrawGeometry(Brushes.Black, null, Mock.Of()); + { + var geometry = new Mock(); + geometry.Setup(g => g.GeometryImpl).Returns(geometry.Object); + ctx.Context.DrawGeometry(Brushes.Black, null, geometry.Object); + } + Assert.False(ctx.Context.GetRenderResults()!.HitTest(default)); } @@ -405,6 +410,7 @@ namespace Avalonia.Base.UnitTests.Rendering.SceneGraph public void Geometry_Node_HitTest_Uses_FillContains_When_Brush_Set() { var geomMock = new Mock(); + geomMock.Setup(g => g.GeometryImpl).Returns(geomMock.Object); geomMock.Setup(g => g.FillContains(new Point(5, 5))).Returns(true); geomMock.Setup(g => g.FillContains(new Point(50, 50))).Returns(false); @@ -421,6 +427,7 @@ namespace Avalonia.Base.UnitTests.Rendering.SceneGraph { var pen = new ImmutablePen(Brushes.Black, 1); var geomMock = new Mock(); + geomMock.Setup(g => g.GeometryImpl).Returns(geomMock.Object); geomMock.Setup(g => g.StrokeContains(pen, new Point(5, 5))).Returns(true); geomMock.Setup(g => g.StrokeContains(pen, new Point(50, 50))).Returns(false); diff --git a/tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/RenderDataStreamHitTestTests.cs b/tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/RenderDataStreamHitTestTests.cs index 65dc9470f3..cb71c17f9a 100644 --- a/tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/RenderDataStreamHitTestTests.cs +++ b/tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/RenderDataStreamHitTestTests.cs @@ -61,6 +61,7 @@ namespace Avalonia.Base.UnitTests.Rendering.SceneGraph public void Geometry_Is_Hit_Via_FillContains() { var geometry = new Mock(); + geometry.Setup(g => g.GeometryImpl).Returns(geometry.Object); geometry.Setup(x => x.FillContains(new Point(5, 5))).Returns(true); geometry.Setup(x => x.FillContains(new Point(50, 50))).Returns(false); @@ -113,6 +114,7 @@ namespace Avalonia.Base.UnitTests.Rendering.SceneGraph public void Geometry_Clip_Restricts_The_Hit_Region() { var geometry = new Mock(); + geometry.Setup(g => g.GeometryImpl).Returns(geometry.Object); geometry.Setup(x => x.FillContains(new Point(5, 5))).Returns(true); geometry.Setup(x => x.FillContains(new Point(50, 50))).Returns(false); diff --git a/tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/RenderDataStreamTests.cs b/tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/RenderDataStreamTests.cs index 943ae19994..619cb3e9ea 100644 --- a/tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/RenderDataStreamTests.cs +++ b/tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/RenderDataStreamTests.cs @@ -123,9 +123,12 @@ namespace Avalonia.Base.UnitTests.Rendering.SceneGraph var calls = new List(); var context = RecordingContext(calls); + var geometry = new Mock(); + geometry.Setup(g => g.GeometryImpl).Returns(geometry.Object); + using var stream = new RenderDataStream(); stream.PushClip(new RoundedRect(new Rect(0, 0, 10, 10))); - stream.PushGeometryClip(Mock.Of()); + stream.PushGeometryClip(geometry.Object); stream.Pop(); stream.Pop(); stream.Replay(context.Object); diff --git a/tests/Avalonia.RenderTests/Media/DrawingContentTests.cs b/tests/Avalonia.RenderTests/Media/DrawingContentTests.cs new file mode 100644 index 0000000000..dc11cfd666 --- /dev/null +++ b/tests/Avalonia.RenderTests/Media/DrawingContentTests.cs @@ -0,0 +1,114 @@ +#if AVALONIA_SKIA +using System; +using System.IO; +using System.Runtime.CompilerServices; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Platform; +using Avalonia.Platform.Surfaces; +using Avalonia.Rendering; +using Avalonia.Rendering.Composition; +using Avalonia.Threading; +using Avalonia.UnitTests; +using SkiaSharp; +using Xunit; + +namespace Avalonia.Skia.RenderTests; + +/// +/// Verifies that replacing a resource inside a is reflected in what a +/// consuming control renders. The initial frame is intentionally ignored (covered elsewhere); only +/// the frame produced _after_ the change is compared against the reference image. +/// +public class DrawingContentTests() + : TestBase(@"Media\DrawingContent") +{ + private sealed class FuncFramebufferSurface(Func cb) : IFramebufferPlatformSurface + { + public IFramebufferRenderTarget CreateFramebufferRenderTarget() => cb(); + } + + [Fact] + public void DrawingBrush_Reflects_Replaced_Inner_Brush() + { + var drawing = new GeometryDrawing + { + Brush = Brushes.Blue, + Geometry = new RectangleGeometry(new Rect(0, 0, 100, 100)) + }; + + var target = new Border + { + Width = 100, + Height = 100, + Background = new DrawingBrush(drawing) { Stretch = Stretch.Fill } + }; + + RenderChange(target, () => drawing.Brush = Brushes.Red); + CompareImages(skipImmediate: true); + } + + [Fact] + public void DrawingImage_Reflects_Replaced_Inner_Brush() + { + var drawing = new GeometryDrawing + { + Brush = Brushes.Blue, + Geometry = new RectangleGeometry(new Rect(0, 0, 100, 100)) + }; + + var target = new Image + { + Width = 100, + Height = 100, + Stretch = Stretch.Fill, + Source = new DrawingImage(drawing) + }; + + RenderChange(target, () => drawing.Brush = Brushes.Red); + CompareImages(skipImmediate: true); + } + + // Renders the target once, applies the change, renders again and writes the second frame to the + // standard composited output path so it can be compared against the committed reference image. + private void RenderChange(Control target, Action change, [CallerMemberName] string testName = "") + { + var timer = new ManualRenderTimer(); + var compositor = new Compositor( + RenderLoop.FromTimer(timer), + null, + true, + new DispatcherCompositorScheduler(), true, Dispatcher.UIThread); + + var root = new TestRenderRoot(1, null!); + using var frameBuffer = new SKBitmap(100, 100, SKColorType.Rgba8888, SKAlphaType.Premul); + + var renderTarget = new FuncFramebufferRenderTarget(() => new LockedFramebuffer( + frameBuffer.GetAddress(0, 0), + new PixelSize(frameBuffer.Width, frameBuffer.Height), + frameBuffer.RowBytes, + new Vector(96, 96), + PixelFormat.Rgba8888, + AlphaFormat.Premul, + null)); + + using (var renderer = new CompositingRenderer(root, compositor, () => [new FuncFramebufferSurface(() => renderTarget)])) + { + root.Initialize(renderer, target); + renderer.Start(); + Dispatcher.UIThread.RunJobs(); + timer.TriggerTick(); + + change(); + Dispatcher.UIThread.RunJobs(); + timer.TriggerTick(); + } + + Directory.CreateDirectory(OutputPath); + var path = Path.Combine(OutputPath, testName + ".composited.out.png"); + using var data = frameBuffer.Encode(SKEncodedImageFormat.Png, 100); + using var file = File.Create(path); + data.SaveTo(file); + } +} +#endif diff --git a/tests/TestFiles/Skia/Media/DrawingContent/DrawingBrush_Reflects_Replaced_Inner_Brush.expected.png b/tests/TestFiles/Skia/Media/DrawingContent/DrawingBrush_Reflects_Replaced_Inner_Brush.expected.png new file mode 100644 index 0000000000000000000000000000000000000000..7de0e220fecfa177b1ff634504ed1c9a09b85e98 GIT binary patch literal 329 zcmeAS@N?(olHy`uVBq!ia0vp^DImEakt zG3V_?M_vX6fddEakt zG3V_?M_vX6fdd