Conception orientée données (ou pourquoi, en utilisant la POO, vous vous tirez probablement une balle dans le pied)

image

Imaginez cette image: la fin du cycle de développement approche, votre jeu rampe à peine, mais dans le profileur, vous ne pouvez pas trouver de problèmes évidents. Qui est à blâmer? Modèles de mémoire à accès aléatoire et échecs de cache persistants. En essayant d'améliorer les performances, vous essayez de paralléliser des parties du code, mais cela vaut les efforts héroïques, et à la fin, en raison de toute la synchronisation qui a dû être ajoutée, l'accélération est à peine perceptible. De plus, le code est tellement compliqué que la correction des bogues pose encore plus de problèmes, et l'idée d'ajouter de nouvelles fonctionnalités est immédiatement écartée. Cela vous semble familier?

Un tel développement d'événements décrit de façon assez précise presque tous les jeux auxquels j'ai participé au cours des dix dernières années. Les raisons ne sont pas dans les langages de programmation ou les outils de développement, ni même le manque de discipline. D'après mon expérience, dans une large mesure, la programmation orientée objet (POO) et sa culture environnante devraient être blâmées. La POO peut ne pas aider, mais interférer avec vos projets!

Tout est question de données


La POO a tellement pénétré la culture existante du développement de jeux vidéo que lorsque vous pensez à un jeu, il est difficile d'imaginer autre chose que des objets. Depuis de nombreuses années, nous créons des classes pour les voitures, les joueurs et les machines d'État. Quelles sont les alternatives? Programmation procédurale? Langages fonctionnels? Langages de programmation exotiques?

La conception orientée données est une autre façon de concevoir un logiciel conçu pour résoudre tous ces problèmes. L'élément principal de la programmation procédurale est les appels de procédure, et la POO traite principalement des objets. Notez que dans les deux cas le code est mis au centre: dans un cas, ce sont des procédures (ou fonctions) ordinaires, dans l'autre, du code groupé associé à un certain état interne. La conception orientée données déplace l'attention des objets vers les données elles-mêmes: le type de données, leur emplacement dans la mémoire, les méthodes de lecture et de traitement dans le jeu.

La programmation par définition est un moyen de convertir des données: l'acte de créer une séquence d'instructions machine qui décrivent le processus de traitement des données d'entrée et de création des données de sortie. Un jeu n'est rien d'autre qu'un programme interactif, alors ne serait-il pas plus logique de se concentrer principalement sur les données, et non sur le code qui les traite?

Afin de ne pas vous confondre, je vais vous expliquer tout de suite: une conception orientée données ne signifie pas que le programme est piloté par les données. Un jeu basé sur les données est généralement un jeu dont les fonctionnalités sont largement en dehors du code; il permet aux données de déterminer le comportement du jeu. Ce concept est indépendant de la conception orientée données et peut être utilisé dans n'importe quelle méthode de programmation.

Des données parfaites


Séquence d'appels avec une approche orientée objet

Figure 1a. Séquence d'appel avec une approche orientée objet

Si nous examinons le programme en termes de données, à quoi ressembleront les données idéales? Cela dépend des données elles-mêmes et de la façon de les utiliser. En général, les données idéales sont dans un format qui peut être utilisé avec un minimum d'effort. Dans le meilleur des cas, le format coïncidera complètement avec le résultat de sortie attendu, c'est-à-dire que le traitement consiste uniquement à copier les données. Très souvent, un schéma de données idéal ressemble à de grands blocs de données homogènes adjacentes qui peuvent être traitées séquentiellement. Quoi qu'il en soit, l'objectif est de minimiser le nombre de transformations; si possible, "cuire" les données dans ce format idéal à l'avance, au stade de la création des ressources du jeu.

Étant donné que la conception orientée données donne la priorité aux données, nous pouvons créer l'architecture d'un programme entier autour d'un format de données idéal. Nous ne réussirons pas toujours à le rendre complètement parfait (tout comme le code ressemble rarement à la POO d'un manuel), mais c'est notre objectif principal, dont nous nous souvenons toujours. Lorsque nous y parvenons, la plupart des problèmes mentionnés au début de l'article se dissolvent simplement (plus sur cette section suivante).

