Guide de compression d'animation squelette


Cet article sera un bref aperçu de la façon d'implémenter un schéma de compression d'animation simple et de certains concepts associés. Je ne suis nullement un expert en la matière, mais il y a très peu d'informations à ce sujet, et elles sont assez fragmentées. Si vous souhaitez lire des articles plus approfondis sur ce sujet, je vous recommande de consulter les liens suivants:


Avant de commencer, il convient de donner une brève introduction à l'animation squelettique et à certains de ses concepts de base.

Bases de l'animation et de la compression


L'animation squelettique est un sujet assez simple, si vous oubliez le skinning. Nous avons un concept de squelette contenant des transformations des os d'un personnage. Ces transformations osseuses sont stockées dans un format hiérarchique; en fait, ils sont stockés comme un delta entre leur position globale et la position du parent. La terminologie ici est déroutante, car dans le moteur de jeu local est souvent appelé l'espace modèle / personnage, et global est l'espace mondial. Dans la terminologie de l'animation, local est appelé l'espace du parent de l'os, et global est soit l'espace du personnage soit l'espace du monde, selon qu'il y a mouvement de l'os racine; mais ne nous inquiétons pas trop. L'important est que les transformations osseuses soient stockées localement par rapport à leurs parents. Cela présente de nombreux avantages, et en particulier lors du mélange (mélange):si le mélange des deux positions était global, alors elles seraient interpolées linéairement dans la position, ce qui entraînerait une augmentation et une diminution des os et une déformation du personnage.Et si vous utilisez des deltas, le mélange est effectué d'une différence à l'autre, donc si la transformation delta pour un os entre deux poses est la même, la longueur de l'os reste constante. Je pense qu'il est plus facile (mais pas tout à fait exact) de procéder de cette façon: l'utilisation de deltas conduit à un mouvement «sphérique» des positions osseuses lors du mélange, et le mélange des transformations globales conduit à un mouvement linéaire des positions osseuses.

L'animation squelettique n'est qu'une liste ordonnée d'images clés avec une fréquence d'images (généralement) constante. L'image clé est la pose squelette. Si nous voulons obtenir une pose entre les images clés, nous échantillonnons les deux images clés et les mélangons entre elles, en utilisant la fraction du temps entre elles comme poids du mélange. L'image ci-dessous montre une animation créée à 30 images par seconde. L'animation a un total de 5 images et nous devons obtenir la pose 0,52 s après le début. Par conséquent, nous devons échantillonner la pose dans l'image 1 et la pose dans l'image 2, puis mélanger entre eux avec un poids de mélange d'environ 57%.


Un exemple d'animation de 5 images et une demande de pose à un temps d'image intermédiaire

Ayant les informations ci-dessus et croyant que la mémoire n'est pas un problème pour nous, la sauvegarde séquentielle de la pose serait le moyen idéal pour stocker l'animation, comme indiqué ci-dessous:


Stockage de données d'animation simple

Pourquoi est-ce parfait? L'échantillonnage d'une image clé se résume à une simple opération de mémorisation. L'échantillonnage d'une pose intermédiaire nécessite deux opérations memcpy et une opération de mélange. Du point de vue du cache, nous copions en utilisant memcpy deux blocs de données dans l'ordre, c'est-à-dire qu'après avoir copié la première trame, l'un des caches aura déjà une deuxième trame. Vous pouvez dire: attendez, quand nous faisons le mixage, nous devons mélanger tous les os; Et si la plupart d'entre eux ne changent pas entre les images? Ne serait-il pas préférable de stocker les os en tant qu'enregistrements et de ne mélanger que les transformations modifiées? Eh bien, si cela est réalisé, un peu plus de ratés de cache peuvent potentiellement se produire lors de la lecture des enregistrements individuels, puis vous devrez garder une trace des conversions que vous devez mélanger, et ainsi de suite ... Le mélange peut sembler beaucoup de travail fastidieux,mais c'est essentiellement l'application d'une instruction à deux blocs de mémoire qui sont déjà dans le cache. De plus, le code de mixage est relativement simple, souvent juste un ensemble d'instructions SIMD sans branchement, et un processeur moderne les traitera en quelques instants.

Le problème avec cette approche est qu'elle prend une très grande quantité de mémoire, en particulier dans les jeux où les conditions suivantes sont vraies pour 95% des données.

  • Les os ont une longueur constante
    • Les personnages de la plupart des jeux n'étirent pas les os, par conséquent, au sein d'une même animation, les enregistrements des transformations sont constants.
  • Nous n'échelonnons généralement pas les os.
    • L'échelle est rarement utilisée dans les animations de jeu. Il est assez activement utilisé dans les films et les effets visuels, mais très peu dans les jeux. Même lorsqu'elle est utilisée, la même échelle est généralement utilisée.
    • En fait, dans la plupart des animations que j'ai créées lors de l'exécution, j'ai profité de ce fait et j'ai conservé la transformation osseuse entière en 8 variables flottantes: 4 pour faire pivoter le quaternion, 3 pour se déplacer et 1 pour l'échelle. Cela réduit considérablement la taille de la pose au moment de l'exécution, offrant une productivité accrue lors du mélange et de la copie.

Avec tout cela à l'esprit, si vous regardez le format de données d'origine, vous pouvez voir à quel point il est inefficace de dépenser de la mémoire. Nous dupliquons les valeurs de déplacement et d'échelle de chaque os, même si elles ne changent pas. Et la situation devient rapidement incontrôlable. Habituellement, les animateurs créent des animations à une fréquence de 30 images par seconde, et dans les jeux de niveau AAA, un personnage possède généralement environ 100 os. Sur la base de cette quantité d'informations et d'un format de 8 flottants, nous avons besoin d'environ 3 Ko par pose et de 94 Ko par seconde d'animation. Les valeurs s'accumulent rapidement et sur certaines plates-formes peuvent facilement obstruer toute la mémoire.

Parlons donc de compression; Lorsque vous essayez de compresser des données, plusieurs aspects doivent être pris en compte:

  • Ratio de compression
    • Combien avons-nous réussi à réduire la quantité de mémoire occupée
  • Qualité
    • Combien d'informations nous avons perdu des données sources
  • Taux de compression
    • .

Je suis principalement préoccupé par la qualité et la vitesse, et moins par la mémoire. De plus, je travaille avec des animations de jeu, et je peux profiter du fait qu'en fait, pour réduire la charge sur la mémoire, nous n'avons pas à utiliser de déplacement et d'échelle dans les données. Pour cette raison, nous pouvons éviter une diminution de la qualité causée par une diminution du nombre de trames et d'autres solutions avec des pertes.

Il est également extrêmement important de noter que vous ne devez pas sous-estimer l'effet de la compression d'animation sur les performances: dans l'un de mes projets précédents, le taux d'échantillonnage a diminué d'environ 35%, et il y a également eu des problèmes de qualité.

Lorsque nous commençons à travailler avec la compression des données d'animation, il y a deux principaux domaines importants à considérer:

  • À quelle vitesse pouvons-nous compresser des éléments d'information individuels dans une image clé (quaternions, float, etc.).
  • Comment pouvons-nous compresser la séquence d'images clés pour supprimer les informations redondantes.

Discrétisation des données


La quasi-totalité de cette section peut être réduite à un seul principe: discrétiser les données.

La discrétisation est un moyen difficile de dire que nous voulons convertir une valeur d'un intervalle continu en un ensemble discret de valeurs.

Flotteur de discrétisation


Quand il s'agit d'échantillonner des valeurs flottantes, nous nous efforçons de prendre cette valeur flottante et de la représenter comme un entier en utilisant moins de bits. L'astuce est qu'un entier peut ne pas représenter réellement un nombre source, mais une valeur dans un intervalle discret, mappé sur un intervalle continu. Habituellement, une approche très simple est utilisée. Pour échantillonner une valeur, nous avons d'abord besoin d'un intervalle pour la valeur d'origine; Ayant reçu cet intervalle, nous normalisons la valeur initiale de cet intervalle. Cette valeur normalisée est ensuite multipliée par la valeur maximale possible pour la taille de sortie donnée souhaitée en bits. Autrement dit, pour 16 bits, nous multiplions la valeur par 65535. Ensuite, la valeur résultante est arrondie à l'entier le plus proche et stockée. Ceci est clairement montré dans l'image:


Un exemple de discrétisation d'un flottant 32 bits en un entier non signé 16.

Pour obtenir à nouveau la valeur d'origine, nous effectuons simplement les opérations dans l'ordre inverse. Il est important de noter ici que nous devons enregistrer quelque part l'intervalle initial de la valeur; sinon, nous ne pourrons pas décoder la valeur échantillonnée. Le nombre de bits dans la valeur échantillonnée détermine la taille de pas dans l'intervalle normalisé, et donc la taille de pas dans l'intervalle d'origine: la valeur décodée sera un multiple de cette taille de pas, ce qui nous permet de calculer facilement l'erreur maximale qui se produit en raison du processus d'échantillonnage, afin que nous puissions déterminer le nombre de bits requis pour notre application.

Je ne donnerai pas d'exemples de code source, car il existe une bibliothèque assez pratique et simple pour effectuer des opérations d'échantillonnage de base, ce qui est une bonne source sur ce sujet: https://github.com/r-lyeh-archived/quant (je dirais que vous ne devez pas utiliser sa fonction de discrétisation quaternion, mais plus à ce sujet plus tard).

Compression Quaternion


La compression quaternion est un sujet bien étudié, donc je ne répéterai pas ce que les autres ont mieux expliqué. Voici un lien vers un article de compression d'instantané qui fournit la meilleure description sur ce sujet: https://gafferongames.com/post/snapshot_compression/ .

Cependant, j'ai quelque chose à dire sur le sujet. Les articles bitsquid, qui parlent de compression quaternion, suggèrent de compresser le quaternion en 32 bits en utilisant environ 10 bits de données pour chaque composant quaternion. C'est exactement ce que fait Quant, car il est basé sur des publications bitsquid. À mon avis, une telle compression est trop importante et dans mes tests, elle a provoqué de fortes secousses. Peut-être que les auteurs ont utilisé des hiérarchies moins profondes du personnage, mais si vous multipliez les quaternions de plus de 15 de mes exemples d'animation, l'erreur combinée s'avère assez grave. À mon avis, le minimum absolu de précision est de 48 bits par quaternion.

Réduction des effectifs due à l'échantillonnage


Avant de commencer à considérer les différentes méthodes de compression et la disposition des enregistrements, voyons quel type de compression nous obtenons si nous appliquons simplement la discrétisation dans le circuit d'origine. Nous utiliserons le même exemple que précédemment (un squelette de 100 os), donc si nous utilisons 48 bits (3 x 16 bits) par quaternion, 48 bits (3 × 16) pour se déplacer et 16 bits pour évoluer, puis au total pour la conversion nous avons besoin de 14 octets au lieu de 32 octets. C'est 43,75% de la taille d'origine. Autrement dit, pour 1 seconde d'animation avec une fréquence de 30 images par seconde, nous avons réduit le volume d'environ 94 Ko à environ 41 Ko.

Ce n'est pas mal du tout, la discrétisation est une opération relativement peu coûteuse, donc cela n'affectera pas trop le temps de déballage. Nous avons trouvé un bon point de départ pour le départ, et dans certains cas, cela sera même suffisant pour implémenter des animations dans le budget des ressources et assurer une excellente qualité et performance.

Compression d'enregistrement


Tout devient très compliqué ici, surtout lorsque les développeurs commencent à essayer des techniques telles que la réduction de l'image clé, l'ajustement de courbe, etc. À ce stade également, nous commençons vraiment à réduire la qualité des animations.

Dans presque toutes ces décisions, il est supposé que les caractéristiques de chaque os (rotation, déplacement et échelle) sont stockées dans un enregistrement distinct. Par conséquent, nous pouvons inverser le circuit, comme je l'ai montré plus tôt:


Sauvegarde des données des os sous forme d'enregistrements

Ici, nous enregistrons simplement tous les enregistrements de manière séquentielle, mais nous pouvons également regrouper tous les enregistrements de rotations, de déplacements et d'échelles. L'idée de base est que nous passons du stockage des données de chaque pose au stockage des enregistrements.

Cela fait, nous pouvons utiliser d'autres moyens pour réduire davantage la mémoire occupée. La première consiste à commencer à supprimer des images. Remarque: cela ne nécessite pas de format d'enregistrement et cette méthode peut être appliquée dans le schéma précédent. Cette méthode fonctionne, mais conduit à la perte de petits mouvements dans l'animation, car nous supprimons la plupart des données. Cette technique était activement utilisée sur la PS3, et parfois nous devions nous pencher sur des fréquences d'échantillonnage incroyablement basses, par exemple, jusqu'à 7 images par seconde (généralement pour des animations peu importantes). J'ai de mauvais souvenirs de cela, en tant que programmeur d'animation, je vois clairement les détails perdus et l'expressivité, mais si vous regardez du point de vue du programmeur système, nous pouvons dire que l'animation est "presque" la même, car en général le mouvement est préservé, mais en même temps nous économiser beaucoup de mémoire.

Oublions cette approche (à mon avis, elle est trop destructrice) et considérons d'autres options possibles. Une autre approche populaire consiste à créer une courbe pour chaque enregistrement et à réduire les images clés sur la courbe, c'est-à-dire suppression des images clés en double. Du point de vue des animations de jeu, avec cette approche, les enregistrements de mouvement et d'échelle sont parfaitement compressés, parfois réduits à une seule image clé. Cette solution est non destructive, mais nécessite un déballage, car chaque fois que nous avons besoin d'obtenir la transformation, nous devons calculer la courbe, car nous ne pouvons plus simplement accéder aux données en mémoire. La situation peut être un peu améliorée si vous calculez des animations dans une seule direction.et stocker l'état de l'échantillonneur de chaque animation pour chaque os (c'est-à-dire d'où obtenir le calcul de la courbe), mais vous devez payer pour cela avec une augmentation de la mémoire et une augmentation significative de la complexité du code. Dans les systèmes d'animation modernes, nous ne jouons souvent pas d'animations du début à la fin. Souvent à certains décalages temporels, ils effectuent des transitions vers de nouvelles animations grâce à des choses comme le mélange synchronisé ou la correspondance de phase. Souvent, nous échantillonnons des poses individuelles mais pas consécutives pour implémenter des choses comme mélanger viser / regarder un objet, et souvent les animations sont jouées dans l'ordre inverse. Par conséquent, je ne recommande pas d'utiliser une telle solution, elle ne vaut tout simplement pas la peine causée par la complexité et les bogues potentiels.

Il y a aussi le concept de supprimer non seulement les clés identiques sur les courbes, mais aussi de spécifier un seuil auquel les clés similaires sont supprimées; cela conduit au fait que l'animation devient plus atténuée, similaire à la méthode de suppression d'images, car le résultat final est le même en termes de données. Des schémas de compression d'animation sont souvent utilisés, dans lesquels des paramètres de compression sont définis pour chaque enregistrement, et les animateurs sont constamment tourmentés par ces valeurs, essayant de maintenir la qualité et de réduire la taille en même temps. Il s'agit d'un flux de travail douloureux et stressant, mais il est nécessaire si vous travaillez avec la mémoire limitée des anciennes générations de consoles. Heureusement, nous avons aujourd'hui un budget mémoire important et nous n'avons pas besoin de choses aussi terribles.

Tous ces aspects sont dévoilés dans les articles de Riot / BitSquid et Nicholas (voir les liens au début de mon article). Je n'en parlerai pas en détail. Au lieu de cela, je vais parler de ce que j'ai décidé de compresser les enregistrements ...

J'ai ... décidé de ne pas compresser les enregistrements.

Avant de commencer à agiter, laissez-moi vous expliquer ...

Lorsque je sauvegarde les données dans les enregistrements, je stocke les données de rotation pour toutes les images. En ce qui concerne le mouvement et l'échelle, je vérifie si le mouvement et l'échelle sont statiques pendant la compression, et si c'est le cas, je n'enregistre qu'une seule valeur par enregistrement. Autrement dit, si l'enregistrement se déplace le long de X, mais pas le long de Y et Z, alors je sauvegarde toutes les valeurs de déplacement de l'enregistrement le long de X, mais une seule valeur de déplacement de l'enregistrement le long de Y et Z.

Cette situation se produit pour la plupart des os dans environ 95% de nos animations, donc à la fin, nous pouvons réduire considérablement la mémoire occupée, absolument sans perdre en qualité. Cela nécessite un travail du point de vue de la création de contenu (DCC): nous ne voulons pas que les os aient de légers mouvements et zooms dans le flux de travail de création d'animation, mais un tel avantage vaut le coût supplémentaire.

Dans notre exemple d'animation, il n'y a que deux enregistrements avec déplacement et aucun enregistrement avec échelle. Ensuite, pendant 1 seconde d'animation, le volume de données passe de 41 Ko à 18,6 Ko (soit jusqu'à 20% du volume des données d'origine). La situation devient encore meilleure avec l'augmentation de la durée de l'animation, nous dépensons des ressources uniquement pour l'enregistrement des virages et des mouvements dynamiques, et le coût des enregistrements statiques reste constant, ce qui économise davantage dans les longues animations. Et nous n'avons pas à subir de perte de qualité causée par l'échantillonnage.

Avec toutes ces informations à l'esprit, mon schéma de données final ressemble à ceci:


Un exemple de schéma de données d'animation compressé (3 images par enregistrement)

De plus, je sauvegarde le décalage dans le bloc de données pour démarrer les données de chaque os. Cela est nécessaire car parfois nous devons échantillonner des données pour un seul os sans lire la pose entière. Cela nous permet d'accéder rapidement aux données d'enregistrement.

En plus des données d'animation stockées dans un bloc de mémoire, j'ai également des options de compression pour chaque enregistrement:


Exemple de paramètres de compression pour les enregistrements de mon moteur Kruger

Ces paramètres stockent toutes les données dont j'ai besoin pour décoder les valeurs échantillonnées de chaque enregistrement. Ils surveillent également la statique des enregistrements afin que je sache comment gérer les données compressées lorsque je tombe sur un enregistrement statique lors de l'échantillonnage.

Vous pouvez également remarquer que la discrétisation de chaque enregistrement est individuelle: pendant la compression, je surveille les valeurs minimales et maximales de chaque caractéristique (par exemple, le long du X) de chaque enregistrement pour garantir que les données sont discrétisées dans l'intervalle minimum / maximum et maintenir une précision maximale. Je ne pense pas qu'il soit généralement possible de créer des intervalles d'échantillonnage globaux sans détruire vos données (lorsque les valeurs sont en dehors de l'intervalle) et sans commettre d'erreurs significatives.

Quoi qu'il en soit, voici un bref résumé de mes stupides tentatives pour implémenter la compression d'animation: au final, j'utilise presque la compression.

All Articles