optimisation des scripts

Lorsque qu'un jeu fonctionne, l'unité centrale (CPU) de notre appareil exécute les instructions. Chaque frame de notre jeu nécessite plusieurs millions de ces instructions CPU pour être exécutée. Pour maintenir une fréquence d'images régulière, le CPU doit exécuter ses instructions dans un laps de temps défini. Lorsque le CPU ne peut pas exécuter toutes ses instructions à temps, notre jeu peut ralentir ou freezer.

Beaucoup de choses peuvent faire en sorte que le CPU ait trop de travail à faire. Des exemples pourraient inclure du code de rendu exigeant, des simulations physiques trop complexes ou trop de rappels d'animation. Cet article se concentre sur une seule de ces raisons: les problèmes de performance CPU causés par le code que nous écrivons dans nos scripts.

Dans cet article, nous allons apprendre comment nos scripts sont transformés en instructions CPU, ce qui peut provoquer nos scripts à générer une quantité excessive de travail pour le CPU, et comment résoudre les problèmes de performance qui sont causés par le code dans nos scripts.

Diagnostiquer les problèmes avec notre code

Les problèmes de performance causés par des sollicitations excessives sur le CPU peuvent se manifester par de faibles fréquences d'images, des performances saccadées ou des gels intermittents. Cependant, d'autres problèmes peuvent causer des symptômes similaires. Si notre jeu a des problèmes de performance comme celui-ci, la première chose que nous devons faire est d'utiliser la fenêtre Profiler d'Unity pour établir si nos problèmes de performance sont dus à l'incapacité du CPU à terminer ses tâches à temps. Une fois que nous contrôler ça, nous devons déterminer si les scripts utilisateur sont la cause du problème, ou si le problème est causé par une autre partie de notre jeu: la physique complexe ou les animations, par exemple.

Une brève introduction sur la façon dont Unity construit et exécute notre jeu

Pour comprendre pourquoi notre code ne fonctionne peut-être pas bien, nous devons d'abord comprendre ce qui se passe quand Unity construit notre jeu. Savoir ce qui se passe en arrière plan nous aidera à prendre des décisions éclairées sur la façon dont nous pouvons améliorer les performances de notre jeu.

Le processus du build

Lorsque nous construisons notre jeu, Unity emballe tout ce dont nous avons besoin pour exécuter notre jeu dans un programme qui peut être exécuté par notre appareil cible. Les CPU ne peuvent exécuter du code écrit que dans des langages très simples connus sous le nom de code machine ou code natif; ils ne peuvent pas exécuter du code écrit dans des langages plus complexes comme C#. Cela signifie que Unity doit traduire notre code dans d'autres langues. Ce processus de traduction s'appelle la compilation.

Unity compile d'abord nos scripts dans un langage appelé Common Intermediate Language (CIL). CIL est un langage facile à compiler dans une large gamme de différentes langues de code natif. Le CIL est ensuite compilé en code natif pour notre périphérique cible spécifique. Cette deuxième étape se produit soit lorsque nous construisons notre jeu (appelé compilation d'avance ou compilation AOT), soit sur le périphérique cible lui-même, juste avant l'exécution du code (appelé compilation juste à temps ou compilation JIT). Que notre jeu utilise la compilation AOT ou JIT dépend généralement du matériel cible.

La relation entre le code que nous écrivons et le code compilé

Le code qui n' a pas encore été compilé est connu sous le nom de code source. Le code source que nous écrivons détermine la structure et le contenu du code compilé.

Pour la plupart, le code source qui est bien structuré et efficace se traduira par un code compilé qui est bien structuré et efficace. Cependant, il est utile pour nous de connaître un peu le code natif afin de mieux comprendre pourquoi le code source est compilé en code natif plus efficace.

Premièrement, certaines instructions CPU prennent plus de temps à exécuter que d'autres. Un exemple de ceci est le calcul d'une racine carrée. Ce calcul prend plus de temps à l'exécution au CPU que, par exemple, la multiplication de deux nombres. La différence entre une instruction CPU rapide et une instruction CPU lente est minime, mais il est utile de comprendre que, fondamentalement, certaines instructions sont simplement plus rapides que d'autres.