Lorsque nous pensons aux objets, nous rappelons immédiatement les arbres - arbres d'héritage, arbres de nidification ou arbres de messages, et nos données sont naturellement ordonnées de cette façon. Par conséquent, lorsque nous effectuons une opération sur un objet, cela conduit généralement au fait que l'objet, à son tour, accède à d'autres objets dans l'arborescence. Lors de l'itération sur plusieurs objets, effectuer la même opération générera en aval, des opérations complètement différentes pour chaque objet (voir la figure 1a).

Séquence d'appels avec une approche orientée données

Figure 1b. Séquence d'appels dans une technique orientée données

Pour obtenir le meilleur schéma de stockage de données, il peut être utile de diviser chaque objet en différents composants et de regrouper les composants du même type en mémoire, quel que soit l'objet duquel nous les avons extraits. Un tel ordre conduit à la création de grands blocs de données homogènes, nous permettant de traiter les données de manière séquentielle (voir figure 1b). La principale raison de la puissance du concept de conception orienté données est qu'il fonctionne très bien avec de grands groupes d'objets. La POO, par définition, fonctionne avec un seul objet. Rappelez-vous le dernier jeu sur lequel vous avez travaillé: combien de fois dans le code il y avait des endroits où vous deviez travailler avec un seul élément? Un ennemi? Un véhicule? Noeud de recherche à sens unique? Une balle? Une pièce? Jamais! Là où il y en a un, il y en a plusieurs autres. La POO ignore cela et fonctionne avec chaque objet individuellement. Par conséquent, nous pouvons simplifier le travail pour nous-mêmes et l'équipement en organisant les données de sorte qu'il était nécessaire de traiter de nombreux éléments du même type.

Cette approche vous semble-t-elle étrange? Mais tu sais quoi? Très probablement, vous l'utilisez déjà dans certaines parties du code: à savoir, dans le système de particules! La conception orientée données transforme la base de code entière en un énorme système de particules. Il est possible que cette méthode semble plus familière aux développeurs de jeux; elle devrait être appelée programmation basée sur les particules.

Avantages de la conception orientée données


Si nous pensons d'abord aux données et créons l'architecture du programme sur cette base, cela nous donnera de nombreux avantages.

Parallélisme


De nos jours, il est impossible de se débarrasser du fait que nous devons travailler avec plusieurs cœurs. Ceux qui ont tenté de paralléliser le code POO peuvent confirmer à quel point la tâche est complexe, sujette aux erreurs et peut-être pas particulièrement efficace. Souvent, vous devez ajouter un grand nombre de primitives de synchronisation pour éviter l'accès simultané aux données de plusieurs threads, et généralement de nombreux threads sont inactifs pendant une longue période, en attendant que d'autres threads finissent de fonctionner. En conséquence, l'augmentation de la productivité est assez médiocre.

Si nous appliquons une conception orientée données, la parallélisation devient beaucoup plus simple: nous avons des données d'entrée, une petite fonction qui les traite et des données de sortie. Quelque chose de similaire peut être facilement divisé en plusieurs flux avec une synchronisation minimale entre eux. Vous pouvez même faire un pas de plus et exécuter ce code sur des processeurs avec mémoire locale (par exemple, dans des SPU de processeurs Cell) sans modifier aucune opération.

Utilisation du cache


En plus d'utiliser le multicœur, l'un des moyens clés pour atteindre des performances élevées sur un équipement moderne avec des pipelines d'instructions approfondies et des systèmes de mémoire lents avec plusieurs niveaux de cache est la mise en œuvre d'un accès aux données qui est pratique pour la mise en cache. La conception orientée données permet une utilisation très efficace du cache de commandes, car le même code y est constamment exécuté. De plus, si nous organisons les données dans de grands blocs adjacents, nous pouvons traiter les données de manière séquentielle, ce qui permet une utilisation presque parfaite du cache de données et d'excellentes performances.

Option d'optimisation


Lorsque nous pensons aux objets ou aux fonctions, nous nous concentrons généralement sur l'optimisation au niveau d'une fonction ou même d'un algorithme: nous essayons de changer l'ordre des appels de fonction, de changer la méthode de tri, ou même de réécrire une partie du code C en langage assembleur.

