Sfoglia il codice sorgente

Merge pull request #8184 from AvaloniaUI/transform-3d

3D transforms (updated to master)
Jumar Macato 3 anni fa
parent
commit
d1afd3bb59

+ 1 - 0
samples/ControlCatalog/Pages/CarouselPage.xaml

@@ -29,6 +29,7 @@
         <ComboBoxItem>None</ComboBoxItem>
         <ComboBoxItem>Slide</ComboBoxItem>
         <ComboBoxItem>Crossfade</ComboBoxItem>
+        <ComboBoxItem>3D Rotation</ComboBoxItem>
       </ComboBox>
     </StackPanel>
 

+ 3 - 0
samples/ControlCatalog/Pages/CarouselPage.xaml.cs

@@ -45,6 +45,9 @@ namespace ControlCatalog.Pages
                 case 2:
                     _carousel.PageTransition = new CrossFade(TimeSpan.FromSeconds(0.25));
                     break;
+                case 3:
+                    _carousel.PageTransition = new Rotate3DTransition(TimeSpan.FromSeconds(0.5), _orientation.SelectedIndex == 0 ? PageSlide.SlideAxis.Horizontal : PageSlide.SlideAxis.Vertical);
+                    break;
             }
         }
     }

+ 3 - 0
samples/RenderDemo/MainWindow.xaml

@@ -72,5 +72,8 @@
     <TabItem Header="Brushes">
       <pages:BrushesPage />
     </TabItem>
+    <TabItem Header="3D Transformation">
+      <pages:Transform3DPage />
+    </TabItem>
   </controls:HamburgerMenu>
 </Window>

+ 210 - 0
samples/RenderDemo/Pages/Transform3DPage.axaml

