矩阵与变换

前言

在阅读本教程之前,我们建议你彻底阅读并理解《向量数学》教程,因为本教程需要向量知识。

本教程会介绍变换以及如何在 Godot 中使用矩阵来表示变换,并不会深入完整地介绍矩阵与变换。变换在大多数情况下应用于平移、旋转、缩放,我们将会重点讲述如何使用矩阵来表示平移、旋转和缩放。

虽然本教程主要侧重于 2D 的变换,用的是 Transform2DVector2,但是对于 3D 中的变换,其工作方式也与 2D 的十分相似。

备注

正如之前的教程中所提到的,在 Godot 中,要记住 2D 的 Y 轴的正方向是向下的,而学校里教的线性代数的坐标系,其 Y 轴正方向是向上的,这两个 Y 轴的方向是相反的,这一点需要注意。

备注

我们习惯 X 轴用红色表示、Y 轴用绿色表示、Z 轴用蓝色表示,本教程中的颜色都遵循这个惯例,不过我们也在原点向量上使用蓝色表示。

矩阵分量和单位矩阵

单位矩阵代表一个没有平移、没有旋转、没有缩放的变换,现在就让我们看看单位矩阵以及其分量是如何与其视觉表现相联系的吧。

../../_images/identity.png

矩阵有行和列,变换矩阵对行和列有特定的规定。

在上图中,我们可以看到红色的 X 向量由矩阵的第一列数对表示,绿色的 Y 向量则由第二列数对表示,改变这几列数对就会改变这些数对所对应的向量。接下来,我们将会在几个例子中看到如何操作这些数对。

由于我们通常使用列来进行操作,因此不必担心直接操作行可能会带来的问题。不过,你也可以把矩阵的行看作是一组表示在给定的方向上移动的向量。

我们指定 t.x.y 这样的值时,表示这是 X 列向量的 Y 分量,换言之,就是这个矩阵的左下角。类似地,t.x.x 就是左上角、t.y.x 就是右上角、t.y.y 就是右下角。此处的 t 是一个 Transform2D。

缩放变换矩阵

应用缩放变换是最容易理解的操作之一,既然如此,那就让我们开始动手尝试吧!把 Godot logo 放置于我们的向量之下,这样我们就可以直观地看出变换该对象上的应用效果:

../../_images/identity-godot.png

现在,要缩放矩阵,我们唯一需要做的就是将每个矩阵分量乘以我们想要缩放的比例。现在来将这个矩阵缩放两倍,1 × 2 = 2,0 × 2 = 0,于是我们便得到了这个结果:

../../_images/scale.png

要在代码中实现这点,我们可以让缩放倍数去乘上每个列向量:

var t = Transform2D()
# Scale
t.x *= 2
t.y *= 2
transform = t # Change the node's transform to what we calculated.

如果我们想要让该变换缩回到原来的尺度,那么我们可以让每个分向量乘以 0.5(即1/2)。以上便是缩放变换矩阵的所有基本介绍了。

要从已存在的变换矩阵中计算对象的缩放尺度,可以对该矩阵的每个列向量使用 length() 方法。

备注

在实际项目中,你可以使用 scaled() 方法去执行缩放变换操作。

旋转变换矩阵

我们将以与前面相同的方式开始本节内容,先在单位矩阵下方叠加一个 Godot logo 吧:

../../_images/identity-godot.png

举个例子,假设我们想让 Godot logo 顺时针旋转 90 度,而现在 X 轴正方形向右,Y 轴正方向向下。如果我们在脑海中模拟旋转这两个轴,那么我们脑海中就理应会想到:旋转后的 X 轴正方向应该向下,旋转后的 Y 轴正方向应该向左。

你可以这样子想:你用手抓住 Godot 的 logo 和其变换矩阵的列向量,然后绕着logo的中心点旋转这个logo以及这些向量。无论你在哪里完成该旋转,向量的方向都将会决定矩阵最终呈现的模样。

我们需要在标准坐标系中表示“下方向”和“左方向”,故我们将 X 设为 (0, 1),将 Y 设为 (-1, 0)。这些也正是 Vector2.DOWNVector2.LEFT 的值。这样做的话,我们就会得到旋转对象后所想看到的结果:

../../_images/rotate1.png

如果你还是难以理解上面的内容,那就试着做下这个小实践:剪一个正方形的纸,在上面画 X 向量和 Y 向量,然后把它放在图表纸上,旋转这张正方形的纸,并记下其端点。

要在代码中执行旋转,我们需要能以编程的方式计算旋转后的变换值。下图展示了由旋转角度计算旋转变换矩阵所需的公式。如果看下图觉得很复杂,不用担心,这保准是你需要知道的那个最难理解矩阵旋转的地方。