De telles optimisations sont certainement utiles, mais si vous pensez d'abord aux données, nous pouvons prendre du recul et créer des optimisations plus ambitieuses et plus importantes. N'oubliez pas que le jeu ne traite que de la conversion de certaines données (ressources, saisie utilisateur, statut) en d'autres données (commandes graphiques, nouveaux états de jeu). Avec ce flux de données à l'esprit, nous pouvons prendre des décisions de niveau supérieur et plus éclairées en fonction de la façon dont les données sont converties et appliquées. De telles optimisations dans des techniques de POO plus traditionnelles peuvent être extrêmement complexes et longues.

Modularité


Tous les avantages ci-dessus de la conception orientée données étaient liés aux performances: utilisation du cache, optimisation et parallélisation. Il ne fait aucun doute que pour nous, programmeurs de jeux, les performances sont extrêmement importantes. Il existe souvent un conflit entre les techniques qui augmentent la productivité et les techniques qui favorisent la lisibilité du code et la facilité de développement. Par exemple, si nous réécrivons une partie du code en langage assembleur, nous améliorerons les performances, mais cela entraîne généralement une diminution de la lisibilité et complique la prise en charge du code.

Heureusement, la conception orientée données profite à la fois à la productivité et à la facilité de développement. Si vous écrivez du code spécifiquement pour la conversion de données, vous obtenez de petites fonctions avec un très petit nombre de dépendances avec d'autres parties du code. La base de code reste très "plate", avec de nombreuses fonctions "feuilles" qui n'ont pas de grandes dépendances. Ce niveau de modularité et l'absence de dépendances simplifie grandement la compréhension, le remplacement et la mise à jour du code.

Test


Le dernier avantage majeur de la conception orientée données est sa facilité de test. Beaucoup de gens savent que l'écriture de tests unitaires pour tester l'interaction des objets est une tâche non triviale. Vous devez créer des dispositions et tester des éléments indirectement. Honnêtement, c'est assez douloureux. D'un autre côté, travailler directement avec les données, écrire des tests unitaires est absolument facile: nous créons des données entrantes, appelons la fonction qui les convertit et vérifions si la sortie correspond aux données attendues. Et c'est tout. En fait, c'est un énorme avantage qui simplifie considérablement les tests de code, qu'il s'agisse de développement piloté par les tests ou d'écriture de tests unitaires après le code.

Inconvénients de la conception orientée données


La conception orientée données n'est pas une «solution miracle» qui résout tous les problèmes de développement de jeux. Cela aide vraiment à écrire du code haute performance et à créer des programmes plus lisibles et plus faciles à maintenir, mais en soi présente certains inconvénients.

Le principal problème avec la conception orientée données: il diffère de ce que la plupart des programmeurs ont appris et utilisé. Cela nécessite de tourner notre modèle mental du programme à quatre-vingt-dix degrés et de changer de point de vue là-dessus. Pour que cette approche devienne une seconde nature, la pratique est nécessaire.

De plus, en raison de la différence d'approches, cela peut entraîner des difficultés d'interaction avec le code existant écrit dans un style procédural ou POO. Il est difficile d'écrire une fonction séparément, mais dès que vous pouvez appliquer une conception orientée données à un sous-système entier, vous pouvez obtenir de nombreux avantages.

Utilisation de la conception orientée données


Assez de théorie et de revues. Comment commencer à implémenter la méthode de conception orientée données? Pour commencer, sélectionnez une zone spécifique de votre code: navigation, animations, collisions ou autre chose. Plus tard, lorsque la partie principale du moteur de jeu sera concentrée sur les données, vous pourrez régler le flux de données tout au long du chemin, du début de l'image à la fin.

Ensuite, il est nécessaire d'identifier clairement les données d'entrée requises par le système et le type de données qu'il doit générer. Vous pensez peut-être dans la terminologie OOP pour l'instant, juste pour identifier les données. Par exemple, pour un système d'animation, une partie des données d'entrée sera des squelettes, des poses de base, des données d'animation et l'état actuel. Le résultat n'est pas un «code d'animation animé», mais des données générées par les animations en cours de lecture. Dans ce cas, la sortie sera un nouvel ensemble de poses et un état mis à jour.

