使用 3D 变换

前言

“噢, 它就像2D旋转一样, 只是现在旋转发生在X,Y和Z轴上” . 起初这似乎很简单, 对于简单的游戏, 这种思维方式甚至可能足够了. 不幸的是, 这往往是不正确的. 三维角度通常被称为“欧拉角”。 欧拉角是由数学家莱昂哈德·欧拉在 1700 年代初引入的。 这种代表三维旋转的方式在当时是开创性的, 但在游戏开发中使用时有一些缺点(这毕竟是一个戴着滑稽帽子的家伙想出来的). 本文的主旨是解释其原因, 并概述在编写3D游戏时处理变换的最佳做法.

欧拉角的问题

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

轴顺序

单一 的从角度构建方向的方法. 没有一个标准的数学函数可以将所有角度放在一起并产生实际的3D旋转. 从角度产生方向的唯一方法是以 任意顺序 按角度旋转物体角度. X , 然后 Y , 然后旋转 Z 来完成. 或者, 你可以先以旋转 Y , 然后旋转 Z , 最后旋转 X . 怎样都行, 但根据顺序不同, 对象的最终方向 不一定是相同的 . 事实上, 这意味着有多种方法可以从3个不同的角度构建方向, 具体取决于 旋转的顺序 . 下图是一个万向结(来自维基百科), 它有可视化的旋转轴(以XYZ顺序). 如你所见, 每个轴的方向取决于前一个轴的旋转方向: 你可能想知道这是如何影响你的. 我们来看一个实际的示例: 想象一下, 你正在做一个第一人称控制器(例如FPS游戏). 向左和向右移动鼠标可以控制与地面平行的视角, 同时上下移动可以调整游戏角色上下的视野. Y 轴上应用旋转(在这种情况下为 “up(向上)”, 因为Godot中Y轴指向正上方(“ Y-Up” 方向)), 然后在 X 轴上旋转. X 轴上应用旋转, 然后再在 Y 轴上应用旋转, 则效果会不理想: 旋转顺序 .

插值

