Nikita Tsukanov пре 2 година
родитељ
комит
cf28998a46
26 измењених фајлова са 1069 додато и 20 уклоњено
  1. 40 0
      samples/RenderDemo/Pages/AnimationsPage.xaml
  2. 22 0
      src/Avalonia.Base/Media/Effects/BlurEffect.cs
  3. 104 0
      src/Avalonia.Base/Media/Effects/DropShadowEffect.cs
  4. 93 0
      src/Avalonia.Base/Media/Effects/Effect.cs
  5. 131 0
      src/Avalonia.Base/Media/Effects/EffectAnimator.cs
  6. 18 0
      src/Avalonia.Base/Media/Effects/EffectConverter.cs
  7. 56 0
      src/Avalonia.Base/Media/Effects/EffectExtesions.cs
  8. 83 0
      src/Avalonia.Base/Media/Effects/EffectTransition.cs
  9. 29 0
      src/Avalonia.Base/Media/Effects/IBlurEffect.cs
  10. 84 0
      src/Avalonia.Base/Media/Effects/IDropShadowEffect.cs
  11. 26 0
      src/Avalonia.Base/Media/Effects/IEffect.cs
  12. 6 0
      src/Avalonia.Base/Platform/IDrawingContextImpl.cs
  13. 9 0
      src/Avalonia.Base/Rect.cs
  14. 8 2
      src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs
  15. 56 0
      src/Avalonia.Base/Rendering/Composition/Expressions/TokenParser.cs
  16. 14 1
      src/Avalonia.Base/Rendering/Composition/Server/DrawingContextProxy.cs
  17. 65 3
      src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionContainerVisual.cs
  18. 24 4
      src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs
  19. 19 1
      src/Avalonia.Base/Visual.cs
  20. 4 2
      src/Avalonia.Base/composition-schema.xml
  21. 50 0
      src/Skia/Avalonia.Skia/DrawingContextImpl.Effects.cs
  22. 9 7
      src/Skia/Avalonia.Skia/DrawingContextImpl.cs
  23. 73 0
      tests/Avalonia.Base.UnitTests/Media/EffectTests.cs
  24. 43 0
      tests/Avalonia.RenderTests/Media/EffectTests.cs
  25. 3 0
      tests/Avalonia.Skia.RenderTests/Avalonia.Skia.RenderTests.csproj
  26. BIN
      tests/TestFiles/Skia/Media/Effects/DropShadowEffect.expected.png

+ 40 - 0
samples/RenderDemo/Pages/AnimationsPage.xaml

@@ -308,6 +308,41 @@
           </Animation>
         </Style.Animations>
       </Style>
+      <Style Selector="Border.Blur">
+        <Style.Animations>
+          <Animation Duration="0:0:3"
+                     IterationCount="Infinite"
+                     PlaybackDirection="Alternate">
+            <KeyFrame Cue="0%">
+              <Setter Property="Effect" Value="blur(0)"/>
+            </KeyFrame>
+            <KeyFrame Cue="100%">
+              <Setter Property="Effect" Value="blur(10)"/>
+            </KeyFrame>
+          </Animation>
+        </Style.Animations>
+        <Setter Property="Child" Value="{StaticResource Acorn}"/>
+      </Style>
+      <Style Selector="Border.DropShadow">
+        <Style.Animations>
+          <Animation Duration="0:0:3"
+                     IterationCount="Infinite"
+                     PlaybackDirection="Alternate">
+            <KeyFrame Cue="0%">
+              <Setter Property="Effect" Value="drop-shadow(0 0 0)"/>
+            </KeyFrame>
+            <KeyFrame Cue="35%">
+              <Setter Property="Effect" Value="drop-shadow(5 5 0 Green)"/>
+            </KeyFrame>
+            <KeyFrame Cue="70%">
+              <Setter Property="Effect" Value="drop-shadow(5 5 5 Red)"/>
+            </KeyFrame>
+            <KeyFrame Cue="100%">
+              <Setter Property="Effect" Value="drop-shadow(20 -5 5 Blue)"/>
+            </KeyFrame>
+          </Animation>
+        </Style.Animations>
+      </Style>
     </Styles>
   </UserControl.Styles>
   <Grid>
@@ -332,6 +367,11 @@
         <Border Classes="Test Rect8" Child="{x:Null}" />
         <Border Classes="Test Rect9" Child="{x:Null}" />
         <Border Classes="Test Rect10" Child="{x:Null}" />
+        <Border Classes="Test Blur" Background="#ffa0a0a0" BorderThickness="4" BorderBrush="Yellow" Padding="10"/>
+        <Border Classes="Test DropShadow" Background="Transparent" BorderThickness="4" BorderBrush="Yellow">
+          <TextBlock VerticalAlignment="Center" HorizontalAlignment="Center">Drop
+            Shadow</TextBlock>
+        </Border>
       </WrapPanel>
     </StackPanel>
   </Grid>

+ 22 - 0
src/Avalonia.Base/Media/Effects/BlurEffect.cs

@@ -0,0 +1,22 @@
+using System;
+// ReSharper disable CheckNamespace
+namespace Avalonia.Media;
+
+public class BlurEffect : Effect, IBlurEffect, IMutableEffect
+{
+    public static readonly StyledProperty<double> RadiusProperty = AvaloniaProperty.Register<BlurEffect, double>(
+        nameof(Radius), 5);
+
+    public double Radius
+    {
+        get => GetValue(RadiusProperty);
+        set => SetValue(RadiusProperty, value);
+    }
+    
+    static BlurEffect()
+    {
+        AffectsRender<BlurEffect>(RadiusProperty);
+    }
+
+    public IImmutableEffect ToImmutable() => new ImmutableBlurEffect(Radius);
+}

+ 104 - 0
src/Avalonia.Base/Media/Effects/DropShadowEffect.cs

