paint-brush
Exploration de Unity DOTS et ECS : cela change-t-il la donne ?by@deniskondratev
3,196
3,196

Exploration de Unity DOTS et ECS : cela change-t-il la donne ?

Denis Kondratev12m2023/07/18
Read on Terminal Reader

Cet article se penche sur la pile technologique orientée données (DOTS) et le système de composants d'entité (ECS) de Unity, qui optimisent le développement de jeux grâce à une architecture simple et intuitive. Ces technologies, associées à des packages Unity supplémentaires, permettent la création de jeux performants et efficaces. Les avantages de ces systèmes sont démontrés à travers un exemple d'algorithme du jeu de la vie de Conway.
featured image - Exploration de Unity DOTS et ECS : cela change-t-il la donne ?
Denis Kondratev HackerNoon profile picture
0-item
1-item
2-item

Unity DOTS permet aux développeurs d'utiliser tout le potentiel des processeurs modernes et de proposer des jeux hautement optimisés et efficaces - et nous pensons qu'il vaut la peine d'y prêter attention.


Cela fait plus de cinq ans que Unity a annoncé pour la première fois le développement de sa pile technologique orientée données (DOTS). Maintenant, avec la sortie de la version de support à long terme (LTS), Unity 2023.3.0f1, nous avons enfin vu une version officielle. Mais pourquoi Unity DOTS est-il si important pour l'industrie du développement de jeux, et quels avantages cette technologie offre-t-elle ?


Bonjour à tous! Je m'appelle Denis Kondratev et je suis développeur Unity chez MY.GAMES. Si vous avez hâte de comprendre ce qu'est Unity DOTS et si cela vaut la peine d'être exploré, c'est l'occasion idéale d'approfondir ce sujet fascinant, et dans cet article, nous le ferons.


Qu'est-ce que le système de composants d'entité (ECS) ?

À la base, DOTS implémente le modèle architectural ECS (Entity Component System). Pour simplifier ce concept, décrivons-le comme suit : ECS repose sur trois éléments fondamentaux : les entités, les composants et les systèmes.


Les entités , en elles-mêmes, n'ont aucune fonctionnalité ou description inhérente. Au lieu de cela, ils servent de conteneurs pour divers composants, qui leur confèrent des caractéristiques spécifiques pour la logique de jeu, le rendu d'objets, les effets sonores, etc.


Les composants , à leur tour, sont de différents types et ne stockent que des données sans leurs propres capacités de traitement indépendantes.


Le cadre ECS est complété par des systèmes qui traitent les composants, gèrent la création et la destruction d'entités et gèrent l'ajout ou la suppression de composants.


Par exemple, lors de la création d'un jeu "Space Shooter", le terrain de jeu comportera plusieurs objets : le vaisseau spatial du joueur, des ennemis, des astéroïdes, du butin, etc.



Tous ces objets sont considérés comme des entités à part entière, dépourvues de toute caractéristique distincte. Cependant, en leur attribuant différents composants, nous pouvons les imprégner d'attributs uniques.


Pour démontrer, considérant que tous ces objets possèdent des positions sur le terrain de jeu, nous pouvons créer un composant de position qui contient les coordonnées de l'objet. De plus, pour le vaisseau spatial, les ennemis et les astéroïdes du joueur, nous pouvons incorporer des composants de santé ; le système responsable de la gestion des collisions d'objets régira la santé de ces entités. De plus, nous pouvons attacher un composant de type ennemi aux ennemis, permettant au système de contrôle ennemi de régir leur comportement en fonction du type qui leur est attribué.


Bien que cette explication donne un aperçu simpliste et rudimentaire, la réalité est un peu plus complexe. Néanmoins, j'espère que le concept fondamental d'ECS est clair. Cela dit, examinons les avantages de cette approche.

Les avantages du système de composants d'entité

L'un des principaux avantages de l'approche Entity Component System (ECS) est la conception architecturale qu'elle favorise. La programmation orientée objet (POO) porte un héritage important avec des modèles tels que l'héritage et l'encapsulation, et même les programmeurs expérimentés peuvent faire des erreurs architecturales dans le feu du développement, conduisant à une refactorisation ou à une logique enchevêtrée dans des projets à long terme.


