Note : ce billet détaille le travail que j’ai réalisé pendant ma période GSoC. Pour la version courte, consultez le work product!
Introduction
Dans le cadre de ma contribution au Google Summer of Code (GSoC) 2023, ma mission était de faire le ménage dans le code graphique de Splash et de le faire fonctionner sur le Raspberry Pi (Rpi). Après un démarrage au ralenti à cause de mes examens finaux, le travail a commencé par une revue des bibliothèques de rendu graphique existantes. Un de mes mentors, Jean-Michael Celerier, m’a fourni une liste de bibliothèques à explorer. Certaines n’étaient plus maintenues, d’autres l’étaient mais pour un usage personnel, etc.
Quête secondaire 1 : les tests
Il était clair qu’il nous fallait un moyen automatisé d’itérer et de tester le code rapidement. J’ai donc entrepris une petite quête secondaire pour mieux connaître Splash pendant que je passais les bibliothèques graphiques en revue. L’infrastructure de tests existante ne permettait pas de vérifier si le rendu produit était correct. Splash disposait par contre d’un sink, un mécanisme permettant de capturer sa sortie au cas où on en aurait besoin ailleurs. Je m’en suis servi pour monter un cas de test de base qui compare des images de référence à la sortie de Splash.
Quête 1 : explorer les bibliothèques et la prise en charge des API graphiques du Pi
En poursuivant l’exploration des bibliothèques, la liste s’est réduite à bgfx, Granite et Magnum. Toutes sont très capables à leur manière, mais chacune avait aussi ses inconvénients.
À ce stade, j’avais l’impression de m’éparpiller. J’ai donc pris un moment pour me recentrer en vérifiant quelles API notre « cible » (le Raspberry Pi 4) prenait en charge. Au moment d’écrire ces lignes, le Pi prend en charge Vulkan 1.2 ainsi qu’OpenGL ES 3.1 et une partie de 3.2. Prendre en charge OpenGL ES semblait être une bonne première étape pour faire tourner Splash sur le Pi, alors je me suis concentré là-dessus. Vulkan était une option séduisante, mais trop verbeuse et trop éloignée du code existant. Et si Vulkan accélère les applications limitées par le CPU, Splash est pour l’instant surtout limité par le GPU.
Quête 2 : transformation
Les semaines suivantes se sont déroulées dans un brouillard de segfaults, de plantages, de callbacks d’erreur et d’innombrables heures de débogage avec renderdoc. « Pourquoi rien ne s’affiche? » me revenait souvent en tête, mais j’ai persévéré. J’ai commencé par corriger les segfaults. Ils venaient du fait qu’on chargeait un contexte OpenGL ES tout en utilisant des appels OpenGL 4.x qui, eux, n’étaient pas chargés : leurs pointeurs de fonction restaient nuls. Il s’agissait donc de lancer Splash encore et encore dans gdb, d’attendre qu’il explose, puis de trouver quel appel OpenGL ES correspondait à l’appel fautif. Certaines parties de l’API demandaient un peu plus de travail que la simple recherche de l’appel OpenGL ES équivalent — le mappage mémoire, par exemple — mais la documentation était toujours là pour me guider.
Ensuite, les erreurs. Le GLSL pour OpenGL ES (GLSLES à partir d’ici) exige qu’on précise la précision des nombres à virgule flottante, soit pour chaque variable, soit pour le shader au complet (d’après ce que j’en comprends pour l’instant). Il ne prend pas non plus en charge certaines conversions implicites que le GLSL normal autorise. Le plus gros obstacle venait de la sorcellerie de construction de shaders de Splash, où un ensemble de « modules/imports » était intégré au programme, chacun associé à un nom. Le code du shader cherchait ensuite include <module> et le remplaçait par le code du module correspondant — un préprocesseur maison, en gros. Résultat : le code remis au pilote différait un peu de celui que j’éditais, et la confusion s’est installée. Mais tout ça s’est réglé avec du bon vieux débogage, aidé par des printf de gdb pour afficher le code de shader assemblé!
Venait ensuite l’initialisation des uniforms. Elle sert à donner des valeurs par défaut aux uniforms directement dans le code source du shader, pour ne pas avoir à en faire le suivi côté CPU. Or GLSLES ne prend pas en charge l’initialisation des uniforms. D’où quelques erreurs de compilation de shaders, ainsi que deux ou trois bogues liés au rendu et à la correction gamma. Mais à ce moment-là, j’avais de l’élan. J’ai donc serré les dents, analysé les journaux d’erreurs et débogué jusqu’au bout.
Et la lumière fut! J’ai enfin réussi à afficher quelque chose avec OpenGL ES. Même si ce n’était pas encore sur le Rpi, c’était une belle première étape. Corriger des choses directement sur le Rpi sans savoir si le code existant fonctionnait aurait été un vrai casse-tête.
Premier rendu « correct » issu du code OpenGL ES; on remarque le vide sombre en arrière-plan, qui est censé être l’interface graphique.
En fait, l’interface graphique s’affichait bel et bien, mais de façon très discrète.
Quête 3 : encore des tests
Au tour des tests. Mon autre mentor, Emmanuel Durand, mainteneur principal de Splash, a fourni une liste de tests de base pour déterminer si toutes les fonctionnalités essentielles tenaient la route. Les trois plus liés au graphique étaient le chargement d’images, le chargement de vidéos et le blending. Le chargement d’images fonctionnait déjà. Emmanuel m’a fourni quelques vidéos dans plusieurs résolutions et formats afin de couvrir les principaux chemins d’exécution.
Et bien sûr, Splash a fait un segfault sur la première vidéo essayée. Comme d’habitude, un peu de débogage et d’exploration de la documentation ont réglé l’affaire. Puis est venu le blending. Il faut savoir que depuis le début, je testais et corrigeais localement, puis je retestais et corrigeais de nouveau sur le Pi. Cette façon de faire m’a joué un tour avec le blending, car celui-ci exige des fonctionnalités d’OpenGL ES 3.2 que le Pi ne prend pas encore tout à fait en charge. J’ai donc été bien déçu de voir le blending fonctionner localement, mais refuser de fonctionner sur le Pi en invoquant GL_INVALID_ENUM à la création d’un shader de tessellation. Ce n’était pas si grave, cela dit, puisque le blending sert surtout quand on a plus d’une sortie. Or le pauvre Pi n’arrivait même pas à bien gérer une seule sortie pour le moment.
Quête secondaire 2 : profilage
Autre quête secondaire : le profilage du Pi avec Tracy. Honnêtement, Tracy relevait de la magie quand ça fonctionnait. Le faire tourner sur le Pi a été un peu pénible, puisqu’il ne prend pas explicitement en charge OpenGL ES, mais une version plus ancienne (v0.7.6) combinée à la solution décrite ici a bien fait le travail.
Résultat : le Pi passe le plus clair de son temps à échanger les buffers, ce qui implique très probablement d’attendre que le GPU termine. Nous étions donc pas mal certains que le Pi était limité par le GPU à ce stade. Il n’y avait pas d’autre moyen de cerner exactement la cause du problème, alors nous nous sommes concentrés à vérifier que le code OpenGL ES n’était pas trop lent par rapport à l’ancien code OpenGL. J’ai passé un peu de temps à profiler sur mon portable, mais je vous épargne les détails ennuyants : ils sont dans le ticket (#85). Bref : le nouveau chemin de code n’affecte pas significativement les performances.
Quête 4 : réconciliation
L’étape suivante consistait à fondre ensemble le nouveau code et l’existant afin que Splash puisse toujours être publié sous forme d’un seul binaire, plutôt qu’une version OpenGL et une version OpenGL ES. Cela implique surtout de choisir une API, de l’initialiser correctement — OpenGL et OpenGL ES exigent des initialisations légèrement différentes — puis de rediriger les appels pour que le bon code soit exécuté selon l’API retenue.
Je me suis inspiré de quelques exemples, principalement l’API RHI de QT. L’idée derrière ce genre de chose est de rediriger les appels à l’exécution depuis une interface unifiée. Ça sent évidemment la POO et l’héritage (ou les pointeurs de fonction, pour les programmeurs C), j’ai donc commencé par esquisser une classe Renderer, que j’ai spécialisée pour OpenGL et OpenGL ES. Je suis ensuite passé aux textures, le plus gros plat de spaghettis que j’avais vu depuis un bon bout de temps (désolé, Manu), mais rien ne pouvait m’arrêter à ce stade. Les buffers GPU ont suivi, et tout a un peu fait boule de neige à partir de là. Après un certain temps, Splash tournait sous OpenGL 4.5 (partout où c’est pris en charge) et sous OpenGL ES (pour le Pi en particulier), avec détection automatique de l’API entre les deux.
La hiérarchie finale des renderers est résumée par le diagramme suivant :

Hiérarchie des classes Renderer
Chaque classe touchant au code graphique a été scindée : une classe contenant le code applicatif, quelques méthodes pour le code graphique commun à OpenGL et OpenGL ES, ainsi que des méthodes virtuelles pures pour les parties propres à une API. Une classe supplémentaire hérite ensuite de celle-ci et implémente les méthodes spécifiques à l’API graphique. Le diagramme suivant en donne un exemple générique, où Foo peut être remplacé par Texture_Image, par exemple :

Hiérarchie mixte des classes Foo
C’était suffisant pour faire fonctionner Splash avec OpenGL comme avec OpenGL ES, mais qu’en est-il des autres API graphiques? Autre point perfectible : le mélange du code applicatif et du code graphique. Voilà les prochains chantiers.
Quête 5 : séparation
J’ai consacré mes deux dernières semaines à la MR #628, qui visait à séparer le code applicatif du code propre aux API graphiques, de sorte que prendre en charge une nouvelle API demanderait théoriquement aux mainteneurs d’implémenter une interface, sans avoir à bricoler dans le code applicatif. Avantage supplémentaire : les changements au code graphique auraient moins d’incidence sur la logique applicative. Cette approche conserve la hiérarchie de renderers de la section précédente, mais ajoute une couche entre la logique applicative et le code graphique pour une meilleure maintenabilité. Le diagramme suivant l’illustre :

Hiérarchie séparée des classes Foo
Désormais, au lieu que chaque renderer crée des instances de Foo pour son API, il crée des gfx::<API>::FooGfxImpl qu’il confie aux objets Foo, lesquels les gèrent sous forme de unique_ptr pour la durée du programme. On remarque aussi que les méthodes communes à OpenGL et OpenGL ES ont été déplacées dans gfx::GlBaseFooGfxImpl, afin de retirer tout le code spécifique à une API qui restait dans Foo, tout en permettant aux classes OpenGL et OpenGL ES de ne redéfinir que les méthodes nécessaires. Si, par exemple, votre code graphique fonctionne à la fois pour OpenGL et OpenGL ES, vous pouvez vous passer de cette classe de base.
Conclusion et mot de la fin
Une chose importante que je tiens à souligner aux autres développeurs : il est essentiel de déployer sur son appareil cible le plus tôt possible. L’une des plus grandes surprises que j’ai rencontrées, encore et encore, c’est que du code qui tourne sur mon portable avec OpenGL ES ne tournera pas forcément sur cette version du Rpi. Deux facteurs à cela : les pilotes de mon portable prennent en charge certains appels OpenGL même dans un contexte OpenGL ES; et le Pi ne prend pas en charge OpenGL ES 3.2 à 100 %, contrairement à mon portable. Résultat : à plusieurs reprises, j’étais convaincu que le code fonctionnait bien, je le testais sur le Rpi, et il plantait sur un appel OpenGL ou signalait une erreur.
Dans l’ensemble, mon passage au GSoC 2023 a été fort agréable et a rehaussé mes compétences de programmeur en C++, en OpenGL et en code graphique de façon générale. Il m’a aussi appris de précieuses leçons sur la séparation du code et la documentation, en plus de m’apprendre à collaborer et à rendre compte à distance de manière efficace et concise. C’était une expérience formidable et je remercie sincèrement mes mentors pour leur aide inestimable durant cette période!