Usando transformaciones 3D

Introducción

Si nunca has hecho juegos 3D antes, trabajar con rotaciones en tres dimensiones puede resultar confuso al principio. Viniendo de 2D, la forma natural de pensar es como «Oh, es como rotar en 2D, excepto que ahora las rotaciones ocurren en X, Y y Z «.

Al principio esto puede parecer fácil y para los juegos simples, esta forma de pensar puede incluso ser suficiente. Por desgracia, con frecuencia es incorrecto.

Los ángulos en tres dimensiones se denominan comúnmente «Ángulos de Euler».

../../_images/transforms_euler.png

Los ángulos de Euler fueron introducidos por el matemático Leonhard Euler a principios del siglo XVIII.

../../_images/transforms_euler_himself.png

Esta forma de representar rotaciones en 3D fue revolucionaria en su momento, pero tiene varios defectos cuando se utiliza en el desarrollo de juegos (lo cual es de esperar de un sujeto con un sombrero gracioso). La idea de este documento es explicar por qué, así como esbozar las mejores prácticas para hacer frente a las transformaciones en la programación de juegos 3D.

Problemas de los ángulos de Euler

Si bien puede parecer intuitivo que cada eje tenga una rotación, la realidad es que no resulta práctico.

Orden de los ejes

La razón principal de esto es que no hay una forma única de construir una orientación desde los ángulos. No hay una función matemática estándar que tome todos los ángulos juntos y produzca una rotación 3D real. La única manera de producir una orientación desde ángulos es girar el objeto ángulo por ángulo, en un orden arbitrario.

Esto podría hacerse rotando primero en X, luego en Y y luego en Z. O bien, se puede rotar primero en Y, luego en Z y finalmente en X. Cualquier método funciona, pero dependiendo del orden, la orientación final del objeto no será necesariamente la misma. De hecho, esto significa que hay varias maneras de construir una orientación desde 3 ángulos diferentes, dependiendo de el orden de las rotaciones.

A continuación se muestra una visualización de los ejes de rotación (en orden X, Y, Z) en un cardán (de Wikipedia). Como se puede ver, la orientación de cada eje depende de la rotación del anterior:

../../_images/transforms_gimbal.gif

Puede que te preguntes cómo te afecta esto. Veamos un ejemplo práctico:

Imagina que estás trabajando en el control de un juego en primera persona (FPS). Mover el ratón a la izquierda y a la derecha controla el ángulo de visión paralelo al suelo, mientras que moverlo hacia arriba y hacia abajo mueve la vista del jugador hacia arriba y hacia abajo.

En este caso para conseguir el efecto deseado, la rotación se debe aplicar primero en el eje Y (en este caso «arriba», ya que Godot utiliza una orientación «Y-Up»), seguido de la rotación en el eje X.

../../_images/transforms_rotate1.gif

Si primero aplicáramos la rotación en el eje X, y luego en el eje Y, el resultado no sería el deseado:

../../_images/transforms_rotate2.gif

Dependiendo del tipo de juego o efecto deseado, el orden en el que desea que se apliquen las rotaciones de los ejes puede variar. Por lo tanto, la aplicación de rotaciones en X, Y y Z no es suficiente: también es necesario un orden de rotación.

Interpolación

Otro problema con el uso de los ángulos de Euler es la interpolación. Imagina que quieres hacer la transición entre dos cámaras diferentes o posiciones enemigas (incluyendo rotaciones). Una manera lógica de acercarse a esto es interpolar los ángulos de una posición a otra. Uno esperaría que se viera así:

../../_images/transforms_interpolate1.gif

Pero esto no siempre tiene el resultado esperado cuando se utilizan ángulos:

../../_images/transforms_interpolate2.gif

¡La cámara giró en la dirección opuesta!

Hay algunas razones por las que esto puede suceder:

  • Las rotaciones no se mapean linealmente a la orientación, por lo que interpolarlas no siempre da como resultado la trayectoria más corta (es decir, pasar de 270 a 0 grados no es lo mismo que pasar de 270 a 360, aunque los ángulos sean equivalentes).
  • El bloqueo del cardán está en juego (el primer y último eje de rotación se alinean, por lo que se pierde un grado de libertad). Consulta la página de Wikipedia en Bloqueo del cardán para obtener una explicación más detallada acerca de este problema.

Di no a los ángulos de Euler

El resultado de todo esto es que deberías no usar la propiedad rotation de class_Spatial` en Godot para los juegos. Está ahí para ser usado principalmente en el editor, para la coherencia con el motor 2D, y para rotaciones simples (generalmente sólo un eje, o incluso dos en casos limitados). Por mucho que te sientas tentado, no lo uses.

En cambio, hay una mejor manera de resolver estos problemas de rotación.

Introducción a Transforms

Godot usa el tipo de datos class_Transform para las orientaciones. Cada nodo class_Spatial` contiene una propiedad transform relativa a la transformación del padre, si el padre es de tipo Spatial.

También es posible acceder a la transformación de coordenadas del mundo a través de la propiedad global_transform.

Un transform (o matriz de transformación) tiene una class_Basis (sub-propiedad transform.basis), que consiste en tres vectores class_Vector3. Se accede a ellos a través de la propiedad transform.basis y se puede acceder a ellos directamente a través de transform.basis.x, transform.basis.y ``, y ``transformar.basis.z. Cada vector apunta en la dirección en la que se ha girado cada eje, por lo que describe eficazmente la rotación total del nodo. La escala (siempre que sea uniforme) también se puede calcular a partir de la longitud de los ejes. Una basis también puede ser interpretada como una matriz 3x3 y usada como transform.basis[x][y].

