矩阵与变换

前言

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

这个教程介绍的是变换以及我们如何在 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

要在代码中做到这件事. 我们可以简单地乘上每个向量:

GDScript

C#

  1. var t = Transform2D()
  2. # Scale
  3. t.x *= 2
  4. t.y *= 2
  5. transform = t # Change the node's transform to what we just calculated.
  1. Transform2D t = Transform2D.Identity;
  2. // Scale
  3. t.x *= 2;
  4. t.y *= 2;
  5. Transform = t; // Change the node's transform to what we just calculated.

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

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

注解

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

旋转变换矩阵

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

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

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

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

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

../../_images/rotate1.png

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

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

../../_images/rotate2.png

注解

Godot代表所有的旋转弧度, 而不是角度. 一个完整转弯是 TAU 或 PI*2 弧度,90度的四分之一转弯是 TAU/4 或 PI/2 弧度. 使用 TAU通 常会产生更易读的代码.

注解

有趣的是: 在Godot中, 除了Y是 down向下, 旋转也是顺时针的. 这意味着所有的数学和三角函数的行为都与Y-is-up CCW系统相同, 因为这些差异”cancel out抵消 “了. 你可以认为在这两个系统中的旋转是” 从X到Y”.

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

../../_images/rotate3.png

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

GDScript

C#

  1. var rot = 0.5 # The rotation to apply.
  2. var t = Transform2D()
  3. t.x.x = cos(rot)
  4. t.y.y = cos(rot)
  5. t.x.y = sin(rot)
  6. t.y.x = -sin(rot)
  7. transform = t # Change the node's transform to what we just calculated.
  1. float rot = 0.5f; // The rotation to apply.
  2. Transform2D t = Transform2D.Identity;
  3. t.x.x = t.y.y = Mathf.Cos(rot);
  4. t.x.y = t.y.x = Mathf.Sin(rot);
  5. t.y.x *= -1;
  6. Transform = t; // Change the node's transform to what we just calculated.

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

注解

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

变换矩阵的基

到目前为止, 我们只使用 x 和 y 向量, 它们负责表示旋转, 缩放和/或剪切(高级, 包括在末尾).X和Y向量一起被称为变换矩阵的 basis基础 . 术语 basis基础basis vectors基向量 是很重要的.

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

请注意在3D中,Godot有一个单独的 Basis 三个元素的结构 Vector3 的值, 因为代码可能变得复杂, 因此参考其 Transform (他由一个 Basis 再加一个参考 Vector3 的原点).

转换变换矩阵

更改 origin 原点向量称为变换矩阵, 转换基本上是 “moving移动” 对象的一个技术术语, 但它不包含任何旋转.

让我们通过一个例子来帮助理解这一点. 我们将像上次一样从身份变换开始, 但这次我们将跟踪原点向量.

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

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

../../_images/translate.png

还有一个 translated() 转换的方法, 它执行与直接添加或更改 origin 不同的操作. 这个 translated() 转换方法将转换该对象相对于它自己的旋转. 例如, 顺时针旋转90度的对象在以下情况下将向右移动. translated() 转换方法 使用 Vector2.UP .

注解

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

把它们放在一起

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

让我们将转换设置为(350,150), 旋转为-0.5 rad, 缩放为3. 我已经发布了一个屏幕截图, 以及能复制它的代码, 但我鼓励您尝试不看代码就复制屏幕截图!

../../_images/putting-all-together.png

GDScript

C#

  1. var t = Transform2D()
  2. # Translation
  3. t.origin = Vector2(350, 150)
  4. # Rotation
  5. var rot = -0.5 # The rotation to apply.
  6. t.x.x = cos(rot)
  7. t.y.y = cos(rot)
  8. t.x.y = sin(rot)
  9. t.y.x = -sin(rot)
  10. # Scale
  11. t.x *= 3
  12. t.y *= 3
  13. transform = t # Change the node's transform to what we just calculated.
  1. Transform2D t = Transform2D.Identity;
  2. // Translation
  3. t.origin = new Vector2(350, 150);
  4. // Rotation
  5. float rot = -0.5f; // The rotation to apply.
  6. t.x.x = t.y.y = Mathf.Cos(rot);
  7. t.x.y = t.y.x = Mathf.Sin(rot);
  8. t.y.x *= -1;
  9. // Scale
  10. t.x *= 3;
  11. t.y *= 3;
  12. 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

GDScript

C#

  1. var t = Transform2D()
  2. # Shear by setting Y to (1, 1)
  3. t.y = Vector2.ONE
  4. transform = t # Change the node's transform to what we just calculated.
  1. Transform2D t = Transform2D.Identity;
  2. // Shear by setting Y to (1, 1)
  3. t.y = Vector2.One;
  4. 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” 方法定义的相对于玩家的向量是什么:

GDScript

C#

  1. # World space vector 100 units below the player.
  2. print(transform.xform(Vector2(0, 100)))
  1. // World space vector 100 units below the player.
  2. GD.Print(Transform.Xform(new Vector2(0, 100)));

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

GDScript

C#

  1. # Where is (0, 100) relative to the player?
  2. print(transform.xform_inv(Vector2(0, 100)))
  1. // Where is (0, 100) relative to the player?
  2. GD.Print(Transform.XformInv(new Vector2(0, 100)));

注解

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

相对于对象本身移动对象

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

由于基础向量是相对于父对象的方向, 而原点向量是相对于父对象的位置, 因此我们可以简单地将基础向量的倍数相加, 以相对于对象本身移动对象.

此代码将对象向右移动100个单位:

GDScript

C#

  1. transform.origin += transform.x * 100
  1. Transform2D t = Transform;
  2. t.origin += t.x * 100;
  3. Transform = t;

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

注解

在实际工程中, 您可以使用3D中的 late_object_local 或者2D中的 move_local_x 和 move_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).

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

GDScript

C#

  1. # Set up transforms just like in the image, except make positions be 100 times bigger.
  2. var parent = Transform2D(Vector2(2, 0), Vector2(0, 1), Vector2(100, 200))
  3. var child = Transform2D(Vector2(0.5, 0), Vector2(0, 0.5), Vector2(100, 100))
  4. # Calculate the child's world space transform
  5. # origin = (2, 0) * 100 + (0, 1) * 100 + (100, 200)
  6. var origin = parent.x * child.origin.x + parent.y * child.origin.y + parent.origin
  7. # basis_x = (2, 0) * 0.5 + (0, 1) * 0
  8. var basis_x = parent.x * child.x.x + parent.y * child.x.y
  9. # basis_y = (2, 0) * 0 + (0, 1) * 0.5
  10. var basis_y = parent.x * child.y.x + parent.y * child.y.y
  11. # Change the node's transform to what we just calculated.
  12. transform = Transform2D(basis_x, basis_y, origin)
  1. // Set up transforms just like in the image, except make positions be 100 times bigger.
  2. Transform2D parent = new Transform2D(2, 0, 0, 1, 100, 200);
  3. Transform2D child = new Transform2D(0.5f, 0, 0, 0.5f, 100, 100);
  4. // Calculate the child's world space transform
  5. // origin = (2, 0) * 100 + (0, 1) * 100 + (100, 200)
  6. Vector2 origin = parent.x * child.origin.x + parent.y * child.origin.y + parent.origin;
  7. // basisX = (2, 0) * 0.5 + (0, 1) * 0 = (0.5, 0)
  8. Vector2 basisX = parent.x * child.x.x + parent.y * child.x.y;
  9. // basisY = (2, 0) * 0 + (0, 1) * 0.5 = (0.5, 0)
  10. Vector2 basisY = parent.x * child.y.x + parent.y * child.y.y;
  11. // Change the node's transform to what we just calculated.
  12. Transform = new Transform2D(basisX, basisY, origin);

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

GDScript

C#

  1. # Set up transforms just like in the image, except make positions be 100 times bigger.
  2. var parent = Transform2D(Vector2(2, 0), Vector2(0, 1), Vector2(100, 200))
  3. var child = Transform2D(Vector2(0.5, 0), Vector2(0, 0.5), Vector2(100, 100))
  4. # Change the node's transform to what would be the child's world transform.
  5. transform = parent * child
  1. // Set up transforms just like in the image, except make positions be 100 times bigger.
  2. Transform2D parent = new Transform2D(2, 0, 0, 1, 100, 200);
  3. Transform2D child = new Transform2D(0.5f, 0, 0, 0.5f, 100, 100);
  4. // Change the node's transform to what would be the child's world transform.
  5. Transform = parent * child;

注解

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

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

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

求逆变换矩阵

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

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

GDScript

C#

  1. var ti = transform.affine_inverse()
  2. var t = ti * transform
  3. # The transform is the identity transform.
  1. Transform2D ti = Transform.AffineInverse();
  2. Transform2D t = ti * Transform;
  3. // The transform is the identity transform.

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

GDScript

C#

  1. var ti = transform.affine_inverse()
  2. position = transform.xform(position)
  3. position = ti.xform(position)
  4. # The position is the same as before.
  1. Transform2D ti = Transform.AffineInverse();
  2. Position = Transform.Xform(Position);
  3. Position = ti.Xform(Position);
  4. // 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

注解

在Godo3.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可以使用 Quat 结构表示四元数. 我给你的建议是完全忽略它们是如何在幕后工作的, 因为它们非常复杂和不直观.

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

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

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

https://eater.net/quaternions