@@ -0,0 +1,104 @@
+// ReSharper disable once CheckNamespace
+
+using System;
+// ReSharper disable CheckNamespace
+
+namespace Avalonia.Media;
+
+public abstract class DropShadowEffectBase : Effect
+{
+    public static readonly StyledProperty<double> BlurRadiusProperty =
+        AvaloniaProperty.Register<DropShadowEffectBase, double>(
+            nameof(BlurRadius), 5);
+
+    public double BlurRadius
+    {
+        get => GetValue(BlurRadiusProperty);
+        set => SetValue(BlurRadiusProperty, value);
+    }
+
+    public static readonly StyledProperty<Color> ColorProperty = AvaloniaProperty.Register<DropShadowEffectBase, Color>(
+        nameof(Color));
+
+    public Color Color
+    {
+        get => GetValue(ColorProperty);
+        set => SetValue(ColorProperty, value);
+    }
+
+    public static readonly StyledProperty<double> OpacityProperty =
+        AvaloniaProperty.Register<DropShadowEffectBase, double>(
+            nameof(Opacity), 1);
+
+    public double Opacity
+    {
+        get => GetValue(OpacityProperty);
+        set => SetValue(OpacityProperty, value);
+    }
+
+    static DropShadowEffectBase()
+    {
+        AffectsRender<DropShadowEffectBase>(BlurRadiusProperty, ColorProperty, OpacityProperty);
+    }
+}
+
+public class DropShadowEffect : DropShadowEffectBase, IDropShadowEffect, IMutableEffect
+{
+    public static readonly StyledProperty<double> OffsetXProperty = AvaloniaProperty.Register<DropShadowEffect, double>(
+        nameof(OffsetX), 3.5355);
+
+    public double OffsetX
+    {
+        get => GetValue(OffsetXProperty);
+        set => SetValue(OffsetXProperty, value);
+    }
+    
+    public static readonly StyledProperty<double> OffsetYProperty = AvaloniaProperty.Register<DropShadowEffect, double>(
+        nameof(OffsetY), 3.5355);
+
+    public double OffsetY
+    {
+        get => GetValue(OffsetYProperty);
+        set => SetValue(OffsetYProperty, value);
+    }
+
+    static DropShadowEffect()
+    {
+        AffectsRender<DropShadowEffect>(OffsetXProperty, OffsetYProperty);
+    }
+
+    public IImmutableEffect ToImmutable()
+    {
+        return new ImmutableDropShadowEffect(OffsetX, OffsetY, BlurRadius, Color, Opacity);
+    }
+}
+
+/// <summary>
+/// This class is compatible with WPF's DropShadowEffect and provides Direction and ShadowDepth properties instead of OffsetX/OffsetY
+/// </summary>
+public class DropShadowDirectionEffect : DropShadowEffectBase, IDirectionDropShadowEffect, IMutableEffect
+{
+    public static readonly StyledProperty<double> ShadowDepthProperty =
+        AvaloniaProperty.Register<DropShadowDirectionEffect, double>(
+            nameof(ShadowDepth), 5);
+
+    public double ShadowDepth
+    {
+        get => GetValue(ShadowDepthProperty);
+        set => SetValue(ShadowDepthProperty, value);
+    }
+
+    public static readonly StyledProperty<double> DirectionProperty = AvaloniaProperty.Register<DropShadowDirectionEffect, double>(
+        nameof(Direction), 315);
+
+    public double Direction
+    {
+        get => GetValue(DirectionProperty);
+        set => SetValue(DirectionProperty, value);
+    }
+    
+    public double OffsetX => Math.Cos(Direction) * ShadowDepth;
+    public double OffsetY => Math.Sin(Direction) * ShadowDepth;
+    
+    public IImmutableEffect ToImmutable() => new ImmutableDropShadowDirectionEffect(OffsetX, OffsetY, BlurRadius, Color, Opacity);
+}

+ 93 - 0
src/Avalonia.Base/Media/Effects/Effect.cs

@@ -0,0 +1,93 @@
+using System;
+using Avalonia.Animation;
+using Avalonia.Animation.Animators;
+using Avalonia.Reactive;
+using Avalonia.Rendering.Composition.Expressions;
+using Avalonia.Utilities;
+
+// ReSharper disable once CheckNamespace
+namespace Avalonia.Media;
+
+public class Effect : Animatable, IAffectsRender
+{
+    /// <summary>
+    /// Marks a property as affecting the brush's visual representation.
+    /// </summary>
+    /// <param name="properties">The properties.</param>
+    /// <remarks>
+    /// After a call to this method in a brush's static constructor, any change to the
+    /// property will cause the <see cref="Invalidated"/> event to be raised on the brush.
+    /// </remarks>
+    protected static void AffectsRender<T>(params AvaloniaProperty[] properties)
+        where T : Effect
+    {
+        var invalidateObserver = new AnonymousObserver<AvaloniaPropertyChangedEventArgs>(
+            static e => (e.Sender as T)?.RaiseInvalidated(EventArgs.Empty));
+
+        foreach (var property in properties)
+        {
+            property.Changed.Subscribe(invalidateObserver);
+        }
+    }
+
+    /// <summary>
+    /// Raises the <see cref="Invalidated"/> event.
+    /// </summary>
+    /// <param name="e">The event args.</param>
+    protected void RaiseInvalidated(EventArgs e) => Invalidated?.Invoke(this, e);
+
+    /// <inheritdoc />
+    public event EventHandler? Invalidated;
+
+
+    static Exception ParseError(string s) => throw new ArgumentException("Unable to parse effect: " + s);
+    public static IEffect Parse(string s)
+    {
+        var span = s.AsSpan();
+        var r = new TokenParser(span);
+        if (r.TryConsume("blur"))
+        {
+            if (!r.TryConsume('(') || !r.TryParseDouble(out var radius) || !r.TryConsume(')') || !r.IsEofWithWhitespace())
+                throw ParseError(s);
+            return new ImmutableBlurEffect(radius);
+        }
+
+       
+        if (r.TryConsume("drop-shadow"))
+        {
+            if (!r.TryConsume('(') || !r.TryParseDouble(out var offsetX)
+                                   || !r.TryParseDouble(out var offsetY))
+                throw ParseError(s);
+            double blurRadius = 0;
+            var color = Colors.Black;
+            if (!r.TryConsume(')'))
+            {
+                if (!r.TryParseDouble(out blurRadius) || blurRadius < 0)
+                    throw ParseError(s);
+                if (!r.TryConsume(')'))
+                {
+                    var endOfExpression = s.LastIndexOf(")", StringComparison.Ordinal);
+                    if (endOfExpression == -1)
+                        throw ParseError(s);
+
+                    if (!new TokenParser(span.Slice(endOfExpression + 1)).IsEofWithWhitespace())
+                        throw ParseError(s);
+
+                    if (!Color.TryParse(span.Slice(r.Position, endOfExpression - r.Position).TrimEnd(), out color))
+                        throw ParseError(s);
+                    return new ImmutableDropShadowEffect(offsetX, offsetY, blurRadius, color, 1);
+                }
+            }
+            if (!r.IsEofWithWhitespace())
+                throw ParseError(s);
+            return new ImmutableDropShadowEffect(offsetX, offsetY, blurRadius, color, 1);
+        }
+
+        throw ParseError(s);
+    }
+
+    static Effect()
+    {
+        EffectAnimator.EnsureRegistered();
+    }
+}

+ 131 - 0
src/Avalonia.Base/Media/Effects/EffectAnimator.cs

