Qu'est-ce que la conception de moteur de jeu orienté données?

Vous avez peut-être entendu parler de la conception de moteur de jeu orientée données, un concept relativement nouveau proposant un état d'esprit différent de la conception orientée objet plus traditionnelle. Dans cet article, je vais expliquer en quoi consiste le DOD et pourquoi certains développeurs de moteurs de jeux estiment que cela pourrait être le ticket pour des gains de performances spectaculaires..

Un peu d'histoire

Dans les premières années du développement de jeux, les jeux et leurs moteurs étaient écrits dans des langages de la vieille école, tels que le langage C. Ils étaient un produit de niche et la priorité absolue consistait à extraire chaque dernier cycle d'horloge d'un matériel lent. Dans la plupart des cas, seul un nombre modeste de personnes pirataient le code d’un seul titre et connaissaient l’ensemble du code par cœur. Les outils qu’ils utilisaient leur servaient bien, et C offrait les avantages en termes de performances qui leur permettaient de tirer le meilleur parti du processeur - et comme ces jeux étaient toujours largement liés par le processeur, s’appuyant sur ses propres mémoires tampon de trames, c'était un point très important.

Avec l'avènement des GPU qui effectuent le traitement des chiffres sur les triangles, les texels, les pixels, etc., nous dépendons moins du processeur. Dans le même temps, l’industrie du jeu a connu une croissance soutenue: de plus en plus de gens veulent jouer de plus en plus de jeux, ce qui a amené de plus en plus d’équipes à s’unir pour les développer.. 

La loi de Moore montre que la croissance du matériel est exponentielle, et non linéaire par rapport au temps: cela signifie que tous les deux ans, le nombre de transistors que nous pouvons installer sur une seule carte ne change pas de manière constante: il double!

Les grandes équipes avaient besoin d'une meilleure coopération. Bientôt, les moteurs de jeu, avec leur niveau complexe, leur intelligence artificielle, leur cueillage et leur logique de rendu, obligeaient les codeurs à être plus disciplinés. conception orientée objet.

Comme Paul Graham l'a dit un jour: 

Dans les grandes entreprises, les logiciels ont tendance à être écrits par de grandes équipes de programmeurs médiocres (qui changent fréquemment). La programmation orientée objet impose à ces programmeurs une discipline qui empêche l’un d’eux de faire trop de dégâts.

Que cela nous plaise ou non, cela doit être vrai dans une certaine mesure: les grandes entreprises ont commencé à déployer des jeux plus grands et de meilleure qualité et, à mesure que la standardisation des outils est apparue, les pirates travaillant sur les jeux sont devenus des éléments faciles à échanger. La vertu d'un pirate particulier est devenue de moins en moins importante.

Problèmes liés à la conception orientée objet

Bien que la conception orientée objet soit un concept intéressant qui aide les développeurs de grands projets, tels que les jeux, à créer plusieurs couches d’abstraction et à ce que tout le monde travaille sur leur couche cible, sans avoir à se soucier des détails d’implémentation de ceux qui se trouvent en dessous. donne nous certains maux de tête.

Nous assistons à une explosion de programmeurs en parallèle exploitant tous les cœurs de processeurs disponibles pour fournir des vitesses de calcul époustouflantes, mais en même temps, le décor de jeu devient de plus en plus complexe et, si nous voulons suivre cette tendance tout en conservant les cadres. à la seconde, nos joueurs s’attendent, nous devons le faire aussi. En utilisant toute la vitesse dont nous disposons, nous pouvons ouvrir la porte à de nouvelles possibilités: utiliser le temps de calcul pour réduire le nombre de données envoyées au GPU, par exemple.

En programmation orientée objet, vous conservez l'état dans un objet, ce qui vous oblige à introduire des concepts tels que les primitives de synchronisation si vous souhaitez y travailler à partir de plusieurs threads. Vous disposez d'un nouveau niveau d'indirection pour chaque appel de fonction virtuelle que vous effectuez. Et les modèles d'accès à la mémoire générés par le code écrit d'une manière orientée objet pouvez être affreux-en fait, Mike Acton (Insomniac Games, ex-Rockstar Games) a un grand ensemble de diapositives expliquant de façon désinvolte un exemple. 

De même, Robert Harper, professeur à la Carnegie Mellon University, a déclaré: 