La prochaine chose que nous devons comprendre est que certaines opérations qui semblent très simples dans le code source peuvent être étonnamment complexes quand elles sont compilées en code. Un exemple est l'insertion d'un élément dans une liste. Il faut beaucoup plus d'instructions pour effectuer cette opération que, par exemple, l'accès à un élément depuis un tableau par index. Encore une fois, lorsqu'on considère un exemple individuel, on parle d'une quantité minime de temps, mais il est important de comprendre que certaines opérations donnent lieu à plus d'instructions que d'autres.

Comprendre ces idées nous aidera à comprendre pourquoi un code fonctionne mieux que d'autres, même si les deux exemples font des choses assez similaires. Même une connaissance limitée du fonctionnement des choses à un niveau peu élevé peut nous aider à écrire des jeux qui fonctionnent bien.

Communication Runtime entre le code Unity Engine et notre code script

Il est utile pour nous de comprendre que nos scripts écrits en C# fonctionnent d'une manière légèrement différente du code qui constitue la majeure partie de l'Unity Engine. La plupart des fonctionnalités de base du moteur Unity Engine est écrit en C++ et a déjà été compilé en code natif. Ce code moteur compilé fait partie de ce que nous installons lorsque nous installons Unity.

Le code compilé vers CIL, tel que notre code source, est connu sous le nom de code géré. Lorsque le code géré est compilé en code natif, il est intégré avec ce que l'on appelle le runtime géré. Le runtime géré s'occupe de choses comme la gestion automatique de la mémoire et les contrôles de sécurité pour s'assurer qu'un bogue dans notre code entraînera une exception plutôt qu'un crash du périphérique.

Lorsque le CPU passe du code moteur en cours d'exécution au code géré, il faut travailler à la mise en place de ces contrôles de sécurité. Lors du transfert des données du code géré vers le code moteur, l'unité centrale peut avoir besoin de travailler pour convertir les données du format utilisé par le runtime géré au format requis par le code moteur. Cette conversion est connue sous le nom de rassemblement. Encore une fois, les frais généraux de tout appel unique entre le code géré et le code moteur ne sont pas particulièrement élevés, mais il est important que nous comprenions que ce coût existe.

Les causes d'un code peu performant

Maintenant que nous comprenons ce qui arrive à notre code lorsque Unity construit et exécute notre jeu, nous pouvons comprendre que lorsque notre code fonctionne mal, c'est parce qu'il crée trop de travail pour le CPU au moment de l'exécution. Voyons les différentes raisons.

La première possibilité est que notre code soit tout simplement un gaspillage ou mal structuré. Un exemple de ceci pourrait être le code qui fait le même appel de fonction à plusieurs reprises quand il pourrait faire l'appel seulement une fois. Cet article couvrira plusieurs exemples courants de mauvaise structure et montrera des exemples de solutions.

La deuxième possibilité est que notre code semble bien structuré, mais qu'il fait inutilement des appels coûteux vers d'autres codes. Un exemple de ceci pourrait être le code qui se traduit par des appels inutiles entre le code géré et le code moteur. Cet article donne des exemples d'appels Unity API qui peuvent être inopinément coûteux, avec des alternatives suggérées qui sont plus efficaces.

La prochaine possibilité est que notre code soit efficace mais qu'il soit appelé quand il n' a pas besoin de l'être. Un exemple de ceci pourrait être le code qui simule la ligne de vue d'un ennemi. Le code lui-même peut bien fonctionner, mais il est inutile d'exécuter ce code quand le joueur est très loin de l'ennemi. Cet article contient des exemples de techniques qui peuvent nous aider à écrire du code qui ne fonctionne que lorsqu'il le faut.

