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
方法的 tx
和 ty
参数将成为转换矩阵第三行的前两个单元格:
| 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
方法时,生成的转换矩阵包含对角单元格中的 sx
和 sy
参数:
| 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
若要对某个 α 角度调用 RotateDegrees
或 RotateRadians
,转换矩阵如下所示:
│ 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[]
的属性。 此属性可用于按 ScaleX
、SkewX
、TransX
、SkewY
、ScaleY
、TransY
、Persp0
、Persp1
和 Persp2
的顺序一次设置或获取九个值。
非仿射转换一文中讨论了 Persp0
、Persp1
和 Persp2
单元格。 如果这些单元格的默认值为 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
值:
MakeTranslation
MakeScale
MakeScale
(具有透视点)MakeRotation
(以弧度表示)MakeRotation
(具有透视点,以弧度表示角度)MakeRotationDegrees
MakeRotationDegrees
(具有透视点)MakeSkew
SKMatrix
还定义了多个连接两个矩阵的静态方法,这意味着它们相乘。 这些方法命名为 Concat
、PostConcat
和 PreConcat
,每个方法有两个版本。 这些方法没有返回值;而是通过 ref
参数引用现有 SKMatrix
值。 在以下示例中,A
、B
和 R
(对于“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
构造函数与静态 Rotate
和 RotateDegrees
方法配合使用:
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
值之外,它们在功能上与 MakeRotation
和 MakeRotationDegrees
方法相同。
假设你有一个要显示的 SKPath
对象,但希望它具有不同的方向或不同的中心点。 可以通过使用 SKMatrix
自变量调用 SKPath
的 Transform
方法来修改该路径的所有坐标。 “路径转换”页面演示了如何执行此操作。 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
要求 SKCanvasView
或 SKCanvasView
的父元素附加 TouchEffect
。 将 Capture
属性设置为 true
。 在 TouchAction
事件处理程序中,程序必须为每个 TouchPoint
实例调用 TouchPoint
中的 ProcessTouchEvent
方法。 如果触摸事件导致触摸点移动,该方法将返回 true
。 此外,PaintSurface
处理程序必须为每个 TouchPoint
实例调用 Paint
方法,并将其传递给 SKCanvas
对象。
TouchPoint
演示了可以将 SkiaSharp 视觉对象封装到单独的类中的常见方法。 类可以定义用于指定视觉对象的特征的属性,具有 SKCanvas
自变量、名为 Paint
的方法可以呈现它。
TouchPoint
的 Center
属性指示对象的位置。 可以将此属性设置为初始化位置;当用户在画布周围拖动圆圈时,属性会更改。
“显示仿射矩阵”页也需要 MatrixDisplay
类。 此类显示 SKMatrix
对象的单元格。 它具有两个公共方法:Measure
用于获取呈现矩阵的维度,Paint
用于显示它。 该类包含类型为 SKPaint
的 MatrixPaint
属性,该属性可以替换为不同的字体大小或颜色。
ShowAffineMatrixPage.xaml 文件实例化 SKCanvasView
并附加 TouchEffect
。 ShowAffineMatrixPage.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
处理程序调用每个 TouchPoint
的 ProcessTouchEvent
方法。 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
单元格类似地倾斜。
通过将 SkewZX
和 SkewZY
设置为 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 的二维平面上,但 SkewXZ
和 SkewYZ
单元格实际上成为二维平移因子。
这就是三维线性转换充当二维非线性转换的方式。 (照此类推,三维图形中的转换是基于 4x4 矩阵的。)
SkiaSharp 中的 SKMatrix
结构定义了第三行的属性:
│ ScaleX SkewY Persp0 │ | x y 1 | × │ SkewX ScaleY Persp1 │ = | x' y' z` | │ TransX TransY Persp2 │
Persp0
和 Persp1
的非零值导致将对象从 Z 等于 1 的二维平面上移动的转换。 当这些对象移回该平面时会发生什么情况,将在关于非仿射转换的文章中进行介绍。