La programmation orientée objet est […] à la fois anti-modulaire et anti-parallèle, et par conséquent inadaptée à un cursus moderne de CS.

Parler de la POO de cette façon est délicat, car la POO englobe un vaste éventail de propriétés, et tout le monde n’est pas d’accord sur ce que signifie la POO. En ce sens, je parle principalement de la programmation orientée objet mise en œuvre par C ++, car c'est actuellement le langage qui domine le monde des moteurs de jeux..

Nous savons donc que les jeux doivent devenir parallèles car il y a toujours plus de travail que le processeur peut (mais ne doit pas nécessairement faire), et passer des cycles à attendre que le GPU ait fini de traiter ne sert à rien. Nous savons également que les approches de conception OO courantes exigent que nous introduisions des conflits de verrous coûteux et que, simultanément, elles peuvent violer la localisation du cache ou causer des branchements inutiles (ce qui peut être coûteux!) Dans les circonstances les plus inattendues..

Si nous ne tirons pas parti de plusieurs cœurs, nous continuons à utiliser la même quantité de ressources de la CPU, même si le matériel est arbitrairement meilleur (a plus de cœurs). Dans le même temps, nous pouvons pousser le processeur graphique à ses limites, car il est, de par sa conception, parallèle et capable d’assumer tout type de travail simultanément. Cela peut interférer avec notre mission de fournir aux joueurs la meilleure expérience sur leur matériel, car nous ne l'utilisons clairement pas à plein potentiel.

Cela soulève la question: devrions-nous repenser complètement nos paradigmes?

Entrez: Conception orientée données

Certains partisans de cette méthodologie ont appelé C’est une conception orientée données, mais la vérité est que le concept général est connu depuis bien plus longtemps. Son principe de base est simple: construisez votre code autour des structures de données et décrivez ce que vous voulez réaliser en termes de manipulation de ces structures

Nous avons déjà entendu ce genre de discours: Linus Torvalds, le créateur de Linux et de Git, a déclaré dans un message sur la liste de diffusion de Git qu'il était un fervent partisan de "la conception du code autour des données, pas l'inverse", et attribue cela à l'une des raisons du succès de Git. Il continue même en affirmant que la différence entre un bon programmeur et un mauvais programmeur réside dans le fait qu'elle s'inquiète des structures de données ou du code lui-même..

La tâche peut sembler paradoxale au début, car elle vous oblige à tourner votre modèle mental à l'envers. Pensez-y ainsi: un jeu en cours d’exécution capture toutes les entrées de l’utilisateur, ainsi que toutes ses composantes lourdes en performances (celles pour lesquelles il serait judicieux d’abandonner la norme tout est un objet philosophie) ne repose pas sur des facteurs extérieurs, tels que le réseau ou l’IPC. Tout ce que vous savez, un jeu consomme les événements de l’utilisateur (souris déplacée, bouton de la manette de commande enfoncé, etc.) et l’état actuel du jeu, et les transforme en un nouvel ensemble de données, par exemple des lots envoyés au GPU. Échantillons PCM envoyés à la carte son et nouvel état du jeu.

Ce «brassage de données» peut être décomposé en beaucoup plus de sous-processus. Un système d'animation prend les données d'image clé suivantes et l'état actuel pour produire un nouvel état. Un système de particules prend son état actuel (positions des particules, vitesses, etc.), son avance dans le temps et produit un nouvel état. Un algorithme de sélection prend un ensemble de candidats à rendre et produit un plus petit ensemble. Presque tout dans un moteur de jeu peut être considéré comme manipulant un bloc de données pour produire un autre bloc de données.

