Attention: Here be dragons

This is the latest (unstable) version of this documentation, which may document features not available in or compatible with released stable versions of Godot.

3D 변형 사용하기

소개

이전에 3D 게임을 만든 적이 없다면 3차원 회전 작업이 처음에는 혼란스러울 수 있습니다. 2D에서 나온 자연스러운 사고 방식은 *"아, 이제 X, Y, Z에서 회전이 발생한다는 점을 제외하면 2D에서 회전하는 것과 같습니다"*라는 노선을 따릅니다.

처음에는 이것이 쉬워 보인다. 간단한 게임의 경우 이러한 사고방식이면 충분할 수도 있습니다. 불행하게도 이는 잘못된 경우가 많습니다.

3차원 각도는 가장 일반적으로 "오일러 각도"라고 합니다.

../../_images/transforms_euler.webp

오일러 각도는 1700년대 초 수학자 레온하르트 오일러(Leonhard Euler)에 의해 소개되었습니다.

../../_images/transforms_euler_himself.png

3D 회전을 표현하는 이러한 방식은 당시로서는 획기적인 것이었지만 게임 개발에 사용하면 몇 가지 단점이 있습니다(웃긴 모자를 쓴 사람에게서는 예상되는 현상입니다). 이 문서의 목적은 그 이유를 설명하고 3D 게임을 프로그래밍할 때 변환을 처리하기 위한 모범 사례를 간략하게 설명하는 것입니다.

오일러 각의 문제

각 축에 회전이 있다는 것이 직관적으로 보일 수도 있지만 실제로는 실용적이지 않습니다.

축 순서

그 주된 이유는 각도에서 방향을 구성하는 고유한 방법이 없기 때문입니다. 모든 각도를 모아 실제 3D 회전을 생성하는 표준 수학 함수는 없습니다. 각도에서 방향을 생성할 수 있는 유일한 방법은 *임의의 순서*로 각도별로 개체 각도를 회전하는 것입니다.

먼저 X*로 회전한 다음 *Y*로 회전한 다음 *Z*로 회전하면 됩니다. 또는 먼저 *Y*에서 회전한 다음 *Z*에서 회전하고 마지막으로 *X*에서 회전할 수도 있습니다. 무엇이든 작동하지만 순서에 따라 개체의 최종 방향이 *반드시 동일하지는 않습니다. 실제로 이는 *회전 순서*에 따라 3가지 다른 각도에서 방향을 구성하는 여러 가지 방법이 있음을 의미합니다.

다음은 짐벌의 회전축(X, Y, Z 순서)을 시각화한 것입니다(Wikipedia 참조). 보시다시피, 각 축의 방향은 이전 축의 회전에 따라 달라집니다.

../../_images/transforms_gimbal.gif

이것이 당신에게 어떤 영향을 미치는지 궁금할 것입니다. 실제적인 예를 살펴보겠습니다:

1인칭 컨트롤러(예: 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

카메라가 실제로 반대 방향으로 회전했습니다!

이런 일이 발생할 수 있는 몇 가지 이유는 다음과 같습니다.

  • 회전은 방향에 선형적으로 매핑되지 않으므로 보간해도 항상 최단 경로가 되는 것은 아닙니다. 즉, 270``에서 ``0 각도로 이동하는 것은 각도가 동일하더라도 ``270``에서 ``360``로 이동하는 것과 동일하지 않습니다.

  • 짐벌 잠금이 작동 중입니다(첫 번째와 마지막 회전 축이 정렬되므로 자유도가 손실됨). 이 문제에 대한 자세한 설명은 `Gimbal Lock <https://en.wikipedia.org/wiki/Gimbal_lock>`_에 대한 Wikipedia 페이지를 참조하세요.

오일러 각도를 거부하세요

이 모든 결과는 게임용 Godot에서 Node3D 노드의 rotation 속성을 사용하지 말아야 한다는 것입니다. 2D 엔진과의 일관성 및 간단한 회전(일반적으로 하나의 축 또는 제한된 경우 두 개의 축)을 위해 주로 편집기에서 사용됩니다. 유혹을 받을 수 있으니 사용하지 마세요.

대신 회전 문제를 해결하는 더 좋은 방법이 있습니다.

변환 소개

Godot는 방향을 위해 Transform3D 데이터 유형을 사용합니다. 각 Node3D 노드에는 상위가 Node3D 파생 유형인 경우 상위의 변환과 관련된 transform 속성이 포함되어 있습니다.

global_transform 속성을 통해 세계 좌표 변환에 액세스하는 것도 가능합니다.

변환에는 세 개의 Vector3 벡터로 구성된 class_Basis`(transform.basis 하위 속성)가 있습니다. 이는 ``transform.basis` 속성을 통해 액세스되며 transform.basis.x, transform.basis.y``transform.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

이것은 또한 3x3 단위 행렬과 유사합니다.

OpenGL 규칙에 따르면 ``X``는 오른쪽 축이고, ``Y``는 위쪽 축이고, ``Z``는 축입니다.

변환에는 *기초*와 함께 *원점*도 있습니다. 이는 이 변환이 실제 원점 ``(0, 0, 0)``에서 얼마나 멀리 떨어져 있는지를 지정하는 *Vector3*입니다. *기초*와 *원점*을 결합한 *변환*은 공간에서의 고유한 변환, 회전 및 크기 조정을 효율적으로 나타냅니다.

../../_images/transforms_camera.png

변환을 시각화하는 한 가지 방법은 "로컬 공간" 모드에서 개체의 3D 기즈모를 보는 것입니다.

../../_images/transforms_local_space.png

기즈모의 화살표는 기본의 X, YZ 축(각각 빨간색, 녹색 및 파란색)을 표시하고 기즈모의 중심은 개체의 원점에 있습니다.

../../_images/transforms_gizmo.png

벡터 및 변환 수학에 대한 자세한 내용은 벡터 수학 튜토리얼을 읽어보세요.

변환 조작

물론 변환은 각도만큼 조작하기가 쉽지 않고 그 자체로 문제가 있습니다.

변환의 기준을 다른 기준으로 곱하거나(이를 누적이라고 함) 회전 방법을 사용하여 변환을 회전할 수 있습니다.

var axis = Vector3(1, 0, 0) # Or Vector3.RIGHT
var rotation_amount = 0.1
# Rotate the transform around the X axis by 0.1 radians.
transform.basis = Basis(axis, rotation_amount) * transform.basis
# shortened
transform.basis = transform.basis.rotated(axis, rotation_amount)

또한 종종 다음과 같이 간략화됩니다:

# Rotate the transform around the X axis by 0.1 radians.
rotate(Vector3(1, 0, 0), 0.1)
# shortened
rotate_x(0.1)

그러면 상위 노드를 기준으로 노드가 회전합니다.

객체 공간을 기준으로 회전하려면(노드 자체 변환) 다음을 사용하십시오.

# Rotate around the object's local X axis by 0.1 radians.
rotate_object_local(Vector3(1, 0, 0), 0.1)

축은 객체의 로컬 좌표계에서 정의되어야 합니다. 예를 들어 개체의 로컬 X, Y 또는 Z축을 중심으로 회전하려면 X축에 Vector3.RIGHT, Y축에 Vector3.UP, Z축에 ``Vector3.FORWARD``를 사용합니다.

정밀도 오류

변환에 대해 연속적인 작업을 수행하면 부동 소수점 오류로 인해 정밀도가 손실됩니다. 이는 각 축의 스케일이 더 이상 정확히 ``1.0``가 아닐 수 있고 서로 정확히 ``90``도가 아닐 수 있음을 의미합니다.

변환이 프레임마다 회전되면 결국 시간이 지남에 따라 변형되기 시작합니다. 이것은 불가피합니다.

이를 처리하는 방법에는 두 가지가 있습니다. 첫 번째는 일정 시간이 지난 후 변환을 *직교정규화*하는 것입니다(매 프레임마다 수정하는 경우 프레임당 한 번).

transform = transform.orthonormalized()

이렇게 하면 모든 축의 길이가 다시 ``1.0``가 되고 서로 ``90``도가 됩니다. 그러나 변환에 적용된 모든 배율은 손실됩니다.

조작할 노드를 확장하지 않는 것이 좋습니다. 대신 자식 노드 노드의 크기를 조정하세요(예: MeshInstance3D). 노드를 반드시 확장해야 하는 경우 마지막에 다시 적용하세요.

transform = transform.orthonormalized()
transform = transform.scaled(scale)

정보 얻기

이 시점에서 다음과 같이 생각할 수도 있습니다. "그래, 그런데 변환에서 각도를 어떻게 구하지?". 다시 대답은: 그렇지 않습니다. 각도로 생각하는 것을 멈추기 위해 최선을 다해야 합니다.

플레이어가 바라보는 방향으로 총알을 쏘아야 한다고 상상해 보세요. 그냥 정방향 축을 사용하세요.

# On RigidBody3D.

# Keep in mind that -Z is forward.
bullet.transform = transform
bullet.linear_velocity = -transform.basis.z * BULLET_SPEED

적이 플레이어를 보고 있나요? 이를 위해 내적을 사용합니다(내적에 대한 설명은 벡터 수학 튜토리얼 참조).

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

왼쪽 스트레이프:

# On CharacterBody3D.

# Keep in mind that -X is left.
if Input.is_action_pressed("strafe_left"):
    velocity = -transform.basis.x * MOVE_SPEED

move_and_slide()

점프:

# On CharacterBody3D.

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

move_and_slide()

모든 일반적인 동작과 논리는 벡터만으로 수행할 수 있습니다.

정보 설정

물론 어떤 경우에는 변형에 정보를 설정해야 할 때가 있습니다. 예를 들어 1인칭 컨트롤러나 공전하는 카메라 같은 경우입니다. 이런 경우에는 변환이 특정한 순서대로 이루어지기를 원하기 때문에, 각도를 사용해서 처리하는 것이 맞습니다.

이런 경우에는, 변형 밖에서 각도와 회전을 유지하고 매 프레임 설정하십시오. 변형은 이런 식으로 동작하도록 된 게 아니므로 이를 검색하거나 재사용하려고 하지 마세요.

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.screen_relative.x * LOOKAROUND_SPEED
        rot_y -= event.screen_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

보시다시피, 이러한 경우 회전을 외부에 유지한 다음 변환을 최종 방향으로 사용하는 것이 훨씬 더 간단합니다.

쿼터니언(quaternions) 으로 보간하기

두 변환 사이의 보간은 쿼터니언을 사용하여 효율적으로 수행할 수 있습니다. 쿼터니언의 작동 방식에 대한 자세한 내용은 인터넷의 다른 곳에서 찾을 수 있습니다. 실제 사용을 위해서는 주로 가장 가까운 경로 보간을 수행하는 것이 주요 용도라는 점을 이해하는 것만으로도 충분합니다. 마찬가지로 두 개의 회전이 있는 경우 쿼터니언은 가장 가까운 축을 사용하여 두 회전 사이의 보간을 원활하게 허용합니다.

회전을 쿼터니언으로 변환하는 것은 간단합니다.

# Convert basis to quaternion, keep in mind scale is lost
var a = Quaternion(transform.basis)
var b = Quaternion(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)

Quaternion 유형 참조에는 데이터 유형에 대한 더 많은 정보가 있습니다(자주 사용되지는 않지만 변환 누적, 변환 지점 등도 수행할 수 있음). 쿼터니언에 연산을 여러 번 보간하거나 적용하는 경우 결국 정규화해야 한다는 점을 명심하세요. 그렇지 않으면 수치 정밀도 오류도 발생합니다.

쿼터니언은 카메라/경로 등을 수행할 때 유용합니다. 결과는 항상 정확하고 매끄러워지기 때문입니다.

변신은 당신의 친구입니다

대부분의 초보자는 변환 작업에 익숙해지는 데 시간이 걸릴 수 있습니다. 그러나 일단 익숙해지면 그 단순성과 강력함을 높이 평가하게 될 것입니다.

어떤 Godot의 온라인 커뮤니티에서든 편하게 이 주제에 대해 질문하세요. 그리고 충분히 자신만만해졌다면, 다른 사람들을 도와주세요!