@@ -0,0 +1,131 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Avalonia.Data;
+using Avalonia.Logging;
+using Avalonia.Media;
+
+// ReSharper disable once CheckNamespace
+namespace Avalonia.Animation.Animators;
+
+public class EffectAnimator : Animator<IEffect?>
+{
+    public override IDisposable? Apply(Animation animation, Animatable control, IClock? clock,
+        IObservable<bool> match, Action? onComplete)
+    {
+        if (TryCreateAnimator<BlurEffectAnimator, IBlurEffect>(out var animator)
+            || TryCreateAnimator<DropShadowEffectAnimator, IDropShadowEffect>(out animator))
+            return animator.Apply(animation, control, clock, match, onComplete);
+
+        Logger.TryGet(LogEventLevel.Error, LogArea.Animations)?.Log(
+            this,
+            "The animation's keyframe value types set is not supported.");
+
+        return base.Apply(animation, control, clock, match, onComplete);
+    }
+
+    private bool TryCreateAnimator<TAnimator, TInterface>([NotNullWhen(true)] out IAnimator? animator)
+        where TAnimator : EffectAnimatorBase<TInterface>, new() where TInterface : class, IEffect
+    {
+        TAnimator? createdAnimator = null;
+        foreach (var keyFrame in this)
+        {
+            if (keyFrame.Value is TInterface)
+            {
+                createdAnimator ??= new TAnimator()
+                {
+                    Property = Property
+                };
+                createdAnimator.Add(new AnimatorKeyFrame(typeof(TAnimator), () => new TAnimator(), keyFrame.Cue,
+                    keyFrame.KeySpline)
+                {
+                    Value = keyFrame.Value
+                });
+            }
+            else
+            {
+                animator = null;
+                return false;
+            }
+        }
+
+        animator = createdAnimator;
+        return animator != null;
+    }
+
+    /// <summary>
+    /// Fallback implementation of <see cref="IEffect"/> animation.
+    /// </summary>
+    public override IEffect? Interpolate(double progress, IEffect? oldValue, IEffect? newValue) => progress >= 0.5 ? newValue : oldValue;
+
+    private static bool s_Registered;
+    public static void EnsureRegistered()
+    {
+        if(s_Registered)
+            return;
+        s_Registered = true;
+        Animation.RegisterAnimator<EffectAnimator>(prop =>
+            typeof(IEffect).IsAssignableFrom(prop.PropertyType));
+    }
+}
+
+public abstract class EffectAnimatorBase<T> : Animator<IEffect?> where T : class, IEffect?
+{
+    public override IDisposable BindAnimation(Animatable control, IObservable<IEffect?> instance)
+    {
+        if (Property is null)
+        {
+            throw new InvalidOperationException("Animator has no property specified.");
+        }
+
+        return control.Bind((AvaloniaProperty<IEffect?>)Property, instance, BindingPriority.Animation);
+    }
+
+    protected abstract T Interpolate(double progress, T oldValue, T newValue);
+    public override IEffect? Interpolate(double progress, IEffect? oldValue, IEffect? newValue)
+    {
+        var old = oldValue as T;
+        var n = newValue as T;
+        if (old == null || n == null)
+            return progress >= 0.5 ? newValue : oldValue;
+        return Interpolate(progress, old, n);
+    }
+}
+
+public class BlurEffectAnimator : EffectAnimatorBase<IBlurEffect>
+{
+    private static readonly DoubleAnimator s_doubleAnimator = new DoubleAnimator();
+
+    protected override IBlurEffect Interpolate(double progress, IBlurEffect oldValue, IBlurEffect newValue)
+    {
+        return new ImmutableBlurEffect(
+            s_doubleAnimator.Interpolate(progress, oldValue.Radius, newValue.Radius));
+    }
+}
+
+public class DropShadowEffectAnimator : EffectAnimatorBase<IDropShadowEffect>
+{
+    private static readonly DoubleAnimator s_doubleAnimator = new DoubleAnimator();
+
+    protected override IDropShadowEffect Interpolate(double progress, IDropShadowEffect oldValue,
+        IDropShadowEffect newValue)
+    {
+        var blur = s_doubleAnimator.Interpolate(progress, oldValue.BlurRadius, newValue.BlurRadius);
+        var color = ColorAnimator.InterpolateCore(progress, oldValue.Color, newValue.Color);
+        var opacity = s_doubleAnimator.Interpolate(progress, oldValue.Opacity, newValue.Opacity);
+
+        if (oldValue is IDirectionDropShadowEffect oldDirection && newValue is IDirectionDropShadowEffect newDirection)
+        {
+            return new ImmutableDropShadowDirectionEffect(
+                s_doubleAnimator.Interpolate(progress, oldDirection.Direction, newDirection.Direction),
+                s_doubleAnimator.Interpolate(progress, oldDirection.ShadowDepth, newDirection.ShadowDepth),
+                blur, color, opacity
+            );
+        }
+
+        return new ImmutableDropShadowEffect(
+            s_doubleAnimator.Interpolate(progress, oldValue.OffsetX, newValue.OffsetX),
+            s_doubleAnimator.Interpolate(progress, oldValue.OffsetY, newValue.OffsetY),
+            blur, color, opacity
+        );
+    }
+}

+ 18 - 0
src/Avalonia.Base/Media/Effects/EffectConverter.cs

@@ -0,0 +1,18 @@
+using System;
+using System.ComponentModel;
+using System.Globalization;
+
+namespace Avalonia.Media;
+
+public class EffectConverter : TypeConverter
+{
+    public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
+    {
+        return sourceType == typeof(string);
+    }
+
+    public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object? value)
+    {
+        return value is string s ? Effect.Parse(s) : null;
+    }
+}

+ 56 - 0
src/Avalonia.Base/Media/Effects/EffectExtesions.cs