La dernière possibilité est que notre code soit tout simplement trop exigeant. Un exemple de ceci pourrait être une simulation très détaillée où un grand nombre d'agents utilisent des IA complexes. Si nous avons épuisé d'autres possibilités et optimisé autant que possible ce code, alors nous devrons peut-être simplement reconcevoir notre jeu pour le rendre moins exigeant: par exemple, simuler des éléments de notre simulation plutôt que de les calculer. La mise en œuvre de ce type d'optimisation dépasse le cadre de cet article car il est extrêmement dépendant du jeu lui-même, mais il nous sera toujours bénéfique de lire l'article et d'examiner comment rendre notre jeu aussi performant que possible.

Améliorer la performance de notre code

Une fois que nous avons établi que les problèmes de performance dans notre jeu sont dus à notre code, nous devons réfléchir soigneusement à la façon de résoudre ces problèmes. L'optimisation d'une fonction exigeante peut sembler être un bon point de départ, mais il se peut que la fonction en question soit déjà aussi optimale qu'elle peut l'être et qu'elle soit tout simplement coûteuse par nature. Au lieu de changer cette fonction, il peut y avoir une petite économie d'efficacité que nous pouvons faire dans un script qui est utilisé par des centaines de GameObjects qui nous donne une augmentation de performance beaucoup plus utile. De plus, l'amélioration des performances CPU de notre code peut avoir un coût: les changements peuvent augmenter l'utilisation de la mémoire ou décharger le travail sur le GPU.

Pour ces raisons, cet article n'est pas un ensemble de simples étapes à suivre. Cet article est plutôt une série de suggestions pour améliorer la performance de notre code, avec des exemples de situations où ces suggestions peuvent être appliquées. Comme pour toute optimisation des performances, il n' y a pas de règles rigides et rapides. La chose la plus importante à faire est de profiler notre jeu, comprendre la nature du problème, expérimenter différentes solutions et mesurer les résultats de nos changements.

Rédiger un code efficace

Rédiger un code efficace et le structurer judicieusement peut améliorer les performances de notre jeu. Bien que les exemples présentés se situent dans le contexte d'un jeu Unity, ces suggestions de bonnes pratiques générales ne sont pas spécifiques aux projets Unity ou aux appels Unity API.

Sortir le code des boucles si possible

Les boucles sont un endroit commun pour les inefficacités, surtout lorsqu'elles sont imbriquées. Les inefficacités peuvent vraiment s'additionner si elles sont dans une boucle qui tourne très fréquemment, surtout si ce code est trouvé sur de nombreux GameObjects dans notre jeu.

Dans l'exemple simple suivant, notre code itère à travers la boucle chaque fois que Update() est appelé, que la condition soit remplie ou non.

C#void Update()
{
    for (int i = 0; i < myArray.Length; i++)
    {
        if (exampleBool)
        {
            ExampleFunction(myArray[i]);
        }
    }
}

Avec un changement simple, le code itère dans la boucle seulement si la condition est remplie.

C#void Update()
{
    if (exampleBool)
    {
        for (int i = 0; i < myArray.Length; i++)
        {
            ExampleFunction(myArray[i]);
        }
    }
}

C'est un exemple simplifié, mais il illustre une réelle économie que nous pouvons réaliser. Nous devrions examiner notre code pour repérer les endroits où nous avons mal structuré nos boucles.

Déterminez si le code doit être exécuté à chaque frame

Update() est une fonction qui est exécutée une fois par frame par Unity. Update() est un endroit pratique pour mettre du code qui doit être appelé fréquemment, ou du code qui doit répondre à des changements fréquents. Cependant, tout ce code n'a pas besoin d'exécuter toutes les images. Sortir le code de Update() pour qu'il ne s'exécute que lorsqu'il est nécessaire peut être un bon moyen d'améliorer les performances.

Exécuter le code seulement quand les choses changent

Voyons un exemple très simple d'optimisation du code pour qu'il ne fonctionne que lorsque les choses changent. Dans le code suivant, DisplayScore() est appelé dans Update(). Cependant, la valeur du score peut ne pas changer dans chaque frame. Cela signifie que nous appelons inutilement DisplayScore().

C#private int score;