使用欧拉角的另一个问题是插值. 设想您想在两个不同的相机或敌人位置(包括旋转)之间转换. 解决这个问题的一个合乎逻辑的方法是从一个位置插值到下一个位置. 人们会期望它看起来像这样: 但是, 在使用角度时, 这并不总是有预期的效果: 相机实际上旋转去了相反的方向! 这可能有几个原因:

  • 2700 的度数与从 270 开始到 360 的度数不同, 即使角度是相同的).
  • 维基百科关于Gimbal Lock 的页面 以了解这个问题的详细解释.

    对欧拉角说不

    不应该 在游戏中使用Godot Spatial 节点的 rotation 属性. 它主要用在编辑器中, 为了与2D引擎一致, 并且用于简单的旋转(通常只有一个轴, 或者, 在有限的情况下, 两个). 你可能会受到诱惑, 但不要使用它. 相反, 有一个更好的方法来解决你的旋转问题.

    变换的介绍

    Transform 数据类型. 每一个 Spatial 节点都包含一个 transform 属性, 如果该父类是一个空间派生类型, 则该属性相对依赖于父类变换. global_transform 属性访问世界坐标变换. Basis (transform.basis子属性), 它由三个 Vector3 向量组成. 这些向量可以通过 transform.basis 属性访问, 并可以通过 transform.basis.x , transform.basis.ytransform.basis.z 直接访问. 每个向量指向它的轴被旋转的方向, 因此它们可以有效地描述节点的总旋转. 比例(只要它三个轴长度是一致的)也可以从轴的长度推断出来. 一个 basis 也可以被解释为一个3x3矩阵并像 transform.basis [x] [y] 这样使用. 默认(未经修改)的basis类似于: GDScript C#
    1. var basis = Basis()# Contains the following default values:basis.x = Vector3(1, 0, 0) # Vector pointing along the X axisbasis.y = Vector3(0, 1, 0) # Vector pointing along the Y axisbasis.z = Vector3(0, 0, 1) # Vector pointing along the Z axis
    1. // 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单位矩阵. X 轴, Y 轴, Z 轴. basi(基) , 变换也有 origin(原点) . 这是一个 Vector3 , 用于指定该变换距离实际原点 (0,0,0) 多远. basisorigin 相结合, 一个 transform(变换) 有效地表示了一个空间中特定的平移, 旋转和缩放. 一种可视化一个变换的方法是在”local space(本地空间)”模式下查看该对象的3D控件. X , Y , 和 Z 轴(分别为红色, 绿色和蓝色), 而控件的中心位于对象的原点. 向量数学 教程.

    操作变换

    当然, 变换并不像角度那样容易控制, 并且有它自己的问题. 可以通过将basis乘以另一个basis(这称为堆积)或使用旋转方法来旋转变换. GDScript C#
    1. # Rotate the transform about the X axistransform.basis = Basis(Vector3(1, 0, 0), PI) * transform.basis# shortenedtransform.basis = transform.basis.rotated(Vector3(1, 0, 0), PI)
    1. // rotate the transform about the X axistransform.basis = new Basis(Vector3.Right, Mathf.Pi) * transform.basis;// shortenedtransform.basis = transform.basis.Rotated(Vector3.Right, Mathf.Pi);
    Spatial中的一种方法简化了这个操作: GDScript C#
    1. # Rotate the transform in X axisrotate(Vector3(1, 0, 0), PI)# shortenedrotate_x(PI)
    1. // Rotate the transform about the X axisRotate(Vector3.Right, Mathf.Pi);// shortenedRotateX(Mathf.Pi);
    这会相对于父节点来旋转节点. 要相对于对象空间旋转(节点自己的变换), 请使用下面的方法: GDScript C#
    1. # Rotate locallyrotate_object_local(Vector3(1, 0, 0), PI)
    1. // Rotate locallyRotateObjectLocal(Vector3.Right, Mathf.Pi);

    精度误差

    1.0 , 并且它们可能不完全相互为 90 度. 如果一个变换每帧旋转一次, 它最终会随着时间的推移开始变形. 这是不可避免的. 正交归一化(orthonormalize) 处理(如果每帧修改一次, 则可能每帧一次): GDScript C#
    1. transform = transform.orthonormalized()
    1. transform = transform.Orthonormalized();
    1.0 的长度并且彼此成 90 度角. 但是, 应用于变换的任何缩放都将丢失. 建议您不要缩放要操纵的节点, 而是缩放其子节点(如MeshInstance). 如果您必须缩放节点, 则在最后重新应用它: GDScript C#
    1. transform = transform.orthonormalized()transform = transform.scaled(scale)
    1. transform = transform.Orthonormalized();transform = transform.Scaled(scale);

    获取信息

    “好吧, 但是我怎么从变换中获得角度?” . 答案又一次是: 没有必要. 你必须尽最大努力停止用角度思考. Z-Z ). GDScript C#
    1. bullet.transform = transformbullet.speed = transform.basis.z * BULLET_SPEED
    1. bullet.Transform = transform;bullet.LinearVelocity = transform.basis.z * BulletSpeed;
    向量数学 教程以获取对点积的解释): GDScript C#
    1. # Get the direction vector from player to enemyvar direction = enemy.transform.origin - player.transform.originif direction.dot(enemy.transform.basis.z) > 0: enemy.im_watching_you(player)
    1. // Get the direction vector from player to enemyVector3 direction = enemy.Transform.origin - player.Transform.origin;if (direction.Dot(enemy.Transform.basis.z) > 0){ enemy.ImWatchingYou(player);}
    向左平移: GDScript C#
    1. # Remember that +X is rightif Input.is_action_pressed("strafe_left"): translate_object_local(-transform.basis.x)
    1. // Remember that +X is rightif (Input.IsActionPressed("strafe_left")){ TranslateObjectLocal(-Transform.basis.x);}
    跳跃: GDScript C#
    1. # Keep in mind Y is up-axisif Input.is_action_just_pressed("jump"): velocity.y = JUMP_SPEEDvelocity = move_and_slide(velocity)
    1. // Keep in mind Y is up-axisif (Input.IsActionJustPressed("jump")) velocity.y = JumpSpeed;velocity = MoveAndSlide(velocity);
    所有常见的行为和逻辑都可以用向量来完成.

    设置信息

    确实希望 变换以特定的顺序进行. 外部 , 并每帧设置他们. 不要尝试获取并重新使用它们, 因为变换是不该这么用的. 环顾四周,FPS风格的示例: GDScript C#
    1. # accumulatorsvar rot_x = 0var rot_y = 0func _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
    1. // accumulatorsprivate 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 }}
    最后的 方向.

    用四元数插值

    用四元数能有效率地完成两个变换之间的插值. 有关四元数如何工作的更多信息可以在互联网上的其他地方找到. 在实际应用中, 了解它们的主要用途是做最短路插值就足够了. 同样, 如果你有两个旋转, 四元数将平滑地使用最近的轴在它们之间进行插值. 将旋转转换为四元数很简单. GDScript C#
    1. # Convert basis to quaternion, keep in mind scale is lostvar 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 backtransform.basis = Basis(c)
    1. // Convert basis to quaternion, keep in mind scale is lostvar 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 backtransform.basis = new Basis(c);
    Quat 类型引用有更多关于数据类型的信息(它还可以做变换累加, 变换点等, 不过这个使用频率较低). 如果你多次对四元数进行插值或应用操作, 请记住它们最终需要被归一化. 否则, 会带来数值精度误差影响. 四元数在处理相机/路径/等东西的移动轨迹时很有用. 插值的结果总会是正确且平滑的.

    变换是你的朋友

    对于大多数初学者来说, 习惯于使用变换可能需要一些时间. 但是, 一旦你习惯了它们, 你会欣赏他们的简单而有力. 线上社区 网站上寻求帮助, 一旦你变得足够自信, 请帮助其他人!