Animando mallas en 3D

Programando un motor 3D desde cero - Brakeza3D

Animando mallas en 3D

Buenas todos! En el último artículo, os presenté una aproximación sobre cómo implementar la IA en mundos en 3D. Hoy voy a hablaros de otra técnica avanzada de obligado uso en juegos modernos: La integración de animaciones 3D basadas en huesos.

Son varios los animadores que se han ofrecido a colaborar en el engine, pero hasta ahora no disponía de esta característica!. He aprovechado unos días libre de trabajo, para poder centrar el problema e implementar una solución definitiva para cargar animaciones de modelos 3D. Vamos allá!

Un poco de historia

Existen multitud de formatos que almacenan información de modelos 3D.

Historicamente, cada empresa disponía de su propio formato de fichero, generalmente en formato binario, además podemos encontrar formatos específicos para juegos concretos, véase los ficheros de modelos MD3 para el Quake, o MDL para el Warcraft, etc. Mayormente, estos formatos se consideran deprecados hoy en día, aunque siguen teniendo un gran uso entre comunidades de desarrolladores independientes.

De forma, paralela, la industria ha ido perfeccionando sus aplicaciones y formatos, asi disponemos de multitud de formatos específicos para determinado software, de esta forma, podemos encontrar ficheros FBX para software de la familia AutoDesk, o ficheros BLEND de Blender, o ficheros OBJ de WaveFront, o ficheros DAE (Collada). De enumerarlos todos la lista sería muy grande.

Cabe destacar el gran nivel de conversión existente hoy en día entre formatos, dicho esto, lo más probable que vuestro software de animaciones 3D ya permita importar/exportar entre los formatos más conocidos.

Voy a hacer especial atención en el formato DAE (Collada – COLLAborative Design Activity), el cual es a mi opinión el formato abierto más completo y accesible, convertido en un estándar. Se basa en XML, por tanto es un fichero de texto legible que podemos editar con cualquier editor ASCII. Su origen data de los tiempos de la PlayStation y es un formato introducido por Sony.

Primer paso: Decisiones

Con lo comentado arriba, una de nuestras primeras elecciones como programadores, será sincronizar junto con el equipo de arte, cual será el formato utilizado para trabajar. Probablemente, la elección del formato ya determine el software a usar o viceversa.

Obviamente hemos de elegir un formato de fichero que contenga todas las características que necesitamos, ya que nos todos permiten almacenar todo tipo de información. Por ejemplo, los ficheros OBJ no incluyen animaciones 3D de forma nativa.

Como programadores, el primer reto una vez elegido el formato de fichero será proceder a parsear todas las estructuras de información del mismo y ponerlas a disposición del engine3D para que pueda operar con ellas. Como os podéis, imaginar esta labor una vez implementada quedará absolutamente acoplada a dicho formato y además dista mucho de ser trivial, requerirá una gran dosis de paciencia leyendo documentación, en el mejor de los casos y haciendo ingeniería inversa en el peor de ellos.

COLLADA

Una vez estudiados los pros y contras de cada formato, opté por COLLADA (ya mencionado arriba). Un fichero de fácil edición mediante cualquier editor de textos y que incluye todas las features que podáis neceesitar, además es un estándar y encontraréis posibilidad de importar/exportar desde cualquier software, en mi caso: Blender 2.8.

Tras una pequeña dosis de estudio, me incliné por la instalación de COLLADA-DOM, una librería de C++ que nos ofrece acceso al documento en una estructura de árbol. Mediante esta librería y con un par de noches más de trabajo conseguí parsear la geometría y coordenadas UV. El siguiente paso era parsear la información de las animaciones.

Si bien este trabajo avanzaba a buen ritmo, en algún punto de este trabajo, agoté mi paciencia, varios fantasmas rondaban mi cabeza:

  • El DOM de Collada es inmenso y complejo: Para un modesto videojuego 3D, no necesito toda la información existente en una escena COLLADA. Además, mi parseo manual tenía que lidiar entre el formato 1.4 y 1.5, incompatibles entre sí.
  • Tras estudiar las estructuras de datos de Collada para la gestión de las animaciones: me veía obligado a implementar un montón de estructuras intermedias en Brakeza3D para almacenar la información.
  • Sentía que no merecía la pena agotar recursos en esta parte y que me estaba encerrando a mi mismo en el uso de este formato.