Il est important de prendre du recul et de classer les données entrantes en fonction de leur utilisation. Sont-ils en lecture seule, en lecture-écriture ou en écriture seule? Une telle classification aidera à prendre des décisions sur l'endroit où stocker les données et quand les traiter en raison des dépendances sur d'autres parties du programme.

À ce stade, vous devez cesser de penser aux données nécessaires pour une opération et commencer à penser à les appliquer à des dizaines ou des centaines d'éléments. Nous n'avons plus un squelette, une pose de base et l'état actuel: nous avons un bloc de chacun de ces types avec de nombreuses instances dans chacun des blocs.

Réfléchissez soigneusement à la façon dont les données seront utilisées dans le processus de transformation de l'entrée à la sortie. Vous pouvez vous rendre compte que pour transmettre des données, vous devez analyser un champ spécifique de la structure, puis vous devez utiliser les résultats pour effectuer une autre passe. Dans ce cas, il peut être plus logique de diviser ce champ source en un bloc de mémoire séparé, qui peut être traité séparément, ce qui permettra de mieux utiliser le cache et de préparer le code pour une parallélisation potentielle. Ou, vous devrez peut-être vectoriser une partie du code si vous avez besoin de recevoir des données de différents endroits pour les mettre dans un registre vectoriel. Dans ce cas, les données seront stockées de manière adjacente afin que les opérations vectorielles puissent être appliquées directement, sans conversions inutiles.

Vous devriez maintenant avoir une très bonne compréhension de vos données. Écrire du code pour les convertir deviendra beaucoup plus facile. Ce sera comme créer du code en remplissant des espaces. Vous serez agréablement surpris que le code se soit avéré être beaucoup plus simple et plus compact que vous ne le pensiez à l'origine, par rapport au même code OOP.

La plupart des articles de mon blog vous ont préparé à ce type de design. Maintenant, nous devons faire attention à la façon dont les données sont organisées, cuire les données au format d'entrée afin qu'elles puissent être utilisées efficacement et utiliser des liens sans pointeurs entre les blocs de données afin qu'ils puissent être facilement déplacés.

Est-il encore possible d'utiliser la POO?


Est-ce à dire que la POO est inutile et ne doit jamais être utilisée lors de la création de programmes? Je ne peux pas dire ça. Penser dans le contexte des objets n'est pas nuisible si nous parlons d'une seule instance de chaque objet (par exemple, un périphérique graphique, un gestionnaire de journaux, etc.), bien que dans ce cas le code puisse être implémenté sur la base de fonctions de style C plus simples et statiques données au niveau du fichier. Et même dans cette situation, il est toujours important que les objets soient conçus en mettant l'accent sur la transformation des données.

Une autre situation dans laquelle j'utilise encore la POO est les systèmes GUI. C'est peut-être parce que nous travaillons ici avec un système qui est déjà conçu de manière orientée objet, ou peut-être parce que les performances et la complexité ne sont pas des facteurs critiques pour le code GUI. Quoi qu'il en soit, je préfère les API GUI qui utilisent peu l'héritage et maximisent l'imbrication (de bons exemples sont Cocoa et CocoaTouch). Il est probable que pour les jeux, vous pouvez écrire de jolis systèmes GUI avec une orientation données, mais jusqu'à présent je ne les ai pas vus.

En fin de compte, rien ne vous empêche de créer une image mentale basée sur des objets de toute façon, si vous préférez penser le jeu de cette façon. C'est juste que l'essence de l'ennemi n'occupera pas une place physique dans la mémoire, mais sera divisée en sous-composants plus petits, chacun faisant partie d'un grand tableau de données de composants similaires.

La conception orientée données est un peu loin des méthodes de programmation traditionnelles, mais si vous pensez toujours aux données et aux moyens nécessaires pour les transformer, cela vous donnera de grands avantages en termes de productivité et de facilité de développement.

Source: https://habr.com/ru/post/fr472052/


All Articles