@@ -0,0 +1,56 @@
+using System;
+
+// ReSharper disable once CheckNamespace
+namespace Avalonia.Media;
+
+public static class EffectExtensions
+{
+    static double AdjustPaddingRadius(double radius)
+    {
+        if (radius <= 0)
+            return 0;
+        return Math.Ceiling(radius) + 1;
+    }
+    internal static Thickness GetEffectOutputPadding(this IEffect? effect)
+    {
+        if (effect == null)
+            return default;
+        if (effect is IBlurEffect blur)
+            return new Thickness(AdjustPaddingRadius(blur.Radius));
+        if (effect is IDropShadowEffect dropShadowEffect)
+        {
+            var radius = AdjustPaddingRadius(dropShadowEffect.BlurRadius);
+            var rc = new Rect(-radius, -radius,
+                radius * 2, radius * 2);
+            rc = rc.Translate(new(dropShadowEffect.OffsetX, dropShadowEffect.OffsetY));
+            return new Thickness(Math.Max(0, 0 - rc.X),
+                Math.Max(0, 0 - rc.Y), Math.Max(0, rc.Right), Math.Max(0, rc.Bottom));
+        }
+
+        throw new ArgumentException("Unknown effect type: " + effect.GetType());
+    }
+
+    /// <summary>
+    /// Converts a effect to an immutable effect.
+    /// </summary>
+    /// <param name="effect">The effect.</param>
+    /// <returns>
+    /// The result of calling <see cref="IMutableEffect.ToImmutable"/> if the effect is mutable,
+    /// otherwise <paramref name="effect"/>.
+    /// </returns>
+    public static IImmutableEffect ToImmutable(this IEffect effect)
+    {
+        _ = effect ?? throw new ArgumentNullException(nameof(effect));
+
+        return (effect as IMutableEffect)?.ToImmutable() ?? (IImmutableEffect)effect;
+    }
+
+    internal static bool EffectEquals(this IImmutableEffect? immutable, IEffect? right)
+    {
+        if (immutable == null && right == null)
+            return true;
+        if (immutable != null && right != null)
+            return immutable.Equals(right);
+        return false;
+    }
+}

+ 83 - 0
src/Avalonia.Base/Media/Effects/EffectTransition.cs

@@ -0,0 +1,83 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Avalonia.Animation.Animators;
+using Avalonia.Animation.Easings;
+using Avalonia.Media;
+
+
+// ReSharper disable once CheckNamespace
+namespace Avalonia.Animation;
+
+/// <summary>
+/// Transition class that handles <see cref="AvaloniaProperty"/> with <see cref="IEffect"/> type.
+/// </summary>
+public class EffectTransition : Transition<IEffect?>
+{
+    private static readonly BlurEffectAnimator s_blurEffectAnimator = new();
+    private static readonly DropShadowEffectAnimator s_dropShadowEffectAnimator = new();
+    private static readonly ImmutableBlurEffect s_DefaultBlur = new ImmutableBlurEffect(0);
+    private static readonly ImmutableDropShadowDirectionEffect s_DefaultDropShadow = new(0, 0, 0, default, 0);
+
+    bool TryWithAnimator<TAnimator, TInterface>(
+        IObservable<double> progress,
+        TAnimator animator,
+        IEffect? oldValue, IEffect? newValue, TInterface defaultValue, [MaybeNullWhen(false)] out IObservable<IEffect?> observable)
+        where TAnimator : EffectAnimatorBase<TInterface> where TInterface : class, IEffect
+    {
+        observable = null;
+        TInterface? oldI = null, newI = null;
+        if (oldValue is TInterface oi)
+        {
+            oldI = oi;
+            if (newValue is TInterface ni)
+                newI = ni;
+            else if (newValue == null)
+                newI = defaultValue;
+            else
+                return false;
+        }
+        else if (newValue is TInterface nv)
+        {
+            oldI = defaultValue;
+            newI = nv;
+
+        }
+        else
+            return false;
+
+        observable = new AnimatorTransitionObservable<IEffect?, Animator<IEffect?>>(animator, progress, Easing, oldI, newI);
+        return true;
+
+    }
+
+    public override IObservable<IEffect?> DoTransition(IObservable<double> progress, IEffect? oldValue, IEffect? newValue)
+    {
+        if ((oldValue != null || newValue != null)
+            && (
+                TryWithAnimator<BlurEffectAnimator, IBlurEffect>(progress, s_blurEffectAnimator,
+                    oldValue, newValue, s_DefaultBlur, out var observable)
+                || TryWithAnimator<DropShadowEffectAnimator, IDropShadowEffect>(progress, s_dropShadowEffectAnimator,
+                    oldValue, newValue, s_DefaultDropShadow, out observable)
+            ))
+            return observable;
+        
+        return new IncompatibleTransitionObservable(progress, Easing, oldValue, newValue);
+    }
+
+    private sealed class IncompatibleTransitionObservable : TransitionObservableBase<IEffect?>
+    {
+        private readonly IEffect? _from;
+        private readonly IEffect? _to;
+
+        public IncompatibleTransitionObservable(IObservable<double> progress, Easing easing, IEffect? from, IEffect? to) : base(progress, easing)
+        {
+            _from = from;
+            _to = to;
+        }
+
+        protected override IEffect? ProduceValue(double progress)
+        {
+            return progress >= 0.5 ? _to : _from;
+        }
+    }
+}

+ 29 - 0
src/Avalonia.Base/Media/Effects/IBlurEffect.cs

@@ -0,0 +1,29 @@
+// ReSharper disable once CheckNamespace
+
+using Avalonia.Animation.Animators;
+
+namespace Avalonia.Media;
+
+public interface IBlurEffect : IEffect
+{
+    double Radius { get; }
+}
+
+public class ImmutableBlurEffect : IBlurEffect, IImmutableEffect
+{
+    static ImmutableBlurEffect()
+    {
+        EffectAnimator.EnsureRegistered();
+    }
+    
+    public ImmutableBlurEffect(double radius)
+    {
+        Radius = radius;
+    }
+
+    public double Radius { get; }
+
+    public bool Equals(IEffect? other) =>
+        // ReSharper disable once CompareOfFloatsByEqualityOperator
+        other is IBlurEffect blur && blur.Radius == Radius;
+}

+ 84 - 0
src/Avalonia.Base/Media/Effects/IDropShadowEffect.cs

