Récemment, j'ai dû résoudre un problème qui est assez courant dans de nombreux jeux avec une vue de dessus: pour afficher à l'écran tout un tas de barres de santé ennemies. Quelque chose comme ça:
Évidemment, je voulais le faire le plus efficacement possible, de préférence en un seul appel. Comme d'habitude, avant de commencer le travail, j'ai fait une petite recherche en ligne sur les décisions des autres et les résultats étaient très différents.
Je ne ferai honte à personne pour le code, mais il suffit de dire que certaines des solutions n'étaient pas entièrement brillantes, par exemple, quelqu'un a ajouté un objet Canvas à chaque ennemi (ce qui est très inefficace).
En conséquence, la méthode à laquelle je suis arrivé est légèrement différente de tout ce que j'ai vu dans d'autres, et n'utilise aucune classe d'interface utilisateur (y compris Canvas), j'ai donc décidé de la documenter pour le public. Et pour ceux qui veulent apprendre le code source, je l'ai posté
sur Github .
Pourquoi ne pas utiliser Canvas?
Une toile pour chaque ennemi est évidemment une mauvaise décision, mais je pourrais utiliser une toile commune pour tous les ennemis; un seul canevas entraînerait également le rendu des lots d'appels.
Cependant, je n'aime pas la quantité de travail effectuée dans chaque cadre lié à cette approche. Si vous utilisez Canvas, dans chaque cadre, vous devez effectuer les opérations suivantes:
- Déterminez quels ennemis sont à l'écran et sélectionnez chacun d'eux dans la bande d'interface utilisateur du pool.
- Projetez la position de l'ennemi dans la caméra pour positionner la bande.
- Redimensionnez la partie "remplissage" de la bande, probablement comme Image.
- Très susceptible de changer la taille des bandes en fonction du type d'ennemis; par exemple, les grands ennemis devraient avoir de larges bandes pour ne pas avoir l'air idiot.
Quoi qu'il en soit, tout cela contaminerait les tampons de géométrie Canvas et conduirait à une reconstruction de toutes les données de vertex dans le processeur. Je ne voulais pas que tout cela soit fait pour un élément aussi simple.
En bref sur ma décision
Une brève description de mon processus de travail:
- Nous attachons des objets de bandes d'énergie aux ennemis en 3D.
- Cela vous permet d'organiser et de couper automatiquement les bandes.
- La position / taille de la bande peut être ajustée en fonction du type d'ennemi.
- Nous dirigerons les bandes vers la caméra dans le code en utilisant la transformation, qui est toujours là.
- Le shader garantit qu'ils restituent toujours au-dessus de tout.
- Nous utilisons Instancing pour rendre toutes les bandes en un seul appel de tirage.
- Nous utilisons des coordonnées UV procédurales simples pour afficher le niveau de plénitude de la bande.
Examinons maintenant la solution plus en détail.
Qu'est-ce que l'instanciation?
En travaillant avec des graphiques, la technique standard est utilisée depuis longtemps: plusieurs objets sont combinés ensemble afin d'avoir des données de sommet et des matériaux communs et ils peuvent être rendus en un seul appel de dessin. C'est exactement ce dont nous avons besoin, car chaque appel de tirage est une charge supplémentaire sur le CPU et le GPU. Au lieu de faire un seul appel de dessin pour chaque objet, nous les rendons tous en même temps et utilisons un shader pour ajouter de la variabilité à chaque copie.
Vous pouvez le faire manuellement en dupliquant les données du sommet du maillage X fois dans un tampon, où X est le nombre maximal de copies pouvant être rendues, puis en utilisant le tableau de paramètres de shader pour convertir / colorer / varier chaque copie. Chaque copie doit stocker des informations sur son instance numérotée afin d'utiliser cette valeur comme index du tableau. Ensuite, nous pouvons utiliser l'appel de rendu indexé, qui ordonne de «rendre uniquement à N», où N est le nombre d'instances
réellement nécessaires dans la trame actuelle, inférieur au nombre maximal de X.
La plupart des API modernes ont déjà du code pour cela, vous n'avez donc pas besoin de le faire manuellement. Cette opération est appelée "Instanciation"; en fait, il automatise le processus décrit ci-dessus avec des restrictions prédéfinies.
Le moteur Unity prend également en charge l'instanciation , il possède sa propre API et un ensemble de macros de shader qui aident à sa mise en œuvre. Il utilise certaines hypothèses, par exemple, que chaque instance nécessite une transformation 3D complète. À strictement parler, pour les bandes 2D, ce n'est pas complètement nécessaire - nous pouvons faire des simplifications, mais comme elles le sont, nous les utiliserons. Cela simplifiera notre shader et fournira également la possibilité d'utiliser des indicateurs 3D, par exemple des cercles ou des arcs.
Classe endommageable
Nos ennemis auront un composant appelé
Damageable
, leur donnant de la santé et leur permettant de subir des dégâts de collisions. Dans notre exemple, c'est assez simple:
public class Damageable : MonoBehaviour { public int MaxHealth; public float DamageForceThreshold = 1f; public float DamageForceScale = 5f; public int CurrentHealth { get; private set; } private void Start() { CurrentHealth = MaxHealth; } private void OnCollisionEnter(Collision other) {
Objet HealthBar: Position / Turn
L'objet de barre de santé est très simple: en fait, ce n'est qu'un Quad attaché à l'ennemi.

Nous utilisons l'
échelle de cet objet pour rendre la bande longue et mince et la placer directement au-dessus de l'ennemi. Ne vous inquiétez pas de sa rotation, nous le corrigerons en utilisant le code attaché à l'objet dans
HealthBar.cs
:
private void AlignCamera() { if (mainCamera != null) { var camXform = mainCamera.transform; var forward = transform.position - camXform.position; forward.Normalize(); var up = Vector3.Cross(forward, camXform.right); transform.rotation = Quaternion.LookRotation(forward, up); } }
Ce code dirige toujours le quad vers la caméra. Nous
pouvons effectuer un redimensionnement et une rotation dans le shader, mais je les implémente ici pour deux raisons.
Tout d'abord, l'instanciation d'Unity utilise toujours la transformation complète de chaque objet, et puisque nous transférons toutes les données de toute façon, vous pouvez l'utiliser. Deuxièmement, le réglage de l'échelle / rotation ici garantit que le parallélogramme de délimitation pour rogner la bande sera toujours vrai. Si nous avons fait de la tâche de taille et de rotation la responsabilité du shader, alors Unity pourrait tronquer les bandes qui devraient être visibles lorsqu'elles sont proches des bords de l'écran, car la taille et la rotation de leur parallélogramme de délimitation ne correspondront pas à ce que nous allons rendre. Bien sûr, nous pourrions implémenter notre propre méthode de troncature, mais il est généralement préférable d'utiliser ce que nous avons si possible (le code Unity est natif et a accès à plus de données spatiales que nous).
Je vais expliquer comment la bande est rendue après avoir regardé le shader.
Shader HealthBar
Dans cette version, nous allons créer une simple bande rouge-vert classique.
J'utilise une texture 2x1 avec un pixel vert à gauche et un rouge à droite. Naturellement, j'ai désactivé le mipmapping, le filtrage et la compression, et défini le paramètre du mode d'adressage sur Clamp, ce qui signifie que les pixels de notre bande seront toujours parfaitement verts ou rouges et ne se répandront pas sur les bords. Cela nous permettra de modifier les coordonnées de la texture dans le shader pour décaler la ligne séparant les pixels rouges et verts de haut en bas de la bande.
(Puisqu'il n'y a que deux couleurs ici, je pourrais simplement utiliser la fonction de pas dans le shader pour revenir au point de l'une ou de l'autre. Cependant, cette méthode est pratique car vous pouvez utiliser une texture plus complexe si vous le souhaitez, et cela fonctionnera de manière similaire pendant la transition. texture moyenne.)Tout d'abord, nous déclarerons les propriétés dont nous avons besoin:
Shader "UI/HealthBar" { Properties { _MainTex ("Texture", 2D) = "white" {} _Fill ("Fill", float) = 0 }
_MainTex
est une texture rouge-vert, et
_Fill
est une valeur de 0 à 1, où 1 est une santé complète.
Ensuite, nous devons ordonner le rendu de la bande dans la file d'attente de superposition, ce qui signifie ignorer toute la profondeur de la scène et effectuer le rendu par-dessus tout:
SubShader { Tags { "Queue"="Overlay" } Pass { ZTest Off
La partie suivante est le code du shader lui-même. Nous écrivons un shader sans éclairage (non éclairé), nous n'avons donc pas à nous soucier de l'intégration avec divers shaders de surface Unity, ce ne sont que quelques shaders de vertex / fragment. Tout d'abord, écrivez bootstrap:
CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_instancing #include "UnityCG.cginc"
Pour la plupart, il s'agit d'un amorçage standard, à l'exception de
#pragma multi_compile_instancing
, qui indique au compilateur Unity ce qui doit être compilé pour Instancing.
La structure des sommets doit inclure des données d'instance, nous allons donc procéder comme suit:
struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID };
Nous devons également spécifier ce qui sera exactement dans les données des instances, en plus des processus Unity (transform) pour nous:
UNITY_INSTANCING_BUFFER_START(Props) UNITY_DEFINE_INSTANCED_PROP(float, _Fill) UNITY_INSTANCING_BUFFER_END(Props)
Nous signalons donc que Unity doit créer un tampon appelé «Props» pour stocker les données de chaque instance, et à l'intérieur, nous utiliserons un flottant par instance pour une propriété appelée
_Fill
.
Vous pouvez utiliser plusieurs tampons; cela vaut la peine si vous avez plusieurs propriétés mises à jour à différentes fréquences; en les divisant, vous pouvez, par exemple, ne pas changer un tampon lorsque vous en changez un autre, ce qui est plus efficace. Mais nous n'en avons pas besoin.
Notre vertex shader fait presque entièrement le travail standard, car la taille, la position et la rotation sont déjà transférées pour se transformer. Ceci est implémenté en utilisant
UnityObjectToClipPos
, qui utilise automatiquement la transformation de chaque instance. On pourrait imaginer que sans instanciation, cela serait généralement une simple utilisation d'une seule propriété de matrice. mais lorsque vous utilisez l'instanciation à l'intérieur du moteur, il ressemble à un tableau de matrices, et Unity sélectionne indépendamment une matrice appropriée pour cette instance.
En outre, vous devez changer UV pour changer l'emplacement du point de transition du rouge au vert conformément à la propriété
_Fill
. Voici l'extrait de code correspondant:
UNITY_SETUP_INSTANCE_ID(v); float fill = UNITY_ACCESS_INSTANCED_PROP(Props, _Fill);
UNITY_SETUP_INSTANCE_ID
et
UNITY_ACCESS_INSTANCED_PROP
font toute la magie en accédant à la version correcte de la propriété
_Fill
partir du tampon constant pour cette instance.
On sait qu'à l'état normal, les coordonnées UV d'un quadrilatère couvrent tout l'intervalle de texture, et que la ligne de division de la bande est au milieu de la texture horizontalement. Par conséquent, de petits calculs mathématiques décalent horizontalement la bande vers la gauche ou la droite, et la valeur de serrage de la texture assure le remplissage de la partie restante.
Le fragment shader ne pourrait pas être plus simple car tout le travail a déjà été fait:
return tex2D(_MainTex, i.uv);
Le code de nuanceur de commentaires complet est disponible dans
le référentiel GitHub .
Matériel Healthbar
Ensuite, tout est simple - il suffit d'affecter à notre bande le matériau utilisé par ce shader. Il n'y a presque plus rien à faire, il suffit de sélectionner le shader souhaité dans la partie supérieure, d'attribuer une texture rouge-vert et, plus important encore, de
cocher la case «Activer l'instance GPU» .

Mise à jour de la propriété de remplissage HealthBar
Donc, nous avons l'objet barre de santé, le shader et le matériau à rendre, nous devons maintenant définir la propriété
_Fill
pour chaque instance. Nous faisons cela dans
HealthBar.cs
comme suit:
private void UpdateParams() { meshRenderer.GetPropertyBlock(matBlock); matBlock.SetFloat("_Fill", damageable.CurrentHealth / (float)damageable.MaxHealth); meshRenderer.SetPropertyBlock(matBlock); }
Nous transformons le
CurrentHealth
classe
CurrentHealth
en une valeur de 0 à 1, en le divisant par
MaxHealth
. Ensuite, nous le transmettons à la propriété
_Fill
aide de
MaterialPropertyBlock
.
Si vous n'avez pas utilisé
MaterialPropertyBlock
pour transférer des données vers des shaders, même sans instanciation, vous devez les étudier. Il n'est pas bien expliqué dans la documentation Unity, mais c'est le moyen le plus efficace pour transférer des données de chaque objet vers des shaders.
Dans notre cas, lorsque l'instanciation est utilisée, les valeurs de toutes les barres de santé sont regroupées dans un tampon constant afin qu'elles puissent être transférées ensemble et dessinées à la fois.
Il n'y a presque rien ici, sauf un passe-partout pour définir des variables, et le code est plutôt ennuyeux; voir
le référentiel GitHub pour plus
de détails.
Démo
Le
référentiel GitHub a une démo de test dans laquelle un tas de cubes bleus maléfiques sont détruits par des sphères rouges héroïques (hourra!), Prenant les dégâts affichés par les rayures décrites dans l'article. Démo écrite en Unity 2018.3.6f1.
L'effet de l'instanciation peut être observé de deux manières:
Panneau Statistiques
Après avoir cliqué sur Play, cliquez sur le bouton Stats au-dessus du panneau Game. Ici, vous pouvez voir combien d'appels de tirage sont enregistrés grâce à l'instanciation:

Après avoir lancé le jeu, vous pouvez cliquer sur le matériel HealthBar et
décocher la case «Activer l'instance GPU», après quoi le nombre d'appels enregistrés sera réduit à zéro.
Débogueur d'images
Après avoir lancé le jeu, allez dans Fenêtre> Analyse> Débogueur d'images, puis cliquez sur «Activer» dans la fenêtre qui apparaît.
En bas à gauche, vous verrez toutes les opérations de rendu effectuées. Notez que bien qu'il existe de nombreux défis distincts pour les ennemis et les obus (si vous le souhaitez, vous pouvez également implémenter l'instanciation pour eux). Si vous faites défiler vers le bas, vous verrez l'élément "Draw Health Mesh (instanced) Healthbar".
Cet appel unique rend toutes les bandes. Si vous cliquez sur cette opération, puis sur l'opération, vous verrez que toutes les bandes disparaissent, car elles sont dessinées en un seul appel. Si vous êtes dans le débogueur de trame, vous décochez la case Activer l'instance GPU du matériau, vous verrez qu'une ligne se transforme en plusieurs, et après avoir défini à nouveau l'indicateur en une.
Comment étendre ce système
Comme je l'ai déjà dit, puisque ces barres de santé sont de vrais objets, rien ne vous empêche de transformer de simples barres 2D en quelque chose de plus complexe. Ils peuvent être des demi-cercles sous les ennemis qui diminuent en arc ou des losanges en rotation au-dessus de leur tête. En utilisant la même approche, vous pouvez toujours les rendre tous en un seul appel.