Optimisation d'un jeu mobile : 17 FPS via draw calls et overdraw
Ma scène de tower defense plafonnait à 41 FPS sur un Android milieu de gamme. Voici comment les draw calls, les matériaux et l'overdraw m'ont mené à 58 FPS.
Une scène de tower defense bloquée à 41 FPS
L'hiver dernier, j'ai récupéré la dernière passe d'optimisation d'un projet de tower defense. Sur un Redmi Note 10 — Snapdragon 678, Adreno 612 — le temps de frame grimpait à 24,3 ms dès la vague 12, soit 41 FPS en moyenne. Ma première hypothèse a été la plus banale : trop d'ennemis à l'écran, donc l'IA et la physique doivent coûter cher. J'ai profilé le planificateur de tâches pendant deux jours sans rien trouver ; le thread de gameplay se terminait en 6,1 ms.
L'image s'est éclaircie quand le Unity Profiler a affiché 14,8 ms sur le thread de rendu et 26 ms sur le GPU. Le Frame Debugger comptait plus de 780 draw calls, dont près de 400 pour les anneaux de portée des tours, les nombres de dégâts, les decals au sol et les particules de fumée. La majeure partie de l'écran était couverte de couches transparentes empilées, chacune repeignant la même surface depuis zéro. Diviser le nombre d'ennemis par deux n'a rapporté que 1,2 ms ; le coupable était ailleurs.
La vraie erreur venait de ma propre définition de l'optimisation d'un jeu mobile : pendant des années, je l'avais réduite à « baisser le nombre de polygones ». La scène comptait 210k triangles au total et l'Adreno 612 dessinait cette géométrie sans broncher. La charge venait de deux fronts distincts : le coût CPU de préparation par draw call, et la bande passante écrite en mémoire principale par pixel. Ce qui suit est le relevé de la façon dont j'ai séparé ces deux fronts et du nombre de millisecondes que chaque changement a réellement rendu.
Quand chaque chemin de batching entre vraiment en jeu
Il existe trois mécanismes et ils ne font pas le même travail. SRP Batcher ne réduit pas le nombre de draw calls ; il conserve les constantes de matériau dans un buffer GPU persistant pour tous les rendus qui partagent une même variante de shader, ce qui allège la préparation CPU de chaque appel. Les 780 appels restent donc 780, mais chacun devient nettement plus léger. Ce qui compte, c'est le nombre de variantes de shader, pas celui des matériaux — l'avoir manqué m'a fait regrouper les mauvaises choses pendant longtemps.
GPU instancing est le mécanisme qui fait vraiment baisser le compteur : même mesh, même matériau, jusqu'à 1023 instances en un seul appel. Le piège est là — dans URP, si le shader est compatible SRP Batcher, le chemin d'instancing ne s'exécute jamais, car le SRP Batcher est prioritaire. Je ne m'en suis rendu compte qu'en dessinant moi-même les socles de tours avec Graphics.DrawMeshInstanced ; je voyais des lignes « SRP Batch » dans le Frame Debugger et je supposais que l'instancing faisait son travail.
Static batching fusionne les meshes dans un grand vertex buffer au moment du build. Le gain est réel mais il a un prix : 38 Mo de mémoire de mesh supplémentaire dans notre scène et une perte de granularité du culling, puisqu'un groupe fusionné est dessiné en entier ou pas du tout. Je ne l'ai laissé actif que pour les éléments de sol qui ne bougent jamais et qui sont de toute façon visibles ensemble. Le désactiver pour les arbres et les rochers a réduit à la fois la mémoire et le nombre de triangles réellement soumis.
Il y a aussi un briseur de batch silencieux : MaterialPropertyBlock. Nous en attachions un à chaque renderer pour teinter les niveaux de tour, et cela suffisait à faire sortir ces objets de la compatibilité SRP Batcher. Après avoir déplacé la variation de couleur dans les données d'instance et les vertex colors, le thread de rendu est passé de 14,8 ms à 11,0 ms — sans modifier une seule ligne de shader.
Nombre de matériaux et organisation du texture atlas
Ce qui pilotait réellement le nombre de draw calls, c'était le nombre de matériaux. La scène en comptait 34, et la plupart ne différaient que par une texture 512x512 différente. Je les ai regroupés dans trois texture atlas en 2048x2048 : le décor, les tours, puis les effets et l'UI. Plus un matériau est partagé par des objets nombreux, plus le réservoir disponible pour l'instancing et le batching est large.
L'atlas est moins mécanique qu'il n'y paraît. En repackant les UV, j'ai dû exclure tous les meshes qui reposent sur le tiling, car le wrap mode repeat ne fonctionne pas à l'intérieur d'un atlas ; les pixels de l'îlot voisin bavent. J'ai gardé un matériau distinct pour les dalles de sol et je les dessine à l'instancing. J'ai aussi donné 8 pixels de padding à chaque îlot pour éviter les fuites de mipmap — avec 4 pixels, de fines coutures colorées apparaissaient à distance.
Côté compression, je suis passé d'ETC2 à ASTC 6x6, visiblement plus propre à budget mémoire égal, surtout sur les dégradés à l'intérieur d'un atlas. Après l'atlas, les matériaux sont tombés de 34 à 6 et les draw calls de 780 à 210. Le thread de rendu est descendu de 11,0 ms à 7,4 ms. C'est le plus gros gain unique de toute la passe, et je n'ai écrit aucune ligne de shader pour l'obtenir.
Ce que les couches transparentes coûtent en overdraw
L'overdraw, c'est le nombre de fois qu'un même pixel est écrit dans une frame. Sur les objets transparents, ZWrite est désactivé : le test de profondeur ne rejette personne, et celui de derrière est ombré exactement comme celui de devant. Dans la vue overdraw du Rendering Debugger, le centre de la scène affichait 11x — certains pixels étaient ombrés onze fois par frame. L'essentiel de ces 26 ms de GPU se trouvait précisément là.
J'ai compté les couches une à une : anneau de portée, decal au sol, fumée, étincelles, flash de dégâts et, par-dessus, un quad de vignettage plein écran. J'ai replié le vignettage dans le shader de post-process final et supprimé le quad séparé — à lui seul, c'est une couche plein écran en 1080p. L'anneau de portée n'est plus dessiné que lorsqu'une tour est sélectionnée. Ensuite, j'ai réduit les particules de fumée de 240 à 90 en agrandissant chacune : même densité visuelle pour un tiers du coût de fill.
Ne comptez pas sur l'alpha testing comme solution. Utiliser clip() sur les matériaux de feuillage et de clôture casse l'early-z et l'élimination des surfaces cachées sur un GPU tile-based ; dans les deux cas que j'ai mesurés, revenir à l'alpha blend a rapporté 0,6 ms. Pour la même raison, trier les transparents d'avant en arrière n'apporte rien, puisque aucun d'eux n'écrit la profondeur. Le gain vient uniquement de la réduction du nombre de couches et de la surface d'écran qu'elles couvrent.
Sur GPU tile-based, la vraie limite est la bande passante
Les GPU mobiles découpent la frame en tiles, traitent chaque tile dans une petite mémoire on-chip très rapide, puis écrivent le résultat en mémoire principale. Ce qui coûte cher, ce n'est pas le calcul de shader, c'est ce trafic d'écriture et de lecture. En 1080p, une seule cible RGBA32 représente environ 8 Mo par frame, soit 500 Mo/s à 60 FPS — et il ne s'agit que d'une passe. Quand l'appareil chauffe, c'est l'horloge mémoire qui est bridée en premier : la bande passante est donc aussi un problème thermique.
C'est pourquoi changer de render target en plein milieu d'une frame coûte cher : chaque changement force la mémoire de tile à être résolue vers la mémoire principale. Notre chaîne de post-process comportait trois blits distincts ; fusionner le downsample du bloom et l'étalonnage des couleurs en une seule passe a retiré 1,7 ms du temps GPU. Pour la même raison, passer RenderBufferLoadAction.DontCare sur les cibles dont le contenu précédent n'a pas d'importance est un gain gratuit — un load inutile revient à relire toute la cible depuis la mémoire principale vers les tiles.
Un depth prepass se retourne généralement contre vous sur mobile. La technique qui coupe l'overdraw sur desktop nous a coûté 0,9 ms ici, parce qu'elle traite la géométrie deux fois et génère un trafic de profondeur supplémentaire sur une architecture tile-based. Le rejet Z basse résolution intégré à l'Adreno fait déjà un travail comparable gratuitement. Essayé, mesuré, annulé — un des endroits où les réflexes desktop ne se transposent tout simplement pas.
La vraie solution pour les couches transparentes a été le rendu des particules en demi-résolution. Je dessine les particules dans un render target de taille réduite de moitié, puis je les recompose avec un upsample depth-aware : le nombre de pixels ombrés tombe au quart, la composition coûte 0,4 ms et le gain net a été de 3,1 ms. Les bords présentent un léger effet d'escalier, invisible sur des effets basse fréquence comme la fumée et la poussière. Les effets fins et nets, étincelles et traînées de projectiles, sont restés en pleine résolution.
Le résultat mesuré sur un Android milieu de gamme
Sur le même enregistrement de 60 secondes de la vague 12, sur le même Redmi Note 10 : le temps de frame est passé de 24,3 ms à 16,4 ms et la moyenne de 41 FPS à 58 FPS. Les draw calls sont tombés de 780 à 190, les matériaux de 34 à 6 et l'overdraw maximal mesuré de 11x à 4x. Sur une session de 15 minutes, la température de la batterie s'est stabilisée à 39 °C au lieu de 44 °C : l'appareil n'a jamais bridé et la chute de FPS des cinq dernières minutes a disparu.
La répartition de ce gain m'importe, car c'est elle qui décide par où je commencerai sur le prochain projet : environ 40 % viennent du travail sur les matériaux et l'atlas, 35 % de la réduction d'overdraw et des particules en demi-résolution, 15 % des ajustements de bande passante, et le reste du retour à la compatibilité SRP Batcher. La réduction de polygones n'apparaît nulle part dans cette liste. Je n'ai touché à aucun mesh et j'ai laissé les réglages de LOD strictement inchangés.
Sur votre propre projet, je suivrais cet ordre : noter d'abord le nombre de matériaux et les raisons de rupture de batch dans le Frame Debugger, puis repérer les trois pires couches dans la vue overdraw, et seulement ensuite toucher à un shader. Mesurez chaque étape sur un appareil réel, à température comparable ; j'ai eu quantité de changements qui affichaient 200 FPS dans l'éditeur et perdaient 0,2 ms sur le téléphone. Et ne regardez jamais un seul chiffre — draw calls, overdraw et bande passante sont des limites distinctes, et en corriger une casse facilement la suivante.