Cherchez Unity

Les bonnes pratiques Unity

Dernière mise à jour : décembre 2018

Ce que vous découvrirez sur cette page : des astuces récentes pour les performances et l'optimisation de la programmation de scripts par Ian Dundore, qui tiennent compte de l'évolution de l'architecture orientée vers les données de Unity.

Unity évolue et les astuces d'hier ne sont plus forcément pertinentes pour exploiter les performances du moteur. Découvrez quelques nouveautés Unity (versions 5.x jusqu'à 2018.x) et comment vous pouvez en tirer parti. Toutes ces bonnes pratiques sont tirées de l'intervention de Ian Dundore, que vous pouvez regarder directement.

Performances en programmation

L'une des tâches les plus compliquées lors de l'optimisation est de choisir comment optimiser le code une fois une zone sensible découverte. De nombreux facteurs interviennent : les modalités du système d'exploitation, le processeur et le GPU propres à la plateforme, le threading, l'accès à la mémoire et bien d'autres encore. Il est difficile de savoir à l'avance quel type d'optimisation produira le plus gros avantage réel.

En général, il vaut mieux tester les options d'optimisation dans de petits projets de test, où vous pouvez itérer bien plus vite. Cependant, isoler le code dans un projet de test pose en soi un problème : isoler un morceau du code modifie son environnement. La durée du threading sera peut-être différente, la pile gérée plus petite ou moins fragmentée. Soyez donc prudent quand vous concevez des tests.

Commencez par réfléchir aux entrées sur votre code et à la façon dont celui-ci réagit lorsque vous les modifiez.

  • Comment le moteur réagit-il aux données hautement cohérentes présentes en série dans la mémoire ?
  • Comment les données incohérentes en cache sont-elles gérées?
  • Combien de code avez-vous supprimé de la boucle dans laquelle s'exécute votre code ? Avez-vous modifié l'utilisation du cache d'instruction du processeur ?
  • Sur quel matériel s'exécute-t-il ? Ce matériel est-il compatible avec la prédiction de branchements ? Exécute-t-il correctement les micro-opérations qui ne fonctionnent plus ? Est-il compatible SIMD ?
  • SI vous utilisez un système multithread lourd exécuté sur un système avec plus de cœurs ou moins de cœurs, comment le système réagit-il ?
  • Quels sont les paramètres d'échelle de votre code ? Se met-il à l'échelle de manière linéaire au fur et à mesure que son ensemble de données grandit, ou est-il plus que linéaire ?

Vous devez effectivement penser à ce que vous mesurez précisément dans le cadre de votre faisceau de test.

Par exemple, examinons le test suivant, pour une opération simple : comparer deux chaînes.

Lorsque les API en C# comparent deux chaînes, elles procèdent à des conversions locales spécifiques afin de s'assurer qu les différents caractères peuvent correspondre à divers caractères lorsqu'ils proviennent de cultures différentes. Vous pourrez constater par vous-même que ce processus est plutôt lent.

La plupart des API en C# sont sensibles à la culture, mais ce n'est pas le cas de celle-ci : String.Equals.

Si vous ouvriez String.CS depuis ce GitHub et que vous regardiez String.Equals, voici ce que vous verriez : une fonction très simple qui procède à quelques vérifications avant de laisser le contrôle à une fonction appelée EqualsHelper, une fonction privée que vous ne pouvez pas appeler directement.

lp MonoString CS1

EqualsHelper est une méthode simple qui lit les chaînes 4 octets à la fois en comparant les octets bruts des chaînes d'entrée. En cas d'incompatibilité, la fonction s'arrête et renvoie un message « false ».

Mais il existe d'autres façons de vérifier l'égalité des chaînes. La plus banale est un overload de String.Equals, qui accepte deux paramètres : la chaîne avec laquelle comparer, et une valeur appelée StringComparison.

Nous avons déjà compris que l'overload à paramètre unique String.Equals en fait peu avant de passer le relai à EqualsHelper. Que fait l'overload à deux paramètres ?

