Using 3D transforms

Wprowadzenie

Gdy pierwszy raz tworzymy grę 3D, praca z rotacjami może się okazać skomplikowana. Osoby pracujące wcześniej w 2D mogą pomyśleć, że jest to „to samo co w 2D, z tym że obracamy wokół osi X, Y i Z”.

Z początku może się to wydawać proste i oczywiste. Dla prostych gier taki sposób myślenia czasem wystarczy. Niestety, często jest on nieprawidłowy.

Kąty obracające w trzech wymiarach zwykle nazywa się „kątami Eulera”.

../../_images/transforms_euler.png

Kąty Eulera zostały wprowadzone przez matematyka Leonarda Eulera na początku osiemnastego wieku.

../../_images/transforms_euler_himself.png

This way of representing 3D rotations was groundbreaking at the time, but it has several shortcomings when used in game development (which is to be expected from a guy with a funny hat). The idea of this document is to explain why, as well as outlining best practices for dealing with transforms when programming 3D games.

Problemy z kątami Eulera

Obracanie wokół poszczególnych osi może i wydaje się intuicyjne, ale jest niepraktyczne.

Kolejność osi

Głównym problemem jest brak jednej, konsekwentnej metody wyznaczającej orientację na podstawie kątów. Nie ma w matematyce zwyczajnej funkcji, która dla trzech kątów stworzy nam obrót w 3D. Jedyne, co możemy zrobić, to wykonać kolejne obroty, jeden po drugim, w pewnej kolejności.

Możemy najpierw obrócić wokół osi X, potem Y, a następnie Z. Możemy też wybrać najpierw oś Y, potem Z i na końcu X. Tak czy inaczej obrót się wykona, ale w zależności od przyjętej kolejności rezultat niekoniecznie będzie ten sam. To oznacza, że mamy kilka sposobów na wyznaczenie orientacji na podstawie trzech kątów, różniących się kolejnością poszczególnych rotacji.

Following is a visualization of rotation axes (in X,Y,Z order) in a gimbal (from Wikipedia). As you can see, the orientation of each axis depends on the rotation of the previous one:

../../_images/transforms_gimbal.gif

You may be wondering how this affects you. Let’s look at a practical example:

Wyobraź sobie, że pracujesz nad kamerą pierwszoosobową (np. w FPS-ie). Poruszanie myszką w lewo i prawo powinno obracać widok równolegle do ziemi. Ruch myszką w górę i w dół oznacza natomiast patrzenie się w górę i w dół.

Żeby osiągnąć zamierzony efekt, trzeba najpierw obrócić wokół osi Y (w Godocie oś Y jest skierowana ku górze), a następnie wokół osi X.

../../_images/transforms_rotate1.gif

Jeżeli zrobimy to w odwrotnej kolejności, tj. najpierw oś X, a następnie Y, uzyskamy coś nieoczekiwanego:

../../_images/transforms_rotate2.gif

W zależności od rodzaju gry lub zamierzonego efektu, pożądana kolejność obrotów może być różna. W związku z tym, nie można zwyczajnie obracać po X, Y i Z. Kolejność obrotów trzeba doprecyzować.

Interpolacja

Kolejnym problemem z kątami Eulera jest interpolacja. Załóżmy, że chcemy płynnie przejść między dwoma położeniami kamery, czy przeciwników (włącznie z rotacjami). Najprostszy sposób to interpolowanie każdego kąta z pierwotnego do docelowego. Rezultat ma wyglądać tak:

../../_images/transforms_interpolate1.gif

Ale gdy używamy kątów, efekt może być inny:

../../_images/transforms_interpolate2.gif

Kamera obróciła się w przeciwnym kierunku!

Mogły się wydarzyć następujące rzeczy:

  • Przekształcenie z obrotu do orientacji nie jest liniowe. Interpolacja może pójść dłuższą drogą (np. zmiana kąta z 270 do 0 stopni przebiega inaczej niż z 270 do 360, mimo iż kąty są równoważne).
  • Gimbal lock is at play (first and last rotated axis align, so a degree of freedom is lost). See Wikipedia’s page on Gimbal Lock for a detailed explanation of this problem.

Powiedz nie dla kątów Eulera

Dlatego zaleca się nie używać własności rotation z węzłów class_Spatial. Istnieje, by była widoczna w edytorze, a także dla zachowania spójności z częścią 2D silnika. Nadaje się jedynie do prostych obrotów (głównie wokół jednej osi, czasami dwóch).

