
Récemment, des discussions ont animé la communauté au sujet des saccades dues aux shaders et de leur impact sur les projets de développement de jeux. Les équipes d’ingénieurs d’Epic Games ont travaillé sur le système de préchargement des PSO de l’Unreal Engine et aujourd’hui, nous allons aborder les raisons de ce phénomène, expliquer la manière dont le préchargement des PSO peut aider à le prévenir et présenter quelques-unes des pratiques de développement qui vous aideront à minimiser les saccades dues aux shaders. Nous vous ferons également part de nos projets à venir pour le système de préchargement des PSO.
Historique
Les saccades dues à la compilation des shaders se produisent lorsqu’un moteur de rendu découvre qu’il doit compiler un nouveau shader juste avant de l’utiliser pour afficher quelque chose. Tout se fige alors en attendant que le pilote termine la compilation. Pour comprendre pourquoi ce phénomène se produit, nous devons examiner de plus près la manière dont les shaders sont traduits en code exécuté sur le GPU.
Les shaders sont des programmes qui s’exécutent sur le GPU pour procéder aux différentes étapes du rendu des images 3D : transformation, déformation, ombrage, éclairage, post-traitement, etc. Ils sont généralement écrits dans un langage de haut niveau tel que le HLSL, qui doit être compilé en code machine que le GPU peut exécuter. Il en va de même pour les CPU, où le code écrit dans un langage de haut niveau comme le C++ est envoyé à un compilateur afin de produire des instructions pour une architecture donnée : x64, ARM, etc.
Il existe toutefois une différence essentielle : chaque plateforme (PC, Mac, Android, etc.) vise généralement un ou deux ensembles d’instructions pour CPU, mais de nombreux GPU différents, avec des ensembles d’instructions extrêmement variés. Un exécutable compilé il y a 10 ans pour les PC x64 fonctionnera sur des puces produites aujourd’hui par AMD et Intel parce que ces deux fabricants utilisent le même ensemble d’instructions, et parce qu’ils offrent des garanties de rétrocompatibilité. A contrario, un binaire de GPU compilé pour AMD ne fonctionnera pas chez NVIDIA et vice-versa. Les ensembles d’instructions peuvent même changer entre deux générations de matériel d’un même fournisseur.
Par conséquent, s’il est possible de compiler des programmes de CPU directement en code machine exécutable et de les distribuer, les programmes de GPU requièrent une approche différente. Le code de shader de haut niveau est compilé en une représentation intermédiaire, ou bytecode, qui utilise un ensemble d’instructions abstrait défini par l’API 3D : DXBC pour Direct3D 11, DXIL pour Direct3D 12, SPIR-V pour Vulkan, etc.
Les jeux incluent ces fichiers binaires en bytecode afin de disposer d’une seule bibliothèque de shaders, au lieu d’une bibliothèque par architecture de GPU possible. À l’exécution, le pilote traduit le bytecode en code exécutable pour le GPU installé dans la machine. Cette approche est parfois utilisée pour les programmes de CPU également. Par exemple, le code source Java est compilé en bytecode afin que le même binaire puisse fonctionner sur toutes les plateformes disposant d’un environnement Java, quel que soit leur processeur.
Lorsque ce système est apparu, les jeux avaient des shaders relativement basiques et peu nombreux, et la transformation du bytecode en code exécutable était simple, donc le coût de cette opération à l’exécution était négligeable. À mesure que les GPU sont devenus plus puissants, le code des shaders s’est étoffé et les pilotes ont également commencé à procéder à des transformations sophistiquées pour produire un code machine plus efficace. Par conséquent, le coût de la compilation à l’exécution est devenu problématique. Un point de rupture a été atteint avec Direct3D 11, donc les API modernes telles que Direct3D 12 et Vulkan ont entrepris de remédier au problème en introduisant le concept d’objets d’état de pipeline (Pipeline State Object) (PSO).
Etat du pipeline
Le rendu d’un objet implique généralement plusieurs shaders (par exemple, un shader de vertex associé à un shader de pixel), ainsi qu’un certain nombre de paramètres GPU : le mode d’élimination prématurée, le mode de fusion, les modes de comparaison de profondeur et de pochoir, etc. Ensemble, ces éléments décrivent la configuration, ou l’état, du pipeline du GPU.
Les API graphiques plus anciennes comme Direct3D 11 et OpenGL permettent de modifier des parties de l’état individuellement et à tout instant, ce qui signifie que le pilote ne voit la configuration complète que lorsque le jeu émet une demande d’affichage. Certains paramètres influencent le code exécutable des shaders. Par conséquent, dans certains cas, le pilote ne peut commencer à compiler les shaders que lorsque la demande d’affichage est traitée. L’opération peut prendre plusieurs dizaines de millisecondes pour une seule demande d’affichage, ce qui se traduit par un affichage très lent lorsqu’un shader est utilisé pour la première fois. Un phénomène connu de la plupart des joueurs sous le nom de saccade.
Avec les API modernes, les développeurs doivent regrouper tous les shaders et paramètres qu’ils utiliseront pour une demande d’affichage dans un objet d’état de pipeline et le définir en tant qu’unité. Les PSO peuvent être constitués à tout moment. En théorie, les moteurs peuvent donc créer tout ce dont ils ont besoin suffisamment tôt (par exemple, pendant le chargement), de sorte que la compilation ait le temps de se terminer avant le rendu.
La théorie et la pratique
L’Unreal Engine intègre un puissant système de création de matériaux que les artistes utilisent pour réaliser des mondes visuellement riches et attrayants. De nombreux jeux contiennent des milliers de matériaux. Chacun peut produire de nombreux shaders différents. Par exemple, il existe des shaders de vertex distincts pour le rendu d’un matériau sur des maillages statiques, sur des maillages surfacés et sur des maillages de spline. Le même shader de vertex peut être utilisé avec plusieurs shaders de pixels, ce qu’il faut encore multiplier par la diversité des ensembles de paramètres de pipeline. Il peut en résulter des millions de PSO différents qu’il faudrait compiler en amont pour que toutes les possibilités soient prises en compte, ce qui est bien sûr irréalisable pour des raisons de temps et de mémoire (le chargement d’un niveau prendrait des heures).
Un sous-ensemble très réduit de ces PSO possibles est utilisé à l’exécution, mais il est impossible de le déterminer en examinant un matériau de manière isolée. Le sous-ensemble peut également changer entre deux sessions de jeu : la modification des paramètres vidéo active certaines fonctionnalités de rendu, qui vont amener le moteur à utiliser des shaders ou des états de pipeline différents. Les premières implémentations de moteur Direct3D 12 se basaient sur des tests de jeu, des survols automatisés des niveaux et d’autres méthodes de découverte similaires pour enregistrer les PSO rencontrés en pratique. Ces données étaient incluses dans le jeu final et utilisées pour créer les PSO identifiés au démarrage ou au chargement du niveau. L’Unreal Engine appelle cela un « cache de PSO en lots« , et c’était notre méthode recommandée jusqu’à l’UE 5.2.
Le cache en lots est suffisant pour certains jeux, mais il présente de nombreuses limitations. Le constituer demande beaucoup de ressources et il doit être mis à jour à chaque changement du contenu. Le processus d’enregistrement peut ne pas suffire pour découvrir tous les PSO dans les jeux aux mondes très dynamiques. Par exemple, si les objets changent de matériaux en fonction des actions du joueur.
Le cache peut grossir plus que nécessaire pendant une session de jeu s’il y a beaucoup de variations d’une partie à l’autre. Par exemple, s’il y a beaucoup de cartes ou si les joueurs peuvent choisir leur apparence. Fortnite est typiquement un cas dans lequel le cache en lots n’est pas adapté, car il coche toutes ces cases. Par ailleurs, il intègre du contenu généré par les utilisateurs. Il faudrait donc utiliser un cache de PSO spécifique à chaque expérience et confier la responsabilité de la collecte de ces caches aux créateurs de contenu.
Préchargement des PSO
Pour pouvoir prendre en charge des mondes de jeu vastes et variés, ainsi que le contenu généré par les utilisateurs, l’Unreal Engine 5.2 a présenté le préchargement des PSO, une technique permettant de déterminer les PSO potentiels au moment du chargement. Lorsqu’un objet est chargé, le système examine ses matériaux et utilise les informations contenues dans le maillage (par exemple, s’il est statique ou animé) ainsi que l’état global (par exemple, les paramètres de qualité vidéo) pour calculer un sous-ensemble de PSO possibles pouvant être utilisés pour le rendu de l’objet.
Ce sous-ensemble est plus grand que ce qui est réellement utilisé, mais bien plus petit que l’éventail complet des possibilités. Il devient alors possible de le compiler pendant le chargement. Par exemple, Fortnite Battle Royale compile environ 30 000 PSO pour une partie et en utilise environ 10 000, mais ce n’est qu’une fraction de la totalité des combinaisons possibles, qui s’élève à plusieurs millions.
Les objets créés durant le chargement de la carte préchargent leurs PSO pendant que l’écran de chargement est affiché. Ceux qui sont chargés de façon dynamique ou apparaissent en cours de partie peuvent soit attendre que leurs PSO soient prêts avant d’être rendus, soit utiliser un matériau par défaut qui a déjà été compilé. Dans la plupart des cas, le chargement dynamique n’est retardé que de quelques images, ce qui n’est pas perceptible. Ce système permet d’éliminer les saccades de compilation des PSO pour les matériaux et fonctionne parfaitement avec le contenu généré par l’utilisateur.
Changer le matériau d’un maillage déjà visible est plus difficile, car nous ne voulons pas le cacher ou le rendre avec un matériau par défaut pendant que le nouveau PSO est compilé. Nous travaillons sur une API qui permet au code du jeu et aux blueprints de prévenir le système de façon à ce que les PSO supplémentaires puissent également être mis en pré-cache. Nous voulons aussi modifier le moteur pour qu’il continue à rendre l’ancien matériau pendant que le nouveau est en cours de compilation.
L’Unreal Engine comporte une classe distincte de shaders qui ne sont pas liés aux matériaux. Il s’agit des shaders globaux, des programmes utilisés par l’outil de rendu pour implémenter divers algorithmes et effets, tels que le flou de mouvement, la mise à l’échelle, le débruitage, etc. Le mécanisme de mise en pré-cache aussi couvre les shaders de calcul globaux, mais dans la version 5.5 de l’UE, il ne gère pas les shaders graphiques globaux. Ces types de PSO peuvent encore causer de rares saccades ponctuelles lorsqu’ils sont utilisés pour la première fois. Nous travaillons actuellement à combler cette lacune du préchargement.
Le cache en lots peut être utilisé en conjonction avec le préchargement, ce qui peut constituer un avantage pour certains jeux. Certains matériaux courants peuvent être inclus dans le cache en lots afin d’être compilés au démarrage plutôt qu’en cours de partie. Il peut également aider avec les shaders graphiques globaux, puisque le processus de découverte les trouvera et les enregistrera.
Le cache des pilotes
Les pilotes enregistrent les PSO compilés sur le disque, de sorte qu’ils puissent être chargés directement si besoin lors des sessions de jeu ultérieures. Cette façon de faire profite à tous les jeux, quels que soient leur moteur et leur stratégie de compilation des PSO. Pour les titres Unreal Engine utilisant le préchargement des PSO, cela implique que l’écran de chargement sera sensiblement plus court à la deuxième exécution. Il faut environ 20 à 30 secondes de plus à Fortnite pour charger une partie de Battle Royale lorsque le cache de pilote est vide. Le cache est vidé à l’installation d’un nouveau pilote. Il est donc normal que les écrans de chargement durent plus longtemps la première fois qu’un jeu est lancé après une mise à jour du pilote.
L’Unreal Engine tire parti du cache de pilote en créant des PSO pendant le chargement et en s’en débarrassant immédiatement à la fin de la compilation (c’est pourquoi cette technique est appelée « préchargement »). Lorsqu’un PSO est requis ultérieurement pour le rendu, le moteur émet une demande de compilation, mais le pilote y répond simplement par le biais du cache, car le système de préchargement a fait en sorte que le PSO s’y trouve. Quand un PSO est utilisé pour l’affichage, il reste chargé jusqu’à ce que toutes les primitives qui l’utilisent dans la scène soient supprimées, ce qui évite de le demander au pilote à chaque image.
La suppression après le préchargement a l’avantage de libérer la mémoire des PSO inutilisés. L’inconvénient est que la recherche d’un PSO dans le cache de pilote quand il est requis peut encore demander un certain temps. Et même si la recherche est beaucoup plus rapide qu’une compilation, des microsaccades peuvent encore survenir la première fois qu’un matériau est rendu.
Une solution simple consiste à conserver les PSO préchargés au lieu de les supprimer, mais cela peut augmenter l’utilisation de la mémoire de plus de 1 Go et doit donc être réservé aux machines disposant de suffisamment de RAM. Nous travaillons sur des solutions permettant de réduire l’impact sur la mémoire et de décider automatiquement quand les PSO préchargés peuvent être conservés.
Seuls certains états affectent le code PSO exécutable. Autrement dit, si on crée deux PSO qui ont les mêmes shaders, mais des paramètres de pipeline différents, il est possible que seul le premier passe par le processus de compilation coûteux et que le second soit renvoyé immédiatement depuis le cache.
Malheureusement, l’ensemble des états pris en compte pour la génération du code diffère d’un GPU à l’autre et peut même changer d’une version de pilote à l’autre. L’Unreal Engine tire parti de connaissances pratiques qui permettent d’ignorer certaines permutations au cours du processus de préchargement. Les requêtes redondantes sont plus courtes grâce au cache de pilote, mais le moteur doit néanmoins toujours les générer. Ce travail s’accumule, donc le processus de nettoyage est utile pour réduire les temps de chargement ainsi que l’utilisation de la mémoire.
Plateformes mobiles et consoles
Les plateformes mobiles utilisent le même modèle de compilation des shaders sur l’appareil et le système de préchargement de l’Unreal Engine y est également efficace. En général, l’outil de rendu sur mobile utilise moins de shaders que sur ordinateur, mais la compilation des PSO prend beaucoup plus de temps en raison de la lenteur des processeurs. Nous avons donc dû apporter quelques modifications au processus pour le rendre possible.
On ignore certaines permutations rarement utilisées, ce qui signifie que la configuration du préchargement ne les conserve pas. Dans certains cas, des saccades peuvent donc survenir si l’un des états peu courants se trouve finalement rendu. Nous appliquons également une limite de temps au préchargement durant le chargement de la carte afin d’éviter que l’écran de chargement ne s’éternise. Par conséquent, la partie peut démarrer alors qu’il reste des tâches de compilation à accomplir, et il y aura donc des saccades si l’un des PSO en attente est requis immédiatement. Un système de boost prioritaire déplace les tâches en haut de la file d’attente lorsqu’un PSO est requis, afin de minimiser ce problème.
Les consoles ne rencontrent pas ce problème, car elles ont un seul GPU cible. Un par un, les shaders sont compilés directement en code exécutable et intégrés au jeu. Il n’y a pas d’explosion combinatoire due à l’utilisation du même shader de vertex avec plusieurs shaders de pixels, ou due aux états de pipeline, car ces facteurs n’entraînent pas de recompilation. Les shaders et les états peuvent être assemblés en PSO au moment de l’exécution sans que cela n’engendre de coûts importants. Il n’y a donc pas de saccades de PSO sur ces plateformes.
La nostalgie de Direct3D 11
Selon une idée partiellement fausse, Direct3D 11 n’aurait jamais connu ces problèmes et on entend parfois des appels à revenir à l’ancien modèle de compilation, ou même aux anciennes API graphiques. Comme nous l’avons expliqué, des saccades se produisaient également à l’époque et en raison de la manière dont l’API était conçue, les moteurs n’avaient aucun moyen de les prévenir. Elles étaient simplement moins fréquentes ou plus courtes, principalement parce que les shaders étaient plus simples et moins nombreux et parce que certaines fonctionnalités telles que le ray tracing n’existaient pas encore.
Les pilotes faisaient aussi tout ce qu’ils pouvaient pour minimiser les saccades, mais n’arrivaient pas à les éviter complètement. Direct3D 12 a tenté de résoudre le problème avant qu’il ne s’aggrave en introduisant les PSO, mais les moteurs ont tardé à les utiliser efficacement, en partie à cause de la difficulté à adapter les systèmes de matériaux existants et en partie à cause des lacunes de l’API qui ne sont apparues qu’à mesure que les jeux sont devenus plus complexes.
L’Unreal Engine est un moteur polyvalent avec de nombreux cas d’utilisation et pour lequel il existe beaucoup de contenu et de flux de travail. Le problème était donc particulièrement difficile à résoudre. Nous arrivons enfin à un stade où nous disposons d’une solution viable et de bonnes initiatives pour aider à pallier les lacunes de l’API, comme l’extension Vulkan de la bibliothèque du pipeline graphique.
Travail restant
Le système de préchargement a beaucoup évolué depuis son ajout expérimental dans la version 5.2 et il permet déjà d’éviter la plupart des saccades de compilation des shaders. Cependant, il ne couvre pas encore tous les cas et des efforts sont actuellement déployés pour l’améliorer davantage. Nous travaillons également avec des fournisseurs de matériel et de logiciels pour adapter les pilotes et les API graphiques à la manière dont les jeux utilisent ces systèmes.
Notre objectif final est de gérer le préchargement de manière automatique et optimale, afin que les développeurs de jeux n’aient pas à s’en soucier. En attendant que le système soit fin prêt, voici ce que vous pouvez faire en tant que titulaire de licence pour garantir la fluidité de votre jeu :
- Utilisez la dernière version du moteur. Comme le préchargement est en évolution constante, les nouvelles versions du moteur seront celles qui fonctionneront le mieux. Si une mise à niveau complète n’est pas possible, vous devriez pouvoir répercuter la plupart des améliorations sur votre version personnalisée du moteur.
- Identifiez les saccades de PSO dans votre jeu. Utilisez r.PSOPrecache.Validation=2, comme expliqué dans la documentation, pour identifier les PSO manqués ou tardifs et en comprendre l’origine.
- Videz le cache de pilote avant les tests. Utiliser l’argument de ligne de commande -clearPSODriverCache pendant les tests de jeu vous donnera un aperçu de l’expérience du joueur lorsqu’il lance le jeu pour la première fois ou après une mise à jour du pilote. Guettez les saccades dans ce mode et résolvez-les à l’aide des outils de profilage et de débogage mentionnés ci-dessus.
- Répétez ce processus régulièrement. Les modifications apportées au contenu ou au code du jeu peuvent introduire de nouveaux problèmes ou révéler des bogues dans le système. Nous vous conseillons vivement de surveiller les statistiques des PSO dans le cadre de leurs procédures de test automatisées.
- Surveillez d’autres types de saccades lors du parcours. La compilation des PSO n’est pas la seule source de saccades en cours de partie et il est difficile d’identifier le véritable responsable sans instruments. Procédez régulièrement au profilage du jeu durant le développement et les tests pour repérer d’autres processus coûteux pouvant provoquer des ralentissements, tels que le chargement synchrone, les apparitions et chargements dynamiques excessifs, les captures de scène déclenchées par les mouvements, etc.