@@ -0,0 +1,210 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+             mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="700"
+             x:Class="RenderDemo.Pages.Transform3DPage">
+    <UserControl.Styles>
+        <Styles>
+            <Styles.Resources>
+                <Template x:Key="TestContent">
+                    <Grid RowDefinitions="*,*" ColumnDefinitions="*,*" Margin="5">
+                        <TextBlock>I'm a text</TextBlock>
+                        <Button Grid.Row="0" Grid.Column="1" Content="A Button"></Button>
+                        <Slider Grid.Row="1"
+                                Grid.Column="0"
+                                Grid.ColumnSpan="2"
+                                Value="{Binding Depth}"
+                                Minimum="100"
+                                Maximum="300" />
+                    </Grid>
+                </Template>
+            </Styles.Resources>
+        </Styles>
+        <Style Selector="Border.Test">
+            <Setter Property="Width" Value="200" />
+            <Setter Property="Height" Value="200" />
+            <Setter Property="Child" Value="{StaticResource TestContent}" />
+            <Setter Property="BorderThickness" Value="2" />
+            <Setter Property="BorderBrush" Value="Black" />
+            <Setter Property="Grid.ColumnSpan" Value="2" />
+        </Style>
+        <Style Selector="TextBlock, Label, Slider">
+            <Setter Property="VerticalAlignment" Value="Center" />
+            <Setter Property="Margin" Value="10,0,10,0" />
+        </Style>
+        <Style Selector="Border TextBlock">
+            <Setter Property="Foreground" Value="White" />
+        </Style>
+        <Style Selector="Border Button">
+            <Setter Property="Background" Value="White"></Setter>
+            <Setter Property="Foreground" Value="Black" />
+        </Style>
+        
+        <Style Selector="Border#B1">
+            <Style.Animations>
+                <Animation Duration="0:0:10"
+                           IterationCount="Infinite">
+                    <KeyFrame Cue="0%">
+                        <Setter Property="Rotate3DTransform.AngleX" Value="0" />
+                        <Setter Property="ZIndex" Value="4" />
+                    </KeyFrame>
+                    <KeyFrame Cue="25%">
+                        <Setter Property="Rotate3DTransform.AngleX" Value="90" />
+                        <Setter Property="ZIndex" Value="1" />
+                    </KeyFrame>
+                    <KeyFrame Cue="100%">
+                        <Setter Property="Rotate3DTransform.AngleX" Value="360" />
+                        <Setter Property="ZIndex" Value="4" />
+                    </KeyFrame>
+                </Animation>
+            </Style.Animations>
+        </Style>
+        <Style Selector="Border#B2">
+            <Style.Animations>
+                <Animation Duration="0:0:10"
+                           IterationCount="Infinite">
+                    <KeyFrame Cue="0%">
+                        <Setter Property="Rotate3DTransform.AngleX" Value="90" />
+                        <Setter Property="ZIndex" Value="1" />
+                    </KeyFrame>
+                    <KeyFrame Cue="25%">
+                        <Setter Property="Rotate3DTransform.AngleX" Value="180" />
+                        <Setter Property="ZIndex" Value="1" />
+                    </KeyFrame>
+                    <KeyFrame Cue="75%">
+                        <Setter Property="Rotate3DTransform.AngleX" Value="360" />
+                        <Setter Property="ZIndex" Value="4" />
+                    </KeyFrame>
+                    <KeyFrame Cue="100%">
+                        <Setter Property="Rotate3DTransform.AngleX" Value="450" />
+                        <Setter Property="ZIndex" Value="1" />
+                    </KeyFrame>
+                </Animation>
+            </Style.Animations>
+        </Style>
+        <Style Selector="Border#B3">
+            <Style.Animations>
+                <Animation Duration="0:0:10"
+                           IterationCount="Infinite">
+                    <KeyFrame Cue="0%">
+                        <Setter Property="Rotate3DTransform.AngleX" Value="180" />
+                        <Setter Property="ZIndex" Value="1" />
+                    </KeyFrame>
+                    <KeyFrame Cue="50%">
+                        <Setter Property="Rotate3DTransform.AngleX" Value="360" />
+                        <Setter Property="ZIndex" Value="4" />
+                    </KeyFrame>
+                    <KeyFrame Cue="75%">
+                        <Setter Property="Rotate3DTransform.AngleX" Value="450" />
+                        <Setter Property="ZIndex" Value="1" />
+                    </KeyFrame>
+                    <KeyFrame Cue="100%">
+                        <Setter Property="Rotate3DTransform.AngleX" Value="540" />
+                        <Setter Property="ZIndex" Value="1" />
+                    </KeyFrame>
+                </Animation>
+            </Style.Animations>
+        </Style>
+        <Style Selector="Border#B4">
+            <Style.Animations>
+                <Animation Duration="0:0:10"
+                           IterationCount="Infinite">
+                    <KeyFrame Cue="0%">
+                        <Setter Property="Rotate3DTransform.AngleX" Value="270" />
+                        <Setter Property="ZIndex" Value="1" />
+                    </KeyFrame>
+                    <KeyFrame Cue="25%">
+                        <Setter Property="Rotate3DTransform.AngleX" Value="360" />
+                        <Setter Property="ZIndex" Value="4" />
+                    </KeyFrame>
+                    <KeyFrame Cue="50%">
+                        <Setter Property="Rotate3DTransform.AngleX" Value="450" />
+                        <Setter Property="ZIndex" Value="1" />
+                    </KeyFrame>
+                    <KeyFrame Cue="100%">
+                        <Setter Property="Rotate3DTransform.AngleX" Value="630" />
+                        <Setter Property="ZIndex" Value="1" />
+                    </KeyFrame>
+                </Animation>
+            </Style.Animations>
+        </Style>
+    </UserControl.Styles>
+
+    <Grid ColumnDefinitions="Auto,*,Auto,*" RowDefinitions="*, Auto, Auto, Auto, Auto, Auto, Auto, Auto">
+        <Grid.Clock>
+            <Clock />
+        </Grid.Clock>
+        <Border Name="B1" Background="DarkRed" Classes="Test">
+            <Border.RenderTransform>
+                <Rotate3DTransform CenterZ="-100"
+                                   Depth="{Binding Depth}" />
+            </Border.RenderTransform>
+        </Border>
+        <Border Name="B2" Grid.Row="0" Grid.Column="0" Classes="Test" Background="DarkGreen">
+            <Border.RenderTransform>
+                <Rotate3DTransform CenterZ="-100"
+                                   Depth="{Binding Depth}" />
+            </Border.RenderTransform>
+        </Border>
+        <Border Name="B3" Grid.Row="0" Grid.Column="0" Classes="Test" Background="DarkBlue">
+            <Border.RenderTransform>
+                <Rotate3DTransform CenterZ="-100"
+                                   Depth="{Binding Depth}" />
+            </Border.RenderTransform>
+        </Border>
+        <Border Name="B4" Grid.Row="0" Grid.Column="0" Classes="Test" Background="Orange">
+            <Border.RenderTransform>
+                <Rotate3DTransform CenterZ="-100"
+                                   Depth="{Binding Depth}" />
+            </Border.RenderTransform>
+        </Border>
+
+        <Label Grid.Column="0" Grid.Row="1">Depth: </Label>
+        <Slider Grid.Column="1" Grid.Row="1" Value="{Binding Depth}" Minimum="100" Maximum="300" />
+
+        <Border Grid.Row="0" Grid.Column="2" Classes="Test" ZIndex="-2">
+            <Border.Background>
+                <LinearGradientBrush StartPoint="0%,0%" EndPoint="0%,100%">
+                    <GradientStop Offset="0" Color="Red" />
+                    <GradientStop Offset="1" Color="Blue" />
+                </LinearGradientBrush>
+            </Border.Background>
+            <Border.Styles>
+                <Style Selector="Label">
+                    <Setter Property="VerticalAlignment" Value="Center" />
+                </Style>
+                <Style Selector="Slider">
+                    <Setter Property="Width" Value="100" />
+                </Style>
+            </Border.Styles>
+            <Border.RenderTransform>
+                <Rotate3DTransform Depth="{Binding Depth}"
+                                   CenterX="{Binding CenterX}"
+                                   CenterY="{Binding CenterY}"
+                                   CenterZ="{Binding CenterZ}"
+                                   AngleX="{Binding AngleX}"
+                                   AngleY="{Binding AngleY}"
+                                   AngleZ="{Binding AngleZ}" />
+            </Border.RenderTransform>
+        </Border>
+
+        <Label Grid.Row="1" Grid.Column="2">Center X: </Label>
+        <Slider Grid.Row="1" Grid.Column="3" Value="{Binding CenterX}" Minimum="-100" Maximum="100" />
+
+        <Label Grid.Row="2" Grid.Column="2">Center Y: </Label>
+        <Slider Grid.Row="2" Grid.Column="3" Value="{Binding CenterY}" Minimum="-100" Maximum="100" />
+
+        <Label Grid.Row="3" Grid.Column="2">Center Z: </Label>
+        <Slider Grid.Row="3" Grid.Column="3" Value="{Binding CenterZ}" Minimum="-100" Maximum="100" />
+
+        <Label Grid.Row="4" Grid.Column="2">Angle X: </Label>
+        <Slider Grid.Row="4" Grid.Column="3" Value="{Binding AngleX}" Minimum="-180" Maximum="180" />
+
+        <Label Grid.Row="5" Grid.Column="2">Angle Y: </Label>
+        <Slider Grid.Row="5" Grid.Column="3" Value="{Binding AngleY}" Minimum="-180" Maximum="180" />
+
+        <Label Grid.Row="6" Grid.Column="2">Angle Z: </Label>
+        <Slider Grid.Row="6" Grid.Column="3" Value="{Binding AngleZ}" Minimum="-180" Maximum="180" />
+    </Grid>
+</UserControl>

+ 21 - 0
samples/RenderDemo/Pages/Transform3DPage.axaml.cs

@@ -0,0 +1,21 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+using RenderDemo.ViewModels;
+
+namespace RenderDemo.Pages;
+
+public class Transform3DPage : UserControl
+{
+    public Transform3DPage()
+    {
+        InitializeComponent();
+        this.DataContext = new Transform3DPageViewModel();
+    }
+
+    private void InitializeComponent()
+    {
+        AvaloniaXamlLoader.Load(this);
+    }
+}
+

+ 55 - 0
samples/RenderDemo/ViewModels/Transform3DPageViewModel.cs