../../_images/rotate2.png

备注

Godot 用弧度(radians)表示所有的旋转,不用角度。完整转一圈是 TAUPI*2 弧度,90 度(四分之一圈)是 TAU/4PI/2 弧度。使用 TAU 通常会让代码更易读。

备注

有趣的事实:在 Godot 中,不仅 Y 是朝下的,连旋转也成了顺时针的,也就是说,Godot 2D 所有的数学和三角函数行为都与 Y 轴朝上的 CCW 坐标系相同,因为这些差异“相互抵消”了。你可以认为在标准平面直角坐标系和 Godot 2D 的直角坐标系这两个坐标系中的旋转都是“从 X 到 Y”。

为了执行 0.5 弧度的旋转(约 28.65 度),我们只需将 0.5 代入上面的公式中,然后计算出实际的数值:

../../_images/rotate3.png

这是在代码中完成的方法(将脚本放在 Node2D 上):

var rot = 0.5 # The rotation to apply.
var t = Transform2D()
t.x.x = cos(rot)
t.y.y = cos(rot)
t.x.y = sin(rot)
t.y.x = -sin(rot)
transform = t # Change the node's transform to what we calculated.

要从现有的变换矩阵中计算对象的旋转,可以使用 atan2(t.x.y, t.x.x),其中 t 是 Transform2D。

备注

在实际项目中,可以使用 rotated() 方法进行旋转。

变换矩阵的基

到目前为止,我们只使用 xy 向量,它们负责表示旋转、缩放和/或剪切(高级,会在文末提及)。X 和 Y 向量合称变换矩阵的(Basis)。“基”和“基向量”都是非常重要的术语。

你可能已经注意到 Transform2D 实际上有三个 Vector2 值:xyorigin。其中 origin 值不是基的一部分,而是变换的一部分,我们需要用它来表示位置。从现在开始,我们将在所有示例中记录原点向量。你可以将原点看作另一列,但把它认为是完全独立的通常更好。

请注意在 3D 中,Godot 有一个单独的 Basis 结构,里面包含矩阵基的三个 Vector3 的值。因为代码可能变得复杂,因此将它们从 Transform3D(由一个 Basis 和一个额外的原点 Vector3 组成)中拆分出来是值得的。

变换矩阵的平移

更改 origin 向量被称为对变换矩阵的平移。平移其实上是“移动”对象的一个技术术语,但它不会涉及任何旋转。

让我们通过一个例子来帮助理解这一点。我们将像上次一样从恒等变换开始,但这次我们将记录原点向量。

../../_images/identity-origin.png

如果希望对象移动到 (1, 2) 的位置,只需将其 origin 向量设置为 (1, 2):

../../_images/translate.png

还有一个 translated_local() 方法,它执行的是与直接增加或更改 origin 不同的操作。这个 translated_local() 方法将让该对象相对于它自己的旋转进行平移。例如,当使用 Vector2.UP 进行 translated_local() 时,顺时针旋转 90 度的对象将向右移动。要相对于全局/父帧平移,请使用 translated()

备注

Godot 的 2D 使用基于像素的坐标,所以在实际项目中,你会想要转换成数百个单位。

融会贯通

我们将把到目前为止提到的所有内容都应用到一个变换上。接下来,创建一个带有 Sprite2D 节点的项目,并使用 Godot 徽标作为纹理资源。

让我们将平移设置为 (350, 150),旋转设为 -0.5 rad,缩放设为 3。我把屏幕截图和重现代码都发出来了,但我鼓励你不看代码来尝试重现屏幕截图!

../../_images/putting-all-together.png
var t = Transform2D()
# Translation
t.origin = Vector2(350, 150)
# Rotation
var rot = -0.5 # The rotation to apply.
t.x.x = cos(rot)
t.y.y = cos(rot)
t.x.y = sin(rot)
t.y.x = -sin(rot)
# Scale
t.x *= 3
t.y *= 3
transform = t # Change the node's transform to what we calculated.

剪切变换矩阵(高级)

备注

如果你只想了解如何使用变换矩阵,请随意跳过本教程的这一节。本节探讨变换矩阵的一个不常用的方面,目的是为了你建立对它们的理解。

Node2D提供了开箱即用的剪切属性。

你可能已经注意到,变换的自由度比上述操作的组合要多。2D 变换矩阵的基在两个 Vector2 值中总共有四个数,而旋转值和缩放的 Vector2 只有三个数字。缺失自由度的高级概念称为剪切(Shearing)。

通常,你将始终拥有彼此垂直的基向量。但是,剪切在某些情况下可能很有用,了解剪切可以帮助你理解变换的工作原理。

为了直观地向你展示它的外观, 让我们在Godot徽标上叠加一个网格:

../../_images/identity-grid.png

此网格上的每个点都是通过将基向量相加而获得的。右下角是 X + Y,而右上角是 X - Y。如果我们更改基向量,整个栅格也会随之移动,因为栅格是由基向量组成的。无论我们对基向量做什么更改,栅格上当前平行的所有直线都将保持平行。

举个例子,让我们将 Y 设置为 (1,1):

../../_images/shear.png
var t = Transform2D()
# Shear by setting Y to (1, 1)
t.y = Vector2.ONE
transform = t # Change the node's transform to what we calculated.

备注

不能在编辑器中设置 Transform2D 的原始值,想要让对象倾斜就必须使用代码。

由于向量不再垂直, 因此对象已被剪切. 栅格的底部中心(相对于自身为(0,1))现在位于世界位置(1,1).

对象内部坐标在纹理中称为UV坐标, 因此我们借用此处的术语. 要从相对位置找到世界位置, 公式为U*X+V*Y, 其中U和V是数字,X和Y是基向量.

栅格的右下角始终位于UV位置(1,1), 位于世界位置(2,1), 该位置是从X*1+Y*1(即(1,0)+(1,1)或(1+1,0+1)或(2,1)计算得出的. 这与我们观察到的图像右下角的位置相吻合.

同样, 栅格的右上角始终位于UV位置(1, -1), 位于世界位置(0, -1), 该位置是从X*1+Y*-1计算得出的,X*1+Y*-1是(1,0)-(1,1)或(1-1,0-1)或(0, -1). 这与我们观察到的图像右上角的位置相吻合.

Hopefully you now fully understand how a transformation matrix affects the object, and the relationship between the basis vectors and how the object's "UV" or "intra-coordinates" have their world position changed.

备注

在Godot中, 所有变换数学运算都是相对于父节点完成的. 当我们提到 "世界位置" 时, 如果节点有父节点, 那么它将相对于节点的父位置.

如果你想要更多的解释,你可以查看 3Blue1Brown 关于线性变换的精彩视频:http://www.bilibili.com/video/BV1ys411472E?p=4

变换的实际应用

在实际项目中,你通常会通过将多个 Node2DNode3D 节点彼此设置为父级来处理变换中的变换。

然而,了解如何手动计算我们需要的值非常有用。我们将介绍如何使用 Transform2DTransform3D 手动计算节点的变换。

在变换之间转换位置

在许多情况下,你可能需要将某个位置转换为变换前或者变换后的位置。例如,如果你有一个相对于玩家的位置并想要查找世界(相对于玩家来说是父级)位置,或者如果你有一个世界位置并想知道它相对于玩家的位置。

我们可以使用 * 运算符,找到相对于玩家的向量在世界空间中的定义:

# World space vector 100 units below the player.
print(transform * Vector2(0, 100))

我们可以以相反的顺序使用 * 运算符,来找到相对于玩家定义的世界空间位置:

# Where is (0, 100) relative to the player?
print(Vector2(0, 100) * transform)

备注

如果你事先知道变换位于 (0, 0) 处,则可以改用“basis_xform”或“basis_xform_inv”方法,这将跳过处理平移的过程。

相对于对象本身移动对象

一种常见的操作,尤其是在 3D 游戏中,是相对于自身移动对象。例如,在第一人称射击游戏中,当你按下 W 键时,你希望角色向前移动(-Z 轴)。

由于基向量是相对于父对象的方向,而原点向量是相对于父对象的位置,我们可以添加多个基向量,以相对于对象自身移动该对象。

此代码会让对象向它自己的右边移动 100 个单位:

transform.origin += transform.x * 100

要在 3D 中移动,需要将“x”替换为“basis.x”。

备注

在实际项目中,你可以在 3D 中使用 translate_object_local 或在2D中使用 move_local_xmove_local_y 来执行该操作。

将变换应用于变换

关于转换, 需要了解的最重要的事情之一是如何将几个转换一起使用. 父节点的变换会影响其所有子节点. 让我们来剖析一个例子.

在此图像中, 子节点的组件名称后面有一个 "2", 以将其与父节点区分开来. 这么多数字可能看起来有点令人不知所措, 但请记住, 每个数字都会显示两次(在箭头旁边和矩阵中), 而且几乎一半的数字都是零.

../../_images/apply.png

这里进行的唯一转换是父节点的比例为(2,1), 子节点的比例为(0.5,0.5), 两个节点都指定了位置.

所有子变换都受父变换的影响。子对象的比例为 (0.5, 0.5),因此你会认为它是 1:1 比例的正方形,确实如此,但仅相对于父对象。子对象的 X 向量最终在世界空间中为 (1, 0),因为它是由父对象的基础向量缩放的。类似地,子节点的 origin 向量被设置为 (1, 1),但由于父节点的基向量,这实际上会在世界空间中移动到 (2, 1)。

要手动计算子变换的世界空间变换, 我们将使用以下代码:

# Set up transforms like in the image, except make positions be 100 times bigger.
var parent = Transform2D(Vector2(2, 0), Vector2(0, 1), Vector2(100, 200))
var child = Transform2D(Vector2(0.5, 0), Vector2(0, 0.5), Vector2(100, 100))

# Calculate the child's world space transform
# origin = (2, 0) * 100 + (0, 1) * 100 + (100, 200)
var origin = parent.x * child.origin.x + parent.y * child.origin.y + parent.origin
# basis_x = (2, 0) * 0.5 + (0, 1) * 0
var basis_x = parent.x * child.x.x + parent.y * child.x.y
# basis_y = (2, 0) * 0 + (0, 1) * 0.5
var basis_y = parent.x * child.y.x + parent.y * child.y.y

# Change the node's transform to what we calculated.
transform = Transform2D(basis_x, basis_y, origin)

在实际项目中,我们可以通过使用 * 运算符,将一个变换应用于另一个变换,来找到子项的世界变换:

# Set up transforms like in the image, except make positions be 100 times bigger.
var parent = Transform2D(Vector2(2, 0), Vector2(0, 1), Vector2(100, 200))
var child = Transform2D(Vector2(0.5, 0), Vector2(0, 0.5), Vector2(100, 100))

# Change the node's transform to what would be the child's world transform.
transform = parent * child

备注

当矩阵相乘时, 顺序很重要!别把它们弄混了.

最后, 应用身份变换始终不起任何作用.

如果你想了解更多信息,可以查看 3Blue1Brown 关于矩阵组成的精彩视频:http://www.bilibili.com/video/BV1ys411472E?p=5

求逆变换矩阵

“affine_inverse”函数返回一个“撤销”前一个变换的变换。这在某些情况下很有用。让我们看几个例子。

将反变换乘以法线变换将撤消所有变换:

var ti = transform.affine_inverse()
var t = ti * transform
# The transform is the identity transform.

通过变换及其逆变换来变换位置将得到相同的位置:

var ti = transform.affine_inverse()
position = transform * position
position = ti * position
# The position is the same as before.

这一切是如何在 3D 模式下工作的?

变换矩阵的一大优点是它们在 2D 和 3D 变换中的工作方式非常相似。上面用于 2D 的所有代码和公式在 3D 中的工作方式相同,但有 3 个例外:增加了第三个轴,每个轴的类型为 Vector3,并且 Godot 将 BasisTransform3D 分开存储,因为数学运算可能很复杂,因此将其分开是有意义的。

与二维相比, 有关平移, 旋转, 缩放和剪切在三维中的工作方式的所有概念都是相同的. 要缩放, 我们取每个分量并将其相乘;要旋转, 我们更改每个基向量指向的位置;要平移, 我们操纵原点;要剪切, 我们将基向量更改为不垂直.

../../_images/3d-identity.png

如果你愿意, 最好尝试一下转换, 以了解它们是如何工作的. Godot 允许你直接从检查器编辑 3D 变换矩阵. 你可以下载此项目, 其中包含彩色线条和立方体, 以帮助在 2D 和 3D 中可视化 Basis 向量和原点: https://github.com/godotengine/godot-demo-projects/tree/master/misc/matrix_transform

备注

Godot 4.0 的检查器中不能直接编辑 Node2D 的变换矩阵。后续 Godot 版本中可能改变这一行为。

如果你想要更多的解释,你可以查看 3Blue1Brown 关于 3D 线性变换的精彩视频:http://www.bilibili.com/video/BV1ys411472E?p=6

表示 3D 中的旋转(高级)

2D 和 3D 变换矩阵之间最大的区别在于你如何在没有基向量的情况下自行表示旋转。

在 2D 中,我们有一种简单的方法 (atan2),可在变换矩阵和角度之间切换。在 3D 中,旋转太复杂,因此无法用一个数字表示。有一种被称为欧拉角的东西,它可以将旋转表示为一组 3 个数字,但是,它们很有限,除了微不足道的情况外,用处不大。

在 3D 中,我们通常不使用角度,我们要么使用变换的基(在 Godot 中几乎到处都使用),要么使用四元数。Godot 可以使用 Quaternion 结构表示四元数。我建议你完全忽略它们在底层的工作原理,因为它们非常复杂且不直观。

然而, 如果你真的想知道它是如何工作的, 这里有一些很棒的参考资料, 你可以按顺序跟随它们:

http://www.bilibili.com/video/BV1fx41187tZ

http://www.bilibili.com/video/BV1SW411y7W1

https://eater.net/quaternions