SkiaSharp 中的矩阵转换

深入探讨具有通用转换矩阵的 SkiaSharp 转换

应用于 SKCanvas 对象的所有转换都会合并到 SKMatrix 结构的单个实例中。 这是一个标准的 3x3 转换矩阵,类似于所有新式 2D 图形系统中的转换矩阵。

如你所见,可以在不了解转换矩阵的情况下在 SkiaSharp 中使用转换,但从理论角度来看,转换矩阵很重要,并且在使用转换修改路径或处理复杂的触摸输入时至关重要,这两者都将在本文和下一篇文章中进行演示。

经过仿射变换的位图

通过访问只读 TotalMatrix 属性,随时可以使用应用于 SKCanvas 的当前转换矩阵。 可以使用 SetMatrix 方法设置新的转换矩阵,并且可以通过调用 ResetMatrix 来将转换矩阵还原为默认值。

直接使用画布矩阵转换的另一个 SKCanvas 成员是 Concat,它通过将两个矩阵相乘来连接两个矩阵。

默认转换矩阵是单位矩阵,由对角单元格中的 1 和其他单元格中的 0 组成:

| 1  0  0 |
| 0  1  0 |
| 0  0  1 |

可以使用静态 SKMatrix.MakeIdentity 方法创建单位矩阵:

SKMatrix matrix = SKMatrix.MakeIdentity();

SKMatrix 默认构造函数不会返回单位矩阵。 它会返回一个所有单元格都设置为零的矩阵。 除非计划手动设置这些单元格,否则不要使用 SKMatrix 构造函数。

当 SkiaSharp 呈现图形对象时,每个点 (x, y) 都会有效地转换为第三列为 1 的 1x3 矩阵:

| x  y  1 |

此 1x3 矩阵表示 Z 坐标设置为 1 的三维点。 二维矩阵转换需要在三维中工作是有数学原因的(稍后讨论)。 可以将此 1x3 矩阵看作是表示三维坐标系中的一个点,但始终位于 Z 等于 1 的二维平面上。

然后,此 1x3 矩阵乘以转换矩阵,结果是画布上呈现的点:

              | 1  0  0 |
| x  y  1 | × | 0  1  0 | = | x'  y'  z' |
              | 0  0  1 |

使用标准矩阵乘法,转换后的点如下所示:

x' = x

y' = y

z' = 1

这是默认转换。

当对 SKCanvas 对象调用 Translate 方法时,Translate 方法的 txty 参数将成为转换矩阵第三行的前两个单元格:

|  1   0   0 |
|  0   1   0 |
| tx  ty   1 |

乘法运算现在如下所示:

              |  1   0   0 |
| x  y  1 | × |  0   1   0 | = | x'  y'  z' |
              | tx  ty   1 |

下面是转换公式:

x' = x + tx

y' = y + ty

缩放因子的默认值为 1。 对新的 SKCanvas 对象调用 Scale 方法时,生成的转换矩阵包含对角单元格中的 sxsy 参数:

              | sx   0   0 |
| x  y  1 | × |  0  sy   0 | = | x'  y'  z' |
              |  0   0   1 |

转换公式如下所示:

x' = sx · x

y' = sy · y

调用 Skew 后的转换矩阵包含与缩放因子相邻的矩阵单元格中的两个参数:

              │   1   ySkew   0 │
| x  y  1 | × │ xSkew   1     0 │ = | x'  y'  z' |
              │   0     0     1 │

转换公式为:

x' = x + xSkew · y

y' = ySkew · x + y

若要对某个 α 角度调用 RotateDegreesRotateRadians,转换矩阵如下所示:

              │  cos(α)  sin(α)  0 │
| x  y  1 | × │ –sin(α)  cos(α)  0 │ = | x'  y'  z' |
              │    0       0     1 │

下面是转换公式:

x' = cos(α) · x - sin(α) · y

y' = sin(α) · x - cos(α) · y

当 α 为 0 度时,它是单位矩阵。 当 α 为 180 度时,转换矩阵如下所示:

| –1   0   0 |
|  0  –1   0 |
|  0   0   1 |

180 度旋转相当于水平和垂直翻转对象,这也可以通过将比例因子设置为 -1 来实现。

所有这些类型的转换都归类为仿射转换。 仿射转换从不涉及矩阵的第三列,该列保留默认值 0、0 和 1。 非仿射转换一文讨论了非仿射转换。

矩阵乘法

使用转换矩阵的一个显著优势是,可以通过矩阵乘法获得复合转换,在 SkiaSharp 文档中,这通常称为串联SKCanvas 中许多与转换相关的方法都引用了“pre-concatenation”或“pre-concat”。这指的是乘法的顺序,这很重要,因为矩阵乘法不是可交换的。

例如,Translate 方法的文档指出它“使用指定的平移预串联当前矩阵”,而 Scale 方法的文档指出它“以指定的比例预串联当前矩阵”。

这意味着方法调用指定的转换是乘数(左操作数),而当前转换矩阵是被乘数(右操作数)。

假设调用 Translate 后跟 Scale

canvas.Translate(tx, ty);
canvas.Scale(sx, sy);

Scale 转换乘以复合转换矩阵的 Translate 转换:

| sx   0   0 |   |  1   0   0 |   | sx   0   0 |
|  0  sy   0 | × |  0   1   0 | = |  0  sy   0 |
|  0   0   1 |   | tx  ty   1 |   | tx  ty   1 |

可以在 Translate 之前调用 Scale,如下所示:

canvas.Scale(sx, sy);
canvas.Translate(tx, ty);

在这种情况下,乘法的顺序是反向的,缩放因子有效地应用于平移因子:

|  1   0   0 |   | sx   0   0 |   |  sx      0    0 |
|  0   1   0 | × |  0  sy   0 | = |   0     sy    0 |
| tx  ty   1 |   |  0   0   1 |   | tx·sx  ty·sy  1 |

下面是具有透视点的 Scale 方法:

canvas.Scale(sx, sy, px, py);

这相当于以下平移和缩放调用:

canvas.Translate(px, py);
canvas.Scale(sx, sy);
canvas.Translate(–px, –py);

这三个转换矩阵的相乘顺序与方法在代码中的显示顺序相反:

|  1    0   0 |   | sx   0   0 |   |  1   0  0 |   |    sx         0     0 |
|  0    1   0 | × |  0  sy   0 | × |  0   1  0 | = |     0        sy     0 |
| –px  –py  1 |   |  0   0   1 |   | px  py  1 |   | px–px·sx  py–py·sy  1 |

SKMatrix 结构

SKMatrix 结构定义与转换矩阵的九个单元格对应的类型为 float 的九个读/写属性:

│ ScaleX  SkewY   Persp0 │
│ SkewX   ScaleY  Persp1 │
│ TransX  TransY  Persp2 │

SKMatrix 还定义了一个名为 Values、类型为 float[] 的属性。 此属性可用于按 ScaleXSkewXTransXSkewYScaleYTransYPersp0Persp1Persp2 的顺序一次设置或获取九个值。

非仿射转换一文中讨论了 Persp0Persp1Persp2 单元格。 如果这些单元格的默认值为 0、0 和 1,则转换乘以坐标点,如下所示:

              │ ScaleX  SkewY   0 │
| x  y  1 | × │ SkewX   ScaleY  0 │ = | x'  y'  z' |
              │ TransX  TransY  1 │

x' = ScaleX · x + SkewX · y + TransX

y' = SkewX · x + ScaleY · y + TransY

z' = 1

这是完整的二维仿射转换。 仿射转换会保留平行线,这意味着矩形永远不会转换为除平行四边形以外的任何内容。

SKMatrix 结构定义了多个用于创建 SKMatrix 值的静态方法。 这些都返回 SKMatrix 值:

SKMatrix 还定义了多个连接两个矩阵的静态方法,这意味着它们相乘。 这些方法命名为 ConcatPostConcatPreConcat,每个方法有两个版本。 这些方法没有返回值;而是通过 ref 参数引用现有 SKMatrix 值。 在以下示例中,ABR(对于“result”)都是 SKMatrix 值。

两个 Concat 方法的调用方式如下:

SKMatrix.Concat(ref R, A, B);