Si vous regardez le code de l'overload à deux paramètres, vous constaterez qu'il procède à quelques vérifications supplémentaires avant de lancer un grand switch statement . Cette procédure vise à tester les valeurs de l’entrée StringComparison. Étant donné que nous recherchons la parité avec l'overload à paramètre unique, nous voulons une comparaison ordinale – bit par bit. Dans ce cas, 4 vérifications sont effectuées avant d'arriver à StringComparison.Ordinal, où le code est très similaire au code de l'overload à paramètre unique String.Equals. Cela signifie que, si vous utilisez l'overload à deux paramètres String.Equals au lieu de l'overload à paramètre unique, le processeur effectuera quelques opérations de comparaison supplémentaires. On pourrait s'attendre à un processus plus lent, mais il se révèle très utile.

Ne vous arrêtez pas à String.Equals si vous voulez comparer tous les moyens de comparer l'égalité de chaînes. Un overload de String.Compare peut effectuer des comparaisons ordinales, tout comme une méthode appelée String.CompareOrdinal et qui dispose de deux overloads différents.

Comme implémentation de référence, voici un exemple simple codé à la main. Il s'agit simplement d'une petite fonction avec une vérification de longueur qui prend en compte chaque caractère des deux chaînes analysées et les vérifie.

Après examen du code pour les points évoqués, il existe quatre cas de test qui semblent utiles dans l'immédiat :

  • Deux chaînes identiques, pour tester les performances dans le pire cas.
  • Deux chaînes de caractères aléatoires mais de longueur identique, afin de contourner les vérifications de longueur.
  • Deux chaînes de caractères aléatoires mais de longueur identique avec un caractère de début commun, pour contourner une optimisation intéressante uniquement disponible dans String.CompareOrdinal.
  • Deux chaînes de caractères aléatoires et de longueurs différentes, pour tester les performances dans le meilleur des cas.

Après quelques tests, String.Equals est le grand gagnant. Cela vaut quelle que soit la plateforme, la version de l'exécution ou l'utilisation de Mono ou de IL2CPP.

String.Equals est la méthode utilisée par l'opérateur d'égalité des chaînes, ==. Pensez à modifier a == b en a.Equals(b) dans tout votre code!

Si l'on observe les résultats, on constate bizarrement à quel point l'implémentation codée à la main est moins bonne. Si l'on regarde le code IL2CPP , on constate que Unity injecte des vérifications de limitations et des vérifications de nullité en cas de compilation croisée de votre code.

Vous pouvez désactiver ces éléments. Dans votre dossier d'installation Unity, trouvez le sous-dossier IL2CPP. Dans ce sous-dossier IL2CPP, vous trouverez IL2CPPSetOptionAttributes.cs. Faites glisser cet élément dans votre projet pour accéder à Il2CppSetOptionAttribute.

Vous pouvez agrémenter les types et les méthodes avec cet attribut, que vous pouvez configurer pour désactiver les vérifications automatiques de nullité, les vérifications automatiques des limitations, ou les deux. Cela permet de rendre le code plus rapide – parfois considérablement. Dans ce cas de test spécifique, la méthode de comparaison des chaînes codées à la main est plus rapide de 20 % !

Astuce de vérification de nullité de l'IP

Transforms

Impossible de le deviner simplement en regardant la hiérarchie dans l'Éditeur Unity, mais le composant Transform a beaucoup évolué entre Unity 5 et Unity 2018. Ces modifications offrent de nouvelles possibilités intéressantes d'amélioration des performances.

Dans Unity 4 et Unity 5.0, un objet était alloué quelque part dans une pile de mémoire native de Unity lorsque vous créiez un Transform. Cet objet pouvait se trouver n'importe où dans la pile de mémoire native – aucune garantie que deux Transformes alloués à la suite étaient alloués à proximité, et aucune garantie qu'un Transforme enfant était alloué près de son parent.

Cela signifie que lorsque nous progressions dans une hiérarchie Transform de manière linéaire, nous ne travaillions pas linéairement dans une région contiguë de mémoire. Le processeur calait de manière répétitive en attendant la récupération des données Transform depuis le cache L2 ou la mémoire centrale.

Dans le backend Unity, chaque fois qu'une position, rotation ou adaptation de Transform est modifiée, l'élément Transform envoie un message OnTransformChanged. Ce message devait être reçu par tous les Transforms enfants, afin que ces derniers actualisent leurs propres données et informent les autres composants concernés par les modifications. Par exemple, un Transforme enfant avec un collisionneur attaché doit actualiser le système de physiques chaque fois que le Transforme parent ou enfant change.

