Unity a récemment introduit ECS. Au cours de mes études, je me suis intéressé à la façon dont l'animation et l'ECS peuvent devenir amis. Et dans le processus de recherche, je suis tombé sur une technique intéressante que les gars de NORDVEUS ont utilisée dans leur démo pour le rapport Unite Austin 2017.
Unite Austin 2017 - Bataille massive dans l'univers de Spellsouls.
Le rapport contient de nombreuses solutions intéressantes, mais aujourd'hui nous discuterons de la préservation de l'animation squelettique dans la texture en vue de son application ultérieure.
Pourquoi de telles difficultés, demandez-vous?
Les gars de NORDVEUS ont simultanément peint à l'écran un grand nombre du même type d'objet animé: squelettes, épéistes. Dans le cas de l'utilisation de l'approche traditionnelle: SkinnedMeshRenderers et Animation \ Animator , entraînera une augmentation des appels de tirage et une charge supplémentaire sur le CPU pour le rendu de l'animation. Et pour résoudre ces problèmes, l'animation a été déplacée du côté du GPU, et plus précisément, du vertex shader.
J'étais très intéressé par l'approche et j'ai décidé de la comprendre plus en détail, et comme je n'ai pas trouvé d'articles sur ce sujet, je suis entré dans le code. En train d'étudier la question, cet article est né, et ma vision de résoudre ce problème.
Coupons donc l'éléphant en morceaux:
- Obtention de clés d'animation à partir de clips
- Enregistrement des données dans la texture
- Préparation de maille (maille)
- Shader
- Tout mettre ensemble
Obtention de clés d'animation à partir de clips d'animation
À partir des composants de SkinnedMeshRenderers, nous obtenons un tableau d'os et un maillage. Le composant Animation fournit une liste des animations disponibles. Donc, pour chaque clip, nous devons enregistrer la matrice de transformation image par image pour tous les os du maillage. En d'autres termes, nous conservons la pose du personnage par unité de temps.
Nous sélectionnons un tableau à deux dimensions dans lequel les données seront stockées. Dont une dimension a le nombre d'images multiplié par la longueur du clip en secondes. Un autre est le nombre total d'os dans le maillage:
var boneMatrices = new Matrix4x4[Mathf.CeilToInt(frameRate * clip.length), renderer.bones.Length];
Dans l'exemple suivant, nous modifions les images du clip un par un et enregistrons les matrices:
Les matrices sont 4 par 4, mais la dernière ligne ressemble toujours à (0, 0, 0, 1). Par conséquent, à des fins de légère optimisation, il peut être ignoré. Ce qui à son tour réduira le coût du transfert de données entre le processeur et la carte vidéo.
a00 a01 a02 a03 a10 a11 a12 a13 a20 a21 a22 a23 0 0 0 1
Enregistrement des données dans la texture
Pour calculer la taille de la texture, nous multiplions le nombre total d'images dans tous les clips d'animation par le nombre d'os et le nombre de lignes dans la matrice (nous avons convenu d'enregistrer les 3 premières lignes).
var dataSize = numberOfBones * numberOfKeyFrames * MATRIX_ROWS_COUNT);
Nous écrivons les données dans la texture. Pour chaque clip, nous enregistrons la matrice de transformation image par image. Le format des données est le suivant. Les clips sont enregistrés séquentiellement un par un et se composent d'un ensemble d'images. Qui à son tour se composent d'un ensemble d'os. Chaque os contient 3 rangées de matrice.
Clip0[Frame0[Bone0[row0,row1,row2]...BoneN[row0,row1,row2].]...FramM[bone0[row0,row1,row2]...ClipK[...]
Voici le code de sauvegarde:
var textureColor = new Color[texture.width * texture.height]; var clipOffset = 0; for (var clipIndex = 0; clipIndex < sampledBoneMatrices.Count; clipIndex++) { var framesCount = sampledBoneMatrices[clipIndex].GetLength(0); for (var keyframeIndex = 0; keyframeIndex < framesCount; keyframeIndex++) { var frameOffset = keyframeIndex * numberOfBones * 3; for (var boneIndex = 0; boneIndex < numberOfBones; boneIndex++) { var index = clipOffset + frameOffset + boneIndex * 3; var matrix = sampledBoneMatrices[clipIndex][keyframeIndex, boneIndex]; textureColor[index + 0] = matrix.GetRow(0); textureColor[index + 1] = matrix.GetRow(1); textureColor[index + 2] = matrix.GetRow(2); } } } texture.SetPixels(textureColor); texture.Apply(false, false);
Préparation de maille (maille)
Ajoutez un ensemble supplémentaire de coordonnées de texture auxquelles nous enregistrons pour chaque sommet les indices osseux associés et le poids de l'influence de l'os sur ce sommet.
Unity fournit une structure de données dans laquelle jusqu'à 4 os sont possibles pour un sommet. Vous trouverez ci-dessous le code pour écrire ces données sur uv. Nous enregistrons les indices osseux dans UV1, les poids dans UV2.
var boneWeights = mesh.boneWeights; var boneIds = new List<Vector4>(mesh.vertexCount); var boneInfluences = new List<Vector4>(mesh.vertexCount); for (var i = 0; i < mesh.vertexCount; i++) { boneIds.Add(new Vector4(bw.boneIndex0, bw.boneIndex1, bw.boneIndex2, bw.boneIndex3); boneInfluences.Add(new Vector4(bw.weight0, bw.weight1, bw.weight2, bw.weight3)); } mesh.SetUVs(1, boneIds); mesh.SetUVs(2, boneInfluences);
Shader
La tâche principale du shader est de trouver la matrice de transformation de l'os associé au sommet et de multiplier les coordonnées du sommet par cette matrice. Pour ce faire, nous avons besoin d'un ensemble supplémentaire de coordonnées avec des indices et des poids osseux. Nous avons également besoin de l'index de la trame courante, il changera avec le temps et sera transmis depuis le CPU.
Nous avons donc obtenu l'indice de la première ligne de la matrice, l'indice des deuxième et troisième sera respectivement +1, +2. Il reste à traduire l'index unidimensionnel en coordonnées normalisées de la texture et pour cela nous avons besoin de la taille de la texture.
inline float4 IndexToUV(int index, float2 size) { return float4(((float)((int)(index % size.x)) + 0.5) / size.x, ((float)((int)(index / size.x)) + 0.5) / size.y, 0, 0); }
Après avoir soustrait les lignes, nous collectons la matrice sans oublier la dernière ligne, qui est toujours égale à (0, 0, 0, 1).
float4 row0 = tex2Dlod(frameOffset, IndexToUV(index + 0, animationTextureSize)); float4 row1 = tex2Dlod(frameOffset, IndexToUV(index + 1, animationTextureSize)); float4 row2 = tex2Dlod(frameOffset, IndexToUV(index + 2, animationTextureSize)); float4 row3 = float4(0, 0, 0, 1); return float4x4(row0, row1, row2, row3);
En même temps, plusieurs os peuvent affecter un sommet à la fois. La matrice résultante sera la somme de toutes les matrices affectant le sommet multipliée par le poids de leur influence.
float4x4 m0 = CreateMatrix(frameOffset, bones.x) * boneInfluences.x; float4x4 m1 = CreateMatrix(frameOffset, bones.y) * boneInfluences.y; float4x4 m2 = CreateMatrix(frameOffset, bones.z) * boneInfluences.z; float4x4 m3 = CreateMatrix(frameOffset, bones.w) * boneInfluences.w; return m0 + m1 + m2 + m3;
Après avoir reçu la matrice, nous la multiplions par les coordonnées du sommet. Par conséquent, tous les sommets seront déplacés vers la pose du personnage, ce qui correspond à l'image actuelle. En changeant le cadre, nous animerons le personnage.
Tout mettre ensemble
Pour afficher les objets, nous utiliserons Graphics.DrawMeshInstancedIndirect, dans lequel nous transférerons le maillage et le matériau préparés. De plus, dans le matériau, nous devons passer la texture avec des animations à la taille de la texture et un tableau avec des pointeurs vers le cadre pour chaque objet à l'heure actuelle. Comme information supplémentaire, nous passons la position de chaque objet et la rotation. Pour savoir comment modifier la position et la rotation du côté du shader, consultez [l'article] .
Dans la méthode Update, augmentez le temps écoulé depuis le début de l'animation sur Time.deltaTime.
Afin de calculer l'indice d'image, nous devons normaliser le temps en le divisant par la longueur du clip. Par conséquent, l'index d'image dans le clip sera le produit du temps normalisé par le nombre d'images. Et l'indice d'image dans la texture sera la somme du décalage du début du clip actuel et du produit de l'image actuelle par la quantité de données stockées dans cette image.
var offset = clipStart + frameIndex * bonesCount * 3.0f
C'est probablement tout après avoir transmis toutes les données au shader, nous appelons Graphics.DrawMeshInstancedIndirect avec le maillage et le matériau préparés.
Conclusions
Le test de cette technique sur une machine avec une carte graphique 1050 a montré une augmentation des performances d'environ 2 fois.

Animation de 4000 objets du même type sur la CPU

Animation de 8000 objets du même type sur le GPU
Dans le même temps, tester cette scène sur un macbook pro 15 avec une carte graphique intégrée montre le résultat inverse. Le GPU perd sans vergogne (environ 2-3 fois), ce qui n'est pas surprenant.
L'animation sur la carte vidéo est un autre outil qui peut être utilisé dans votre application. Mais comme tous les outils, il doit être utilisé à bon escient et hors de propos.
Les références
[Code de projet GitHub]Merci de votre attention.
PS: Je suis nouveau sur Unity et je ne connais pas toutes les subtilités, l'article peut contenir des inexactitudes. J'espère les corriger avec votre aide et mieux comprendre le sujet.