SkiaSharp 中的 3D 旋转

使用非仿射变换在 3D 空间中旋转 2D 对象。

非仿射变换的一个常见应用是在 3D 空间中模拟 2D 对象的旋转:

在 3D 空间中旋转的文本字符串

此作业涉及到使用三维旋转,然后派生执行这些 3D 旋转的非仿射 SKMatrix 变换。

很难开发这种只在二维空间内工作的 SKMatrix 变换。 如果这种 3x3 矩阵是从 3D 图形中使用的 4x4 矩阵中派生的,那么作业会变得简单得多。 SkiaSharp 提供 SKMatrix44 类来用于此目的,但要理解 3D 旋转和 4x4 变换矩阵,具备 3D 图形的一些背景知识是必要的。

三维坐标系添加了第三个轴 Z。从概念上讲,Z 轴与屏幕成直角。 3D 空间中的坐标点用三个数字表示:(x, y, z)。 在本文中使用的 3D 坐标系中,就像在二维空间中一样,X 轴的增加值向右,Y 轴的增加值向下。 Z 轴的增加正值指向屏幕外。 与在 2D 图形中一样,原点在左上角。 可以将屏幕想象成一个 XY 平面,Z 轴与此平面成直角。

这称为左手坐标系。 如果将左手食指指向 X 坐标正值的方向(向右),中指指向 Y 轴增加值的方向(向下),那么你的拇指就指向 Z 轴增加值的方向(向屏幕外)。

在 3D 图形中,变换基于 4x4 矩阵。 下面是 4x4 单位矩阵:

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

使用 4x4 矩阵时,使用行号和列号识别单元格很方便:

|  M11  M12  M13  M14  |
|  M21  M22  M23  M24  |
|  M31  M32  M33  M34  |
|  M41  M42  M43  M44  |

但是,SkiaSharp 的 Matrix44 类略有不同。 要设置或获取 SKMatrix44 中的单个单元格值,只能使用 Item 索引器。 行和列索引是从零开始的,而不是从 1 开始的,并且行和列是交换的。 在 SKMatrix44 对象中使用 [3, 0] 索引器访问上图中的单元格 M14。

在 3D 图形系统中,一个 3D 点 (x, y, z) 被转换为 1x4 矩阵,乘以 4x4 变换矩阵:

                 |  M11  M12  M13  M14  |
| x  y  z  1 | × |  M21  M22  M23  M24  | = | x'  y'  z'  w' |
                 |  M31  M32  M33  M34  |
                 |  M41  M42  M43  M44  |

与三维空间中发生的 2D 变换类似,假定 3D 变换发生在四维空间中。 第四个维度称为 W,假设 3D 空间存在于 4D 空间中,其中 W 坐标为 1。 变换公式如下所示:

x' = M11·x + M21·y + M31·z + M41

y' = M12·x + M22·y + M32·z + M42

z' = M13·x + M23·y + M33·z + M43

w' = M14·x + M24·y + M34·z + M44

从变换公式中可以明显看出,单元格 M11M22M33 是 X、Y 和 Z 方向的缩放因子,M41M42M43 是 X、Y 和 Z 方向的转换因子。

若要将这些坐标转换回 W 为 1 的 3D 空间,x'、y' 和 z' 坐标全部除以 w':

x" = x' / w'

y" = y' / w'

z" = z' / w'

w" = w' / w' = 1

除以 w' 可在 3D 空间中提供透视。 如果 w' 为 1,则没有透视。

3D 空间中的旋转可能相当复杂,但最简单的旋转是围绕 X、Y 和 Z 轴的旋转。 围绕 X 轴旋转 α 度得到以下矩阵:

|  1     0       0     0  |
|  0   cos(α)  sin(α)  0  |
|  0  –sin(α)  cos(α)  0  |
|  0     0       0     1  |

受此变换约束时,X 的值保持不变。 如果围绕 Y 轴旋转,则 Y 的值保持不变:

|  cos(α)  0  –sin(α)  0  |
|    0     1     0     0  |
|  sin(α)  0   cos(α)  0  |
|    0     0     0     1  |

围绕 Z 轴旋转与 2D 图形中的旋转相同:

|  cos(α)  sin(α)  0  0  |
| –sin(α)  cos(α)  0  0  |
|    0       0     1  0  |
|    0       0     0  1  |

旋转方向由坐标系的旋向性来决定。 这是一个左手坐标系,因此如果你的左手拇指指向特定轴的增加值 - 围绕 X 轴旋转则指向右,围绕 Y 轴旋转则指向下,围绕 Z 轴旋转则指向你,那么你其他手指弯曲方向指示旋转正角度的方向。

SKMatrix44 具有通用的静态 CreateRotationCreateRotationDegrees 方法,可用于指定围绕哪个轴进行旋转:

public static SKMatrix44 CreateRotationDegrees (Single x, Single y, Single z, Single degrees)

若要围绕 X 轴旋转,请将前三个参数设置为 1、0 和 0。 若要围绕 Y 轴旋转,请将它们设置为 0、1、0。若要围绕 Z 轴旋转,请将其设置为 0、0、1。

4x4 矩阵的第四列是关于透视的。 SKMatrix44 没有用于创建透视变换的方法,但你可使用以下代码自行创建一个透视变换:

SKMatrix44 perspectiveMatrix = SKMatrix44.CreateIdentity();
perspectiveMatrix[3, 2] = -1 / depth;

参数名称为 depth 的原因很快就会清楚。 该代码会创建以下矩阵:

|  1  0  0      0     |
|  0  1  0      0     |
|  0  0  1  -1/depth  |
|  0  0  0      1     |

变换公式将得到 w' 的以下计算结果:

w' = –z / depth + 1

当 X 轴的值小于零(从概念上讲,就是位于 XY 平面的背后),这会减少 X 和 Y 坐标;如果 Z 轴的值为正时,这会增加 X 和 Y 坐标。当 Z 坐标等于 depth 时,w' 为零,坐标变为无穷大。 三维图形系统是围绕相机隐喻构建的,这里的 depth 值表示相机与坐标系原点的距离。 如果图形对象的 Z 坐标距离原点 depth 个单位,那么该对象在概念上接触到相机镜头并变得无穷大。

请记住,你可能会将此值 perspectiveMatrix 与旋转矩阵结合使用。 如果要旋转的图形对象的 X 或 Y 坐标大于 depth,那么此对象在 3D 空间中的旋转可能涉及到大于 depth 的 Z 坐标。 必须避免这种情况! 创建 perspectiveMatrix 时,无论图形对象如何旋转,你都希望将 depth 设置为对该对象中的所有坐标来说都足够大的值。 这可确保永远不会除以零。

将 3D 旋转和透视相结合需要将 4x4 矩阵相乘。 为此,SKMatrix44 定义了串联方法。 如果 ABSKMatrix44 对象,则以下代码将 A 设置为 A × B:

A.PostConcat(B);

在 2D 图形系统中使用 4x4 变换矩阵时,该矩阵将应用于 2D 对象。 这些对象是平面的,假定 Z 坐标为零。 与前面所示的变换相比,变换相乘要简单一点:

                 |  M11  M12  M13  M14  |
| x  y  0  1 | × |  M21  M22  M23  M24  | = | x'  y'  z'  w' |
                 |  M31  M32  M33  M34  |
                 |  M41  M42  M43  M44  |

如果 z 的值为零,变换公式不会涉及矩阵第三行中的任何单元格:

x' = M11·x + M21·y + M41

y' = M12·x + M22·y + M42

z' = M13·x + M23·y + M43

w' = M14·x + M24·y + M44

