使用三维变换

介绍

如果你以前从来没有做过3D游戏,那么在开始的时候使用三维旋转会让人困惑。从2d开始,自然的思维方式是沿着 “哦,就像在二维空间中旋转一样,除了现在旋转发生在x,y和z中。” .

起初这看起来很简单,对于简单的游戏来说,这种思维方式甚至足够了。不幸的是,这常常是错误的。

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

../../_images/transforms_euler.png

欧拉角是由数学家莱昂哈德·欧拉在18世纪初提出的。

../../_images/transforms_euler_himself.png

这种表示三维旋转的方法在当时是开创性的,但在游戏开发中使用它有几个缺点(这是一个戴着滑稽帽子的人所期望的)。本文档的目的是解释原因,并概述在编程3D游戏时处理转换的最佳实践。

欧拉角问题

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

坐标轴顺序

主要原因是没有 独特的 从角度构造方向的方法。没有一个标准的数学函数可以将所有的角度结合在一起并产生实际的三维旋转。从角度产生方向的唯一方法是在 任意顺序 .

这可以通过首先旋转来完成。 X 然后 Y 然后在 Z . 或者,您可以先向内旋转 Y ,然后在 Z 最后在 X . 任何东西都可以,但根据顺序,对象的最终方向将 不一定是一样的 . 实际上,这意味着有几种方法可以从3个不同的角度构造方向,这取决于 旋转的顺序 .

下面是旋转轴(X,Y,Z顺序)在框架(从维基百科)中的可视化。如您所见,每个轴的方向取决于前一个轴的旋转:

../../_images/transforms_gimbal.gif

你可能想知道这对你有什么影响。让我们来看一个实际的例子:

假设您正在使用第一人称控制器(例如FPS游戏)。左右移动鼠标可以控制与地面平行的视角,同时上下移动鼠标可以上下移动玩家的视角。

在这种情况下,为了达到所需的效果,必须首先在 Y 轴(在本例中为“向上”,因为Godot使用“Y向上”方向),然后在 X 轴。

../../_images/transforms_rotate1.gif

如果我们在 X 先是轴线,然后是轴线 Y ,效果是不希望的:

../../_images/transforms_rotate2.gif

根据所需的游戏类型或效果,应用轴旋转的顺序可能不同。因此,在x、y和z中应用旋转是不够的:您还需要 旋转顺序 .

插值

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

../../_images/transforms_interpolate1.gif

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

../../_images/transforms_interpolate2.gif

摄像机实际上是反方向旋转的!

可能发生这种情况的原因有几个:

  • 旋转不会线性映射到方向,因此插值并不总是产生最短路径(即从 2700 学位和毕业不一样 270360 ,即使角度是相等的)。

  • 万向节锁处于工作状态(第一个和最后一个旋转轴对齐,因此自由度丢失)。见 Wikipedia's page on Gimbal Lock 对于这个问题的详细解释。

拒绝欧拉角

所有这些的结果是你应该 不使用 这个 rotation 性质 空间 游戏的godot节点。它主要在编辑器中使用,用于与二维引擎的一致性,以及简单的旋转(通常只有一个轴,在有限的情况下甚至只有两个轴)。尽管你很有诱惑力,但不要使用它。

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

介绍转换

Godot使用 变换 方向的数据类型。各 空间 节点包含 transform 如果父级是空间派生类型,则为与父级转换相关的属性。

也可以通过 global_transform 财产。

转换具有 基础 (transform.basis子属性),由三个 矢量3 向量。可通过 transform.basis 属性,可以直接访问 transform.basis.xtransform.basis.ytransform.basis.z . 每个向量指向其轴旋转方向,因此它们有效地描述了节点的总旋转。尺度(只要它是均匀的)也可以从轴的长度推断出来。一 基础 也可以解释为3x3矩阵,并用作 transform.basis[x][y] .

默认基础(未修改)类似于:

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对吗? 轴, YUp 轴和 Z福沃德 轴。

基础 ,转换还具有 起源 . 这是一个 矢量3 指定距离实际原点的距离 (0, 0, 0) 这个转换是。组合 基础起源 ,A 转型 有效地表示空间中唯一的平移、旋转和缩放。

../../_images/transforms_camera.png

可视化转换的一种方法是在“局部空间”模式下查看对象的三维小控件。

../../_images/transforms_local_space.png

小控件的箭头显示 XYZ 坐标轴(分别为红色、绿色和蓝色),而gizmo的中心位于对象的原点。

../../_images/transforms_gizmo.png

有关向量和变换数学的更多信息,请阅读 矢量数学 教程。

操纵变换

当然,变换并不像角度那样容易操作,也有自己的问题。

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

# 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);

空间方法简化了这一点:

# 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 彼此的度数。但是,应用于转换的任何比例都将丢失。

建议不要缩放将要操作的节点;而是缩放其子节点(如mesheinstance)。如果必须缩放节点,请在结尾处重新应用:

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;

敌人在看玩家吗?为此使用DOT产品(请参见 矢量数学 关于点积解释的教程):

# 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);

这个 夸脱 类型引用具有更多关于数据类型的信息(它还可以进行转换累积、转换点等,尽管使用频率较低)。如果多次对四元数进行插值或应用操作,请记住,它们最终需要进行归一化,否则它们也可能受到数值精度错误的影响。

四元数在进行相机/路径等插值时很有用,因为结果总是正确和平滑的。

变形金刚是你的朋友

对于大多数初学者来说,熟悉转换需要一些时间。然而,一旦你习惯了他们,你会欣赏他们的简单和力量。

不要犹豫,在任何一个Godot的问题上寻求帮助。 online communities 一旦你变得足够自信,请帮助他人!