From eacaa775de1856b6c58ffe4f7ba924a1d3500f87 Mon Sep 17 00:00:00 2001 From: timunie Date: Mon, 2 Mar 2026 14:59:43 +0100 Subject: [PATCH 1/8] add failing test for RenderTargetBitmap_DropShadowEffect --- .../Media/RenderTargetBitmapTests.cs | 59 ++++++++++++++++++ ...TargetBitmap_DropShadowEffect.expected.png | Bin 0 -> 3561 bytes 2 files changed, 59 insertions(+) create mode 100644 tests/Avalonia.RenderTests/Media/RenderTargetBitmapTests.cs create mode 100644 tests/TestFiles/Skia/Media/RenderTargetBitmap/RenderTargetBitmap_DropShadowEffect.expected.png diff --git a/tests/Avalonia.RenderTests/Media/RenderTargetBitmapTests.cs b/tests/Avalonia.RenderTests/Media/RenderTargetBitmapTests.cs new file mode 100644 index 0000000000..15e36e195b --- /dev/null +++ b/tests/Avalonia.RenderTests/Media/RenderTargetBitmapTests.cs @@ -0,0 +1,59 @@ +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Skia.RenderTests; + +public class RenderTargetBitmapTests : TestBase +{ + public RenderTargetBitmapTests() : base(@"Media\RenderTargetBitmap") + { + } + + [Fact] + public async Task RenderTargetBitmap_DropShadowEffect() + { + var root = new Grid + { + Width = 300, + Height = 300, + Children = + { + new Canvas + { + Width = 300, + Height = 300, + Background = Brushes.White, + Children = + { + new Rectangle + { + Fill = Brushes.Red, + Width = 200, + Height = 200, + Margin = new Thickness(50), + Effect = new DropShadowEffect + { + Color = Colors.Black, + BlurRadius = 30 + } + } + } + } + } + }; + + await RenderToFile(root); + + CompareImages(); + + // var rtb = new RenderTargetBitmap(new PixelSize(300,300), new Vector(96,96)); + // rtb.Render(root); + // + // rtb.Save("test.png"); + } +} diff --git a/tests/TestFiles/Skia/Media/RenderTargetBitmap/RenderTargetBitmap_DropShadowEffect.expected.png b/tests/TestFiles/Skia/Media/RenderTargetBitmap/RenderTargetBitmap_DropShadowEffect.expected.png new file mode 100644 index 0000000000000000000000000000000000000000..d0e64a9544556587169e2aafa3e3cef95ad299fb GIT binary patch literal 3561 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 RX#js$AXg_3$7*{V`JXG2iPiuB literal 0 HcmV?d00001 From 5b0c354d49ff4b4fc7371bc2659620790b208931 Mon Sep 17 00:00:00 2001 From: timunie Date: Mon, 2 Mar 2026 15:31:17 +0100 Subject: [PATCH 2/8] Fix immidiateRenderer with Effect wasn't working as expected --- src/Avalonia.Base/Media/DrawingContext.cs | 22 ++++++++++++++++++- src/Avalonia.Base/Media/DrawingGroup.cs | 21 ++++++++++++++++++ .../Media/PlatformDrawingContext.cs | 16 ++++++++++++++ .../Drawing/Nodes/RenderDataNodes.cs | 21 +++++++++++++++++- .../Drawing/RenderDataDrawingContext.cs | 8 +++++++ .../Rendering/ImmediateRenderer.cs | 1 + .../Shapes/ShapeTests.cs | 14 +++++++++--- .../Media/RenderTargetBitmapTests.cs | 6 ----- 8 files changed, 98 insertions(+), 11 deletions(-) diff --git a/src/Avalonia.Base/Media/DrawingContext.cs b/src/Avalonia.Base/Media/DrawingContext.cs index df6e7d112b..5e39b0050c 100644 --- a/src/Avalonia.Base/Media/DrawingContext.cs +++ b/src/Avalonia.Base/Media/DrawingContext.cs @@ -285,7 +285,8 @@ namespace Avalonia.Media GeometryClip, OpacityMask, RenderOptions, - TextOptions + TextOptions, + Effect } public RestoreState(DrawingContext context, PushedStateType type) @@ -314,6 +315,8 @@ namespace Avalonia.Media _context.PopRenderOptionsCore(); else if (_type == PushedStateType.TextOptions) _context.PopTextOptionsCore(); + else if (_type == PushedStateType.Effect) + _context.PopEffectCore(); } } @@ -433,10 +436,26 @@ namespace Avalonia.Media return new PushedState(this); } + /// + /// Pushes an effect. + /// + /// The effect. + /// The bounds of the effect. + /// A disposable used to undo the effect. + public PushedState PushEffect(IEffect effect, Rect bounds) + { + PushEffectCore(effect, bounds); + _states ??= StateStackPool.Get(); + _states.Push(new RestoreState(this, RestoreState.PushedStateType.Effect)); + return new PushedState(this); + } + protected abstract void PushTextOptionsCore(TextOptions textOptions); protected abstract void PushTransformCore(Matrix matrix); + protected abstract void PushEffectCore(IEffect effect, Rect bounds); + protected abstract void PopClipCore(); protected abstract void PopGeometryClipCore(); protected abstract void PopOpacityCore(); @@ -444,6 +463,7 @@ namespace Avalonia.Media protected abstract void PopTransformCore(); protected abstract void PopRenderOptionsCore(); protected abstract void PopTextOptionsCore(); + protected abstract void PopEffectCore(); private static bool PenIsVisible(IPen? pen) { diff --git a/src/Avalonia.Base/Media/DrawingGroup.cs b/src/Avalonia.Base/Media/DrawingGroup.cs index 75921196c0..c76a417b87 100644 --- a/src/Avalonia.Base/Media/DrawingGroup.cs +++ b/src/Avalonia.Base/Media/DrawingGroup.cs @@ -21,6 +21,9 @@ namespace Avalonia.Media public static readonly StyledProperty OpacityMaskProperty = AvaloniaProperty.Register(nameof(OpacityMask)); + public static readonly StyledProperty EffectProperty = + AvaloniaProperty.Register(nameof(Effect)); + public static readonly DirectProperty ChildrenProperty = AvaloniaProperty.RegisterDirect( nameof(Children), @@ -53,6 +56,12 @@ namespace Avalonia.Media set => SetValue(OpacityMaskProperty, value); } + public IEffect? Effect + { + get => GetValue(EffectProperty); + set => SetValue(EffectProperty, value); + } + internal RenderOptions? RenderOptions { get; set; } internal TextOptions? TextOptions { get; set; } @@ -80,6 +89,7 @@ namespace Avalonia.Media using (OpacityMask != null ? context.PushOpacityMask(OpacityMask, bounds) : default) using (RenderOptions != null ? context.PushRenderOptions(RenderOptions.Value) : default) using (TextOptions != null ? context.PushTextOptions(TextOptions.Value) : default) + using (Effect != null ? context.PushEffect(Effect, bounds) : default) { foreach (var drawing in Children) { @@ -336,6 +346,15 @@ namespace Avalonia.Media drawingGroup.TextOptions = textOptions; } + protected override void PushEffectCore(IEffect effect, Rect bounds) + { + // Instantiate a new drawing group and set it as the _currentDrawingGroup + var drawingGroup = PushNewDrawingGroup(); + + // Set the effect on the new DrawingGroup + drawingGroup.Effect = effect; + } + protected override void PopClipCore() => Pop(); protected override void PopGeometryClipCore() => Pop(); @@ -350,6 +369,8 @@ namespace Avalonia.Media protected override void PopTextOptionsCore() => Pop(); + protected override void PopEffectCore() => Pop(); + /// /// Creates a new DrawingGroup for a Push* call by setting the /// _currentDrawingGroup to a newly instantiated DrawingGroup, diff --git a/src/Avalonia.Base/Media/PlatformDrawingContext.cs b/src/Avalonia.Base/Media/PlatformDrawingContext.cs index 12eef33a6d..846c17dee5 100644 --- a/src/Avalonia.Base/Media/PlatformDrawingContext.cs +++ b/src/Avalonia.Base/Media/PlatformDrawingContext.cs @@ -88,6 +88,14 @@ internal sealed class PlatformDrawingContext : DrawingContext protected override void PushTextOptionsCore(TextOptions textOptions) => _impl.PushTextOptions(textOptions); + protected override void PushEffectCore(IEffect effect, Rect bounds) + { + if (_impl is IDrawingContextImplWithEffects effectImpl) + { + effectImpl.PushEffect(bounds, effect); + } + } + protected override void PopClipCore() => _impl.PopClip(); protected override void PopGeometryClipCore() => _impl.PopGeometryClip(); @@ -104,6 +112,14 @@ internal sealed class PlatformDrawingContext : DrawingContext protected override void PopTextOptionsCore() => _impl.PopTextOptions(); + protected override void PopEffectCore() + { + if (_impl is IDrawingContextImplWithEffects effectImpl) + { + effectImpl.PopEffect(); + } + } + protected override void DisposeCore() { if (_ownsImpl) diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataNodes.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataNodes.cs index c7d10f69a1..ded07e52b2 100644 --- a/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataNodes.cs +++ b/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataNodes.cs @@ -14,7 +14,8 @@ enum RenderDataPopNodeType Clip, GeometryClip, Opacity, - OpacityMask + OpacityMask, + Effect } interface IRenderDataServerResourcesCollector @@ -247,3 +248,21 @@ class RenderDataTextOptionsNode : RenderDataPushNode context.Context.PopTextOptions(); } } + +class RenderDataEffectNode : RenderDataPushNode +{ + public IEffect? Effect { get; set; } + public Rect BoundsRect { get; set; } + + public override void Push(ref RenderDataNodeRenderContext context) + { + if (Effect != null && context.Context is IDrawingContextImplWithEffects effectImpl) + effectImpl.PushEffect(BoundsRect, Effect); + } + + public override void Pop(ref RenderDataNodeRenderContext context) + { + if (Effect != null && context.Context is IDrawingContextImplWithEffects effectImpl) + effectImpl.PopEffect(); + } +} diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs index ead6a3f730..ec4d46d453 100644 --- a/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs +++ b/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs @@ -269,6 +269,12 @@ internal class RenderDataDrawingContext : DrawingContext TextOptions = textOptions }); + protected override void PushEffectCore(IEffect effect, Rect bounds) => Push(new RenderDataEffectNode() + { + Effect = effect, + BoundsRect = bounds + }); + protected override void PopClipCore() => Pop(); protected override void PopGeometryClipCore() => Pop(); @@ -283,6 +289,8 @@ internal class RenderDataDrawingContext : DrawingContext protected override void PopTextOptionsCore() => Pop(); + protected override void PopEffectCore() => Pop(); + internal override void DrawBitmap(IRef? source, double opacity, Rect sourceRect, Rect destRect) { if (source == null || sourceRect.IsEmpty() || destRect.IsEmpty()) diff --git a/src/Avalonia.Base/Rendering/ImmediateRenderer.cs b/src/Avalonia.Base/Rendering/ImmediateRenderer.cs index f054e77db8..1921977b46 100644 --- a/src/Avalonia.Base/Rendering/ImmediateRenderer.cs +++ b/src/Avalonia.Base/Rendering/ImmediateRenderer.cs @@ -63,6 +63,7 @@ internal class ImmediateRenderer }) using (visual.Clip is { } clip ? context.PushGeometryClip(clip) : default(DrawingContext.PushedState?)) using (visual.OpacityMask is { } opctMask ? context.PushOpacityMask(opctMask, rect) : default(DrawingContext.PushedState?)) + using (visual.Effect is { } effect ? context.PushEffect(effect, rect) : default(DrawingContext.PushedState?)) { var totalTransform = transform * parentTransform; var visualBounds = rect.TransformToAABB(totalTransform); diff --git a/tests/Avalonia.Controls.UnitTests/Shapes/ShapeTests.cs b/tests/Avalonia.Controls.UnitTests/Shapes/ShapeTests.cs index b7b5604135..d5453551d4 100644 --- a/tests/Avalonia.Controls.UnitTests/Shapes/ShapeTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Shapes/ShapeTests.cs @@ -181,8 +181,12 @@ public class ShapeTests : ScopedTestBase protected override void PushRenderOptionsCore(RenderOptions renderOptions) { } - - protected override void PushTextOptionsCore(TextOptions textOptions) + + protected override void PushTextOptionsCore(TextOptions textOptions) + { + } + + protected override void PushEffectCore(IEffect effect, Rect bounds) { } @@ -213,10 +217,14 @@ public class ShapeTests : ScopedTestBase protected override void PopRenderOptionsCore() { } - + protected override void PopTextOptionsCore() { } + + protected override void PopEffectCore() + { + } protected override void DisposeCore() { diff --git a/tests/Avalonia.RenderTests/Media/RenderTargetBitmapTests.cs b/tests/Avalonia.RenderTests/Media/RenderTargetBitmapTests.cs index 15e36e195b..83c171fdf5 100644 --- a/tests/Avalonia.RenderTests/Media/RenderTargetBitmapTests.cs +++ b/tests/Avalonia.RenderTests/Media/RenderTargetBitmapTests.cs @@ -48,12 +48,6 @@ public class RenderTargetBitmapTests : TestBase }; await RenderToFile(root); - CompareImages(); - - // var rtb = new RenderTargetBitmap(new PixelSize(300,300), new Vector(96,96)); - // rtb.Render(root); - // - // rtb.Save("test.png"); } } From f37a4dab7f6d3b914ce3f4bab6645a957e8d9165 Mon Sep 17 00:00:00 2001 From: timunie Date: Mon, 2 Mar 2026 15:37:14 +0100 Subject: [PATCH 3/8] yet another render test --- .../Media/DrawingContextTests.cs | 32 ++++++++++++++++++ ...nder_DrawingGroup_With_Effect.expected.png | Bin 0 -> 1173 bytes 2 files changed, 32 insertions(+) create mode 100644 tests/TestFiles/Skia/Media/DrawingContext/Should_Render_DrawingGroup_With_Effect.expected.png diff --git a/tests/Avalonia.RenderTests/Media/DrawingContextTests.cs b/tests/Avalonia.RenderTests/Media/DrawingContextTests.cs index c201e7502a..5d79637ee7 100644 --- a/tests/Avalonia.RenderTests/Media/DrawingContextTests.cs +++ b/tests/Avalonia.RenderTests/Media/DrawingContextTests.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Threading.Tasks; using Avalonia.Controls; +using Avalonia.Layout; using Avalonia.Media; using Xunit; #pragma warning disable CS0649 @@ -29,6 +30,37 @@ public class DrawingContextTests : TestBase CompareImages(skipImmediate: true); } + [Fact] + public async Task Should_Render_DrawingGroup_With_Effect() + { + var group = new DrawingGroup(); + using (var context = group.Open()) + { + context.DrawRectangle(Brushes.White, null, new Rect(0, 0, 100, 100)); + using (context.PushEffect(new DropShadowEffect { BlurRadius = 10, Color = Colors.Black, Opacity = 1 }, new Rect(0, 0, 100, 100))) + { + context.DrawRectangle(Brushes.Red, null, new Rect(20, 20, 60, 60)); + } + } + + var target = new Border + { + Width = 100, + Height = 100, + Background = Brushes.White, + Child = new Image { + Source = new DrawingImage { Drawing = group }, + Stretch = Stretch.None, + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center, + ClipToBounds = false + } + }; + + await RenderToFile(target); + CompareImages(); + } + internal class RenderControl : Control { private static readonly Typeface s_typeface = new Typeface(TestFontFamily); diff --git a/tests/TestFiles/Skia/Media/DrawingContext/Should_Render_DrawingGroup_With_Effect.expected.png b/tests/TestFiles/Skia/Media/DrawingContext/Should_Render_DrawingGroup_With_Effect.expected.png new file mode 100644 index 0000000000000000000000000000000000000000..4cef7575eaf0d3637a67d5e6ce6382b3c929ef55 GIT binary patch literal 1173 zcmeAS@N?(olHy`uVBq!ia0vp^DImUt4|mI4|#d#!}ExK!Jnl`<#2neKg}*d|Yel>guFbmI39hz1#RIB-%Kd z7!oDwq#Xr%>i$XXXMaM0V`HInSoX^F~gwzoxN7gKJ4< zm(J-6+jKvOE}Ie*;8lBCb^3=*P8ul}TduhTik$c?<9nHbVMnp}KUthg9 zTf|9d*0RFHu9Q2URKKdFtkqtUdpmHSMfu%4z9Rc4e#^u|-}ByAdg52(A)(|nLvbL{ zyOgM_zv5g@%=Kx#qkVj~K>Ym)OC6`Wul%Jkwe@JP>(9OGCYW+9e6=|0bztO;onNas z^_^;tU09m9w$+f$b5hR{pYT)buh%X2I=5b5f5j@U)W}5|Tq3TGn{)z0MZf*F&qn@b1Qp-h}31gTe~DWM4f{?Y<2 literal 0 HcmV?d00001 From 0edef9aee63ab4e4d43e3321d40e87d6fd0d5ed6 Mon Sep 17 00:00:00 2001 From: timunie Date: Mon, 2 Mar 2026 15:50:34 +0100 Subject: [PATCH 4/8] XML comments for newly added members --- src/Avalonia.Base/Media/DrawingContext.cs | 11 ++++++++++- src/Avalonia.Base/Media/DrawingGroup.cs | 8 ++++++++ src/Avalonia.Base/Media/PlatformDrawingContext.cs | 2 ++ .../Composition/Drawing/Nodes/RenderDataNodes.cs | 12 ++++++++++++ .../Composition/Drawing/RenderDataDrawingContext.cs | 2 ++ 5 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Base/Media/DrawingContext.cs b/src/Avalonia.Base/Media/DrawingContext.cs index 5e39b0050c..9ccb730d29 100644 --- a/src/Avalonia.Base/Media/DrawingContext.cs +++ b/src/Avalonia.Base/Media/DrawingContext.cs @@ -451,9 +451,14 @@ namespace Avalonia.Media } protected abstract void PushTextOptionsCore(TextOptions textOptions); - + protected abstract void PushTransformCore(Matrix matrix); + /// + /// Pushes an effect. + /// + /// The effect. + /// The bounds of the effect. protected abstract void PushEffectCore(IEffect effect, Rect bounds); protected abstract void PopClipCore(); @@ -463,6 +468,10 @@ namespace Avalonia.Media protected abstract void PopTransformCore(); protected abstract void PopRenderOptionsCore(); protected abstract void PopTextOptionsCore(); + + /// + /// Pops an effect. + /// protected abstract void PopEffectCore(); private static bool PenIsVisible(IPen? pen) diff --git a/src/Avalonia.Base/Media/DrawingGroup.cs b/src/Avalonia.Base/Media/DrawingGroup.cs index c76a417b87..2cab34344d 100644 --- a/src/Avalonia.Base/Media/DrawingGroup.cs +++ b/src/Avalonia.Base/Media/DrawingGroup.cs @@ -21,6 +21,9 @@ namespace Avalonia.Media public static readonly StyledProperty OpacityMaskProperty = AvaloniaProperty.Register(nameof(OpacityMask)); + /// + /// Defines the property. + /// public static readonly StyledProperty EffectProperty = AvaloniaProperty.Register(nameof(Effect)); @@ -56,6 +59,9 @@ namespace Avalonia.Media set => SetValue(OpacityMaskProperty, value); } + /// + /// Gets or sets the effect to apply to the drawing group. + /// public IEffect? Effect { get => GetValue(EffectProperty); @@ -346,6 +352,7 @@ namespace Avalonia.Media drawingGroup.TextOptions = textOptions; } + /// protected override void PushEffectCore(IEffect effect, Rect bounds) { // Instantiate a new drawing group and set it as the _currentDrawingGroup @@ -369,6 +376,7 @@ namespace Avalonia.Media protected override void PopTextOptionsCore() => Pop(); + /// protected override void PopEffectCore() => Pop(); /// diff --git a/src/Avalonia.Base/Media/PlatformDrawingContext.cs b/src/Avalonia.Base/Media/PlatformDrawingContext.cs index 846c17dee5..6ebd11f7fd 100644 --- a/src/Avalonia.Base/Media/PlatformDrawingContext.cs +++ b/src/Avalonia.Base/Media/PlatformDrawingContext.cs @@ -88,6 +88,7 @@ internal sealed class PlatformDrawingContext : DrawingContext protected override void PushTextOptionsCore(TextOptions textOptions) => _impl.PushTextOptions(textOptions); + /// protected override void PushEffectCore(IEffect effect, Rect bounds) { if (_impl is IDrawingContextImplWithEffects effectImpl) @@ -112,6 +113,7 @@ internal sealed class PlatformDrawingContext : DrawingContext protected override void PopTextOptionsCore() => _impl.PopTextOptions(); + /// protected override void PopEffectCore() { if (_impl is IDrawingContextImplWithEffects effectImpl) diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataNodes.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataNodes.cs index ded07e52b2..3830d9c825 100644 --- a/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataNodes.cs +++ b/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataNodes.cs @@ -249,17 +249,29 @@ class RenderDataTextOptionsNode : RenderDataPushNode } } +/// +/// A render data node that pushes an effect. +/// class RenderDataEffectNode : RenderDataPushNode { + /// + /// Gets or sets the effect to push. + /// public IEffect? Effect { get; set; } + + /// + /// Gets or sets the bounds of the effect. + /// public Rect BoundsRect { get; set; } + /// public override void Push(ref RenderDataNodeRenderContext context) { if (Effect != null && context.Context is IDrawingContextImplWithEffects effectImpl) effectImpl.PushEffect(BoundsRect, Effect); } + /// public override void Pop(ref RenderDataNodeRenderContext context) { if (Effect != null && context.Context is IDrawingContextImplWithEffects effectImpl) diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs index ec4d46d453..ea42fe7710 100644 --- a/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs +++ b/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs @@ -269,6 +269,7 @@ internal class RenderDataDrawingContext : DrawingContext TextOptions = textOptions }); + /// protected override void PushEffectCore(IEffect effect, Rect bounds) => Push(new RenderDataEffectNode() { Effect = effect, @@ -289,6 +290,7 @@ internal class RenderDataDrawingContext : DrawingContext protected override void PopTextOptionsCore() => Pop(); + /// protected override void PopEffectCore() => Pop(); internal override void DrawBitmap(IRef? source, double opacity, Rect sourceRect, Rect destRect) From b7a050039fe341e194278b6bb4cbe73f9f7f130d Mon Sep 17 00:00:00 2001 From: timunie Date: Tue, 3 Mar 2026 08:35:01 +0100 Subject: [PATCH 5/8] Address Copilot review --- src/Avalonia.Base/Media/DrawingGroup.cs | 5 ++++- src/Avalonia.Base/Rendering/ImmediateRenderer.cs | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Base/Media/DrawingGroup.cs b/src/Avalonia.Base/Media/DrawingGroup.cs index 2cab34344d..e725528b0c 100644 --- a/src/Avalonia.Base/Media/DrawingGroup.cs +++ b/src/Avalonia.Base/Media/DrawingGroup.cs @@ -70,6 +70,7 @@ namespace Avalonia.Media internal RenderOptions? RenderOptions { get; set; } internal TextOptions? TextOptions { get; set; } + internal Rect? EffectBounds { get; set; } /// /// Gets or sets the collection that contains the child geometries. @@ -89,13 +90,14 @@ namespace Avalonia.Media internal override void DrawCore(DrawingContext context) { var bounds = GetBounds(); + var effectBounds = EffectBounds ?? bounds.Inflate(Effect?.GetEffectOutputPadding() ?? default); using (context.PushTransform(Transform?.Value ?? Matrix.Identity)) using (context.PushOpacity(Opacity)) using (ClipGeometry != null ? context.PushGeometryClip(ClipGeometry) : default) using (OpacityMask != null ? context.PushOpacityMask(OpacityMask, bounds) : default) using (RenderOptions != null ? context.PushRenderOptions(RenderOptions.Value) : default) using (TextOptions != null ? context.PushTextOptions(TextOptions.Value) : default) - using (Effect != null ? context.PushEffect(Effect, bounds) : default) + using (Effect != null ? context.PushEffect(Effect, effectBounds) : default) { foreach (var drawing in Children) { @@ -360,6 +362,7 @@ namespace Avalonia.Media // Set the effect on the new DrawingGroup drawingGroup.Effect = effect; + drawingGroup.EffectBounds = bounds; } protected override void PopClipCore() => Pop(); diff --git a/src/Avalonia.Base/Rendering/ImmediateRenderer.cs b/src/Avalonia.Base/Rendering/ImmediateRenderer.cs index 1921977b46..0ae1e47163 100644 --- a/src/Avalonia.Base/Rendering/ImmediateRenderer.cs +++ b/src/Avalonia.Base/Rendering/ImmediateRenderer.cs @@ -63,7 +63,7 @@ internal class ImmediateRenderer }) using (visual.Clip is { } clip ? context.PushGeometryClip(clip) : default(DrawingContext.PushedState?)) using (visual.OpacityMask is { } opctMask ? context.PushOpacityMask(opctMask, rect) : default(DrawingContext.PushedState?)) - using (visual.Effect is { } effect ? context.PushEffect(effect, rect) : default(DrawingContext.PushedState?)) + using (visual.Effect is { } effect ? context.PushEffect(effect, rect.Inflate(effect.GetEffectOutputPadding())) : default(DrawingContext.PushedState?)) { var totalTransform = transform * parentTransform; var visualBounds = rect.TransformToAABB(totalTransform); From 4f36cf41a147d5ff8a9b38c4b59eb041fe954c63 Mon Sep 17 00:00:00 2001 From: timunie Date: Tue, 3 Mar 2026 08:35:50 +0100 Subject: [PATCH 6/8] Add another unit test to ensure the recent changes don't get lost at some point in time --- .../Media/DrawingGroupTests.cs | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 tests/Avalonia.Base.UnitTests/Media/DrawingGroupTests.cs diff --git a/tests/Avalonia.Base.UnitTests/Media/DrawingGroupTests.cs b/tests/Avalonia.Base.UnitTests/Media/DrawingGroupTests.cs new file mode 100644 index 0000000000..795821b0f0 --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Media/DrawingGroupTests.cs @@ -0,0 +1,99 @@ +using Avalonia.Media; +using Avalonia.Platform; +using Avalonia.Rendering.SceneGraph; +using Avalonia.UnitTests; +using Avalonia.Utilities; +using Moq; +using Xunit; + +namespace Avalonia.Base.UnitTests.Media; + +public class DrawingGroupTests +{ + [Fact] + public void PushEffect_Should_Store_Provided_Bounds() + { + using (UnitTestApplication.Start(new TestServices(renderInterface: Mock.Of()))) + { + var group = new DrawingGroup(); + var effect = new BlurEffect { Radius = 10 }; + var bounds = new Rect(10, 10, 100, 100); + + using (var context = group.Open()) + { + using (context.PushEffect(effect, bounds)) + { + context.DrawRectangle(Brushes.Red, null, new Rect(20, 20, 50, 50)); + } + } + + // The Open() call adds a child DrawingGroup to the root group when PushEffect is called. + Assert.Single(group.Children); + var childGroup = Assert.IsType(group.Children[0]); + Assert.Equal(effect, childGroup.Effect); + Assert.Equal(bounds, childGroup.EffectBounds); + } + } + + [Fact] + public void Drawing_With_Effect_Should_Use_Stored_Bounds() + { + using (UnitTestApplication.Start(new TestServices(renderInterface: Mock.Of()))) + { + var effect = new BlurEffect { Radius = 10 }; + var bounds = new Rect(10, 10, 100, 100); + var group = new DrawingGroup + { + Effect = effect, + EffectBounds = bounds + }; + group.Children.Add(new GeometryDrawing { Brush = Brushes.Red, Geometry = new RectangleGeometry { Rect = new Rect(20, 20, 50, 50) } }); + + var mockContext = new MockDrawingContext(); + group.Draw(mockContext); + + Assert.Equal(effect, mockContext.Effect); + Assert.Equal(bounds, mockContext.Bounds); + } + } + + private class MockDrawingContext : DrawingContext + { + public IEffect? Effect { get; private set; } + public Rect Bounds { get; private set; } + + protected override void PushEffectCore(IEffect effect, Rect bounds) + { + Effect = effect; + Bounds = bounds; + } + + protected override void PopEffectCore() { } + + // Implementing required abstract members + protected override void DrawEllipseCore(IBrush? brush, IPen? pen, Rect rect) { } + protected override void DrawGeometryCore(IBrush? brush, IPen? pen, IGeometryImpl geometry) { } + protected override void DrawLineCore(IPen pen, Point p1, Point p2) { } + protected override void DrawRectangleCore(IBrush? brush, IPen? pen, RoundedRect rrect, BoxShadows boxShadows = default) { } + protected override void PushClipCore(Rect rect) { } + protected override void PushClipCore(RoundedRect rect) { } + protected override void PushGeometryClipCore(Geometry clip) { } + protected override void PushOpacityCore(double opacity) { } + protected override void PushOpacityMaskCore(IBrush mask, Rect bounds) { } + protected override void PushTransformCore(Matrix matrix) { } + protected override void PopClipCore() { } + protected override void PopGeometryClipCore() { } + protected override void PopOpacityCore() { } + protected override void PopOpacityMaskCore() { } + protected override void PopTransformCore() { } + + protected override void DisposeCore() { } + internal override void DrawBitmap(IRef source, double opacity, Rect sourceRect, Rect destRect) { } + public override void Custom(ICustomDrawOperation custom) { } + public override void DrawGlyphRun(IBrush? foreground, GlyphRun glyphRun) { } + protected override void PushRenderOptionsCore(RenderOptions renderOptions) { } + protected override void PushTextOptionsCore(TextOptions textOptions) { } + protected override void PopRenderOptionsCore() { } + protected override void PopTextOptionsCore() { } + } +} From ab1cd6e8fba25cdee3dd72a538889df9eecb41ee Mon Sep 17 00:00:00 2001 From: timunie Date: Sun, 8 Mar 2026 12:12:09 +0100 Subject: [PATCH 7/8] address review --- src/Avalonia.Base/Media/DrawingGroup.cs | 15 ++++++++++++--- .../Drawing/RenderDataDrawingContext.cs | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/Avalonia.Base/Media/DrawingGroup.cs b/src/Avalonia.Base/Media/DrawingGroup.cs index e725528b0c..5d053699a4 100644 --- a/src/Avalonia.Base/Media/DrawingGroup.cs +++ b/src/Avalonia.Base/Media/DrawingGroup.cs @@ -89,12 +89,21 @@ namespace Avalonia.Media internal override void DrawCore(DrawingContext context) { - var bounds = GetBounds(); - var effectBounds = EffectBounds ?? bounds.Inflate(Effect?.GetEffectOutputPadding() ?? default); + // Compute effect bounds in local coordinate space (pre-transform) + // GetBounds() already includes transform, so we need to inverse-transform + // to get the local bounds, or compute bounds without applying transform + var localBounds = new Rect(); + foreach (var drawing in Children) + { + localBounds = localBounds.Union(drawing.GetBounds()); + } + var effectPadding = Effect?.GetEffectOutputPadding() ?? default; + var effectBounds = EffectBounds ?? localBounds.Inflate(effectPadding); + using (context.PushTransform(Transform?.Value ?? Matrix.Identity)) using (context.PushOpacity(Opacity)) using (ClipGeometry != null ? context.PushGeometryClip(ClipGeometry) : default) - using (OpacityMask != null ? context.PushOpacityMask(OpacityMask, bounds) : default) + using (OpacityMask != null ? context.PushOpacityMask(OpacityMask, localBounds) : default) using (RenderOptions != null ? context.PushRenderOptions(RenderOptions.Value) : default) using (TextOptions != null ? context.PushTextOptions(TextOptions.Value) : default) using (Effect != null ? context.PushEffect(Effect, effectBounds) : default) diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs index ea42fe7710..7ba2dedf3d 100644 --- a/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs +++ b/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs @@ -272,7 +272,7 @@ internal class RenderDataDrawingContext : DrawingContext /// protected override void PushEffectCore(IEffect effect, Rect bounds) => Push(new RenderDataEffectNode() { - Effect = effect, + Effect = effect.ToImmutable(), BoundsRect = bounds }); From a2904a1aef82e051701921d20536364a63102e90 Mon Sep 17 00:00:00 2001 From: Tim <47110241+timunie@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:29:17 +0100 Subject: [PATCH 8/8] Apply Review suggestion Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/Avalonia.RenderTests/Media/RenderTargetBitmapTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Avalonia.RenderTests/Media/RenderTargetBitmapTests.cs b/tests/Avalonia.RenderTests/Media/RenderTargetBitmapTests.cs index 83c171fdf5..d213bde3b5 100644 --- a/tests/Avalonia.RenderTests/Media/RenderTargetBitmapTests.cs +++ b/tests/Avalonia.RenderTests/Media/RenderTargetBitmapTests.cs @@ -1,3 +1,4 @@ +#if AVALONIA_SKIA using System.Threading.Tasks; using Avalonia.Controls; using Avalonia.Controls.Shapes; @@ -51,3 +52,4 @@ public class RenderTargetBitmapTests : TestBase CompareImages(); } } +#endif