¿Cómo crear física de cuerdas controlada por lógica?

Normalmente los desarrolladores, sobre todo noveles, se sienten atraídos por el uso del motor físico de los motores de juego para programar mecánicas de la lógica de juego. Esto sucede porque las ecuaciones matemáticas necesarias para llevarlo a cabo no son triviales y los desarrolladores probablemente no sepan implementarlas sin ayuda de un libro o de algún tutorial. Al tener esos algoritmos ya programados en el motor físico, es lógico que la primera idea del desarrollador sea usarlas. Pero normalmente es un error de concepto.

Normalmente esto no suele ser buena idea, salvo que todo el juego este siendo programado como un juego de físicas. Un ejemplo de este tipo de juego es la serie Trine. Pero, si esto no es así, es mejor tener un mayor control sobre la lógica del juego y no depender del motor físico, ya que este puede tener errores de precisión o comportamientos impredecibles que pueden implicar efectos colaterales no deseados en el gameplay. Todo lo contrario, a lo que normalmente uno espera de la lógica de juego, que sea totalmente predecible.

Siguiendo esta premisa, la implementación de una cuerda dentro de un juego puede tomar dos caminos: El inicialmente fácil pero difícil de ajustar o el más complicado inicialmente pero que produce mayor fiabilidad a largo plazo.

El camino fácil es usar los componentes de Hinge Join de de los diferentes motores físicos. En el caso de Unity de Physx. Esta suele ser la forma de programar una cuerda, cadenas, telas o cualquier mecanismo en la que haya unas piezas fijas que dependen en el movimiento de ciertas restricciones y de la interacción entre ellas. Así que es ideal para modelar péndulos, cuerdas, cadenas, puentes colgantes, etc.

Hinge Joint en Unity

Pero cuando se modelan este tipo de objetos en un juego, normalmente forman parte de los elementos con los que el personaje interactúa. Así que normalmente su interacción depende unicamente de una interacción simple de empujar a un objeto, arrastrarlo en el caso de una cadena, o subirse a un extremo de la cuerda y balancearse. Para una interacción así de simple, este modelo de programación con joints es el más apropiado.

Pero ¿Qué sucede cuando la interacción con la cuerda forma parte de la propia lógica del juego?

Lo normal es asumir que, al igual que en el caso anterior, lo mas lógico es utilizar el propio motor físico para simular la cuerda. Pero ¿Cúal es el problema si usamos joints…? Si probamos a configurar unos Joints para modelar, por ejemplo, la cadena que une dos objetos kinemáticos que pueden moverse libremente por el mundo, saltar o desplazarse, pronto veremos la respuesta… los Joints comienza a tener comportamientos erráticos, entrar en estados de permanente movimiento o efectos colaterales poco deseables.

Esto sucede principalmente por problemas con las restricciones que no están bien definidas, bien ponderadas o por errores de redondeo en los cálculos que generan comportamientos no esperados. Y es que dar la clave de la configuración del joint para que se comporte correctamente no es una tarea sencilla, debido a la cantidad de parámetros que estan involucrados en la configuración del joint y en la impredecibilidad de los jugadores a la hora de jugar.

Hinge Join en Unity

No es lo mismo el movimiento de un único extremo de la cuerda o cadena más o menos controlado, donde le resto de la cuerda simplemente reacciona a ese primer nodo que se mueve, que tener dos nodos moviéndose libremente, saltando, atravesando plataformas físicas, muriendo y reapareciendo… La locura y el número de problemas que esto produce hace que no podamos confiar únicamente en las restricciones definidas en los Hinge Joints y haya que empezar tocar la física mediante código. Bienvenidos al infierno.

¿Cuál es la mejor alternativa en estos casos?

Pues sí, lo habéis adivinado, programar la propia física de la cuerda manualmente. El Hinge Joint es un componente físico muy versátil que lo mismo te simulan una cuerda, que un puente colgante o una tela. Utiliza toda la interacción física del motor y puede modelar un comportamiento muy realista que seguramente no necesitaras para la lógica de tu juego. Por lo tanto, la idea no es re-implementar los joints (para eso los usaríamos). La idea es programar una simplificación de la física de la cuerda que se comporte suficientemente realista para lo que busca tu juego, pero que reduzca la complejidad de los cálculos para tener mucho mayor control sobre los mismos. El objetivo es manejar muchos menos parámetros para pode ajustarlos mucho mejor y reducir las posibilidades de un mal funcionamiento de la cuerda. Es decir, hacerla mucho más predecible y más fácil de configurar.

Y aquí entra en juego la integración de Verlet

La integración de Verlet

La integración de Verlet es un método de integración numérica que tiene como característica principal que conserva la energía y por tanto produce menos errores que la integración de Euler clásica.

La integración de Verlet