Ce message inévitable a entraîné de nombreux problèmes de performances, en particulier parce qu'il n'existait aucune solution intégrée pour éviter ces messages inadaptés. Si vous vouliez modifier un Transform, vous saviez que vous deviez également modifier ses enfants et il n'y avait aucun moyen d'empêcher Unity d'envoyer un message OnTransformChanged après chaque modification. Cela faisait perdre beaucoup de temps au processeur.

À cause de ce détail, l'un des plus précieux conseils pour les anciennes versions de Unity était de regrouper les modifications apportées aux Transforms. C'est-à-dire, capturer la position et la rotation d'un Transform une fois, au début d'un frame, puis utiliser et actualiser ces valeurs en cache au cours du frame. Appliquer les modifications de la position et de la rotation une fois seulement, à la fin du frame. C'était un bon conseil, du moins jusqu'à Unity 2017.2.

Heureusement, à partir de Untiy 2017.4 et 2018.1, OnTransformChanged a disparu. Il a été remplacé par le nouveau système TransformChangeDispatch.

TransformChangeDispatch a fait son apparition dans Unity 5.4. Dans cette version, les Transforms ne sont plus des objets isolés éparpillés partout dans la pile de mémoire native de Unity. Chaque Transform racine dans une scène est représenté par un tampon de données contigu appelé structure TransformHierarchy, qui contient toutes les données relatives aux Transforms qui descendent d'un Transform racine.

En outre, un TransformHierarchy stocke également des métadonnées relatives à chaque Transform qu'il comprend. Ces métadonnées incluent un bitmask qui indique si un Transforme donné est « dirty » – si sa position, sa rotation ou son échelle ont changé depuis la dernière fois où il a été marqué « clean ». Elles incluent également un bitmask qui suit les autres systèmes Unity concernés par les modifications apportées à un Transform spécifique.

Grâce à ces données, Unity peut désormais créer une liste de Transforms « dirty » pour chacun de ses systèmes internes.Par exemple, le système de physiques peut ordonner à TransformChangeDispatch de récupérer une liste des Transforms dont les données ont été modifiées depuis la dernières FixedUpdate exécutée par le système de physiques.

Pour assembler la liste des Transforms modifiés, le système TransformChangeDispatch ne doit pas analyser tous les Transforms d'une scène. Ce processus serait en effet très lent pour les scènes qui comprennent de nombreux éléments Transforms. Surtout puisque, dans la plupart des cas, seuls quelques Transforms auraient été modifiés.

Pour corriger ce point, TransformChangeDispatch suit une liste de structures TransformHierarchy « dirty ». Chaque fois qu'un Transform change, il est se marque « dirty », tout comme ses enfants, puis enregistre le TransformHierarchy dans lequel il est stocké avec le système TransformChangeDispatch. Lorsqu'un autre système dans Unity requiert une liste des Transforms modifiés, TransformChangeDispatch analyse chaque Transform enregistré dans chaque structure TransformHierarchy « dirty ». Les Transforms aux ensembles de bits d'intérêt de « dirty » appropriés sont ajoutés à une liste qui est renvoyée au système à l'origine de la requête.

En raison de cette architecture, plus vous divisez votre hiérarchie plus Unity peut suivre facilement les modifications à un niveau granulaire. Plus il existe de Transforms à la racine d'une scène, moins le nombre de Transforms analysés sera important.

TransformChangeDispatch utilise le système multithread interne de Unity pour diviser les tâches dont il a besoin pour l'analyse des structures TransformHierarchy. Cette division et la fusion des résultats ajoute un peu de surcharge chaque fois qu'un système a besoin de demander une liste de modifications depuis TransformChangeDispatch.

La plupart des systèmes internes de Unity requièrent des actualisations une fois par frame, immédiatement après leur exécution. Par exemple, le système d'animation requiert des actualisations immédiatement avant d'évaluser tous les animateurs actifs dans votre scène. De la même manière, le système de rendu requiert des mises à jour pour tous les éléments de rendus actifs dans cotre scène avant de commencer avec la liste des objets visibles.