@@ -0,0 +1,84 @@
+// ReSharper disable once CheckNamespace
+
+using System;
+using Avalonia.Animation.Animators;
+
+namespace Avalonia.Media;
+
+public interface IDropShadowEffect : IEffect
+{
+    double OffsetX { get; }
+    double OffsetY { get; }
+    double BlurRadius { get; }
+    Color Color { get; }
+    double Opacity { get; }
+}
+
+internal interface IDirectionDropShadowEffect : IDropShadowEffect
+{
+    double Direction { get; }
+    double ShadowDepth { get; }
+}
+
+public class ImmutableDropShadowEffect : IDropShadowEffect, IImmutableEffect
+{
+    static ImmutableDropShadowEffect()
+    {
+        EffectAnimator.EnsureRegistered();
+    }
+    
+    public ImmutableDropShadowEffect(double offsetX, double offsetY, double blurRadius, Color color, double opacity)
+    {
+        OffsetX = offsetX;
+        OffsetY = offsetY;
+        BlurRadius = blurRadius;
+        Color = color;
+        Opacity = opacity;
+    }
+
+    public double OffsetX { get; }
+    public double OffsetY { get; }
+    public double BlurRadius { get; }
+    public Color Color { get; }
+    public double Opacity { get; }
+    public bool Equals(IEffect? other)
+    {
+        return other is IDropShadowEffect d
+               && d.OffsetX == OffsetX && d.OffsetY == OffsetY
+               && d.BlurRadius == BlurRadius
+               && d.Color == Color && d.Opacity == Opacity;
+    }
+}
+
+
+public class ImmutableDropShadowDirectionEffect : IDirectionDropShadowEffect, IImmutableEffect
+{
+    static ImmutableDropShadowDirectionEffect()
+    {
+        EffectAnimator.EnsureRegistered();
+    }
+    
+    public ImmutableDropShadowDirectionEffect(double direction, double shadowDepth, double blurRadius, Color color, double opacity)
+    {
+        Direction = direction;
+        ShadowDepth = shadowDepth;
+        BlurRadius = blurRadius;
+        Color = color;
+        Opacity = opacity;
+    }
+    
+    public double OffsetX => Math.Cos(Direction) * ShadowDepth;
+    public double OffsetY => Math.Sin(Direction) * ShadowDepth;
+    public double Direction { get; }
+    public double ShadowDepth { get; }
+    public double BlurRadius { get; }
+    public Color Color { get; }
+    public double Opacity { get; }
+    public bool Equals(IEffect? other)
+    {
+        return other is IDropShadowEffect d
+               && d.OffsetX == OffsetX && d.OffsetY == OffsetY
+               && d.BlurRadius == BlurRadius
+               && d.Color == Color && d.Opacity == Opacity;
+    }
+}

+ 26 - 0
src/Avalonia.Base/Media/Effects/IEffect.cs

@@ -0,0 +1,26 @@
+// ReSharper disable once CheckNamespace
+
+using System;
+using System.ComponentModel;
+
+namespace Avalonia.Media;
+
+[TypeConverter(typeof(EffectConverter))]
+public interface IEffect
+{
+    
+}
+
+public interface IMutableEffect : IEffect, IAffectsRender
+{
+    /// <summary>
+    /// Creates an immutable clone of the effect.
+    /// </summary>
+    /// <returns>The immutable clone.</returns>
+    internal IImmutableEffect ToImmutable();
+}
+
+public interface IImmutableEffect : IEffect, IEquatable<IEffect>
+{
+    
+}

+ 6 - 0
src/Avalonia.Base/Platform/IDrawingContextImpl.cs

@@ -180,6 +180,12 @@ namespace Avalonia.Platform
         object? GetFeature(Type t);
     }
 
+    public interface IDrawingContextImplWithEffects
+    {
+        void PushEffect(IEffect effect);
+        void PopEffect();
+    }
+
     public static class DrawingContextImplExtensions
     {
         /// <summary>

+ 9 - 0
src/Avalonia.Base/Rect.cs

@@ -526,6 +526,15 @@ namespace Avalonia
             }
         }
 
+        internal static Rect? Union(Rect? left, Rect? right)
+        {
+            if (left == null)
+                return right;
+            if (right == null)
+                return left;
+            return left.Value.Union(right.Value);
+        }
+
         /// <summary>
         /// Returns a new <see cref="Rect"/> with the specified X position.
         /// </summary>

+ 8 - 2
src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs

@@ -252,8 +252,14 @@ public class CompositingRenderer : IRendererWithCompositor
             comp.Opacity = (float)visual.Opacity;
             comp.ClipToBounds = visual.ClipToBounds;
             comp.Clip = visual.Clip?.PlatformImpl;
-            comp.OpacityMask = visual.OpacityMask;
-            
+
+
+            if (!Equals(comp.OpacityMask, visual.OpacityMask))
+                comp.OpacityMask = visual.OpacityMask?.ToImmutable();
+
+            if (!comp.Effect.EffectEquals(visual.Effect))
+                comp.Effect = visual.Effect?.ToImmutable();
+
             var renderTransform = Matrix.Identity;
 
             if (visual.HasMirrorTransform) 

+ 56 - 0
src/Avalonia.Base/Rendering/Composition/Expressions/TokenParser.cs

@@ -29,6 +29,8 @@ namespace Avalonia.Rendering.Composition.Expressions
             }
         }
 
+        public bool NextIsWhitespace() => _s.Length > 0 && char.IsWhiteSpace(_s[0]);
+
         static bool IsAlphaNumeric(char ch) => (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'z') ||
                                                (ch >= 'A' && ch <= 'Z');
 
@@ -238,6 +240,12 @@ namespace Avalonia.Rendering.Composition.Expressions
                     len = c + 1;
                     dotCount++;
                 }
+                else if (ch == '-')
+                {
+                    if (len != 0)
+                        return false;
+                    len = c + 1;
+                }
                 else
                     break;
             }
@@ -254,7 +262,55 @@ namespace Avalonia.Rendering.Composition.Expressions
             Advance(len);
             return true;
         }
+        
+        public bool TryParseDouble(out double res)
+        {
+            res = 0;
+            SkipWhitespace();
+            if (_s.Length == 0)
+                return false;
+            
+            var len = 0;
+            var dotCount = 0;
+            for (var c = 0; c < _s.Length; c++)
+            {
+                var ch = _s[c];
+                if (ch >= '0' && ch <= '9')
+                    len = c + 1;
+                else if (ch == '.' && dotCount == 0)
+                {
+                    len = c + 1;
+                    dotCount++;
+                }
+                else if (ch == '-')
+                {
+                    if (len != 0)
+                        return false;
+                    len = c + 1;
+                }
+                else
+                    break;
+            }
+
+            var span = _s.Slice(0, len);
+
+#if NETSTANDARD2_0
+            if (!double.TryParse(span.ToString(), NumberStyles.Number, CultureInfo.InvariantCulture, out res))
+                return false;
+#else
+            if (!double.TryParse(span, NumberStyles.Number, CultureInfo.InvariantCulture, out res))
+                return false;
+#endif
+            Advance(len);
+            return true;
+        }
 
+        public bool IsEofWithWhitespace()
+        {
+            SkipWhitespace();
+            return Length == 0;
+        }
+        
         public override string ToString() => _s.ToString();
 
     }

+ 14 - 1
src/Avalonia.Base/Rendering/Composition/Server/DrawingContextProxy.cs

@@ -18,7 +18,8 @@ namespace Avalonia.Rendering.Composition.Server;
 /// they have information about the full render transform (they are not)
 /// 2) Keeps the draw list for the VisualBrush contents of the current drawing operation.
 /// </summary>