En revanche, ECS propose une architecture simple et intuitive. Tout tombe naturellement dans des composants et des systèmes isolés, ce qui facilite la compréhension et le développement en utilisant cette approche ; même les développeurs novices saisissent rapidement cette approche avec un minimum d'erreurs.


ECS suit une approche composite, où des composants isolés et des systèmes de comportement sont créés au lieu de hiérarchies d'héritage complexes. Ces composants et systèmes peuvent être facilement ajoutés ou supprimés, permettant des modifications flexibles des caractéristiques et du comportement de l'entité - cette approche améliore considérablement la réutilisation du code.


Un autre avantage clé d'ECS est l'optimisation des performances. Dans ECS, les données sont stockées en mémoire de manière contiguë et optimisée, avec des types de données identiques placés à proximité les uns des autres. Cela optimise l'accès aux données, réduit les échecs de cache et améliore les modèles d'accès à la mémoire. De plus, les systèmes composés de blocs de données séparés sont plus faciles à paralléliser sur différents processus, ce qui entraîne des gains de performances exceptionnels par rapport aux approches traditionnelles.

Explorer les packages de Unity DOTS

Unity DOTS englobe un ensemble de technologies fournies par Unity Technologies qui implémentent le concept ECS dans Unity. Il comprend plusieurs packages conçus pour améliorer différents aspects du développement de jeux ; couvrons quelques-uns d'entre eux maintenant.


Le cœur de DOTS est le package Entities , qui facilite la transition des MonoBehaviours et GameObjects familiers vers l'approche Entity Component System. Ce package constitue la base du développement basé sur DOTS.


Le package Unity Physics introduit une nouvelle approche de la gestion de la physique dans les jeux, atteignant une vitesse remarquable grâce à des calculs parallélisés.


De plus, le package Havok Physics for Unity permet l'intégration avec le moteur Havok Physics moderne. Ce moteur offre une détection de collision et une simulation physique hautes performances, alimentant des jeux populaires tels que The Legend of Zelda: Breath of the Wild, Doom Eternal, Death Stranding, Mortal Kombat 11, et plus encore.


Death Stranding, comme beaucoup d'autres jeux vidéo, utilise le populaire moteur Havok Physics.


Le package Entities Graphics se concentre sur le rendu dans DOTS. Il permet une collecte efficace des données de rendu et fonctionne de manière transparente avec les pipelines de rendu existants tels que Universal Render Pipeline (URP) ou High Definition Render Pipeline (HDRP).


Une dernière chose, Unity a également activement développé une technologie de mise en réseau appelée Netcode. Il comprend des packages tels que Unity Transport pour le développement de jeux multijoueurs de bas niveau, Netcode pour GameObjects pour les approches traditionnelles et le remarquable package Unity Netcode for Entities , qui s'aligne sur les principes DOTS. Ces forfaits sont relativement nouveaux et continueront d'évoluer à l'avenir.

Amélioration des performances dans Unity DOTS et au-delà

Plusieurs technologies étroitement liées au DOTS peuvent être utilisées dans le cadre du DOTS et au-delà. Le package Job System fournit un moyen pratique d'écrire du code avec des calculs parallèles. Il s'agit de diviser le travail en petits morceaux appelés travaux, qui effectuent des calculs sur leurs propres données. Le système de tâches répartit uniformément ces tâches sur les threads pour une exécution efficace.


Pour garantir la sécurité du code, le Job System prend en charge le traitement des types de données blittables. Les types de données blittables ont la même représentation dans la mémoire managée et non managée et ne nécessitent aucune conversion lorsqu'ils sont passés entre le code managé et non managé. Des exemples de types blittables incluent byte, sbyte, short, ushort, int, uint, long, ulong, float, double, IntPtr et UIntPtr. Les tableaux unidimensionnels de types primitifs blittables et les structures contenant exclusivement des types blittables sont également considérés comme blittables.