Les processeurs adorent la localité de référence et l'utilisation du cache. Ainsi, dans la conception orientée données, nous avons tendance, dans la mesure du possible, à tout organiser en grands tableaux homogènes et, dans la mesure du possible, à utiliser de bons algorithmes à force brute cohérents en cache à la place d'un algorithme potentiellement plus sophistiqué (qui a Big O meilleur coût, mais ne parvient pas à embrasser les limitations de l'architecture du matériel sur lequel il travaille). 

Lorsque cela est effectué par image (ou plusieurs fois par image), cela donne potentiellement d’énormes avantages en termes de performances. Par exemple, les employés de Scalyr signalent la recherche de fichiers journaux à 20 Go / s à l’aide d’un balayage linéaire soigneusement conçu mais d'une sonorité naïve.. 

Lorsque nous traitons des objets, nous devons les considérer comme des "boîtes noires" et appeler leurs méthodes, qui à leur tour accèdent aux données et nous fournissent ce que nous voulons (ou apportons les modifications que nous voulons). C’est formidable de travailler pour la maintenabilité, mais ne pas savoir comment nos données sont structurées peut nuire à la performance..

Exemples

La conception orientée données nous amène à penser aux données. Faisons donc également quelque chose de différent de ce que nous faisons habituellement. Considérons ce morceau de code:

void MyEngine :: queueRenderables () for (auto it = mRenderables.begin (); it! = mRenderables.end (); ++ it) if ((* it) -> isVisible ()) queueRenderable (* it ) 

Bien que beaucoup simplifié, ce modèle commun est ce que l'on voit souvent dans les moteurs de jeu orientés objet. Mais attendez-vous si beaucoup de rendus ne sont pas réellement visibles, nous rencontrons de nombreuses erreurs de prédiction de branche qui poussent le processeur à supprimer certaines instructions qu'il avait exécutées dans l'espoir qu'une branche particulière ait été prise. 

Pour les petites scènes, ce n'est évidemment pas un problème. Mais combien de fois faites-vous cette chose en particulier, pas seulement lorsque vous mettez en file d'attente des rendus, mais également lors d'une itération dans des lumières de scène, des scissions de zones d'ombre, des zones ou similaires? Qu'en est-il des mises à jour de l'IA ou de l'animation? Multipliez tout ce que vous faites dans la scène, voyez le nombre de cycles d'horloge que vous expulsez, calculez combien de temps votre processeur dispose pour livrer tous les lots de GPU à un rythme constant de 120 images par seconde et vous verrez que ces choses-là pouvez échelle à un montant considérable. 

Ce serait drôle si, par exemple, un pirate informatique travaillant sur une application Web envisageait de telles micro-optimisations minuscules, mais nous savons que les jeux sont des systèmes en temps réel où les contraintes de ressources sont incroyablement limitées. Cette considération ne nous est donc pas mal placée..

Pour éviter que cela ne se produise, réfléchissons d'une autre manière: que se passe-t-il si nous conservons la liste des éléments à rendre visibles dans le moteur? Bien sûr, nous sacrifierions la syntaxe soignée de myRenerable-> hide () et violer pas mal de principes de POO, mais nous pourrions alors faire ceci:

void MyEngine :: queueRenderables () for (auto it = mVisibleRenderables.begin (); it! = mVisibleRenderables.end (); ++ it) queueRenderable (* it); 

Hourra! Aucune mauvaise prédiction de branche, et en supposant mVisibleRenderables est une belle std :: vector (qui est un tableau contigu), nous aurions pu aussi bien récrire cela comme un rapide memcpy call (avec quelques mises à jour supplémentaires de nos structures de données, probablement).

Maintenant, vous pouvez m'appeler sur le cheesiness pur de ces échantillons de code et vous aurez tout à fait raison: ceci est simplifié beaucoup. Mais pour être honnête, je n'ai même pas encore égratigné la surface. Penser aux structures de données et à leurs relations nous ouvre de nombreuses possibilités auxquelles nous n'avions pas pensé auparavant. Regardons certains d'entre eux ensuite.

Parallélisation et vectorisation

Si nous avons des fonctions simples et bien définies qui fonctionnent sur de gros morceaux de données comme blocs de construction de base pour notre traitement, il est facile de générer quatre, huit, ou 16 threads de production et de donner à chacun d'eux un élément de données pour conserver toute la CPU. Noyaux occupés. Pas de mutex, d'atomics ou de conflit de verrous, et une fois que vous avez besoin des données, il vous suffit de rejoindre tous les threads et d'attendre qu'ils se terminent. Si vous devez trier les données en parallèle (tâche très fréquente lors de la préparation des éléments à envoyer au GPU), vous devez penser à cela sous un angle différent. Ces diapositives pourraient vous aider..

En prime, dans un fil, vous pouvez utiliser les instructions vectorielles SIMD (telles que SSE / SSE2 / SSE3) pour obtenir un gain de vitesse supplémentaire. Parfois, vous ne pouvez y parvenir qu'en disposant vos données d’une manière différente, en plaçant par exemple des tableaux de vecteurs de manière structurée (SoA) (comme XXX… YYY… ZZZ… ) plutôt que le réseau conventionnel de structures (AoS; ce serait XYZXYZXYZ… ). Je gratte à peine la surface ici; vous pouvez trouver plus d'informations dans le Lectures complémentaires section ci-dessous.

Lorsque nos algorithmes traitent directement les données, il devient trivial de les mettre en parallèle, et nous pouvons également éviter certains inconvénients liés à la vitesse..

Les tests unitaires que vous ne saviez pas étaient possibles

Disposer de fonctions simples sans effets externes facilite leur test unitaire. Cela peut être particulièrement utile dans une forme de test de régression pour les algorithmes que vous souhaitez permuter facilement.. 

Par exemple, vous pouvez créer une suite de tests pour le comportement d'un algorithme de sélection, configurer un environnement orchestré et mesurer exactement ses performances. Lorsque vous concevez un nouvel algorithme de sélection, vous relancez le même test sans aucune modification. Vous mesurez la performance et l'exactitude, de sorte que vous pouvez avoir une évaluation à portée de main. 

En vous familiarisant davantage avec les approches de conception orientées données, vous constaterez qu'il est de plus en plus facile de tester des aspects de votre moteur de jeu..

Combinaison de classes et d'objets avec des données monolithiques

La conception orientée données n'est en aucun cas opposée à la programmation orientée objet, mais seulement à certaines de ses idées. En conséquence, vous pouvez utiliser très proprement des idées de la conception orientée données et toujours obtenir la plupart des abstractions et des modèles mentaux que vous avez l'habitude de. 

Jetez un coup d'œil, par exemple, au travail sur la version 2.0 d'OGRE: Matias Goldberg, le cerveau derrière cette tentative, a choisi de stocker les données dans de grands tableaux homogènes et de disposer de fonctions qui parcourent des tableaux entiers au lieu de travailler sur un seul datum , afin d'accélérer Ogre. Selon un critère de référence (qu’il admet très injuste, mais l’avantage de performance mesuré ne peut pas être seulement à cause de cela), il fonctionne maintenant trois fois plus vite. Non seulement cela, il a conservé une grande partie des anciennes abstractions de classe familières, de sorte que l'API était loin d'être une réécriture complète..

Est-ce pratique?

Il existe de nombreuses preuves que les moteurs de jeu de cette manière peuvent et seront développés.

Le blog de développement de Molecule Engine a une série nommée Aventures dans un design orienté données,et contient beaucoup de conseils utiles sur l'endroit où le DOD a été mis à profit avec d'excellents résultats.

DICE semble s'intéresser à la conception orientée données, telle qu'elle l'a utilisée dans le système de cueillage de Frostbite Engine (et a également obtenu d'importantes accélérations!). Certaines autres diapositives de celles-ci incluent également l’utilisation de la conception orientée données dans le sous-système d’intelligence artificielle, qui mérite également d’être examinée..

