Depuis l'introduction au public du terme gen12 en 2019, les développeurs de Star Citizen ont régulièrement parlé des projets du studio en termes d'ingénierie graphique. Ce sujet est extrêmement complexe et souvent réduit au strict minimum lors de la communication autour des technologies vidéoludiques. Les joueurs de Star Citizen ont cependant la chance d'avoir accès à de nombreux détails sur la technologie graphique en travaux chez CIG actuellement. Malheureusement, le vocabulaire et les concepts impliqués ne sont pas connus de tous, tant s'en faut. Dans cet article, la rédaction tente de décomposer et vulgariser ces concepts pour comprendre au mieux les réalisations et objectifs des développeurs.
Définitions et concepts
Dans les communications de CIG, de nombreux termes informatiques sont utilisés qui, s'ils font partie du décor Internet depuis longtemps, recèlent certaines informations clés pour comprendre ce dont il est question. Chacun de ces termes est défini ici, pour contenir au mieux les informations à suivre.
TL ; DR
- Le CPU est le "cerveau" d'un ordinateur.
- La mémoire RAM permet au processeur d'accéder rapidement aux données dont il a besoin.
- Un core est un composant physique du processeur pouvant exécuter un ou plusieurs threads.
- Un thread est une sous-partie d'un programme contenant une suite d'instructions propre exécutée sur un cœur logique du processeur.
- La carte graphique est un ensemble de composants ayant son propre mode d'exécution, mais est régi par le système central.
- Une API graphique est un programme servant de passerelle entre le code d'un programme et la carte graphique.
- Le moteur de rendu est un programme dédié à l'affichage, faisant appel à de nombreux sous-programmes.
CPU et Mémoire RAM
Le CPU ou processeur est le cœur d'un ordinateur. Il effectue un nombre colossal d'opérations. Les paramètres et résultats de ces informations sont stockées dans la mémoire (RAM).
La mémoire vive ou RAM est un composant qui permet de lire et écrire des valeurs très rapidement à la différence du stockage sur disque dur. Lorsqu'une application s'exécute, ce qui est essentiel à ce programme est chargé dans la RAM et y reste durant toute la durée de l'exécution.
Certains programmes complexes comme Star Citizen utilisent une technologie de "streaming d'entités" qui permet de charger et décharger des valeurs depuis la RAM vers le disque dur pendant l'exécution, mais nous ferons abstraction de ce concept pour cet article.
Cores et Threads
La notion de thread est complexe et sujette à interprétation. De nombreuses choses sont appelées threads qui n'en sont pas réellement. Nous allons tenter d'établir une définition qui sera probablement incomplète, mais consensuelle et qui définira bien la réalité du sujet actuel.
Un CPU possède plusieurs cœurs (4 à 8 pour la majorité des processeurs actuels). Un cœur est un ensemble de circuits physiques distincts au sein d'un processeur. Chaque cœur d'un processeur est indépendant et son travail est régi par un système central. Certains processeurs (la majorité de ceux du marché en réalité) ont la capacité d'utiliser un même cœur (le même circuit) pour effectuer deux tâches différentes en parallèle. On parle alors de cœur logique ou de thread. Certains processeurs ont un seul thread par cœur, d'autres deux, et certains vont même jusqu'à quatre, mais l'important est de garder à l'esprit que le nombre de threads disponibles est fixe pour une machine donnée.
Un thread peut être défini comme un emplacement vide auquel peut être assigné une suite d'instructions. Chaque suite d'instructions assignée à un thread est finie (un début et une fin) et doit être explicitement décrite par le programme. Lorsque la suite d'instructions change (une tâche est terminée et une autre commence) le processeur doit modifier son état pour permettre d'effectuer la prochaine suite d'instructions. Ce procédé prend du temps, ce qui ne permet pas de systématiquement combler les vides entre deux suites d'instructions, ainsi la réduction de ce nombre de vides (bubbles) fait partie du processus d'optimisation d'un programme.
Le multithreading ou multitâche désigne l'utilisation par les programmeurs de ces différentes unités d'exécution. On peut alors permettre à deux tâches distinctes de s'exécuter en parallèle : dans le cas des jeux vidéos, il est commun de voir la simulation et le rendu avoir chacun leur propre thread. Mais le réel avantage apparaît lorsqu'on fractionne une lourde tâche en plusieurs tâches plus petites. Le programmeur peut alors utiliser chaque thread disponible pour exécuter une fraction de la tâche en question. Un programme optimisé ira même jusqu'à tirer parti d'une pause sur un thread pour exécuter une autre tâche avant que la tâche principale ne reprenne.
Exemple sur le modèle du travail en cuisine cité plus haut : Le chef (thread principal) veut préparer des maki-sushi : il fait cuire le riz et demande à ses 3 commis (worker-threads) de couper la garniture et de rouler les makis. La garniture est composée de 6 ingrédients, chaque commis coupe alors 2 ingrédients, mais ne peut pas rouler ses makis tant que le riz n'est pas cuit. Cette manière de procéder s'applique parfaitement dans notre cadre informatique. On peut ajouter que le chef peut par exemple laver ses ustensiles en même temps qu'il surveille son riz (on parle alors de tâche asynchrone ou de programmation concurrentielle).
Cette approche de la programmation est complexe, notamment parce que les threads partagent le même emplacement de la mémoire en lecture et en écriture. Son utilisation requiert prudence et organisation.
On parle de multithreading en parallèle, ce qui est le sujet ici. Notez qu'il existe d'autres approches de leur utilisation telles que les exécutions asynchrones ou le contournement d'opérations bloquantes.
GPU et drivers
La carte graphique, ou GPU, est un ordinateur dans l'ordinateur. C'est un composant qui possède son propre processeur, et sa propre mémoire, et sa propre carte mère (carte fille). Son architecture est telle que ce composant exécute plus rapidement et plus facilement les opérations propres à l'affichage d'images sur un écran. Chacune des données que le GPU manipule lui est fournie par le CPU principal, et il peut également lui en retourner.
Un driver est un programme informatique permettant au constructeur d'un composant (AMD, Nvidia, Intel, etc) de communiquer avec le système d'exploitation d'un ordinateur (Windows, Linux, etc) via une API (programme tiers). Sans driver, le composant est inaccessible. Si le système et le driver n'arrivent pas à communiquer parce que l'un ou l'autre n'embarque pas les bonnes fonctions, le matériel est inaccessible ou mal utilisé.
API graphique
Une API graphique est un programme qui s'exécute dans le système d'exploitation, afin de communiquer avec le driver du GPU.
Il en existe un certain nombre dont voici une liste non exhaustive :
Direct3D (DirectX) pour Windows.
OpenGL pour Linux et Windows (et OSX jusqu'à Mojave).
Vulkan pour virtuellement toutes les plateformes.
Metal pour OSX.
Elle permet aux développeurs d'écrire du code à exécuter sur le matériel graphique sans avoir à se soucier du constructeur. Selon l'API utilisée, différentes fonctionnalités sont disponibles pour les programmeurs (statistiques, fonctionnalités spécifiques au matériel, etc).
Rendu graphique
Le processus de rendu graphique consiste à décomposer l'état du programme du jeu à un instant T, afin de fournir ces informations à la carte graphique sous une forme qui lui est interprétable, pour afficher cet état à l'écran.
Le moteur de rendu graphique (comme gen12) est un programme qui accède à des données dans la mémoire (exemple les points et la couleur d'un triangle), puis utilise l'API graphique et d'autres programmes appelés shaders pour décrire comment la carte graphique doit utiliser ces données en 3D pour les afficher sur un écran en 2D. Il est également en charge de mettre les images en tampon pour qu'elles soient pré-calculées, et plus généralement de gérer tout ce qui est relatif à l'affichage.
Le cœur du moteur de rendu est appelé le pipeline. Le pipeline graphique est une suite d'instructions que suit la carte graphique pour transformer la 3D et ses valeurs continues (coordonnées), en 2D et valeur discrètes (pixels).
Certaines fonctions du pipeline sont fixes et seuls leurs paramètres peuvent être modifiés, et d'autres sont programmables et les développeurs peuvent écrire chaque étape du processus. En voici les étapes dans l'ordre :
- Assembleur
- Vertex Shader
- Tessellation
- Shader Géométrique
- Rasterisation
- Fragment Shader
- Color blending
Une fois ces étapes passées, l'image est prête à être affichée, lorsque son tour vient.
Juste avant le pipeline se situe la render pass ou passe de rendu. Cette étape vise à réunir et à formater les informations au sujet de l'objet qui va être affiché à l'écran, et elle ne fait pas appel directement à l'API. L'ensemble des opérations du processus de rendu est intégré dans ce qui est appelé le render-graph.
Gen12
Avant toute chose, un peu de contexte. La grande majorité des moteurs de jeu vidéo (Cry Engine, Unity, Unreal Engine, etc) utilisent deux threads majeurs : le main-thread, et le render-thread.
L'un contient la simulation du jeu, et l'autre le moteur de rendu.
Le main-thread travaille sur une image I au temps T, et le render-thread prépare cette même image pour affichage au temps T + 1. Chaque Temps étant défini soit par la VSync, soit par la fin des processus du thread le plus lent.
Fin 2021, les développeurs de CIG ont placé 3 étapes majeures pour considérer le moteur comme terminé :
- La réécriture de tous les processus dans le moteur pour une efficacité maximale.
- Le passage de Direct3D à Vulkan.
- Un rendu complétement multithread (plus de thread de rendu).
Réécriture du moteur
Le moteur originel chez CIG décomposait trop les étapes nécessaires au rendu. Les différentes passes, les différents pipelines et les différentes instructions étaient sélectionnés à la volée, selon les paramètres reçus par le moteur, indifféremment du type d'objet manipulé. Cela signifiait qu'il existait beaucoup d'interdépendance entre les diverses fonctions, et donc que chaque processus utilisait plus de puissance de calcul qu'il ne lui était en réalité nécessaire.
Le programme gen12, reformule chacune de ces instructions afin qu'un type d'objet (transparent, opaque, UI de premier plan, etc) ait une unique suite d'instructions qui sont propres à son type. Ainsi deux objets d'un même type exécuteront exactement la même suite d'opérations lors du rendu. Cela permet d'anticiper le comportement du programme, de mieux comprendre les problèmes, et bien sûr d'améliorer les performances grâce à la réduction générale du nombre d'instructions.
Cette approche paraît contrintuitive à première vue. En effet, la première méthode offre de la flexibilité, et devrait en théorie, si elle est bien exécutée, être plus performante. Dans les faits, chaque étape de la décomposition était coûteuse, et l'interprétation des paramètres entre chaque commande faisait que cette méthode était au final moins performante que l'approche choisie pour gen12. De plus, cette première méthode s'avérait être très complexe, et difficile à travailler et à améliorer du fait du nombre de dépendances.
Par ailleurs, cette nouvelle méthode permet d'aborder le multithreading bien plus aisément.
Ce premier jalon dans la sortie de gen12 sera passé en 3.18.
Passage à Vulkan
Vulkan est une API graphique platform-agnostic ce qui signifie qu'elle s'exécute de la même manière quelle que soit la plateforme (Window, Linux, Console de jeu, etc). Les spécificités de chaque plateforme pour l'affichage par exemple, sont gérées par un autre programme dans cette approche. Ce programme a été créé avant tout pour tirer le meilleur parti des matériels modernes, face à des concurrents vieillissants qui favorisent la compatibilité avec du matériel plus ancien, et donc les bases du programme sont datées, ce qui rend plus complexe leur utilisation. Par ailleurs, le paradigme de Vulkan veut laisser le maximum d'options possibles aux mains des développeurs, ce qui nécessite plus de lignes de code, mais un moteur de rendu mieux adapté aux besoins spécifiques, et plus théoriquement performant.
Chez CIG, le principal bottleneck (thread bloquant) est le moteur de rendu. La première raison qui les a poussés à passer sur Vulkan est la capacité de l'API à soumettre des travaux au GPU en parallèle, ce qui est un gain potentiel en performances très conséquent.
Parmi les avantages de Vulkan, qui seront explorés à l'avenir, CIG cite principalement :
- Le variable rate shading(*1)
- Les Bindless Resources(*2)
- Le Ray-Tracing(*3)
CIG compte également utiliser les fonctionnalités de Vulkan pour collecter des données relatives au matériel des joueurs, afin de proposer à leur tour des fonctionnalités ciblées pour le matériel de sa base de joueurs.
Comme cité dans le premier paragraphe, Vulkan propose très peu de solutions "out-of-the-box". CIG développe donc sa propre approche des différentes étapes nécessaires au rendu dans ce qu'ils appellent le render-graph. Avoir ce contrôle sur la totalité des opérations effectuées, ainsi que leur propre outil de débogage, leur permet de cibler facilement les divers problèmes et les possibles optimisations.
L'état des données est constamment observé et accessible dans le render-graph. Cela permet aux équipes de sauvegarder (cache) certaines données et ressources d'une image pour les réutiliser dans la suivante, ce qui est une réelle optimisation.
Des pipeline barriers sont placées entre les différentes fonctions du render-graph. Ces barrières permettent de valider une donnée, ainsi que changer son état (lecture/écriture), et s'assurer que tous les éléments sont présents pour pouvoir exécuter la fonction suivante. L'introduction de ces barrières est une anticipation du rendu multithread, qui permet à la fois la synchronisation des processus, des données, et de l'ordre des images en elles même.
L'implémentation de Vulkan permet également l'utilisation du compilateur de shaders Dxc(*4). Dxc permet l'utilisation du modèle de shaders 6.x (shader model 6). Cette version de l'écriture des shaders permet la maîtrise par les développeurs du calcul parallèle sur GPU (un procédé qui peut se rapporter au multithreading sur CPU, mais l'architecture des GPU étant différente, il s'agit plutôt de milliers de micro-tâches exécutées en même temps), ainsi que l'implémentation du variable rate shading.
La migration du moteur de rendu vers Vulkan permet donc de meilleures performances en laissant aux développeurs la liberté de n'inclure que les fonctionnalités dont ils ont besoin, ainsi que leurs propres outils de suivi des performances. Cette approche permettra aussi d'utiliser facilement toutes les technologies graphiques modernes.
Architecture Multithread
La décision de travailler sur un rendu multithread repose sur un postulat simple : le render-thread est ce qui bloque les performances sur Star Citizen, et c'est aussi le seul processus qui n'utilise pas les capacités du matériel informatique moderne. En effet, le render-thread ne peut pas être programmé en multithread dans son état actuel (cf. Paragraphe multithread).
Le paradigme visé par CIG est de supprimer totalement le render-thread. Comme cité plus haut, actuellement, le main-thread travaille pendant une image, et le render-thread accède à ces données lors de l'image suivante. Le tout étant synchronisé par la Vsync, ce qui laisse des espaces lorsqu'un thread termine son travail avant l'autre. Dans la nouvelle implémentation, le main-thread préparerait en permanence des groupes de données pour exécution par le moteur de rendu en multithread total. Tout le processus serait alors contrôlé et synchronisé uniquement par le thread principal.
La suite de ce paragraphe se place dans le contexte des objets physiques exclusivement (Scene Objects).
Chaque objet exécute ce que l'on appelle un "Draw Call" pour être affiché à l'écran. Un draw call regroupe le code du moteur de rendu, et le code du pilote graphique. Tous les objets ont un draw call d'un coût similaire en puissance de calcul. Actuellement, chaque draw call est exécuté tour à tour sur le render-thread, jusqu'à ce que tous les objets soient prêts à être affichés.
Le passage au multithread total se fera en plusieurs étapes :
- Culling en parallèle : Cette étape consiste à ce que le main-thread détermine si un objet est visible de quelque manière que ce soit (caméra, ombres, RTT(*5)). Ces opérations seront exécutées en parallèle pour tous les objets dans des sous-tâches, puis copiées dans la mémoire destinée au render-thread. Lors du rendu de l'objet, le render-thread n'aura donc plus à déterminer la visibilité de l'objet, ce travail aura été réalisé en avance.
- Déplacement du code du moteur de rendu vers les groupes de travail créés lors de l'étape précédente, afin que ne subsiste sur le render-thread, que le code du driver graphique. Ce processus se fait en même temps que la réécriture du moteur, afin de ne cibler que les fonctions nécessaires et les réécrire au besoin pour s'adapter à leur nouvelle formule d'exécution.
- Viendra enfin le déplacement du code du driver vers ces mêmes groupes. Cette dernière étape n'est réalisable que parce que Vulkan le permet, et aurait été impossible à faire sous Dx12. Cependant, pour cette exécution en parallèle, les données traitées doivent être préparées d'un certaine manière, ce qui est le sujet des deux paragraphes précédents.
La création de gen12 permet donc de se défaire du render-thread afin de diviser son travail en plusieurs threads indépendants, tous contrôlés et programmés par le main-thread. Lors de la citizenCon 2021, les développeurs en étaient à l'étape 2. Cette étape sera terminée avec la 3.18 et l'étape 3 ne pourra être finalisée que lorsque Vulkan aura été implémenté.