L'un des systèmes est toutefois légèrement différent : les physiques.

Dans Unity 2017.1 (et dans les versions supérieures), les actualisations des physiques étaient synchrones. Lorsque vous déplaciez ou tourniez un Transform avec un collisionneur attaché, nous actualisions immédiatement la scène de physiques. Cela nous permettait de nous assurer que la position ou la rotation modifiée du collisionneur était prise en compte dans le monde des physiques, afin d'obtenir des requêtes Raycast ou Physics précises.

Lorsque nous avons migré les physiques pour utiliser TransformChangeDispatch dans Unity 2017.2, nous avons mesuré les risques potentiels de cette évolution essentielle. À chaque Raycast, nous devient demander une liste des Transforms modifiés à TransformChangeDispatch et les appliquer au monde des physiques. Cela pouvait nécessiter de nombreuses ressources, selon la taille des hiérarchies Transforms et la façon dont votre code appelait les API de physiques.

Ce comportement est gouverné par un nouveau paramètre : Physics.autoSyncTransforms. De Unity 2017.2 à Unity 2018.2, ce paramètre est par défaut sur « true », et Unity synchronise automatiquement le monde des physiques en onction des actualisations des Transforms caque fois que vous appelez une API de requête de physiques comme Raycast ou Spherecast.

Ce paramètre peut être modifié dans les paramètres des physiques, dans l'Éditeur Unity ou à l'exécution, en configurant la propriété Physics.autoSyncTransforms. Si vous la paramétrez sur « false » et que vous désactivez la synchronisation automatique des physiques, le système de physiques demandera au système TransformChangeDispatch des modifications à un moment précis : immédiatement avant l’exécution de FixedUpdate.

Si vous constatez des problèmes de performance lors de l'appel d'API de requête de physiques, vous pouvez les corriger de deux manières différentes.

Pour commencer, vous pouvez paramétrer Physics.autoSyncTransforms sur « false ». Cela éliminera les pics générés par l'actualisation des scènes de physiques et de TransformChangeDispatch à partir des requêtes physiques.

Si vous procédez ainsi, les modifications apportées au collisionneur ne seront pas synchronisées dans la scène des physiques avant la prochaine FixedUpdate. Cela signifie donc que si vous désactivez autoSyncTransforms, déplacez un collisionneur puis appelez Raycast avec un élément Ray dirigé vers la nouvelle exposition du collisionneur, le Raycast pourrait ne pas atteindre le collisionneur. Cela tient au fait que le Raycast s'appuie sur la dernière version à jour de la scène des physiques, et que la nouvelle position du collisionneur n'a pas encore été actualisée dans cette scène.

Cela peut entraîner des erreurs inhabituelles. Nous vous conseillons de tester votre jeu avec attention afin de vous assurer que le fait de désactiver la synchronisation automatique des Transforms ne cause aucun problème. Si vous devez forcer les physiques à actualiser la scène des physiques suite aux modifications des Transforms, vous pouvez appeler Physics.SyncTransforms. Cette API étant lente, évitez de l'appeler plusieurs fois par frame !

À partir de Unity 2018.3, Physics.autoSyncTransforms est par défaut sur « false ».

La deuxième façon d'optimiser le temps que représente les requêtes TransformChangeDispatch consiste à réorganiser l'ordre dans lequel vous effectuez des requêtes et actualisez la scènes des physiques afin de mieux adapter les procédures au nouveau système.

N'oubliez pas qu'avec le paramétrage « true » de Physics.autoSyncTransforms, chaque requête de physiques vérifiera les modifications avec TransformChangeDispatch. Toutefois, si TransformChangeDispatch n'a aucune structure TransformHierarchy « sale » à vérifier, et que le système de physiques n'a aucun Transform actualisé à appliquer à la scène des physiques, quasiment aucune surcharge ne sera ajoutée à la requête de physiques.

Vous pourriez effectuer toutes vos requêtes de physiques dans un lot puis appliquer toutes les modifications aux Transforms dans un lot également. Ne mélangez pas les modifications des Transforms et les API de requêtes de physiques.

Cet exemple illustre la différence :

Exemple de Transforms

