矩阵与变换

前言

在阅读本教程之前,我们推荐你从头到尾阅读并且理解 向量数学 教程,因为本教程需要一点向量的知识。

这个教程介绍的是变换以及我们如何在 Godot 中使用矩阵表示它。这不是完整深入的矩阵指南。变换大多数时候被应用为平移、旋转、缩放,所以我们将会关注如何用矩阵表示这些变换。

虽然这个指南主要关注于 2D,使用 Transform2DVector2,但是 3D 中的工作方式也十分相似。

备注

正如之前的教程中提到的,要记住在 Godot 中,2D 的 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 just calculated.

如果我们想要回到它原来的尺度, 我们可以对每个分量乘以0.5. 这几乎就是缩放一个变换矩阵的全部了.

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

备注

在实际的项目中, 你可以使用 scaled() 方法去执行缩放.

旋转变换矩阵

我们将以与前面相同的方式开始, 在标识矩阵下使用Godot徽标:

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

举个例子,假设我们想顺时针旋转 Godot 标志 90 度。现在,X 轴指向右边,Y 轴向下。如果我们在头脑中旋转这些,我们就会在逻辑上看到,新的 X 轴应该向下,新的 Y 轴应该指向左边。

你可以想象, 你抓住了godot的图标和它的向量, 然后旋转它的中心. 无论你在哪里完成旋转, 向量的方向决定了矩阵是什么.

我们需要在法线坐标中表示“下”和“左”,因此我们将 X 设为 (0, 1),将 Y 设为 (-1, 0)。这些也正是 Vector2.DOWN 和 Vector2.LEFT 的值,当我们这样做时,就会得到旋转对象想要的结果:

../../_images/rotate1.png

如果你很难理解上面的内容, 那就试试这个练习: 剪一个正方形的纸, 在上面画X和Y向量, 把它放在图表纸上, 然后旋转它并记下端点.

要在代码中执行旋转, 我们需要能够以编程方式计算值. 这幅图像显示了从旋转角度计算变换矩阵所需的公式. 如果这部分看起来很复杂, 别担心, 我保证这是你需要知道的最难的事情.

../../_images/rotate2.png

备注

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

备注

有趣的事实:在 Godot 中,不仅 Y 是朝下的,旋转也是顺时针的。这意味着所有的数学和三角函数的行为都与 Y 朝上的 CCW 坐标系相同,因为这些差异“相互抵消”了。你可以认为在这两个坐标系中的旋转都是“从 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 just calculated.

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

备注

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

变换矩阵的基

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

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

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

变换矩阵的平移

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

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

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

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

../../_images/translate.png

还有一个 translated() 方法,它执行的是与直接增加或更改 origin 不同的操作。这个 translated() 方法将让该对象相对于它自己的旋转进行平移。例如,顺时针旋转了 90 度的对象如果用 Vector2.UP 调用了 translated(),那么它就会向右移动。

备注

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

融会贯通

我们将把到目前为止提到的所有内容都应用到一个变换上。接下来,使用 Sprite 节点创建一个简单的项目,并使用 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 just calculated.

剪切变换矩阵(高级)

备注

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

您可能已经注意到,变换的自由度比上述操作的组合要多。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 just 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). 这与我们观察到的图像右上角的位置相吻合.

希望您现在完全了解变换矩阵如何影响对象,以及基向量之间的关系以及对象的“UV”或“内部坐标”如何更改其世界位置。

备注

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

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

变换的实际应用

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

但是, 有时手动计算我们需要的值非常有用. 我们将介绍如何使用 Transform2DTransform 手动计算节点转换.

在变换之间转换位置

在许多情况下, 您可能需要将位置转换为变换中的位置或将其转换为转换外的位置. 例如, 如果您有一个相对于球员的位置并想要查找世界(父级相对)位置, 或者如果您有一个世界位置并想知道它相对于球员的位置.

通过“xform”方法,我们可以找到相对于玩家的向量如果定义在世界空间中的话应该是什么:

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

我们可以使用 "xform_inv" 方法来查找世界空间位置(如果它是相对于玩家定义的):

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

备注

如果您事先知道变换位于 (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 just 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 just calculated.
transform = Transform2D(basis_x, basis_y, origin)

在实际工程中, 我们可以通过 * 运算符将一个变换应用到另一个变换中, 从而找到孩子的世界变换:

# Set up transforms just 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 关于矩阵组成的精彩视频:https://www.bilibili.com/video/BV1ys411472E?p=5

求逆变换矩阵

"affine_inverse" 函数返回一个 "撤销" 前一个转换的转换. 这在某些情况下可能很有用, 但只提供几个示例会更容易.

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

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

通过转换转换位置及其反转会导致相同的位置(与 "xform_inv" 相同):

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

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

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

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

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

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

备注

在 Godot 3.2 的检查器中,Spatial 的“Matrix”部分显示的是转置过的矩阵,横向的是列、纵向的是行。这一点可能会在 Godot 的未来版本中进行更改,使其不那么令人困惑。

备注

不能在Godot 3.2的检查器中直接编辑Node2D的变换矩阵. 在Godot的未来版本中, 这一点可能会有所改变.

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

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

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

对于2D, 我们有一个在变换矩阵和角度之间切换的简单方法(Atan2). 在3D中, 我们不能简单地将旋转表示为一个数字. 有一种叫做欧拉角的东西, 它可以将旋转表示为一组3个数字, 但它们是有限的, 除了微不足道的情况外, 它们并不是很有用.

在 3D 中,我们通常不使用角度,我们要么使用变换的基(在 Godot 中几乎到处都使用),要么使用四元数。Godot 可以使用 Quat 结构表示四元数。我给你的建议是完全忽略它们是如何在幕后工作的,因为它们非常复杂和不直观。

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

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

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

https://eater.net/quaternions