GPU Bound. Comment tout transférer sur la carte vidéo et un peu plus. Des animations

Il était une fois un événement énorme quand une unité multitexture ou une transformation matérielle et un éclairage (T&L) sont apparus sur le GPU. Le réglage de la fonction fixe Pipeline était un chamanisme magique. Et ceux qui savaient comment activer et utiliser les fonctionnalités avancées de puces spécifiques via les hacks de l'API D3D9, se considéraient comme ayant appris le Zen. Mais le temps a passé, des shaders sont apparus. Au début, fortement limité à la fois en fonctionnalité et en longueur. De plus en plus de fonctionnalités, plus d'instructions, plus de vitesse. L'informatique (CUDA, OpenCL, DirectCompute) est apparue et l'étendue des capacités des cartes vidéo a commencé à s'étendre rapidement.

Dans cette série d'articles (espérons-le), j'essaierai d'expliquer et de montrer comment «inhabituellement» vous pouvez appliquer les capacités du GPU moderne lors du développement de jeux, en plus des effets graphiques. La première partie sera consacrée au système d'animation. Tout ce qui est décrit est basé sur une expérience pratique, mis en œuvre et fonctionne dans des projets de jeu réels.

Oooo, encore une fois l'animation. À ce sujet, cent fois déjà écrit et décrit. Qu'est-ce qui est si compliqué? Nous emballons la matrice des os dans le tampon / la texture et l'utilisons pour le skinning dans le vertex shader. Cela a été décrit dans les GPU Gems 3 (Chapitre 2. Rendu de foule animé) . Et implémenté dans la récente présentation Unite Tech . Est-ce possible d'une autre manière?

Technodemka de Unity


Beaucoup de battage médiatique, mais est-ce vraiment cool? Il y a un article sur le hub qui décrit en détail comment l'animation squelettique est faite et fonctionne dans cette techno-démo. Le travail parallèle est tout bon, nous ne les considérons pas. Mais nous devons savoir quoi et comment là-bas, en termes de rendu.

Dans une bataille à grande échelle, deux armées se battent, chacune composée d'un type d'unité. Squelettes à gauche, chevaliers à droite. La variété est moyenne. Chaque unité se compose de 3 LOD (~ 300, ~ 1000, ~ 4000 sommets chacun), et seulement 2 os affectent le sommet. Le système d'animation ne comprend que 7 animations pour chaque type d'unité (je rappelle qu'il y en a déjà 2). Les animations ne se fondent pas, mais passent discrètement du code simple qui est exécuté dans job'ax, qui est souligné dans la présentation. Il n'y a pas de machine d'état. Lorsque nous avons deux types de maillage, vous pouvez dessiner toute la foule en deux appels de tirage instanciés. L'animation squelettique, comme je l'ai déjà écrit, est basée sur la technologie décrite en 2009.
Innovant? Hmm ... une percée? Hum ... Convient aux jeux modernes? Eh bien, peut-être, le rapport FPS au nombre d'unités se vante.

Les principaux inconvénients de cette approche (pré-matrice dans les textures):

  1. Dépendant de la fréquence d'images. Recherché deux fois plus d'images d'animation - donnez deux fois plus de mémoire.
  2. Manque d'animations de mélange. Vous pouvez les faire, bien sûr, mais dans le shader de peau, un désordre complexe se formera à partir de la logique de mélange.
  3. Absence de liaison avec la machine d'état Unity Animator. Un outil pratique pour ajuster le comportement du personnage, qui peut être connecté à n'importe quel système de skinning, mais dans notre cas, en raison du point 2, tout devient très difficile (imaginez comment mélanger le BlendTree imbriqué).

GPAS


Système d'animation alimenté par GPU. Le nom vient d'apparaître.
Le nouveau système d'animation avait plusieurs exigences:

  1. Travaillez vite (enfin, c'est compréhensible). Vous devez animer des dizaines de milliers d'unités différentes.
  2. Soyez un analogue complet (ou presque) du système d'animation Unity. Si l'animation ressemble à ceci, alors dans le nouveau système, elle devrait être exactement la même. Possibilité de basculer entre les systèmes CPU et GPU intégrés. Ceci est souvent nécessaire pour le débogage. Lorsque les animations sont «boguées», en passant à l'animateur classique, vous pouvez comprendre: ce sont les défauts du nouveau système, ou la machine d'état / l'animation elle-même.
  3. Toutes les animations sont personnalisables dans Unity Animator. Un outil pratique, testé et surtout prêt à l'emploi. Nous construirons des vélos ailleurs.

Repensons la préparation et la cuisson des animations. Nous n'utiliserons pas de matrices. Les cartes vidéo modernes fonctionnent bien avec les boucles, prennent en charge nativement int en plus de float, nous allons donc travailler avec des images clés comme sur un CPU.

Regardons un exemple d'animation dans la visionneuse d'animation:



On peut voir que les images clés sont définies séparément pour la position, l'échelle et la rotation. Pour certains os, vous en avez besoin de beaucoup, pour certains seulement, et pour les os qui ne sont pas animés séparément, seules les images clés initiales et finales sont définies.

Position - Vector3, quaternion - Vector4, échelle - Vector3. La structure des images clés aura une chose en commun (pour simplifier), nous avons donc besoin de 4 flotteurs pour s'adapter à l'un des types ci-dessus. Nous avons également besoin d'InTangent et OutTangent pour l'interpolation correcte entre les images clés en fonction de la courbure. Oh oui, et le temps normalisé n'oublie pas:

struct KeyFrame { float4 v; float4 inTan, outTan; float time; }; 

Pour obtenir toutes les images clés, utilisez AnimationUtility.GetEditorCurve ().
De plus, nous devons nous rappeler les noms des os, car il sera nécessaire de remapper les os de l'animation dans les os du squelette (et ils peuvent ne pas coïncider) au stade de la préparation des données GPU.

En remplissant les tampons linéaires avec des tableaux d'images clés, nous nous souviendrons de leurs décalages afin de trouver ceux qui se rapportent à l'animation dont nous avons besoin.

Maintenant intéressant. Animation squelette GPU.

Nous préparons un grand ("nombre de squelettes animés" X "nombre d'os dans le squelette" X "coefficient empirique du nombre maximum de mélanges d'animation"). Nous y stockons la position, la rotation et l'échelle de l'os au moment de l'animation. Et pour tous les os animés planifiés dans ce cadre, exécutez le calcul shader. Chaque fil anime son os.

Chaque image clé, quelle que soit sa taille (traduction, rotation, échelle), est interpolée exactement de la même manière (recherche par recherche linéaire, pardonnez-moi Knuth):

 void InterpolateKeyFrame(inout float4 rv, int startIdx, int endIdx, float t) { for (int i = startIdx; i < endIdx; ++i) { KeyFrame k0 = keyFrames[i + 0]; KeyFrame k1 = keyFrames[i + 1]; float lerpFactor = (t - k0.time) / (k1.time - k0.time); if (lerpFactor < 0 || lerpFactor > 1) continue; rv = CurveInterpoate(k0, k1, lerpFactor); break; } } 

La courbe est une courbe de Bézier cubique, donc la fonction d'interpolation sera la suivante:

 float4 CurveInterpoate(KeyFrame v0, KeyFrame v1, float t) { float dt = v1.time - v0.time; float4 m0 = v0.outTan * dt; float4 m1 = v1.inTan * dt; float t2 = t * t; float t3 = t2 * t; float a = 2 * t3 - 3 * t2 + 1; float b = t3 - 2 * t2 + t; float c = t3 - t2; float d = -2 * t3 + 3 * t2; float4 rv = a * v0.v + b * m0 + c * m1 + d * v1.v; return rv; } 

La posture locale (TRS) de l'os a été calculée. Ensuite, avec un shader de calcul séparé, nous fusionnons toutes les animations nécessaires pour cet os. Pour ce faire, nous avons un tampon avec des indices d'animation et les poids de chaque animation dans le mélange final. Nous obtenons ces informations de la machine d'état. La situation de BlendTree à l'intérieur de BlendTree est résolue comme suit. Par exemple, il y a un arbre:



BlendTree Walk aura un poids de 0,35, Run - 0,65. En conséquence, la position finale des os doit être déterminée par 4 animations: Walk1, Walk2, Run1 et Run2. Leurs poids auront des valeurs (0,35 * 0,92, 0,35 * 0,08, 0,65 * 0,92, 0,65 * 0,08) = (0,322, 0,028, 0,598, 0,052), respectivement. Il convient de noter que la somme des poids doit toujours être égale à un, sinon des bugs magiques sont fournis.

Le «cœur» de la fonction de mélange:

 float bw = animDef.blendWeight; BoneXForm boneToBlend = animatedBones[srcBoneIndex]; float4 q = boneToBlend.quat; float3 t = boneToBlend.translate; float3 s = boneToBlend.scale; if (dot(resultBone.quat, q) < 0) q = -q; resultBone.translate += t * bw; resultBone.quat += q * bw; resultBone.scale += s * bw; 

Vous pouvez maintenant traduire en matrice de transformation. Arrêter A propos de la hiérarchie des os complètement oubliée.
Sur la base des données du squelette, nous construisons un tableau d'indices, où la cellule avec l'indice osseux contient l'indice de son parent. En racine, écrivez -1.

Un exemple:



 float4x4 animMat = IdentityMatrix(); float4x4 mat = initialPoses[boneId]; while (boneId >= 0) { BoneXForm b = blendedBones[boneId]; float4x4 xform = MakeTransformMatrix(b.translate, b.quat, b.scale); animMat = mul(animMat, xform); boneId = bonesHierarchyIndices[boneId]; } mat = mul(mat, animMat); resultSkeletons[id] = mat; 

Voici, en principe, tous les principaux points du rendu et du mélange des animations.

GPSM


Machine d'état alimentée par GPU (vous l'avez deviné). Le système d'animation décrit ci-dessus fonctionnerait parfaitement avec la machine d'état d'animation Unity, mais tous les efforts seraient alors inutiles. Avec la possibilité de calculer des dizaines (sinon des centaines) de milliers d'animations par image, UnityAnimator ne retirera pas des milliers de machines d'état fonctionnant simultanément. Hmm ...
Qu'est-ce qu'une machine d'état dans Unity? Il s'agit d'un système fermé d'états et de transitions, contrôlé par de simples propriétés numériques. Chaque machine d'état fonctionne indépendamment les unes des autres et pour le même ensemble de données d'entrée. Attends une minute. C'est une tâche idéale pour le GPU et les shaders de calcul!

Phase de cuisson

Tout d'abord, nous devons collecter et placer toutes les données de la machine d'état dans une structure conviviale GPU. Et cela: états (états), transitions (transitions) et paramètres (paramètres).
Toutes ces données sont placées dans des tampons linéaires et traitées par des index.
Chaque thread de calcul considère sa machine d'état. AnimatorController fournit une interface à toutes les structures de machine d'état interne nécessaires.

Les principales structures de la machine d'État:

 struct State { float speed; int firstTransition; int numTransitions; int animDefId; }; struct Transition { float exitTime; float duration; int sourceStateId; int targetStateId; int firstCondition; int endCondition; uint properties; }; struct StateData { int id; float timeInState; float animationLoop; }; struct TransitionData { int id; float timeInTransition; }; struct CurrentState { StateData srcState, dstState; TransitionData transition; }; struct AnimationDef { uint animId; int nextAnimInTree; int parameterIdx; float lengthInSec; uint numBones; uint loop; }; struct ParameterDef { float2 line0ab, line1ab; int runtimeParamId; int nextParameterId; }; struct Condition { int checkMode; int runtimeParamIndex; float referenceValue; }; 

  • State contient la vitesse à laquelle l'état est joué, et les indices des conditions de transition vers les autres selon la machine d'état.
  • La transition contient des indices d'état «de» et «à». Temps de transition, temps de sortie et lien vers un tableau de conditions pour entrer dans cet état.
  • CurrentState est un bloc de données d'exécution avec des données sur l'état actuel de la machine d'état.
  • AnimationDef contient une description de l'animation avec des liens vers d'autres qui lui sont liés par BlendTree.
  • ParameterDef est une description du paramètre qui contrôle le comportement des machines à états. Line0ab et Line1ab sont les coefficients de l'équation des lignes pour déterminer le poids de l'animation par la valeur du paramètre. D'ici:


  • Condition - spécification de la condition de comparaison de la valeur d'exécution du paramètre et de la valeur de référence.

Phase d'exécution

Le cycle principal de chaque machine d'état peut être affiché en utilisant l'algorithme suivant:



Il existe 4 types de paramètres dans Unity animator: float, int, bool et trigger (qui est bool). Nous les présenterons tous sous forme de flotteur. Lors de la configuration des conditions, il est possible de choisir l'un des six types de comparaison. Si == est égal à. IfNot == NotEqual. Nous n'utiliserons donc que 4. L'index d'opérateur est passé au champ checkMode de la structure Condition.

 for (int i = t.firstCondition; i < t.endCondition; ++i) { Condition c = allConditions[i]; float paramValue = runtimeParameters[c.runtimeParamIndex]; switch (c.checkMode) { case 3: if (paramValue < c.referenceValue) return false; case 4: if (paramValue > c.referenceValue) return false; case 6: if (abs(paramValue - c.referenceValue) > 0.001f) return false; case 7: if (abs(paramValue - c.referenceValue) < 0.001f) return false; } } return true; 

Pour démarrer la transition, toutes les conditions doivent être remplies. Les étiquettes de cas étranges sont simplement (int) AnimatorConditionMode. La logique d'interruption est une logique délicate pour interrompre et annuler les transitions.

Après avoir mis à jour l'état de la machine d'état et fait défiler les horodatages sur le cadre delta t, il est temps de préparer des données sur les animations à lire dans ce cadre et les poids correspondants. Cette étape est ignorée si le modèle d'unité n'est pas dans le cadre (Frustum abattu). Pourquoi devrions-nous envisager des animations de ce qui n'est pas visible? Nous passons en revue l'état source de l'arbre de mélange, en fonction de l'état de destination de l'arbre de mélange, nous en ajoutons toutes les animations et nous calculons les poids par le temps de transition normalisé de la source à la destination (temps passé en transition). Avec les données préparées, GPAS entre en jeu et compte les animations pour chaque entité animée du jeu.

Les paramètres de contrôle de l'unité proviennent de la logique de contrôle de l'unité. Par exemple, vous devez activer l'exécution, définir le paramètre CharSpeed ​​et une machine à états correctement configurée mélange en douceur les animations de transition de «marche» à «course».

Naturellement, l'analogie complète avec Unity Animator n'a pas fonctionné. Les principes de travail internes, s'ils ne sont pas décrits dans la documentation, ont dû être inversés et un analogue créé. Certaines fonctionnalités ne sont pas encore terminées (elles ne le sont peut-être pas). Par exemple, BlendType dans BlendTree ne prend en charge que 1D. Faire d'autres types, en principe, n'est pas difficile, il n'est pas nécessaire pour l'instant. Il n'y a pas d'événements d'animation, car il est nécessaire de faire une relecture avec le GPU, et la relecture «correcte» sera plusieurs images derrière, ce qui n'est pas toujours acceptable. Mais c'est aussi possible.

Rendu


Le rendu des unités se fait par instanciation. Selon SV_InstanceID, dans le vertex shader, nous obtenons la matrice de tous les os qui affectent le sommet et le transformons. Absolument rien d'inhabituel:

 float4 ApplySkin(float3 v, uint vertexID, uint instanceID) { BoneInfoPacked bip = boneInfos[vertexID]; BoneInfo bi = UnpackBoneInfo(bip); SkeletonInstance skelInst = skeletonInstances[instanceID]; int bonesOffset = skelInst.boneOffset; float4x4 animMat = 0; for (int i = 0; i < 4; ++i) { float bw = bi.boneWeights[i]; if (bw > 0) { uint boneId = bi.boneIDs[i]; float4x4 boneMat = boneMatrices[boneId + bonesOffset]; animMat += boneMat * bw; } } float4 rv = float4(v, 1); rv = mul(rv, animMat); return rv; } 

Résumé


Cette ferme fonctionne-t-elle rapidement? Évidemment plus lent que d'échantillonner la texture avec des matrices, mais je peux quand même montrer quelques chiffres (GTX 970).

Voici 50 000 machines d'état:



Voici 280 000 os animés:



Concevoir et déboguer tout cela est une vraie douleur. Un tas de tampons et de décalages. Un tas de composants et leurs interactions. Il y avait des moments où les mains tombaient lorsque vous vous frappiez la tête à propos d'un problème pendant plusieurs jours, mais vous ne pouvez pas trouver le problème. C'est particulièrement "sympa" quand tout fonctionne comme il se doit sur les données de test, mais dans une vraie situation de "combat", il n'y a pas de problème d'animation. Les écarts entre le fonctionnement des machines d'état Unity et les leurs ne sont pas non plus immédiatement visibles. En général, si vous décidez de faire un analogique pour vous, alors je ne vous envie pas. En fait, tout le développement sous le GPU est une telle chose à se plaindre.

PS Je veux jeter une pierre dans le jardin des développeurs d'Unite TechDemo. Ils ont un grand nombre de modèles identiques de ruines et de ponts sur scène, et ils n'ont en aucun cas optimisé leur rendu. Ils ont plutôt essayé en cochant «statique». Seulement maintenant, dans les index 16 bits, vous ne pouvez pas entasser beaucoup de géométrie (trois fois haha, 2017) et rien ne s'est assemblé, car les modèles sont fortement polygonaux. J'ai mis "Activer l'instanciation" pour tous les shaders, et décoché "Statique". Il n'y a pas eu de boost tangible, mais bon sang, vous faites une démo techno, vous vous battez pour chaque FPS. Vous ne pouvez pas chier comme ça.

Était
 *** Summary *** Draw calls: 2553 Dispatch calls: 0 API calls: 8378 Index/vertex bind calls: 2992 Constant bind calls: 648 Sampler bind calls: 395 Resource bind calls: 805 Shader set calls: 682 Blend set calls: 230 Depth/stencil set calls: 92 Rasterization set calls: 238 Resource update calls: 1017 Output set calls: 74 API:Draw/Dispatch call ratio: 3.28163 298 Textures - 1041.01 MB (1039.95 MB over 32x32), 42 RTs - 306.94 MB. Avg. tex dimension: 1811.77x1810.21 (2016.63x2038.98 over 32x32) 216 Buffers - 180.11 MB total 17.54 MB IBs 159.81 MB VBs. 1528.06 MB - Grand total GPU buffer + texture load. *** Draw Statistics *** Total calls: 2553, instanced: 2, indirect: 2 Instance counts: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: >=15: ******************************************************************************************************************************** (2) 


Est devenu
 *** Summary *** Draw calls: 1474 Dispatch calls: 0 API calls: 11106 Index/vertex bind calls: 3647 Constant bind calls: 1039 Sampler bind calls: 348 Resource bind calls: 718 Shader set calls: 686 Blend set calls: 230 Depth/stencil set calls: 110 Rasterization set calls: 258 Resource update calls: 1904 Output set calls: 74 API:Draw/Dispatch call ratio: 7.5346 298 Textures - 1041.01 MB (1039.95 MB over 32x32), 42 RTs - 306.94 MB. Avg. tex dimension: 1811.77x1810.21 (2016.63x2038.98 over 32x32) 427 Buffers - 93.30 MB total 9.81 MB IBs 80.51 MB VBs. 1441.25 MB - Grand total GPU buffer + texture load. *** Draw Statistics *** Total calls: 1474, instanced: 391, indirect: 2 Instance counts: 1: 2: ******************************************************************************************************************************** (104) 3: ************************************************* (40) 4: ********************** (18) 5: ****************************** (25) 6: ********************************************************************************************* (76) 7: *********************************** (29) 8: ************************************************** (41) 9: ********* (8) 10: ************** (12) 11: 12: ****** (5) 13: ******* (6) 14: ** (2) >=15: ****************************** (25) 


PPS À tout moment, les jeux étaient principalement liés au processeur, c'est-à-dire Le CPU n'a pas suivi le GPU. Trop de logique et de physique. En transférant une partie de la logique du jeu du CPU au GPU, nous déchargeons le premier et chargons le second, c'est-à-dire rendre la situation du GPU lié plus probable. D'où le titre de l'article.

Source: https://habr.com/ru/post/fr468549/


All Articles