La différence de performance entre ces deux exemples est évidentes et s'accroît lorsqu'une scène contient uniquement de petites hiérarchies de Transforms.

Résultats de l'exemple de Transforms

Le système audio

En interne, Unity utilise un système appelé FMOD pour lire des clips audio. FMOD s’appuie sur ses propres threads, qui décodent et mixent l'audio. La lecture audio n'est toutefois pas gratuite. Des tâches sont exécutées sur le thread principal pour chaque source audio active dans une scène. Sur les plateformes disposant d'un nombre de cœurs inférieur (les anciens téléphones portables par exemple), les threads audio de FMOD peuvent jouer le rôle des cœurs du processeur avec le thread principal et le thread de rendu de Unity.

À chaque frame, Unity passe en revue toutes les sources audio actives. Pour chacune d'entre elles, Unity calcule la distance entre la source audio et l'auditeur actif, ainsi que quelques autres paramètres supplémentaires. Ces données permettent de calculer l'atténuation du volume, l'effet Doppler et d'autres effets susceptibles d'affecter les sources audio individuelles.

Les cases à cocher « Mute » des sources audio génèrent un problème fréquent. Vous pensez peut-être que le fait de paramétrer Mute sur True élimine les calculs liés à la source audio concernée, mais ce n'est pas le cas.

Liste de vérification de l'audio

Le paramètre Mute se contente de faire passer le paramètre Volume sur zéro une fois tous les autres calculs relatifs au son effectués, y compris la vérification de la distance. Unity soumettra également la source audio Mute à FMOD, qui l'ignorera ensuite. Le calcul des paramètres de source audio et la soumission des sources audio à FMOD sera visible en tant que AudiosSystem.Update dans le Profiler Unity.

Si vous constatez qu'une quantité de temps importante est allouée à ce marqueur de Profiler, vérifiez la quantité des sources audio sur Mute. Si elles sont nombreuses, désactivez les composants de ces sources audio au lieu de les passer sur Mute, ou désactivez leurs GameObject. Vous pouvez également appeler AudioSource.Stop, qui arrêtera la lecture.

Vous pouvez également modifier le compte de voix dans les paramètres audio de Unity. Pour ce faire, appelez AudioSettings.GetConfiguration, qui renvoie une structure comprenant deux valeurs pertinentes : le compte des voix virtuelles et le compte des voix réelles.

Réduire le nombre de voix virtuelles réduira le nombre de sources audio que FMOD examine pour déterminer quelles sources audio lire. Réduire les voix réelles réduira le nombre de sources audio que FMOD mixe ensemble pour produire l'audio de votre jeu.

Pour modifier le nombre de voix réelle sou virtuelles qu'utilise FMOD, vous pouvez modifier les valeurs appropriées dans la structure AudioConfiguration renvoyée par AudioSettings.GetConfiguration, puis redémarrer le système audio avec la nouvelle configuration en utilisant la structure AudioConfigurationcomme paramètre de AudioSettings.Reset. La lecture audio sera interrompue. Nous vous conseillons donc de procéder lorsque les joueurs ne remarquent pas le changement, par exemple lors d'un écran de chargement ou au démarrage.

Animations

Deux systèmes différents peuvent être utilisés pour lire des animations dans Unity : les systèmes d'animateur et d'animation.

Le système d'animateur correspond au système qui comprend le composant animateur, attaché à des GameObjects afin de les animer, ainsi que la ressource AnimatorController, réérencée par un ou plusieurs animateurs. Précédemment appelé Mecanim, ce système comprend de nombreuses fonctionnalités.

Dans un Animator Controller , vous définissez des états. Ces états peuvent être soit dans un clip d'animation soit dans un arbre d'animation. Les états peuvent être organisés en couches. Chaque frame, l'état actif sur chaque couche est analysé, et les résultats obtenus pour chaque couche sont regroupés et appliqués au modèle animé. Lors de la transition entre deux états, ces deux états sont évalués.

L'autre système, appelé système d'animation, est représenté par le composant Animation et est très simple. Chaque frame, chaque composant Animation actif itère de manière linéaire à travers toutes les courbes de son clip d'animation attaché, évalue ces courbes puis applique les résultats.