public void IncrementScore(int incrementBy)
{
    score += incrementBy;
}

void Update()
{
    DisplayScore(score);
}

Avec un simple changement, nous nous assurons maintenant que DisplayScore() n'est appelé que lorsque la valeur du score a changé.

C#private int score;

public void IncrementScore(int incrementBy)
{
    score += incrementBy;
    DisplayScore(score);
}

Encore une fois, l'exemple ci-dessus est délibérément simplifié, mais le principe est clair. Si nous appliquons cette approche dans l'ensemble de notre code, nous pourrions être en mesure d'économiser les ressources CPU.

Exécuter le code toutes les X frames

Si le code doit s'exécuter fréquemment et ne peut pas être déclenché par un événement, cela ne veut pas dire qu'il doit exécuter toutes les frames. Dans ces cas, nous pouvons choisir d'exécuter le code toutes les X frames.

Dans cet exemple de code, une fonction coûteuse s'exécute une fois par frame.

C#void Update()
{
    ExampleExpensiveFunction();
}

En fait, il suffirait pour nos besoins d'exécuter ce code une fois toutes les 3 frames. Dans le code suivant, nous utilisons l'opérateur de module pour s'assurer que la fonction coûteuse ne fonctionne que une frame sur trois.

C#private int interval = 3;

void Update()
{
    if (Time.frameCount % interval == 0)
    {
        ExampleExpensiveFunction();
    }
}

Un autre avantage de cette technique est qu'il est très facile de répandre un code coûteux sur des frames séparées, évitant ainsi les pics. Dans l'exemple suivant, chacune des fonctions est appelée une fois toutes les 3 frames et jamais sur la même frame.

C#private int interval = 3;

void Update()
{
    if (Time.frameCount % interval == 0)
    {
        ExampleExpensiveFunction();
    }
    else if (Time.frameCount % interval == 1)
    {
        AnotherExampleExpensiveFunction();
    }
}

Utiliser la mise en cache

Si notre code fait appel à plusieurs reprises à des fonctions coûteuses qui renvoient un résultat, puis rejette ces résultats,

cela peut être une opportunité d'optimisation

. Le stockage et la réutilisation des références à ces résultats peuvent être plus efficaces. Cette technique est connue sous le nom de mise en cache.

Dans Unity, il est courant d'appeler GetComponent() pour accéder aux composants. Dans l'exemple suivant, nous appelons GetComponent() dans Update() pour accéder à un composant Renderer avant de le passer à une autre fonction. Ce code fonctionne, mais il est inefficace à cause de l'appel répété de GetComponent().

C#void Update()
{
    Renderer myRenderer = GetComponent();
    ExampleFunction(myRenderer);
}

Le code suivant n'appelle GetComponent() qu'une seule fois, à la suite de la fonction est mis en cache. Le résultat mis en cache peut être réutilisé dans Update() sans autre appel à GetComponent().

C#private Renderer myRenderer;

void Start()
{
    myRenderer = GetComponent();
}

void Update()
{
    ExampleFunction(myRenderer);
}

Nous devrions examiner notre code pour les cas où nous faisons des appels fréquents à des fonctions qui renvoient un résultat. Il est possible que nous puissions réduire le coût de ces appels en utilisant la mise en cache.

Utiliser la bonne structure de données

La façon dont nous structurons nos données peut avoir un impact important sur la performance de notre code. Il n'existe pas de structure de données idéale pour toutes les situations, donc pour obtenir les meilleures performances de notre jeu, nous devons utiliser la bonne structure de données pour chaque tâche.

Pour prendre la bonne décision quant à la structure de données à utiliser, nous devons comprendre les forces et les faiblesses des différentes structures de données et réfléchir soigneusement à ce que nous voulons que notre code fasse.Nous pouvons avoir des milliers d'éléments que nous devons itérer une fois par frame, ou nous pouvons avoir un petit nombre d'éléments que nous devons fréquemment ajouter et supprimer. Ces différents problèmes seront mieux résolus avec différentes structures de données.