@@ -0,0 +1,55 @@
+using System;
+using MiniMvvm;
+using Avalonia.Animation;
+
+namespace RenderDemo.ViewModels
+{
+    public class Transform3DPageViewModel : ViewModelBase
+    {
+        private double _depth = 200;
+
+        private double _centerX = 0;
+        private double _centerY = 0;
+        private double _centerZ = 0;
+        private double _angleX = 0;
+        private double _angleY = 0;
+        private double _angleZ = 0;
+        
+        public double Depth
+        {
+            get => _depth;
+            set => RaiseAndSetIfChanged(ref _depth, value);
+        }
+        
+        public double CenterX
+        {
+            get => _centerX;
+            set => RaiseAndSetIfChanged(ref _centerX, value);
+        }
+        public double CenterY
+        {
+            get => _centerY;
+            set => RaiseAndSetIfChanged(ref _centerY, value);
+        }
+        public double CenterZ
+        {
+            get => _centerZ;
+            set => RaiseAndSetIfChanged(ref _centerZ, value);
+        }
+        public double AngleX
+        {
+            get => _angleX;
+            set => RaiseAndSetIfChanged(ref _angleX, value);
+        }
+        public double AngleY
+        {
+            get => _angleY;
+            set => RaiseAndSetIfChanged(ref _angleY, value);
+        }
+        public double AngleZ
+        {
+            get => _angleZ;
+            set => RaiseAndSetIfChanged(ref _angleZ, value);
+        }
+    }
+}

+ 1 - 0
src/Avalonia.Base/Animation/Animators/TransformAnimator.cs

@@ -43,6 +43,7 @@ namespace Avalonia.Animation.Animators
                     normalTransform.Children.Add(new SkewTransform());
                     normalTransform.Children.Add(new RotateTransform());
                     normalTransform.Children.Add(new TranslateTransform());
+                    normalTransform.Children.Add(new Rotate3DTransform());
 
                     ctrl.RenderTransform = normalTransform;
                 }

+ 3 - 3
src/Avalonia.Base/Animation/PageSlide.cs

