Comprendre le pipeline de rendu 3d avec Godot

Le rendu 3d est un processus complexe, que Godot simplifie énormément pour le rendre abordable. Dans ce billet, nous allons décortiquer le fonctionnement du rendu 3d moderne. Comment le CPU et le GPU travaillent ensemble et quelles sont les responsabilités de chacun ? Que sont les shaders ?

Note : ce billet a été initialement rédigé en vue de proposer son intégration à la documentation officielle, proposition qui n'a finalement pas abouti.

Quand vous jouez à un jeu vidéo, chaque objet 3D de la scène — un personnage, un terrain, un effet de particules — s'affiche à l'écran sous forme d'image plate. Pour y parvenir, le GPU fonctionne comme une chaîne de montage : des données brutes entrent (meshes, textures, paramètres de matériaux) et des pixels sortent. Chaque donnée traverse une séquence d'étapes dans un ordre fixe. Cette séquence, c'est le pipeline de rendu.

Les API graphiques bas niveau — Vulkan, OpenGL, Direct3D ou Metal — exposent le pipeline dans ses moindres détails et vous obligent à configurer chaque étape vous-même. Godot fait abstraction de tout cela : vous placez des objets dans une scène, vous leur assignez des matériaux, et le moteur se charge du reste.

Quelques clics suffisent pour afficher des objets 3d avec Godot

Il reste utile de comprendre dans les grandes lignes ce qui se passe à l'intérieur du GPU, pour plusieurs raisons :

  • Les abstractions sont plus faciles à utiliser quand on sait ce qu'elles recouvrent.
  • Les performances sont plus faciles à optimiser quand on sait où le GPU passe son temps.
  • Les options de rendu de Godot — comme les paramètres render_mode des shaders spatiaux — correspondent directement aux étapes du pipeline. Les connaître rend ces options intuitives.

Ce qui s'affiche à l'écran

Une frame est une image de jeu unique affichée à l'écran. Les jeux affichent de nombreuses frames par seconde (souvent 60 ou plus) pour créer l'illusion du mouvement. Chaque frame est un instantané complet de la scène vue depuis la caméra.

Un mesh est une surface 3D composée de triangles — il définit la forme des objets à l'écran. Personnages, arbres, bâtiments, terrains : ce sont tous des meshes. Chaque triangle est défini par trois sommets (vertices) : des points dans l'espace 3D. Chaque sommet porte des données appelées attributs :

Un mesh — modèle 3d représentant un arbre — édité dans Blender

  • Position — l'emplacement du sommet dans l'espace 3D, stocké sous forme de trois coordonnées (x, y, z).
  • Normale — la direction dans laquelle pointe la surface à cet endroit, utilisée pour l'éclairage. Une surface orientée vers la lumière apparaît claire ; une surface orientée à l'opposé apparaît sombre.
  • UV — une coordonnée 2D qui indique au GPU quel point d'une image de texture correspond à ce sommet. Les UV des trois sommets d'un triangle déterminent comment la texture s'étire sur sa surface.

Un seul vertex en surbrillance, et l'interface qui permet de visualiser les attributs associés. En bleu, le vecteur normal.

Le CPU et le GPU

Votre ordinateur fait appel à deux processeurs pour produire chaque frame.

Le CPU (Central Processing Unit) exécute la logique de jeu. Dans Godot, cela recouvre les scripts GDScript et C#, la simulation physique, la navigation, les animations et la gestion de l'arbre de scène. Une fois l'état du jeu à jour, le CPU prépare le travail de rendu : il rassemble les meshes visibles, leurs matériaux, leurs positions, puis envoie le tout au GPU.

Le GPU (Graphics Processing Unit) contient des milliers de petits cœurs conçus pour exécuter la même opération sur de nombreux sommets ou pixels en parallèle. Il reçoit ces données et produit la frame.

Le CPU et le GPU travaillent en parallèle. Le CPU n'attend pas que le GPU ait fini de dessiner une frame avant de préparer la suivante. Ce chevauchement maintient les deux processeurs occupés et rend le temps réel possible.

Les draw calls

Pour produire chaque frame, le CPU envoie des commandes au GPU. Chaque commande dit : « dessine ces triangles, avec ce shader, ces textures et ces paramètres (transformations, couleurs, etc.) ». Cette commande s'appelle un draw call.

Godot émet les draw calls automatiquement. À chaque frame, le moteur parcourt les objets visibles de la scène et émet un draw call par matériau et par objet. Un personnage avec des matériaux distincts pour la peau, les cheveux et les vêtements génère trois draw calls. Une scène 3D classique accumule de quelques centaines à plusieurs milliers de draw calls par frame.

Chaque draw call coûte du temps CPU pour être préparé et soumis, ce qui fait du nombre de draw calls un indicateur de performance important. Vous pouvez le consulter dans l'éditeur 3D de Godot (Perspective > Afficher les informations).

Les étapes du pipeline

Chaque draw call fait passer un ensemble de triangles à travers une séquence d'étapes. Certaines exécutent du code que vous pouvez écrire ou personnaliser — ce code s'appelle un shader. D'autres sont des opérations fixes, effectuées par le matériel.

Voici les principales étapes :

  1. Traitement des sommets — convertit chaque sommet de sa position 3D vers une position 2D à l'écran (programmable : vertex shader)
  2. Rastérisation — détermine quels pixels chaque triangle recouvre (matériel fixe)
  3. Traitement des fragments — calcule la couleur de chaque pixel couvert (programmable : fragment shader)
  4. Fusion de sortie (output merging) — détermine si et comment chaque pixel atteint la frame finale (configurable)

La suite de cette page détaille chaque étape.

Traitement des sommets

Le GPU exécute le vertex shader une fois par sommet. Un mesh de 300 sommets déclenche 300 exécutions du vertex shader — toutes en parallèle.

Le rôle principal du vertex shader est de convertir la position de chaque sommet depuis sa position 3D d'origine vers sa position finale à l'écran. Cette conversion passe par plusieurs espaces de coordonnées (rarement traduits en français) :

  • Local space — le système de coordonnées propre au mesh, centré sur son origine. Un arbre de 5 m modélisé dans un logiciel 3D est défini en local space : la base du tronc peut se trouver en (0, 0, 0) et le sommet en (0, 5, 0), peu importe où l'arbre est placé dans le monde du jeu.
  • World space — le système de coordonnées partagé par toute la scène. Quand vous déplacez un objet Node3D dans l'éditeur, vous modifiez sa position en world space. Trois instances du même mesh d'arbre placées dans une scène ont chacune des coordonnées world space différentes, même si elles partagent la même géométrie locale.
  • View space (ou camera space) — le système de coordonnées dans lequel la caméra est à l'origine. Chaque position de sommet est mesurée par rapport à la caméra : à quelle distance devant, à quelle distance sur le côté, à quelle hauteur. Le même arbre placé à 20 mètres devant la caméra a une profondeur de 20 en view space, quelle que soit la position de la caméra ou de l'arbre en world space.
  • Clip space — le système de coordonnées qui aplatit la scène 3D en image 2D. L'angle de vue et les distances limites de la caméra définissent un volume visible ; tout ce qui se trouve en dehors est écarté (clipped). Le clip space conserve une composante de profondeur — le GPU s'en sert plus tard pour le test de profondeur — mais l'effet de perspective est déjà appliqué : les sommets éloignés sont rapprochés les uns des autres. Après cette étape, le GPU convertit les positions en clip space en coordonnées écran (positions en pixels sur l'affichage).

Le vertex shader est un programme complet qui transforme chaque sommet du local space au clip space. Il transmet également les attributs des sommets (normales, UV, couleurs) aux étapes suivantes.

Godot simplifie énormément l'écriture de shaders, et gère automatiquement la transformation complète des coordonnées.

Plutôt que d'écrire un programme de vertex shader autonome, vous écrivez une fonction vertex() qui peut modifier les données du sommet avant la transformation — par exemple, décaler les positions à chaque frame pour animer un drapeau qui flotte au vent.

Consultez l'Introduction aux shaders de Godot pour comprendre le fonctionnement du système de shaders.

Rastérisation

Après la projection, le GPU dispose de triangles 2D plats en coordonnées écran. Le rastériseur détermine quels pixels de l'écran chaque triangle recouvre. Chaque pixel couvert devient un fragment.

La rastérisation est une étape matérielle fixe — on n'écrit pas de code pour elle.

Le rastériseur effectue aussi le face culling (élimination des faces arrière) : par défaut, les triangles orientés à l'opposé de la caméra sont ignorés, puisqu'ils représentent l'envers d'une surface. Dans Godot, vous pouvez désactiver ce comportement avec le render_mode cull_disabled des shaders spatiaux.

Le nombre de fragments produits par le rastériseur détermine directement le coût de l'étape suivante. Un même triangle vu de loin ne couvre que quelques pixels et produit peu de fragments. Vu de près, il peut remplir une grande partie de l'écran et en produire des millions.

Le rastériseur détermine quels pixels chaque triangle recouvre. Chaque pixel couvert devient un fragment.

Interpolation

Le fragment shader attend des valeurs d'attributs (UV, normales, couleurs) pour chaque fragment, mais ces valeurs sont définies par sommet, pas par pixel. Le GPU les interpole automatiquement : il mélange les valeurs de chaque sommet en fonction de la position du fragment dans le triangle.

Un fragment proche d'un sommet reçoit des valeurs proches de ce sommet. Un fragment au centre reçoit un mélange à peu près égal des trois sommets. C'est pourquoi une texture apparaît lisse sur un triangle, même si les coordonnées UV n'existent qu'aux coins.

Traitement des fragments

Le GPU exécute le fragment shader une fois par fragment. Il reçoit les attributs interpolés et calcule une couleur. C'est à cette étape qu'ont lieu les lectures de texture, l'éclairage et les effets visuels.

Le GPU traite des milliers de fragments en parallèle — c'est ce qui rend le rendu temps réel possible. Du fait de ce parallélisme, chaque fragment est traité de manière isolée, sans accès aux fragments voisins.

Dans Godot, c'est la fonction fragment() du shader qui gère cette étape. Godot exécute aussi une fonction light() séparée, par fragment et par lumière, pour l'éclairage.

Les écrans contenant bien plus de pixels que les meshes n'ont de sommets, le traitement des fragments est en général l'étape la plus coûteuse. Un mesh de 500 sommets peut couvrir 100 000 pixels à l'écran — chacun déclenche une exécution du fragment shader.

Fusion de sortie

Après que le fragment shader a produit une couleur, le GPU décide si ce fragment atteindra la frame finale. Cela passe par une série de tests, et les résultats sont écrits dans le framebuffer.

Un framebuffer fonctionne comme une grille de la taille de l'écran, où chaque cellule correspond à un pixel. Chaque cellule stocke plusieurs valeurs :

  • Une couleur — la couleur visible du pixel à l'écran.
  • Une profondeur — la distance entre la caméra et le fragment le plus proche déjà dessiné.

Le GPU utilise ces valeurs stockées pour tester chaque fragment entrant avant de l'écrire.

Test de profondeur. Quand un nouveau fragment arrive, le GPU compare sa profondeur à celle déjà présente dans le framebuffer. Si un fragment plus proche a déjà été dessiné à ce pixel, le nouveau fragment est écarté. Sinon, il remplace la profondeur et la couleur stockées.

Le test de profondeur permet aux objets opaques de se masquer mutuellement, quel que soit l'ordre de dessin. Dans Godot, vous pouvez le désactiver avec le render_mode depth_test_disabled des shaders spatiaux.

Test de stencil. Le framebuffer peut aussi stocker une valeur de stencil par pixel : un entier que le GPU écrit et compare selon des règles configurées dans le shader. Cela permet des effets de masquage — par exemple, ne dessiner une scène qu'à l'intérieur du contour d'un portail. Dans Godot, les shaders spatiaux exposent les opérations de stencil via des render modes dédiés.

Blending. Pour les objets opaques, la couleur du fragment remplace celle du framebuffer. Les objets transparents fonctionnent différemment : leur couleur se mélange avec la couleur existante, pondérée par une valeur alpha. Ce mélange dépend de l'ordre dans lequel les fragments sont combinés : le moteur doit donc trier les objets transparents de l'arrière vers l'avant avant de les soumettre au GPU.

Les objets transparents n'écrivent pas non plus dans le depth buffer — s'ils le faisaient, le test de profondeur écarterait des fragments situés derrière eux qui devraient rester partiellement visibles. Sans écriture de profondeur, les fragments transparents qui se chevauchent ne peuvent pas s'éliminer entre eux, et le GPU doit tous les traiter. Ces contraintes rendent les objets transparents plus coûteux que les objets opaques. Dans Godot, les shaders spatiaux contrôlent le blending avec des render modes comme blend_mix et blend_add.

Une fois tous les draw calls d'une frame traités, le framebuffer contient l'image finale. Le GPU l'envoie à l'écran, et le processus recommence pour la frame suivante.

Points clés

  • Une frame est une image de jeu complète. Le CPU et le GPU travaillent en parallèle pour en produire de nombreuses par seconde.

  • Un draw call est une commande CPU qui ordonne au GPU de dessiner un lot de triangles. Godot les émet automatiquement — environ un par matériau et par objet visible.

  • Un fragment est un échantillon de triangle à la taille d'un pixel — pas encore un pixel final. Un pixel de l'écran est construit à partir d'un ou plusieurs fragments.

  • Les vertex shaders s'exécutent par sommet. Les fragment shaders s'exécutent par pixel couvert. Les fragment shaders dominent en général le coût de rendu.

  • Le depth buffer permet aux objets opaques de se masquer mutuellement de façon automatique. Les objets transparents n'y écrivent pas et nécessitent un tri de l'arrière vers l'avant.

  • Les fonctions vertex() et fragment() des shaders Godot correspondent aux étapes de traitement des sommets et des fragments. De nombreux paramètres render_mode des shaders spatiaux — depth_test_disabled, cull_disabled, blend_add — configurent des étapes spécifiques du pipeline décrites sur cette page.

Dans les prochains articles de la série, nous étudierons plus en détails comment fonctionne l'interaction entre le cpu et le gpu, et comment utiliser la puissance du gpu pour exploiter ses performances.