Prendre les bonnes décisions ici dépend de notre connaissance du sujet. Le meilleur endroit pour commencer, si c'est un nouveau domaine de connaissance, est d'apprendre à connaître Big O Notation. Big O Notation est la façon dont la complexité algorithmique est discutée, et la compréhension de cela nous aidera à comparer différentes structures de données. Cet article est un guide clair et facile à comprendre. Nous pouvons ensuite en apprendre davantage sur les structures de données à notre disposition et les comparer afin de trouver les bonnes solutions de données pour différents problèmes. Ce guide MSDN sur les collections et les structures de données en c# donne des conseils généraux sur le choix de structures de données appropriées et fournit des liens vers une documentation plus approfondie.

Il est peu probable qu'un seul choix concernant les structures de données ait un impact important sur notre jeu. Cependant, dans un jeu de données qui implique un grand nombre de telles collections, les résultats de ces choix peuvent vraiment s'additionner. Une compréhension de la complexité algorithmique et des forces et faiblesses des différentes structures de données nous aidera à créer un code qui fonctionne bien.

Minimiser l'impact de la récupération de place

Garbage Collection est une opération qui intervient dans le cadre de la gestion de la mémoire par Unity. La façon dont notre code utilise la mémoire détermine la fréquence et le coût du processeur de la récupération de place. Il est donc important que nous comprenions le fonctionnement de la récupération de place.

Cet article traite en profondeur du thème de la récupération de place et propose plusieurs stratégies pour minimiser son impact.

Utiliser le pool d'objets

Il est généralement plus coûteux d'instancier et de détruire un objet que de le désactiver et de le réactiver. Ceci est particulièrement vrai si l'objet contient du code de démarrage, tel que des appels à GetComponent() dans une fonction Awake() ou Start(). Si nous devons frayer et disposer de nombreuses copies d'un même objet, comme des balles dans un jeu de tir, alors nous pouvons bénéficier du regroupement d'objets.

La mise en commun d'objets est une technique où, au lieu de créer et de détruire des instances d'un objet, les objets sont temporairement désactivés, puis recyclés et réactivés selon les besoins. Bien que bien connue comme technique de gestion de l'utilisation de la mémoire, la mise en commun d'objets peut également être utile comme technique pour réduire l'utilisation excessive du CPU.

Un guide complet sur la mise en commun d'objets dépasse le cadre de cet article, mais c'est une technique très utile et qui vaut la peine d'être apprise. Ce tutoriel sur la mise en commun d'objets sur le site Unity Learn est un excellent guide pour implémenter un système de mise en commun d'objets dans Unity.

Éviter les appels coûteux vers l'API d'Unity

Parfois, les appels que notre code fait à d'autres fonctions ou API peuvent être inopinément coûteux. Il pourrait y avoir plusieurs raisons à cela. Ce qui ressemble à une variable pourrait en fait être un accesseur qui contient du code additionnel, déclenche un événement ou fait un appel du code géré vers le code moteur.

Dans cette section, nous allons examiner quelques exemples d'appels Unity API qui sont plus coûteux qu'ils ne paraissent. Nous examinerons comment nous pourrions réduire ou éviter ces coûts. Ces exemples illustrent différentes causes sous-jacentes du coût, et les solutions proposées peuvent être appliquées à d'autres situations similaires.

Il est important de comprendre qu'il n' y a pas de liste d'appels à l'API d'Unity que nous devrions éviter. Chaque appel API peut être utile dans certaines situations et moins utile dans d'autres. Dans tous les cas, nous devons établir un profil de notre jeu avec soin, identifier la cause du code coûteux et réfléchir soigneusement à la façon de résoudre le problème d'une manière qui soit la meilleure pour notre jeu.

SendMessage()

SendMessage() et BroadcastMessage() sont des fonctions très flexibles qui requièrent peu de connaissances sur la façon dont un projet est structuré et sont très rapides à mettre en œuvre. Ces fonctions sont donc très utiles pour le prototypage ou pour les scripts de niveau débutant. Cependant, leur utilisation est extrêmement coûteuse. C'est parce que ces fonctions utilisent la réflexion. La réflexion est le terme utilisé lorsque le code examine et prend des décisions sur lui-même au moment de l'exécution plutôt qu'au moment de la compilation. Le code qui utilise la réflexion produit beaucoup plus de travail pour le processeur que le code qui n'utilise pas la réflexion.

Il est recommandé que SendMessage() et BroadcastMessage() ne soient utilisés que pour le prototypage et que d'autres fonctions soient utilisées dans la mesure du possible. Par exemple, si nous savons sur quel composant nous voulons appeler une fonction, nous devons référencer le composant directement et appeler la fonction de cette façon. Si nous ne savons pas sur quel composant nous souhaitons appeler une fonction, nous pourrions envisager d'utiliser Events ou Delegates.

Find()

Find() et les fonctions connexes sont puissantes mais coûteuses. Ces fonctions requièrent à Unity de itérer sur chaque GameObject et Composant en mémoire. Cela signifie qu'ils ne sont pas particulièrement exigeants dans le cas de petits projets simples, mais qu'ils deviennent plus coûteux à utiliser à mesure que la complexité d'un projet augmente.

Il est préférable d'utiliser rarement Find() et des fonctions similaires et de cacher les résultats si possible. Certaines techniques simples qui peuvent nous aider à réduire l'utilisation de Find() dans notre code incluent le paramétrage des références à des objets en utilisant le panneau Inspector si possible, ou la création de scripts qui gèrent les références à des objets fréquemment recherchés.

Transform

Le réglage de la position ou de la rotation d'une transformation provoque la propagation d'un événement OnTransformChanged interne à tous les enfants de cette transformation. Cela signifie qu'il est relativement coûteux de définir la position et les valeurs de rotation d'une transformation, en particulier dans les transformations qui ont beaucoup d'enfants.

Pour limiter le nombre de ces événements internes, il faut éviter de fixer la valeur de ces propriétés plus souvent que nécessaire. Par exemple, nous pouvons effectuer un calcul pour définir la position x d'une transformation, puis un autre pour définir sa position z dans Update(). Dans cet exemple, nous devrions envisager de copier la position Transform dans un Vector3, d'effectuer les calculs requis sur ce Vector3 et ensuite de régler la position Transform à la valeur de ce Vector3. Il en résulterait un seul événement OnTransformChanged.

Transform.position est un exemple d'un accesseur qui se traduit par un calcul en arrière plan. Ceci peut être contrasté avec Transform.localPosition.local. La valeur de localPosition est stockée dans le Transform et l'appel Transform.localPosition retourne simplement cette valeur. Cependant, la position mondiale du Transform est calculée chaque fois que nous appelons Transform.position.

Si notre code fait un usage fréquent de Transform.position et que nous pouvons utiliser Transform.localPosition à sa place, cela se traduira par moins d'instructions CPU et pourrait finalement bénéficier de performances. Si nous utilisons fréquemment Transform.position, nous devrions le mettre en cache si possible.

Update()

Update(), LateUpdate() et d'autres fonctions d'événement ressemblent à des fonctions simples, mais elles ont une surcharge cachée. Ces fonctions nécessitent une communication entre le code moteur et le code géré à chaque appel. En outre, Unity effectue un certain nombre de contrôles de sécurité avant d'appeler ces fonctions. Les contrôles de sécurité s'assurent que le GameObject est dans un état valide, n' a pas été détruit, et ainsi de suite. Ce coût supplémentaire n'est pas particulièrement important pour un seul appel, mais il peut s'accumuler dans un jeu qui a des milliers de comportements MonoBehaviours.

Pour cette raison, les appels vides de Update() peuvent être particulièrement coûteux. On peut supposer que parce que la fonction est vide et que notre code ne contient pas d'appels directs, la fonction vide ne fonctionnera pas. Ce n'est pas le cas: en arrière plan, ces contrôles de sécurité et appels natifs se produisent même si le corps de la fonction Update() est vide. Pour éviter de perdre du temps CPU, nous devons veiller à ce que notre jeu ne contienne pas d'appels vides de Update().