Istnieje lepszy sposób na obracanie.

Introducing transforms

Godot uses the class_Transform datatype for orientations. Each class_Spatial node contains a transform property which is relative to the parent’s transform, if the parent is a Spatial-derived type.

It is also possible to access the world coordinate transform via the global_transform property.

A transform has a class_Basis (transform.basis sub-property), which consists of three class_Vector3 vectors. These are accessed via the transform.basis property and can be accessed directly by transform.basis.x, transform.basis.y, and transform.basis.z. Each vector points in the direction its axis has been rotated, so they effectively describe the node’s total rotation. The scale (as long as it’s uniform) can also be inferred from the length of the axes. A basis can also be interpreted as a 3x3 matrix and used as transform.basis[x][y].

A default basis (unmodified) is akin to:

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

This is also an analog of a 3x3 identity matrix.

Following the OpenGL convention, X is the Right axis, Y is the Up axis and Z is the Forward axis.

Together with the basis, a transform also has an origin. This is a Vector3 specifying how far away from the actual origin (0, 0, 0) this transform is. Combining the basis with the origin, a transform efficiently represents a unique translation, rotation, and scale in space.

../../_images/transforms_camera.png

One way to visualize a transform is to look at an object’s 3D gizmo while in „local space” mode.

../../_images/transforms_local_space.png

The gizmo’s arrows show the X, Y, and Z axes (in red, green, and blue respectively) of the basis, while the gizmo’s center is at the object’s origin.

../../_images/transforms_gizmo.png

For more information on the mathematics of vectors and transforms, please read the Wektory tutorials.

Manipulating transforms

Of course, transforms are not as straightforward to manipulate as angles and have problems of their own.

It is possible to rotate a transform, either by multiplying its basis by another (this is called accumulation), or by using the rotation methods.

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

A method in Spatial simplifies this:

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

Obraca węzeł względem rodzicielskiego węzła.

To rotate relative to object space (the node’s own transform), use the following:

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

Precision errors

Doing successive operations on transforms will result in a loss of precision due to floating-point error. This means the scale of each axis may no longer be exactly 1.0, and they may not be exactly 90 degrees from each other.

If a transform is rotated every frame, it will eventually start deforming over time. This is unavoidable.

There are two different ways to handle this. The first is to orthonormalize the transform after some time (maybe once per frame if you modify it every frame):

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

This will make all axes have 1.0 length again and be 90 degrees from each other. However, any scale applied to the transform will be lost.

It is recommended you not scale nodes that are going to be manipulated; scale their children nodes instead (such as MeshInstance). If you absolutely must scale the node, then re-apply it at the end:

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

Obtaining information

You might be thinking at this point: „Ok, but how do I get angles from a transform?”. The answer again is: you don’t. You must do your best to stop thinking in angles.

Imagine you need to shoot a bullet in the direction your player is facing. Just use the forward axis (commonly Z or -Z).

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

Is the enemy looking at the player? Use the dot product for this (see the Wektory tutorial for an explanation of the dot product):

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

Strafe left:

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

Skocz:

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

All common behaviors and logic can be done with just vectors.

Setting information

There are, of course, cases where you want to set information to a transform. Imagine a first person controller or orbiting camera. Those are definitely done using angles, because you do want the transforms to happen in a specific order.

For such cases, keep the angles and rotations outside the transform and set them every frame. Don’t try to retrieve and re-use them because the transform is not meant to be used this way.

Example of looking around, FPS style:

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

As you can see, in such cases it’s even simpler to keep the rotation outside, then use the transform as the final orientation.

Interpolating with quaternions

Interpolating between two transforms can efficiently be done with quaternions. More information about how quaternions work can be found in other places around the Internet. For practical use, it’s enough to understand that pretty much their main use is doing a closest path interpolation. As in, if you have two rotations, a quaternion will smoothly allow interpolation between them using the closest axis.

Converting a rotation to quaternion is straightforward.

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

The class_Quat type reference has more information on the datatype (it can also do transform accumulation, transform points, etc., though this is used less often). If you interpolate or apply operations to quaternions many times, keep in mind they need to be eventually normalized or they also may suffer from numerical precision errors.

Quaternions are useful when doing camera/path/etc. interpolations, as the result will always be correct and smooth.

Transforms are your friend

For most beginners, getting used to working with transforms can take some time. However, once you get used to them, you will appreciate their simplicity and power.

Don’t hesitate to ask for help on this topic in any of Godot’s online communities and, once you become confident enough, please help others!