使用3D变换

简介

如果您以前从未制作过3D游戏,那么首先在三维环境中进行旋转可能会让人感到困惑。 从2D开始,自然的思维方式就是沿着“噢,它就像2D旋转一样,而现在旋转发生在X,Y和Z轴上”*

起初这似乎很简单,对于简单的游戏,这种思维方式甚至可能足够了。 不幸的是,这往往是不正确的。

三维角度通常被称为``欧拉角``。

../../_images/transforms_euler.png

欧拉角是由数学家莱昂哈德·欧拉在1700年代初引入的。

../../_images/transforms_euler_himself.png

这种代表三维旋转的方式在当时是开创性的,但在游戏开发中使用时有一些缺点(这可以从一个戴着滑稽帽子的家伙那里得到)。 本文的主旨是解释其原因,并概述在编写3D游戏时处理变换的最佳做法。

欧拉角问题

虽然看起来很直观,每个轴都有一个旋转,但事实是它并不实用。

轴顺序

这样做的主要原因是没有一种独特的方法可以从角度构建方向。 没有一个标准的数学函数可以将所有角度放在一起并产生实际的3D旋转。 从角度产生方向的唯一方法是以 任意顺序 按角度旋转物体角度。

这可以通过先旋转* X ,然后 Y 然后旋转 Z 来完成。 或者,您可以先以 Y 旋转,然后以 Z 旋转,最后以 X 旋转。 怎样都行,但根据顺序,对象的最终方向 *不一定是相同的 。 事实上,这意味着有多种方法可以从3个不同的角度构建方向,具体取决于 旋转的顺序

以下(来自维基百科)是万向节旋转轴(X,Y,Z顺序)的可视化。 如您所见,每个轴的方向取决于前一个轴的旋转方向:

../../_images/transforms_gimbal.gif

您可能想知道这是如何影响您的。 我们来看一个实际的示例:

想象一下,您正在研究第一人称控制器(FPS游戏)。 向左和向右移动鼠标可以控制与地面平行的视角,同时上下移动可以调整游戏角色上下的视野。

在这种情况下,为了达到所需的效果,必须首先在* Y 轴上应用旋转(在这种情况下为``上``,因为Godot使用``Y-Up``方向),然后在 X *轴上旋转。

../../_images/transforms_rotate1.gif

如果我们首先在* X 轴上应用旋转,然后再在 Y *中应用旋转,则效果会不理想:

../../_images/transforms_rotate2.gif

根据所需的游戏类型或效果,您想要应用轴旋转的顺序可能会有所不同。 因此,在X,Y和Z中应用旋转是不够的:您还需要 旋转顺序

插值

使用欧拉角的另一个问题是插值。 设想您想在两个不同的相机或敌人位置(包括旋转)之间转换。 解决这个问题的一个合乎逻辑的方法是从一个位置插入到下一个位置。 人们会期望它看起来像这样:

../../_images/transforms_interpolate1.gif

但是,在使用角度时,这并不总是有预期的效果:

../../_images/transforms_interpolate2.gif

相机实际上旋转了相反的方向!

这可能有几个原因:

  • 旋转不会线性映射到方向,因此插入它们并不总是会形成最短路径(即从``270``到``0``的度数与从``270``开始到``360``的度数不同,即使角度是相同的)。
  • 万向节锁正在发挥作用(第一个和最后一个旋转的轴对齐,因此失去了一定的自由度)。 请参阅 维基百科关于Gimbal Lock的页面 以了解这个问题的详细解释。

对欧拉角说不

所有这些的结论是,您不应该在游戏中使用Godot的 rotation 属性 class_Spatial 节点。 它主要用在编辑器中,为了与2D引擎一致,并且用于简单的旋转(通常只有一个轴,或者在有限的情况下使用两个)。 您可能会受到诱惑, 但不要使用它。

相反,有一个更好的方法来解决您的旋转问题。

变换的介绍

Godot对方向使用 class_Transform 数据类型。 每一个 class_Spatial 节点都包含一个 transform 属性,如果该父类是一个空间派生类型,则该属性相对依赖于父类变换。

也可以通过 global_transform 属性访问世界坐标变换。

一个变换有一个 class_Basis (transform.basis子属性),它由三个 class_Vector3 向量组成。 这些向量可以通过 transform.basis 属性访问,并可以通过 transform.basis.xtransform.basis.ytransform.basis.z 直接访问。 每个向量指向它的轴被旋转的方向,因此它们可以有效地描述节点的总旋转。 比例(只要它是一致的)也可以从轴的长度推断出来。 一个 basis 也可以被解释为一个3x3矩阵并被用作 transform.basis [x] [y]

