[0:00]Au cours des dernières années, l'architecture ECS est devenue de plus en plus populaire dans l'industrie du jeu vidéo. La caractéristique la plus attrayante d'un ECS est l'excellente performance qu'il peut apporter à un programme. Il le fait en utilisant des techniques de conception orientées données afin d'obtenir le maximum du matériel.
[0:21]Afin de comprendre pourquoi la conception orientée données accélère un programme, nous devons comprendre le matériel avec lequel nous travaillons, en particulier le CPU et la mémoire principale. La racine du problème est qu'il existe un écart de performance significatif entre les processeurs et la mémoire. La performance des CPU a augmenté à un rythme de 60 % chaque année, tandis que la mémoire s'est améliorée de moins de 10 % chaque année, ce qui signifie que l'écart de performance augmente de 50 % chaque année. Accéder aux données ou aux instructions de la mémoire principale est une opération terriblement lente, qui peut prendre des centaines de cycles d'horloge. Cependant, les concepteurs de matériel ont reconnu ce problème et, comme solution, ils ont placé de petits morceaux de mémoire rapide dans le CPU, appelés caches.
[1:18]L'accès aux données stockées dans les caches est très rapide, ne prenant généralement que quelques cycles. Cependant, comme les caches sont de petite taille, il est important que les données transférées vers les caches ne soient pas gaspillées.
[1:32]Si les données n'existent pas dans le cache, elles sont chargées depuis la mémoire principale, ce qui, encore une fois, est lent. C'est ce qu'on appelle un "cache miss". Mais une fois chargées, les données sont ensuite transférées dans le cache, de sorte que nous n'aurons pas un autre "cache miss".
[1:56]Mais imaginez si les données qui viennent d'être éjectées du cache sont à nouveau nécessaires plus tard dans le programme. Cela causera un autre "cache miss" et les données devront être chargées à nouveau depuis la mémoire principale. Et puis quelque chose d'autre devra être éjecté du cache, et ainsi de suite. Le cycle continuera. Cela entraînera des latences inutiles dans votre programme, simplement en attendant que les données soient chargées à cause d'une mauvaise utilisation du cache. Pour utiliser le cache plus efficacement, les données doivent être utilisées en une seule fois, afin de ne pas courir le risque d'un "cache miss".
[2:34]Les données sont transférées par blocs de taille fixe, généralement 64 octets, appelés "cache line". Cela signifie que lorsqu'une donnée est accédée, vous n'obtenez pas seulement la mémoire pour cette donnée, vous obtenez également toutes les données proches sous forme de "cache line".
[2:56]Si nous devions accéder à un élément d'un tableau d'entiers, vous obtiendriez 16 entiers à la fois de la mémoire sous forme de "cache line", même si nous ne sommes intéressés que par l'élément unique que nous accédons.
[3:12]Et donc, à cause des "cache lines", il est important que les données qui doivent être utilisées ensemble soient stockées les unes à côté des autres en mémoire afin que les "cache lines" ne soient pas gaspillées.
[3:25]L'architecture ECS est spécifiquement conçue pour le développement de jeux et vise à utiliser le matériel de manière efficace en appliquant les choses que nous venons de discuter. ECS signifie entité, composant, système. Une entité est généralement utilisée comme identifiant unique pour un ensemble de composants. Un composant est simplement une structure de données passive, ce qui signifie qu'il ne contient que des données et aucun comportement. Cependant, un système n'a que des comportements et pas de données. Les systèmes prennent les composants comme entrées et transforment les données, qui sont généralement récupérées par un autre système.
[4:06]L'architecture ECS privilégie la composition sur l'héritage, ce qui signifie qu'une entité est construite en utilisant des composants au lieu d'hériter d'une classe de base. Pour donner un exemple, disons que nous avons deux acteurs, A et B. L'acteur A se déplace vers un emplacement aléatoire à chaque image. B a le même comportement mais tourne également à chaque image.
[4:36]En utilisant l'héritage, nous écririons la logique de déplacement dans l'acteur A, puis l'utiliserions comme parent pour l'acteur B. Nous écririons ensuite la logique de rotation dans B.
[4:50]Si nous utilisions plutôt l'ECS avec des compositions de composants, nous créerions un composant de localisation et un composant de rotation. Nous aurions ensuite deux systèmes : un système de déplacement et un système de rotation. Le système de déplacement met à jour la localisation de tous les composants de localisation, tandis que le système de rotation met à jour la rotation de tous les composants de rotation. L'acteur A n'aurait que le composant de localisation, tandis que l'acteur B aurait à la fois le composant de localisation et le composant de rotation. Nous avons maintenant atteint le même comportement en utilisant la composition au lieu de l'héritage, tout en permettant une plus grande flexibilité et réutilisabilité du code puisque nous pouvons plus facilement construire des entités en utilisant ces petits composants. Nous pourrions maintenant avoir un troisième type qui ne fait que tourner. Il lui suffit du composant de rotation. Parce que les systèmes s'exécutent sur tous les composants, nous n'avons pas besoin d'écrire de code supplémentaire, tout est déjà fait pour nous. Ajouter ce troisième type en utilisant l'héritage serait déjà un problème, car UObject ne permet pas l'héritage multiple de types UObject. Donc, travailler autour de cela ne ferait généralement que créer un désordre. Les composants sont disposés de manière contiguë en mémoire pour une meilleure utilisation du cache. Nous voulons rarement mettre à jour la localisation d'une seule entité dans une trame. Nous aimerions probablement mettre à jour plusieurs localisations à la fois.
[6:15]C'est pour cette raison que tous les composants d'un certain type sont stockés les uns à côté des autres en mémoire, de sorte qu'une ligne de cache ne contient que les données de ces composants.
[6:29]De cette façon, nous réduisons le nombre de "cache miss" lors de l'itération sur les composants, ce qui nous donne un gain de performance significatif. En bref, vous pourriez définir un ECS comme une base de données, car ils partagent beaucoup de similitudes. Vous avez des lignes de composants et des colonnes d'entités. Chaque ligne stocke tous les composants d'un certain type, et chaque colonne se traduit donc en une entité, complète avec toutes ses données.
[7:01]Évidemment, toutes les entités ne devraient pas avoir les mêmes types de composants, c'est pourquoi l'ECS, comme une base de données, a différentes tables pour chaque combinaison unique de composants. Dans cet exemple, nous avons une composition Rotation-Location-Move, qui est juste quelque chose qui peut se déplacer dans le monde. Ensuite, il y a une composition Rotation-Location-Move-Health, qui est aussi quelque chose de mobile comme la composition précédente, mais celle-ci a un composant de santé, ce qui implique qu'elle est endommageable ou guérissable. Ceci est fait par la composition Location-Targeter-Damager ou Healer, qui est statique dans le monde, car elle n'a que le composant de localisation et aucun composant de mouvement. Ces entités ciblent tout ce qui a un composant de santé en utilisant le composant cible, puis infligent des dégâts ou les soignent.
[7:58]Mais pourquoi devrions-nous nous en soucier ? Unreal Engine 4 est un moteur très capable qui pilote certains des meilleurs jeux actuels et fournit des frameworks pour optimiser les jeux sans utiliser aucune des techniques susmentionnées. Pour vous donner une idée, je vais citer Noel de Games From Within. Imaginez ceci : vers la fin du cycle de développement, votre jeu rampe, mais vous ne voyez aucun point chaud évident dans le profileur. Le coupable ? Les modèles d'accès aléatoire à la mémoire et les "cache misses" constants. Dans une tentative d'améliorer les performances, vous essayez de paralléliser des parties du code, mais cela demande des efforts héroïques, et, au final, vous obtenez à peine une accélération en raison de toute la synchronisation que vous avez dû ajouter. Pour couronner le tout, le code est si complexe que la correction des bugs crée plus de problèmes, et l'idée d'ajouter de nouvelles fonctionnalités est immédiatement écartée. Cela vous semble familier ? Pour ceux d'entre nous qui ont publié des jeux, cela semble en effet familier, du moins dans une certaine mesure. Je sais que je peux me rapporter aux problèmes qu'il décrit.
[9:09]Nous devons constamment sacrifier la vision et l'ampleur des jeux pour respecter les contraintes de performance, et c'est particulièrement vrai pour les consoles et les PC bas à moyen de gamme. Je pense que très bientôt, lorsque nous nous lancerons dans la création de jeux plus ambitieux, nous atteindrons la limite de ce que la manière traditionnelle d'écrire des jeux peut nous offrir en termes de performances.
[9:36]Alors, comment l'ECS fonctionne-t-il réellement en pratique ? Ici, vous pouvez voir une simulation de "boids" fonctionnant sur mon framework ECS en cours de développement dans Unreal. Une simulation de "boids" reproduit le comportement de vol des oiseaux et est définie par trois règles principales : éviter les autres compagnons de vol, se diriger vers la direction moyenne des compagnons de vol proches et se diriger vers l'emplacement moyen des compagnons de vol. Il y a donc une logique assez complexe impliquée, chaque entité a besoin d'une relation pour toutes les entités proches afin que les calculs corrects puissent être effectués. Sur ma machine, avec un Ryzen 3950X, cette démo exécute 1000 acteurs avec un temps de simulation total de seulement 4,8 millisecondes. Cela signifie qu'en théorie, je pourrais exécuter cette démo à près de 210 Hz. Cependant, la démo étant limitée par le GPU, je ne pouvais l'exécuter qu'à environ 100 Hz.
[10:38]Si nous comparons cette démo écrite en utilisant un ECS à quelque chose d'écrit de manière traditionnelle, les différences sont substantielles. Avec les mêmes 1000 acteurs, le temps de simulation total est de 14,4 millisecondes. Cela signifie que la version utilisant l'ECS est trois fois plus rapide. Mais gardez à l'esprit que mon CPU a un cache L1 de 1 mégaoctet, ce qui est plus que la plupart des CPU sur le marché, donc les effets de la mise en cache ne sont pas ressentis aussi fortement. La différence sera probablement encore plus grande sur une machine moins puissante en raison de la taille de cache plus petite.
[11:19]Alors, comment cette démo a-t-elle été réellement implémentée en utilisant l'ECS ? Le début de toute entité dans l'ECS commence par la disposition des données, dans ce cas, les composants. Premièrement, nous avons un composant de mouvement. Ces composants stockent toutes les données liées au mouvement, telles que la position, la vitesse et la direction. Ensuite, il y a un composant Boid. Celui-ci stocke les informations des entités proches et est utilisé pour calculer les données dans les composants de mouvement. Enfin, il y a le composant propriétaire, qui est juste là pour donner une référence à l'acteur afin que la position et la rotation mondiales puissent être définies sur l'acteur lui-même. Ensuite, ce sont les systèmes, en commençant par le système Boid. Le but de ce système est de calculer les données dans le composant Boid en itérant sur toutes les entités. Afin d'accéder aux composants, une requête est définie qui interroge toutes les entités avec un composant de mouvement et un composant Boid. La première étape consiste à extraire la position et la direction de chaque entité, car ce sont les seules propriétés nécessaires de ces composants. Nous faisons cela afin de remplir entièrement la ligne de cache.
[12:35]L'étape suivante parcourt toutes les entités de la requête et, à l'intérieur, parcourt à nouveau toutes les entités. Ici, nous mesurons la distance et si elle est à portée, les données du composant Boid sont définies. Ensuite, c'est le système de mouvement. Ce système calcule les valeurs dans les composants de mouvement en utilisant les nouvelles données définies dans les composants Boid. Tout d'abord, l'accélération est calculée en utilisant les données du composant Boid. Enfin, la vitesse est calculée, et ainsi, une nouvelle position et direction peuvent être déterminées.
[13:20]Enfin, il y a le système de mouvement Unreal. Ce système est juste là pour définir la position et la rotation de l'acteur sur le propriétaire en utilisant les données du composant de mouvement.
[13:33]C'est le cœur de l'implémentation. Maintenant, tout ce qui reste à faire est de connecter notre acteur au module ECS. Dans le "begin play" de l'acteur, un archétype est créé en utilisant les composants. Ensuite, une entité est créée, et enfin, la nouvelle entité est ajoutée à l'archétype avec les valeurs par défaut des composants. C'est ça ! Un système de boids très performant dans Unreal utilisant une architecture ECS.
[14:10]Le simple fait d'intégrer un ECS dans votre jeu ne résoudra pas tous les problèmes. Mais cela aidera beaucoup à écrire un code très performant et à créer des systèmes hautement modulaires et flexibles. Comme pour tout, il y a quelques inconvénients. C'est très différent de ce que les programmeurs apprennent et sont habitués à faire. On nous apprend à écrire du code de la même manière que nous voyons le monde. Bien que cela soit facile à comprendre et à apprendre, cela ne donnera pas les meilleurs résultats en termes de performances. Le désapprentissage et le changement de mentalité de ce que vous faites depuis des années, voire des décennies, sera un défi. Cela m'a pris un certain temps pour m'adapter. Il est également très difficile d'interfacer avec le code et les systèmes existants, car ils ne sont généralement pas développés de manière isolée. Donc, vous ne pourrez pas vraiment bénéficier d'un ECS, à moins que tout le sous-système ne soit écrit en l'utilisant. Et c'est pour cette raison qu'un jeu doit probablement être construit en utilisant un ECS dès le départ. Une simple bataille à mort 5v5 destinée au PC à 60 Hz pourrait ne pas être le meilleur candidat pour un ECS.
[15:40]Cependant, un jeu MMO en monde ouvert à grande échelle ou quelque chose qui doit être livré sur consoles et PC bas et moyen de gamme pourrait bénéficier d'un ECS.
[15:57]Ces sujets sont quelque chose qui a d'abord attiré mon attention en regardant la présentation d'Unreal Fest 2019 de Rare sur la façon dont ils ont implémenté un planificateur personnalisé pour augmenter la cohérence du cache des instructions. Depuis, cela m'a conduit sur un chemin qui m'a donné une nouvelle vision de la programmation dans son ensemble, et m'a fait remettre en question la façon dont j'ai appris et écrit du code pendant des années, et cela a en quelque sorte rendu la programmation amusante pour moi à nouveau.