Les fonctionnalités ne sont pas les seules différences entre ces deux systèmes. Les détails d'implémentation sous-jacents varient également.

Le système d'animateur est multithread.Ses performances varient grandement selon les processeurs et leur nombre de cœurs. En général, il s'adapte de manière peu linéaire à l'accroissement du nombre de courbes dans les clips d'animation. Il est dont très performant pour analyser des animations complexes avec de nombreuses courbes, mais il génère une surcharge importante.

Le système d'animation, très simple, ne représente quasiment aucune surcharge. Ses performances s'ajustent linéairement selon le nombre de courbes dans les clips d'animation lus.

La différence est plus évidente lorsque les deux systèmes sont comparés en jouant des clips d'animation similaires.

Test de clips d'animation

Lorsque vous lisez des clips d'animation, essayez de choisir le système qui correspond le mieux à la complexité de votre contenu et au matériel sur lequel s'exécutera votre jeu.

La surutilisation des souches dans Animator Controllers est un autre problème commun. Lorsqu’un animateur s'exécute, il évalue toutes les couches de son Animator Controller pour chaque frame. Cela inclut les couches dont le poids de couche est défini à zéro, ce qui signifie qu'il n'apporte aucune contribution visible aux résultats de l'animation finale.

Chaque couche supplémentaire ajoutera des calculs à chaque Animator Controller pour chaque frame. En général, essayez d'utiliser les couches de manière raisonnable. Si vous avez des couches de débogage, de démo ou de cinématique dans un Animator Controller, essayez de les remanier. Fusionnez-les avec des couches existantes, ou éliminez-les avant de publier votre jeu.

Rid générique VS humanoïde

Par défaut, Unity importe les modèles animés avec le rig générique, mais les développeurs passent facilement au rig humanoïde lorsqu'ils animent un personnage. Mais il y a un coût.

Le rig humanoïde ajoute des fonctionnalités supplémentaires au système d'animateur : les cinématiques inversées et le reciblage d'animation. Le reciblage d'animation est très utile car il vous permet de réutiliser des animations pour différents avatars.

Toutefois, même si vous n'utilisez ni les cinématiques inversées ni le reciblage d'animation, un animateur de personnage à rig humanoïde alcule tout de même ces valeurs à chaque frame. Cela représente 30 à 50 % de temps en plus pour le processeur par rapport à un rig générique, qui ne procède pas à tous ces calculs.

Si vous n'utilisez pas les fonctionnalités de rig humanoïde, nous vous conseillons d'utiliser le rig générique.

Regroupement d'animateur

Le regroupement d'objets est une stratégie clé pour éviter les pics de performances pendant le jeu. Il est pourtant connu que les animateurs fonctionnent difficilement avec le regroupement d'objets. Chaque fois qu'un GameObject d'un animateur est activé, il doit reconstruire des tampons de données intermédiaires à utiliser pour l'évaluation de l'Animator Controller de l'animateur. Cela s'appelle un Animator Rebind et s'affiche en tant que Animator Rebind dans le profiler Unity.

Avant Unity 2018, la seule solution consistait à désactiver le composant Animator, non GameObject. Ce procédé entraînait des effets indésirables : si vous aviez beaucoup de MonoBehaviors, de Mesh Colliders ou de Mesh Renderer dans votre personnage, vous deviez les désactiver également. Vous pouviez ainsi économiser le temps de processeur qui était utilisé par votre processeur. Cela complexifie toutefois votre code.

Dans Unity 2018.1, nous avons introduit l'API Animator.KeepControllerStateOnEnable. Cette propriété est sur « false » par défaut, ce qui signifie que l'animateur se comportera comme à son habitude – déchargeant ses tampons de données intermédiaires lorsqu'il est désactivé, les chargeant à nouveau lorsqu'il est activé.

Si vous configurez cette propriété sur « true », les animateurs conserveront leurs tampons lorsqu'ils sont désactivés. Cela signifie qu'il n'y aura pas d'élément Animator.Rebind lorsque cet animateur est réactivé. Les animateurs peuvent enfin être groupés !

Plus de ressources

Dites-nous si vous avez aimé ce contenu !

Oui, continuez comme ça Ça pourrait être mieux