SKMatrix.Concat(ref R, ref A, ref B);

它们执行以下乘法运算:

R = B × A

其他方法只有两个参数。 修改第一个参数,并在方法调用返回时包含两个矩阵的乘积。 两个 PostConcat 方法的调用方式如下:

SKMatrix.PostConcat(ref A, B);

SKMatrix.PostConcat(ref A, ref B);

这些调用执行以下运算:

A = A × B

两个 PreConcat 方法类似:

SKMatrix.PreConcat(ref A, B);

SKMatrix.PreConcat(ref A, ref B);

这些调用执行以下运算:

A = B × A

这些方法具有所有 ref 自变量的版本在调用底层实现时稍微高效一些,但对于读取代码并假设具有 ref 自变量的任何内容都被该方法修改的人来说,这可能会让人感到困惑。 此外,传递作为 Make 方法之一的结果的自变量通常很方便,例如:

SKMatrix result;
SKMatrix.Concat(result, SKMatrix.MakeTranslation(100, 100),
                        SKMatrix.MakeScale(3, 3));

这会创建以下矩阵:

│   3    0  0 │
│   0    3  0 │
│ 100  100  1 │

这是缩放转换乘以平移转换。 在这种特殊情况下,SKMatrix 结构提供了一个名为 SetScaleTranslate 的方法的快捷方式:

SKMatrix R = new SKMatrix();
R.SetScaleTranslate(3, 3, 100, 100);

这是少数几个可以安全使用 SKMatrix 构造函数的情况之一。 SetScaleTranslate 方法设置矩阵的所有九个单元格。 还可以将 SKMatrix 构造函数与静态 RotateRotateDegrees 方法配合使用:

SKMatrix R = new SKMatrix();

SKMatrix.Rotate(ref R, radians);

SKMatrix.Rotate(ref R, radians, px, py);

SKMatrix.RotateDegrees(ref R, degrees);

SKMatrix.RotateDegrees(ref R, degrees, px, py);

这些方法不会将旋转转换连接到现有转换。 这些方法设置矩阵的所有单元格。 除了不实例化 SKMatrix 值之外,它们在功能上与 MakeRotationMakeRotationDegrees 方法相同。

假设你有一个要显示的 SKPath 对象,但希望它具有不同的方向或不同的中心点。 可以通过使用 SKMatrix 自变量调用 SKPathTransform 方法来修改该路径的所有坐标。 “路径转换”页面演示了如何执行此操作。 PathTransform 类引用字段中的 HendecagramPath 对象,但使用其构造函数将转换应用于该路径:

public class PathTransformPage : ContentPage
{
    SKPath transformedPath = HendecagramArrayPage.HendecagramPath;

    public PathTransformPage()
    {
        Title = "Path Transform";

        SKCanvasView canvasView = new SKCanvasView();
        canvasView.PaintSurface += OnCanvasViewPaintSurface;
        Content = canvasView;

        SKMatrix matrix = SKMatrix.MakeScale(3, 3);
        SKMatrix.PostConcat(ref matrix, SKMatrix.MakeRotationDegrees(360f / 22));
        SKMatrix.PostConcat(ref matrix, SKMatrix.MakeTranslation(300, 300));

        transformedPath.Transform(matrix);
    }
    ...
}

HendecagramPath 对象的中心位于 (0, 0),星形的 11 个点从该中心向所有方向向外延伸 100 个单位。 这意味着路径同时具有正坐标和负坐标。 “路径转换”页面更喜欢使用三倍大的星形和所有正坐标。 此外,它不希望星形的一个点指向正上方。 相反,它希望星形的一个点指向正下方。 (由于星形有 11 个点,所以不可能两者都有。)这需要星形旋转 360 度除以 22。

构造函数使用具有以下模式的 PostConcat 方法从三个独立的转换构建 SKMatrix 对象,其中 A、B 和 C 是 SKMatrix 的实例:

SKMatrix matrix = A;
SKMatrix.PostConcat(ref A, B);
SKMatrix.PostConcat(ref A, C);

这是一系列连续的乘法运算,因此结果如下所示:

A × B × C