@@ -10,7 +10,7 @@ using Avalonia.VisualTree;
 namespace Avalonia.Animation
 {
     /// <summary>
-    /// Transitions between two pages by sliding them horizontally.
+    /// Transitions between two pages by sliding them horizontally or vertically.
     /// </summary>
     public class PageSlide : IPageTransition
     {
@@ -62,7 +62,7 @@ namespace Avalonia.Animation
         public Easing SlideOutEasing { get; set; } = new LinearEasing();
 
         /// <inheritdoc />
-        public async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken)
+        public virtual async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken)
         {
             if (cancellationToken.IsCancellationRequested)
             {
@@ -157,7 +157,7 @@ namespace Avalonia.Animation
         /// <remarks>
         /// Any one of the parameters may be null, but not both.
         /// </remarks>
-        private static IVisual GetVisualParent(IVisual? from, IVisual? to)
+        protected static IVisual GetVisualParent(IVisual? from, IVisual? to)
         {
             var p1 = (from ?? to)!.VisualParent;
             var p2 = (to ?? from)!.VisualParent;

+ 120 - 0
src/Avalonia.Base/Animation/Transitions/Rotate3DTransition.cs

@@ -0,0 +1,120 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia.Media;
+using Avalonia.Styling;
+
+namespace Avalonia.Animation;
+
+public class Rotate3DTransition: PageSlide
+{
+
+    /// <summary>
+    ///  Creates a new instance of the <see cref="Rotate3DTransition"/>
+    /// </summary>
+    /// <param name="duration">How long the rotation should take place</param>
+    /// <param name="orientation">The orientation of the rotation</param>
+    public Rotate3DTransition(TimeSpan duration, SlideAxis orientation = SlideAxis.Horizontal, double? depth = null)
+        : base(duration, orientation)
+    {
+        Depth = depth;
+    }
+    
+    /// <summary>
+    ///  Defines the depth of the 3D Effect. If null, depth will be calculated automatically from the width or height
+    ///  of the common parent of the visual being rotated.
+    /// </summary>
+    public double? Depth { get; set; }
+
+    /// <summary>
+    ///  Creates a new instance of the <see cref="Rotate3DTransition"/>
+    /// </summary>
+    public Rotate3DTransition() { }
+
+    /// <inheritdoc />
+    public override async Task Start(Visual? @from, Visual? to, bool forward, CancellationToken cancellationToken)
+    {
+        if (cancellationToken.IsCancellationRequested)
+        {
+            return;
+        }
+
+        var tasks = new Task[from != null && to != null ? 2 : 1];
+        var parent = GetVisualParent(from, to);
+        var (rotateProperty, center) = Orientation switch
+        {
+            SlideAxis.Vertical => (Rotate3DTransform.AngleXProperty, parent.Bounds.Height),
+            SlideAxis.Horizontal => (Rotate3DTransform.AngleYProperty, parent.Bounds.Width),
+            _ => throw new ArgumentOutOfRangeException()
+        };
+
+        var depthSetter = new Setter {Property = Rotate3DTransform.DepthProperty, Value = Depth ?? center};
+        var centerZSetter = new Setter {Property = Rotate3DTransform.CenterZProperty, Value = -center / 2};
+
+        KeyFrame CreateKeyFrame(double cue, double rotation, int zIndex, bool isVisible = true) => 
+            new() {
+                Setters =
+                {
+                    new Setter { Property = rotateProperty, Value = rotation },
+                    new Setter { Property = Visual.ZIndexProperty, Value = zIndex },
+                    new Setter { Property = Visual.IsVisibleProperty, Value = isVisible },
+                    centerZSetter,
+                    depthSetter
+                },
+                Cue = new Cue(cue)
+            };
+
+        if (from != null)
+        {
+            var animation = new Animation
+            {
+                Easing = SlideOutEasing,
+                Duration = Duration,
+                FillMode = FillMode.Forward,
+                Children =
+                {
+                    CreateKeyFrame(0d, 0d, 2),
+                    CreateKeyFrame(0.5d, 45d * (forward ? -1 : 1), 1),
+                    CreateKeyFrame(1d, 90d * (forward ? -1 : 1), 1, isVisible: false)
+                }
+            };
+
+            tasks[0] = animation.RunAsync(from, null, cancellationToken);
+        }
+
+        if (to != null)
+        {
+            to.IsVisible = true;
+            var animation = new Animation
+            {
+                Easing = SlideInEasing,
+                Duration = Duration,
+                FillMode = FillMode.Forward,
+                Children =
+                {
+                    CreateKeyFrame(0d, 90d * (forward ? 1 : -1), 1),
+                    CreateKeyFrame(0.5d, 45d * (forward ? 1 : -1), 1),
+                    CreateKeyFrame(1d, 0d, 2)
+                }
+            };
+
+            tasks[from != null ? 1 : 0] = animation.RunAsync(to, null, cancellationToken);
+        }
+
+        await Task.WhenAll(tasks);
+
+        if (!cancellationToken.IsCancellationRequested)
+        {
+            if (to != null)
+            {
+                to.ZIndex = 2;
+            }
+            
+            if (from != null)
+            {
+                from.IsVisible = false;
+                from.ZIndex = 1;
+            }
+        }
+    }
+}

+ 206 - 62
src/Avalonia.Base/Matrix.cs

@@ -1,12 +1,22 @@
 using System;
 using System.Globalization;
+using System.Linq;
+using System.Numerics;
 using Avalonia.Utilities;
 
 namespace Avalonia
 {
     /// <summary>
-    /// A 2x3 matrix.
+    /// A 3x3 matrix.
     /// </summary>
+    /// <remakrs>Matrix layout:
+    ///         | 1st col | 2nd col | 3r col |
+    /// 1st row | scaleX  | skrewY  | persX  |
+    /// 2nd row | skrewX  | scaleY  | persY  |
+    /// 3rd row | transX  | transY  | persZ  |
+    /// 
+    /// Note: Skia.SkMatrix uses a transposed layout (where for example skrewX/skrewY and perspp0/tranX are swapped).
+    /// </remakrs>
 #if !BUILDTASK
     public
 #endif
@@ -14,40 +24,76 @@ namespace Avalonia
     {
         private readonly double _m11;
         private readonly double _m12;
+        private readonly double _m13;
         private readonly double _m21;
         private readonly double _m22;
+        private readonly double _m23;
         private readonly double _m31;
         private readonly double _m32;
+        private readonly double _m33;
+        
+        /// <summary>
+        /// Initializes a new instance of the <see cref="Matrix"/> struct (equivalent to a 2x3 Matrix without perspective).
+        /// </summary>
+        /// <param name="scaleX">The first element of the first row.</param>
+        /// <param name="skrewY">The second element of the first row.</param>
+        /// <param name="skrewX">The first element of the second row.</param>
+        /// <param name="scaleY">The second element of the second row.</param>
+        /// <param name="offsetX">The first element of the third row.</param>
+        /// <param name="offsetY">The second element of the third row.</param>
+        public Matrix(
+            double scaleX,
+            double skrewY,
+            double skrewX,
+            double scaleY,
+            double offsetX,
+            double offsetY) : this( scaleX, skrewY, 0, skrewX, scaleY, 0, offsetX, offsetY, 1)
+        {
+        }
+
+
 
         /// <summary>
         /// Initializes a new instance of the <see cref="Matrix"/> struct.
         /// </summary>
-        /// <param name="m11">The first element of the first row.</param>
-        /// <param name="m12">The second element of the first row.</param>
-        /// <param name="m21">The first element of the second row.</param>
-        /// <param name="m22">The second element of the second row.</param>
+        /// <param name="scaleX">The first element of the first row.</param>
+        /// <param name="skrewY">The second element of the first row.</param>
+        /// <param name="persX">The third element of the first row.</param>
+        /// <param name="skrewX">The first element of the second row.</param>
+        /// <param name="scaleY">The second element of the second row.</param>
+        /// <param name="persY">The third element of the second row.</param>
         /// <param name="offsetX">The first element of the third row.</param>
         /// <param name="offsetY">The second element of the third row.</param>
+        /// <param name="persZ">The third element of the third row.</param>
         public Matrix(
-            double m11,
-            double m12,
-            double m21,
-            double m22,
+            double scaleX,
+            double skrewY,
+            double persX,
+            double skrewX,
+            double scaleY,
+            double persY, 
             double offsetX,
-            double offsetY)
+            double offsetY,
+            double persZ)
         {
-            _m11 = m11;
-            _m12 = m12;
-            _m21 = m21;
-            _m22 = m22;
+            _m11 = scaleX;
+            _m12 = skrewY;
+            _m13 = persX;
+            _m21 = skrewX;
+            _m22 = scaleY;
+            _m23 = persY;
             _m31 = offsetX;
             _m32 = offsetY;
+            _m33 = persZ;
         }
 
         /// <summary>
         /// Returns the multiplicative identity matrix.
         /// </summary>
-        public static Matrix Identity { get; } = new Matrix(1.0, 0.0, 0.0, 1.0, 0.0, 0.0);
+        public static Matrix Identity { get; } = new Matrix(
+            1.0, 0.0, 0.0,
+            0.0, 1.0, 0.0,
+            0.0, 0.0, 1.0);
 
         /// <summary>
         /// Returns whether the matrix is the identity matrix.
@@ -60,35 +106,50 @@ namespace Avalonia
         public bool HasInverse => !MathUtilities.IsZero(GetDeterminant());
 
         /// <summary>
-        /// The first element of the first row
+        /// The first element of the first row (scaleX).
         /// </summary>
         public double M11 => _m11;
 
         /// <summary>
-        /// The second element of the first row
+        /// The second element of the first row (skrewY).
         /// </summary>
         public double M12 => _m12;
 
         /// <summary>
-        /// The first element of the second row
+        /// The third element of the first row (persX: input x-axis perspective factor).
+        /// </summary>
+        public double M13 => _m13;
+
+        /// <summary>
+        /// The first element of the second row (skrewX).
         /// </summary>
         public double M21 => _m21;
 
         /// <summary>
-        /// The second element of the second row
+        /// The second element of the second row (scaleY).
         /// </summary>
         public double M22 => _m22;
 
         /// <summary>
-        /// The first element of the third row
+        /// The third element of the second row (persY: input y-axis perspective factor).
+        /// </summary>
+        public double M23 => _m23;
+
+        /// <summary>
+        /// The first element of the third row (offsetX/translateX).
         /// </summary>
         public double M31 => _m31;
 
         /// <summary>
-        /// The second element of the third row
+        /// The second element of the third row (offsetY/translateY).
         /// </summary>
         public double M32 => _m32;
 
+        /// <summary>
+        /// The third element of the third row (persZ: perspective scale factor).
+        /// </summary>
+        public double M33 => _m33;
+
         /// <summary>
         /// Multiplies two matrices together and returns the resulting matrix.
         /// </summary>
@@ -98,12 +159,15 @@ namespace Avalonia
         public static Matrix operator *(Matrix value1, Matrix value2)
         {
             return new Matrix(
-                (value1.M11 * value2.M11) + (value1.M12 * value2.M21),
-                (value1.M11 * value2.M12) + (value1.M12 * value2.M22),
-                (value1.M21 * value2.M11) + (value1.M22 * value2.M21),
-                (value1.M21 * value2.M12) + (value1.M22 * value2.M22),
-                (value1._m31 * value2.M11) + (value1._m32 * value2.M21) + value2._m31,
-                (value1._m31 * value2.M12) + (value1._m32 * value2.M22) + value2._m32);
+                (value1.M11 * value2.M11) + (value1.M12 * value2.M21) + (value1.M13 * value2.M31),
+                (value1.M11 * value2.M12) + (value1.M12 * value2.M22) + (value1.M13 * value2.M32),
+                (value1.M11 * value2.M13) + (value1.M12 * value2.M23) + (value1.M13 * value2.M33),
+                (value1.M21 * value2.M11) + (value1.M22 * value2.M21) + (value1.M23 * value2.M31),
+                (value1.M21 * value2.M12) + (value1.M22 * value2.M22) + (value1.M23 * value2.M32),
+                (value1.M21 * value2.M13) + (value1.M22 * value2.M23) + (value1.M23 * value2.M33),
+                (value1.M31 * value2.M11) + (value1.M32 * value2.M21) + (value1.M33 * value2.M31),
+                (value1.M31 * value2.M12) + (value1.M32 * value2.M22) + (value1.M33 * value2.M32), 
+                (value1.M31 * value2.M13) + (value1.M32 * value2.M23) + (value1.M33 * value2.M33));
         }
 
         /// <summary>
@@ -171,7 +235,7 @@ namespace Avalonia
         /// <returns>A scaling matrix.</returns>
         public static Matrix CreateScale(double xScale, double yScale)
         {
-            return CreateScale(new Vector(xScale, yScale));
+            return new Matrix(xScale, 0, 0, yScale, 0, 0);
         }
 
         /// <summary>
@@ -181,7 +245,7 @@ namespace Avalonia
         /// <returns>A scaling matrix.</returns>
         public static Matrix CreateScale(Vector scales)
         {
-            return new Matrix(scales.X, 0, 0, scales.Y, 0, 0);
+            return CreateScale(scales.X, scales.Y);
         }
 
         /// <summary>
@@ -214,7 +278,7 @@ namespace Avalonia
         {
             return angle * 0.0174532925;
         }
-
+        
         /// <summary>
         /// Appends another matrix as post-multiplication operation.
         /// Equivalent to this * value;
@@ -227,7 +291,7 @@ namespace Avalonia
         }
 
         /// <summary>
-        /// Prpends another matrix as pre-multiplication operation.
+        /// Prepends another matrix as pre-multiplication operation.
         /// Equivalent to value * this;
         /// </summary>
         /// <param name="value">A matrix.</param>
@@ -247,7 +311,49 @@ namespace Avalonia
         /// </remarks>
         public double GetDeterminant()
         {
-            return (_m11 * _m22) - (_m12 * _m21);
+            // implemented using "Laplace expansion":
+            return _m11 * (_m22 * _m33 - _m23 * _m32)
+                 - _m12 * (_m21 * _m33 - _m23 * _m31)
+                 + _m13 * (_m21 * _m32 - _m22 * _m31);
+        }
+
+        /// <summary>
+        ///  Transforms the point with the matrix
+        /// </summary>
+        /// <param name="p">The point to be transformed</param>
+        /// <returns>The transformed point</returns>
+        public Point Transform(Point p)
+        {
+            Point transformedResult;
+            
+            // If this matrix contains a non-affine transform with need to extend
+            // the point to a 3D vector and flatten it back for 2d display
+            // by multiplying X and Y with the inverse of the Z axis.
+            // The code below also works with affine transformations, but for performance (and compatibility)
+            // reasons we will use the more complex calculation only if necessary
+            if (ContainsPerspective())
+            {
+                var m44 = new Matrix4x4(
+                    (float)M11, (float)M12, (float)M13, 0,
+                    (float)M21, (float)M22, (float)M23, 0,
+                    (float)M31, (float)M32, (float)M33, 0,
+                    0, 0, 0, 1
+                );
+            
+                var vector = new Vector3((float)p.X, (float)p.Y, 1);
+                var transformedVector = Vector3.Transform(vector, m44);
+                var z = 1 / transformedVector.Z;
+            
+                transformedResult = new Point(transformedVector.X * z, transformedVector.Y * z);
+            }
+            else
+            {
+                return new Point(
+                    (p.X * M11) + (p.Y * M21) + M31,
+                    (p.X * M12) + (p.Y * M22) + M32);
+            }
+
+            return transformedResult;
         }
 
         /// <summary>
@@ -260,10 +366,13 @@ namespace Avalonia
             // ReSharper disable CompareOfFloatsByEqualityOperator
             return _m11 == other.M11 &&
                    _m12 == other.M12 &&
+                   _m13 == other.M13 &&
                    _m21 == other.M21 &&
                    _m22 == other.M22 &&
+                   _m23 == other.M23 &&
                    _m31 == other.M31 &&
-                   _m32 == other.M32;
+                   _m32 == other.M32 &&
+                   _m33 == other.M33;
             // ReSharper restore CompareOfFloatsByEqualityOperator
         }
 
@@ -280,9 +389,18 @@ namespace Avalonia
         /// <returns>The hash code.</returns>
         public override int GetHashCode()
         {
-            return M11.GetHashCode() + M12.GetHashCode() +
-                   M21.GetHashCode() + M22.GetHashCode() +
-                   M31.GetHashCode() + M32.GetHashCode();
+            return (_m11, _m12, _m13, _m21, _m22, _m23, _m31, _m32, _m33).GetHashCode();
+        }
+
+        /// <summary>
+        ///  Determines if the current matrix contains perspective (non-affine) transforms (true) or only (affine) transforms that could be mapped into an 2x3 matrix (false).
+        /// </summary>
+        public bool ContainsPerspective()
+        {
+
+            // ReSharper disable CompareOfFloatsByEqualityOperator
+            return _m13 != 0 || _m23 != 0 || _m33 != 1;
+            // ReSharper restore CompareOfFloatsByEqualityOperator
         }
 
         /// <summary>
@@ -292,15 +410,25 @@ namespace Avalonia
         public override string ToString()
         {
             CultureInfo ci = CultureInfo.CurrentCulture;
+
+            string msg;
+            double[] values;
+
+            if (ContainsPerspective())
+            {
+                msg = "{{ {{M11:{0} M12:{1} M13:{2}}} {{M21:{3} M22:{4} M23:{5}}} {{M31:{6} M32:{7} M33:{8}}} }}";
+                values = new[] { M11, M12, M13, M21, M22, M23, M31, M32, M33 };
+            }
+            else
+            {
+                msg = "{{ {{M11:{0} M12:{1}}} {{M21:{2} M22:{3}}} {{M31:{4} M32:{5}}} }}";
+                values = new[] { M11, M12, M21, M22, M31, M32 };
+            }
+
             return string.Format(
                 ci,
-                "{{ {{M11:{0} M12:{1}}} {{M21:{2} M22:{3}}} {{M31:{4} M32:{5}}} }}",
-                M11.ToString(ci),
-                M12.ToString(ci),
-                M21.ToString(ci),
-                M22.ToString(ci),
-                M31.ToString(ci),
-                M32.ToString(ci));
+                msg,
+                values.Select((v) => v.ToString(ci)).ToArray());
         }
 
         /// <summary>
