QUANTECH BLOG – OPTIMISATION DU MOTEUR 3D POUR PC

Quand il s’agit d’aborder la question de la technologie, Quantic Dream repose sur un principe essentiel : la technique est avant tout au service de l’expérience ; elle ne doit pas être un frein à la créativité, mais au contraire la sublimer.

C’est dans ce cadre que nos équipes de développement imaginent jour après jour les solutions qui permettent de servir la vision de nos artistes et scénaristes. À travers cette nouvelle série de blogs techniques, nous laissons la parole à nos équipes de développement, qui lèvent le voile sur des aspects très précis de leurs travaux.

Cet article n’est évidemment pas adressé à des lecteurs classiques, que ce soient les joueurs ou les fans ; nous souhaitons nous adresser directement ici à des développeurs, ingénieurs, chercheurs, étudiants et amateurs éclairés de technologie.

Cet essai sur l’optimisation du moteur 3D est signé par Kevin Havranek, 3D Engine Programmer.

Table des matières

  • Introduction
  • Interface avec l’API graphique
    • Librairie RENDER_CONTEXT
    • Exposition des Pipelines
    • Exposition des Ressources
    • Pseudo-Code
  • Réduction de nos Draw Calls
    • Limiter les échanges CPU -> GPU
    • Suppression des Vertex Buffers
    • Regroupement des Index Buffers (et autres)
  • Conclusion
  • Remerciements

Introduction
Historiquement, la technologie interne développée par Quantic Dream cible la PlayStation, et nous avions également une version PC tournant sur OpenGL pour nos besoins d’édition. De façon classique, notre moteur 3D utilise une librairie qui sert d’interface entre le reste du moteur et la librairie graphique utilisée.
Pour porter Detroit: Become Human sur PC nous avons réécrit l’interface de notre moteur de rendu pour supporter Vulkan. Je vous invite à lire l’article que nous avions écrit alors pour AMD GPU Open. Nous avons ensuite poursuivi ce travail d’optimisation pour avoir un moteur 3D plus performant, afin de supporter les productions suivantes.
Le but des optimisations de l’interface de rendu est avant tout de piloter le GPU le plus rapidement possible. Une opération particulièrement récurrente est par exemple la fonction DrawPrimitive() et ses variantes. Lorsqu’il y a un grand nombre d’objets, il est critique qu’elle soit rapide pour garder un bon frame rate.
La première implémentation Vulkan de notre interface, pour des raisons de temps et de facilité, a été assez directe, et a consisté à réécrire les concepts OpenGL ou DirectX. Une grande partie du travail consistait à exposer les concepts Vulkan et modifier le moteur pour qu’il les utilise.

Interface avec l’API graphique
Librairie RENDER_CONTEXT

Le RENDER_CONTEXT dans notre moteur est tout simplement l’interface qui permet de faire le lien entre notre moteur et les différentes API graphiques que nous supportons comme Vulkan, OpenGL et la PlayStation.

Cette librairie a une classe centrale : RENDER_CONTEXT, qui permet de piloter le GPU, et de créer des classes qui représentent des textures, des buffers, des render targets, etc. Toutes ces classes ont une implémentation par API graphique.

Lorsque nous avons porté notre moteur sur PC avec Vulkan, le plus gros du travail a été fait sur cette interface. Faute de temps, nous n’avons pas pu modifier le moteur en profondeur et nous nous sommes concentrés sur une implémentation Vulkan de l’API qui respectait l’interface de l’époque.

Nous avons rapidement compris que pour obtenir de bonnes performances, il fallait exposer certains concepts de Vulkan dans notre interface et donc modifier le moteur en profondeur.

Exposition des Pipelines
Contrairement à OpenGL et les autres API graphiques, Vulkan utilise le concept de VkPipeline qui peut contenir un ensemble de shaders avec des états. Avec OpenGL c’était le driver qui s’occupait de générer les différents pipelines nécessaires à l’affichage. Mais avec Vulkan, c’est aux développeurs que revient cette tâche avec la création d’objets de type VkPipeline.

Lors de la première implémentation de Vulkan pour Detroit: Become Human, les programmeurs envoyaient directement à notre interface les shaders ainsi que leurs états. C’était ensuite notre interface qui se chargeait de créer et utiliser les différents pipelines au moment de l’affichage. Finalement on reproduisait ce que faisait OpenGL, mais au niveau de notre interface.

Cela causait 2 problèmes :

  • Cela prend du temps, car à chaque draw call, tout un process est nécessaire pour aller chercher un VkPipeline existant (selon les ressources utilisées), ou pour le créer, le cas échéant.
  • Les programmeurs n’ont pas conscience de ce mode de fonctionnement et peuvent produire un code non optimal qui nécessite un nombre plus élevé de VkPipeline.

Pour accorder la possibilité de mieux contrôler la création et l’enchaînement des VkPipeline, on a retravaillé notre interface afin que les développeurs puissent les créer directement. On a donc permis à nos différents modules de rendus de pouvoir être contrôlés via une nouvelle gestion des shaders, qui est conceptuellement proche du système de VkPipeline proposé par Vulkan. Une fois ces modules réécrits, cette nouvelle façon de procéder nous a permis de nous débarrasser de cette gestion automatique des VkPipeline, qui impactait les performances. De plus cela oblige le développeur à bien définir les différents états dès la création d’un pipeline, ce qui empêchera les comportements aléatoires que pouvait introduire une gestion de type « machine à états ».

Exposition des Ressources
Pour gérer et envoyer les ressources au GPU avec Vulkan, on doit passer par un objet de type VkDescriptorSet. Là où en OpenGL un simple appel à une fonction de type bind permet d’envoyer facilement une ressource au shader, pour Vulkan il faut d’abord mettre à jour le VkDescriptorSet puis l’attacher au VkCommandBuffer, qui exécutera le shader ; le processus d’envoi de ressources est donc plus complexe.

Dans Detroit: Become Human, pour la première implémentation de Vulkan – et de la même manière que pour les VkPipeline – la gestion des VkDescriptorSet était faite en interne dans notre interface. Cela impliquait une gestion automatique des VkDescriptorSetLayout qui permet de décrire les différentes ressources et le format que nos shaders / VkDescriptorSet auraient. La gestion de ces VkDescriptorSetLayout a donc amené de nouveaux concepts qu’il nous a fallu appréhender pour optimiser notre moteur.

Contrairement à ce que propose OpenGL, il faut connaître à l’avance les combinaisons de descripteurs pour créer des VkDescriptorSetLayout. Derrière une interface et sans indication de la part du développeur, il est impossible de connaitre cette combinaison avant même l’exécution du shader et de ses ressources associées. Il fallait alors générer les VkDescriptorSetLayout et les VkDescriptorSet au moment du draw call ou du dispatch. Même si on avait des exécutions avec des combinaisons de descripteur semblables, il fallait tout de même générer une paire de VkDescriptorSetLayout / VkDescriptorSet pour chacune des exécutions. Un premier problème concerne donc un nombre plus élevé que nécessaire de VkDescriptorSet et de leur mise à jour, alors que cela pourrait être fait à l’initialisation plutôt que pendant la première exécution.

La première moitié du travail sur l’interface a été de donner la possibilité aux développeurs de créer et mettre à jour leurs propres VkDescriptorSet, pour pouvoir le faire à l’initialisation de nos différents modules, plutôt que d’en laisser la charge à l’interface elle-même. Pour utiliser un VkDescriptorSet, il suffit de l’attacher avant un draw call ou un dispatch.

Il a également fallu donner la possibilité aux développeurs de créer leurs VkDescriptorSetLayout, pour qu’ils définissent eux-mêmes les schémas de bindings et regrouper différentes combinaisons de descripteurs similaires, ce que notre interface ne proposait pas auparavant.

Nous avons donc profité de cette nouvelle possibilité pour certains de nos modules de rendu, qui effectuent beaucoup de draw calls aux combinaisons de ressources très proches. Alors que notre interface générait un nombre important de VkDescriptorSetLayout / VkDescriptorSet pour chaque draw call provenant de ces modules, il était désormais possible de ne créer qu’un seul VkDescriptorSetLayout / VkDescriptorSet pour l’ensemble des draw calls de la frame. Cette nouvelle approche nous a d’ailleurs permis d’avoir un gain significatif de performances pour la mise à jour des différents VkDescriptorSet.

Cette optimisation a permis de diviser le temps d’update des descriptor sets par deux.

Pseudo-Code

// Before
void RENDERER::Init()
{
}

void RENDERER::Display()
{
    RENDER_CONTEXT::SetTexture(0, _Tex0);
    RENDER_CONTEXT::SetBuffer(0, _Buf0);
    RENDER_CONTEXT::SetDepthTest(true);
    RENDER_CONTEXT::SetDepthFunc(LESS);
    RENDER_CONTEXT::SetCullMode(FRONT);

    RENDER_CONTEXT::SetVertexShader(_VertexShader);
    RENDER_CONTEXT::SetPixelShader(_PixelShader);

    RENDER_CONTEXT::Draw();
}

// After
void RENDERER::Init()
{
    BINDINGS_LAYOUT BindingsLayout;
    BindingsLayout.RegisterTexture(0, TEXTURE_TYPE);
    BindingsLayout.RegisterBuffer(0, BUFFER_TYPE);

    PIPELINE_CREATE_INFOS CreateInfos;
    CreateInfos._bDepthTest = true;
    CreateInfos._eDepthFunc = LESS;
    CreateInfos._eCullMode = FRONT;
    CreateInfos._pVertexShader = _VertexShader;
    CreateInfos._pPixelShader = _PixelShader;

    _pPipeline = RENDER_CONTEXT::CreatePipeline(CreateInfos, BindingsLayout);
    _pResourceSet = RENDER_CONTEXT::CreateResourceSet(BindingsLayout);

    _pResourceSet->SetTexture(0, _Tex0);
    _pResourceSet->SetBuffer(0, _Buf0);
}

void RENDERER::Display()
{
    RENDER_CONTEXT::SetPipeline(_pPipeline);
    RENDER_CONTEXT::SetResourceSet(0, _pResourceSet);

    RENDER_CONTEXT::Draw();
}

Réduction de nos Draw Calls
Limiter les échanges CPU -> GPU
Lorsqu’on travaille avec un GPU, mieux vaut limiter les échanges avec le CPU, qui peuvent être coûteux. Un des axes d’optimisation concerne le nombre de draw calls : moins il y en a, plus le nombre de frames par seconde sera élevé.

Pour diminuer le nombre de draw calls, il est possible d’en soumettre plusieurs en une seule commande. Avec Vulkan, c’est la fonction vkCmdDrawIndexedIndirect que nous utilisons, mais il faut que les draw calls partagent certaines caractéristiques : mêmes shader pipelines, mêmes descriptor sets et mêmes buffers. Donc moins il y a de différences entre les draw calls et plus nous pouvons les regrouper.

Notre équipe travaille sur la réduction du nombre de shader pipelines, que nous aimerions beaucoup moins élevés que dans Detroit: Become Human. En exposant les descriptors sets, nous en avons donc profité pour réduire leur nombre, comme spécifié précédemment. Enfin, nous avons tout simplement décidé de ne plus utiliser de vertex buffers et de regrouper les index buffers.

Suppression des Vertex Buffers
Il est possible de supprimer l’usage du vertex buffer (et ainsi de la vertex declaration associée) en allant directement récupérer dans le shader les infos de vertex (position, normal, uvs, etc.) via un regular buffer. En fonction des shaders, le format de vertex varie : par exemple le nombre d’uvs peut changer selon le matériau. Comme nous avons décidé de placer toutes ces infos dans un seul regular buffer, nous allons récupérer ces données, en fonction du format de vertex défini par le matériau.

Pour ce faire, nous avons créé un système d’accès générique pour obtenir nos informations, selon la déclaration que l’on souhaite pour nos shaders. Nous avons donc utilisé un descripteur avec un type générique (un uint, par exemple), puis il nous revenait d’effectuer la reconstruction de notre donnée. Ainsi, si on veut 3 float pour une position, on aura donc 3 chargements de uint en les réinterprétant en float.

L’économie de setup de vertex buffer et de vertex declaration entre chaque draw call a permis d’améliorer le frame rate.

Regroupement des Index Buffers (et autres)
Il y a aussi d’autres buffers qui sont propres à notre moteur et qui ont dû subir ce travail de fusion ; par exemple, notre buffer contenant les informations pour les instances de nos objets. Le travail à faire sur ce genre de buffer est finalement toujours un peu le même, et les seules difficultés rencontrées sont souvent liées à l’architecture de notre moteur, qui n’était pas pensé pour effectuer ce genre de traitement.

Cela étant, il nous reste encore à fusionner les buffers pour les index. Il s’agit selon nous de la dernière étape avant de pouvoir commencer à fusionner au maximum nos draw calls. Une fois cette étape effectuée, on devrait être capable de pouvoir envoyer toutes les primitives pour un même shader, avec un unique multi draw call. Cela devrait nous permettre un gain de performance considérable sur le temps d’affichage d’une frame.

Conclusion
Avec le travail effectué sur notre interface, ainsi que sur les différents refactos de nos buffers, nous avons déjà réalisé un grand pas vers une architecture de moteur plus saine et respectant les nouveaux standards apportés par les récentes API de rendus, comme Vulkan. En couplant ce travail à celui de réduction des draw calls, nous gagnons déjà en performance sur notre moteur.

L’étape suivante est d’optimiser nos dernières tâches de regroupement de buffers – notamment celle sur nos index buffers – pour atteindre un nombre optimal de draw calls, et maximiser toujours plus les performances. D’autres travaux d’optimisation sur des aspects différents de notre moteur sont également en cours, et feront sans aucun doute l’objet d’un futur article.

Kevin Havranek

Remerciements
Ronan Marchalot, Éric Lescot, Grégory Lecop, Clément Roge, Alexandre Lamure, Nathan Biette, Bertrand Cavalie, Jean-Charles Perrier, Max Besnard, Lisa Pendse, Mélanie Sfar.