此外,z' 的坐标在这里也无关紧要。 当 3D 对象显示在 2D 图形系统中时,会忽略 Z 坐标值,将该对象折叠到二维对象。 变换公式实际上只是下面两个:

x" = x' / w'

y" = y' / w'

这意味着可以忽略 4x4 矩阵的第三行和第三列。

但是如果是这样的话,为什么 4x4 矩阵最初是必要的呢?

尽管 4x4 矩阵的第三行和第三列对于二维变换来说是不相关的,但当各个 SKMatrix44 值相乘时,第三行和列会在此之前发挥作用。 例如,将围绕 Y 轴的旋转乘以透视变换:

|  cos(α)  0  –sin(α)  0  |   |  1  0  0      0     |   |  cos(α)  0  –sin(α)   sin(α)/depth  |
|    0     1     0     0  | × |  0  1  0      0     | = |    0     1     0           0        |
|  sin(α)  0   cos(α)  0  |   |  0  0  1  -1/depth  |   |  sin(α)  0   cos(α)  -cos(α)/depth  |  
|    0     0     0     1  |   |  0  0  0      1     |   |    0     0     0           1        |

在乘积中,单元格 M14 现在包含透视值。 如果要将该矩阵应用于 2D 对象,则会消除第三行和第三列,将其转换为一个 3x3 矩阵:

|  cos(α)  0  sin(α)/depth  |
|    0     1       0        |
|    0     0       1        |

现在,它可用于变换 2D 点:

                |  cos(α)  0  sin(α)/depth  |
|  x  y  1  | × |    0     1       0        | = |  x'  y'  z'  |
                |    0     0       1        |

变换公式为:

x' = cos(α)·x

y' = y

z' = (sin(α)/depth)·x + 1

现在,将所有项都除以 z':

x" = cos(α)·x / ((sin(α)/depth)·x + 1)

y" = y / ((sin(α)/depth)·x + 1)

当 2D 对象围绕 Y 轴旋转正角度时,X 轴正值会方向向里,X 轴负值方向向外。 X 值移动得似乎更靠近 Y 轴(这是由余弦值控制的),因为离 Y 轴最远的坐标会随着远离观察者而变小,随着靠近观察者而变大。

使用 SKMatrix44 时,通过将各个 SKMatrix44 值相乘来执行所有 3D 旋转和透视操作。 然后,可以使用 SKMatrix44 类的 Matrix 属性从 4x4 矩阵中提取二维 3x3 矩阵。 此属性返回熟悉的 SKMatrix 的值。

使用“旋转 3D”页可以尝试 3D 旋转。 Rotation3DPage.xaml 文件实例化 4 个滑块来设置围绕 X、Y 和 Z 轴的旋转并设置一个深度值:

<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.Rotation3DPage"
             Title="Rotation 3D">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <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="Margin" Value="20, 0" />
                    <Setter Property="Maximum" Value="360" />
                </Style>
            </ResourceDictionary>
        </Grid.Resources>

        <Slider x:Name="xRotateSlider"
                Grid.Row="0"
                ValueChanged="OnSliderValueChanged" />

        <Label Text="{Binding Source={x:Reference xRotateSlider},
                              Path=Value,
                              StringFormat='X-Axis Rotation = {0:F0}'}"
               Grid.Row="1" />

        <Slider x:Name="yRotateSlider"
                Grid.Row="2"
                ValueChanged="OnSliderValueChanged" />

        <Label Text="{Binding Source={x:Reference yRotateSlider},
                              Path=Value,
                              StringFormat='Y-Axis Rotation = {0:F0}'}"
               Grid.Row="3" />

        <Slider x:Name="zRotateSlider"
                Grid.Row="4"
                ValueChanged="OnSliderValueChanged" />

        <Label Text="{Binding Source={x:Reference zRotateSlider},
                              Path=Value,
                              StringFormat='Z-Axis Rotation = {0:F0}'}"
               Grid.Row="5" />

        <Slider x:Name="depthSlider"
                Grid.Row="6"
                Maximum="2500"
                Minimum="250"
                ValueChanged="OnSliderValueChanged" />

        <Label Grid.Row="7"
               Text="{Binding Source={x:Reference depthSlider},
                              Path=Value,
                              StringFormat='Depth = {0:F0}'}" />

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