连续乘法有助于了解每个转换的作用。 缩放转换会将路径坐标的大小增加 3 倍,因此坐标范围为 –300 到 300。 旋转转换使星形绕其原点旋转。 然后,平移转换将它向右和向下移动 300 哥像素,因此所有坐标都变为正。

还有其他序列会产生相同的矩阵。 下面是另一个示例:

SKMatrix matrix = SKMatrix.MakeRotationDegrees(360f / 22);
SKMatrix.PostConcat(ref matrix, SKMatrix.MakeTranslation(100, 100));
SKMatrix.PostConcat(ref matrix, SKMatrix.MakeScale(3, 3));

这首先绕其中心旋转路径,然后将其向右和向下平移 100 个像素,使所有坐标为正。 然后,星形的大小相对于其新的左上角(即点 (0, 0))增加。

PaintSurface 处理程序可以简单地呈现此路径:

public class PathTransformPage : ContentPage
{
    ...
    void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
    {
        SKImageInfo info = args.Info;
        SKSurface surface = args.Surface;
        SKCanvas canvas = surface.Canvas;

        canvas.Clear();

        using (SKPaint paint = new SKPaint())
        {
            paint.Style = SKPaintStyle.Stroke;
            paint.Color = SKColors.Magenta;
            paint.StrokeWidth = 5;

            canvas.DrawPath(transformedPath, paint);
        }
    }
}

它显示在画布的左上角:

“路径转换”页的三重屏幕截图

此程序的构造函数使用以下调用将矩阵应用于路径:

transformedPath.Transform(matrix);

路径不会将此矩阵保留为属性。 相反,它将转换应用于路径的所有坐标。 如果再次调用 Transform,则再次应用转换,而返回的唯一方法是应用另一个撤消转换的矩阵。 幸运的是,SKMatrix 结构定义了一个 TryInvert 方法,该方法可获取反转给定矩阵的矩阵:

SKMatrix inverse;
bool success = matrix.TryInverse(out inverse);

该方法称为 TryInverse,因为并非所有矩阵都是不可逆的,但不可逆矩阵不太可能用于图形转换。

还可以将矩阵转换应用于 SKPoint 值、点数组、SKRect,甚至只应用于程序中的单个数字。 SKMatrix 结构通过一组以单词 Map 开头的方法来支持这些操作,例如:

SKPoint transformedPoint = matrix.MapPoint(point);

SKPoint transformedPoint = matrix.MapPoint(x, y);

SKPoint[] transformedPoints = matrix.MapPoints(pointArray);

float transformedValue = matrix.MapRadius(floatValue);

SKRect transformedRect = matrix.MapRect(rect);

如果使用最后一种方法,请记住,SKRect 结构无法表示旋转的矩形。 此方法仅对表示平移和缩放的 SKMatrix 值有意义。

交互式试验

了解仿射转换的一种方法是在屏幕周围交互式地移动位图的三个角,并查看转换结果。 这就是“显示仿射矩阵”页面背后的想法。 此页面需要其他两个类,这些类也用于其他演示:

TouchPoint 类显示一个可以在屏幕上拖动的半透明圆圈。 TouchPoint 要求 SKCanvasViewSKCanvasView 的父元素附加 TouchEffect。 将 Capture 属性设置为 true。 在 TouchAction 事件处理程序中,程序必须为每个 TouchPoint 实例调用 TouchPoint 中的 ProcessTouchEvent 方法。 如果触摸事件导致触摸点移动,该方法将返回 true。 此外,PaintSurface 处理程序必须为每个 TouchPoint 实例调用 Paint 方法,并将其传递给 SKCanvas 对象。

TouchPoint 演示了可以将 SkiaSharp 视觉对象封装到单独的类中的常见方法。 类可以定义用于指定视觉对象的特征的属性,具有 SKCanvas 自变量、名为 Paint 的方法可以呈现它。

TouchPointCenter 属性指示对象的位置。 可以将此属性设置为初始化位置;当用户在画布周围拖动圆圈时,属性会更改。