Cependant, les types contenant un tableau variable de types blittables ne sont pas eux-mêmes considérés comme blittables. Pour remédier à cette limitation, Unity a développé le package Collections , qui fournit un ensemble de structures de données non gérées à utiliser dans les travaux. Ces collections sont structurées et stockent les données dans une mémoire non gérée à l'aide des mécanismes Unity. Il est de la responsabilité du développeur de désallouer ces collections à l'aide de la méthode Disposal().


Un autre package important est le Burst Compiler , qui peut être utilisé avec le Job System pour générer du code hautement optimisé. Bien qu'il soit livré avec certaines limitations d'utilisation du code, le compilateur Burst offre une amélioration significative des performances.

Mesurer les performances avec Job System et Burst Compile

Comme mentionné, Job System et Burst Compiler ne sont pas des composants directs de DOTS mais fournissent une aide précieuse pour programmer des calculs parallèles efficaces et rapides. Testons leurs capacités à l'aide d'un exemple pratique : la mise en œuvre Algorithme du jeu de la vie de Conway . Dans cet algorithme, un champ est divisé en cellules, chacune pouvant être vivante ou morte. À chaque tour, nous vérifions le nombre de voisins vivants pour chaque cellule, et leurs états sont mis à jour selon des règles spécifiques.



Voici l'implémentation de cet algorithme en utilisant l'approche traditionnelle :


 private void SimulateStep() { Profiler.BeginSample(nameof(SimulateStep)); for (var i = 0; i < width; i++) { for (var j = 0; j < height; j++) { var aliveNeighbours = CountAliveNeighbours(i, j); var index = i * height + j; var isAlive = aliveNeighbours switch { 2 => _cellStates[index], 3 => true, _ => false }; _tempResults[index] = isAlive; } } _tempResults.CopyTo(_cellStates); Profiler.EndSample(); } private int CountAliveNeighbours(int x, int y) { var count = 0; for (var i = x - 1; i <= x + 1; i++) { if (i < 0 || i >= width) continue; for (var j = y - 1; j <= y + 1; j++) { if (j < 0 || j >= height) continue; if (_cellStates[i * width + j]) { count++; } } } return count; }


J'ai ajouté des marqueurs à Profiler pour mesurer le temps nécessaire aux calculs. Les états des cellules sont stockés dans un tableau unidimensionnel appelé _cellStates . Nous écrivons initialement les résultats temporaires dans _tempResults , puis les copions dans _cellStates une fois les calculs terminés. Cette approche est nécessaire car l'écriture du résultat final directement dans _cellStates affecterait les calculs ultérieurs.


J'ai créé un champ de 1000x1000 cellules et exécuté le programme pour mesurer les performances. Voici les résultats:



Comme le montrent les résultats, les calculs ont pris 380 ms.


Appliquons maintenant le Job System et Burst Compiler pour améliorer les performances. Dans un premier temps, nous allons créer le Job chargé d'exécuter l'algorithme Game of Life de Conway.


 public struct SimulationJob : IJobParallelFor { public int Width; public int Height; [ReadOnly] public NativeArray<bool> CellStates; [WriteOnly] public NativeArray<bool> TempResults; public void Execute(int index) { var i = index / Height; var j = index % Height; var aliveNeighbours = CountAliveNeighbours(i, j); var isAlive = aliveNeighbours switch { 2 => CellStates[index], 3 => true, _ => false }; TempResults[index] = isAlive; } private int CountAliveNeighbours(int x, int y) { var count = 0; for (var i = x - 1; i <= x + 1; i++) { if (i < 0 || i >= Width) continue; for (var j = y - 1; j <= y + 1; j++) { if (j < 0 || j >= Height) continue; if (CellStates[i * Width + j]) { count++; } } } return count; } }


J'ai attribué l'attribut [ReadOnly] au champ CellStates , permettant un accès illimité à toutes les valeurs du tableau à partir de n'importe quel thread. Cependant, pour le champ TempResults , qui a l'attribut [WriteOnly] , l'écriture ne peut se faire que via l'index spécifié dans la méthode Execute(int index) . Tenter d'écrire une valeur dans un index différent générera un avertissement. Cela garantit la sécurité des données lorsque vous travaillez en mode multithread.