Tras unas búsquedas por Internet, encontré mi solución: Open Asset Import Library (Assimp)

Open Asset Import Library

Esta librería, nos permite cargar la información de modelos 3D para multitud de formatos, incluyendo entre otros a COLLADA. Voy a destacar también su buena integración con el formato FBX, muy usado en la industria del videojuego.

Finalmente borré el código implementado para el parseo mediante COLLADA-DOM (a veces hay que dar un paso atrás :P) y opté por acoplarme totalmente a las estructuras de datos que ofrece ASSIMP, las cuales me parecieron muy razonables y entendibles.  Las ventajas sonaban evidentes: con las mismas estructuras de datos, tendría acceso a la información del modelo 3D y sus animaciones (y mucho más), sin depender del formato en que haya sido preparado por el artista.

Además, es destacable la existencia de código de ejemplos en el repositorio de ASSIMP, lo que facilita enormemente la integración de esta librería con nuestro engine.

Os muestro la línea que se encarga de abrir modelos 3D con ASSIMP en Brakeza3D, realmente sencillo:

Assimp::Importer importer;
this->scene = importer.ReadFile( Filename,
aiProcess_CalcTangentSpace |
aiProcess_Triangulate |
aiProcess_JoinIdenticalVertices |
aiProcess_SortByPType
);

Antes de detallar las estructuras de datos que nos ofrece ASSIMP para el parseo de un modelo 3D, vamos a respondernos a la siguiente pregunta: ¿Qué es una animación 3D?

¿Qué son las animaciones 3D?

Quizás la pregunta parezca obvia, pero vamos a profundizar en su respuesta: De igual forma que una película de dibujos animada, es una sucesión de imágenes estáticas en el tiempo, una animación 3D es una sucesión de cambios en la geometría del modelo en el tiempo. Al menos, en su estado más bruto, podemos definirla así.

Viejos videojuegos y sus primeros formatos 3D de animación, almacenaban sus animaciones de esta manera, cual mallas individuales que conformaban una animación. Cada malla incorporaba las variaciones en la geometría que fuesen oportunas.

En la actualidad, esto ha evolucionado mucho. Hoy en día se utiliza el concepto de BONES (huesos) como piedra angular de las animaciones 3D, tanto desde el punto de vista del artista, como de nosotros, los programadores.

Desde el punto de vista del artista las ventajas son innumerables, principalmente la posibilidad de aplicar una animación a distintos modelos sin repetir el trabajo. Me viene a la mente los ficheros BVH (Motion capture), donde ya dispondréis de una animación que podéis «acoplar» fácilmente a vuestro modelo riggeado.

Desde el punto de vista del programador, los huesos suponen un ahorro en la cantidad de información que hemos de procesar para animar una malla. En definitiva supone un nivel de abstracción entre la malla y la animación.

Enlazando huesos y malla

Nos interesa comprender bien un concepto: El Rigging.

El rigging no es más que el proceso de vinculación de una malla, es decir, una sopa de triángulos, con un sistema de huesos. Aunque este proceso ha mejorado muchísimo hasta el punto de poder ser casi hecho automáticamente, hacerlo bien, es todo un arte y el resultado dependerá en gran medida de las dotes del artista implicado.

Resultado de imagen de rigging 3d

Sería injusto reducir el rigging a esta explicación. No podemos olvidar el trabajo previo de la creación del sistema de huesos con restricciones (IK/FK), lo cual es un esfuerzo importante. La buena noticia es, que para bípedos humanos, existen sistemas de huesos más que solventes y perfectamente «programados» por todo Internet, es decir, con sus constraints perfectamente definidas, para por ejemplo… no poder girar la pierna 360 grados.. por mencionar una limitación evidente.

Un detalle importante que no me gustaría pasar por alto: En el Rigging, cada vértice de la geometría es asociado a uno o varios huesos. Esto genera una tensión entre ellos, que siempre da como resultado 1. Es lo que llamaremos «pesos». Dicho de otra forma, un vértice puede verse afectado por varios huesos, pero un hueso tener una influencia mayor que el resto.