En outre, des développeurs tels que Mike Acton, mentionné ci-dessus, semblent adhérer au concept. Il existe quelques points de repère qui prouvent que les performances de ce dernier sont très bonnes, mais je n'ai pas vu beaucoup d'activité sur le front de la conception orientée données. Bien sûr, cela pourrait n'être qu'une lubie, mais ses prémisses principales semblent très logiques. Il y a certes beaucoup d'inertie dans cette activité (et dans toute autre activité de développement de logiciels, d'ailleurs), de sorte que cela peut entraver l'adoption à grande échelle d'une telle philosophie. Ou peut-être que ce n'est pas une si bonne idée qu'elle semble l'être. Qu'est-ce que tu penses? Les commentaires sont les bienvenus!

Lectures complémentaires

  1. Conception orientée données (ou pourquoi vous pourriez vous tirer dans le pied avec la POO)
  2. Introduction à la conception orientée données [DICE] 
  3. Une discussion plutôt sympa sur le débordement de pile 
  4. Un livre en ligne de Richard Fabian expliquant beaucoup de concepts 
  5. Un repère montrant l'autre côté de l'histoire, un résultat apparemment contre-intuitif 
  6. L'examen de OgreNode.cpp par Mike Acton, révélant des pièges courants dans le développement de moteurs de jeu OOP