默认的basis(未修改)类似于:

var basis = Basis()
# Contains the following default values:
basis.x = Vector3(1, 0, 0) # Vector pointing along the X axis
basis.y = Vector3(0, 1, 0) # Vector pointing along the Y axis
basis.z = Vector3(0, 0, 1) # Vector pointing along the Z axis
// Due to technical limitations on structs in C# the default
// constructor will contain zero values for all fields.
var defaultBasis = new Basis();
GD.Print(defaultBasis); // prints: ((0, 0, 0), (0, 0, 0), (0, 0, 0))

// Instead we can use the Identity property.
var identityBasis = Basis.Identity;
GD.Print(identityBasis.x); // prints: (1, 0, 0)
GD.Print(identityBasis.y); // prints: (0, 1, 0)
GD.Print(identityBasis.z); // prints: (0, 0, 1)

// The Identity basis is equivalent to:
var basis = new Basis(Vector3.Right, Vector3.Up, Vector3.Back);
GD.Print(basis); // prints: ((1, 0, 0), (0, 1, 0), (0, 0, 1))

这也是一个3x3单位矩阵的类似物。

遵循OpenGL惯例,``X``是* 右 轴,``Y``是轴,``Z``是*轴。

一起,变换也有 原点 。 这是一个* 3维向量 *,用于指定该变换距离实际原点“(0,0,0)”多远。 *基*与*原点*相结合,一个 *变换*有效地表示了一个空间中特定的平移,旋转和缩放。

../../_images/transforms_camera.png

一种查看一个变换的方法是在``本地空间``模式下查看该对象的3D Gizmo。

../../_images/transforms_local_space.png

Gizmo的箭头显示基准的“X”,“Y”和“Z”轴(分别为红色,绿色和蓝色),而Gizmo的中心位于对象的原点。

../../_images/transforms_gizmo.png

有关向量和变换在数学方面的更多信息,请阅读 :ref:`doc_vector_math`教程。

操作变换

当然,变换并不像角度那样有那么简单的操作,并且有它自己的问题。

可以通过将其的基乘以另一个(这称为堆积)或使用旋转方法来旋转变换。

# Rotate the transform about the X axis
transform.basis = Basis(Vector3(1, 0, 0), PI) * transform.basis
# shortened
transform.basis = transform.basis.rotated(Vector3(1, 0, 0), PI)
// rotate the transform about the X axis
transform.basis = new Basis(Vector3.Right, Mathf.Pi) * transform.basis;
// shortened
transform.basis = transform.basis.Rotated(Vector3.Right, Mathf.Pi);

Spatial中的一种方法简化了这一点:

# Rotate the transform in X axis
rotate(Vector3(1, 0, 0), PI)
# shortened
rotate_x(PI)
// Rotate the transform about the X axis
Rotate(Vector3.Right, Mathf.Pi);
// shortened
RotateX(Mathf.Pi);

这会相对于父节点来旋转节点。

要相对于对象空间旋转(节点自己的变换),请使用以下内容:

# Rotate locally
rotate_object_local(Vector3(1, 0, 0), PI)
// Rotate locally
RotateObjectLocal(Vector3.Right, Mathf.Pi);

精度误差

对变换执行连续的操作将导致由于浮点错误导致的精度损失。 这意味着每个轴的比例可能不再精确地为``1.0``,并且它们可能不完全相互为``90``度。

如果一个变换每帧旋转一次,它最终会随着时间的推移开始变形。 这是不可避免的。

有两种不同的方法来处理这个问题。 首先是在一段时间后对变换进行正交归一化处理(如果每帧修改一次,则可能每帧一次):

transform = transform.orthonormalized()
transform = transform.Orthonormalized();

这将使所有的轴再次拥有有``1.0``的长度并且彼此成90度角。 但是,应用于变换的任何缩放都将丢失。

建议您不要缩放要操纵的节点,而是缩放其子节点(如MeshInstance)。 如果您必须缩放节点,则在最后重新应用它:

transform = transform.orthonormalized()
transform = transform.scaled(scale)
transform = transform.Orthonormalized();
transform = transform.Scaled(scale);

获取信息

您可能在这一点上想:** ``好吧,但是我怎么从变换中获得角度?``**。 答案又一次是:您没有必要。 您必须尽最大努力停止在角度方向的思考。

想象一下,您需要朝您的游戏角色面对的方向射击子弹。 只需使用前进轴(通常为 Z-Z )。

bullet.transform = transform
bullet.speed = transform.basis.z * BULLET_SPEED
bullet.Transform = transform;
bullet.LinearVelocity = transform.basis.z * BulletSpeed;