En la imagen inferior se puede ver una representación visual de los «pesos» definidos para los huesos de los vértices del hombro de un modelo 3D humano:

Resultado de imagen de rigging weights

Una vez tenemos el rigging hecho, estamos en disposición de animar nuestro modelo.

El tiempo y el movimiento

Si tenéis algo experiencia con software de animación, sea cual sea, hay algunos elementos que de una u otra forma siempre están presentes, voy a destacar dos: Línea de tiempo y Keyframes.

Resultado de imagen de blender keyframes

Como hemos dicho antes, una animación evoluciona en el tiempo. Tenemos por tanto, una línea de tiempo sobre la que podremos crear Keyframes. Cada keyframe supondrá la captura de información de toda la geometría en dicho momento específico. Podemos visualizar un Keyframe como un frame de la animación, a más keyframes, más detalle de animación.

El trabajo de un animador, consiste en gran medida en mover los huesos acorde a sus requisitos y generar los keyframes oportunos, asi hasta articular la animación final.

Dentro de las capacidades de cada software de animación, al animador se le ofrecen multitud de opciones para interpolar movimientos entre keyframes y conseguir efectos más relistas (aceleraciones etc).

Estructuras de datos

Antes de profundizar en las estructuras de datos de ASSIMP, vamos a analizar cuales serían nuestros requisitos para almacenar la información necesaria antes de implementar un sistema de animación por huesos:

  • Vértices: Información en bruto de cada vértice
  • Caras/Triángulos: Asociación de vértices para articular triángulos
  • Coordenadas UV: Coordenadas para mapeo de texturas
  • Materiales: Las texturas en si mismas.
  • Mallas: Un modelo puede estar compuesto por varias mallas
  • Huesos: Pueden existir mulitud de huesos. Cabe destacar que los huesos se almacenan en una estructura de árbol (huesos que dependen de otros huesos)
  • Keyframes: Estructuras donde se almacenarían los datos relativos a las transformaciones de un keyframe de la animación.

En realidad, ya disponemos de muchas de estas estructuras. Vértices, triángulos, UV y mallas ya se encuentran contempladas a través de clases y/o estructuras disponibles en Brakeza3D. Nuestro objetivo será integrar las animaciones sin alterar el flujo ya existente en el render.

Para agrupar toda la lógica relativa a mallas animadas, en Brakeza3D incorporé el objeto: Mesh3DAnimated, derivado del objeto Mesh3D, el cual ya disponía de la información necesaria para renderizar una malla, almacenando sus vértices, triángulo y UV (entre otras). Por tanto, mi objetivo se limitaba a incorporar la funcionalidad necesaria para cargar un modelo mediante ASSIMP y ofrecer herramientas para actualizar la geometría en función de la línea de tiempo, algo que haremos en cada frame.

Con el objetivo bien definido, podemos proceder a presentar las estructuras de datos que ofrece ASSIMP:

Estructuras de datos de ASSIMP

En primera instancia, vamos a centrar esfuerzos en asentar la relación entre un hueso y los vértices sobre los que ejerce influencia. Como ya hemos dicho esta influencia está marcada por «pesos», cuya suma total ha de ser uno.

El primer objetivo será diseñar una estructura de datos en la que podamos almacenar la relación entre un hueso y sus vértices junto con la influencia de cada uno.

En la siguiente imagen se puede observar como ASSIMP almacena dicha información mediante índices, algo muy común en formatos 3D.

Estructuras de datos de ASSIMP para vértices

En el caso de Brakeza3D, la estructura para almacenar esta información por vértice es la siguiente:

#define NUM_BONES_PER_VEREX 4
#define ARRAY_SIZE_IN_ELEMENTS(a) (sizeof(a)/sizeof(a[0]))

struct VertexBoneData
{
uint IDs[NUM_BONES_PER_VEREX];
float Weights[NUM_BONES_PER_VEREX];

void AddBoneData(uint BoneID, float Weight) {
for (uint i = 0 ; i < ARRAY_SIZE_IN_ELEMENTS(IDs) ; i++) {
if (Weights[i] == 0.0) {
IDs[i] = BoneID;
Weights[i] = Weight;
return;
}
}
}
};

A destacar la constante NUM_BONES_PER_VEREX definida a 4, este valor es variable en función del modelo que estáis importando. En mi caso me encontraba haciendo pruebas con un modelo que garantizaba un máximo de 4 huesos por vértice (El Doom3 tiene un máximo de 4 huesos por vértice en sus modelos)

Otro detalle importante sería la matriz offsetMatrix disponible en cada hueso. Esto equivale a la matriz de transformación (transformación local!) para ese hueso. Recordar que una matriz de transformación agrupa las operaciones de rotación, translación y escalado. Esto es crucial, más adelante haremos hincapié en esta parte. Podéis consultar el post sobre la implementación de nuestra primera cámara, donde se explica el concepto de «Espacio Local». offsetMatrix es una matriz 4×4, que incluye las operaciones de translación, rotación, escalado simultáneamente para ese Bone. Podéis descomponer esta información fácilmente con ASSIMP:

aiVector3t<float> scaling;
aiVector3t<float> position;
aiQuaterniont<float> rotation;

offsetMatrix.Decompose(scaling, rotation, position);

Por lo demás, se vé fácilmente la existencia de la estructura que da soporte a los vértices en ASSIMP. En el gráfico superior, no queda reflejada la existencia de los objetos mFaces, que almacenan los índices que forman triángulos.

Conviene no olvidar que un modelo 3D puede estar formado por VARIAS mallas: Imaginad un complejo personaje, donde una malla es su cuerpo, otra su pantalon, otra su jersey, etc.

En ASSIMP los índices de huesos y/o vértices son relativos a cada malla, habréis de tener esto en cuenta a la hora de generar vuestras estructuras de datos:

En Brakeza3D, todas las estructuras de datos mencionadas, incluyen el nivel de «malla»:

std::vector< std::vector<VertexBoneData> > meshVerticesBoneData;
std::vector< std::vector<Vertex3D> > meshVertices;

Llegados a este punto, estamos listos para afrontar «cada frame». Hasta ahora simplemente hemos presentado las estructuras de datos que hemos de rellenar en la carga inicial del modelo, básicamente: la información mínima para relacionar un hueso con sus vértices y pesos.

Frame a frame

A continuación, el dibujo de como ASSIMP almacena la información relativa a las animaciones 3D:

Estructuras de datos de ASSIMP para huesos

De la escena cuelga una colección de animaciones, donde cada una almacena:

  • Su duración en segundos
  • Sus «channels». Un channel equivale a un hueso

Además cada «channel» almacena:

  • Nodo al que afecta (nodo del hueso)
  • Nombre del nodo (nombre del hueso)
  • Colección de posiciones (una por cada keyframe)
  • Colección de rotaciones (una por cada keyframe)
  • Coleccion de escalados (una por cada keyframe)

El channel es un meta-elemento que podemos asimilar directamente como un hueso y sobre el que se almacenan los keyframes de la animación 3D para la malla a la que pertenezca (me refiero para la malla dentro del mismo modelo, recordad!)

Jerarquía de huesos

Existe un detalle crucial que no hemos de pasar por alto y es la jerarquía de nuestros huesos a la hora de hacer nuestros cálculos.

Un sistema de huesos se almacena en una estructura de árbol, donde existe al menos un elemento raíz a partir del cual se crea el árbol. En el caso de un sistema de huesos para un humano el raíz podría ser el hueso espinal y a partir de éste, articularíamos cadera, hombros, más delante extremidades, etc.

Lo habitual es que el hueso raíz sea un hueso «extra» alejado de la masa de huesos principal, para mover con comodidad el objeto. Un ejemplo real:

Resultado de imagen de human bones rigging

¿Porqué esto deberia importarnos? Si recordáis arriba nos encontrábamos la offsetMatrix de cada hueso, que representaba la transformación completa para los vértices de ese hueso. Si pensamos en ese nodo (su geometría), como un objeto independiente, esta matriz representaría la posición del objeto en el espacio en su «Object Space» y he aqui el detalle: Esta matriz también afecta a todos sus huesos hijos.