Si notre jeu a beaucoup de MonoBehaviours actifs avec des appels Update(), nous pouvons bénéficier de structurer notre code différemment pour réduire cette surcharge. Ce billet du blog d'Unity sur ce sujet va beaucoup plus en détail sur ce sujet.

Vector2 et Vector3

Nous savons que certaines opérations entraînent simplement plus d'instructions CPU que d'autres opérations. Les opérations de maths vectorielles en sont un exemple: elles sont tout simplement plus complexes que les opérations flottantes ou int math. Bien que la différence réelle entre le temps nécessaire pour deux de ces calculs soit minime, à une échelle suffisante, de telles opérations peuvent avoir un impact sur les performances.

Il est courant et commode d'utiliser les structures Vector2 et Vector3 d'Unity pour les opérations mathématiques, en particulier lorsqu'il s'agit de transformations. Si nous effectuons de nombreuses opérations mathématiques Vector2 et Vector3 fréquentes dans notre code, par exemple dans des boucles imbriquées dans Update() sur un grand nombre d'objets GameObjects, nous pourrions bien créer du travail inutile pour le CPU. Dans ces cas, nous pouvons peut-être réaliser une économie de performance en effectuant des calculs int ou float à la place.

Plus tôt dans cet article, nous avons appris que les instructions CPU requises pour effectuer un calcul de racine carrée sont plus lentes que celles utilisées pour, disons, la simple multiplication. Vector2.magnitude et Vector3.magnitude en sont des exemples, car elles impliquent toutes deux des calculs de racine carrée. De plus, Vector2.Distance et Vector3.Distance utilisent la magnitude en arrière plan.

Si notre jeu fait un usage extensif et très fréquent de l'amplitude ou de la distance, il peut nous être possible d'éviter le calcul relativement coûteux de la racine carrée en utilisant Vector2.sqrMagnitude et Vector3.sqrMagnitude à la place. Encore une fois, le remplacement d'un seul appel n'entraînera qu'une différence minime, mais à une échelle suffisamment grande, il peut être possible de réaliser une économie de performance utile.

Camera.main

Camera.main est un appel pratique de l'API d'Unity qui renvoie une référence au premier composant Camera activé qui est étiqueté avec "Main Camera". C'est un autre exemple de quelque chose qui ressemble à une variable mais qui est en fait un accesseur. Dans ce cas, l'accesseur appelle une fonction interne similaire à Find() en arrière plan.

Camera.main souffre donc du même problème que Find() : il recherche dans tous les GameObjects et Composants en mémoire et peut être très coûteux à utiliser.

Pour éviter cet appel potentiellement coûteux, nous devons soit mettre en cache le résultat de Camera.main, soit éviter son utilisation et gérer manuellement les références à nos caméras.

Autres appels d'API d'Unity et autres optimisations

Nous avons examiné quelques exemples courants d'appels Unity API qui peuvent s'avérer inopinément coûteux, et nous avons pris connaissance des différentes raisons de ce coût. Toutefois, il ne s'agit nullement d'une liste exhaustive de moyens d'améliorer l'efficacité de nos appels Unity API.

Cet article sur la performance dans Unity est un guide d'optimisation de Unity qui contient un certain nombre d'autres optimisations de l'API d'Unity que nous pouvons trouver utiles. En outre, cet article approfondit considérablement les optimisations qui dépassent le cadre de cet article relativement de haut niveau et facile à lire pour les débutants.

Exécuter du code uniquement lorsqu'il doit s'exécuter

Il y a un dicton dans la programmation : "le code le plus rapide est le code qui ne tourne pas". Souvent, la façon la plus efficace de résoudre un problème de performance n'est pas d'utiliser une technique avancée: c'est simplement de supprimer le code qui n' a pas besoin d'être là en premier lieu. Examinons quelques exemples pour voir où nous pourrions faire ce genre d'économies.