@@ -318,14 +446,20 @@ namespace Avalonia
                 return false;
             }
 
+            var invdet = 1 / d;
+            
             inverted = new Matrix(
-                _m22 / d,
-                -_m12 / d,
-                -_m21 / d,
-                _m11 / d,
-                ((_m21 * _m32) - (_m22 * _m31)) / d,
-                ((_m12 * _m31) - (_m11 * _m32)) / d);
-
+                (_m22 * _m33 - _m32 * _m23) * invdet,
+                (_m13 * _m31 - _m12 * _m33) * invdet,
+                (_m12 * _m23 - _m13 * _m22) * invdet,
+                (_m23 * _m31 - _m21 * _m33) * invdet,
+                (_m11 * _m33 - _m13 * _m31) * invdet,
+                (_m21 * _m13 - _m11 * _m23) * invdet,
+                (_m21 * _m32 - _m31 * _m22) * invdet,
+                (_m21 * _m12 - _m11 * _m32) * invdet,
+                (_m11 * _m22 - _m21 * _m12) * invdet
+                );
+            
             return true;
         }
 
@@ -336,7 +470,7 @@ namespace Avalonia
         /// <returns>The inverted matrix.</returns>
         public Matrix Invert()
         {
-            if (!TryInvert(out Matrix inverted))
+            if (!TryInvert(out var inverted))
             {
                 throw new InvalidOperationException("Transform is not invertible.");
             }
@@ -347,20 +481,30 @@ namespace Avalonia
         /// <summary>
         /// Parses a <see cref="Matrix"/> string.
         /// </summary>
-        /// <param name="s">Six comma-delimited double values (m11, m12, m21, m22, offsetX, offsetY) that describe the new <see cref="Matrix"/></param>
+        /// <param name="s">Six or nine comma-delimited double values (m11, m12, m21, m22, offsetX, offsetY[, persX, persY, persZ]) that describe the new <see cref="Matrix"/></param>
         /// <returns>The <see cref="Matrix"/>.</returns>
         public static Matrix Parse(string s)
         {
+            // initialize to satisfy compiler - only used when retrieved from string.
+            double v8 = 0;
+            double v9 = 0;
+
             using (var tokenizer = new StringTokenizer(s, CultureInfo.InvariantCulture, exceptionMessage: "Invalid Matrix."))
             {
-                return new Matrix(
-                    tokenizer.ReadDouble(),
-                    tokenizer.ReadDouble(),
-                    tokenizer.ReadDouble(),
-                    tokenizer.ReadDouble(),
-                    tokenizer.ReadDouble(),
-                    tokenizer.ReadDouble()
-                );
+                var v1 = tokenizer.ReadDouble();
+                var v2 = tokenizer.ReadDouble();
+                var v3 = tokenizer.ReadDouble();
+                var v4 = tokenizer.ReadDouble();
+                var v5 = tokenizer.ReadDouble();
+                var v6 = tokenizer.ReadDouble();
+                var pers = tokenizer.TryReadDouble(out var v7);
+                pers = pers && tokenizer.TryReadDouble(out v8);
+                pers = pers && tokenizer.TryReadDouble(out v9);
+
+                if (pers) 
+                    return new Matrix(v1, v2, v7, v3, v4, v8, v5, v6, v9);
+                else
+                    return new Matrix(v1, v2, v3, v4, v5, v6);
             }
         }
 
@@ -369,14 +513,14 @@ namespace Avalonia
         /// </summary>
         /// <param name="matrix">Matrix to decompose.</param>
         /// <param name="decomposed">Decomposed matrix.</param>
-        /// <returns>The status of the operation.</returns>
+        /// <returns>The status of the operation.</returns>        
         public static bool TryDecomposeTransform(Matrix matrix, out Decomposed decomposed)
         {
             decomposed = default;
 
             var determinant = matrix.GetDeterminant();
             
-            if (MathUtilities.IsZero(determinant))
+            if (MathUtilities.IsZero(determinant) || matrix.ContainsPerspective())
             {
                 return false;
             }

+ 3 - 18
src/Avalonia.Base/Point.cs

@@ -1,5 +1,6 @@
 using System;
 using System.Globalization;
+using System.Numerics;
 #if !BUILDTASK
 using Avalonia.Animation.Animators;
 #endif
@@ -168,12 +169,7 @@ namespace Avalonia
         /// <param name="point">The point.</param>
         /// <param name="matrix">The matrix.</param>
         /// <returns>The resulting point.</returns>
-        public static Point operator *(Point point, Matrix matrix)
-        {
-            return new Point(
-                (point.X * matrix.M11) + (point.Y * matrix.M21) + matrix.M31,
-                (point.X * matrix.M12) + (point.Y * matrix.M22) + matrix.M32);
-        }
+        public static Point operator *(Point point, Matrix matrix) => matrix.Transform(point);
 
         /// <summary>
         /// Parses a <see cref="Point"/> string.
@@ -242,18 +238,7 @@ namespace Avalonia
         /// </summary>
         /// <param name="transform">The transform.</param>
         /// <returns>The transformed point.</returns>
-        public Point Transform(Matrix transform)
-        {
-            var x = X;
-            var y = Y;
-            var xadd = y * transform.M21 + transform.M31;
-            var yadd = x * transform.M12 + transform.M32;
-            x *= transform.M11;
-            x += xadd;
-            y *= transform.M22;
-            y += yadd;
-            return new Point(x, y);
-        }
+        public Point Transform(Matrix transform) => transform.Transform(this);
 
         /// <summary>
         /// Returns a new point with the specified X coordinate.

+ 210 - 0
src/Avalonia.Base/Rotate3DTransform.cs

@@ -0,0 +1,210 @@
+using System;
+using System.Numerics;
+using Avalonia.Animation.Animators;
+
+namespace Avalonia.Media;
+
+/// <summary>
+///  Non-Affine 3D transformation for rotating a visual around a definable axis
+/// </summary>
+public class Rotate3DTransform : Transform
+{
+    private readonly bool _isInitializing;
+    
+    /// <summary>
+    /// Defines the <see cref="AngleX"/> property.
+    /// </summary>
+    public static readonly StyledProperty<double> AngleXProperty =
+        AvaloniaProperty.Register<Rotate3DTransform, double>(nameof(AngleX));
+
+    /// <summary>
+    /// Defines the <see cref="AngleY"/> property.
+    /// </summary>
+    public static readonly StyledProperty<double> AngleYProperty =
+        AvaloniaProperty.Register<Rotate3DTransform, double>(nameof(AngleY));
+
+    /// <summary>
+    /// Defines the <see cref="AngleZ"/> property.
+    /// </summary>
+    public static readonly StyledProperty<double> AngleZProperty =
+        AvaloniaProperty.Register<Rotate3DTransform, double>(nameof(AngleZ));
+
+
+    /// <summary>
+    /// Defines the <see cref="CenterX"/> property.
+    /// </summary>
+    public static readonly StyledProperty<double> CenterXProperty =
+        AvaloniaProperty.Register<Rotate3DTransform, double>(nameof(CenterX));
+
+
+    /// <summary>
+    /// Defines the <see cref="CenterY"/> property.
+    /// </summary>
+    public static readonly StyledProperty<double> CenterYProperty =
+        AvaloniaProperty.Register<Rotate3DTransform, double>(nameof(CenterY));
+
+
+    /// <summary>
+    /// Defines the <see cref="CenterZ"/> property.
+    /// </summary>
+    public static readonly StyledProperty<double> CenterZProperty =
+        AvaloniaProperty.Register<Rotate3DTransform, double>(nameof(CenterZ));
+
+    /// <summary>
+    /// Defines the <see cref="Depth"/> property.
+    /// </summary>
+    public static readonly StyledProperty<double> DepthProperty =
+        AvaloniaProperty.Register<Rotate3DTransform, double>(nameof(Depth));
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="Rotate3DTransform"/> class.
+    /// </summary>
+    public Rotate3DTransform() { }
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="Rotate3DTransform"/> class.
+    /// </summary>
+    /// <param name="angleX">The rotation around the X-Axis</param>
+    /// <param name="angleY">The rotation around the Y-Axis</param>
+    /// <param name="angleZ">The rotation around the Z-Axis</param>
+    /// <param name="centerX">The origin of the X-Axis</param>
+    /// <param name="centerY">The origin of the Y-Axis</param>
+    /// <param name="centerZ">The origin of the Z-Axis</param>
+    /// <param name="depth">The depth of the 3D effect</param>
+    public Rotate3DTransform(
+        double angleX,
+        double angleY,
+        double angleZ,
+        double centerX,
+        double centerY,
+        double centerZ,
+        double depth) : this()
+    {
+        _isInitializing = true;
+        AngleX = angleX;
+        AngleY = angleY;
+        AngleZ = angleZ;
+        CenterX = centerX;
+        CenterY = centerY;
+        CenterZ = centerZ;
+        Depth = depth;
+        _isInitializing = false;
+    }
+
+    /// <summary>
+    /// Sets the rotation around the X-Axis
+    /// </summary>
+    public double AngleX
+    {
+        get => GetValue(AngleXProperty);
+        set => SetValue(AngleXProperty, value);
+    }
+
+    /// <summary>
+    /// Sets the rotation around the Y-Axis
+    /// </summary>
+    public double AngleY
+    {
+        get => GetValue(AngleYProperty);
+        set => SetValue(AngleYProperty, value);
+    }
+
+    /// <summary>
+    ///  Sets the rotation around the Z-Axis
+    /// </summary>
+    public double AngleZ
+    {
+        get => GetValue(AngleZProperty);
+        set => SetValue(AngleZProperty, value);
+    }
+
+    /// <summary>
+    ///  Moves the origin the X-Axis rotates around
+    /// </summary>
+    public double CenterX
+    {
+        get => GetValue(CenterXProperty);
+        set => SetValue(CenterXProperty, value);
+    }
+
+    /// <summary>
+    ///  Moves the origin the Y-Axis rotates around
+    /// </summary>
+    public double CenterY
+    {
+        get => GetValue(CenterYProperty);
+        set => SetValue(CenterYProperty, value);
+    }
+
+    /// <summary>
+    ///  Moves the origin the Z-Axis rotates around
+    /// </summary>
+    public double CenterZ
+    {
+        get => GetValue(CenterZProperty);
+        set => SetValue(CenterZProperty, value);
+    }
+
+    /// <summary>
+    ///  Affects the depth of the rotation effect
+    /// </summary>
+    public double Depth
+    {
+        get => GetValue(DepthProperty);
+        set => SetValue(DepthProperty, value);
+    }
+
+    /// <summary>
+    /// Gets the transform's <see cref="Matrix"/>. 
+    /// </summary>
+    public override Matrix Value
+    {
+        get
+        {
+            var matrix44 = Matrix4x4.Identity;
+            //Copy values first, because it's not guaranteed, that values will not change during calculation
+            var (copyCenterX, 
+                 copyCenterY, 
+                 copyCenterZ, 
+                 copyAngleX, 
+                 copyAngleY, 
+                 copyAngleZ, 
+                 copyDepth) = (CenterX, CenterY, CenterZ, AngleX, AngleY, AngleZ, Depth);
+            
+            var centerSum = copyCenterX + copyCenterY + copyCenterZ;
+            
+            if (Math.Abs(centerSum) > double.Epsilon) matrix44 *= Matrix4x4.CreateTranslation(-(float)copyCenterX, -(float)copyCenterY, -(float)copyCenterZ);
+
+            if (copyAngleX != 0) matrix44 *= Matrix4x4.CreateRotationX((float)Matrix.ToRadians(copyAngleX));
+            if (copyAngleY != 0) matrix44 *= Matrix4x4.CreateRotationY((float)Matrix.ToRadians(copyAngleY));
+            if (copyAngleZ != 0) matrix44 *= Matrix4x4.CreateRotationZ((float)Matrix.ToRadians(copyAngleZ));
+
+            if (Math.Abs(centerSum) > double.Epsilon) matrix44 *= Matrix4x4.CreateTranslation((float)copyCenterX, (float)copyCenterY, (float)copyCenterZ);
+
+            if (copyDepth != 0)
+            {
+                var perspectiveMatrix = Matrix4x4.Identity;
+                perspectiveMatrix.M34 = -1 / (float)copyDepth;
+                matrix44 *= perspectiveMatrix;
+            }
+
+            var matrix = new Matrix(
+                matrix44.M11,
+                matrix44.M12,
+                matrix44.M14,
+                matrix44.M21,
+                matrix44.M22,
+                matrix44.M24,
+                matrix44.M41,
+                matrix44.M42,
+                matrix44.M44);
+
+            return matrix;
+        }
+    }
+
+    protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+    {
+        if (!_isInitializing) RaiseChanged();
+    } 
+}

+ 1 - 0
src/Avalonia.Build.Tasks/Avalonia.Build.Tasks.csproj

@@ -103,5 +103,6 @@
       <Compile Remove="../Markup/Avalonia.Markup.Xaml.Loader\xamlil.github\src\XamlX\IL\SreTypeSystem.cs" />
       <PackageReference Include="Mono.Cecil" Version="0.11.2" />
       <PackageReference Include="Microsoft.Build.Framework" Version="15.1.548" PrivateAssets="All" />
+      <PackageReference Include="System.Numerics.Vectors" Version="4.5.0" />
     </ItemGroup>
 </Project>

+ 3 - 3
src/Skia/Avalonia.Skia/SkiaSharpExtensions.cs

@@ -103,9 +103,9 @@ namespace Avalonia.Skia
                 SkewY = (float)m.M12,
                 ScaleY = (float)m.M22,
                 TransY = (float)m.M32,
-                Persp0 = 0,
-                Persp1 = 0,
-                Persp2 = 1
+                Persp0 = (float)m.M13,
+                Persp1 = (float)m.M23,
+                Persp2 = (float)m.M33
             };
 
             return sm;

+ 92 - 0
tests/Avalonia.Base.UnitTests/MatrixTests.cs

@@ -0,0 +1,92 @@
+using System;
+using System.Numerics;
+using System.Runtime.InteropServices;
+using Avalonia.Media;
+using Xunit;
+
+namespace Avalonia.Visuals.UnitTests;
+
+/// <summary>
+///  These tests use the "official" Matrix4x4 and Matrix3x2 from the System.Numerics namespace, to validate
+///  that Avalonias own implementation of a 3x3 Matrix works correctly.
+/// </summary>
+public class MatrixTests
+{
+    /// <summary>
+    ///  Because Avalonia is working internally with doubles, but System.Numerics Vector and Matrix implementations
+    ///  only make use of floats, we need to reduce precision, comparing them. It should be sufficient to compare
+    ///  5 fractional digits to ensure, that the result is correct.
+    /// </summary>
+    /// <param name="expected">The expected vector</param>
+    /// <param name="actual">The actual transformed point</param>
+    private void AssertCoordinatesEqualWithReducedPrecision(Vector2 expected, Point actual)
+    {
+        double ReducePrecision(double input) => Math.Truncate(input * 10000);
+        
+        var expectedX = ReducePrecision(expected.X);
+        var expectedY = ReducePrecision(expected.Y);
+        
+        var actualX = ReducePrecision(actual.X);
+        var actualY = ReducePrecision(actual.Y);
+        
+        Assert.Equal(expectedX, actualX);
+        Assert.Equal(expectedY, actualY);
+    }
+    
+    [Fact]
+    public void Transform_Point_Should_Return_Correct_Value_For_Translated_Matrix()
+    {
+        var vector2 = Vector2.Transform(
+            new Vector2(1, 1), 
+            Matrix3x2.CreateTranslation(2, 2));
+        var expected = new Point(vector2.X, vector2.Y);
+        
+        var matrix = Matrix.CreateTranslation(2, 2);
+        var point = new Point(1, 1);
+        var transformedPoint = matrix.Transform(point);
+        
+        Assert.Equal(expected, transformedPoint);
+    }
+    
+    [Fact]
+    public void Transform_Point_Should_Return_Correct_Value_For_Rotated_Matrix()
+    {
+        var expected = Vector2.Transform(
+            new Vector2(0, 10), 
+            Matrix3x2.CreateRotation((float)Matrix.ToRadians(45)));
+
+        var matrix = Matrix.CreateRotation(Matrix.ToRadians(45));
+        var point = new Point(0, 10);
+        var actual = matrix.Transform(point);
+
+        AssertCoordinatesEqualWithReducedPrecision(expected, actual);
+    }
+    
+    [Fact]
+    public void Transform_Point_Should_Return_Correct_Value_For_Scaled_Matrix()
+    {
+        var vector2 = Vector2.Transform(
+            new Vector2(1, 1), 
+            Matrix3x2.CreateScale(2, 2));
+        var expected = new Point(vector2.X, vector2.Y);
+        var matrix = Matrix.CreateScale(2, 2);
+        var point = new Point(1, 1);
+        var actual = matrix.Transform(point);
+        
+        Assert.Equal(expected, actual);
+    }
+    
+    [Fact]
+    public void Transform_Point_Should_Return_Correct_Value_For_Skewed_Matrix()
+    {
+        var expected = Vector2.Transform(
+            new Vector2(1, 1), 
+            Matrix3x2.CreateSkew(30, 20));
+
+        var matrix = Matrix.CreateSkew(30, 20);
+        var point = new Point(1, 1);
+        var actual = matrix.Transform(point);
+        
+        AssertCoordinatesEqualWithReducedPrecision(expected, actual);
+    }
+}