Una base por defecto (sin modificar) es similar a:

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

Esto también es similar a una matriz de identidad 3x3.

Siguiendo la convención OpenGL, X es el eje horizontal (Right), Y es el eje vertical (Up) y Z es el eje frontal (Forward).

Junto con la base, una transformación también tiene un origen. Este es un Vector3 especificando cuán lejos del origen real (0, 0, 0) se encuentra esta transformación. Combinando la base con el origen, una transformación representa eficientemente un desplazamiento, rotación y escala únicos en el espacio.

../../_images/transforms_camera.png

Una forma de visualizar una transformación es mirar el gizmo 3D de un objeto mientras está en modo «espacio local».

../../_images/transforms_local_space.png

Las flechas del Gizmo muestran los ejes X. Y y Z (En rojo, verde, y azul respectivamente) de la basis, mientras que el centro del Gizmo está en el origen del objeto.

../../_images/transforms_gizmo.png

Para más información sobre las matemáticas de vectores y transformaciones, por favor lee los tutoriales Matemáticas vectoriales.

Manipulando Transforms

Por supuesto, las transformaciones no son tan sencillas de manipular como los ángulos y tienen sus propios problemas.

Es posible rotar un transformación, ya sea multiplicando su base por otra (esto es llamado acumulación), o usando los métodos de rotación.

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

El método en Spatial simplifica esto:

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

Esto rota el nodo relativo al nodo padre.

Para rotar en relación con el espacio del objeto (la transformación propia del nodo) se utiliza lo siguiente:

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

Errores de precisión

Si se realizan operaciones consecutivas con transformaciones, se producirá una pérdida de precisión debido a un error de punto flotante. Esto significa que la escala de cada eje puede que ya no sea exactamente 1.0, y puede que no sea exactamente 90 grados entre sí.

Si se rota una transformación en cada cuadro, eventualmente comenzará a deformarse con el tiempo. Esto es inevitable.

Hay dos maneras diferentes de resolver esto. La primera es ortonormalizar la transformación después de algún tiempo (tal vez una vez por fotograma si se modifica cada fotograma):

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

Esto hará que todos los ejes tengan de nuevo una longitud de 1.0 y estén a 90 grados entre sí. Sin embargo, cualquier escala aplicada a la transformación se perderá.

Se recomienda no escalar los nodos que van a ser manipulados. Escala los nodos hijos en su lugar (como MeshInstance). Si es absolutamente necesario escalar el nodo, se debe volver a aplicar al final:

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

Obteniendo información

Podrías estar pensando en este momento: «Bueno, ¿pero cómo obtengo los ángulos de una transformación?» La respuesta de nuevo es: no se puede. Debes hacer todo lo posible para dejar de pensar en ángulos.

Imagina que necesitas disparar una bala en la dirección en la que tu jugador está mirando. Sólo usa el eje delantero (o forward, comúnmente Z o -Z).

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

¿El enemigo está mirando al jugador? Usa el producto escalar para esto (ver el tutorial Matemáticas vectoriales para una explicación del producto escalar):

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

Ataque a la izquierda:

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

Salto:

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

Todos los comportamientos comunes y la lógica se pueden hacer sólo con vectores.

Asignando valores

Hay, por supuesto, casos en los que se desea establecer la información como una transformación. Imagina un controlador de primera persona o una cámara en órbita. Estos se hacen definitivamente usando ángulos, porque quieres que las transformaciones ocurran en un orden específico.

En estos casos, mantén los ángulos y rotaciones fuera del transform y ponlos en cada fotograma. No intentes recuperarlos y reutilizarlos porque el transform no está hecho para ser usado de esta manera.

Un ejemplo de cómo mirar alrededor, al estilo 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
    }
}

Como puedes ver, en estos casos es aún más simple mantener la rotación fuera, y luego usar el transform como orientación final.

Interpolando con quaternions

La interpolación entre dos transformaciones se puede hacer eficientemente con quaternions. Para más información sobre cómo funcionan los quaternions, puedes consultar otros sitios de Internet. Para uso práctico, es suficiente entender que su uso principal es hacer una interpolación de ruta más cercana. Como en el caso anterior, si tiene dos rotaciones, un quaternion permitirá suavemente la interpolación entre ellas usando el eje más cercano.

Convertir una rotación a quaternion es sencillo.

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

La referencia de class_Quat tiene más información sobre el tipo de datos (este tambien puede realizar acumulación de transformaciones, transformar puntos, etc. aunque se utiliza con menos frecuencia). Si interpolas o aplicas operaciones a quaternions muchas veces, ten en cuenta que necesitarán ser normalizadas eventualmente o también pueden sufrir errores de precisión numérica.

Los quaternions son útiles cuando se realizan interpolaciones de cámara/trayectoria/etc., ya que el resultado será siempre correcto y suave.

Los Transforms son tus amigos

Para la mayoría de los principiantes, acostumbrarse a trabajar con transformaciones puede llevar algún tiempo. Sin embargo, una vez que te acostumbres a ellos, apreciarás su simplicidad y poder.

No dudes en pedir ayuda sobre este tema en cualquiera de las comunidades en línea de Godot y, una vez que tengas suficiente experiencia ¡ayuda a los demás!