Culling

Unity contient le code qui vérifie si les objets se trouvent dans le frustum d'une caméra. S'ils ne sont pas dans le frustum d'une caméra, le code relatif au rendu de ces objets ne fonctionne pas. Le terme pour cela est frustum culling.

Nous pouvons adopter une approche similaire au code de nos scripts. Si nous avons un code qui se rapporte à l'état visuel d'un objet, il se peut que nous n'ayons pas besoin d'exécuter ce code lorsque l'objet ne peut pas être vu par le joueur. Dans une scène complexe avec de nombreux objets, cela peut se traduire par des économies de performance considérables.

Dans le code d'exemple simplifié suivant, nous avons un exemple d'ennemi patrouillant. Chaque fois que Update() est appelé, le script contrôlant cet ennemi appelle deux fonctions d'exemple: l'une liée au déplacement de l'ennemi, l'autre liée à son état visuel.

C#void Update()
{
    UpdateTransformPosition();
    UpdateAnimations();
}

Dans le code suivant, nous vérifions maintenant si le rendu de l'ennemi se trouve dans le frustum de n'importe quelle caméra. Le code lié à l'état visuel de l'ennemi ne fonctionne que si l'ennemi est visible.

C#private Renderer myRenderer;

void Start()
{
    myRenderer = GetComponent();
}

void Update()
{
    UpdateTransformPosition();

    if (myRenderer.isVisible)
    {
        UpateAnimations();
    }
}

Désactiver le code lorsque les choses ne sont pas vues par le joueur peut se faire de plusieurs façons. Si nous savons qu'il y a certains objets dans notre scène qui ne sont pas visibles à un moment particulier du jeu, nous pouvons les désactiver manuellement. Lorsque nous sommes moins certains et que nous avons besoin de calculer la visibilité, nous pourrions utiliser un calcul grossier (par exemple, vérifier si l'objet est derrière le joueur), des fonctions telles que OnBecameInvisible() et OnBecameVisible(), ou un raycast plus détaillé. La meilleure mise en œuvre dépend beaucoup de notre jeu, et l'expérimentation et le profilage sont essentiels.

Niveau de détail

Le niveau de détail, également connu sous le nom de LOD, est une autre technique courante d'optimisation du rendu. Les objets les plus proches du joueur sont rendus en toute fidélité à l'aide de meshes et textures détaillés. Les objets distants utilisent des meshes et des textures moins détaillés. Une approche similaire peut être utilisée avec notre code. Par exemple, nous pouvons avoir un ennemi avec un script d'IA qui détermine son comportement. Une partie de ce comportement peut impliquer des opérations coûteuses pour déterminer ce qu'il peut voir et entendre, et comment il doit réagir à cette entrée. Nous pourrions utiliser un système de niveau de détail pour activer et désactiver ces opérations coûteuses en fonction de la distance de l'ennemi par rapport au joueur. Dans une scène avec beaucoup de ces ennemis, nous pourrions faire une économie considérable de performance si seulement les ennemis les plus proches effectuent les opérations les plus chères.

L'API CullingGroup d'Unity nous permet de nous connecter au système LOD d'Unity pour optimiser notre code. La page Manuel de l'API de CullingGroup contient plusieurs exemples de la façon dont cela pourrait être utilisé dans notre jeu. Comme toujours, nous devrions tester, profiler et trouver la bonne solution pour notre jeu.

Conclusion

Nous avons appris comment s'exécute le code que nous écrivons lorsque notre jeu Unity est construit et exécuté, pourquoi notre code peut causer des problèmes de performances et comment minimiser l'impact des coûts sur notre jeu. Nous avons pris connaissance d'un certain nombre de causes communes de problèmes de performance dans notre code, et avons envisagé quelques solutions différentes. En utilisant ces connaissances et nos outils de profilage, nous devrions maintenant pouvoir diagnostiquer, comprendre et corriger les problèmes de performance liés au code dans notre jeu.

les réactions

Pour laisser un avis, vous devez être inscrit et connecté

Se connecter