Maintenant, à partir du code normal, lançons notre Job :


 private void SimulateStepWithJob() { Profiler.BeginSample(nameof(SimulateStepWithJob)); var job = new SimulationJob { Width = width, Height = height, CellStates = _cellStates, TempResults = _tempResults }; var jobHandler = job.Schedule(width * height, 4); jobHandler.Complete(); job.TempResults.CopyTo(_cellStates); Profiler.EndSample(); }


Après avoir copié toutes les données nécessaires, nous planifions l'exécution du travail à l'aide de la méthode Schedule() . Il est important de noter que cet ordonnancement n'exécute pas immédiatement les calculs : ces actions sont lancées à partir du thread principal et l'exécution se fait via des travailleurs répartis entre différents threads. Pour attendre la fin du travail, nous utilisons jobHandler.Complete() . Ce n'est qu'alors que nous pourrons copier le résultat obtenu dans _cellStates .


Mesurons la vitesse :



La vitesse d'exécution a presque décuplé et le temps d'exécution est maintenant d'environ 42 ms. Dans la fenêtre Profiler, nous pouvons voir que la charge de travail a été répartie entre 17 travailleurs. Ce nombre est légèrement inférieur au nombre de threads du processeur dans l'environnement de test, qui est un processeur Intel® Core™ i9-10900 avec 10 cœurs et 20 threads. Bien que les résultats puissent varier sur des processeurs avec moins de cœurs, nous pouvons garantir la pleine utilisation de la puissance du processeur.


Mais ce n'est pas tout - il est temps d'utiliser Burst Compiler, qui fournit une optimisation significative du code mais comporte certaines restrictions. Pour activer Burst Compiler, ajoutez simplement l'attribut [BurstCompile] au SimulationJob .


 [BurstCompile] public struct SimulationJob : IJobParallelFor { ... }


Mesurons encore :



Les résultats dépassent même les attentes les plus optimistes : la vitesse a été multipliée par près de 200 par rapport au résultat initial. Or, le temps de calcul pour 1 million de cellules n'est plus que de 2 ms. Dans Profiler, les parties exécutées par le code compilé avec le Burst Compiler sont surlignées en vert.

Conclusion

Bien que l'utilisation de calculs multithreads ne soit pas toujours nécessaire et que l'utilisation de Burst Compiler ne soit pas toujours possible, nous pouvons observer une tendance commune dans le développement des processeurs vers des architectures multicœurs. Cela signifie que nous devons être prêts à exploiter toute leur puissance. ECS, et plus particulièrement Unity DOTS, s'alignent parfaitement sur ce paradigme.


Je crois que Unity DOTS mérite au moins l'attention. Bien que ce ne soit pas la meilleure solution dans tous les cas, l'ECS peut faire ses preuves dans de nombreux jeux.


Le framework Unity DOTS, avec son approche orientée données et multithread, offre un énorme potentiel d'optimisation des performances dans les jeux Unity. En adoptant l'architecture Entity Component System et en tirant parti de technologies telles que Job System et Burst Compiler, les développeurs peuvent débloquer de nouveaux niveaux de performances et d'évolutivité.


À mesure que le développement de jeux continue d'évoluer et que le matériel progresse, adopter Unity DOTS devient de plus en plus précieux. Il permet aux développeurs d'exploiter tout le potentiel des processeurs modernes et de proposer des jeux hautement optimisés et efficaces. Bien que Unity DOTS ne soit pas la solution idéale pour tous les projets, il est sans aucun doute très prometteur pour ceux qui recherchent un développement et une évolutivité axés sur les performances.


Unity DOTS est un cadre puissant qui peut bénéficier de manière significative aux développeurs de jeux en améliorant les performances, en permettant des calculs parallèles et en embrassant l'avenir du traitement multicœur. Il vaut la peine d'explorer et d'envisager son adoption pour tirer pleinement parti du matériel moderne et optimiser les performances des jeux Unity.