Movimiento en Unity: ¿cómo y por qué?

Uno de los primeros comportamientos que programamos cuando desarrollamos un videojuego es el movimiento de nuestro personaje. Ya sea que desarrollemos en 2D o 3D, siempre necesitaremos programar esa interacción mínima para comenzar a crear experiencias increíbles.

Realmente no es difícil encontrar en internet artículos, entradas de blog o videotutoriales en YouTube que muestren la manera de lograrlo. Por supuesto, la mayoría de estos recursos proporcionan el código para conseguir nuestro objetivo. Pero… ¿Qué implica realmente mover un objeto en un videojuego?

En este post voy a explicarte qué es lo que hay detrás de la magia científica del movimiento de objetos en los videojuegos, utilizando Unity y C# como nuestra plataforma de desarrollo.

FPS: la ilusión de movimiento

Los game engines están construidos sobre ciclos de comportamiento programados que permiten que los componentes y las instrucciones que componen al los videojuegos (físicas, animaciones, scripts, etc.) se actualicen con cierta frecuencia establecida. Este proceso es iterativo y se repite una y otra vez durante toda la ejecución de los mismos.

Dentro de este ciclo, el motor se encarga de procesar todas las operaciones y matemática necesaria para actualizar los componentes en escena, por ejemplo: las posiciones de los vértices, los colores de pixel en pantalla, las entradas de dispositivos como mandos, ratones, teclados, etc. Al final de este proceso el motor renderiza una única imagen con dicha información actualizada.

Flujo del ciclo de vida en Unity

No existe el movimiento real en los videojuegos. Ya que estos ciclos son constantes e iterativos, al final de cada uno de ellos el motor devuelve al buffer de imagen una captura (como una screenshot) del instante en que se encuentra. Entonces, ¿cómo se produce la ilusión de movimiento?

Los ciclos en los que los motores actualizan la información son procesos constantes, pero la velocidad de su ejecución es en realidad variada. Esto dependerá del hardware con el cual se ejecuta el videojuego.

Pongamos un ejemplo sencillo: imagina que tienes un PC de alta gama capaz de ejecutar un videojuego a 60 FPS constantes. Esto quiere decir que, en un segundo transcurrido, el ciclo de actualización del motor se habrá ejecutado 60 veces, devolviendo al buffer de imagen 60 imágenes distintas con información actualizada entre cada una de ellas.

Siguiendo con el ejemplo, ahora imagina que han transcurrido 10 segundos de juego. Durante ese tiempo el ciclo de actualización se ha ejecutado 600 veces, es decir, el buffer de imagen ha recibido 600 imágenes. Estas imágenes son enviadas al dispositivo de salida (pantalla) sucedidas una a una, en orden y de forma constante. Esto es lo que produce la ilusión de movimiento en los videojuegos. Un efecto parecido al de un flipbook de animación.

Flipbook

FPS y su relación con los ciclos de ejecución

El rendimiento de un videojuego depende (en mayor medida) de las especificaciones de la unidad de procesamiento central (CPU), el procesador gráfico (GPU) y la memoria de acceso aleatorio (RAM) del hardware donde se ejecuta.

Cuando nuestro hardware es poderoso y no tiene tantas limitaciones, es posible ejecutar videojuegos con altas tasas de cuadros por segundo, siendo 60 FPS un estándar. Estas tasas pueden ser superiores y por supuesto, también pueden ser inferiores. Todo dependerá del hardware.

En Unity podemos visualizar algunas estadísticas relevantes durante la ejecución de nuestro proyecto. Estas muestran datos indispensables como el frame rate y la velocidad de actualización del ciclo de vida (medido en milisegundos).

Estadísticas en un proyecto de Unity

En las estadísticas anteriores el frame rate se sitúa en 147.4 FPS con un tiempo de ejecución de 6.8 ms. ¿Qué indican estos valores y como son obtenidos?

La matemática es la siguiente: el proyecto se ejecuta a 147.4 FPS, lo cual quiere decir que en un segundo transcurrido de ejecución hay 147 imágenes (el valor es redondeado) mostradas en pantalla. Esta operación corresponde a la división 1/147 = 0.0068. El resultado es equivalente al tiempo que el ciclo tarda en ejecutarse: 6.8 ms dado que 1 ms = 1/1000.

Movimiento de un objeto en Unity

Vallamos a la práctica con los conceptos anteriores. En Unity crearemos un proyecto 2D para demostrar de que manera los FPS afectan el movimiento de los objetos. Mantendré mi proyecto tan simple como sea posible.

He creado una escena con algunos tilemaps para darle algo de personalidad. Dentro de esta existe nuestro objeto Player, el cual contendrá un script básico llamado Movement.cs.

Escena del proyecto en Unity

Este es el código que contendrá nuestro script Movement.cs:

using UnityEngine;
public class Movement : MonoBehaviour
{
    public float speed = 3f;
    private Vector3 _moveDirection;
    void Update()
    {
        _moveDirection.x = Input.GetAxis("Horizontal");
        _moveDirection.y = Input.GetAxis("Vertical");
        transform.position += _moveDirection * speed;
    }
}

Al ejecutar nuestro proyecto e intentar mover a nuestro Player, notaremos que el movimiento es extraño y brusco. El jugador se mueve a velocidades exageradas y no es posible controlarlo de forma adecuada. De hecho es casi imposible de apreciarse en pantalla una vez comienza a moverse.

Movimiento no normalizado y dependiente del frame rate

Este movimiento es inconsistente y rompe completamente la experiencia del usuario. En este caso concreto, el movimiento es casi imperceptible, ya que el juego se ejecuta a altas tasas de FPS.

En equipos con bajas prestaciones de hardware o muy viejos el jugador se movería a una tasa de frames menor pero manteniendo las instrucciones de entrada de datos y la velocidad. Esto provoca un efecto de teletransportación y reacción retrasada a los comandos del usuario.

No es posible jugar videojuegos de esta manera, entonces, ¿cómo hacemos para tener experiencias de juego consistentes e iguales para todos los jugadores y dispositivos?. Tiempo es la respuesta.

DeltaTime: normalizando la experiencia de juego

En Unity existe una clase propia del engine llamada Time. Esta clase proporciona acceso a distintas propiedades con información sobre el tiempo de juego. La clase Time es la forma en que podemos ser consientes de que un videojuego está en marcha y actualizando sus componentes constantemente.

Time.deltaTime es quizá la propiedad más conocida de la clase Time. Esta retorna el valor del intervalo (en segundos) desde el frame anterior hasta el frame actual. Ya que la tasa de frames no es constante y varía todo el tiempo, utilizamos deltaTime para acceder a ese periodo de tiempo entre frames y hacer los cálculos que nos permiten normalizar el movimiento.

La suma de los valores de deltaTime equivalen a 1. Esto significa que deltaTime es el recíproco de la tasa de frames: 60 = 1/60. Esto se puede interpretar como si el valor de deltaTime fuese equivalente a los milisegundos de ejecución, pero no es exactamente así.

Los milisegundos en los que los ciclos de ejecución se actualizan son precisamente eso, milisegundos, mientras que deltaTime utiliza valores de segundos. Así 1/60 = 0.0166 segundos para deltaTime.

using UnityEngine;
public class Movement : MonoBehaviour
{
    public float speed = 3f;
    private Vector3 _moveDirection;
    void Update()
    {
        _moveDirection.x = Input.GetAxis("Horizontal");
        _moveDirection.y = Input.GetAxis("Vertical");
        transform.position += _moveDirection * speed * Time.deltaTime;
    }
}

Si agregamos una multiplicación por Time.deltaTime al movimiento de nuestro objeto en el código como se muestra arriba, obtendremos una velocidad normalizada que le permitirá moverse a una velocidad constante e independiente de la variación del frame rate en cualquier dispositivo.

Movimiento normalizado e independiente del frame rate

Lo que sucede detrás de esto es el resultado de multiplicar el frame rate por la velocidad del jugador (3 en este caso) y el valor de deltaTime. En un ejemplo ya conocido tendremos: 60 * 3 * 1/60 = 3 dónde 60 * 1/60 = 1 y 1 * 3 = 3. Este es el valor normalizado para la velocidad de movimiento de nuestro objeto.

Ya que el método Update se ejecuta cada frame el valor de deltaTime multiplicara la velocidad de nuestro objeto por el frame rate durante ese preciso frame. Si un frame tarda demasiado tiempo en procesarse el valor de deltaTime será mayor. Si el frame se procesa rápidamente el valor será menor. Esto es lo que permite la normalización de la velocidad y el movimiento suavizado que percibe el usuario.

Siguientes pasos

Estas son las bases del movimiento de objetos en videojuegos. Claro que no para aquí, pues ahora viene la mejor parte: experimentar. Time es una clase sumamente amplia que nos proporciona acceso a muchas variaciones y propiedades del tiempo, ¿sabías que existe una propiedad que nos proporciona acceso al intervalo de tiempo en el que se ejecutan las físicas?

Aunque aquí experimentamos con 2D, podemos aplicar las mismas reglas con espacios en 3D, esto no cambia. Te aconsejo leer la documentación relacionada con la clase Time y sus propiedades derivadas. Sobra decir que es recomendable leer toda la documentación en general, aunque poco a poco, a tu ritmo.

Recuerda que la practica no traiciona. Experimenta tanto como puedas, rompe las cosas e intenta arreglarlas después. No tengas miedo de probar algo nuevo. Así es como todos comenzamos. Te toca a ti dar el siguiente paso.