No vamos a meternos en sus bondades matemáticas, pero si vamos a definir en concreto cuál es el comportamiento de este método cuando queremos calcular el movimiento en el espacio. Podemos definir la ecuación de movimiento con la integral de Verlet de la siguiente forma. X(t) es la posición del objeto en el momento t de tiempo. Δt es el incremento de tiempo producido por el siguiente update del juego y v(t) la velocidad en el momento t. Por lo tanto, la posición en el siguiente momento de tiempo será la posición anterior multiplicado por el incremento de tiempo y por la velocidad que llevaba en el momento anterior el objeto.

x(t+Δt) = x(t) + Δt·v(t)

Veamos en código como se programa esto:

Creamos un conjunto de secciones de cuerda que denominaremos RopeSection. Con ellas formaremos una lista. Para integrar la lista la recorreremos y aplicaremos el cálculo que hemos descrito anteriormente. En RopeSection tenemos la posición actual de la sección y la posición anterior.

Con ambas posiciones calculamos la velocidad que es la diferencia entre la posición actual y la anterior. Dicha velocidad suma la posición y le añadimos la gravedad para que la cuerda tienda a bajar, actualizando la posición anterior con la actual para repetir el proceso en el siguiente ciclo.


void VerletIntegration()
{
    for (int i = 1; i < _ropeSections.Count; i++)
    {
        RopeSection currentRopeSection = _ropeSections[i];
        Vector3 velocity = currentRopeSection.pos - currentRopeSection.oldPos;
        //Actualizamos la posición anterrior
        currentRopeSection.oldPos = currentRopeSection.pos;
        //le sumamos la velocidad a la posición actual.
        currentRopeSection.pos += velocity;
        //añadimos la gravedad
        currentRopeSection.pos += gravity * Time.deltaTime;
    }
}

Cómo podemos ver en el ejemplo anterior, en cada iteración vamos calculando la diferencia entre la posición anterior y la nueva. Cuando se produce un movimiento de uno de los extremos, y gracias a las restricciones de longitud de los segmentos, este movimiento se propaga. Pero la intensidad del movimiento se irá reduciendo hasta pararse. Este proceso tardará varios ciclos en llegar a un estado estacionario. Esto es así debido a que no copiamos la posición cuando aplicamos la velocidad ni la gravedad si no antes de aplicarla. Gracias a esto, la posición en la siguiente iteración será diferente de la oldPos almacenada y se seguirá actualizando hasta que los incrementos de velocidad sean mínimos.

Pero sólo este método no garantiza la propagación del movimiento entre los diferentes segmentos, ni que las secciones de la cuerda mantengan su integridad. Los segmentos deben ser siempre del tamaño definido en la configuración de la cuerda. Para ello comprobamos que las longitudes de la cuerda se conservan y si no es así, modificamos las posiciones de los segmentos para adecuarlas al tamaño de los segmentos.

Puede suceder que una sección de cuerda esté comprimida o estira. En función de si está comprimida (los nodos están mas cerca de lo que debería) o estirada (los nodos están mas separados de lo que debería) tomamos una estrategia. La idea es que si están comprimidas hay que descomprimirlas y si están estiradas, comprimir la cuerda para que se adapte al tamaño. Veamos un ejemplo:


void RopeStretchMax()
{
    for (int i = 1; i < _ropeSections.Count-1; i++)
    {
            RopeSection top = _ropeSections[i];
            RopeSection bottom = _ropeSections[i + 1];
            //Calculamos la distancia
            float distance = Vector3.Distance(top.pos - bottom.pos);
            //Calculamos el error de la distancia con respecto a la longitud del segmento definido como parámetro
            float distError = Mathf.Abs(distance - ropeSectionLength);
            Vector3 changeDir = Vector3.zero;
            if (distance > ropeSectionLength) //fragmento de cuerda estirado, lo comprimimos
                changeDir = top.pos - bottom.pos; //dirección positiva hacia arriba
            else  if (distance > ropeSectionLength) //fragmento comprimido, lo estiramos
                changeDir = bottom.pos - top.pos; //dirección positiva hacia abajo
            if(changeDir != Vector3.zero)
            {
                //la corrección de distancia se reparte entre los dos extremos al 50%
                bottom.pos += changeDir.normalized * distError * 0.5f; 
                top.pos -= changeDir.normalized * distError * 0.5f;
            }
    }
}

Este proceso se realiza una serie de veces para que el comportamiento de la cuerda no tenga problemas de precisión en los cálculos y produzca inestabilidad. Podemos calcular el RopeStretchMax entre 10 y 20 veces por update para conseguir buenos resultados. También podemos añadirle un factor de elasticidad a la distancia para darle algo más de elasticidad a la cuerda y simular una goma.

