Dans le développement de jeux vidéo et le graphisme haute performance, les développeurs débattent souvent pour savoir si la programmation orientée objet (POO) classique tient encore la route face au Data-Oriented Design (DOD) pur. Si la localité de cache et les tampons mémoire contigus sont cruciaux pour les pipelines GPU modernes, l'Object-Oriented Graphics Rendering Engine (OGRE 3D) reste un exemple éminent de POO bien appliquée.
1. Une vraie abstraction : masquer les API graphiques bas niveau
Un objectif central de la POO est l'abstraction — fournir un modèle mental propre et de haut niveau tout en masquant des détails d'implémentation complexes.
OGRE abstrait les API graphiques bas niveau en découplant la gestion de scène du rendu au niveau pilote. Avec CppDepend, nous pouvons inspecter comment les pilotes concrets (comme RenderSystem_GL ou RenderSystem_Direct3D11) interagissent avec les abstractions d'OgreMain :
La leçon d'architecture
En dérivant les backends concrets de contrats abstraits clés comme RenderSystem, Texture et HardwareVertexBuffer, OGRE garantit que l'ajout d'une nouvelle API de rendu (par exemple Vulkan ou WebAssembly/WebGL) n'exige aucun changement structurel de la logique applicative ni du parcours du graphe de scène.
2. Polymorphisme sans abus d'héritage
Un écueil courant des frameworks C++ est la sur-utilisation de l'héritage — des hiérarchies de classes profondes et fragiles, ou l'abus d'héritage multiple. OGRE maintient une cohésion exceptionnelle en préférant la composition à l'héritage et en gardant des hiérarchies peu profondes.
Exécutons une requête Code Quest pour vérifier la profondeur d'héritage dans la bibliothèque centrale :
from t in Types
where t.BaseClasses.Count() > 1
select new { t, NbBaseClasses = t.BaseClasses.Count(), t.DepthOfInheritance }
Ce que montrent les données
- Arbres d'héritage peu profonds : la plupart des classes d'OgreMain ont une profondeur d'arbre d'héritage (DIT) ≤ 3.
- Héritage multiple contrôlé : seule une infime fraction des types dérive de plus d'une classe de base. L'héritage multiple dans OGRE se limite essentiellement aux interfaces mixin (par exemple dériver à la fois d'une interface métier et d'une classe de base listener ou factory).
3. Une instanciation propre avec les patterns Factory et Singleton
Dans les grandes applications C++, l'instanciation incontrôlée d'objets crée un couplage fort. OGRE résout ce problème avec deux patterns de création fondamentaux :
Le pattern Factory pour la création d'objets
Au lieu de laisser le code client instancier directement des objets concrets avec new, OGRE délègue la création à des classes factory spécialisées (par exemple EntityFactory, SceneManagerFactory).
from m in Methods
where m.DepthOfCreateA("Ogre.Entity") == 1
select m
Résultat : seul EntityFactory instancie directement les objets Entity, ce qui garantit que la gestion de la mémoire et les hooks du cycle de vie restent complètement encapsulés.
Des gestionnaires Singleton pour l'état du système
OGRE centralise la gestion des sous-systèmes via des singletons dérivés d'un template thread-safe (Ogre::Singleton<T>) :
Des sous-systèmes comme MeshManager, TextureManager et MaterialManager héritent de ce template uniforme, offrant aux développeurs un accès prévisible et thread-safe aux ressources du moteur.
4. Le pattern Facade : simplifier les sous-systèmes du moteur via Ogre::Root
Gérer des dizaines de gestionnaires, loaders et cibles de rendu spécialisés peut submerger les applications clientes. OGRE utilise le pattern Facade dans sa classe d'entrée : Ogre::Root.
À l'aide de la métrique Couplage efférent (EC) de CppDepend — qui mesure le nombre de types dont dépend une classe — nous voyons qu'Ogre::Root présente un couplage élevé (EC > 100).
Alors qu'un couplage élevé est généralement un code smell pour des classes ordinaires, c'est pour une Facade un choix de conception délibéré. Root orchestre les initialisations, les frame listeners, les boucles de rendu et les cycles de vie des gestionnaires derrière une API unifiée et facile à utiliser.
5. Graphe Abstraction vs. Instabilité
Robert C. Martin a écrit un article intéressant sur un ensemble de métriques permettant de mesurer la qualité d'une conception orientée objet en termes d'interdépendance entre les sous-systèmes de cette conception.
Voici ce qu'il dit dans cet article de l'interdépendance entre modules :
Qu'est-ce qui rend une conception rigide, fragile et difficile à réutiliser ? C'est l'interdépendance des sous-systèmes au sein de cette conception. Une conception est rigide si elle ne peut pas être facilement modifiée. Cette rigidité vient du fait qu'une modification unique d'un logiciel fortement interdépendant déclenche une cascade de modifications dans les modules dépendants. Quand l'ampleur de cette cascade ne peut pas être prévue par les concepteurs ou les mainteneurs, l'impact du changement ne peut pas être estimé. Le coût du changement devient alors impossible à évaluer. Les managers, confrontés à cette imprévisibilité, deviennent réticents à autoriser les changements. La conception devient ainsi rigide.
Et pour combattre la rigidité, il introduit des métriques comme le couplage afférent, le couplage efférent, l'abstraction et l'instabilité.
Couplage afférent : le nombre de types extérieurs à ce projet qui dépendent de types de ce projet.
Couplage efférent : le nombre de types extérieurs à ce projet utilisés par les types de ce projet.
Le couplage efférent et le couplage afférent s'appliquent aussi aux namespaces et aux types. Par exemple, le couplage efférent d'un type particulier est le nombre de types dont il dépend directement. Les types dont le TypeCe est très élevé dépendent de trop d'autres types. Ils sont complexes et ont en général plus d'une responsabilité.
Abstraction
Le rapport entre le nombre de types abstraits internes (c'est-à-dire les classes abstraites et les interfaces) et le nombre de types internes. Cette métrique varie de 0 à 1, A=0 indiquant un projet totalement concret et A=1 un projet totalement abstrait.
A = Na / Nc
Où :
- A = abstraction d'un module. Zéro correspond à un module totalement concret. Un correspond à un module totalement abstrait.
- Na = nombre de classes abstraites dans le module.
- Nc = nombre de classes concrètes dans le module.
Instabilité
Le rapport entre le couplage efférent (Ce) et le couplage total. Cette métrique est un indicateur de la résilience du projet au changement. Elle varie de 0 à 1, I=0 indiquant un projet totalement stable et I=1 un projet totalement instable.
I = Ce / (Ce + Ca)
- I représente le degré d'instabilité associé à un projet.
- Ca représente le couplage afférent, c'est-à-dire les dépendances entrantes.
- Ce représente le couplage efférent, c'est-à-dire les dépendances sortantes.
Le graphe Abstraction vs. Instabilité et la zone de douleur
Voici le graphe Abstraction vs. Instabilité du projet Ogre.
L'idée derrière ce graphe est que plus un élément de code est largement utilisé dans un programme, plus il devrait être abstrait. Autrement dit, évitez de dépendre trop fortement d'implémentations concrètes ; dépendez plutôt d'abstractions. Par élément de code populaire, j'entends un projet (mais l'idée fonctionne aussi pour les packages et les types) massivement utilisé par les autres projets du programme.
Avoir des types concrets largement utilisés dans toute votre base de code n'est pas une bonne idée. Cela crée des zones de douleur dans votre programme, où modifier les implémentations peut potentiellement affecter une grande partie du programme. Et les implémentations évoluent, on le sait, plus souvent que les abstractions.
La ligne de séquence principale (en pointillés) du diagramme ci-dessus montre comment abstraction et instabilité devraient s'équilibrer. Un composant stable se positionnerait à gauche. Si l'on regarde la séquence principale, on voit qu'un tel composant devrait être très abstrait pour rester proche de la ligne souhaitable — en revanche, si son degré d'abstraction est faible, il se situe dans une zone appelée la « zone de douleur ».
Comment combattre la rigidité avec l'approche POO ?
Comme l'écrit Robert C. Martin dans son article, il faut utiliser des classes abstraites et des interfaces pour rendre nos projets plus flexibles et réduire le couplage élevé entre les éléments de code.
Le couplage en POO peut être introduit par :
- L'héritage : souvent suremployé quand on adopte le paradigme POO, il rend malheureusement le code plus rigide dans de nombreux cas. Certains design patterns sont utiles pour résoudre la rigidité introduite par l'héritage, comme le pattern Adapter, qui minimise la rigidité introduite par l'héritage.
- L'utilisation directe d'une implémentation concrète : dans ce cas aussi, le code devient rigide parce qu'il est difficile à modifier si nous devons utiliser une autre bibliothèque ou un autre framework. Comme pour l'héritage, il existe des design patterns pour minimiser cette rigidité, comme Bridge ou Proxy.
Avec l'approche POO, il est recommandé de maîtriser les patterns structurels du GoF ; ils aident à réduire la rigidité introduite par le couplage.
Analyse par heatmap de métriques : la concentration du risque au niveau des méthodes dans OgreMain
La treemap visualise la hiérarchie de la base de code au sein du composant OgreMain, où la taille des rectangles représente la taille du code (par exemple les lignes de code) et le codage couleur souligne la sévérité des métriques (du bleu/vert pour un risque faible au rouge pour un risque élevé, comme une complexité cyclomatique élevée ou une dette technique importante).
Un enseignement clé de cette visualisation : les métriques à haut risque sont isolées exclusivement au niveau des méthodes plutôt que de signaler une dégradation architecturale globale de classes ou de namespaces entiers :
- Hotspots au niveau des méthodes : les rectangles rouges bien visibles, dispersés dans la treemap, correspondent à des routines individuelles précises — comme
translate,parse,logetvisit. Ces fonctions spécifiques agissent comme des hotspots de complexité, contenant vraisemblablement un flux de contrôle dense (par exemple de gros blocs switch ou des boucles conditionnelles imbriquées). - Conteneurs de classes et de structures sains : autour de ces hotspots de méthodes, les conteneurs parents (classes et namespaces) restent majoritairement verts et jaunes. Cela indique que l'organisation globale des classes et les frontières de modules dans OgreMain sont structurellement saines, les goulets d'étranglement qualité étant concentrés à l'intérieur de fonctions membres isolées.
- Stratégie de refactoring ciblée : parce que les signaux rouges de sévérité sont strictement localisés sur des méthodes comme
parseettranslate, la remédiation n'exige pas de repenser les hiérarchies de classes ni les interfaces. Le refactoring peut cibler directement la décomposition de ces fonctions précises en routines auxiliaires plus petites, à responsabilité unique.
Métriques de synthèse : pourquoi OGRE traverse le temps
En analysant OgreMain avec CppDepend, les métriques structurelles de haut niveau confirment ce que les développeurs louent depuis plus de deux décennies :
- Forte cohésion : des scores LCOM (Lack of Cohesion in Methods) faibles sur les composants de scène centraux indiquent des classes ciblées, à responsabilité unique.
- Faible complexité cyclomatique : les méthodes sont courtes, ciblées et facilement testables, sans boucles de dispatch monolithiques massives.
- Frontières de modules propres : un minimum de dépendances circulaires d'en-têtes entre les namespaces.
Conclusion
OGRE prouve que la programmation orientée objet en C++ n'est ni intrinsèquement lente ni trop complexe lorsque les principes architecturaux sont rigoureusement appliqués. En s'appuyant sur des outils modernes d'analyse statique comme CppDepend, les équipes peuvent étudier des moteurs comme OGRE pour imposer des interfaces propres, prévenir la dégradation architecturale et construire des logiciels C++ durables.