请注意,在 Minimum 值为 250 的情况下初始化 depthSlider。 这意味着,这里旋转的 2D 对象的 X 和 Y 坐标被限制在围绕原点的 250 像素半径定义的圆内。 3D 空间中此对象的任何旋转总是会导致坐标值小于 250。

Rotation3DPage.cs 代码隐藏文件在 300 像素正方形的位图中加载:

public partial class Rotation3DPage : ContentPage
{
    SKBitmap bitmap;

    public Rotation3DPage()
    {
        InitializeComponent();

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

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

    void OnSliderValueChanged(object sender, ValueChangedEventArgs args)
    {
        if (canvasView != null)
        {
            canvasView.InvalidateSurface();
        }
    }
    ...
}

如果 3D 变换以这个位图为中心,那么 X 和 Y 坐标的范围在 –150 和 150 之间,而角距中心 212 个像素,所以所有点都在 250 像素半径内。

PaintSurface 处理程序会根据滑块创建 SKMatrix44 对象,并使用 PostConcat 将它们相乘。 从最终的 SKMatrix 对象中提取的 SKMatrix44 值被转换变换包围,在屏幕中心居中旋转:

public partial class Rotation3DPage : ContentPage
{
    SKBitmap bitmap;

    public Rotation3DPage()
    {
        InitializeComponent();

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

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

    void OnSliderValueChanged(object sender, ValueChangedEventArgs args)
    {
        if (canvasView != null)
        {
            canvasView.InvalidateSurface();
        }
    }

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

        canvas.Clear();

        // Find center of canvas
        float xCenter = info.Width / 2;
        float yCenter = info.Height / 2;

        // Translate center to origin
        SKMatrix matrix = SKMatrix.MakeTranslation(-xCenter, -yCenter);

        // Use 3D matrix for 3D rotations and perspective
        SKMatrix44 matrix44 = SKMatrix44.CreateIdentity();
        matrix44.PostConcat(SKMatrix44.CreateRotationDegrees(1, 0, 0, (float)xRotateSlider.Value));
        matrix44.PostConcat(SKMatrix44.CreateRotationDegrees(0, 1, 0, (float)yRotateSlider.Value));
        matrix44.PostConcat(SKMatrix44.CreateRotationDegrees(0, 0, 1, (float)zRotateSlider.Value));

        SKMatrix44 perspectiveMatrix = SKMatrix44.CreateIdentity();
        perspectiveMatrix[3, 2] = -1 / (float)depthSlider.Value;
        matrix44.PostConcat(perspectiveMatrix);

        // Concatenate with 2D matrix
        SKMatrix.PostConcat(ref matrix, matrix44.Matrix);

        // Translate back to center
        SKMatrix.PostConcat(ref matrix,
            SKMatrix.MakeTranslation(xCenter, yCenter));

        // Set the matrix and display the bitmap
        canvas.SetMatrix(matrix);
        float xBitmap = xCenter - bitmap.Width / 2;
        float yBitmap = yCenter - bitmap.Height / 2;
        canvas.DrawBitmap(bitmap, xBitmap, yBitmap);
    }
}

当你尝试第四个滑块时,你将注意到,如果深度设置不同,对象离观察者的距离不会更远,而是会改变透视效果的范围:

旋转 3D 页面的屏幕截图(包含三部分)

动画旋转 3D 也使用 在 3D 空间中对文本字符串进行动画处理。SKMatrix44textPaint 构造函数中,使用设为字段的对象来确定文本的边界:

public class AnimatedRotation3DPage : ContentPage
{
    SKCanvasView canvasView;
    float xRotationDegrees, yRotationDegrees, zRotationDegrees;
    string text = "SkiaSharp";
    SKPaint textPaint = new SKPaint
    {
        Style = SKPaintStyle.Stroke,
        Color = SKColors.Black,
        TextSize = 100,
        StrokeWidth = 3,
    };
    SKRect textBounds;