Ahora nos falta explicar como iniciaríamos los valores de la cuerda para que ésta pueda seguir a un objeto móvil. El primer elemento de la cuerda toma la posición del objeto del juego al que está atachado. Y el objeto que arrastra de la cuerda, toma la posición del último segmento de la cuerda, que tendrá un valor determinado por la integral de Verlet y por la implementación de RopeStretchMax. En nuestro caso **_transforToConectTheRope** es el objeto al que anclamos la cuerda y **_transforHangingFromTheRope** el objeto que arrastra o cuelga de la cuerda. VerletIntegrationInitStep se ejecutaría antes de la integración y VerletIntegrationFinalStep después de la integración y la ejecución de RopeStretchMax.


void VerletIntegrationInitStep()
{
    RopeSection firstRopeSection = _ropeSections[0];

    firstRopeSection.pos = _transforToConectTheRope.position;
}

void VerletIntegrationFinalStep()
{
    //Colocamos la cuerda en la posición del último
    _transforHangingFromTheRope.position = _ropeSections[_ropeSections.Count - 1].pos;
}

//el update quedaría así:
void Update()
{
    VerletIntegrationInitStep();
    VerletIntegration();
    for(int i = 0; i < numStepsRunRopeStretchMax; i++)
    {
        RopeStretchMax();
    }
    VerletIntegrationFinalStep();
}

Con esto conseguimos el siguiente efecto:

Con esta implementación podemos controlar el comportamiento de una cuerda, pero no es muy diferente del comportamiento que podemos conseguir con los Hinge Joints de Unity. Eso si, mucho más fácil de configurar. Pero donde verdaderamente tenemos un problema con la física de Unity es cuando, como en sucede con Fulvinter, ambos extremos de la cuerda son móviles. Si recordáis, en la primera aproximación, sólo actualizamos la posición del ancla y colocamos el objeto que se arrastra en la última posición de la cuerda.

Para conseguir que los dos extremos de la cuerda influyan en su comportamiento de ésta, tendremos que hacer algunas modificaciones al algoritmo inicial:

  • La primera es no debemos teletransportar el objeto que cuelga al final de la cuerda, ya que debemos permitir que este se mueva libremente.
  • Ahora, su posición la determina su lógica y no el resto de la cuerda.
  • Así que eliminamos VerletIntegrationFinalStep
  • En su lugar, actualizamos la última posición de la cuerda con la posición del objeto que hasta ahora era el objeto colgante y que habrá cambiado de posición en función de su lógica.

Veamos como quedaría:


void VerletIntegrationInitStep()
{
    RopeSection firstRopeSection = _ropeSections[0];

    firstRopeSection.pos = _transforToConectTheRope.position;

    RopeSection lastRopeSection = _ropeSections[_ropeSections.Count-1];

    lastRopeSection.pos = _transformT_transforHangingFromTheRopeoConectTheRope.position;
}

Esto es lo que hace descontrolarse a los Hinge Joints ya es completamente imprevisible lo que puede suceder al tener dos extremos de la física controlados por dos jugadores diferentes.

Una vez que hemos actualizado la posición y final, le resto de posiciones van determinadas por la integral de Verlet y con las propias distancias máximas de los segmentos que componen la cuerda. Con este pequeño cambio, el comportamiento del objeto queda como sigue:

Para completar el comportamiento, debemos aplicarle física de colisiones a los objetos. En el caso de la cuerda que une a los dos protagonistas, y que veremos en acción en el siguiente video, además en cada sección tiene que haber una comprobación física de las colisiones. Se puede hacer con los métodos de overlaping de física de Unity. Nuestra implementación es diferente y depende de la lógica de nuestro juego para que sea más eficiente. Podemos ver el resultado final en este video.

Nota adicional: hemos puesto colisión al objeto colgando en la versión inicial de la cuerda para poder interactuar con ella. La colisión consiste en la detección de la collisión con el evento OnControllerColliderHit en el player y simulamos la fisica aplicando un impulso a la última sección de la cuerda ^_^. Recuerda que no tenemos física ni Rigidbody por ningún sitio. Todo está siendo simulado por nosotros, por lo tanto, debemos tambien simular el impulso físico.

void OnControllerColliderHit(ControllerColliderHit hit)
{
    CollisionWithPlayer collisionWithPlayer = hit.gameObject.GetComponent<CollisionWithPlayer>();
    if (collisionWithPlayer != null)
        collisionWithPlayer.ApplyForce(hit.moveDirection);
}

public void ApplyForce(Vector3 force)
{
    RopeSection ropSelection = _ropeSections[_ropeSections.Count - 1];
    ropSelection.pos = ropSelection.pos + force;
}

Y esto es todo. Así es como hemos programado el comportmiento fisico de las cuerdas en el juego. No sólo de la que une a los personajes, si no tambien otras que encontrarás por el juego. Espero que te sirva de guía por si quieres hacer algo similar en tu juego.