SkiaSharp 中的 3D 旋转
使用非仿射变换在 3D 空间中旋转 2D 对象。
非仿射变换的一个常见应用是在 3D 空间中模拟 2D 对象的旋转:
此作业涉及到使用三维旋转,然后派生执行这些 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
从变换公式中可以明显看出,单元格 M11
、M22
、M33
是 X、Y 和 Z 方向的缩放因子,M41
、M42
和 M43
是 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
具有通用的静态 CreateRotation
和 CreateRotationDegrees
方法,可用于指定围绕哪个轴进行旋转:
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
定义了串联方法。 如果 A
和 B
是 SKMatrix44
对象,则以下代码将 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 空间中对文本字符串进行动画处理。SKMatrix44
在 textPaint
构造函数中,使用设为字段的对象来确定文本的边界:
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
对象,以不同的速率对 xRotationDegrees
、yRotationDegrees
和 zRotationDegrees
字段进行动画处理。 请注意,这些动画的周期被设置为质数(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 变换包围,以将旋转的中心移动到屏幕的中心,并缩放文本字符串的大小,使其与屏幕同宽: