Guía de compresión de animación esqueleto


Este artículo será una breve descripción de cómo implementar un esquema simple de compresión de animación y algunos conceptos relacionados. De ninguna manera soy un experto en este asunto, pero hay muy poca información sobre este tema y está bastante fragmentada. Si desea leer más artículos sobre este tema, le recomiendo que vaya a los siguientes enlaces:


Antes de comenzar, vale la pena dar una breve introducción a la animación esquelética y algunos de sus conceptos básicos.

Conceptos básicos de animación y compresión.


La animación esquelética es un tema bastante simple, si te olvidas del desollado. Tenemos un concepto de un esqueleto que contiene transformaciones de los huesos de un personaje. Estas transformaciones óseas se almacenan en un formato jerárquico; de hecho, se almacenan como un delta entre su posición global y la posición de los padres. La terminología aquí es confusa, porque en el motor del juego, el local a menudo se llama espacio modelo / personaje, y global es el espacio mundial. En terminología de animación, local se llama el espacio del padre del hueso, y global es el espacio del personaje o el espacio mundial, dependiendo de si hay movimiento del hueso raíz; pero no nos preocupemos tanto por eso. Lo importante es que las transformaciones óseas se almacenan localmente en relación con sus padres. Esto tiene muchas ventajas, y especialmente al mezclar (mezclar):Si la mezcla de las dos posiciones fuera global, entonces se interpolarían linealmente en la posición, lo que conduciría a un aumento y disminución de los huesos y la deformación del personaje.Y si usa deltas, la mezcla se realiza de una diferencia a otra, por lo que si la transformación delta para un hueso entre dos poses es la misma, entonces la longitud del hueso permanece constante. Creo que es más fácil (pero no del todo exacto) tomarlo de esta manera: el uso de deltas conduce a un movimiento "esférico" de las posiciones óseas durante la mezcla, y la mezcla de transformaciones globales conduce a un movimiento lineal de las posiciones óseas.

La animación esquelética es solo una lista ordenada de fotogramas clave con una frecuencia de fotogramas (generalmente) constante. El marco clave es la pose del esqueleto. Si queremos obtener una pose entre los fotogramas clave, tomamos muestras de ambos fotogramas clave y mezclamos entre ellos, usando la fracción del tiempo entre ellos como el peso de la mezcla. La imagen a continuación muestra una animación creada a 30 fps. La animación tiene un total de 5 cuadros y necesitamos obtener la pose 0.52 s después del comienzo. Por lo tanto, necesitamos muestrear la pose en el cuadro 1 y la pose en el cuadro 2, y luego mezclarlas entre ellas con un peso de mezcla de aproximadamente el 57%.


Un ejemplo de una animación de 5 cuadros y una solicitud de una pose en un tiempo de cuadro intermedio

Teniendo la información anterior y considerando que la memoria no es un problema para nosotros, el guardado secuencial de la pose sería la forma ideal de almacenar la animación, como se muestra a continuación:


Almacenamiento de datos de animación simple

¿Por qué es esto perfecto? El muestreo de cualquier fotograma clave se reduce a una simple operación de memoria. El muestreo de una pose intermedia requiere dos operaciones memcpy y una operación de mezcla. Desde el punto de vista del caché, copiamos usando memcpy dos bloques de datos en orden, es decir, después de copiar el primer fotograma, uno de los cachés ya tendrá un segundo fotograma. Puedes decir: espera, cuando hacemos la mezcla, necesitamos mezclar todos los huesos; ¿Qué pasa si la mayoría de ellos no cambian entre cuadros? ¿No sería mejor almacenar huesos como registros y mezclar solo transformaciones cambiadas? Bueno, si esto se implementa, pueden producirse un poco más de errores de caché al leer registros individuales, y luego deberá realizar un seguimiento de las conversiones que necesita mezclar, y así sucesivamente ... La mezcla puede parecer mucho trabajo que lleva mucho tiempo,pero en esencia es la aplicación de una instrucción a dos bloques de memoria que ya están en el caché. Además, el código de mezcla es relativamente simple, a menudo solo un conjunto de instrucciones SIMD sin ramificación, y un procesador moderno las procesará en cuestión de segundos.

El problema con este enfoque es que requiere una cantidad extremadamente grande de memoria, especialmente en juegos donde las siguientes condiciones son verdaderas para el 95% de los datos.

  • Los huesos tienen una longitud constante.
    • Los personajes en la mayoría de los juegos no estiran los huesos, por lo tanto, dentro de la misma animación, los registros de las transformaciones son constantes.
  • Usualmente no escalamos los huesos.
    • La escala rara vez se usa en animaciones de juegos. Se usa bastante activamente en películas y efectos visuales, pero muy poco en juegos. Incluso cuando se usa, generalmente se usa la misma escala.
    • De hecho, en la mayoría de las animaciones que creé en tiempo de ejecución, aproveché este hecho y mantuve toda la transformación ósea en 8 variables flotantes: 4 para rotar el cuaternión, 3 para mover y 1 para escalar. Esto reduce significativamente el tamaño de la pose en el tiempo de ejecución, proporcionando una mayor productividad al mezclar y copiar.

Con todo esto en mente, si observa el formato de datos original, puede ver cuán ineficiente está gastando memoria. Duplicamos los valores de desplazamiento y escala de cada hueso, incluso si no cambian. Y la situación se está yendo rápidamente de las manos. Por lo general, los animadores crean animaciones a una frecuencia de 30 fps, y en los juegos de nivel AAA, un personaje generalmente tiene alrededor de 100 huesos. Según esta cantidad de información y un formato de 8 flotantes, como resultado necesitamos alrededor de 3 KB por pose y 94 KB por segundo de animación. Los valores se acumulan rápidamente y en algunas plataformas pueden obstruir fácilmente toda la memoria.

Entonces hablemos de compresión; Al intentar comprimir datos, hay varios aspectos a considerar:

  • Índice de compresión
    • ¿Cuánto logramos reducir la cantidad de memoria ocupada?
  • Calidad
    • Cuánta información perdimos de los datos de origen
  • Tasa de compresión
    • .

Me preocupa principalmente la calidad y la velocidad, y me preocupa menos la memoria. Además, trabajo con animaciones de juegos, y puedo aprovechar el hecho de que, de hecho, para reducir la carga en la memoria, no tenemos que usar el desplazamiento y la escala en los datos. Debido a esto, podemos evitar una disminución en la calidad causada por una disminución en el número de cuadros y otras soluciones con pérdidas.

También es extremadamente importante tener en cuenta que no debe subestimar el efecto de la compresión de animación en el rendimiento: en uno de mis proyectos anteriores, la tasa de muestreo disminuyó en aproximadamente un 35%, y también hubo algunos problemas de calidad.

Cuando comenzamos a trabajar con la compresión de datos de animación, hay dos áreas importantes a tener en cuenta:

  • ¿Con qué rapidez podemos comprimir elementos individuales de información en un cuadro clave (cuaterniones, flotante, etc.).
  • ¿Cómo podemos comprimir la secuencia de fotogramas clave para eliminar información redundante?

Discretización de datos


Casi toda esta sección se puede reducir a un principio: discretizar datos.

La discretización es una forma difícil de decir que queremos convertir un valor de un intervalo continuo a un conjunto discreto de valores.

Flotador de discretización


Cuando se trata de muestrear valores flotantes, nos esforzamos por tomar ese valor flotante y representarlo como un entero usando menos bits. El truco es que un número entero puede no representar realmente un número fuente, sino un valor en un intervalo discreto asignado a un intervalo continuo. Por lo general, se utiliza un enfoque muy simple. Para muestrear un valor, primero necesitamos un intervalo para el valor original; Habiendo recibido este intervalo, normalizamos el valor inicial para este intervalo. Luego, este valor normalizado se multiplica por el valor máximo posible para el tamaño de salida dado en bits deseado. Es decir, para 16 bits multiplicamos el valor por 65535. Luego, el valor resultante se redondea al entero más cercano y se almacena. Esto se muestra claramente en la imagen:


Un ejemplo de muestreo de un flotante de 32 bits a un entero de 16 bits sin signo

Para obtener nuevamente el valor original, simplemente realizamos las operaciones en el orden inverso. Es importante tener en cuenta aquí que necesitamos registrar en algún lugar el intervalo inicial del valor; de lo contrario, no podremos decodificar el valor muestreado. El número de bits en el valor muestreado determina el tamaño del paso en el intervalo normalizado y, por lo tanto, el tamaño del paso en el intervalo original: el valor descodificado será un múltiplo de este tamaño de paso, lo que nos permite calcular fácilmente el error máximo que se produce debido al proceso de muestreo, por lo que podemos determinar el número de bits requerido para nuestra aplicación.

No daré ejemplos del código fuente, porque hay una biblioteca bastante conveniente y simple para realizar operaciones de muestreo básicas, que es una buena fuente sobre este tema: https://github.com/r-lyeh-archived/quant (Diría que no debería usar su función de discretización de cuaternión, sino más sobre esto más adelante).

Compresión Quaternion


La compresión Quaternion es un tema bien estudiado, por lo que no repetiré lo que otras personas explicaron mejor. Aquí hay un enlace a una publicación de compresión de instantáneas que proporciona la mejor descripción sobre este tema: https://gafferongames.com/post/snapshot_compression/ .

Sin embargo, tengo algo que decir sobre el tema. Las publicaciones de bitsquid, que hablan sobre la compresión del cuaternión, sugieren comprimir el cuaternión a 32 bits utilizando aproximadamente 10 bits de datos para cada componente del cuaternión. Esto es exactamente lo que hace Quant, porque se basa en publicaciones de bitsquid. En mi opinión, dicha compresión es demasiado grande y en mis pruebas causó fuertes sacudidas. Quizás los autores usaron jerarquías menos profundas del personaje, pero si multiplica más de 15 cuaterniones de mis ejemplos de animación, el error combinado resulta ser bastante grave. En mi opinión, el mínimo absoluto de precisión es de 48 bits por cuaternión.

Reducción de tamaño debido al muestreo


Antes de comenzar a considerar los diferentes métodos de compresión y la disposición de los registros, veamos qué tipo de compresión obtenemos si simplemente aplicamos la discretización en el circuito original. Usaremos el mismo ejemplo que antes (un esqueleto de 100 huesos), así que si usamos 48 bits (3 x 16 bits) por cuaternión, 48 bits (3 × 16) para mover y 16 bits para escalar, entonces en total para la conversión Necesitamos 14 bytes en lugar de 32 bytes. Esto es 43.75% del tamaño original. Es decir, durante 1 segundo de animación con una frecuencia de 30 FPS, redujimos el volumen de aproximadamente 94 KB a aproximadamente 41 KB.

Esto no está nada mal, la discretización es una operación de costo relativamente bajo, por lo que esto no afectará demasiado el tiempo de desempaque. Encontramos un buen punto de partida para el inicio, y en algunos casos esto incluso será suficiente para implementar animaciones dentro del presupuesto de recursos y garantizar una excelente calidad y rendimiento.

Grabar compresión


Aquí todo se vuelve muy complicado, especialmente cuando los desarrolladores comienzan a probar técnicas como reducir el marco clave, el ajuste de curvas, etc. También en esta etapa realmente estamos comenzando a reducir la calidad de las animaciones.

En casi todas estas decisiones, se supone que las características de cada hueso (rotación, desplazamiento y escala) se almacenan como un registro separado. Por lo tanto, podemos voltear el circuito, como lo mostré antes:


Guardar datos de huesos como registros

Aquí simplemente guardamos todos los registros secuencialmente, pero también podríamos agrupar todos los registros de rotaciones, desplazamientos y escalas. La idea básica es pasar de almacenar datos de cada pose a almacenar registros.

Una vez hecho esto, podemos usar otras formas para reducir aún más la memoria ocupada. El primero es comenzar a soltar cuadros. Nota: esto no requiere un formato de registro y este método se puede aplicar en el esquema anterior. Este método funciona, pero conduce a la pérdida de pequeños movimientos en la animación, porque descartamos la mayoría de los datos. Esta técnica se utilizó activamente en la PS3, y a veces tuvimos que rebajarnos a frecuencias de muestreo increíblemente bajas, por ejemplo, hasta 7 cuadros por segundo (generalmente para animaciones no muy importantes). Tengo malos recuerdos de esto, como programador de animación veo claramente los detalles perdidos y la expresividad, pero si se mira desde el punto de vista del programador del sistema, podemos decir que la animación es "casi" la misma, porque en general el movimiento se conserva, pero al mismo tiempo Ahorre mucha memoria.

Omitamos este enfoque (en mi opinión, es demasiado destructivo) y consideremos otras opciones posibles. Otro enfoque popular es crear una curva para cada registro y realizar la reducción de fotogramas clave en la curva, es decir eliminar fotogramas clave duplicados. Desde el punto de vista de las animaciones de juegos, con este enfoque, las grabaciones de movimiento y escala se comprimen perfectamente, a veces se reducen a un fotograma clave. Esta solución no es destructiva, pero requiere desempaquetar, porque cada vez que necesitamos obtener la transformación, tenemos que calcular la curva, porque ya no podemos simplemente ir a los datos en la memoria. La situación se puede mejorar un poco si calcula las animaciones en una sola dirección.y almacene el estado de la muestra de cada animación para cada hueso (es decir, de dónde obtener el cálculo de la curva), pero debe pagar esto con un aumento en la memoria y un aumento significativo en la complejidad del código. En los sistemas de animación modernos, a menudo no reproducimos animaciones de principio a fin. A menudo, en ciertas compensaciones de tiempo, hacen transiciones a nuevas animaciones gracias a cosas como la combinación sincronizada o la coincidencia de fases. A menudo, tomamos muestras de poses individuales pero no consecutivas para implementar cosas como mezclar apuntar / mirar un objeto, y a menudo las animaciones se reproducen en orden inverso. Por lo tanto, no recomiendo usar una solución de este tipo, simplemente no vale la pena la molestia causada por la complejidad y los posibles errores.

También existe el concepto de no solo eliminar claves idénticas en las curvas, sino también especificar un umbral en el que se eliminen claves similares; Esto lleva al hecho de que la animación se desvanece, similar al método de soltar cuadros, porque el resultado final es el mismo en términos de datos. A menudo se utilizan esquemas de compresión de animación, en los que se establecen parámetros de compresión para cada registro, y los animadores se atormentan constantemente con estos valores, tratando de mantener la calidad y reducir el tamaño al mismo tiempo. Este es un flujo de trabajo doloroso y estresante, pero es necesario si trabaja con la memoria limitada de las generaciones anteriores de consolas. Afortunadamente, hoy tenemos un gran presupuesto de memoria y no necesitamos cosas tan terribles.

Todos estos aspectos se revelan en las publicaciones de Riot / BitSquid y Nicholas (ver enlaces al comienzo de mi artículo). No hablaré de ellos en detalle. En cambio, hablaré sobre lo que decidí sobre comprimir los registros ...

Yo ... decidí no comprimir los registros.

Antes de comenzar a agitar, permítame explicar ...

Cuando guardo los datos en los registros, almaceno los datos de rotación para todos los cuadros. Cuando se trata de movimiento y escala, hago un seguimiento de si el movimiento y la escala son estáticos durante la compresión, y si es así, guardo solo un valor por registro. Es decir, si el registro se mueve a lo largo de X, pero no a lo largo de Y y Z, entonces guardo todos los valores de mover el registro a lo largo de X, pero solo un valor de mover el registro a lo largo de Y y Z.

Esta situación surge para la mayoría de los huesos en aproximadamente el 95% de nuestras animaciones, por lo que al final podemos reducir significativamente la memoria ocupada, absolutamente sin perder calidad. Esto requiere trabajo desde el punto de vista de la creación de contenido (DCC): no queremos que los huesos tengan ligeros movimientos y aumentos en el flujo de trabajo de creación de animación, pero tal beneficio vale el costo adicional.

En nuestro ejemplo de animación, solo hay dos registros con movimiento y no hay registros con escala. Luego, durante 1 segundo de animación, el volumen de datos disminuye de 41 KB a 18.6 KB (es decir, hasta el 20% del volumen de los datos originales). La situación se vuelve aún mejor cuando aumenta la duración de la animación, gastamos recursos solo en grabar turnos y movimientos dinámicos, y los costos de las grabaciones estáticas permanecen constantes, lo que ahorra más en animaciones largas. Y no tenemos que experimentar una pérdida de calidad causada por el muestreo.

Con toda esta información en mente, mi esquema de datos final se ve así:


Un ejemplo de un esquema de datos de animación comprimido (3 cuadros por registro)

Además, guardo el desplazamiento en el bloque de datos para iniciar los datos de cada hueso. Esto es necesario porque a veces necesitamos muestrear datos para un solo hueso sin leer la pose completa. Esto nos proporciona una forma rápida de acceder directamente a los datos de registro.

Además de los datos de animación almacenados en un bloque de memoria, también tengo opciones de compresión para cada registro:


Ejemplo de parámetros de compresión para registros de mi motor Kruger

Estos parámetros almacenan todos los datos que necesito para decodificar los valores muestreados de cada registro. También monitorean la estática de los registros para que yo sepa cómo manejar los datos comprimidos cuando me tropiezo con un registro estático durante el muestreo.

También puede observar que la discretización para cada registro es individual: durante la compresión, sigo los valores mínimos y máximos de cada característica (por ejemplo, moviéndome a lo largo de la X) de cada registro para asegurar que los datos se discreticen dentro del intervalo mínimo / máximo y mantenga la máxima precisión. No creo que en general sea posible crear intervalos de muestreo globales sin destruir sus datos (cuando los valores están fuera del intervalo) y sin cometer errores significativos.

Sea como fuere, aquí hay un breve resumen de mis estúpidos intentos de implementar la compresión de animación: al final, casi uso la compresión.

All Articles