-internal class CompositorDrawingContextProxy : IDrawingContextImpl, IDrawingContextWithAcrylicLikeSupport
+internal class CompositorDrawingContextProxy : IDrawingContextImpl,
+    IDrawingContextWithAcrylicLikeSupport, IDrawingContextImplWithEffects
 {
     private IDrawingContextImpl _impl;
 
@@ -155,4 +156,16 @@ internal class CompositorDrawingContextProxy : IDrawingContextImpl, IDrawingCont
         if (_impl is IDrawingContextWithAcrylicLikeSupport acrylic) 
             acrylic.DrawRectangle(material, rect);
     }
+
+    public void PushEffect(IEffect effect)
+    {
+        if (_impl is IDrawingContextImplWithEffects effects)
+            effects.PushEffect(effect);
+    }
+
+    public void PopEffect()
+    {
+        if (_impl is IDrawingContextImplWithEffects effects)
+            effects.PopEffect();
+    }
 }

+ 65 - 3
src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionContainerVisual.cs

@@ -1,4 +1,6 @@
+using System;
 using System.Numerics;
+using Avalonia.Media;
 using Avalonia.Platform;
 
 // Special license applies <see href="https://raw.githubusercontent.com/AvaloniaUI/Avalonia/master/src/Avalonia.Base/Rendering/Composition/License.md">License.md</see>
@@ -13,6 +15,8 @@ namespace Avalonia.Rendering.Composition.Server
     internal partial class ServerCompositionContainerVisual : ServerCompositionVisual
     {
         public ServerCompositionVisualCollection Children { get; private set; } = null!;
+        private Rect? _transformedContentBounds;
+        private IImmutableEffect? _oldEffect;
         
         protected override void RenderCore(CompositorDrawingContextProxy canvas, Rect currentTransformedClip)
         {
@@ -24,18 +28,76 @@ namespace Avalonia.Rendering.Composition.Server
             }
         }
 
-        public override void Update(ServerCompositionTarget root)
+        public override UpdateResult Update(ServerCompositionTarget root)
         {
-            base.Update(root);
+            var (combinedBounds, oldInvalidated, newInvalidated) = base.Update(root);
             foreach (var child in Children)
             {
                 if (child.AdornedVisual != null)
                     root.EnqueueAdornerUpdate(child);
                 else
-                    child.Update(root);
+                {
+                    var res = child.Update(root);
+                    oldInvalidated |= res.InvalidatedOld;
+                    newInvalidated |= res.InvalidatedNew;
+                    combinedBounds = Rect.Union(combinedBounds, res.Bounds);
+                }
             }
+            
+            // If effect is changed, we need to clean both old and new bounds
+            var effectChanged = !Effect.EffectEquals(_oldEffect);
+            if (effectChanged)
+                oldInvalidated = newInvalidated = true;
+            
+            // Expand invalidated bounds to the whole content area since we don't actually know what is being sampled
+            // We also ignore clip for now since we don't have means to reset it?
+            if (_oldEffect != null && oldInvalidated && _transformedContentBounds.HasValue)
+                AddEffectPaddedDirtyRect(_oldEffect, _transformedContentBounds.Value);
+
+            if (Effect != null && newInvalidated && combinedBounds.HasValue)
+                AddEffectPaddedDirtyRect(Effect, combinedBounds.Value);
+            
+            _oldEffect = Effect;
+            _transformedContentBounds = combinedBounds;
 
             IsDirtyComposition = false;
+            return new(_transformedContentBounds, oldInvalidated, newInvalidated);
+        }
+
+        void AddEffectPaddedDirtyRect(IImmutableEffect effect, Rect transformedBounds)
+        {
+            var padding = effect.GetEffectOutputPadding();
+            if (padding == default)
+            {
+                AddDirtyRect(transformedBounds);
+                return;
+            }
+            
+            // We are in a weird position here: bounds are in global coordinates while padding gets applied in local ones
+            // Since we have optimizations to AVOID recomputing transformed bounds and since visuals with effects are relatively rare
+            // we instead apply the transformation matrix to rescale the bounds
+            
+            
+            // If we only have translation and scale, just scale the padding
+            if (CombinedTransformMatrix is
+                {
+                    M12: 0, M13: 0, M14: 0,
+                    M21: 0, M23: 0, M24: 0,
+                    M31: 0, M32: 0,  M34: 0,
+                    M43: 0, M44: 1
+                })
+                padding = new Thickness(padding.Left * CombinedTransformMatrix.M11,
+                    padding.Top * CombinedTransformMatrix.M22,
+                    padding.Right * CombinedTransformMatrix.M11,
+                    padding.Bottom * CombinedTransformMatrix.M22);
+            else
+            {
+                // Conservatively use the transformed rect size
+                var transformedPaddingRect = new Rect().Inflate(padding).TransformToAABB(CombinedTransformMatrix);
+                padding = new(Math.Max(transformedPaddingRect.Width, transformedPaddingRect.Height));
+            }
+
+            AddDirtyRect(transformedBounds.Inflate(padding));
         }
 
         partial void Initialize()

+ 24 - 4
src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs

@@ -54,6 +54,9 @@ namespace Avalonia.Rendering.Composition.Server
             canvas.PostTransform = MatrixUtils.ToMatrix(transform);
             canvas.Transform = Matrix.Identity;
 
+            if (Effect != null)
+                canvas.PushEffect(Effect);
+            
             if (Opacity != 1)
                 canvas.PushOpacity(Opacity, boundsRect);
             if (ClipToBounds && !HandlesClipToBounds)
@@ -79,6 +82,9 @@ namespace Avalonia.Rendering.Composition.Server
                 canvas.PopClip();
             if (Opacity != 1)
                 canvas.PopOpacity();
+            
+            if (Effect != null)
+                canvas.PopEffect();
         }
 
         protected virtual bool HandlesClipToBounds => false;
@@ -101,10 +107,18 @@ namespace Avalonia.Rendering.Composition.Server
         public Matrix4x4 CombinedTransformMatrix { get; private set; } = Matrix4x4.Identity;
         public Matrix4x4 GlobalTransformMatrix { get; private set; }
 
-        public virtual void Update(ServerCompositionTarget root)
+        public record struct UpdateResult(Rect? Bounds, bool InvalidatedOld, bool InvalidatedNew)
+        {
+            public UpdateResult() : this(null, false, false)
+            {
+                
+            }
+        }
+        
+        public virtual UpdateResult Update(ServerCompositionTarget root)
         {
             if (Parent == null && Root == null)
-                return;
+                return default;
 
             var wasVisible = IsVisibleInFrame;
 
@@ -146,6 +160,11 @@ namespace Avalonia.Rendering.Composition.Server
             GlobalTransformMatrix = newTransform;
 
             var ownBounds = OwnContentBounds;
+            
+            // Since padding is applied in the current visual's coordinate space we expand bounds before transforming them
+            if (Effect != null)
+                ownBounds = ownBounds.Inflate(Effect.GetEffectOutputPadding());
+            
             if (ownBounds != _oldOwnContentBounds || positionChanged)
             {
                 _oldOwnContentBounds = ownBounds;
@@ -168,7 +187,7 @@ namespace Avalonia.Rendering.Composition.Server
 
             _combinedTransformedClipBounds =
                 AdornedVisual?._combinedTransformedClipBounds
-                ?? Parent?._combinedTransformedClipBounds
+                ?? (Parent?.Effect == null ? Parent?._combinedTransformedClipBounds : null)
                 ?? new Rect(Root!.Size);
 
             if (_transformedClipBounds != null)
@@ -208,9 +227,10 @@ namespace Avalonia.Rendering.Composition.Server
             readback.Matrix = GlobalTransformMatrix;
             readback.TargetId = Root.Id;
             readback.Visible = IsHitTestVisibleInFrame;
+            return new(TransformedOwnContentBounds, invalidateNewBounds, invalidateOldBounds);
         }
 
-        void AddDirtyRect(Rect rc)
+        protected void AddDirtyRect(Rect rc)
         {
             if (rc == default)
                 return;

+ 19 - 1
src/Avalonia.Base/Visual.cs

@@ -48,7 +48,7 @@ namespace Avalonia
         /// </summary>
         public static readonly StyledProperty<Geometry?> ClipProperty =
             AvaloniaProperty.Register<Visual, Geometry?>(nameof(Clip));
-
+        
         /// <summary>
         /// Defines the <see cref="IsVisible"/> property.
         /// </summary>
@@ -66,6 +66,12 @@ namespace Avalonia
         /// </summary>
         public static readonly StyledProperty<IBrush?> OpacityMaskProperty =
             AvaloniaProperty.Register<Visual, IBrush?>(nameof(OpacityMask));
+        
+        /// <summary>
+        /// Defines the <see cref="Effect"/> property.
+        /// </summary>
+        public static readonly StyledProperty<IEffect?> EffectProperty =
+            AvaloniaProperty.Register<Visual, IEffect?>(nameof(Effect));
 
         /// <summary>
         /// Defines the <see cref="HasMirrorTransform"/> property.
@@ -127,6 +133,8 @@ namespace Avalonia
                 ClipToBoundsProperty,
                 IsVisibleProperty,
                 OpacityProperty,
+                OpacityMaskProperty,
+                EffectProperty,
                 HasMirrorTransformProperty);
             RenderTransformProperty.Changed.Subscribe(RenderTransformChanged);
             ZIndexProperty.Changed.Subscribe(ZIndexChanged);
@@ -233,6 +241,16 @@ namespace Avalonia
             get { return GetValue(OpacityMaskProperty); }
             set { SetValue(OpacityMaskProperty, value); }
         }
+        
+        /// <summary>
+        /// Gets or sets the effect of the control.
+        /// </summary>
+        public IEffect? Effect
+        {
+            get => GetValue(EffectProperty);
+            set => SetValue(EffectProperty, value);
+        }
+
 
         /// <summary>
         /// Gets or sets a value indicating whether to apply mirror transform on this control.

+ 4 - 2
src/Avalonia.Base/composition-schema.xml

@@ -6,7 +6,8 @@
     <Using>Avalonia.Rendering.Composition.Animations</Using>
     
     <Manual Name="Avalonia.Platform.IGeometryImpl" Passthrough="true"/>
-    <Manual Name="Avalonia.Media.IBrush" Passthrough="true"/>
+    <Manual Name="Avalonia.Media.IImmutableBrush" Passthrough="true"/>
+    <Manual Name="Avalonia.Media.IImmutableEffect" Passthrough="true"/>
     <Manual Name="CompositionSurface" />
     <Manual Name="CompositionDrawingSurface" />
     <Object Name="CompositionVisual" Abstract="true">
@@ -27,7 +28,8 @@
         <Property Name="TransformMatrix" Type="Matrix4x4" DefaultValue="Matrix4x4.Identity" Animated="true"/>
         <Property Name="AdornedVisual" Type="CompositionVisual?" Internal="true" />
         <Property Name="AdornerIsClipped" Type="bool" Internal="true" />
-        <Property Name="OpacityMaskBrush" Type="Avalonia.Media.IBrush?" Internal="true" />
+        <Property Name="OpacityMaskBrush" Type="Avalonia.Media.IImmutableBrush?" Internal="true" />
+        <Property Name="Effect" Type="Avalonia.Media.IImmutableEffect?" Internal="true" />
     </Object>
     <Object Name="CompositionContainerVisual" Inherits="CompositionVisual"/>
     <Object Name="CompositionSolidColorVisual" Inherits="CompositionContainerVisual">

+ 50 - 0
src/Skia/Avalonia.Skia/DrawingContextImpl.Effects.cs

@@ -0,0 +1,50 @@
+using System;
+using Avalonia.Media;
+using SkiaSharp;
+
+namespace Avalonia.Skia;
+
+partial class DrawingContextImpl
+{
+    
+    public void PushEffect(IEffect effect)
+    {
+        CheckLease();
+        using var filter = CreateEffect(effect);
+        var paint = SKPaintCache.Shared.Get();
+        paint.ImageFilter = filter;
+        Canvas.SaveLayer(paint);
+        SKPaintCache.Shared.ReturnReset(paint);
+    }
+
+    public void PopEffect()
+    {
+        CheckLease();
+        Canvas.Restore();
+    }
+
+    SKImageFilter? CreateEffect(IEffect effect)
+    {
+        if (effect is IBlurEffect blur)
+        {
+            if (blur.Radius <= 0)
+                return null;
+            var sigma = SkBlurRadiusToSigma(blur.Radius);
+            return SKImageFilter.CreateBlur(sigma, sigma);
+        }
+
+        if (effect is IDropShadowEffect drop)
+        {
+            var sigma = drop.BlurRadius > 0 ? SkBlurRadiusToSigma(drop.BlurRadius) : 0;
+            var alpha = drop.Color.A * drop.Opacity;
+            if (!_useOpacitySaveLayer)
+                alpha *= _currentOpacity;
+            var color = new SKColor(drop.Color.R, drop.Color.G, drop.Color.B, (byte)Math.Max(0, Math.Min(255, alpha)));
+
+            return SKImageFilter.CreateDropShadow((float)drop.OffsetX, (float)drop.OffsetY, sigma, sigma, color);
+        }
+
+        return null;
+    }
+    
+}

+ 9 - 7
src/Skia/Avalonia.Skia/DrawingContextImpl.cs

@@ -19,7 +19,9 @@ namespace Avalonia.Skia
     /// <summary>
     /// Skia based drawing context.
     /// </summary>