En la imagen inferior se observa el sistema de huesos de una mano, a la derecha de la misma se ve claramente la estructura jerárquica de los elementos.

Ejemplo jerarquía huesos

Pongamos un ejemplo: Si pretendemos calcular la transformación final de los vértices afectados por el hueso de una mano, hemos de tener en cuenta, que la mano se ve afectada por el movimiento del hueso del antebrazo, a su vez este se ve afectado por el movimiento de la cadera, asi hasta llegar a la espina dorsal.

Existe por tanto, un proceso recursivo que hemos de tener en cuenta donde acumularemos cálculos desde el hueso que estemos intentando animar hasta el hueso raíz, para finalmente disponer de una matriz de transformación que poder aplicar a vértices previos a su renderizado. Todo esto lo haremos en tiempo real para cada hueso.

void Mesh3DAnimated::ReadNodeHeirarchy(float AnimationTime, const aiNode *pNode, const aiMatrix4x4 &ParentTransform)

Arriba se puede ver la definición de la función recursiva que he utilizado en Brakeza3D, Su responsabilidad es acumular transformaciones desde el nodo solicitado, hasta el raíz.

Interpolación de la animación

Como ya hemos mencionado, una animación 3D evoluciona en el tiempo y además tiene una duración limitada. Además, ASSIMP nos ofrece un TicksPerSecond, lo que nos permite situar los keyframes en el tiempo, respecto uno de otro.

La interpolación es una feature opcional, pero muy deseable si deseáis un movimiento más fluido en las animaciones 3D en vuestro engine. Su responsabilidad sería la de proporcionarnos las transformaciones para cada hueso, dado un parámetro de tiempo «t». Esto nos permitirá resolver las transformaciones para tiempos intermedios entre Keyframes definidos en la animación.

En líneas generales lo que haremos, será situarnos entre los dos keyframes que contengan nuestro parámetro «t» solicitado e interpolaremos su translación, rotación y escalado.

aiMatrix4x4 Mesh3DAnimated::BoneTransform(float TimeInSeconds, std::vector<aiMatrix4x4> &Transforms, int numBones)

Arriba podéis ver la definición de la función que se encarga de esto en Brakeza3D: Se puede ver como solicitamos las transformaciones para un «TimeInSeconds«, además ofrecemos un vector de transformaciones para que sea rellenado, una por hueso.

Juntando las piezas

Ya hemos presentado todo lo necesario para completar un sistema de animaciones 3D en vuestras mallas. Hagámos un repaso:

  • En tiempo de carga: Nos apoyaremos en ASSIMP para la carga del modelo.
  • En tiempo de carga: Una vez dispongamos de la información del modelo mediante ASSIMP, en nuestro engine, añadiremos estructuras de datos para almacenar a nivel de vértice la información de los huesos que le afectan. Además implementaremos herramientas para consultar esta información con comodidad.
  • En tiempo de ejecución: Por cada malla del modelo, solicitaremos las transformaciones de sus huesos para el momento «t» en que nos encontremos y las aplicaremos a todos los vértices de la misma.
  • En tiempo de ejecución: Una vez tenemos toda la geometría del modelo transformada, simplemente, creamos los triángulos y los envíamos al render.

Resultado

Si queréis comfirmar que vuestra implementación es correcta podéis comparar vuestro resultado con la carga de un programa externo, en el siguiente ejemplo se ven: Blender, el previsualizador de OSX y Brakeza3D.

Es una animación 3D compuesta por 49 huesos y 572 keyframes, repartidos en apenas 3 segundos de animación. Asi se vería en movimiento:

Resultado animaciones en Brakeza3D

Resumen

Hemos presentado los principales elementos que componen las animaciones 3D. Además analizamos el cíclo de vida de una animación: desde el rigging, hasta su parseo desde código, así como ofrecer herramientas por código para animar en tiempo real nuestras mallas.

Los interesados en ver el desarrollo al completo os invito a ver la implementación al completo:

https://github.com/rzeronte/brakeza3d/blob/master/headers/Objects/Mesh3DAnimated.cpp

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *