Exemple de BatchRendererGroup : Obtenir un taux de rafraîchissement élevé même sur les appareils à bas prix

Dans ce billet, nous décrivons un petit exemple de jeu de tir qui anime et rend plusieurs objets interactifs. De nombreuses démonstrations sont conçues pour les PC haut de gamme, mais l'objectif ici est d'obtenir un taux de rafraîchissement élevé sur un téléphone économique en utilisant GLES 3.0. Cet exemple utilise BatchRendererGroup, le compilateur Burst et le système de travail C#. Il fonctionne avec Unity 2022.3 et ne nécessite pas de packages DOTS entities ou entities.graphics.
Commençons.
Voyons tout de suite ce qu'est l'échantillon. Cet échantillon tourne à 60 images par seconde sur un Samsung Galaxy A51 de 2019 (utilisant un GPU Mali G72-MP3). L'API graphique est définie sur GLES 3.0.
Vous pouvez étudier le code et l'essayer sur votre plateforme préférée en téléchargeant le projet sur GitHub. Vous n'aurez besoin que de la version 2022.3 de Unity.
Dans ce billet, nous nous concentrons principalement sur BatchRendererGroup et la classe d'exemple BRG_Container.cs. Vous pouvez également étudier le code d'animation et de physique dans les classes BRG_Background.cs et BRG_Debris.cs.
Explorons ce que nous voyons avant d'approfondir la façon de le fabriquer.
- Le sol de l'arrière-plan est constitué de nombreux cubes. Toutes les boîtes sont animées et se déplacent vers le haut et vers le bas.
- Le vaisseau principal se déplace horizontalement sur l'écran et tire des missiles sur des sphères colorées. (Vous pouvez tirer des missiles plus rapidement en touchant l'écran).
- Lorsqu'un missile survole le sol, un champ magnétique soulève légèrement les cellules du sol et les met en évidence. Il projette également des débris terrestres dans l'air.
- Lorsqu'un missile touche une sphère, il explose en débris colorés.
- Lorsque des débris touchent le sol, la cellule qui entre en collision avec le sol clignote en blanc. Plus il y a de débris dans une cellule, plus la couleur de la cellule s'assombrit. En outre, le poids des débris provoque des indentations dans le sol.
Les cellules du sol et les débris sont constitués de cubes. Chaque cube a une position et une couleur différentes. Nous voulons animer et gérer le tout en utilisant le CPU pour faciliter les interactions entre le sol et les débris. (Les débris ne sont pas seulement un aspect visuel, ils ne peuvent donc pas être traités uniquement avec le GPU).
Pour le rendu, nous ne créons pas de GameObject par élément afin d'éviter une baisse de performance inutile sur un appareil mobile bas de gamme. Au lieu de cela, nous utilisons la nouvelle API BatchRendererGroup.
Graphics.DrawMeshInstanced est un moyen pratique et rapide de rendre plusieurs maillages similaires à des positions différentes. Cependant, elle présente les limitations suivantes par rapport à l'API BatchRendererGroup :
- Il faut fournir un tableau de mémoire géré avec des matrices, ce qui peut entraîner un ramassage des ordures. De plus, les matrices inversées sont calculées par le CPU, même si le shader n'en a pas besoin (par exemple, avec URP/unlit).
- Si vous souhaitez personnaliser une propriété autre que la matrice obj2world (comme avoir une couleur par instance), vous devez fournir votre propre shader personnalisé, soit en l'écrivant à partir de zéro, soit en utilisant Shader Graph.
- Les données matricielles ou personnalisées doivent être téléchargées dans la mémoire du GPU à chaque tirage. Vous ne pouvez pas avoir de données persistantes dans la mémoire du GPU avec Graphics.DrawMeshInstanced. Selon le contexte, il peut s'agir d'une baisse considérable des performances.
BatchRendererGroup (ou BRG) est une API qui génère efficacement des commandes de dessin à partir de C# et produit des appels de dessin intégrant le GPU. Comme il n'utilise pas de mémoire gérée, vous pouvez également générer des commandes à l'aide du compilateur Burst.

Conseil : Le package entities. graphics est conçu pour effectuer le rendu des entités (package ECS) et est construit au-dessus de BRG. entities.package se charge de la gestion de la mémoire du GPU et de la création des commandes de dessin optimales pour vous. Nous n'utilisons pas ECS dans cet échantillon, nous allons donc piloter directement BRG.
Le BRG utilise une disposition spécifique des données du GPU et une variante de shader dédiée. La variante de shader peut récupérer des données à partir du tampon constant standard (UnityPerMaterial) ou d'un grand tampon GPU personnalisé (BRG raw buffer). C'est à vous de gérer la façon dont vous stockez vos données dans la mémoire tampon brute, qui est un objet tampon de stockage de shaders (SSBO, ou tampon d'adresses d'octets). La structure de données par défaut du BRG est de type structure de tableaux (SoA).
Vous pouvez instancier toutes les propriétés d'un matériau sans avoir à créer un shader personnalisé. Dans l'exemple, nous voulons instancier la matrice obj2world (pour positionner les cubes), la matrice world2obj (pour l'éclairage), et BaseColor par instance de boîte (parce que chaque cellule du sol ou débris a sa propre couleur).
Toutes les autres propriétés sont les mêmes pour tous les cubes (par exemple, la valeur de lissage), et vous pouvez décrire les propriétés qui auront des valeurs personnalisées par instance à l'aide de métadonnées.
Les métadonnées BRG sont une valeur optionnelle de 32 bits que vous pouvez définir pour chaque propriété de nuanceur. Il indique au code du shader comment charger la valeur de la propriété dans la mémoire du GPU et à quel endroit. Les bits 0-30 définissent le décalage de la propriété dans le tampon brut BRG, et le bit 31 indique si la valeur de la propriété est la même pour toutes les instances ou si le décalage est le début d'un tableau, avec une valeur par instance.
La signification exacte des métadonnées BRG dépend également du type de propriété du shader. Résumons toutes les possibilités :


Contrairement à Graphics.DrawMeshInstanced, BRG utilise une mémoire tampon persistante du GPU. Supposons que vous ayez 10 positions et couleurs de cubes dans la mémoire tampon brute, mais que seuls les cubes 0, 3 et 7 soient visibles. Vous ne voulez dessiner que trois cubes, mais vous avez besoin que le shader lise correctement la position et la couleur de ces cubes. Pour ce faire, le shader BRG utilise une petite indirection supplémentaire. Ce tampon de visibilité est simplement un tableau de "int" que vous remplissez lorsque vous générez des commandes de dessin.
Dans cet exemple, vous devez remplir un tableau de trois ints avec {0,3,7} et vous pouvez ensuite générer une commande de dessin BRG de trois instances.

Le code du shader pour récupérer la propriété "baseColor" ressemble à ceci :
Type de bloc inconnu "codeBlock", veuillez spécifier un sérialiseur pour ce type dans la propriété `serializers.types`.
Allez plus loin que l'échantillon : Comme vous pouvez instancier n'importe quelle propriété des shaders SRP (unlit, simplelit, lit), toutes les propriétés matérielles ont une branche "if metadata&(1<<31". Même si vous n'avez pas besoin d'une valeur de lissage personnalisée par instance, cela a un coût en termes de performances. Dans l'exemple, nous voulons seulement instancier baseColor. Vous pouvez créer un graphique de shaders dans lequel seule la couleur sera définie en tant que BRG instanciable. Le code généré ne comporte donc l'indirection de la recherche de données lourdes que pour la propriété de couleur. Shader devrait fonctionner même légèrement plus vite sur un GPU bas de gamme.
Dans notre exemple de jeu, le sol est composé de 32x100 cellules, soit 3 200. Chaque cellule a une position, une hauteur et une couleur, et les cellules défilent tandis que la caméra reste statique. Lorsqu'une ligne sort de la vue, nous injectons une nouvelle ligne de 32 cellules.

Avec 3 200 cellules à tout moment, l'élimination n'est pas vraiment nécessaire (toutes les cellules sont toujours dans le champ de vision de la caméra). Pour positionner chaque cellule, vous avez besoin d'une matrice obj2world par cellule, de la matrice d'inversion pour l'éclairage et d'une couleur. Pour rendre le sol complet, nous utiliserons une seule commande de dessin BRG.

Les débris de l'échantillon sont constitués de petits cubes, chacun ayant une position, une couleur et une rotation sur son axe vertical. Il s'agit d'un système très similaire aux cellules de plancher. Pour ce faire, nous avons créé BRG_Container.cs. La classe gère un objet BRG pour rendre les cellules du sol ou les débris d'explosion. Toutes les animations et interactions physiques sont réalisées en code C# à l'aide du fichier BRG_Debris.cs.
Contrairement aux cellules de plancher, la quantité de débris varie d'un côté à l'autre du cadre. Lors de l'initialisation, vous indiquez le nombre maximum d'éléments à BRG_Container. Dans notre exemple, c'est 16 384 pour les débris (chaque explosion consiste en 1 024 cubes de débris) et nous utilisons des tâches asynchrones pour animer les débris dans un champ de gravité. Lorsque des débris frappent une cellule de plancher, ils interagissent en s'enfonçant dans le sol.
Pour optimiser le stockage de la mémoire du GPU et la bande passante, BRG utilise un float3x4 pour stocker une matrice au lieu d'un float4x4. N'oubliez pas qu'une matrice BRG dans la mémoire tampon brute représente 48 octets, et non 64.

La mémoire tampon brute se présente comme suit :

Conseil : Les données brutes de la mémoire tampon des débris ressemblent aux données du sol, car elles utilisent également trois propriétés personnalisées (obj2world, world2obj et color). Le nombre maximum d'éléments est de 16 384 pour les débris, ce qui signifie une mémoire tampon brute de 112x16 384 octets, soit 1,75 Mio. Tous les débris ne sont pas rendus la plupart du temps, en fonction du nombre de cubes de débris existant à un moment donné.
Nous avons un tampon graphique GPU de 358 400 octets. L'animation étant réalisée par l'unité centrale, nous allouons également une mémoire tampon similaire dans la mémoire du système (l'unité centrale peut traiter les données à pleine vitesse dans la mémoire du système). Appelons ce deuxième tampon une "copie fantôme" de la mémoire du GPU. Le code C# animera les cellules du sol, en utilisant le péché et les débris de la copie d'ombre. Lorsque l'animation est terminée, nous téléchargeons le tampon de copie d'ombre vers le GPU à l'aide de l'API GraphicsBuffer.SetData.
Allez plus loin que l'échantillon : L'optimisation du rendu par le GPU signifie souvent l'optimisation de la quantité de données. Dans notre échantillon, nous utilisons des shaders SRP standard et stock. C'est pourquoi nous avons utilisé trois float4 pour la matrice et un float4 pour la couleur. Vous pouvez aller plus loin, en écrivant un shader personnalisé pour réduire la taille des données, ou vous pouvez utiliser une valeur de hauteur de cellule de 32 bits.
Si vous souhaitez continuer, utilisez l'index de la cellule pour calculer sa position dans le monde, puis calculez la matrice et inversez la matrice dans le shader. Enfin, utilisez un nombre entier de 32 bits pour stocker la couleur. À la fin, téléchargez 8 octets par élément au lieu de 112. Cela permet de multiplier par 14 la vitesse de téléchargement des données du GPU. Cela impliquerait de réécrire le code de récupération des shaders.
Toute commande de dessin BRG a besoin d'un MeshID, d'un MaterialID et d'un BatchID. Les deux premiers sont faciles à comprendre, mais BatchID est plus subtil. Pensez à BatchID comme à une "sorte de lot". Pour rendre le sol, vous devez enregistrer un type de lot, défini comme suit :
1. La propriété "unity_ObjectToWorld" est un tableau commençant à l'offset 0 du tampon brut BRG.
2. La propriété "unity_WorldToObject" est un tableau commençant à l'offset 153,600.
3. La propriété "_BaseColor" est un tableau, commençant à l'offset 307,200
Le code permettant d'enregistrer ce type de lot au moment de la création ressemblera à ceci :
Type de bloc inconnu "codeBlock", veuillez spécifier un sérialiseur pour ce type dans la propriété `serializers.types`.
Nous obtenons le m_batchId au moment de la création, et nous pouvons ensuite l'utiliser pour chaque commande de dessin BRG (afin que le shader sache exactement comment récupérer les données pour ce type de lot).
Conseil : BatchRendererGroup.AddBatch n'est pas une commande de rendu. Il est utilisé pour enregistrer une sorte de lot, pour de futures commandes de rendu.
Jusqu'à présent, nous pouvons animer les cellules du sol, télécharger la mémoire tampon du système de copie d'ombre vers le GPU et effectuer le rendu de toutes les cellules à l'aide d'une seule commande DrawCommand de 3 200 instances.
Cette méthode fonctionne sur la plupart des plateformes : DirectX, Vulkan, Metal et diverses consoles de jeux, mais pas sur GLES. Le problème est que la plupart des périphériques GLES 3.0 ne peuvent pas accéder à la SSBO pendant l'étape du sommet (c'est-à-dire que la valeur GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS est égale à 0). Ainsi, lorsque l'API graphique est définie sur GLES, BRG utilise un tampon constant, ou UBO, pour stocker les données brutes.
Cela ajoute des contraintes : Un tampon constant peut être de n'importe quelle taille, mais seule une petite partie de celui-ci (une fenêtre) est visible à tout moment lorsque le shader est en cours d'exécution. La taille de la fenêtre dépend du matériel et du pilote, mais une valeur largement acceptée est de 16 KiB.
Conseil : En mode UBO, vous devez toujours utiliser l'API BatchRendererGroup.GetConstantBufferMaxWindowSize() pour obtenir la taille correcte de la fenêtre BRG.
Voyons comment notre code change si nous voulons l'exécuter sur GLES. Pour les cellules d'étage, la quantité totale de données est de 350 KiB. Nous ne pouvons pas faire un seul DrawInstanced(3,200) parce que le shader ne pourra pas voir 350 KiB à la fois. Nous devons donc diviser les données au sein de l'UBO afin de maximiser le nombre d'instances par tirage, dans un bloc de 16 KiB. Une cellule d'étage contient 112 octets (deux matrices et une couleur), ce qui permet de placer 16 384 divisés par 112, soit 146 instances, dans un bloc de 16 KiB. Pour rendre 3 200 instances, nous devrons émettre 21 DrawInstanced(146) et un dernier DrawInstanced(134).
Maintenant, l'UBO de 350 Ko sera divisé en 22 blocs de fenêtres de 16 Ko chacun, comme suit :

Conseil : En mode UBO, chaque décalage de fenêtre doit être aligné sur BatchRendererGroup.GetConstantBufferOffsetAlignment(). Les valeurs d'alignement typiques sont comprises entre 4 et 256 octets.
Dans GLES, en raison de l'UBO et des fenêtres de 16 KiB, vous devez enregistrer 22 BatchID afin de stocker les décalages de chaque fenêtre. Le code d'initialisation a alors besoin d'une boucle :
Type de bloc inconnu "codeBlock", veuillez spécifier un sérialiseur pour ce type dans la propriété `serializers.types`.
Conseil : Pour prendre en charge GLES (UBO) et d'autres API graphiques (SSBO) dans l'exemple de jeu, BRG_Container.cs définit certaines variables au moment de l'initialisation. En mode SSBO, m_windowCount est égal à 1 et m_alignedGPUWindowSize correspond à la taille totale de la mémoire tampon. En mode UBO, m_alignedGPUWindowSize est de 16 KiB et m_windowCount contient le nombre de blocs de 16 KiB. (La valeur de 16 KiB est destinée à faciliter la lecture. Utilisez GetConstantBufferMaxWindowSize() API pour obtenir la valeur correcte.)
Une fois que l'unité centrale a mis à jour toutes les matrices et les couleurs dans la mémoire du système, vous pouvez télécharger les données vers le GPU. Cette opération s'effectue à l'aide de la fonction BRG_Container.UploadGpuData. En raison du modèle de données SoA, il n'est pas possible de télécharger un seul bloc de mémoire. Pour les débris, la mémoire tampon est de 16 384 éléments. En mode GLES, cela signifie 113 fenêtres de 16 KiB chacune si 16 384 débris sont à l'écran.
Mais que se passe-t-il si seulement 5 300 cubes de débris se trouvent dans un cadre donné ? Comme vous avez 146 éléments par fenêtre, cela signifie que les 36 premières fenêtres consécutives de 16 KiB doivent être téléchargées afin que vous puissiez utiliser un seul SetData (36x16 KiB). Dans la dernière fenêtre, seuls 44 cubes de débris devraient être affichés. Pour télécharger 44 matrices, inverser les matrices et les couleurs et utiliser trois commandes SetData. À la toute fin, quatre commandes SetData doivent être émises.

Conseil : Même en mode SSBO, si le nombre d'éléments est inférieur au maximum (par exemple, 5 300 débris sur un maximum de 16 384), trois commandes SetData sont nécessaires. Vous pouvez consulter BRG_Container.UploadGpuData(int instanceCount) pour plus de détails sur l'implémentation.
Le principal point d'entrée de BRG est la fonction de rappel d'abattage que vous fournissez au moment de la création. Le prototype se présente comme suit :
Type de bloc inconnu "codeBlock", veuillez spécifier un sérialiseur pour ce type dans la propriété `serializers.types`.
Votre code dans ce rappel est responsable de deux choses :
1. Pour générer toutes les commandes de dessin dans la structure de sortie BatchCullingOut
2. Pour utiliser (ou non) les informations fournies dans la structure en lecture seule BatchCullingContext dans votre propre code d'abattage
Remarque : Le rappel renvoie un JobHandle au cas où vous souhaiteriez lancer un travail asynchrone pour effectuer ces opérations. Le moteur l'utilisera pour se synchroniser au moment où le résultat est nécessaire, de sorte que votre code de génération de commandes ne bloquera pas le fil d'exécution principal.
BatchCullingContext contient des informations telles que la matrice de la caméra, les plans du tronc commun de la caméra, etc. En gros, toutes les données dont vous avez besoin pour traiter et générer moins de commandes de dessin. Dans l'exemple, tous les objets tiennent dans la vue de la caméra (cellules du sol et débris), il n'est donc pas nécessaire d'utiliser le code d'abattage.
La structure BatchCullingOutputDrawCommands contient diverses données, y compris des tableaux. Il incombe à l'utilisateur d'allouer de la mémoire native à ces tableaux. Le moteur est responsable de la libération de cette mémoire une fois que les données ont été consommées (vous allouez, Unity est responsable de la libération). L'allocation de mémoire doit être de type Allocator.TempJob.
Type de bloc inconnu "codeBlock", veuillez spécifier un sérialiseur pour ce type dans la propriété `serializers.types`.
Le premier tableau à allouer est le tableau de visibilité int. Dans l'exemple, comme nous supposons que tout est visible, nous remplissons simplement le tableau visibility int avec des valeurs incrémentales, comme {0,1,2,3,4,...}.
Une commande de dessin BRG est presque un appel GPU DrawInstanced. Le tableau le plus important à allouer et à remplir est la commande BatchDrawCommand. Supposons qu'il y ait 4 737 cubes de débris dans le cadre actuel.
m_maxInstancePerWindow est de 146 en mode GLES. Vous pouvez calculer le nombre de commandes de dessin et allouer le tampon en utilisant la valeur plafond de m_instanceCount divisée par m_maxInstancePerWindow :
Type de bloc inconnu "codeBlock", veuillez spécifier un sérialiseur pour ce type dans la propriété `serializers.types`.
Pour éviter de dupliquer des paramètres similaires dans plusieurs commandes de dessin, BatchCullingOutputDrawCommands dispose d'un tableau de structures BatchDrawRange. Vous pouvez définir divers paramètres dans BatchDrawRange.filterSettings, tels que renderingLayerMask, receive shadow flags, etc. Comme toutes les commandes de dessin partagent les mêmes paramètres de rendu, vous pouvez allouer une seule structure DrawCommandRange qui s'appliquera à partir de la commande de dessin 0 et contiendra toutes les commandes DrawCommandCount.
Type de bloc inconnu "codeBlock", veuillez spécifier un sérialiseur pour ce type dans la propriété `serializers.types`.
Ensuite, remplissez les commandes de dessin. Chaque BatchDrawCommand contient un meshID, un batchID (pour savoir comment utiliser les métadonnées) et un materialID. Il contient également l'offset de départ dans le tampon du tableau d'intersections de visibilité. Comme nous n'avons pas besoin d'éliminer les frustes dans notre contexte, nous remplissons le tableau de visibilité avec {0,1,2,3,...}. Ensuite, toutes les commandes de dessin feront référence à la même indirection {0,1,2,3,...}, de sorte que chaque BatchDrawCommand utilisera 0 comme décalage de départ du tableau de visibilité.Le code suivant alloue et remplit toutes les commandes de dessin nécessaires :
Type de bloc inconnu "codeBlock", veuillez spécifier un sérialiseur pour ce type dans la propriété `serializers.types`.
Le pilotage direct de BatchRendererGroup nécessite un peu de travail. Cependant, il fonctionne dès le départ sans nécessiter de shaders personnalisés ou de paquets supplémentaires. Dans certaines situations, comme le rendu d'un grand nombre d'objets simulés par l'unité centrale avec des propriétés instanciées personnalisées, BatchRendererGroup est votre meilleur ami.
Vous pouvez télécharger le projet à partir de ce dépôt.
Vous pouvez également visiter les forums pour discuter de détails supplémentaires sur la façon dont nous avons utilisé le système de tâches C# et le compilateur Burst pour gérer toutes les animations et interactions à pleine vitesse, même sur un processeur bas de gamme.