    public AnimatedRotation3DPage()
    {
        Title = "Animated Rotation 3D";

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

        // Measure the text
        textPaint.MeasureText(text, ref textBounds);
    }
    ...
}

OnAppearing 替代会定义三个 Xamarin.FormsAnimation 对象,以不同的速率对 xRotationDegreesyRotationDegreeszRotationDegrees 字段进行动画处理。 请注意,这些动画的周期被设置为质数(5 秒、7 秒和11 秒),因此整体组合只会每 385 秒重复一次,或者 10 分钟以上重复一次:

public class AnimatedRotation3DPage : ContentPage
{
    ...
    protected override void OnAppearing()
    {
        base.OnAppearing();

        new Animation((value) => xRotationDegrees = 360 * (float)value).
            Commit(this, "xRotationAnimation", length: 5000, repeat: () => true);

        new Animation((value) => yRotationDegrees = 360 * (float)value).
            Commit(this, "yRotationAnimation", length: 7000, repeat: () => true);

        new Animation((value) =>
        {
            zRotationDegrees = 360 * (float)value;
            canvasView.InvalidateSurface();
        }).Commit(this, "zRotationAnimation", length: 11000, repeat: () => true);
    }

    protected override void OnDisappearing()
    {
        base.OnDisappearing();
        this.AbortAnimation("xRotationAnimation");
        this.AbortAnimation("yRotationAnimation");
        this.AbortAnimation("zRotationAnimation");
    }
    ...
}

如上一个程序一样,PaintCanvas 处理程序会为旋转和透视创建 SKMatrix44 值,并将它们相乘:

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

        canvas.Clear();

        // Find center of canvas
        float xCenter = info.Width / 2;
        float yCenter = info.Height / 2;

        // Translate center to origin
        SKMatrix matrix = SKMatrix.MakeTranslation(-xCenter, -yCenter);

        // Scale so text fits
        float scale = Math.Min(info.Width / textBounds.Width,
                               info.Height / textBounds.Height);
        SKMatrix.PostConcat(ref matrix, SKMatrix.MakeScale(scale, scale));

        // Calculate composite 3D transforms
        float depth = 0.75f * scale * textBounds.Width;

        SKMatrix44 matrix44 = SKMatrix44.CreateIdentity();
        matrix44.PostConcat(SKMatrix44.CreateRotationDegrees(1, 0, 0, xRotationDegrees));
        matrix44.PostConcat(SKMatrix44.CreateRotationDegrees(0, 1, 0, yRotationDegrees));
        matrix44.PostConcat(SKMatrix44.CreateRotationDegrees(0, 0, 1, zRotationDegrees));

        SKMatrix44 perspectiveMatrix = SKMatrix44.CreateIdentity();
        perspectiveMatrix[3, 2] = -1 / depth;
        matrix44.PostConcat(perspectiveMatrix);

        // Concatenate with 2D matrix
        SKMatrix.PostConcat(ref matrix, matrix44.Matrix);

        // Translate back to center
        SKMatrix.PostConcat(ref matrix,
            SKMatrix.MakeTranslation(xCenter, yCenter));

        // Set the matrix and display the text
        canvas.SetMatrix(matrix);
        float xText = xCenter - textBounds.MidX;
        float yText = yCenter - textBounds.MidY;
        canvas.DrawText(text, xText, yText, textPaint);
    }
}

这个 3D 旋转被几个 2D 变换包围,以将旋转的中心移动到屏幕的中心,并缩放文本字符串的大小,使其与屏幕同宽:

动画旋转 3D 页面的屏幕截图(包含三部分)