非仿射转换

使用变换矩阵的第三列创建透视和锥形效果

平移、缩放、旋转和倾斜都属于仿射变换。 仿射变换保留平行线。 如果两条线在变换之前平行,则它们在变换之后仍保持平行。 矩形始终变换为平行四边形。

但是,SkiaSharp 还能够进行非仿射变换,可将矩形变换为任何凸四边形:

位图转换为凸四边形

凸四边形是内角始终小于 180 度且边不互相交叉的四边形。

当变换矩阵的第三行设置为 0、0 和 1 以外的值时,会产生非仿射变换。 完整的 SKMatrix 乘法为:

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

最终的变换公式为:

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

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

z` = Persp0·x + Persp1·y + Persp2

使用 3×3 矩阵进行二维变换的基本规则是所有内容都保留在 Z 等于 1 的平面上。 除非 Persp0Persp1 为 0,且 Persp2 等于 1,否则变换会将 Z 坐标移离该平面。

若要将其还原为二维变换,必须将坐标移回该平面。 还需要执行另一个步骤。 x'、y' 和 z` 值必须除以 z':

x" = x' / z'

y" = y' / z'

z" = z' / z' = 1

这些称为齐次坐标,由数学家 August Ferdinand Möbius 提出,Möbius 因他的拓扑奇观“Möbius 带”而知名

如果 z' 为 0,则除法结果为无限坐标。 事实上,Möbius 开发齐次坐标的动机之一是能够用有限的数字表示无限值。

但是,在显示图形时,建议你避免渲染其坐标会变换为无限值的内容。 这些坐标不会渲染。 这些坐标附近的所有内容将非常大,并且可能在视觉上不连贯。

在此等式中,你不希望 z' 的值变为零:

z` = Persp0·x + Persp1·y + Persp2

因此,这些值存在一些实际限制:

Persp2 单元格可为零,也可以不为零。 如果 Persp2 为零,则点 (0, 0) 的 z' 为零,这通常是不可取的,因为该点在二维图形中非常常见。 如果 Persp2 不等于 0,则将 Persp2 固定为 1 也不会丢失泛性。 例如,如果确定 Persp2 应为 5,则你只需将矩阵中的所有单元格除以 5,这使得 Persp2 等于 1,而结果是相同的。

由于这些原因,Persp2 通常固定为 1,这与标识矩阵中的值相同。

通常,Persp0Persp1 是小数字。 例如,假设从标识矩阵开始,但将 Persp0 设置为 0.01:

| 1  0   0.01 |
| 0  1    0   |
| 0  0    1   |

变换公式为:

x` = x / (0.01·x + 1)

y' = y / (0.01·x + 1)

现在使用此变换来渲染位于原点处的 100 像素方框。 下面是四个角的变换方式:

(0, 0) → (0, 0)

(0, 100) → (0, 100)

(100, 0) → (50, 0)

(100, 100) → (50, 50)

当 x 为 100 时,z' 分母为 2,因此 x 和 y 坐标实际上会减半。 框的右侧变得比左侧短:

经过非仿射转换的方格

这些单元格名称的 Persp 部分指的是“透视”,因为透视缩短表明该框现在倾斜,右侧距离观察者更远。

在“测试透视”页中,可以试验 Persp0Pers1 的值,以了解它们的工作原理。 这些矩阵单元格的合理值非常小,以至于通用 Windows 平台中的 Slider 无法正确处理它们。 为了解决 UWP 问题,需要将 TestPerspective.xaml 中的两个 Slider 元素初始化为 –1 到 1 的范围:

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:skia="clr-namespace:SkiaSharp.Views.Forms;assembly=SkiaSharp.Views.Forms"
             x:Class="SkiaSharpFormsDemos.Transforms.TestPerspectivePage"
             Title="Test Perpsective">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>

        <Grid.Resources>
            <ResourceDictionary>
                <Style TargetType="Label">
                    <Setter Property="HorizontalTextAlignment" Value="Center" />
                </Style>

                <Style TargetType="Slider">
                    <Setter Property="Minimum" Value="-1" />
                    <Setter Property="Maximum" Value="1" />
                    <Setter Property="Margin" Value="20, 0" />
                </Style>
            </ResourceDictionary>
        </Grid.Resources>

        <Slider x:Name="persp0Slider"
                Grid.Row="0"
                ValueChanged="OnPersp0SliderValueChanged" />

        <Label x:Name="persp0Label"
               Text="Persp0 = 0.0000"
               Grid.Row="1" />

        <Slider x:Name="persp1Slider"
                Grid.Row="2"
                ValueChanged="OnPersp1SliderValueChanged" />

        <Label x:Name="persp1Label"
               Text="Persp1 = 0.0000"
               Grid.Row="3" />

        <skia:SKCanvasView x:Name="canvasView"
                           Grid.Row="4"
                           PaintSurface="OnCanvasViewPaintSurface" />
    </Grid>
</ContentPage>

TestPerspectivePage 代码隐藏文件中滑块的事件处理程序将值除以 100,使其范围介于 –0.01 和 0.01 之间。 此外,构造函数加载位图:

public partial class TestPerspectivePage : ContentPage
{
    SKBitmap bitmap;

    public TestPerspectivePage()
    {
        InitializeComponent();

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

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

    void OnPersp0SliderValueChanged(object sender, ValueChangedEventArgs args)
    {
        Slider slider = (Slider)sender;
        persp0Label.Text = String.Format("Persp0 = {0:F4}", slider.Value / 100);
        canvasView.InvalidateSurface();
    }

    void OnPersp1SliderValueChanged(object sender, ValueChangedEventArgs args)
    {
        Slider slider = (Slider)sender;
        persp1Label.Text = String.Format("Persp1 = {0:F4}", slider.Value / 100);
        canvasView.InvalidateSurface();
    }
    ...
}

PaintSurface 处理程序根据这两个滑块的值除以 100 来计算名为 perspectiveMatrixSKMatrix 值。 它与两个平移变换相结合,将变换的中心置于位图的中心:

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

        canvas.Clear();

        // Calculate perspective matrix
        SKMatrix perspectiveMatrix = SKMatrix.MakeIdentity();
        perspectiveMatrix.Persp0 = (float)persp0Slider.Value / 100;
        perspectiveMatrix.Persp1 = (float)persp1Slider.Value / 100;

        // Center of screen
        float xCenter = info.Width / 2;
        float yCenter = info.Height / 2;

        SKMatrix matrix = SKMatrix.MakeTranslation(-xCenter, -yCenter);
        SKMatrix.PostConcat(ref matrix, perspectiveMatrix);
        SKMatrix.PostConcat(ref matrix, SKMatrix.MakeTranslation(xCenter, yCenter));

        // Coordinates to center bitmap on canvas
        float x = xCenter - bitmap.Width / 2;
        float y = yCenter - bitmap.Height / 2;

        canvas.SetMatrix(matrix);
        canvas.DrawBitmap(bitmap, x, y);
    }
}

下面是一些示例图像:

“测试透视”页的三屏幕截图

在试验滑块时,你会发现超过 0.0066 或低于 –0.0066 的值会导致图像突然变得破碎且不连贯。 正在变换的位图是 300 像素的正方形。 它相对于其中心进行变换,因此位图的坐标范围为 –150 到 150。 回想一下,z' 的值为:

z` = Persp0·x + Persp1·y + 1

如果 Persp0Persp1 大于 0.0066 或小于 –0.0066,则位图的某个坐标始终会导致 z' 值为零。 这会导致除以零,使渲染内容变得一团糟。 使用非仿射变换时,建议避免渲染其坐标会导致除以零的任何内容。

通常,你不会单独设置 Persp0Persp1。 通常还需要在矩阵中设置其他单元以实现某些类型的非仿射变换。

此类非仿射变换之一是锥形变换。 这种类型的非仿射变换保留了矩形的整体大小,但一侧逐渐变细:

经过锥形转换的方格

TaperTransform 类基于以下参数执行非仿射变换的广义计算:

  • 正在变换的图像的矩形大小;
  • 一个指示渐缩矩形边的枚举;
  • 另一个指示它如何逐渐变细的枚举,以及
  • 逐渐变细的程度。

下面是 代码:

enum TaperSide { Left, Top, Right, Bottom }

enum TaperCorner { LeftOrTop, RightOrBottom, Both }

static class TaperTransform
{
    public static SKMatrix Make(SKSize size, TaperSide taperSide, TaperCorner taperCorner, float taperFraction)
    {
        SKMatrix matrix = SKMatrix.MakeIdentity();

        switch (taperSide)
        {
            case TaperSide.Left:
                matrix.ScaleX = taperFraction;
                matrix.ScaleY = taperFraction;
                matrix.Persp0 = (taperFraction - 1) / size.Width;

                switch (taperCorner)
                {
                    case TaperCorner.RightOrBottom:
                        break;

                    case TaperCorner.LeftOrTop:
                        matrix.SkewY = size.Height * matrix.Persp0;
                        matrix.TransY = size.Height * (1 - taperFraction);
                        break;

                    case TaperCorner.Both:
                        matrix.SkewY = (size.Height / 2) * matrix.Persp0;
                        matrix.TransY = size.Height * (1 - taperFraction) / 2;
                        break;
                }
                break;

            case TaperSide.Top:
                matrix.ScaleX = taperFraction;
                matrix.ScaleY = taperFraction;
                matrix.Persp1 = (taperFraction - 1) / size.Height;

                switch (taperCorner)
                {
                    case TaperCorner.RightOrBottom:
                        break;

                    case TaperCorner.LeftOrTop:
                        matrix.SkewX = size.Width * matrix.Persp1;
                        matrix.TransX = size.Width * (1 - taperFraction);
                        break;

                    case TaperCorner.Both:
                        matrix.SkewX = (size.Width / 2) * matrix.Persp1;
                        matrix.TransX = size.Width * (1 - taperFraction) / 2;
                        break;
                }
                break;

            case TaperSide.Right:
                matrix.ScaleX = 1 / taperFraction;
                matrix.Persp0 = (1 - taperFraction) / (size.Width * taperFraction);

                switch (taperCorner)
                {
                    case TaperCorner.RightOrBottom:
                        break;

                    case TaperCorner.LeftOrTop:
                        matrix.SkewY = size.Height * matrix.Persp0;
                        break;

                    case TaperCorner.Both:
                        matrix.SkewY = (size.Height / 2) * matrix.Persp0;
                        break;
                }
                break;

            case TaperSide.Bottom:
                matrix.ScaleY = 1 / taperFraction;
                matrix.Persp1 = (1 - taperFraction) / (size.Height * taperFraction);

                switch (taperCorner)
                {
                    case TaperCorner.RightOrBottom:
                        break;

                    case TaperCorner.LeftOrTop:
                        matrix.SkewX = size.Width * matrix.Persp1;
                        break;

                    case TaperCorner.Both:
                        matrix.SkewX = (size.Width / 2) * matrix.Persp1;
                        break;
                }
                break;
        }
        return matrix;
    }
}

此类在“锥形变换”页中使用。 XAML 文件实例化两个 Picker 元素来选择枚举值,并实例化一个 Slider 来选择锥度分数。 PaintSurface 处理程序将锥形变换与两个平移变换相结合,使变换相对于位图的左上角:

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

    canvas.Clear();

    TaperSide taperSide = (TaperSide)taperSidePicker.SelectedItem;
    TaperCorner taperCorner = (TaperCorner)taperCornerPicker.SelectedItem;
    float taperFraction = (float)taperFractionSlider.Value;

    SKMatrix taperMatrix =
        TaperTransform.Make(new SKSize(bitmap.Width, bitmap.Height),
                            taperSide, taperCorner, taperFraction);

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

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

    // Center bitmap on canvas
    float x = (info.Width - bitmap.Width) / 2;
    float y = (info.Height - bitmap.Height) / 2;

    SKMatrix matrix = SKMatrix.MakeTranslation(-x, -y);
    SKMatrix.PostConcat(ref matrix, taperMatrix);
    SKMatrix.PostConcat(ref matrix, SKMatrix.MakeTranslation(x, y));

    canvas.SetMatrix(matrix);
    canvas.DrawBitmap(bitmap, x, y);
}

以下是一些示例:

“锥形转换”页的三屏幕截图

另一种广义非仿射变换是 3D 旋转,下一篇文章 3D 旋转将对此进行演示。

非仿射变换可以将矩形变换为任意凸四边形。 “显示非仿射矩阵”页对此进行了演示。 它与矩阵变换一文中的“显示仿射矩阵”页非常相似,不同之处在于它有第四个 TouchPoint 对象来操控位图的第四个角

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

只要你不尝试使位图一个角的内角大于 180 度,或使两条边相交,程序就会使用 ShowNonAffineMatrixPage 类中的此方法成功计算变换:

static SKMatrix ComputeMatrix(SKSize size, SKPoint ptUL, SKPoint ptUR, SKPoint ptLL, SKPoint ptLR)
{
    // 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
    };

    // Non-Affine transform
    SKMatrix inverseA;
    A.TryInvert(out inverseA);
    SKPoint abPoint = inverseA.MapPoint(ptLR);
    float a = abPoint.X;
    float b = abPoint.Y;

    float scaleX = a / (a + b - 1);
    float scaleY = b / (a + b - 1);

    SKMatrix N = new SKMatrix
    {
        ScaleX = scaleX,
        ScaleY = scaleY,
        Persp0 = scaleX - 1,
        Persp1 = scaleY - 1,
        Persp2 = 1
    };

    // Multiply S * N * A
    SKMatrix result = SKMatrix.MakeIdentity();
    SKMatrix.PostConcat(ref result, S);
    SKMatrix.PostConcat(ref result, N);
    SKMatrix.PostConcat(ref result, A);

    return result;
}

为了便于计算,此方法将总变换获取为三个单独变换的乘积,此处用箭头进行符号化,显示这些变换如何修改位图的四个角:

(0, 0) → (0, 0) → (0, 0) → (x0, y0)(左上)

(0, H) → (0, 1) → (0, 1) → (x1, y1)(左下)

(W, 0) → (1, 0) → (1, 0) → (x2, y2)(右上)

(W, H) → (1, 1) → (a, b) → (x3, y3)(右下)

右侧的最终坐标是与四个触摸点关联的四个点。 这些是位图角的最终坐标。

W 和 H 表示位图的宽度和高度。 第一个变换 S 只是将位图缩放为 1 像素的正方形。 第二个变换是非仿射变换 N,第三个变换是仿射变换 A。 该仿射变换基于三个点,因此它就像早期的仿射 ComputeMatrix 方法一样,并且不涉及包含 (a, b) 点的第四行。

计算 ab 值,使第三个变换成为仿射变换。 该代码获取仿射变换的逆变换,然后使用它来映射右下角。 该位置就是点 (a, b)。

非仿射变换的另一个用途是模仿三维图形。 下一篇文章 3D 旋转将介绍如何在 3D 空间中旋转二维图形。