“显示仿射矩阵”页也需要 MatrixDisplay 类。 此类显示 SKMatrix 对象的单元格。 它具有两个公共方法:Measure 用于获取呈现矩阵的维度,Paint 用于显示它。 该类包含类型为 SKPaintMatrixPaint 属性,该属性可以替换为不同的字体大小或颜色。

ShowAffineMatrixPage.xaml 文件实例化 SKCanvasView 并附加 TouchEffectShowAffineMatrixPage.xaml.cs 代码隐藏文件创建三个 TouchPoint 对象,然后将它们设置为与从嵌入的资源加载的位图的三个角相对应的位置:

public partial class ShowAffineMatrixPage : ContentPage
{
    SKMatrix matrix;
    SKBitmap bitmap;
    SKSize bitmapSize;

    TouchPoint[] touchPoints = new TouchPoint[3];

    MatrixDisplay matrixDisplay = new MatrixDisplay();

    public ShowAffineMatrixPage()
    {
        InitializeComponent();

        string resourceID = "SkiaSharpFormsDemos.Media.SeatedMonkey.jpg";
        Assembly assembly = GetType().GetTypeInfo().Assembly;

        using (Stream stream = assembly.GetManifestResourceStream(resourceID))
        {
            bitmap = SKBitmap.Decode(stream);
        }

        touchPoints[0] = new TouchPoint(100, 100);                  // upper-left corner
        touchPoints[1] = new TouchPoint(bitmap.Width + 100, 100);   // upper-right corner
        touchPoints[2] = new TouchPoint(100, bitmap.Height + 100);  // lower-left corner

        bitmapSize = new SKSize(bitmap.Width, bitmap.Height);
        matrix = ComputeMatrix(bitmapSize, touchPoints[0].Center,
                                           touchPoints[1].Center,
                                           touchPoints[2].Center);
    }
    ...
}

仿射矩阵是由三个点唯一定义的。 三个 TouchPoint 对象对应于位图的左上角、右上角和左下角。 由于仿射矩阵只能将矩形转换为平行四边形,所以第四点由其他三点隐含。 构造函数最后调用 ComputeMatrix,从这三个点计算 SKMatrix 对象的单元格。

TouchAction 处理程序调用每个 TouchPointProcessTouchEvent 方法。 scale 值从 Xamarin.Forms 坐标转换为像素:

public partial class ShowAffineMatrixPage : ContentPage
{
    ...
    void OnTouchEffectAction(object sender, TouchActionEventArgs args)
    {
        bool touchPointMoved = false;

        foreach (TouchPoint touchPoint in touchPoints)
        {
            float scale = canvasView.CanvasSize.Width / (float)canvasView.Width;
            SKPoint point = new SKPoint(scale * (float)args.Location.X,
                                        scale * (float)args.Location.Y);
            touchPointMoved |= touchPoint.ProcessTouchEvent(args.Id, args.Type, point);
        }

        if (touchPointMoved)
        {
            matrix = ComputeMatrix(bitmapSize, touchPoints[0].Center,
                                               touchPoints[1].Center,
                                               touchPoints[2].Center);
            canvasView.InvalidateSurface();
        }
    }
    ...
}

如果移动了任何 TouchPoint,则该方法将再次调用 ComputeMatrix 并使图面失效。

ComputeMatrix 方法确定这三个点所隐含的矩阵。 名为 A 的矩阵根据三个点将一个像素的方形矩形转换为平行四边形,而名为 S 的缩放转换将位图缩放为一个像素的方形矩形。 复合矩阵为 S × A

public partial class ShowAffineMatrixPage : ContentPage
{
    ...
    static SKMatrix ComputeMatrix(SKSize size, SKPoint ptUL, SKPoint ptUR, SKPoint ptLL)
    {
        // Scale transform
        SKMatrix S = SKMatrix.MakeScale(1 / size.Width, 1 / size.Height);

        // Affine transform
        SKMatrix A = new SKMatrix
        {
            ScaleX = ptUR.X - ptUL.X,
            SkewY = ptUR.Y - ptUL.Y,
            SkewX = ptLL.X - ptUL.X,
            ScaleY = ptLL.Y - ptUL.Y,
            TransX = ptUL.X,
            TransY = ptUL.Y,
            Persp2 = 1
        };

        SKMatrix result = SKMatrix.MakeIdentity();
        SKMatrix.Concat(ref result, A, S);
        return result;
    }
    ...
}

