Blog 14 min de lecture

Dans les coulisses du code source d’Unreal Engine 4.5

Share this article
Dans les coulisses du code source d’Unreal Engine 4.5

Le moteur Unreal Engine est un moteur de jeu développé par Epic Games, présenté pour la première fois dans le jeu de tir à la première personne de 1998 Unreal. Bien que principalement développé pour les jeux de tir à la première personne, il a été utilisé avec succès dans de nombreux autres genres, dont l'infiltration, les MMORPG et d'autres RPG.

Son code est écrit en C++, et il est utilisé par de nombreux développeurs de jeux aujourd'hui. Son code source est disponible sur GitHub, et il est gratuit pour les étudiants. De nombreux jeux extraordinaires ont été développés avec ce moteur ; il permet de produire des rendus très réalistes comme celui-ci.

488-unreal-engine-4

Quel code source est exécuté en coulisses pour produire ce rendu réaliste ?

Il est très intéressant de regarder à l'intérieur de ce puissant moteur de jeu et de découvrir comment il est conçu et implémenté. Les développeurs C++ peuvent apprendre de nombreuses bonnes pratiques de sa base de code.

Explorons son code source en utilisant CppDepend et CQLinq pour détecter quelques choix de conception et d'implémentation de son équipe de développement.

1- Les espaces de noms

Unreal Engine utilise largement les espaces de noms pour trois raisons principales :

  • De nombreux espaces de noms ne contiennent que des enums, comme le montre la requête CQLinq suivante, qui retourne ceux ne contenant que des enums.
unreal2

Dans un grand projet, rien ne garantit que deux enums distinctes n'utiliseront pas le même nom. Ce problème a été résolu en C++11 avec enum class, qui place implicitement les valeurs de l'enum dans la portée du nom de celle-ci.

  • L'espace de noms anonyme : un espace de noms sans nom évite de créer des variables statiques globales. L'espace de noms « anonyme » que vous créez n'est accessible que dans le fichier où vous l'avez créé. Voici la liste de tous les espaces de noms anonymes utilisés :
unreal3
  • Modulariser la base de code : recherchons tous les autres espaces de noms, c'est-à-dire ni les anonymes ni ceux ne contenant que des enums :
unreal6

Les espaces de noms sont une bonne solution pour modulariser l'application ; Unreal Engine définit plus de 250 espaces de noms pour garantir sa modularité, ce qui rend le code plus lisible et plus maintenable.

2- Le paradigme utilisé :

Le C++ n'est pas seulement un langage orienté objet. Comme le souligne Bjarne Stroustrup, « le C++ est un langage multi-paradigmes ». Il prend en charge de nombreux styles de programmes différents, ou paradigmes, et la programmation orientée objet n'en est qu'un parmi d'autres. Parmi les autres figurent la programmation procédurale et la programmation générique.

2-1 Le paradigme procédural

2-1-1 Les fonctions globales

Recherchons toutes les fonctions globales définies dans le code source d'Unreal Engine :

unreal7

Nous pouvons classer ces fonctions en trois catégories :

1 - Les fonctions utilitaires : par exemple, 6 344 d'entre elles sont des fonctions Z_Construct_UXXX, utilisées pour créer les instances nécessaires au moteur.

unreal8

2 - Les opérateurs : de nombreux opérateurs sont définis, comme le montre le résultat de cette requête CQLinq :

unreal9

Presque toutes sortes d'opérateurs sont implémentés dans le code source d'Unreal Engine.

3 - Les fonctions liées à la logique du moteur : de nombreuses fonctions globales contenant de la logique du moteur sont implémentées. Peut-être que ce genre de fonctions pourrait être regroupé par catégorie, comme méthodes statiques dans des classes, ou regroupé dans des espaces de noms.

2-1-2 Les fonctions globales statiques

Il est de bonne pratique de déclarer une fonction globale comme static, sauf si vous avez un besoin spécifique de l'appeler depuis un autre fichier source.

unreal10

De nombreuses fonctions globales sont déclarées static, et comme précisé précédemment, d'autres fonctions globales sont définies dans les espaces de noms anonymes.

2-1-3 Les fonctions globales qui pourraient être statiques

Les fonctions globales non exportées, non définies dans un espace de noms anonyme et non utilisées par une méthode en dehors du fichier où elles sont définies : ce sont de bonnes candidates pour être refactorisées en fonctions statiques.

unreal65

Comme on peut le constater, certaines fonctions globales sont candidates pour devenir statiques.

2-2 Le paradigme orienté objet

2-2-1 L'héritage

En programmation orientée objet (POO), l'héritage est un moyen d'établir une relation est-un entre des objets. Il est souvent confondu avec un moyen de réutiliser du code existant, ce qui n'est pas une bonne pratique car l'héritage pour la réutilisation d'implémentation mène à un couplage fort. La réutilisabilité du code est obtenue par la composition (la composition plutôt que l'héritage). Recherchons toutes les classes ayant au moins une classe de base :

unreal13

Et pour avoir une meilleure idée des classes concernées par cette requête, nous pouvons utiliser la vue Metric.

Dans la vue Metric, la base de code est représentée par un treemap. Le treemapping est une méthode d'affichage de données arborescentes à l'aide de rectangles imbriqués. L'arborescence utilisée dans un treemap CppDepend est la hiérarchie de code habituelle :

  • Les projets contiennent des espaces de noms.
  • Les espaces de noms contiennent des types.
  • Les types contiennent des méthodes et des champs.

La vue treemap offre un moyen pratique de représenter le résultat d'une requête CQLinq ; les rectangles bleus représentent ce résultat, afin de voir visuellement les types concernés par la requête.

unreal12

Comme on peut le constater, l'héritage est largement utilisé dans le code source d'Unreal Engine.

L'héritage multiple : recherchons les classes héritant de plus d'une classe concrète.

unreal15

L'héritage multiple n'est pas largement utilisé ; seules quelques classes héritent de plus d'une classe.

2-2-2 Les méthodes virtuelles

Recherchons toutes les méthodes virtuelles définies dans le code source d'Unreal Engine :

unreal19

De nombreuses méthodes sont virtuelles, et certaines d'entre elles sont virtuelles pures :

unreal21

Comme le paradigme procédural, le paradigme POO est aussi largement utilisé dans le code source d'Unreal Engine. Qu'en est-il du paradigme de la programmation générique ?

2-3 La programmation générique

Le C++ offre des capacités uniques pour exprimer les idées de la programmation générique à travers les templates. Les templates fournissent une forme de polymorphisme paramétrique qui permet d'exprimer des algorithmes et des structures de données génériques. Le mécanisme d'instanciation des templates C++ garantit que lorsqu'un algorithme ou une structure de données générique est utilisé, une version entièrement optimisée et spécialisée sera créée et adaptée à cet usage particulier, permettant aux algorithmes génériques d'être aussi efficaces que leurs homologues non génériques.

2-3-1 Les types génériques

Recherchons tous les types génériques définis dans le code source du moteur :

unreal23

Seuls quelques types sont définis comme génériques. Recherchons les méthodes génériques :

unreal26

Plus de 40 000 méthodes sont génériques ; elles représentent plus de 25 % des méthodes implémentées.

Pour résumer, le code source d'Unreal Engine mélange les trois paradigmes.

3- Les POD pour définir le modèle de données

En programmation orientée objet, les plain old data (POD) sont des structures de données représentées uniquement comme des collections passives de valeurs de champs (variables d'instance), sans utiliser de fonctionnalités orientées objet. En informatique, on parle de structure de données passive.

Recherchons les types POD dans le code source d'Unreal Engine.

unreal28

Plus de 2 000 types sont définis comme des types POD ; beaucoup d'entre eux servent à définir le modèle de données du moteur.

4- Les design patterns du Gang of Four

Les design patterns sont un concept de génie logiciel décrivant des solutions récurrentes à des problèmes courants de conception logicielle. Les patterns du Gang of Four sont les plus populaires. Découvrons-en quelques-uns utilisés dans le code source d'Unreal Engine.

4-1 Singleton

Le singleton est le plus populaire et le plus utilisé. Voici quelques classes singleton définies dans le code source :

unreal29

TThreadSingleton est une version spéciale du singleton : une seule instance est créée pour chaque thread. Appeler sa méthode Get() est thread-safe.

4-2 Factory

Utiliser des factories est intéressant pour isoler la logique d'instanciation et renforcer la cohésion ; voici la liste des factories définies dans le code source :

unreal30

Et voici la liste des factories abstraites :

unreal31

4-3 Observer

Le pattern observer est un design pattern logiciel dans lequel un objet maintient une liste de ses dépendants, appelés observateurs, et les notifie automatiquement de tout changement d'état, généralement en appelant l'une de leurs méthodes.

Quelques observateurs sont implémentés dans son code source ; FAIMessageObserver en fait partie.

Voici un graphe de dépendances montrant l'appel de la méthode OnMessage de cet observateur :

unreal70

4-4 Command

Le pattern command est un design pattern comportemental dans lequel un objet est utilisé pour représenter et encapsuler toutes les informations nécessaires pour appeler une méthode à un moment ultérieur.

Quatre termes toujours associés au pattern command sont command, receiver, invoker et client. Un objet command a un objet receiver et invoque une méthode du receiver d'une manière spécifique à la classe de celui-ci.

Voici, par exemple, toutes les commandes héritant de IAutomationLatentCommand :

unreal33

5- Couplage et cohésion

5.1 Le couplage

Un faible couplage est souhaitable car un changement dans une zone d'une application nécessitera moins de changements dans toute l'application. À long terme, cela peut faire gagner beaucoup de temps, d'efforts et réduire les coûts liés à la modification et à l'ajout de nouvelles fonctionnalités.

Un faible couplage peut être obtenu en utilisant des classes abstraites ou en utilisant des types et méthodes génériques.

Recherchons toutes les classes abstraites définies dans le code source d'Unreal Engine :

unreal34

Seuls quelques types sont déclarés abstraits. Le faible couplage est mieux garanti en utilisant des types et des méthodes génériques.

Voici, par exemple, les méthodes utilisant au moins une méthode générique :

unreal27

Comme on peut le constater, de nombreuses méthodes utilisent des méthodes génériques ; le faible couplage est garanti par les paramètres template des fonctions. En effet, le type réel de ces paramètres peut changer sans modifier le code source de la méthode appelée.

La cohésion

Le principe de responsabilité unique stipule qu'une classe ne devrait pas avoir plus d'une raison de changer. Une telle classe est dite cohésive. Une valeur LCOM élevée signale généralement une classe peu cohésive. Il existe plusieurs métriques LCOM. LCOM prend ses valeurs dans l'intervalle [0-1]. LCOM HS (HS pour Henderson-Sellers) prend ses valeurs dans l'intervalle [0-2]. Une valeur LCOM HS supérieure à 1 devrait être considérée comme alarmante. Voici comment calculer les métriques LCOM :

LCOM = 1 – (sum(MF)/M*F) LCOM HS = (M – sum(MF)/F)(M-1)

Où :

  • M est le nombre de méthodes de la classe (les méthodes statiques et d'instance sont comptées, cela inclut aussi les constructeurs, les getters/setters de propriétés, les méthodes add/remove d'événements).
  • F est le nombre de champs d’instance de la classe.
  • MF est le nombre de méthodes de la classe accédant à un champ d’instance donné.
  • Sum(MF) est la somme des MF sur tous les champs d’instance de la classe.

L'idée sous-jacente à ces formules peut s'énoncer ainsi : une classe est totalement cohésive si toutes ses méthodes utilisent tous ses champs d'instance, ce qui signifie que sum(MF)=M*F, et donc LCOM = 0 et LCOMHS = 0.

Une valeur LCOMHS supérieure à 1 devrait être considérée comme alarmante.

unreal36

Seuls quelques types sont considérés comme non cohésifs.

6- Immutabilité, pureté et effets de bord

6-1 Les types immuables

Fondamentalement, un objet est immuable si son état ne change plus une fois l'objet créé. Par conséquent, une classe est immuable si ses instances sont immuables.

Il y a un argument important en faveur de l'utilisation des objets immuables : cela simplifie radicalement la programmation concurrente. Réfléchissez-y — pourquoi écrire du code multithread correct est-il une tâche difficile ? Parce qu'il est difficile de synchroniser l'accès des threads aux ressources (objets ou autres ressources de l'OS). Pourquoi est-il difficile de synchroniser ces accès ? Parce qu'il est difficile de garantir qu'il n'y aura pas de situations de compétition entre les multiples accès en écriture et en lecture effectués par plusieurs threads sur plusieurs objets. Et s'il n'y avait plus d'accès en écriture ? Autrement dit, si l'état des objets accédés par les threads ne changeait pas ? Alors il n'y aurait plus besoin de synchronisation !

Un autre avantage des classes immuables est qu'elles ne peuvent jamais violer le LSP (principe de substitution de Liskov) ; voici une définition du LSP tirée de sa page wiki :

La notion de sous-type comportemental de Liskov définit une notion de substituabilité pour les objets muables ; c'est-à-dire que si S est un sous-type de T, alors les objets de type T dans un programme peuvent être remplacés par des objets de type S sans altérer aucune des propriétés souhaitables de ce programme (par exemple, sa correction).

Voici la liste des types immuables définis dans le code source :

unreal38

6-2 Pureté et effets de bord

Le principal avantage des types immuables vient du fait qu'ils éliminent les effets de bord. Je ne saurais le dire mieux que Wes Dyer, alors je le cite :

Nous savons tous qu'en général, utiliser des variables globales n'est pas une bonne idée. C'est fondamentalement l'extrême de l'exposition des effets de bord (la portée globale). Beaucoup de programmeurs qui n'utilisent pas de variables globales ne réalisent pas que les mêmes principes s'appliquent aux champs, propriétés, paramètres et variables à une échelle plus limitée : ne les mutez pas sauf si vous avez une bonne raison.(…)Une façon d'augmenter la fiabilité d'une unité est d'éliminer les effets de bord. Cela rend la composition et l'intégration des unités entre elles beaucoup plus faciles et plus robustes. Comme elles sont sans effets de bord, elles fonctionnent toujours de la même manière, quel que soit l'environnement. C'est ce qu'on appelle la transparence référentielle. Écrire vos fonctions/méthodes sans effets de bord — pour qu'elles soient des fonctions pures, c'est-à-dire qu'elles ne mutent pas l'objet — facilite le raisonnement sur la correction de votre programme.

Voici la liste de toutes les méthodes sans effets de bord.

unreal41

Plus de 125 000 méthodes sont pures.

7- La qualité de l'implémentation

7-1 Les méthodes trop grosses

Les méthodes avec un grand nombre de lignes de code ne sont pas faciles à maintenir et à comprendre. Recherchons les méthodes de plus de 60 lignes.

unreal44

Le code source d'Unreal Engine contient plus de 150 000 méthodes, donc moins de 1 % pourraient être considérées comme trop grosses.

7-2 Les méthodes avec beaucoup de paramètres

unreal45

Peu de méthodes ont plus de 8 paramètres ; la plupart d'entre elles sont génériques pour éviter de définir des fonctions variadiques, comme dans le cas des méthodes TCString::Snprintf.

7-3 Les méthodes avec beaucoup de variables locales

unreal46

Moins de 1 % ont beaucoup de variables locales.

7-4 Les méthodes trop complexes

De nombreuses métriques existent pour détecter les fonctions complexes ; NBLinesOfCode, le nombre de paramètres et le nombre de variables locales sont les plus basiques.

Il existe d’autres métriques intéressantes pour détecter les fonctions complexes :

  • La complexité cyclomatique est une métrique logicielle procédurale populaire, égale au nombre de décisions pouvant être prises dans une procédure.
  • La profondeur d'imbrication (Nesting Depth) est une métrique définie sur les méthodes, relative à la profondeur maximale de la portée la plus imbriquée dans le corps d'une méthode.
  • Max Nested Loop correspond au niveau maximal d'imbrication des boucles dans une fonction.

La valeur maximale tolérée pour ces métriques dépend surtout des choix de l’équipe ; il n’existe pas de valeurs standard.

Recherchons les méthodes qui pourraient être considérées comme complexes dans la base de code d'Unreal Engine.

unreal49

Seulement 1,5 % sont candidates à une refactorisation pour minimiser leur complexité.

7-5 La complexité de Halstead

Les mesures de complexité de Halstead

sont des métriques logicielles introduites par Maurice Howard Halstead en 1977. Halstead a fait l'observation que les métriques du logiciel devraient refléter l'implémentation ou l'expression des algorithmes dans différents langages, tout en étant indépendantes de leur exécution sur une plateforme spécifique. Ces métriques sont donc calculées statiquement à partir du code.

unreal50

1 748 méthodes nécessitent plus d'une heure pour être implémentées.

8- Le RTTI

Le RTTI désigne la capacité du système à rapporter le type dynamique d'un objet et à fournir des informations sur ce type à l'exécution (par opposition à la compilation). Cependant, le RTTI est devenu controversé au sein de la communauté C++. De nombreux développeurs C++ choisissent de ne pas utiliser ce mécanisme.

Et l'équipe de développement d'Unreal Engine ?

unreal60

Aucune méthode n'utilise le mot-clé dynamic_cast ; l'équipe d'Unreal Engine a choisi de ne pas utiliser le mécanisme RTTI.

9- Les exceptions

La gestion des exceptions est une autre fonctionnalité controversée du C++. De nombreux projets C++ open source connus ne l'utilisent pas.

Recherchons si une exception est levée quelque part dans le code source d'Unreal Engine.

unreal62

Des exceptions sont levées dans certaines méthodes ; prenons celle de RaiseException comme exemple :

unreal61

Comme spécifié dans leurs commentaires, des exceptions pourraient être générées pour l'outil d'en-tête, mais dans le code d'exécution normal, ils ne prennent pas en charge la gestion des exceptions.

10- Quelques statistiques

10-1 Les types les plus populaires

Il est intéressant de connaître les types les plus utilisés d'un projet ; en effet, ces types doivent être bien conçus, implémentés et testés. Et tout changement les concernant pourrait impacter tout le projet.

Nous pouvons les trouver en utilisant la métrique TypesUsingMe:

unreal71

Cependant, il existe une autre métrique intéressante pour rechercher les types populaires : TypeRank.

Les valeurs TypeRank sont calculées en appliquant l'algorithme Google PageRank au graphe des dépendances entre types. Une homothétie de centre 0,15 est appliquée pour que la moyenne des TypeRank soit de 1.

Les types avec un TypeRank élevé devraient être testés avec plus de soin, car les bugs y sont susceptibles d’être plus catastrophiques.

Voici le résultat de tous les types populaires selon la métrique TypeRank :

unreal52

10-2 Les méthodes les plus populaires

unreal54

10-3 Les méthodes appelant beaucoup d'autres méthodes

Il est intéressant de connaître les méthodes qui en utilisent beaucoup d'autres ; cela pourrait révéler un problème de conception dans ces méthodes, et dans certains cas une refactorisation est nécessaire pour les rendre plus lisibles et maintenables.

unreal57
Share this article