-    internal class DrawingContextImpl : IDrawingContextImpl, IDrawingContextWithAcrylicLikeSupport
+    internal partial class DrawingContextImpl : IDrawingContextImpl,
+        IDrawingContextWithAcrylicLikeSupport,
+        IDrawingContextImplWithEffects
     {
         private IDisposable?[]? _disposables;
         private readonly Vector _dpi;
@@ -249,6 +251,12 @@ namespace Avalonia.Skia
             }
         }
 
+        private static float SkBlurRadiusToSigma(double radius) {
+            if (radius <= 0)
+                return 0.0f;
+            return 0.288675f * (float)radius + 0.5f;
+        }
+        
         private struct BoxShadowFilter : IDisposable
         {
             public readonly SKPaint Paint;
@@ -262,12 +270,6 @@ namespace Avalonia.Skia
                 ClipOperation = clipOperation;
             }
 
-            private static float SkBlurRadiusToSigma(double radius) {
-                if (radius <= 0)
-                    return 0.0f;
-                return 0.288675f * (float)radius + 0.5f;
-            }
-
             public static BoxShadowFilter Create(SKPaint paint, BoxShadow shadow, double opacity)
             {
                 var ac = shadow.Color;

+ 73 - 0
tests/Avalonia.Base.UnitTests/Media/EffectTests.cs

@@ -0,0 +1,73 @@
+using System;
+using Avalonia.Media;
+using Xunit;
+
+namespace Avalonia.Base.UnitTests.Media;
+
+public class EffectTests
+{
+    [Fact]
+    public void Parse_Parses_Blur()
+    {
+        var effect = (ImmutableBlurEffect)Effect.Parse("blur(123.34)");
+        Assert.Equal(123.34, effect.Radius);
+    }
+
+    private const uint Black = 0xff000000;
+
+    [Theory,
+     InlineData("drop-shadow(10 20)", 10, 20, 0, Black),
+     InlineData("drop-shadow( 10  20 ) ", 10, 20, 0, Black),
+     InlineData("drop-shadow( 10  20 30 ) ", 10, 20, 30, Black),
+     InlineData("drop-shadow(10  20 30)", 10, 20, 30, Black),
+     InlineData("drop-shadow(-10  -20 30)", -10, -20, 30, Black),
+     InlineData("drop-shadow(10 20 30 #ffff00ff)", 10, 20, 30, 0xffff00ff),
+     InlineData("drop-shadow ( 10 20 30 #ffff00ff ) ", 10, 20, 30, 0xffff00ff),
+     InlineData("drop-shadow(10 20 30 red)", 10, 20, 30, 0xffff0000),
+     InlineData("drop-shadow ( 10   20   30 red  ) ", 10, 20, 30, 0xffff0000),
+     InlineData("drop-shadow(10 20 30 rgba(100, 30, 45, 90%))", 10, 20, 30, 0x90641e2d),
+     InlineData("drop-shadow(10 20 30  rgba(100, 30, 45, 90%) ) ", 10, 20, 30, 0x90641e2d),
+
+    ]
+    public void Parse_Parses_DropShadow(string s, double x, double y, double r, uint color)
+    {
+        var effect = (ImmutableDropShadowEffect)Effect.Parse(s);
+        Assert.Equal(x, effect.OffsetX);
+        Assert.Equal(y, effect.OffsetY);
+        Assert.Equal(r, effect.BlurRadius);
+        Assert.Equal(1, effect.Opacity);
+    }
+
+    [Theory,
+     InlineData("blur"),
+     InlineData("blur("),
+     InlineData("blur()"),
+     InlineData("blur(123"),
+     InlineData("blur(aaab)"),
+     InlineData("drop-shadow(-10  -20 -30)"),
+    ]
+    public void Invalid_Effect_Parse_Fails(string b)
+    {
+        Assert.Throws<ArgumentException>(() => Effect.Parse(b));
+    }
+
+    [Theory,
+     InlineData("blur(2.5)", 4, 4, 4, 4),
+     InlineData("blur(0)", 0, 0, 0, 0),
+     InlineData("drop-shadow(10 15)", 0, 0, 10, 15),
+     InlineData("drop-shadow(10 15 5)", 0, 0, 16, 21),
+     InlineData("drop-shadow(0 0 5)", 6, 6, 6, 6),
+     InlineData("drop-shadow(3 3 5)", 3, 3, 9, 9)
+
+
+    ]
+
+    public static void PaddingIsCorrectlyCalculated(string effect, double left, double top, double right, double bottom)
+    {
+        var padding = Effect.Parse(effect).GetEffectOutputPadding();
+        Assert.Equal(left, padding.Left);
+        Assert.Equal(top, padding.Top);
+        Assert.Equal(right, padding.Right);
+        Assert.Equal(bottom, padding.Bottom);
+    }
+}

+ 43 - 0
tests/Avalonia.RenderTests/Media/EffectTests.cs

@@ -0,0 +1,43 @@
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Media;
+using Xunit;
+#pragma warning disable CS0649
+
+#if AVALONIA_SKIA
+namespace Avalonia.Skia.RenderTests;
+
+public class EffectTests : TestBase
+{
+    public EffectTests() : base(@"Media\Effects")
+    {
+    }
+
+    [Fact]
+    public async Task DropShadowEffect()
+    {
+        var target = new Border
+        {
+            Width = 200,
+            Height = 200,
+            Background = Brushes.White,
+            Child = new Border()
+            {
+                Background = null,
+                Margin = new Thickness(40),
+                Effect = new ImmutableDropShadowEffect(20, 30, 5, Colors.Green, 1),
+                Child = new Border
+                {
+                    Background = new SolidColorBrush(Color.FromArgb(128, 0, 0, 255)),
+                    BorderBrush = Brushes.Red,
+                    BorderThickness = new Thickness(5)
+                }
+            }
+        };
+        
+        await RenderToFile(target);
+        CompareImages(skipImmediate: true);
+    }
+    
+}
+#endif

+ 3 - 0
tests/Avalonia.Skia.RenderTests/Avalonia.Skia.RenderTests.csproj

@@ -6,6 +6,9 @@
   </PropertyGroup>
   <ItemGroup>
     <Compile Include="..\Avalonia.RenderTests\**\*.cs" />
+    <Compile Update="..\Avalonia.RenderTests\Media\EffectTests.cs">
+      <Link>Media\EffectTests.cs</Link>
+    </Compile>
   </ItemGroup>
   <ItemGroup>
     <EmbeddedResource Include="..\Avalonia.RenderTests\*\*.ttf" />

BIN
tests/TestFiles/Skia/Media/Effects/DropShadowEffect.expected.png