最后,PaintSurface 方法基于该矩阵呈现位图,在屏幕底部显示矩阵,并呈现位图三个角的触摸点:

public partial class ShowAffineMatrixPage : ContentPage
{
    ...
    void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
    {
        SKImageInfo info = args.Info;
        SKSurface surface = args.Surface;
        SKCanvas canvas = surface.Canvas;

        canvas.Clear();

        // Display the bitmap using the matrix
        canvas.Save();
        canvas.SetMatrix(matrix);
        canvas.DrawBitmap(bitmap, 0, 0);
        canvas.Restore();

        // Display the matrix in the lower-right corner
        SKSize matrixSize = matrixDisplay.Measure(matrix);

        matrixDisplay.Paint(canvas, matrix,
            new SKPoint(info.Width - matrixSize.Width,
                        info.Height - matrixSize.Height));

        // Display the touchpoints
        foreach (TouchPoint touchPoint in touchPoints)
        {
            touchPoint.Paint(canvas);
        }
    }
  }

下面的 iOS 屏幕在首次加载页面时显示位图,而另外两个屏幕在一些操作后显示它:

“显示仿射矩阵”页面的三重屏幕截图

虽然看起来好像触摸点拖动了位图的角,但这只是一种幻觉。 根据触摸点计算的矩阵将转换位图,使得角与触摸点重合。

对于用户来说,移动、调整位图大小和旋转位图更为自然,不是通过拖动角点,而是通过直接在对象上使用一两根手指进行拖动、收缩和旋转。 下一篇文章触摸操作对此进行了介绍。

3x3 矩阵的原因

预计二维图形系统只需要 2x2 转换矩阵:

           │ ScaleX  SkewY  │
| x  y | × │                │ = | x'  y' |
           │ SkewX   ScaleY │

这适用于缩放、旋转甚至倾斜,但它不能进行最基本的转换,即平移。

问题在于,2x2 矩阵表示二维中的线性转换。 线性转换会保留一些基本的算术运算,但其中一个含义是线性转换永远不会改变点 (0, 0)。 线性转换使平移变得不可能。

在三维中,线性转换矩阵如下所示:

              │ ScaleX  SkewYX  SkewZX │
| x  y  z | × │ SkewXY  ScaleY  SkewZY │ = | x'  y'  z' |
              │ SkewXZ  SkewYZ  ScaleZ │

标记为 SkewXY 的单元格意味着值基于 Y 的值使 X 坐标倾斜;单元格 SkewXZ 意味着值基于 Z 的值使 X 坐标倾斜;并且值对于其他 Skew 单元格类似地倾斜。

通过将 SkewZXSkewZY 设置为 0 并将 ScaleZ 设置为 1,可以将此三维转换矩阵限制为二维平面:

              │ ScaleX  SkewYX   0 │
| x  y  z | × │ SkewXY  ScaleY   0 │ = | x'  y'  z' |
              │ SkewXZ  SkewYZ   1 │

如果二维图形完全绘制在 Z 等于 1 的三维空间中的平面上,则转换乘法如下所示:

              │ ScaleX  SkewYX   0 │
| x  y  1 | × │ SkewXY  ScaleY   0 │ = | x'  y'  1 |
              │ SkewXZ  SkewYZ   1 │

所有内容都停留在 Z 等于 1 的二维平面上,但 SkewXZSkewYZ 单元格实际上成为二维平移因子。

这就是三维线性转换充当二维非线性转换的方式。 (照此类推,三维图形中的转换是基于 4x4 矩阵的。)

SkiaSharp 中的 SKMatrix 结构定义了第三行的属性:

              │ ScaleX  SkewY   Persp0 │
| x  y  1 | × │ SkewX   ScaleY  Persp1 │ = | x'  y'  z` |
              │ TransX  TransY  Persp2 │

Persp0Persp1 的非零值导致将对象从 Z 等于 1 的二维平面上移动的转换。 当这些对象移回该平面时会发生什么情况,将在关于非仿射转换的文章中进行介绍。