敌人在看着游戏角色吗? 为此判断您可以使用点积(请参阅 向量数学 教程以获取对点积的解释):

# Get the direction vector from player to enemy
var direction = enemy.transform.origin - player.transform.origin
if direction.dot(enemy.transform.basis.z) > 0:
    enemy.im_watching_you(player)
// Get the direction vector from player to enemy
Vector3 direction = enemy.Transform.origin - player.Transform.origin;
if (direction.Dot(enemy.Transform.basis.z) > 0)
{
    enemy.ImWatchingYou(player);
}

向左平移:

# Remember that +X is right
if Input.is_action_pressed("strafe_left"):
    translate_object_local(-transform.basis.x)
// Remember that +X is right
if (Input.IsActionPressed("strafe_left"))
{
    TranslateObjectLocal(-Transform.basis.x);
}

跳跃:

# Keep in mind Y is up-axis
if Input.is_action_just_pressed("jump"):
    velocity.y = JUMP_SPEED

velocity = move_and_slide(velocity)
// Keep in mind Y is up-axis
if (Input.IsActionJustPressed("jump"))
    velocity.y = JumpSpeed;

velocity = MoveAndSlide(velocity);

所有常见的行为和逻辑都可以用向量来完成。

设置信息

当然,有些情况下您想要将一些信息赋予到变换上。 想象一下第一人称控制器或轨道摄像机。 那些肯定是用角度来完成的,因为您希望变换以特定的顺序进行。

对于这种情况,请保持变换 的角度和旋转,并将其设置为每帧。 不要尝试恢复并重新使用它们,因为变换没有以这种方式使用的意思。

环顾四周,FPS风格的示例:

# accumulators
var rot_x = 0
var rot_y = 0

func _input(event):
    if event is InputEventMouseMotion and event.button_mask & 1:
        # modify accumulated mouse rotation
        rot_x += event.relative.x * LOOKAROUND_SPEED
        rot_y += event.relative.y * LOOKAROUND_SPEED
        transform.basis = Basis() # reset rotation
        rotate_object_local(Vector3(0, 1, 0), rot_x) # first rotate in Y
        rotate_object_local(Vector3(1, 0, 0), rot_y) # then rotate in X
// accumulators
private float _rotationX = 0f;
private float _rotationY = 0f;

public override void _Input(InputEvent @event)
{
    if (@event is InputEventMouseMotion mouseMotion)
    {
        // modify accumulated mouse rotation
        _rotationX += mouseMotion.Relative.x * LookAroundSpeed;
        _rotationY += mouseMotion.Relative.y * LookAroundSpeed;

        // reset rotation
        Transform transform = Transform;
        transform.basis = Basis.Identity;
        Transform = transform;

        RotateObjectLocal(Vector3.Up, _rotationX); // first rotate about Y
        RotateObjectLocal(Vector3.Right, _rotationY); // then rotate about X
    }
}

如您所见,在这种情况下,保持外部旋转更为简单,然后使用变换作为* 最后的 *方向。

四元数插值

用四元数能有效地完成两个变换之间的插值。 有关四元数如何工作的更多信息可以在互联网上的其他地方找到。 在实际应用中,了解它们的主要用途是做最近的路径插值就足够了。 同样,如果您有两次旋转,四元数将平滑地使用最近的轴在它们之间进行插值。

将旋转转换为四元数很简单。

# Convert basis to quaternion, keep in mind scale is lost
var a = Quat(transform.basis)
var b = Quat(transform2.basis)
# Interpolate using spherical-linear interpolation (SLERP).
var c = a.slerp(b,0.5) # find halfway point between a and b
# Apply back
transform.basis = Basis(c)
// Convert basis to quaternion, keep in mind scale is lost
var a = transform.basis.Quat();
var b = transform2.basis.Quat();
// Interpolate using spherical-linear interpolation (SLERP).
var c = a.Slerp(b, 0.5f); // find halfway point between a and b
// Apply back
transform.basis = new Basis(c);

class_Quat 类型的引用有更多关于数据类型的信息(它也可以做变换累加,变换点等等,虽然这个用法较少)。 如果多次对四元数进行插值或应用运算,请记住它们需要最终归一化,否则它们可能会产生数值精度错误。

四元数在做相机/路径/等东西时很有用。 插值的结果总会是正确且平滑的。

变换是您的朋友

对于大多数初学者来说,习惯于使用变换可能需要一些时间。 但是,一旦您习惯了它们,您会欣赏他们的简单而有力。

不要犹豫,在Godot的任何``在线社区``网站上寻求帮助,一旦您变得足够自信,请帮助其他人!